feat(org-chart): add core cards and fixed-layout integration
This commit is contained in:
1 parent
d18e3c3380
commit
61c6f8d1f1
161 files changed
+13044
-1
No files matched your search
@@ -0,0 +1,366 @@
|
||||
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 { 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 { 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"');
|
||||
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;
|
||||
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 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, organizationLinks };
|
||||
// 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 tree = engineTree(flattened ? normalized.roots.flatMap(node => flattenRoot(node) ? node.children : [node]) : normalized.roots, prefix, nextContext, 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;
|
||||
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' ? createOrgChartContent(document, nextContext, viewport!, contentGeneration.signal)
|
||||
: view === 'tree' ? viewport!
|
||||
: view === 'positionSummary' ? createPositionSummary(document, nextContext)
|
||||
: view === 'affiliationSummary' ? createAffiliationSummary(document, nextContext)
|
||||
: createStatusSummary(document, nextContext, contentGeneration.signal);
|
||||
prepareEmployeeCards(nextContent);
|
||||
content!.replaceChildren(nextContent);
|
||||
const restoreFocus = !!activeElement && (activeElement === viewport || !activeElement.isConnected);
|
||||
if (restoreFocus) (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 (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';
|
||||
// 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; },
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user