import type { Employee, OrgChartDataset, OrgChartSummary, OrganizationTreeNode } from '../data/index.js'; import type { RenderEngineNode } from './engine.js'; import { createEmployeeCard } from './employee-card.js'; import { applyOrganizationStyle, organizationStylePresets } from './style-presets.js'; import { getDepartmentCardAsset } from './department-card-assets.js'; import type { OrgChartOrganizationLinks } from './types.js'; import type { CompanyColorPreset } from '../company-color-presets.js'; import { decorateExpandedNode } from './expanded-content.js'; import type { OrgChartConnectionDirection, OrgChartTopNode, OrgChartInteractionOptions } from './types.js'; import type { ResolvedAutoLayout } from './auto-layout-policy.js'; const organizationNames = { root: '최상위 조직', division: '부문', office: '실', div: 'Div', cell: '셀', team: '팀' } as const; export interface RenderContext { data: OrgChartDataset; summary: OrgChartSummary; orderedOrganizations: OrganizationTreeNode['organization'][]; employeesByOrganization: ReadonlyMap; topLeaderIds: ReadonlySet; /** View-only composition: no collapse controls or diagnostic text are created. */ readOnly?: boolean; organizationLinks?: OrgChartOrganizationLinks; /** Only the Expanded presenter supplies company header colors. */ expandedCompanyPreset?: CompanyColorPreset; organizationCompanies?: ReadonlyMap; collapsedOrganizations?: ReadonlySet; companyTopNodes?: Readonly>; employeeConnections?: Readonly>; autoLayout?: ResolvedAutoLayout; interactions?: Readonly>; toggleExpanded?: (organizationId: string) => void; } export function engineTree(roots: OrganizationTreeNode[], prefix: string, context: RenderContext, startDepth = 0): RenderEngineNode[] { function convert(tree: OrganizationTreeNode, depth: number, variant: number): RenderEngineNode { variant = organizationStylePresets.find(preset => preset.organizationId === tree.organization.id)?.id ?? variant; const index = context.orderedOrganizations.length; context.orderedOrganizations.push(tree.organization); return { id: `${prefix}-org-${index}`, name: '', organizationIndex: index, depth, groupVariant: variant, // Make public (root=Depth 1) semantics explicit rather than depending on // upstream's detached-DOM ancestor inference during mode reconstruction. ...(context.autoLayout ? { level: depth + 1 } : {}), ...(context.autoLayout?.type === 'tb' && context.autoLayout.hybridOrganizationIds.includes(tree.organization.id) ? { hybrid: true } : {}), ...(tree.children.length && !context.collapsedOrganizations?.has(tree.organization.id) ? { children: tree.children.map((child, childIndex) => convert(child, depth + 1, context.readOnly ? variant : depth === startDepth ? childIndex % 6 : variant)) } : {}), }; } // Multiple actual roots stay independent; no fictitious organizational parent. return roots.map((tree, index) => convert(tree, startDepth, index % 6)); } function text(document: Document, tag: string, className: string, value: string): HTMLElement { const element = document.createElement(tag); element.className = className; element.textContent = value; return element; } export function decorateNode( node: HTMLElement, value: RenderEngineNode, context: RenderContext, signal: AbortSignal, toggle: (node: HTMLElement) => boolean | undefined, ): void { const organization = context.orderedOrganizations[Number(value.organizationIndex)]; if (!organization) return; if (context.expandedCompanyPreset) { decorateExpandedNode(node, value, context, signal); return; } const document = node.ownerDocument; const card = node.querySelector('.hm-orgchart-organization')!; node.dataset.organizationId = organization.id; node.dataset.depth = String(value.depth); // Engine roots can be flattened for composition. Card policy uses the // original ancestry rather than the engine's display depth or job title. let organizationDepth = 0; let ancestor = organization; while (ancestor.parentId) { const parent = context.data.organizations.find(item => item.id === ancestor.parentId); if (!parent) break; organizationDepth++; ancestor = parent; } node.dataset.organizationDepth = String(organizationDepth); applyOrganizationStyle(node, Number(value.groupVariant)); node.dataset.organizationType = organization.type; // Header role is styling-only: depth 0-1 are organization titles, deeper nodes are cell/team headers. node.dataset.headerRole = (context.readOnly ? organizationDepth : Number(value.depth)) <= 1 ? 'organization' : 'group'; // Hide upstream mouse-only arrows, retaining the engine's hierarchy connectors. for (const edge of node.querySelectorAll('.edge')) edge.setAttribute('aria-hidden', 'true'); const readOnly = context.readOnly === true; // Kind, ID, raw counts and vacancy notes are diagnostic; read-only shows the header and people only. if (!readOnly) card.append(text(document, 'span', 'hm-orgchart-kind', organizationNames[organization.type])); const heading = text(document, 'h3', 'hm-orgchart-organization-name', organization.name); const href = organizationDepth === 1 ? context.organizationLinks?.[organization.id] : undefined; if (href) { const link = document.createElement('a'); link.className = 'hm-orgchart-organization-link'; link.href = href; link.textContent = organization.name; heading.replaceChildren(link); // Native links retain keyboard and browser navigation semantics; this // click must not also reach the upstream node selection/collapse handler. link.addEventListener('click', event => event.stopPropagation(), { signal }); link.addEventListener('keydown', event => event.stopPropagation(), { signal }); } card.append(heading); if (!readOnly) { const id = text(document, 'span', 'hm-orgchart-domain-id', organization.id); id.title = organization.id; card.append(id); const counts = context.summary.raw.byOrganization[organization.id]; card.append(text(document, 'p', 'hm-orgchart-node-count', `원본 직원: 직접 ${counts?.direct ?? 0}명 · 하위 포함 ${counts?.inclusive ?? 0}명`)); } const leader = organization.leaderId ? context.data.employees.find(employee => employee.id === organization.leaderId) : undefined; const affiliationName = (employee: Employee) => context.data.affiliations.find(item => item.id === employee.affiliationId)?.name; if (leader && !context.topLeaderIds.has(leader.id)) card.append(createEmployeeCard(document, leader, { kind: 'leader', affiliationName: affiliationName(leader), workdeskPresentation: readOnly, cardAsset: getDepartmentCardAsset(organization.id, readOnly && organizationDepth !== 1 ? 'employee' : 'leader', context.data.organizations), }, signal)); else if (readOnly) { /* The top leader card or an empty slot needs no placeholder text. */ } else if (leader) card.append(text(document, 'p', 'hm-orgchart-vacancy', '조직장: 상단 독립 카드')); else card.append(text(document, 'p', 'hm-orgchart-vacancy', '조직장 미지정')); if (context.data.presentation?.groups.showEmployees !== false) { const employees = document.createElement('div'); employees.className = 'hm-orgchart-employees'; for (const employee of context.employeesByOrganization.get(organization.id) ?? []) { if (employee.id !== leader?.id && !context.topLeaderIds.has(employee.id)) employees.append(createEmployeeCard(document, employee, { kind: 'employee', workdeskPresentation: readOnly, affiliationName: affiliationName(employee), cardAsset: getDepartmentCardAsset(organization.id, 'employee', context.data.organizations) }, signal)); } card.append(employees); } // Read-only hierarchy is always fully expanded; no toggle (and no leftover space) is created. if (value.children?.length && !readOnly) { const button = document.createElement('button'); button.type = 'button'; button.className = 'hm-orgchart-toggle'; button.textContent = '하위 조직 접기'; button.setAttribute('aria-label', `${organization.name} 하위 조직 접기`); button.setAttribute('aria-expanded', 'true'); button.addEventListener('click', event => { event.stopPropagation(); const expanded = toggle(node); if (expanded === undefined) return; button.setAttribute('aria-expanded', String(expanded)); const label = expanded ? '하위 조직 접기' : '하위 조직 펼치기'; button.textContent = label; button.setAttribute('aria-label', `${organization.name} ${label}`); }, { signal }); card.append(button); } } export function createHeading(document: Document, context: RenderContext): HTMLElement { const header = document.createElement('header'); header.className = 'hm-orgchart-heading'; header.append(text(document, 'h2', 'hm-orgchart-title', context.data.presentation?.title ?? '조직도')); // Read-only keeps the title and as-of date; description and relation notes are explanatory text. if (context.data.presentation?.description && !context.readOnly) header.append(text(document, 'p', 'hm-orgchart-description', context.data.presentation.description)); header.append(text(document, 'p', 'hm-orgchart-date', `기준일 ${context.data.metadata.asOfDate}`)); if (context.data.relations?.length && !context.readOnly) header.append(text(document, 'p', 'hm-orgchart-description', `추가 관계 ${context.data.relations.length}건은 이번 기본 조직 계층선에 포함되지 않습니다.`)); return header; }