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(); let instanceSequence = 0; function freeze(value: T, seen = new WeakSet()): 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(); const initializedOrganizations = new Set(); let expandedViewport: ExpandedViewport | undefined; let fitNext = true; let focusOrganization: string | undefined; const readOnly = view === 'composition' && options.displayMode === 'readOnly'; const organizationLinks: Record = 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 | 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 { 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('.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('.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('.hm-orgchart-person-name-tooltip') ?? []) tip.hidden = true; for (const name of content.querySelectorAll('[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('.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 { 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('.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('.hm-orgchart-employee')].find(card => card.dataset.employeeId === focusEmployee) : undefined) ?? (focusOrganization ? [...content!.querySelectorAll('.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('.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 { 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(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('.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('.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(); }, }; }