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

+83 -8
View File
@@ -1,10 +1,85 @@
import type { OrganizationTreeNode } from '../data/index.js';
/** dabeng owns recursive widths and connectors. Reserve the tallest content
* height per depth so child rows align with unequal staff counts. Observe the
* articles rather than reserved nodes to avoid ResizeObserver feedback. */
export function alignExpandedLevels(viewport: HTMLElement, signal: AbortSignal, layoutReady: () => void = () => {}): void {
const view = viewport.ownerDocument.defaultView;
if (!view || signal.aborted) return;
const nodes = [...viewport.querySelectorAll<HTMLElement>('.node[data-depth]')];
let frame = 0;
const align = () => {
frame = 0;
if (signal.aborted) return;
const heights = new Map<string, number>();
for (const node of nodes) {
const height = node.querySelector<HTMLElement>('.hm-orgchart-organization')?.offsetHeight ?? 0;
heights.set(node.dataset.depth!, Math.max(heights.get(node.dataset.depth!) ?? 0, Math.ceil(height)));
}
for (const node of nodes) {
node.style.minHeight = `${heights.get(node.dataset.depth!) ?? 0}px`;
node.style.setProperty('--hm-orgchart-expanded-content-height', `${node.querySelector<HTMLElement>('.hm-orgchart-organization')?.offsetHeight ?? 0}px`);
}
drawEmployeeConnectors(viewport);
layoutReady();
};
const schedule = () => {
if (!frame && !signal.aborted) frame = view.requestAnimationFrame(align);
};
const observer = new view.ResizeObserver(schedule);
for (const node of nodes) {
const article = node.querySelector('.hm-orgchart-organization');
if (article) observer.observe(article);
}
signal.addEventListener('abort', () => {
observer.disconnect();
if (frame) view.cancelAnimationFrame(frame);
}, { once: true });
schedule();
}
/** P5-1A preview projection only. Keep the validated hierarchy intact and
* expose the root's immediate groups without pretending to place descendants.
* P5-1B can replace this boundary with a recursive layout result shared by
* every runtime; neither React nor the Story owns a layout algorithm. */
export function expandedPreviewRoots(roots: readonly OrganizationTreeNode[]): OrganizationTreeNode[] {
return roots.flatMap(root => root.organization.type === 'root' ? root.children : [root])
.map(node => ({ organization: node.organization, children: [] }));
/** Draw routes in unscaled article coordinates. The endpoint defines both the
* connector's incoming direction and the single accent side on the card. */
function drawEmployeeConnectors(viewport: HTMLElement): void {
const document = viewport.ownerDocument;
for (const article of viewport.querySelectorAll<HTMLElement>('.hm-orgchart-organization')) {
article.querySelector('.hm-orgchart-employee-connectors')?.remove();
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.classList.add('hm-orgchart-employee-connectors');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('width', String(article.offsetWidth));
svg.setAttribute('height', String(article.offsetHeight));
let previous = article.querySelector<HTMLElement>('.hm-orgchart-top-node-title') ?? article.querySelector<HTMLElement>('.hm-orgchart-organization-name')!;
const local = (element: HTMLElement) => {
let x = 0, y = 0, current: HTMLElement | null = element;
while (current && current !== article) { x += current.offsetLeft; y += current.offsetTop; current = current.offsetParent as HTMLElement | null; }
return { x, y, width: element.offsetWidth, height: element.offsetHeight };
};
const heading = local(previous);
let source = { x: heading.x + heading.width / 2, y: heading.y + heading.height };
let staffSource = source;
for (const card of article.querySelectorAll<HTMLElement>('.hm-orgchart-employee')) {
const rect = local(card);
const direction = card.dataset.connectionDirection;
const target = direction === 'left' ? { x: rect.x, y: rect.y + rect.height / 2 }
: direction === 'right' ? { x: rect.x + rect.width, y: rect.y + rect.height / 2 }
: { x: rect.x + rect.width / 2, y: rect.y };
const path = document.createElementNS(svg.namespaceURI, 'path');
if (direction === 'left' || direction === 'right') {
const rail = direction === 'left' ? rect.x / 2 : rect.x + rect.width + (article.offsetWidth - rect.x - rect.width) / 2;
const bend = staffSource.y + (rect.y - staffSource.y) / 2;
path.setAttribute('d', `M ${staffSource.x} ${staffSource.y} V ${bend} H ${rail} V ${target.y} H ${target.x}`);
// All staff branch off the same rail above the list, avoiding crossings.
staffSource = { x: rail, y: bend };
} else {
path.setAttribute('d', `M ${source.x} ${source.y} V ${(source.y + target.y) / 2} H ${target.x} V ${target.y}`);
}
path.setAttribute('data-employee-id', card.dataset.employeeId!);
path.setAttribute('data-direction', direction ?? 'top');
path.setAttribute('data-end-x', String(target.x));
path.setAttribute('data-end-y', String(target.y));
svg.append(path);
source = { x: rect.x + rect.width / 2, y: rect.y + rect.height };
if (!card.closest('.hm-orgchart-employees')) staffSource = source;
}
article.prepend(svg);
}
}