Files
shkim4 d56d587fd2 feat(orgchart): integrate chart-only viewport and scoped guard fixes
Add initial fit and direct wheel zoom with preserved default presentation. Integrate five scoped GuardFix findings and AI generation preselection policy. Exclude Cell experiments and retain five historical guard failures.
2026-10-13 18:41:14 +09:00

622 lines
37 KiB
TypeScript

import { applyOrgChartMove, calculateSummary, createOrgChartMoveValidator, normalizeOrgChartDataset, validateOrgChartDataset } from '../data/index.js';
import type { OrgChartDataset, OrgChartMoveRequest, OrgChartMoveResult, OrgChartMoveValidation, OrgChartDataSource, ValidationIssue } from '../data/index.js';
import { createEngineAdapter, loadEngine } from './engine.js';
import type { EngineAdapter, EngineConstructor } from './engine.js';
import { decorateNode, engineTree } from './render.js';
import { createOrgChartTree } from './org-chart-tree.js';
import { createOrgChartContent } from './org-chart-content.js';
import { createExpandedOrgChartContent } from './expanded-content.js';
import { getCompanyColorPreset } from '../company-color-presets.js';
import { resolveExpandedCompanies, validateTopNodes } from './expanded-company.js';
import { createExpandedViewport, type ExpandedViewport } from './expanded-viewport.js';
import { createExpandedEditing, type ExpandedEditing } from './expanded-editing.js';
import { applyInitialVisibility, autoEngineOptions, resolveAutoLayout, resolveInteractions } from './auto-layout-policy.js';
import { createPositionSummary } from './position-summary.js';
import { createAffiliationSummary } from './affiliation-summary.js';
import { createStatusSummary } from './status-summary.js';
import { createEmployeeInfoDialog } from './employee-info-dialog.js';
import type { EmployeeInfoDialog } from './employee-info-dialog.js';
import type { RenderContext } from './render.js';
import type { OrgChartEmployeeClickContext, OrgChartHandle, OrgChartOptions, OrgChartState } from './types.js';
export type { OrgChartLayoutMode, OrgChartEmployeeClickContext, OrgChartHandle, OrgChartOptions, OrgChartOrganizationLinks, OrgChartState } from './types.js';
const owners = new WeakMap<HTMLElement, symbol>();
let instanceSequence = 0;
function freeze<T>(value: T, seen = new WeakSet<object>()): T {
if (value && typeof value === 'object' && !seen.has(value)) {
seen.add(value);
for (const child of Object.values(value)) freeze(child, seen);
Object.freeze(value);
}
return value;
}
function snapshot(input: unknown): unknown {
// JSON objects are captured before any async boundary. A failure is controlled.
return freeze(structuredClone(input));
}
/** Framework-independent facade; construction has no DOM or request side effects. */
export function createOrgChart(options: OrgChartOptions): OrgChartHandle {
return createOrgChartView(options, 'composition');
}
export type CoreView = 'composition' | 'tree' | 'positionSummary' | 'affiliationSummary' | 'statusSummary';
/** One source/validation/lifecycle controller for full and standalone views.
* Summary-only views never load the engine or require its stylesheet. */
export function createOrgChartView(options: OrgChartOptions, view: CoreView): OrgChartHandle {
const needsEngine = view === 'composition' || view === 'tree';
if (!options.dataSource || typeof options.dataSource.load !== 'function') throw new TypeError('An OrgChartDataSource is required');
if (!options.stylesheetUrls?.core || (needsEngine && !options.stylesheetUrls.engine)) throw new TypeError('Provide the required OrgChart stylesheet URLs');
const stylesheetUrls = { ...options.stylesheetUrls };
if (options.displayMode !== undefined && options.displayMode !== 'interactive' && options.displayMode !== 'readOnly') throw new TypeError('displayMode must be "interactive" or "readOnly"');
if (options.layoutMode !== undefined && options.layoutMode !== 'fixed' && options.layoutMode !== 'auto') throw new TypeError('layoutMode must be "fixed" or "auto"');
const layoutMode = options.layoutMode ?? 'fixed';
const expanded = view === 'composition' && layoutMode === 'auto';
if (expanded && options.dragDropEnabled !== undefined && typeof options.dragDropEnabled !== 'boolean') throw new TypeError('dragDropEnabled must be a boolean');
let dragDropEnabled = expanded && (options.dragDropEnabled ?? false);
let expandedEditing: ExpandedEditing | undefined;
let autoLayout = resolveAutoLayout(expanded ? options.autoLayout : undefined);
let interactionOptions = resolveInteractions(expanded ? options.interactions : undefined);
let resetVisibility = false;
let renderedLayoutKey: string | undefined;
const expandedCompanyPreset = expanded ? getCompanyColorPreset(options.companyId ?? 'hanmac') : undefined;
if (expanded && !expandedCompanyPreset) throw new TypeError('companyId must be a registered company preset in Auto mode');
const topNodes = expanded ? freeze(structuredClone(options.companyTopNodes ?? {})) : {};
const employeeConnections = expanded ? freeze(structuredClone(options.employeeConnections ?? {})) : {};
if (Object.values(employeeConnections).some(value => !['top', 'left', 'right'].includes(value))) throw new TypeError('employeeConnections must use top, left or right');
const zoom = { min: options.zoom?.min ?? 0.05, max: options.zoom?.max ?? 3, step: options.zoom?.step ?? 1.2 };
if (expanded && (!Number.isFinite(zoom.min) || !Number.isFinite(zoom.max) || !Number.isFinite(zoom.step) || zoom.min <= 0 || zoom.min > 1 || zoom.max < 1 || zoom.min >= zoom.max || zoom.step <= 1)) throw new TypeError('zoom requires 0 < min <= 1 <= max and step > 1');
if (options.presentationMode !== undefined && options.presentationMode !== 'default' && options.presentationMode !== 'chart-only') throw new TypeError('presentationMode must be "default" or "chart-only"');
if (options.initialView !== undefined && options.initialView !== 'center' && options.initialView !== 'fit') throw new TypeError('initialView must be "center" or "fit"');
if (options.wheelZoom !== undefined && options.wheelZoom !== 'modifier' && options.wheelZoom !== 'direct') throw new TypeError('wheelZoom must be "modifier" or "direct"');
// Fixed owns the approved Workdesk composition; never strip it silently.
if (view === 'composition' && options.presentationMode === 'chart-only' && !expanded) throw new TypeError('presentationMode "chart-only" requires layoutMode "auto"');
const chartOnly = expanded && options.presentationMode === 'chart-only';
const initialView = options.initialView ?? (chartOnly ? 'fit' : 'center');
const wheelZoom = options.wheelZoom ?? (chartOnly ? 'direct' : 'modifier');
const copySelection = (ids?: readonly string[]) => {
if (ids !== undefined && (!Array.isArray(ids) || ids.some(id => typeof id !== 'string' || !getCompanyColorPreset(id)))) throw new TypeError('selectedCompanyIds must contain registered company IDs');
return ids === undefined ? undefined : [...new Set(ids)];
};
let selectedCompanies = expanded ? copySelection(options.selectedCompanyIds) : undefined;
let availableCompanies: string[] = [];
let latestData: OrgChartDataset | undefined;
let moveValidator: ((request: OrgChartMoveRequest) => OrgChartMoveValidation) | undefined;
let moveMessage = '';
let moveStatus = 'idle';
let blockPointerClick = false;
let focusEmployee: string | undefined;
const collapsedOrganizations = new Set<string>();
const initializedOrganizations = new Set<string>();
let expandedViewport: ExpandedViewport | undefined;
let fitNext = true;
let focusOrganization: string | undefined;
const readOnly = view === 'composition' && options.displayMode === 'readOnly';
const organizationLinks: Record<string, string> = Object.create(null);
if (options.onEmployeeClick !== undefined && typeof options.onEmployeeClick !== 'function') throw new TypeError('onEmployeeClick must be a function');
if (options.onMove !== undefined && typeof options.onMove !== 'function') throw new TypeError('onMove must be a function');
let employeeClickCallback = options.onEmployeeClick;
for (const [id, value] of Object.entries(options.organizationLinks ?? {})) {
if (value === undefined || value === '') continue;
if (typeof value !== 'string') throw new TypeError('organizationLinks values must be URL strings');
const href = value.trim();
if (!href) continue;
let url: URL;
try { url = new URL(href, 'https://orgchart.invalid/'); }
catch { throw new TypeError('organizationLinks must contain valid HTTP(S) or relative URLs'); }
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new TypeError('organizationLinks must contain valid HTTP(S) or relative URLs');
organizationLinks[id] = href;
}
let preferredContainer = options.container;
let source: OrgChartDataSource | undefined = options.dataSource;
let policy = options.aggregationPolicy ? freeze(structuredClone(options.aggregationPolicy)) : undefined;
let state: OrgChartState = freeze({ status: 'idle' });
let destroyed = false;
let revision = 0;
let request: AbortController | undefined;
let container: HTMLElement | undefined;
let root: HTMLElement | undefined;
let shell: HTMLElement | undefined;
let status: HTMLElement | undefined;
let content: HTMLElement | undefined;
let viewport: HTMLElement | undefined;
let engine: EngineAdapter | undefined;
let renderContext: RenderContext | undefined;
let Engine: EngineConstructor | undefined;
let styleReady: Promise<readonly ValidationIssue[] | undefined> | undefined;
let cancelStyles: (() => void) | undefined;
let contentGeneration: AbortController | undefined;
let interactions: AbortController | undefined;
let employeeDialog: EmployeeInfoDialog | undefined;
const owner = Symbol('hm-orgchart-owner');
const prefix = `hm-orgchart-${++instanceSequence}`;
const listeners = new Set<(state: OrgChartState) => void>();
if (options.onStateChange) listeners.add(options.onStateChange);
function active(version: number): boolean { return !destroyed && version === revision; }
function notify(next: OrgChartState): void {
state = freeze(next);
const notifiedState = state;
for (const listener of [...listeners]) {
// Reentrant update/reload/destroy may supersede this notification.
if (destroyed || state !== notifiedState) break;
if (!listeners.has(listener)) continue;
try { listener(notifiedState); } catch { /* Consumer callbacks do not break Core lifecycle. */ }
}
}
function assertMounted(): void {
if (destroyed) throw new Error('OrgChart handle is destroyed; create a new handle');
if (!container) throw new Error('Mount OrgChart before update or reload');
}
const moveRequest = (source: HTMLElement, target: HTMLElement): OrgChartMoveRequest => ({
kind: source.matches('.hm-orgchart-employee') ? 'employee' : 'organization',
sourceId: source.dataset.employeeId ?? source.dataset.organizationId!, targetOrganizationId: target.dataset.organizationId!,
});
function validateMove(request: OrgChartMoveRequest): OrgChartMoveValidation {
if (!dragDropEnabled) return { allowed: false, code: 'EDITING_DISABLED', message: 'Drag & Drop이 OFF입니다. 조직 이동을 사용하려면 ON으로 설정하세요.' };
// An explicitly configured company top card also owns a protected reference.
if (request?.kind === 'employee' && Object.values(topNodes).some(top => top.employeeId === request.sourceId)) {
return { allowed: false, code: 'LEADER_PROTECTED', message: '회사 상단에 지정된 직원은 상단 직원 지정 정책을 먼저 변경해야 이동할 수 있습니다.' };
}
return moveValidator?.(request) ?? { allowed: false, code: 'INVALID_DATA', message: '조직 데이터를 불러온 뒤 이동하세요.' };
}
function notifyMove(result: OrgChartMoveResult): void {
try { options.onMove?.(result); } catch { /* Host callbacks never own the Core transaction. */ }
}
async function move(request: OrgChartMoveRequest): Promise<OrgChartMoveResult> {
assertMounted();
if (!expanded) throw new Error('Organization editing is available only in Auto mode');
const policy = validateMove(request);
let result: OrgChartMoveResult = !latestData || state.status !== 'ready'
? { moved: false, code: 'INVALID_DATA', message: '조직 데이터를 불러온 뒤 이동하세요.' }
: !policy.allowed ? { moved: false, code: policy.code, message: policy.message }
: applyOrgChartMove(latestData, request);
if (!result.moved) {
moveMessage = `이동 실패 · ${result.message}`; moveStatus = 'failure';
const feedback = content?.querySelector<HTMLElement>('.hm-orgchart-move-feedback');
if (feedback) { feedback.textContent = moveMessage; feedback.dataset.moveStatus = moveStatus; }
notifyMove(result); return result;
}
const label = request.kind === 'employee' ? result.data.employees.find(item => item.id === request.sourceId)?.name
: result.data.organizations.find(item => item.id === request.sourceId)?.name;
const target = result.data.organizations.find(item => item.id === request.targetOrganizationId)!;
moveMessage = `이동 완료 · ${label} → ${target.name}`; moveStatus = 'success';
focusEmployee = request.kind === 'employee' ? request.sourceId : undefined;
focusOrganization = request.targetOrganizationId; fitNext = false;
const { version } = begin();
try { await apply(result.data, version); }
catch { fail(version, [{ code: 'CORE_RENDER', path: 'core', message: 'Move rerender failed' }]); }
if (!active(version) || state.status !== 'ready') result = { moved: false, code: 'INVALID_DATA', message: '이동 중 다른 데이터 요청이 시작되었거나 화면을 갱신할 수 없습니다.' };
if (active(version)) notifyMove(result);
return result;
}
function begin(): { version: number; signal: AbortSignal } {
request?.abort();
const Abort = container?.ownerDocument.defaultView?.AbortController ?? AbortController;
request = new Abort();
const version = ++revision;
status!.textContent = '조직도를 불러오는 중입니다.';
shell!.setAttribute('aria-busy', 'true');
const signal = request.signal;
notify({ status: 'loading' });
return { version, signal };
}
function releaseView(): void {
employeeDialog?.close();
contentGeneration?.abort();
contentGeneration = undefined;
expandedEditing = undefined;
engine?.dispose();
engine = undefined;
renderContext = undefined;
viewport = undefined;
expandedViewport = undefined;
latestData = undefined;
moveValidator = undefined;
content?.replaceChildren();
}
function activateEmployee(event: MouseEvent | KeyboardEvent): void {
if (event.defaultPrevented || !renderContext || !content) return;
const Element = content.ownerDocument.defaultView?.Element;
if (!Element || !(event.target instanceof Element)) return;
// Links, collapse buttons and any future nested controls own their events.
if (event.target.closest('a, button, input, select, textarea')) return;
const card = event.target.closest<HTMLElement>('.hm-orgchart-employee');
if (!card || !content.contains(card)) return;
const employee = renderContext.data.employees.find(item => item.id === card.dataset.employeeId);
if (!employee) return;
event.preventDefault();
event.stopPropagation();
card.focus({ preventScroll: true });
// A name tooltip can be open from pointer hover. Close it before opening
// information UI; the existing hover/focus tooltip behavior stays intact.
for (const tip of shell?.querySelectorAll<HTMLElement>('.hm-orgchart-person-name-tooltip') ?? []) tip.hidden = true;
for (const name of content.querySelectorAll<HTMLElement>('[data-tooltip-open="true"]')) name.dataset.tooltipOpen = 'false';
const context: OrgChartEmployeeClickContext = {
organization: renderContext.data.organizations.find(item => item.id === employee.organizationId),
affiliation: renderContext.data.affiliations.find(item => item.id === employee.affiliationId),
trigger: card,
originalEvent: event,
};
if (employeeClickCallback) {
try { employeeClickCallback(employee, context); }
catch { /* Consumer callbacks do not interrupt Core event/lifecycle ownership. */ }
} else employeeDialog?.show(employee, context);
}
function prepareEmployeeCards(element: HTMLElement): void {
for (const card of element.querySelectorAll<HTMLElement>('.hm-orgchart-employee')) {
card.tabIndex = 0;
card.setAttribute('role', 'button');
if (!employeeClickCallback) card.setAttribute('aria-haspopup', 'dialog');
else card.removeAttribute('aria-haspopup');
const name = card.querySelector('.hm-orgchart-person-name')?.textContent ?? '';
const position = card.querySelector('.hm-orgchart-person-role')?.textContent ?? '';
card.setAttribute('aria-label', `${name} ${position} 직원 정보 보기`);
}
}
function fail(version: number, issues: readonly ValidationIssue[]): void {
if (!active(version)) return;
releaseView();
shell!.setAttribute('aria-busy', 'false');
status!.textContent = '조직도를 표시할 수 없습니다.';
const document = content!.ownerDocument;
const list = document.createElement('ul');
list.className = 'hm-orgchart-issues';
for (const issue of issues) {
const row = document.createElement('li');
row.textContent = `${issue.code} · ${issue.path}: ${issue.message}`;
list.append(row);
}
content!.append(list);
notify({ status: 'error', issues });
}
async function apply(input: unknown, version: number): Promise<void> {
if (!active(version)) return;
let captured: unknown;
try { captured = snapshot(input); }
catch {
fail(version, [{ code: 'INPUT_SNAPSHOT', path: '$', message: 'Input must be a cloneable JSON dataset' }]);
return;
}
const validation = validateOrgChartDataset(captured);
if (!validation.valid) { fail(version, validation.issues); return; }
const data = freeze(validation.data);
const normalized = normalizeOrgChartDataset(data);
const companyState = expanded ? resolveExpandedCompanies(data, normalized.roots, expandedCompanyPreset!.companyId) : undefined;
if (companyState) {
const issues = [...companyState.issues, ...validateTopNodes(data, normalized.roots, companyState.companies, topNodes)];
if (issues.length) { fail(version, issues); return; }
availableCompanies = companyState.companyIds;
applyInitialVisibility(data.organizations, collapsedOrganizations, initializedOrganizations, autoLayout.initialVisibleDepth, resetVisibility);
resetVisibility = false;
latestData = data;
moveValidator = createOrgChartMoveValidator(data);
}
const summary = freeze(calculateSummary(data, policy));
const topLeaderIds = new Set(view === 'composition' ? normalized.roots.filter(node => node.organization.type === 'root' && node.organization.leaderId).map(node => node.organization.leaderId!) : []);
const nextContext: RenderContext = { data, summary, orderedOrganizations: [], employeesByOrganization: normalized.employeesByOrganization, topLeaderIds, readOnly: readOnly || expanded, organizationLinks, expandedCompanyPreset,
organizationCompanies: companyState?.companies, collapsedOrganizations: expanded ? collapsedOrganizations : undefined,
companyTopNodes: topNodes, employeeConnections, autoLayout: expanded ? autoLayout : undefined, interactions: expanded ? interactionOptions : undefined, toggleExpanded: id => {
if (destroyed || !latestData || !interactionOptions.collapse) return;
if (collapsedOrganizations.has(id)) collapsedOrganizations.delete(id); else collapsedOrganizations.add(id);
focusOrganization = id; fitNext = false;
const { version } = begin();
void apply(latestData, version).catch(() => fail(version, [{ code: 'CORE_RENDER', path: 'core', message: 'Expanded rerender failed.' }]));
},
};
// Composition shows the root leader as the standalone top card, so the root's direct
// organizations become the horizontal engine roots (data and parentId stay untouched).
// A root with other direct employees keeps its node so those employees stay visible.
const flattenRoot = (node: typeof normalized.roots[number]) => view === 'composition' && node.organization.type === 'root' && !!node.organization.leaderId
&& (normalized.employeesByOrganization.get(node.organization.id) ?? []).every(employee => employee.id === node.organization.leaderId);
const flattened = normalized.roots.some(flattenRoot);
const roots = expanded ? normalized.roots.filter(node => selectedCompanies === undefined || selectedCompanies.includes(companyState!.companies.get(node.organization.id)!))
: flattened ? normalized.roots.flatMap(node => flattenRoot(node) ? node.children : [node]) : normalized.roots;
const tree = engineTree(roots, prefix, nextContext, !expanded && flattened ? 1 : 0);
const styleIssues = await styleReady;
if (!active(version)) return;
if (styleIssues) { fail(version, styleIssues); return; }
const LoadedEngine = needsEngine ? Engine ?? await loadEngine() : undefined;
// Last-started operation wins even if an upstream engine import was pending.
if (!active(version)) return;
if (LoadedEngine) Engine = LoadedEngine;
const document = content!.ownerDocument;
const previousViewport = expandedViewport?.state();
const previousFitScale = expandedViewport?.fitScale();
const previousManualPosition = expandedViewport?.hasManualPosition();
employeeDialog?.close();
contentGeneration?.abort();
const Abort = document.defaultView?.AbortController ?? AbortController;
contentGeneration = new Abort();
renderContext = nextContext;
if (needsEngine && !viewport) {
viewport = createOrgChartTree(document);
content!.append(viewport);
}
const activeElement = root!.shadowRoot?.activeElement;
if (needsEngine) {
const layoutKey = expanded ? `${autoLayout.type}:${autoLayout.hybridStartDepth}` : 'fixed';
// Upstream init merges old options. Rebuild only the engine when changing
// direction/vertical policy so obsolete verticalLevel cannot survive LR.
if (engine && renderedLayoutKey !== layoutKey) { engine.dispose(); engine = undefined; }
if (engine) engine.update(tree);
else engine = createEngineAdapter(Engine!, viewport!, tree, (node, value, signal) => {
if (renderContext) decorateNode(node, value, renderContext, signal, target => engine?.toggle(target));
}, expanded ? autoEngineOptions(autoLayout) : undefined, expanded ? {
canDrop: (source, target) => validateMove(moveRequest(source, target)).allowed,
onDrop: (source, target) => { void move(moveRequest(source, target)); },
} : undefined);
renderedLayoutKey = layoutKey;
}
// If regeneration removed keyboard focus, offer the stable viewport next.
const nextContent = view === 'composition' ? expanded
? createExpandedOrgChartContent(document, nextContext, viewport!, contentGeneration.signal, () => expandedViewport?.layoutReady(), { chartOnly, wheelZoom })
: createOrgChartContent(document, nextContext, viewport!, contentGeneration.signal)
: view === 'tree' ? viewport!
: view === 'positionSummary' ? createPositionSummary(document, nextContext)
: view === 'affiliationSummary' ? createAffiliationSummary(document, nextContext)
: createStatusSummary(document, nextContext, contentGeneration.signal);
if (expanded) {
// Chart Only keeps the same viewport controller without its visible toolbar.
const toolbar = chartOnly ? undefined : document.createElement('div');
if (toolbar) {
toolbar.className = 'hm-orgchart-expanded-toolbar';
nextContent.insertBefore(toolbar, viewport!);
}
// A fitted replacement starts from the new forest, not the previous Pan/Zoom.
const resetView = fitNext && initialView === 'fit';
expandedViewport = createExpandedViewport(viewport!, toolbar, contentGeneration.signal,
{ ...zoom, zoomEnabled: interactionOptions.zoom, panEnabled: interactionOptions.pan, wheelZoom, fitFloor: initialView === 'fit', initialFitScale: resetView ? undefined : previousFitScale },
resetView ? undefined : previousViewport, fitNext ? initialView : undefined, !resetView && !!previousManualPosition);
fitNext = false;
const feedback = document.createElement('p');
feedback.className = 'hm-orgchart-move-feedback'; feedback.id = `${prefix}-move-feedback`;
feedback.setAttribute('role', 'status'); feedback.setAttribute('aria-live', 'polite'); feedback.setAttribute('aria-atomic', 'true');
feedback.dataset.moveStatus = moveStatus;
feedback.textContent = moveMessage || (dragDropEnabled
? 'Drag & Drop ON · 카드/제목: 이동 · Alt+M: 키보드 이동 · 빈 공간: Pan'
: 'Drag & Drop OFF · 직원 정보·그룹 접기·빈 공간 Pan 사용 가능');
nextContent.insertBefore(feedback, viewport!);
viewport!.dataset.dragDropEnabled = String(dragDropEnabled);
expandedEditing = createExpandedEditing(viewport!, feedback, contentGeneration.signal, engine!, validateMove,
request => { void move(request); }, () => { blockPointerClick = true; }, dragDropEnabled);
for (const source of nextContent.querySelectorAll<HTMLElement>('.hm-orgchart-employee, .hm-orgchart-expanded-toggle')) {
if (dragDropEnabled) source.setAttribute('aria-keyshortcuts', 'Alt+M');
else source.removeAttribute('aria-keyshortcuts');
source.setAttribute('aria-describedby', feedback.id);
}
if (toolbar) {
const selection = document.createElement('p');
selection.className = 'hm-orgchart-expanded-selection';
selection.setAttribute('aria-live', 'polite');
const selected = new Set(roots.map(root => companyState!.companies.get(root.organization.id)!));
const employeeCount = data.employees.filter(employee => selected.has(companyState!.companies.get(employee.organizationId)!)).length;
selection.textContent = `선택 ${selected.size}개 회사 · ${employeeCount}명 / 원본 ${data.employees.length}명`;
nextContent.insertBefore(selection, toolbar);
}
if (!roots.length) {
const empty = document.createElement('p');
empty.className = 'hm-orgchart-expanded-empty';
empty.textContent = '선택된 회사가 없습니다. 표시할 회사를 선택해 주세요.';
viewport!.append(empty);
} else viewport!.querySelector('.hm-orgchart-expanded-empty')?.remove();
}
prepareEmployeeCards(nextContent);
content!.replaceChildren(nextContent);
const restoreFocus = !!activeElement && (activeElement === viewport || !activeElement.isConnected);
const focusTarget = (focusEmployee ? [...content!.querySelectorAll<HTMLElement>('.hm-orgchart-employee')].find(card => card.dataset.employeeId === focusEmployee) : undefined)
?? (focusOrganization ? [...content!.querySelectorAll<HTMLButtonElement>('.hm-orgchart-expanded-toggle')].find(button => button.dataset.organizationId === focusOrganization) : undefined);
focusEmployee = undefined;
focusOrganization = undefined;
if (focusTarget) focusTarget.focus({ preventScroll: true });
else if (restoreFocus) (expanded ? viewport! : readOnly ? shell! : viewport ?? content!.querySelector<HTMLElement>('.hm-orgchart-summary-scroll') ?? shell!).focus({ preventScroll: true });
shell!.setAttribute('aria-busy', 'false');
status!.textContent = '';
if (!active(version)) return;
notify({ status: 'ready', dataVersion: data.metadata.dataVersion, summary, aggregation: summary.aggregation });
}
async function reload(nextSource?: OrgChartDataSource): Promise<void> {
assertMounted();
if (expanded) { fitNext = true; moveMessage = ''; moveStatus = 'idle'; }
if (nextSource) {
if (typeof nextSource.load !== 'function') throw new TypeError('An OrgChartDataSource is required');
source = nextSource;
}
const { version, signal } = begin();
if (!active(version)) return;
let input: unknown;
try {
input = await source!.load({ signal });
} catch (error) {
if (!active(version) || signal.aborted) return;
const sourceCode = error && typeof error === 'object' && 'code' in error && ['SOURCE_LOAD', 'SOURCE_PARSE', 'SOURCE_MAP', 'SOURCE_URL', 'SOURCE_SNAPSHOT'].includes(String(error.code)) ? String(error.code) : 'SOURCE_LOAD';
fail(version, [{ code: sourceCode, path: 'dataSource', message: 'Data source or chart rendering failed; verify the source and CSS configuration' }]);
return;
}
try { if (active(version)) await apply(input, version); }
catch { fail(version, [{ code: 'CORE_RENDER', path: 'core', message: 'Chart rendering failed' }]); }
}
return {
async mount(nextContainer = preferredContainer) {
if (destroyed) throw new Error('OrgChart handle is destroyed; create a new handle');
if (container) throw new Error('OrgChart is already mounted');
const Element = nextContainer?.ownerDocument.defaultView?.HTMLElement;
if (!Element || !(nextContainer instanceof Element) || !nextContainer.isConnected) throw new TypeError('Mount requires a connected HTMLElement');
if (owners.has(nextContainer)) throw new Error('Another OrgChart instance owns this container');
container = nextContainer;
owners.set(container, owner);
const document = container.ownerDocument;
root = document.createElement('div');
root.className = 'hm-orgchart-host';
root.dataset.orgchartInstance = prefix;
if (chartOnly) root.dataset.presentationMode = 'chart-only';
const shadow = root.attachShadow({ mode: 'open' });
const styleDisposers: (() => void)[] = [];
const styles = [...(needsEngine ? [{ key: 'engine', href: stylesheetUrls.engine }] : []), { key: 'core', href: stylesheetUrls.core }];
styleReady = Promise.all(styles.map(({ key, href }) => new Promise<ValidationIssue | undefined>(resolve => {
const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = href;
let settled = false;
const finish = (issue?: ValidationIssue) => {
if (settled) return;
settled = true;
link.removeEventListener('load', loaded); link.removeEventListener('error', failed);
resolve(issue);
};
const loaded = () => finish();
const failed = () => finish({ code: 'CORE_STYLE', path: `stylesheetUrls.${key}`, message: 'Required OrgChart stylesheet failed to load' });
link.addEventListener('load', loaded); link.addEventListener('error', failed);
styleDisposers.push(() => finish());
shadow.append(link);
}))).then(results => {
const issues = results.filter((issue): issue is ValidationIssue => !!issue);
return issues.length ? issues : undefined;
});
cancelStyles = () => { for (const dispose of styleDisposers) dispose(); };
shell = document.createElement('section');
shell.className = 'hm-orgchart-shell';
shell.dataset.layoutMode = view === 'composition' ? layoutMode : 'fixed';
if (chartOnly) shell.dataset.presentationMode = 'chart-only';
// The Core's Korean labels must shape identically in ko HTML and en Storybook hosts.
shell.lang = 'ko';
shell.tabIndex = -1;
shell.setAttribute('aria-label', '조직도 콘텐츠');
status = document.createElement('p'); status.className = 'hm-orgchart-state'; status.setAttribute('role', 'status'); status.setAttribute('aria-live', 'polite');
content = document.createElement('div'); content.className = 'hm-orgchart-content';
shell.append(status, content); shadow.append(shell); container.append(root);
const Abort = document.defaultView?.AbortController ?? AbortController;
interactions = new Abort();
employeeDialog = createEmployeeInfoDialog(shell, interactions.signal, prefix, readOnly);
if (expanded) {
// This capture guard survives the generation replaced by a successful drop.
shell.addEventListener('pointerdown', () => { blockPointerClick = false; }, { capture: true, signal: interactions.signal });
shell.addEventListener('click', event => {
if (!blockPointerClick || event.detail === 0) return;
blockPointerClick = false; event.preventDefault(); event.stopImmediatePropagation();
}, { capture: true, signal: interactions.signal });
}
content.addEventListener('click', activateEmployee, { signal: interactions.signal });
content.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ' ') activateEmployee(event);
}, { signal: interactions.signal });
await reload();
},
async update(input) {
assertMounted();
if (expanded) fitNext = true;
// Capture before begin() invokes callbacks that can mutate consumer input.
let captured: unknown;
try { captured = snapshot(input); }
catch {
const { version } = begin();
fail(version, [{ code: 'INPUT_SNAPSHOT', path: '$', message: 'Input must be a cloneable JSON dataset' }]);
return;
}
const { version } = begin();
if (!active(version)) return;
try { await apply(captured, version); }
catch { fail(version, [{ code: 'CORE_RENDER', path: 'core', message: 'Chart rendering failed' }]); }
},
reload,
destroy() {
if (destroyed) return;
destroyed = true;
++revision;
request?.abort(); request = undefined;
cancelStyles?.(); cancelStyles = undefined; styleReady = undefined;
interactions?.abort(); interactions = undefined;
employeeDialog = undefined;
releaseView();
root?.remove();
if (container && owners.get(container) === owner) owners.delete(container);
container = undefined; preferredContainer = undefined;
root = undefined; shell = undefined; status = undefined; content = undefined;
source = undefined; policy = undefined; Engine = undefined;
listeners.clear();
state = freeze({ status: 'destroyed' });
},
subscribe(listener) {
if (destroyed) throw new Error('OrgChart handle is destroyed; create a new handle');
if (typeof listener !== 'function') throw new TypeError('Listener must be a function');
listeners.add(listener);
return () => { listeners.delete(listener); };
},
getState() { return state; },
async setSelectedCompanies(ids) {
assertMounted();
if (!expanded) throw new Error('Company selection is available only in Auto mode');
selectedCompanies = copySelection(ids); fitNext = true;
if (latestData) { const { version } = begin(); await apply(latestData, version); }
},
getSelectedCompanies() { return Object.freeze([...(selectedCompanies ?? availableCompanies)]); },
async setAutoLayout(patch) {
assertMounted();
if (!expanded) throw new Error('Auto layout configuration is available only in Auto mode');
const next = resolveAutoLayout({ ...autoLayout, ...patch });
resetVisibility = resetVisibility || next.initialVisibleDepth !== autoLayout.initialVisibleDepth;
autoLayout = next; fitNext = true;
if (latestData) { const { version } = begin(); await apply(latestData, version); }
},
getAutoLayout() { return autoLayout; },
setEmployeeClickHandler(handler) {
assertMounted();
if (!expanded) throw new Error('Popup ownership changes are available only in Auto mode');
if (handler !== undefined && typeof handler !== 'function') throw new TypeError('Employee click handler must be a function or undefined');
employeeDialog?.close();
employeeClickCallback = handler;
if (content) prepareEmployeeCards(content);
},
async setInteractions(patch) {
assertMounted();
if (!expanded) throw new Error('Interaction configuration is available only in Auto mode');
interactionOptions = resolveInteractions({ ...interactionOptions, ...patch });
fitNext = false;
if (latestData) { const { version } = begin(); await apply(latestData, version); }
},
getInteractions() { return interactionOptions; },
setDragDropEnabled(enabled) {
assertMounted();
if (!expanded) throw new Error('Drag & Drop configuration is available only in Auto mode');
if (typeof enabled !== 'boolean') throw new TypeError('dragDropEnabled must be a boolean');
if (enabled === dragDropEnabled) return;
dragDropEnabled = enabled;
expandedEditing?.setEnabled(enabled);
if (viewport) viewport.dataset.dragDropEnabled = String(enabled);
const feedback = content?.querySelector<HTMLElement>('.hm-orgchart-move-feedback');
const cancelled = feedback?.dataset.moveStatus === 'cancelled';
moveMessage = ''; moveStatus = cancelled ? 'cancelled' : 'idle';
if (feedback) {
feedback.dataset.moveStatus = moveStatus;
feedback.textContent = (cancelled ? '이동을 취소했습니다. ' : '') + (enabled
? 'Drag & Drop ON · 카드/제목: 이동 · Alt+M: 키보드 이동 · 빈 공간: Pan'
: 'Drag & Drop OFF · 직원 정보·그룹 접기·빈 공간 Pan 사용 가능');
}
for (const source of content?.querySelectorAll<HTMLElement>('.hm-orgchart-employee, .hm-orgchart-expanded-toggle') ?? []) {
if (enabled) source.setAttribute('aria-keyshortcuts', 'Alt+M');
else source.removeAttribute('aria-keyshortcuts');
}
},
getDragDropEnabled() { return dragDropEnabled; },
move,
getData() { return latestData; },
zoomTo(scale) { if (interactionOptions.zoom) expandedViewport?.zoomTo(scale); },
panBy(x, y) { if (interactionOptions.pan) expandedViewport?.panBy(x, y); },
fitToView() { expandedViewport?.fit(); },
getViewportState() { return expandedViewport?.state(); },
};
}