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.
This commit is contained in:
shkim4 committed 2026-10-12 21:59:09 +09:00
1 parent a92913d6bc
commit 064517ec46
30 files changed
+7532 -183

No files matched your search

+79 -6
View File
@@ -6,8 +6,9 @@ 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 { expandedPreviewRoots } from './expanded-layout.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';
@@ -55,6 +56,22 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
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');
@@ -134,6 +151,8 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
engine = undefined;
renderContext = undefined;
viewport = undefined;
expandedViewport = undefined;
latestData = undefined;
content?.replaceChildren();
}
@@ -206,18 +225,36 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
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 };
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 ? expandedPreviewRoots(normalized.roots)
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 tree = engineTree(roots, prefix, nextContext, !expanded && flattened ? 1 : 0);
const styleIssues = await styleReady;
if (!active(version)) return;
if (styleIssues) { fail(version, styleIssues); return; }
@@ -226,6 +263,7 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
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;
@@ -244,16 +282,39 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
}
// If regeneration removed keyboard focus, offer the stable viewport next.
const nextContent = view === 'composition' ? expanded
? createExpandedOrgChartContent(document, nextContext, viewport!, contentGeneration.signal)
? 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);
if (restoreFocus) (readOnly ? shell! : viewport ?? content!.querySelector<HTMLElement>('.hm-orgchart-summary-scroll') ?? shell!).focus({ preventScroll: true });
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;
@@ -262,6 +323,7 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
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;
@@ -375,5 +437,16 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
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(); },
};
}