feat(org-chart): add core cards and fixed-layout integration
This commit is contained in:
1 parent
d18e3c3380
commit
61c6f8d1f1
161 files changed
+13044
-1
No files matched your search
@@ -0,0 +1,140 @@
|
||||
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';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
...(tree.children.length ? { 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;
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user