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:
1 parent
a92913d6bc
commit
064517ec46
30 files changed
+7532
-183
No files matched your search
@@ -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(); },
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user