162 lines
9.6 KiB
TypeScript
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;
|
|
}
|