feat(org-chart): finalize chart features and AI generation rules

This commit is contained in:
shkim4 committed 2026-10-13 14:39:42 +09:00
1 parent 7614507583
commit d0828a7ff4
80 files changed
+8152 -221

No files matched your search

+164 -14
View File
@@ -1,5 +1,5 @@
import { calculateSummary, normalizeOrgChartDataset, validateOrgChartDataset } from '../data/index.js';
import type { OrgChartDataSource, ValidationIssue } from '../data/index.js';
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';
@@ -9,6 +9,8 @@ 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';
@@ -54,6 +56,13 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
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 ?? {})) : {};
@@ -67,15 +76,22 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
};
let selectedCompanies = expanded ? copySelection(options.selectedCompanyIds) : undefined;
let availableCompanies: string[] = [];
let latestData: unknown;
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');
const employeeClickCallback = options.onEmployeeClick;
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');
@@ -131,6 +147,49 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
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;
@@ -147,12 +206,14 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
employeeDialog?.close();
contentGeneration?.abort();
contentGeneration = undefined;
expandedEditing = undefined;
engine?.dispose();
engine = undefined;
renderContext = undefined;
viewport = undefined;
expandedViewport = undefined;
latestData = undefined;
moveValidator = undefined;
content?.replaceChildren();
}
@@ -190,6 +251,7 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
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} 직원 정보 보기`);
@@ -230,16 +292,17 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
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);
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, toggleExpanded: id => {
if (destroyed || !latestData) return;
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();
@@ -264,6 +327,7 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
if (LoadedEngine) Engine = LoadedEngine;
const document = content!.ownerDocument;
const previousViewport = expandedViewport?.state();
const previousManualPosition = expandedViewport?.hasManualPosition();
employeeDialog?.close();
contentGeneration?.abort();
const Abort = document.defaultView?.AbortController ?? AbortController;
@@ -275,10 +339,18 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
}
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
@@ -292,8 +364,25 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
const toolbar = document.createElement('div');
toolbar.className = 'hm-orgchart-expanded-toolbar';
nextContent.insertBefore(toolbar, viewport!);
expandedViewport = createExpandedViewport(viewport!, toolbar, contentGeneration.signal, zoom, previousViewport, fitNext);
expandedViewport = createExpandedViewport(viewport!, toolbar, contentGeneration.signal,
{ ...zoom, zoomEnabled: interactionOptions.zoom, panEnabled: interactionOptions.pan }, previousViewport, fitNext, 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);
}
const selection = document.createElement('p');
selection.className = 'hm-orgchart-expanded-selection';
selection.setAttribute('aria-live', 'polite');
@@ -311,7 +400,9 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
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;
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 });
@@ -323,7 +414,7 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
async function reload(nextSource?: OrgChartDataSource): Promise<void> {
assertMounted();
if (expanded) fitNext = true;
if (expanded) { fitNext = true; moveMessage = ''; moveStatus = 'idle'; }
if (nextSource) {
if (typeof nextSource.load !== 'function') throw new TypeError('An OrgChartDataSource is required');
source = nextSource;
@@ -391,6 +482,14 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
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);
@@ -399,6 +498,7 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
},
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); }
@@ -444,8 +544,58 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
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); },
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(); },
};