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

+89 -37
View File
@@ -1,47 +1,99 @@
import { createEmployeeCard } from './employee-card.js';
import { createHeading, type RenderContext } from './render.js';
import { representativeCardAsset } from './representative-card-asset.js';
import type { RenderEngineNode } from './engine.js';
import { getCompanyColorPreset } from '../company-color-presets.js';
import { alignExpandedLevels } from './expanded-layout.js';
/** P5-1A presenter boundary. The shared Core supplies the initial engine groups;
* this presenter never assigns coordinates or interprets parentId. A later
* layout adapter can replace group placement without duplicating card or data
* rendering in React and Vanilla consumers. */
export function createExpandedOrgChartContent(
document: Document, context: RenderContext, tree: HTMLElement, signal: AbortSignal,
): HTMLElement {
/** Auto-only presenter. The source and popup identity remain unchanged. */
export function decorateExpandedNode(node: HTMLElement, value: RenderEngineNode, context: RenderContext, signal: AbortSignal): void {
const organization = context.orderedOrganizations[Number(value.organizationIndex)]!;
const companyId = context.organizationCompanies!.get(organization.id)!;
const preset = getCompanyColorPreset(companyId)!;
const document = node.ownerDocument;
const article = node.querySelector<HTMLElement>('.hm-orgchart-organization')!;
const collapsed = context.collapsedOrganizations!.has(organization.id);
const top = organization.parentId === null ? context.companyTopNodes?.[companyId] : undefined;
node.dataset.organizationId = organization.id;
node.dataset.parentId = organization.parentId ?? '';
node.dataset.depth = node.dataset.organizationDepth = String(value.depth);
node.dataset.organizationType = organization.type;
node.dataset.companyId = companyId;
node.dataset.collapsed = String(collapsed);
node.style.setProperty('--hm-orgchart-expanded-primary', preset.primary);
node.style.setProperty('--hm-orgchart-expanded-title-ink', ['saman', 'jangheon-industry'].includes(companyId) ? 'var(--hm-orgchart-resolved-fg-primary)' : 'var(--hm-orgchart-resolved-on-action-primary)');
preset.secondary.forEach((color, index) => node.style.setProperty(`--hm-orgchart-expanded-secondary-${index + 1}`, color));
for (const edge of node.querySelectorAll('.edge')) edge.setAttribute('aria-hidden', 'true');
const leaderId = top?.employeeId === undefined ? organization.leaderId : top.employeeId;
const leader = context.data.employees.find(employee => employee.id === leaderId && employee.organizationId === organization.id);
const staff = context.data.presentation?.groups.showEmployees === false ? [] : (context.employeesByOrganization.get(organization.id) ?? []).filter(employee => employee.id !== leaderId);
const hasChildren = context.data.organizations.some(org => org.parentId === organization.id) || staff.length > 0;
const heading = document.createElement('h3');
heading.className = 'hm-orgchart-organization-name';
const button = document.createElement('button');
button.type = 'button';
button.className = 'hm-orgchart-expanded-toggle';
button.dataset.organizationId = organization.id;
button.textContent = `${top?.organizationName ?? organization.name} ${hasChildren ? collapsed ? '▸' : '▾' : '·'}`;
button.setAttribute('aria-label', `${top?.organizationName ?? organization.name} 하위 조직·직원`);
button.setAttribute('aria-expanded', String(!collapsed));
button.disabled = !hasChildren;
button.addEventListener('click', event => { event.stopPropagation(); context.toggleExpanded?.(organization.id); }, { signal });
heading.append(button);
const href = context.organizationLinks?.[organization.id];
if (href) {
const link = document.createElement('a');
link.className = 'hm-orgchart-expanded-link';
link.href = href;
link.textContent = '↗';
link.setAttribute('aria-label', `${organization.name} 페이지 이동`);
link.addEventListener('click', event => event.stopPropagation(), { signal });
heading.append(link);
}
article.append(heading);
const colorCard = (card: HTMLElement, affiliationId: string) => {
const employeePreset = getCompanyColorPreset(affiliationId) ?? preset;
card.dataset.companyId = employeePreset.companyId;
card.style.setProperty('--hm-orgchart-expanded-card-primary', `var(--hm-orgchart-company-${employeePreset.companyId})`);
return card;
};
if (leader && top?.display !== 'hidden' && top?.display !== 'titleOnly') {
const presentation = top ? { ...leader, position: top.title ?? leader.position, name: top.name ?? leader.name, profileImage: top.profileImage === undefined ? leader.profileImage : top.profileImage } : leader;
const card = colorCard(createEmployeeCard(document, presentation, { kind: organization.parentId === null ? 'topLeader' : 'leader', expandedPresentation: true, hideStatusLabel: true }, signal), leader.affiliationId);
card.dataset.connectionDirection = 'top';
article.append(card);
} else if (leader && top?.display === 'titleOnly') {
const title = document.createElement('p');
title.className = 'hm-orgchart-top-node-title';
title.textContent = `${top.title ?? leader.position} · ${top.name ?? leader.name}`;
article.append(title);
}
if (!collapsed && staff.length) {
const employees = document.createElement('div');
employees.className = 'hm-orgchart-employees';
const direction = context.employeeConnections?.[organization.id] ?? 'top';
employees.dataset.connectionDirection = direction;
for (const employee of staff) {
const card = colorCard(createEmployeeCard(document, employee, { kind: 'employee', expandedPresentation: true, hideStatusLabel: true }, signal), employee.affiliationId);
card.dataset.connectionDirection = direction;
employees.append(card);
}
article.append(employees);
}
}
export function createExpandedOrgChartContent(document: Document, context: RenderContext, tree: HTMLElement, signal: AbortSignal, layoutReady: () => void): HTMLElement {
const content = document.createElement('div');
content.className = 'hm-orgchart-expanded';
content.dataset.component = 'ExpandedOrgChartContent';
content.dataset.layoutMode = 'auto';
content.dataset.layoutStatus = 'preview';
const heading = createHeading(document, context);
const status = document.createElement('p');
status.className = 'hm-orgchart-expanded-stage-note';
status.textContent = 'P5-1A · 확장형 준비 화면';
const description = document.createElement('p');
description.className = 'hm-orgchart-expanded-description';
description.textContent = '대표자와 1Depth 예시 그룹을 표시합니다. 하위 계층 자동 배치와 접기·펼치기, Zoom/Pan은 후속 단계에서 구현합니다.';
content.append(heading, status, description);
const leaders = document.createElement('section');
leaders.className = 'hm-orgchart-expanded-top-leaders';
leaders.setAttribute('aria-label', '최상위 조직 리더');
for (const id of context.topLeaderIds) {
const employee = context.data.employees.find(item => item.id === id);
if (!employee) continue;
leaders.append(createEmployeeCard(document, employee, {
kind: 'topLeader',
workdeskPresentation: true,
cardAsset: representativeCardAsset,
affiliationName: context.data.affiliations.find(item => item.id === employee.affiliationId)?.name,
}, signal));
content.dataset.layoutStatus = 'hierarchical';
content.dataset.cardDesignStatus = 'current-presentation';
for (const company of new Set([...context.organizationCompanies!.values(), ...context.data.employees.map(employee => employee.affiliationId)])) {
const preset = getCompanyColorPreset(company);
if (preset) content.style.setProperty(`--hm-orgchart-company-${company}`, preset.primary);
}
if (leaders.childElementCount) content.append(leaders);
// Keep the stable Core viewport for keyboard access and ordinary scrolling.
// This is a flow preview, with no fixed-stage scale observer or artwork.
tree.setAttribute('aria-label', '확장형 조직도 초기 그룹 영역');
content.append(tree);
tree.setAttribute('aria-label', '확장형 조직 계층도 · Ctrl 또는 Command + 휠로 확대 · 드래그로 이동');
content.append(createHeading(document, context), tree);
alignExpandedLevels(tree, signal, layoutReady);
return content;
}