Files
HMDesign/packages/org-chart/src/core/render.ts
T

162 lines
9.6 KiB
TypeScript

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<string, readonly Employee[]>;
topLeaderIds: ReadonlySet<string>;
/** 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<string, string>;
collapsedOrganizations?: ReadonlySet<string>;
companyTopNodes?: Readonly<Record<string, OrgChartTopNode>>;
employeeConnections?: Readonly<Record<string, OrgChartConnectionDirection>>;
autoLayout?: ResolvedAutoLayout;
interactions?: Readonly<Required<OrgChartInteractionOptions>>;
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;
}