/** 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. */ import type { ResolvedAutoLayout } from './auto-layout-policy.js'; export function alignExpandedLevels(viewport: HTMLElement, signal: AbortSignal, layoutReady: () => void = () => {}, layout?: ResolvedAutoLayout): void { const view = viewport.ownerDocument.defaultView; if (!view || signal.aborted) return; const nodes = [...viewport.querySelectorAll('.node[data-depth]')]; let frame = 0; const align = () => { frame = 0; if (signal.aborted) return; const heights = new Map(); const widths = new Map(); for (const node of nodes) { const height = node.querySelector('.hm-orgchart-organization')?.offsetHeight ?? 0; heights.set(node.dataset.depth!, Math.max(heights.get(node.dataset.depth!) ?? 0, Math.ceil(height))); const width = node.querySelector('.hm-orgchart-organization')?.offsetWidth ?? 0; widths.set(node.dataset.depth!, Math.max(widths.get(node.dataset.depth!) ?? 0, Math.ceil(width))); } for (const node of nodes) { node.style.minHeight = layout?.type === 'lr' || node.closest('.nodes.vertical') ? '0' : `${heights.get(node.dataset.depth!) ?? 0}px`; if (layout?.type === 'lr') node.style.minWidth = `${widths.get(node.dataset.depth!) ?? 0}px`; node.style.setProperty('--hm-orgchart-expanded-content-height', `${node.querySelector('.hm-orgchart-organization')?.offsetHeight ?? 0}px`); } drawEmployeeConnectors(viewport); if (layout?.type === 'lr' || layout?.type === 'hybrid' || layout?.hybridOrganizationIds.length) drawOrganizationConnectors(viewport, layout); 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(); } /** Adapt dabeng's existing hierarchy DOM to tall HM cards. These paths own only * connector presentation; dabeng still creates and recursively lays out nodes. */ function drawOrganizationConnectors(viewport: HTMLElement, layout: ResolvedAutoLayout): void { const document = viewport.ownerDocument; for (const tree of viewport.querySelectorAll('.hm-orgchart-tree')) { tree.querySelector(':scope > .hm-orgchart-organization-connectors')?.remove(); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.classList.add('hm-orgchart-organization-connectors'); svg.setAttribute('aria-hidden', 'true'); svg.setAttribute('width', String(tree.offsetWidth)); svg.setAttribute('height', String(tree.offsetHeight)); const nodes = [...tree.querySelectorAll('.node[data-organization-id]')]; const byId = new Map(nodes.map(node => [node.dataset.organizationId!, node])); const local = (element: HTMLElement) => { const treeRect = tree.getBoundingClientRect(), rect = element.getBoundingClientRect(); // Forest scale is the only transform (LR's upstream rotation is scoped out). const scale = Number(viewport.dataset.scale) || 1; return { x: (rect.left - treeRect.left) / scale, y: (rect.top - treeRect.top) / scale, width: element.offsetWidth, height: element.offsetHeight }; }; for (const node of nodes) { const parent = byId.get(node.dataset.parentId!); if (!parent) continue; const article = parent.querySelector(':scope > .hm-orgchart-organization')!; const heading = node.querySelector('.hm-orgchart-organization-name')!; const from = local(article), to = local(heading); const left = layout.type === 'lr' || !!node.closest('.nodes.vertical'); const path = document.createElementNS(svg.namespaceURI, 'path'); if (layout.type === 'lr') { const parentHeading = local(parent.querySelector('.hm-orgchart-organization-name')!); const x = parentHeading.x + parentHeading.width, y = parentHeading.y + parentHeading.height / 2; const targetY = to.y + to.height / 2, rail = (x + to.x) / 2; path.setAttribute('d', `M ${x} ${y} H ${rail} V ${targetY} H ${to.x}`); } else if (left) { const x = from.x + from.width / 2, y = from.y + from.height; const rail = to.x - 16, targetY = to.y + to.height / 2; const branchY = y + 12; path.setAttribute('d', `M ${x} ${y} V ${branchY} H ${rail} V ${targetY} H ${to.x}`); } else { const x = from.x + from.width / 2, y = from.y + from.height; const targetX = to.x + to.width / 2, rail = (y + to.y) / 2; path.setAttribute('d', `M ${x} ${y} V ${rail} H ${targetX} V ${to.y}`); } path.setAttribute('data-organization-id', node.dataset.organizationId!); path.setAttribute('data-parent-id', node.dataset.parentId!); path.setAttribute('data-direction', left ? 'left' : 'top'); svg.append(path); } tree.prepend(svg); } } /** 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('.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('.hm-orgchart-top-node-title') ?? article.querySelector('.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('.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); } }