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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user