Files
HMDesign/packages/org-chart/src/core/index.ts
T
shkim4 064517ec46 feat(org-chart): implement expandable layout and multi-company navigation
Integrate P5-1B through P5-4 shared Core layout, collapse, zoom/pan/fit, company roots and keyboard accessibility. Include React/HTML examples, virtual 20/100/300 employee fixtures, focused checks and the additive data contract.
2026-10-12 21:59:09 +09:00

453 lines
25 KiB
TypeScript

import { calculateSummary, normalizeOrgChartDataset, validateOrgChartDataset } from '../data/index.js';
import type { OrgChartDataSource, ValidationIssue } from '../data/index.js';
import { createEngineAdapter, loadEngine } from './engine.js';
import type { EngineAdapter, EngineConstructor } from './engine.js';
import { decorateNode, engineTree } from './render.js';
import { createOrgChartTree } from './org-chart-tree.js';
import { createOrgChartContent } from './org-chart-content.js';
import { createExpandedOrgChartContent } from './expanded-content.js';
import { getCompanyColorPreset } from '../company-color-presets.js';
import { resolveExpandedCompanies, validateTopNodes } from './expanded-company.js';
import { createExpandedViewport, type ExpandedViewport } from './expanded-viewport.js';
import { createPositionSummary } from './position-summary.js';
import { createAffiliationSummary } from './affiliation-summary.js';
import { createStatusSummary } from './status-summary.js';
import { createEmployeeInfoDialog } from './employee-info-dialog.js';
import type { EmployeeInfoDialog } from './employee-info-dialog.js';
import type { RenderContext } from './render.js';
import type { OrgChartEmployeeClickContext, OrgChartHandle, OrgChartOptions, OrgChartState } from './types.js';
export type { OrgChartLayoutMode, OrgChartEmployeeClickContext, OrgChartHandle, OrgChartOptions, OrgChartOrganizationLinks, OrgChartState } from './types.js';
const owners = new WeakMap<HTMLElement, symbol>();
let instanceSequence = 0;
function freeze<T>(value: T, seen = new WeakSet<object>()): T {
if (value && typeof value === 'object' && !seen.has(value)) {
seen.add(value);
for (const child of Object.values(value)) freeze(child, seen);
Object.freeze(value);
}
return value;
}
function snapshot(input: unknown): unknown {
// JSON objects are captured before any async boundary. A failure is controlled.
return freeze(structuredClone(input));
}
/** Framework-independent facade; construction has no DOM or request side effects. */
export function createOrgChart(options: OrgChartOptions): OrgChartHandle {
return createOrgChartView(options, 'composition');
}
export type CoreView = 'composition' | 'tree' | 'positionSummary' | 'affiliationSummary' | 'statusSummary';
/** One source/validation/lifecycle controller for full and standalone views.
* Summary-only views never load the engine or require its stylesheet. */
export function createOrgChartView(options: OrgChartOptions, view: CoreView): OrgChartHandle {
const needsEngine = view === 'composition' || view === 'tree';
if (!options.dataSource || typeof options.dataSource.load !== 'function') throw new TypeError('An OrgChartDataSource is required');
if (!options.stylesheetUrls?.core || (needsEngine && !options.stylesheetUrls.engine)) throw new TypeError('Provide the required OrgChart stylesheet URLs');
const stylesheetUrls = { ...options.stylesheetUrls };
if (options.displayMode !== undefined && options.displayMode !== 'interactive' && options.displayMode !== 'readOnly') throw new TypeError('displayMode must be "interactive" or "readOnly"');
if (options.layoutMode !== undefined && options.layoutMode !== 'fixed' && options.layoutMode !== 'auto') throw new TypeError('layoutMode must be "fixed" or "auto"');
const layoutMode = options.layoutMode ?? 'fixed';
const expanded = view === 'composition' && layoutMode === 'auto';
const expandedCompanyPreset = expanded ? getCompanyColorPreset(options.companyId ?? 'hanmac') : undefined;
if (expanded && !expandedCompanyPreset) throw new TypeError('companyId must be a registered company preset in Auto mode');
const topNodes = expanded ? freeze(structuredClone(options.companyTopNodes ?? {})) : {};
const employeeConnections = expanded ? freeze(structuredClone(options.employeeConnections ?? {})) : {};
if (Object.values(employeeConnections).some(value => !['top', 'left', 'right'].includes(value))) throw new TypeError('employeeConnections must use top, left or right');
const zoom = { min: options.zoom?.min ?? 0.05, max: options.zoom?.max ?? 3, step: options.zoom?.step ?? 1.2 };
if (expanded && (!Number.isFinite(zoom.min) || !Number.isFinite(zoom.max) || !Number.isFinite(zoom.step) || zoom.min <= 0 || zoom.min > 1 || zoom.max < 1 || zoom.min >= zoom.max || zoom.step <= 1)) throw new TypeError('zoom requires 0 < min <= 1 <= max and step > 1');
const copySelection = (ids?: readonly string[]) => {
if (ids !== undefined && (!Array.isArray(ids) || ids.some(id => typeof id !== 'string' || !getCompanyColorPreset(id)))) throw new TypeError('selectedCompanyIds must contain registered company IDs');
return ids === undefined ? undefined : [...new Set(ids)];
};
let selectedCompanies = expanded ? copySelection(options.selectedCompanyIds) : undefined;
let availableCompanies: string[] = [];
let latestData: unknown;
const collapsedOrganizations = new Set<string>();
let expandedViewport: ExpandedViewport | undefined;
let fitNext = true;
let focusOrganization: string | undefined;
const readOnly = view === 'composition' && options.displayMode === 'readOnly';
const organizationLinks: Record<string, string> = Object.create(null);
if (options.onEmployeeClick !== undefined && typeof options.onEmployeeClick !== 'function') throw new TypeError('onEmployeeClick must be a function');
const employeeClickCallback = options.onEmployeeClick;
for (const [id, value] of Object.entries(options.organizationLinks ?? {})) {
if (value === undefined || value === '') continue;
if (typeof value !== 'string') throw new TypeError('organizationLinks values must be URL strings');
const href = value.trim();
if (!href) continue;
let url: URL;
try { url = new URL(href, 'https://orgchart.invalid/'); }
catch { throw new TypeError('organizationLinks must contain valid HTTP(S) or relative URLs'); }
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new TypeError('organizationLinks must contain valid HTTP(S) or relative URLs');
organizationLinks[id] = href;
}
let preferredContainer = options.container;
let source: OrgChartDataSource | undefined = options.dataSource;
let policy = options.aggregationPolicy ? freeze(structuredClone(options.aggregationPolicy)) : undefined;
let state: OrgChartState = freeze({ status: 'idle' });
let destroyed = false;
let revision = 0;
let request: AbortController | undefined;
let container: HTMLElement | undefined;
let root: HTMLElement | undefined;
let shell: HTMLElement | undefined;
let status: HTMLElement | undefined;
let content: HTMLElement | undefined;
let viewport: HTMLElement | undefined;
let engine: EngineAdapter | undefined;
let renderContext: RenderContext | undefined;
let Engine: EngineConstructor | undefined;
let styleReady: Promise<readonly ValidationIssue[] | undefined> | undefined;
let cancelStyles: (() => void) | undefined;
let contentGeneration: AbortController | undefined;
let interactions: AbortController | undefined;
let employeeDialog: EmployeeInfoDialog | undefined;
const owner = Symbol('hm-orgchart-owner');
const prefix = `hm-orgchart-${++instanceSequence}`;
const listeners = new Set<(state: OrgChartState) => void>();
if (options.onStateChange) listeners.add(options.onStateChange);
function active(version: number): boolean { return !destroyed && version === revision; }
function notify(next: OrgChartState): void {
state = freeze(next);
const notifiedState = state;
for (const listener of [...listeners]) {
// Reentrant update/reload/destroy may supersede this notification.
if (destroyed || state !== notifiedState) break;
if (!listeners.has(listener)) continue;
try { listener(notifiedState); } catch { /* Consumer callbacks do not break Core lifecycle. */ }
}
}
function assertMounted(): void {
if (destroyed) throw new Error('OrgChart handle is destroyed; create a new handle');
if (!container) throw new Error('Mount OrgChart before update or reload');
}
function begin(): { version: number; signal: AbortSignal } {
request?.abort();
const Abort = container?.ownerDocument.defaultView?.AbortController ?? AbortController;
request = new Abort();
const version = ++revision;
status!.textContent = '조직도를 불러오는 중입니다.';
shell!.setAttribute('aria-busy', 'true');
const signal = request.signal;
notify({ status: 'loading' });
return { version, signal };
}
function releaseView(): void {
employeeDialog?.close();
contentGeneration?.abort();
contentGeneration = undefined;
engine?.dispose();
engine = undefined;
renderContext = undefined;
viewport = undefined;
expandedViewport = undefined;
latestData = undefined;
content?.replaceChildren();
}
function activateEmployee(event: MouseEvent | KeyboardEvent): void {
if (event.defaultPrevented || !renderContext || !content) return;
const Element = content.ownerDocument.defaultView?.Element;
if (!Element || !(event.target instanceof Element)) return;
// Links, collapse buttons and any future nested controls own their events.
if (event.target.closest('a, button, input, select, textarea')) return;
const card = event.target.closest<HTMLElement>('.hm-orgchart-employee');
if (!card || !content.contains(card)) return;
const employee = renderContext.data.employees.find(item => item.id === card.dataset.employeeId);
if (!employee) return;
event.preventDefault();
event.stopPropagation();
card.focus({ preventScroll: true });
// A name tooltip can be open from pointer hover. Close it before opening
// information UI; the existing hover/focus tooltip behavior stays intact.
for (const tip of shell?.querySelectorAll<HTMLElement>('.hm-orgchart-person-name-tooltip') ?? []) tip.hidden = true;
for (const name of content.querySelectorAll<HTMLElement>('[data-tooltip-open="true"]')) name.dataset.tooltipOpen = 'false';
const context: OrgChartEmployeeClickContext = {
organization: renderContext.data.organizations.find(item => item.id === employee.organizationId),
affiliation: renderContext.data.affiliations.find(item => item.id === employee.affiliationId),
trigger: card,
originalEvent: event,
};
if (employeeClickCallback) {
try { employeeClickCallback(employee, context); }
catch { /* Consumer callbacks do not interrupt Core event/lifecycle ownership. */ }
} else employeeDialog?.show(employee, context);
}
function prepareEmployeeCards(element: HTMLElement): void {
for (const card of element.querySelectorAll<HTMLElement>('.hm-orgchart-employee')) {
card.tabIndex = 0;
card.setAttribute('role', 'button');
if (!employeeClickCallback) card.setAttribute('aria-haspopup', 'dialog');
const name = card.querySelector('.hm-orgchart-person-name')?.textContent ?? '';
const position = card.querySelector('.hm-orgchart-person-role')?.textContent ?? '';
card.setAttribute('aria-label', `${name} ${position} 직원 정보 보기`);
}
}
function fail(version: number, issues: readonly ValidationIssue[]): void {
if (!active(version)) return;
releaseView();
shell!.setAttribute('aria-busy', 'false');
status!.textContent = '조직도를 표시할 수 없습니다.';
const document = content!.ownerDocument;
const list = document.createElement('ul');
list.className = 'hm-orgchart-issues';
for (const issue of issues) {
const row = document.createElement('li');
row.textContent = `${issue.code} · ${issue.path}: ${issue.message}`;
list.append(row);
}
content!.append(list);
notify({ status: 'error', issues });
}
async function apply(input: unknown, version: number): Promise<void> {
if (!active(version)) return;
let captured: unknown;
try { captured = snapshot(input); }
catch {
fail(version, [{ code: 'INPUT_SNAPSHOT', path: '$', message: 'Input must be a cloneable JSON dataset' }]);
return;
}
const validation = validateOrgChartDataset(captured);
if (!validation.valid) { fail(version, validation.issues); return; }
const data = freeze(validation.data);
const normalized = normalizeOrgChartDataset(data);
const companyState = expanded ? resolveExpandedCompanies(data, normalized.roots, expandedCompanyPreset!.companyId) : undefined;
if (companyState) {
const issues = [...companyState.issues, ...validateTopNodes(data, normalized.roots, companyState.companies, topNodes)];
if (issues.length) { fail(version, issues); return; }
availableCompanies = companyState.companyIds;
const validIds = new Set(data.organizations.map(org => org.id));
for (const id of collapsedOrganizations) if (!validIds.has(id)) collapsedOrganizations.delete(id);
latestData = data;
}
const summary = freeze(calculateSummary(data, policy));
const topLeaderIds = new Set(view === 'composition' ? normalized.roots.filter(node => node.organization.type === 'root' && node.organization.leaderId).map(node => node.organization.leaderId!) : []);
const nextContext: RenderContext = { data, summary, orderedOrganizations: [], employeesByOrganization: normalized.employeesByOrganization, topLeaderIds, readOnly: readOnly || expanded, organizationLinks, expandedCompanyPreset,
organizationCompanies: companyState?.companies, collapsedOrganizations: expanded ? collapsedOrganizations : undefined,
companyTopNodes: topNodes, employeeConnections, toggleExpanded: id => {
if (destroyed || !latestData) return;
if (collapsedOrganizations.has(id)) collapsedOrganizations.delete(id); else collapsedOrganizations.add(id);
focusOrganization = id; fitNext = false;
const { version } = begin();
void apply(latestData, version).catch(() => fail(version, [{ code: 'CORE_RENDER', path: 'core', message: 'Expanded rerender failed.' }]));
},
};
// Composition shows the root leader as the standalone top card, so the root's direct
// organizations become the horizontal engine roots (data and parentId stay untouched).
// A root with other direct employees keeps its node so those employees stay visible.
const flattenRoot = (node: typeof normalized.roots[number]) => view === 'composition' && node.organization.type === 'root' && !!node.organization.leaderId
&& (normalized.employeesByOrganization.get(node.organization.id) ?? []).every(employee => employee.id === node.organization.leaderId);
const flattened = normalized.roots.some(flattenRoot);
const roots = expanded ? normalized.roots.filter(node => selectedCompanies === undefined || selectedCompanies.includes(companyState!.companies.get(node.organization.id)!))
: flattened ? normalized.roots.flatMap(node => flattenRoot(node) ? node.children : [node]) : normalized.roots;
const tree = engineTree(roots, prefix, nextContext, !expanded && flattened ? 1 : 0);
const styleIssues = await styleReady;
if (!active(version)) return;
if (styleIssues) { fail(version, styleIssues); return; }
const LoadedEngine = needsEngine ? Engine ?? await loadEngine() : undefined;
// Last-started operation wins even if an upstream engine import was pending.
if (!active(version)) return;
if (LoadedEngine) Engine = LoadedEngine;
const document = content!.ownerDocument;
const previousViewport = expandedViewport?.state();
employeeDialog?.close();
contentGeneration?.abort();
const Abort = document.defaultView?.AbortController ?? AbortController;
contentGeneration = new Abort();
renderContext = nextContext;
if (needsEngine && !viewport) {
viewport = createOrgChartTree(document);
content!.append(viewport);
}
const activeElement = root!.shadowRoot?.activeElement;
if (needsEngine) {
if (engine) engine.update(tree);
else engine = createEngineAdapter(Engine!, viewport!, tree, (node, value, signal) => {
if (renderContext) decorateNode(node, value, renderContext, signal, target => engine?.toggle(target));
});
}
// If regeneration removed keyboard focus, offer the stable viewport next.
const nextContent = view === 'composition' ? expanded
? createExpandedOrgChartContent(document, nextContext, viewport!, contentGeneration.signal, () => expandedViewport?.layoutReady())
: createOrgChartContent(document, nextContext, viewport!, contentGeneration.signal)
: view === 'tree' ? viewport!
: view === 'positionSummary' ? createPositionSummary(document, nextContext)
: view === 'affiliationSummary' ? createAffiliationSummary(document, nextContext)
: createStatusSummary(document, nextContext, contentGeneration.signal);
if (expanded) {
const toolbar = document.createElement('div');
toolbar.className = 'hm-orgchart-expanded-toolbar';
nextContent.insertBefore(toolbar, viewport!);
expandedViewport = createExpandedViewport(viewport!, toolbar, contentGeneration.signal, zoom, previousViewport, fitNext);
fitNext = false;
const selection = document.createElement('p');
selection.className = 'hm-orgchart-expanded-selection';
selection.setAttribute('aria-live', 'polite');
const selected = new Set(roots.map(root => companyState!.companies.get(root.organization.id)!));
const employeeCount = data.employees.filter(employee => selected.has(companyState!.companies.get(employee.organizationId)!)).length;
selection.textContent = `선택 ${selected.size}개 회사 · ${employeeCount}명 / 원본 ${data.employees.length}명`;
nextContent.insertBefore(selection, toolbar);
if (!roots.length) {
const empty = document.createElement('p');
empty.className = 'hm-orgchart-expanded-empty';
empty.textContent = '선택된 회사가 없습니다. 표시할 회사를 선택해 주세요.';
viewport!.append(empty);
} else viewport!.querySelector('.hm-orgchart-expanded-empty')?.remove();
}
prepareEmployeeCards(nextContent);
content!.replaceChildren(nextContent);
const restoreFocus = !!activeElement && (activeElement === viewport || !activeElement.isConnected);
const focusTarget = focusOrganization ? [...content!.querySelectorAll<HTMLButtonElement>('.hm-orgchart-expanded-toggle')].find(button => button.dataset.organizationId === focusOrganization) : undefined;
focusOrganization = undefined;
if (focusTarget) focusTarget.focus({ preventScroll: true });
else if (restoreFocus) (expanded ? viewport! : readOnly ? shell! : viewport ?? content!.querySelector<HTMLElement>('.hm-orgchart-summary-scroll') ?? shell!).focus({ preventScroll: true });
shell!.setAttribute('aria-busy', 'false');
status!.textContent = '';
if (!active(version)) return;
notify({ status: 'ready', dataVersion: data.metadata.dataVersion, summary, aggregation: summary.aggregation });
}
async function reload(nextSource?: OrgChartDataSource): Promise<void> {
assertMounted();
if (expanded) fitNext = true;
if (nextSource) {
if (typeof nextSource.load !== 'function') throw new TypeError('An OrgChartDataSource is required');
source = nextSource;
}
const { version, signal } = begin();
if (!active(version)) return;
let input: unknown;
try {
input = await source!.load({ signal });
} catch (error) {
if (!active(version) || signal.aborted) return;
const sourceCode = error && typeof error === 'object' && 'code' in error && ['SOURCE_LOAD', 'SOURCE_PARSE', 'SOURCE_MAP', 'SOURCE_URL', 'SOURCE_SNAPSHOT'].includes(String(error.code)) ? String(error.code) : 'SOURCE_LOAD';
fail(version, [{ code: sourceCode, path: 'dataSource', message: 'Data source or chart rendering failed; verify the source and CSS configuration' }]);
return;
}
try { if (active(version)) await apply(input, version); }
catch { fail(version, [{ code: 'CORE_RENDER', path: 'core', message: 'Chart rendering failed' }]); }
}
return {
async mount(nextContainer = preferredContainer) {
if (destroyed) throw new Error('OrgChart handle is destroyed; create a new handle');
if (container) throw new Error('OrgChart is already mounted');
const Element = nextContainer?.ownerDocument.defaultView?.HTMLElement;
if (!Element || !(nextContainer instanceof Element) || !nextContainer.isConnected) throw new TypeError('Mount requires a connected HTMLElement');
if (owners.has(nextContainer)) throw new Error('Another OrgChart instance owns this container');
container = nextContainer;
owners.set(container, owner);
const document = container.ownerDocument;
root = document.createElement('div');
root.className = 'hm-orgchart-host';
root.dataset.orgchartInstance = prefix;
const shadow = root.attachShadow({ mode: 'open' });
const styleDisposers: (() => void)[] = [];
const styles = [...(needsEngine ? [{ key: 'engine', href: stylesheetUrls.engine }] : []), { key: 'core', href: stylesheetUrls.core }];
styleReady = Promise.all(styles.map(({ key, href }) => new Promise<ValidationIssue | undefined>(resolve => {
const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = href;
let settled = false;
const finish = (issue?: ValidationIssue) => {
if (settled) return;
settled = true;
link.removeEventListener('load', loaded); link.removeEventListener('error', failed);
resolve(issue);
};
const loaded = () => finish();
const failed = () => finish({ code: 'CORE_STYLE', path: `stylesheetUrls.${key}`, message: 'Required OrgChart stylesheet failed to load' });
link.addEventListener('load', loaded); link.addEventListener('error', failed);
styleDisposers.push(() => finish());
shadow.append(link);
}))).then(results => {
const issues = results.filter((issue): issue is ValidationIssue => !!issue);
return issues.length ? issues : undefined;
});
cancelStyles = () => { for (const dispose of styleDisposers) dispose(); };
shell = document.createElement('section');
shell.className = 'hm-orgchart-shell';
shell.dataset.layoutMode = view === 'composition' ? layoutMode : 'fixed';
// The Core's Korean labels must shape identically in ko HTML and en Storybook hosts.
shell.lang = 'ko';
shell.tabIndex = -1;
shell.setAttribute('aria-label', '조직도 콘텐츠');
status = document.createElement('p'); status.className = 'hm-orgchart-state'; status.setAttribute('role', 'status'); status.setAttribute('aria-live', 'polite');
content = document.createElement('div'); content.className = 'hm-orgchart-content';
shell.append(status, content); shadow.append(shell); container.append(root);
const Abort = document.defaultView?.AbortController ?? AbortController;
interactions = new Abort();
employeeDialog = createEmployeeInfoDialog(shell, interactions.signal, prefix, readOnly);
content.addEventListener('click', activateEmployee, { signal: interactions.signal });
content.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ' ') activateEmployee(event);
}, { signal: interactions.signal });
await reload();
},
async update(input) {
assertMounted();
// Capture before begin() invokes callbacks that can mutate consumer input.
let captured: unknown;
try { captured = snapshot(input); }
catch {
const { version } = begin();
fail(version, [{ code: 'INPUT_SNAPSHOT', path: '$', message: 'Input must be a cloneable JSON dataset' }]);
return;
}
const { version } = begin();
if (!active(version)) return;
try { await apply(captured, version); }
catch { fail(version, [{ code: 'CORE_RENDER', path: 'core', message: 'Chart rendering failed' }]); }
},
reload,
destroy() {
if (destroyed) return;
destroyed = true;
++revision;
request?.abort(); request = undefined;
cancelStyles?.(); cancelStyles = undefined; styleReady = undefined;
interactions?.abort(); interactions = undefined;
employeeDialog = undefined;
releaseView();
root?.remove();
if (container && owners.get(container) === owner) owners.delete(container);
container = undefined; preferredContainer = undefined;
root = undefined; shell = undefined; status = undefined; content = undefined;
source = undefined; policy = undefined; Engine = undefined;
listeners.clear();
state = freeze({ status: 'destroyed' });
},
subscribe(listener) {
if (destroyed) throw new Error('OrgChart handle is destroyed; create a new handle');
if (typeof listener !== 'function') throw new TypeError('Listener must be a function');
listeners.add(listener);
return () => { listeners.delete(listener); };
},
getState() { return state; },
async setSelectedCompanies(ids) {
assertMounted();
if (!expanded) throw new Error('Company selection is available only in Auto mode');
selectedCompanies = copySelection(ids); fitNext = true;
if (latestData) { const { version } = begin(); await apply(latestData, version); }
},
getSelectedCompanies() { return Object.freeze([...(selectedCompanies ?? availableCompanies)]); },
zoomTo(scale) { expandedViewport?.zoomTo(scale); },
panBy(x, y) { expandedViewport?.panBy(x, y); },
fitToView() { expandedViewport?.fit(); },
getViewportState() { return expandedViewport?.state(); },
};
}