Integrate P5-1B through P5-4 shared Core layout, collapse, zoom/pan/fit, company roots and keyboard accessibility. Include React/HTML examples, virtual 20/100/300 employee fixtures, focused checks and the additive data contract.
453 lines
25 KiB
TypeScript
453 lines
25 KiB
TypeScript
import { calculateSummary, normalizeOrgChartDataset, validateOrgChartDataset } from '../data/index.js';
|
|
import type { 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 { 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';
|
|
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');
|
|
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: unknown;
|
|
const collapsedOrganizations = 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');
|
|
const 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');
|
|
}
|
|
|
|
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;
|
|
engine?.dispose();
|
|
engine = undefined;
|
|
renderContext = undefined;
|
|
viewport = undefined;
|
|
expandedViewport = undefined;
|
|
latestData = 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');
|
|
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;
|
|
const validIds = new Set(data.organizations.map(org => org.id));
|
|
for (const id of collapsedOrganizations) if (!validIds.has(id)) collapsedOrganizations.delete(id);
|
|
latestData = 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, toggleExpanded: id => {
|
|
if (destroyed || !latestData) 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();
|
|
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) {
|
|
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));
|
|
});
|
|
}
|
|
// If regeneration removed keyboard focus, offer the stable viewport next.
|
|
const nextContent = view === 'composition' ? expanded
|
|
? createExpandedOrgChartContent(document, nextContext, viewport!, contentGeneration.signal, () => expandedViewport?.layoutReady())
|
|
: 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) {
|
|
const toolbar = document.createElement('div');
|
|
toolbar.className = 'hm-orgchart-expanded-toolbar';
|
|
nextContent.insertBefore(toolbar, viewport!);
|
|
expandedViewport = createExpandedViewport(viewport!, toolbar, contentGeneration.signal, zoom, previousViewport, fitNext);
|
|
fitNext = false;
|
|
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 = focusOrganization ? [...content!.querySelectorAll<HTMLButtonElement>('.hm-orgchart-expanded-toggle')].find(button => button.dataset.organizationId === focusOrganization) : 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;
|
|
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;
|
|
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';
|
|
// 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);
|
|
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();
|
|
// 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)]); },
|
|
zoomTo(scale) { expandedViewport?.zoomTo(scale); },
|
|
panBy(x, y) { expandedViewport?.panBy(x, y); },
|
|
fitToView() { expandedViewport?.fit(); },
|
|
getViewportState() { return expandedViewport?.state(); },
|
|
};
|
|
}
|