Files
HMDesign/packages/org-chart/src/data/examples.ts
T

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;
}