feat(org-chart): finalize chart features and AI generation rules
This commit is contained in:
1 parent
7614507583
commit
d0828a7ff4
80 files changed
+8152
-221
No files matched your search
@@ -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(); },
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user