125 lines
7.9 KiB
TypeScript
125 lines
7.9 KiB
TypeScript
import { companyColorPresets, type CompanyId } from '../company-color-presets.js';
|
|
import { normalizeOrgChartDataset } from './normalize.js';
|
|
import { snapshotOrgChartDataset } from './snapshot.js';
|
|
import { validateOrgChartDataset } from './validate.js';
|
|
import type { Employee, OrgChartDataset, Organization } from './types.js';
|
|
|
|
export type OrgChartExampleScope = 'total' | 'per-company';
|
|
export type OrgChartExampleEmployeeCount = 20 | 100 | 300;
|
|
export interface OrgChartExampleSelection {
|
|
/** Total is allocated across all seven companies before company filtering. */
|
|
scope: OrgChartExampleScope;
|
|
employeeCount: OrgChartExampleEmployeeCount;
|
|
/** Omission selects all companies. An empty selection belongs to view state. */
|
|
companyIds?: readonly CompanyId[];
|
|
}
|
|
|
|
const companyIds = Object.keys(companyColorPresets) as CompanyId[];
|
|
|
|
/** Deterministic virtual examples derived from an existing seven-company fixture.
|
|
* This provider is independent of layout, React and popup policy. Use its result
|
|
* with the existing JsonOrgChartDataSource; no production/API source is replaced.
|
|
*
|
|
* The P5-3 large fixture's hierarchy and employee records are reused. Larger
|
|
* company examples add virtual, company-namespaced employees. Neither the fixture
|
|
* nor a previously returned dataset is modified. Total-company selection never
|
|
* redistributes unselected companies' staff into a remaining company.
|
|
*/
|
|
export function createOrgChartExampleDataset(template: OrgChartDataset, selection: OrgChartExampleSelection): OrgChartDataset {
|
|
if (!selection || !['total', 'per-company'].includes(selection.scope)) throw new RangeError('Example scope must be total or per-company.');
|
|
if (![20, 100, 300].includes(selection.employeeCount)) throw new RangeError('Example employeeCount must be 20, 100 or 300.');
|
|
const requested = selection.companyIds ?? companyIds;
|
|
if (!Array.isArray(requested) || !requested.length) throw new RangeError('Example data requires at least one company; use an empty view selection to hide all companies.');
|
|
if (requested.some(id => !Object.hasOwn(companyColorPresets, id))) throw new RangeError('Example companyIds require registered company presets.');
|
|
// Preset order makes equivalent set selections identical, including IDs/order.
|
|
const selected = companyIds.filter(id => requested.includes(id));
|
|
const validation = validateOrgChartDataset(template);
|
|
if (!validation.valid) throw new TypeError('Example template must be a valid OrgChart dataset.');
|
|
const base = validation.data;
|
|
const organizationCompanies = resolveTemplateCompanies(base);
|
|
const rosters = new Map<CompanyId, Employee[]>(companyIds.map(id => [id, []]));
|
|
for (const employee of base.employees) {
|
|
const company = organizationCompanies.get(employee.organizationId)!;
|
|
if (employee.affiliationId !== company) throw new TypeError('Example employees must belong to their organization company affiliation.');
|
|
rosters.get(company)!.push(employee);
|
|
}
|
|
if (companyIds.some(id => !rosters.get(id)!.length)) throw new TypeError('Example template must contain employees from all seven companies.');
|
|
const counts = selection.scope === 'total'
|
|
? allocateTotal(selection.employeeCount, companyIds.map(id => rosters.get(id)!.length))
|
|
: companyIds.map(() => selection.employeeCount);
|
|
const chosen = new Set<CompanyId>(selected);
|
|
const organizations: Organization[] = base.organizations.filter(org => chosen.has(organizationCompanies.get(org.id)!))
|
|
.map(org => ({ ...org, companyId: organizationCompanies.get(org.id)! }));
|
|
const employees: Employee[] = [];
|
|
const existingIds = new Set(base.employees.map(employee => employee.id));
|
|
const organizationPool = new Map<CompanyId, Organization[]>(selected.map(id => [id, organizations.filter(org => org.companyId === id && org.parentId !== null)]));
|
|
for (const id of selected) {
|
|
const roster = rosters.get(id)!;
|
|
const count = counts[companyIds.indexOf(id)]!;
|
|
employees.push(...roster.slice(0, count).map(employee => ({ ...employee })));
|
|
// Unequal weighted placement reuses the original tree instead of cloning it.
|
|
const pool = organizationPool.get(id)!;
|
|
const weighted = (pool.length ? pool : organizations.filter(org => org.companyId === id))
|
|
.flatMap((org, index) => Array.from({ length: 1 + index % 4 }, () => org));
|
|
for (let index = roster.length; index < count; index++) {
|
|
let employeeId = `p7-virtual-${id}-${String(index + 1).padStart(3, '0')}`;
|
|
// A caller fixture may already use our namespace. Resolve collisions without
|
|
// changing the input or tying identity to the other selected companies.
|
|
while (existingIds.has(employeeId)) employeeId += '-example';
|
|
existingIds.add(employeeId);
|
|
const organization = weighted[(index - roster.length) % weighted.length]!;
|
|
employees.push({
|
|
id: employeeId, organizationId: organization.id, affiliationId: id,
|
|
name: `가상직원 ${companyColorPresets[id].companyName} ${String(index + 1).padStart(3, '0')}`,
|
|
position: ['사원', '선임', '책임', '수석'][index % 4]!, jobTitle: null,
|
|
profileImage: null, workStatus: 'active',
|
|
});
|
|
}
|
|
}
|
|
const employeeIds = new Set(employees.map(employee => employee.id));
|
|
for (const organization of organizations) if (organization.leaderId && !employeeIds.has(organization.leaderId)) organization.leaderId = null;
|
|
const organizationIds = new Set(organizations.map(org => org.id));
|
|
return snapshotOrgChartDataset({
|
|
...base, organizations, employees,
|
|
affiliations: base.affiliations.filter(affiliation => chosen.has(affiliation.id as CompanyId)),
|
|
...(base.relations ? { relations: base.relations.filter(relation => organizationIds.has(relation.sourceOrganizationId) && organizationIds.has(relation.targetOrganizationId)) } : {}),
|
|
...(base.presentation ? { presentation: { ...base.presentation, organizationOrder: base.presentation.organizationOrder.filter(id => organizationIds.has(id)) } } : {}),
|
|
metadata: { ...base.metadata, dataVersion: `p7-example-${selection.scope}-${selection.employeeCount}-${selected.join('_')}`, updateNote: '가상 예시 데이터 · 운영 정보 아님' },
|
|
});
|
|
}
|
|
|
|
function resolveTemplateCompanies(data: OrgChartDataset): Map<string, CompanyId> {
|
|
const employees = new Map(data.employees.map(employee => [employee.id, employee]));
|
|
const result = new Map<string, CompanyId>();
|
|
const pending = normalizeOrgChartDataset(data).roots.map(node => ({ node, inherited: undefined as CompanyId | undefined }));
|
|
while (pending.length) {
|
|
const { node, inherited } = pending.pop()!;
|
|
const company = node.organization.companyId ?? inherited ?? (node.organization.leaderId ? employees.get(node.organization.leaderId)?.affiliationId : undefined);
|
|
if (!company || !Object.hasOwn(companyColorPresets, company) || (inherited && company !== inherited)) throw new TypeError('Example template requires a consistent registered company on each organization tree.');
|
|
result.set(node.organization.id, company as CompanyId);
|
|
pending.push(...node.children.map(child => ({ node: child, inherited: company as CompanyId })));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** Largest-remainder allocation, with at least one employee in every company. */
|
|
function allocateTotal(total: number, weights: number[]): number[] {
|
|
const sum = weights.reduce((value, weight) => value + weight, 0);
|
|
const targets = weights.map(weight => total * weight / sum);
|
|
const counts = targets.map(target => Math.max(1, Math.floor(target)));
|
|
let current = counts.reduce((value, count) => value + count, 0);
|
|
while (current !== total) {
|
|
const adding = current < total;
|
|
let candidate = -1;
|
|
let greatest = -Infinity;
|
|
counts.forEach((count, index) => {
|
|
if (!adding && count <= 1) return;
|
|
const difference = adding ? targets[index]! - count : count - targets[index]!;
|
|
if (difference > greatest) { greatest = difference; candidate = index; }
|
|
});
|
|
counts[candidate]! += adding ? 1 : -1;
|
|
current += adding ? 1 : -1;
|
|
}
|
|
return counts;
|
|
}
|