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(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(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(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 { const employees = new Map(data.employees.map(employee => [employee.id, employee])); const result = new Map(); 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; }