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

@@ -0,0 +1,88 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyInitialVisibility, autoEngineOptions, resolveAutoLayout, resolveInteractions } from './auto-layout-policy.js';
import { createOrgChart } from './index.js';
import { engineTree, type RenderContext } from './render.js';
import type { Organization, OrganizationTreeNode } from '../data/index.js';
const organization = (id: string, parentId: string | null): Organization => ({ id, parentId, name: id, type: parentId ? 'team' : 'root', leaderId: null, displayOrder: 0 });
const organizations = [organization('root', null), organization('a', 'root'), organization('b', 'root'), organization('a1', 'a'), organization('a2', 'a1')];
test('each independent layout policy selects compatible installed engine options', () => {
assert.deepEqual(autoEngineOptions(resolveAutoLayout()), { direction: 't2b', visibleLevel: 999 });
assert.deepEqual(autoEngineOptions(resolveAutoLayout({ type: 'lr', initialVisibleDepth: 1 })), { direction: 'l2r', visibleLevel: 999 });
for (const depth of [2, 3, 4] as const) assert.deepEqual(autoEngineOptions(resolveAutoLayout({ type: 'hybrid', hybridStartDepth: depth })), { direction: 't2b', visibleLevel: 999, verticalLevel: depth });
});
test('collapse, Zoom and Pan have independent validated defaults', () => {
assert.deepEqual(resolveInteractions(), { collapse: true, zoom: true, pan: true });
assert.deepEqual(resolveInteractions({ collapse: false }), { collapse: false, zoom: true, pan: true });
assert.deepEqual(resolveInteractions({ zoom: false }), { collapse: true, zoom: false, pan: true });
assert.deepEqual(resolveInteractions({ pan: false }), { collapse: true, zoom: true, pan: false });
assert.throws(() => resolveInteractions({ zoom: 0 as unknown as boolean }), /interactions.zoom must be boolean/);
});
test('incompatible per-group/global/directional combinations fail explicitly', () => {
assert.throws(() => resolveAutoLayout({ type: 'lr', hybridOrganizationIds: ['a'] }), /supported only with TB/);
assert.throws(() => resolveAutoLayout({ type: 'hybrid', hybridOrganizationIds: ['a'] }), /supported only with TB/);
assert.throws(() => resolveAutoLayout({ initialVisibleDepth: 0 as 1 }), /initialVisibleDepth/);
assert.throws(() => resolveAutoLayout({ hybridStartDepth: 5 as 2 }), /hybridStartDepth/);
assert.throws(() => resolveAutoLayout({ hybridOrganizationIds: [''] }), /nonempty/);
const input = ['a', 'a'];
const resolved = resolveAutoLayout({ hybridOrganizationIds: input });
input.push('b');
assert.deepEqual(resolved.hybridOrganizationIds, ['a']);
assert.ok(Object.isFrozen(resolved.hybridOrganizationIds));
});
test('initial depth is one-based and user expansion survives subsequent renders and new data', () => {
const collapsed = new Set<string>(), initialized = new Set<string>();
applyInitialVisibility(organizations, collapsed, initialized, 2);
assert.deepEqual([...collapsed], ['a', 'b', 'a1', 'a2']);
collapsed.delete('a');
applyInitialVisibility([...organizations, organization('b1', 'b')], collapsed, initialized, 2);
assert.equal(collapsed.has('a'), false);
assert.equal(collapsed.has('b1'), true);
applyInitialVisibility(organizations, collapsed, initialized, 'all', true);
assert.equal(collapsed.size, 0);
collapsed.add('a1');
applyInitialVisibility(organizations.filter(item => item.id !== 'a2'), collapsed, initialized, 'all');
assert.equal(collapsed.has('a1'), true);
assert.equal(initialized.has('a2'), false);
});
test('Depth 1 projection keeps independent company roots while collapsed descendants stay out of DOM', () => {
const roots: OrganizationTreeNode[] = [{ organization: organizations[0]!, children: [{ organization: organizations[1]!, children: [{ organization: organizations[3]!, children: [] }] }] }];
const collapsed = new Set<string>(), initialized = new Set<string>();
applyInitialVisibility(organizations, collapsed, initialized, 1);
const context = { orderedOrganizations: [], readOnly: true, collapsedOrganizations: collapsed, autoLayout: resolveAutoLayout() } as unknown as RenderContext;
const tree = engineTree(roots, 'initial', context);
assert.equal(tree.length, 1);
assert.equal(tree[0]!.children, undefined);
assert.deepEqual(context.orderedOrganizations.map(item => item.id), ['root']);
});
test('per-group Hybrid projection marks only the selected parent and leaves source immutable', () => {
const roots: OrganizationTreeNode[] = [{ organization: organizations[0]!, children: [{ organization: organizations[1]!, children: [{ organization: organizations[3]!, children: [] }] }, { organization: organizations[2]!, children: [] }] }];
const before = structuredClone(roots);
const context = { orderedOrganizations: [], readOnly: true, autoLayout: resolveAutoLayout({ hybridOrganizationIds: ['a'] }) } as unknown as RenderContext;
const tree = engineTree(roots, 'groups', context);
assert.equal(tree[0]!.hybrid, undefined);
assert.equal(tree[0]!.level, 1);
assert.equal(tree[0]!.children![0]!.hybrid, true);
assert.equal(tree[0]!.children![0]!.level, 2);
assert.equal(tree[0]!.children![0]!.children![0]!.level, 3);
assert.equal(tree[0]!.children![1]!.hybrid, undefined);
assert.deepEqual(roots, before);
});
test('Fixed ignores Auto configuration and facade defaults keep existing TB fully expanded', () => {
const options = { dataSource: { load: async () => ({}) }, stylesheetUrls: { engine: '/engine.css', core: '/style.css' } };
const fixed = createOrgChart({ ...options, autoLayout: { type: 'invalid' as 'tb' } });
assert.equal(fixed.getAutoLayout().type, 'tb');
fixed.destroy();
const expanded = createOrgChart({ ...options, layoutMode: 'auto' });
assert.equal(expanded.getAutoLayout().initialVisibleDepth, 'all');
assert.equal(expanded.getAutoLayout().type, 'tb');
expanded.destroy();
});
@@ -0,0 +1,59 @@
import type { Organization } from '../data/index.js';
import type { OrgChartAutoLayoutOptions, OrgChartAutoLayoutType, OrgChartInteractionOptions } from './types.js';
export function resolveInteractions(options: OrgChartInteractionOptions = {}): Readonly<Required<OrgChartInteractionOptions>> {
for (const key of ['collapse', 'zoom', 'pan'] as const) {
if (options[key] !== undefined && typeof options[key] !== 'boolean') throw new TypeError(`interactions.${key} must be boolean`);
}
return Object.freeze({ collapse: options.collapse ?? true, zoom: options.zoom ?? true, pan: options.pan ?? true });
}
export interface ResolvedAutoLayout {
readonly type: OrgChartAutoLayoutType;
readonly hybridStartDepth: 2 | 3 | 4;
readonly initialVisibleDepth: 1 | 2 | 3 | 'all';
readonly hybridOrganizationIds: readonly string[];
}
/** Match installed dabeng 6's mutually exclusive directional/vertical policies. */
export function resolveAutoLayout(options: OrgChartAutoLayoutOptions = {}): ResolvedAutoLayout {
const type = options.type ?? 'tb';
const hybridStartDepth = options.hybridStartDepth ?? 3;
const initialVisibleDepth = options.initialVisibleDepth ?? 'all';
if (!['tb', 'lr', 'hybrid'].includes(type)) throw new TypeError('autoLayout.type must be tb, lr or hybrid');
if (![2, 3, 4].includes(hybridStartDepth)) throw new TypeError('autoLayout.hybridStartDepth must be 2, 3 or 4');
if (![1, 2, 3, 'all'].includes(initialVisibleDepth)) throw new TypeError('autoLayout.initialVisibleDepth must be 1, 2, 3 or all');
if (options.hybridOrganizationIds !== undefined && (!Array.isArray(options.hybridOrganizationIds) || options.hybridOrganizationIds.some(id => typeof id !== 'string' || !id.trim()))) throw new TypeError('autoLayout.hybridOrganizationIds must contain nonempty organization IDs');
const hybridOrganizationIds = Object.freeze([...new Set(options.hybridOrganizationIds ?? [])]);
if (type !== 'tb' && hybridOrganizationIds.length) throw new TypeError('Per-group hybridOrganizationIds are supported only with TB; clear them before selecting LR or Hybrid');
return Object.freeze({ type, hybridStartDepth, initialVisibleDepth, hybridOrganizationIds });
}
export function autoEngineOptions(layout: ResolvedAutoLayout): { direction: 't2b' | 'l2r'; verticalLevel?: number; visibleLevel: number } {
// Core owns collapse state and prunes its projection. Upstream visibleLevel
// would retain hidden descendants that disagree with Core keyboard/state APIs.
return { direction: layout.type === 'lr' ? 'l2r' : 't2b', visibleLevel: 999,
...(layout.type === 'hybrid' ? { verticalLevel: layout.hybridStartDepth } : {}) };
}
/** Apply the visibleLevel policy to new IDs only, keeping user changes stable. */
export function applyInitialVisibility(organizations: readonly Organization[], collapsed: Set<string>, initialized: Set<string>, visibleDepth: ResolvedAutoLayout['initialVisibleDepth'], reset = false): void {
const valid = new Set(organizations.map(organization => organization.id));
for (const id of collapsed) if (!valid.has(id) || reset) collapsed.delete(id);
for (const id of initialized) if (!valid.has(id) || reset) initialized.delete(id);
const byId = new Map(organizations.map(organization => [organization.id, organization]));
for (const organization of organizations) {
if (initialized.has(organization.id)) continue;
initialized.add(organization.id);
if (visibleDepth === 'all') continue;
let depth = 1;
let current = organization;
const seen = new Set([current.id]);
while (current.parentId) {
const parent = byId.get(current.parentId);
if (!parent || seen.has(parent.id)) break;
seen.add(parent.id); current = parent; depth++;
}
if (depth >= visibleDepth) collapsed.add(organization.id);
}
}
@@ -27,8 +27,20 @@ export function createEmployeeInfoDialog(
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'hm-orgchart-employee-dialog-close';
closeButton.textContent = '닫기';
closeButton.setAttribute('aria-label', '직원 정보 닫기');
// HM Dialog's Lucide XIcon, kept as native SVG for the shared Vanilla Core.
const closeIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
for (const [name, value] of Object.entries({
width: '24', height: '24', viewBox: '0 0 24 24', fill: 'none',
stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round',
'stroke-linejoin': 'round', 'aria-hidden': 'true', focusable: 'false',
})) closeIcon.setAttribute(name, value);
for (const d of ['M18 6 6 18', 'm6 6 12 12']) {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', d);
closeIcon.append(path);
}
closeButton.append(closeIcon);
const fields = document.createElement('dl');
fields.className = 'hm-orgchart-employee-dialog-fields';
header.append(title, closeButton);
+53 -1
View File
@@ -1,10 +1,17 @@
import type OrgChart from 'orgchart';
import type { EngineNode } from 'orgchart';
import type { EngineNode, EngineOptions } from 'orgchart';
export type RenderEngineNode = EngineNode;
export interface EngineEditing {
canDrop(source: HTMLElement, target: HTMLElement): boolean;
onDrop(source: HTMLElement, target: HTMLElement): void;
}
export interface EngineAdapter {
update(data: RenderEngineNode[]): void;
toggle(node: HTMLElement): boolean | undefined;
prepareDrag(source: HTMLElement): void;
drop(source: HTMLElement, target: HTMLElement): void;
cancelDrag(): void;
dispose(): void;
}
@@ -51,6 +58,8 @@ export function createEngineAdapter(
viewport: HTMLElement,
data: RenderEngineNode[],
decorate: (node: HTMLElement, data: RenderEngineNode, signal: AbortSignal) => void,
layoutOptions: Pick<EngineOptions, 'direction' | 'verticalLevel' | 'visibleLevel'> = {},
editing?: EngineEditing,
): EngineAdapter {
const Abort = viewport.ownerDocument.defaultView?.AbortController ?? AbortController;
let generation = new Abort();
@@ -59,7 +68,12 @@ export function createEngineAdapter(
const forest = viewport.ownerDocument.createElement('div');
forest.className = 'hm-orgchart-forest';
viewport.append(forest);
function cancelDrag(): void {
if (!editing) return;
for (const instance of instances) instance.dragendHandler();
}
function releaseGeneration(): void {
cancelDrag();
generation.abort();
for (const instance of instances) {
instance.disconnectInitObserver();
@@ -84,8 +98,10 @@ export function createEngineAdapter(
treeContainer.className = 'hm-orgchart-tree';
forest.append(treeContainer);
const instance = new Engine(treeContainer, {
...layoutOptions,
data: root, pan: false, zoom: false, minimap: false, draggable: false, exportButton: false,
chartClass: 'hm-orgchart-engine',
...(editing ? { dropCriteria: (source: HTMLElement, _parent: HTMLElement | null, target: HTMLElement) => editing.canDrop(source, target) } : {}),
nodeTemplate: () => '<article class="hm-orgchart-organization"></article>',
createNode: (node, value) => decorate(node, value, generation.signal),
});
@@ -108,6 +124,23 @@ export function createEngineAdapter(
}
function bindGeneration(): void {
forest.addEventListener('transitionend', syncButtons, { signal: generation.signal });
if (!editing) return;
for (const instance of instances) {
// Core owns immutable data and rerendering. Reuse upstream drop events,
// but cancel its DOM/data mutation before either horizontal or hybrid path.
const commitDrop = (event: Event) => {
event.preventDefault();
const detail = (event as CustomEvent<{ draggedNode?: HTMLElement; draggedItem?: HTMLElement; dropZone: HTMLElement }>).detail;
if (!detail) return;
const source = detail.draggedNode ?? detail.draggedItem;
const target = detail.dropZone;
if (source && target && instance.chart?.contains(source) && instances.some(candidate => candidate.chart?.contains(target)) && editing.canDrop(source, target)) {
editing.onDrop(source, target);
}
};
instance.chart?.addEventListener('nodedrop.orgchart', commitDrop, { signal: generation.signal });
instance.chart?.addEventListener('otherdropped.orgchart', commitDrop, { signal: generation.signal });
}
}
build(data);
bindGeneration();
@@ -133,6 +166,25 @@ export function createEngineAdapter(
syncButtons();
return !expanded;
},
prepareDrag(source) {
if (disposed || !editing) return;
cancelDrag();
if (!instances.some(candidate => candidate.chart?.contains(source))) return;
// Independent company trees share one editing policy. Upstream caches the
// source and filters targets within its own chart, so filter each tree.
for (const instance of instances) instance.filterAllowedDropNodes(source);
},
drop(source, target) {
if (disposed || !editing) return;
const instance = instances.find(candidate => candidate.chart?.contains(source));
if (!instance || !instances.some(candidate => candidate.chart?.contains(target)) || !editing.canDrop(source, target)) { cancelDrag(); return; }
// Refresh upstream's cached source and allowed targets for the final data
// policy check. Pointer coordinates and thresholds stay in the Core UI.
for (const candidate of instances) candidate.filterAllowedDropNodes(source);
void instance.dropHandler({ dropZone: target });
cancelDrag();
},
cancelDrag,
dispose() {
if (disposed) return;
disposed = true;
@@ -19,6 +19,13 @@ export function decorateExpandedNode(node: HTMLElement, value: RenderEngineNode,
node.dataset.organizationType = organization.type;
node.dataset.companyId = companyId;
node.dataset.collapsed = String(collapsed);
node.draggable = false;
const counts = context.summary.raw.byOrganization[organization.id];
node.dataset.directEmployeeCount = String(counts?.direct ?? 0);
node.dataset.inclusiveEmployeeCount = String(counts?.inclusive ?? 0);
const vertical = value.vertical === true || (context.autoLayout?.type === 'hybrid' && Number(value.depth) + 1 >= context.autoLayout.hybridStartDepth);
node.dataset.verticalLayout = String(vertical);
const layoutDirection = context.autoLayout?.type === 'lr' || vertical ? 'left' : 'top';
node.style.setProperty('--hm-orgchart-expanded-primary', preset.primary);
node.style.setProperty('--hm-orgchart-expanded-title-ink', ['saman', 'jangheon-industry'].includes(companyId) ? 'var(--hm-orgchart-resolved-fg-primary)' : 'var(--hm-orgchart-resolved-on-action-primary)');
preset.secondary.forEach((color, index) => node.style.setProperty(`--hm-orgchart-expanded-secondary-${index + 1}`, color));
@@ -34,10 +41,11 @@ export function decorateExpandedNode(node: HTMLElement, value: RenderEngineNode,
button.className = 'hm-orgchart-expanded-toggle';
button.dataset.organizationId = organization.id;
button.textContent = `${top?.organizationName ?? organization.name} ${hasChildren ? collapsed ? '▸' : '▾' : '·'}`;
button.setAttribute('aria-label', `${top?.organizationName ?? organization.name} 하위 조직·직원`);
button.setAttribute('aria-label', `${top?.organizationName ?? organization.name} 하위 조직·직원 · 직접 ${counts?.direct ?? 0}명 · 하위 포함 ${counts?.inclusive ?? 0}명`);
button.setAttribute('aria-expanded', String(!collapsed));
button.disabled = !hasChildren;
button.addEventListener('click', event => { event.stopPropagation(); context.toggleExpanded?.(organization.id); }, { signal });
// Empty titles remain focusable move sources/targets. Collapse is independent.
button.setAttribute('aria-disabled', String(!hasChildren || context.interactions?.collapse === false));
button.addEventListener('click', event => { event.stopPropagation(); if (hasChildren && context.interactions?.collapse !== false) context.toggleExpanded?.(organization.id); }, { signal });
heading.append(button);
const href = context.organizationLinks?.[organization.id];
if (href) {
@@ -51,6 +59,7 @@ export function decorateExpandedNode(node: HTMLElement, value: RenderEngineNode,
}
article.append(heading);
const colorCard = (card: HTMLElement, affiliationId: string) => {
card.draggable = false;
const employeePreset = getCompanyColorPreset(affiliationId) ?? preset;
card.dataset.companyId = employeePreset.companyId;
card.style.setProperty('--hm-orgchart-expanded-card-primary', `var(--hm-orgchart-company-${employeePreset.companyId})`);
@@ -59,7 +68,7 @@ export function decorateExpandedNode(node: HTMLElement, value: RenderEngineNode,
if (leader && top?.display !== 'hidden' && top?.display !== 'titleOnly') {
const presentation = top ? { ...leader, position: top.title ?? leader.position, name: top.name ?? leader.name, profileImage: top.profileImage === undefined ? leader.profileImage : top.profileImage } : leader;
const card = colorCard(createEmployeeCard(document, presentation, { kind: organization.parentId === null ? 'topLeader' : 'leader', expandedPresentation: true, hideStatusLabel: true }, signal), leader.affiliationId);
card.dataset.connectionDirection = 'top';
card.dataset.connectionDirection = context.autoLayout?.type === 'lr' ? 'left' : 'top';
article.append(card);
} else if (leader && top?.display === 'titleOnly') {
const title = document.createElement('p');
@@ -70,7 +79,7 @@ export function decorateExpandedNode(node: HTMLElement, value: RenderEngineNode,
if (!collapsed && staff.length) {
const employees = document.createElement('div');
employees.className = 'hm-orgchart-employees';
const direction = context.employeeConnections?.[organization.id] ?? 'top';
const direction = context.employeeConnections?.[organization.id] ?? layoutDirection;
employees.dataset.connectionDirection = direction;
for (const employee of staff) {
const card = colorCard(createEmployeeCard(document, employee, { kind: 'employee', expandedPresentation: true, hideStatusLabel: true }, signal), employee.affiliationId);
@@ -86,6 +95,10 @@ export function createExpandedOrgChartContent(document: Document, context: Rende
content.className = 'hm-orgchart-expanded';
content.dataset.component = 'ExpandedOrgChartContent';
content.dataset.layoutMode = 'auto';
content.dataset.autoLayout = context.autoLayout?.type ?? 'tb';
content.dataset.hybridStartDepth = String(context.autoLayout?.hybridStartDepth ?? 3);
content.dataset.initialVisibleDepth = String(context.autoLayout?.initialVisibleDepth ?? 'all');
content.dataset.groupHybrid = String(!!context.autoLayout?.hybridOrganizationIds.length);
content.dataset.layoutStatus = 'hierarchical';
content.dataset.cardDesignStatus = 'current-presentation';
for (const company of new Set([...context.organizationCompanies!.values(), ...context.data.employees.map(employee => employee.affiliationId)])) {
@@ -94,6 +107,6 @@ export function createExpandedOrgChartContent(document: Document, context: Rende
}
tree.setAttribute('aria-label', '확장형 조직 계층도 · Ctrl 또는 Command + 휠로 확대 · 드래그로 이동');
content.append(createHeading(document, context), tree);
alignExpandedLevels(tree, signal, layoutReady);
alignExpandedLevels(tree, signal, layoutReady, context.autoLayout);
return content;
}
@@ -0,0 +1,161 @@
import type { OrgChartMoveRequest, OrgChartMoveValidation } from '../data/index.js';
import type { EngineAdapter } from './engine.js';
export interface ExpandedEditing {
setEnabled(enabled: boolean): void;
}
/** The pointer threshold is shared conceptually with Pan (6 screen pixels).
* Upstream owns allowedDrop and drop events; Core owns immutable relationships.
* Browser hit testing already includes the forest's Zoom/Pan transform. */
export function createExpandedEditing(viewport: HTMLElement, feedback: HTMLElement, signal: AbortSignal,
engine: EngineAdapter, validate: (request: OrgChartMoveRequest) => OrgChartMoveValidation,
move: (request: OrgChartMoveRequest) => void, suppressClick: () => void, initiallyEnabled = false): ExpandedEditing {
const document = viewport.ownerDocument, view = document.defaultView!;
let enabled = initiallyEnabled;
const nodes = () => [...viewport.querySelectorAll<HTMLElement>('.node[data-organization-id]')];
let pending: { source: HTMLElement; id: number; x: number; y: number; moved: boolean } | undefined;
let keyboardSource: HTMLElement | undefined;
let hover: HTMLElement | undefined;
const sourceFor = (target: EventTarget | null): HTMLElement | undefined => {
if (!(target instanceof view.Element)) return;
const employee = target.closest<HTMLElement>('.hm-orgchart-employee');
if (employee && viewport.contains(employee)) return employee;
const title = target.closest('.hm-orgchart-expanded-toggle');
if (title && viewport.contains(title)) return title.closest<HTMLElement>('.node') ?? undefined;
};
const requestFor = (source: HTMLElement, target?: HTMLElement): OrgChartMoveRequest => ({
kind: source.matches('.hm-orgchart-employee') ? 'employee' : 'organization',
sourceId: source.dataset.employeeId ?? source.dataset.organizationId!,
targetOrganizationId: target?.dataset.organizationId ?? '',
});
const name = (node: HTMLElement) => node.querySelector('.hm-orgchart-expanded-toggle')?.textContent ?? node.dataset.organizationId!;
function clear(): void {
engine.cancelDrag();
for (const element of viewport.querySelectorAll<HTMLElement>('[data-drop-state], [data-drop-hover], [data-edit-source]')) {
delete element.dataset.dropState; delete element.dataset.dropHover; delete element.dataset.editSource;
}
hover = undefined; delete viewport.dataset.editDragging;
}
function start(source: HTMLElement): void {
clear(); engine.prepareDrag(source);
source.dataset.editSource = 'true'; viewport.dataset.editDragging = 'true';
for (const target of nodes()) target.dataset.dropState = validate(requestFor(source, target)).allowed ? 'allowed' : 'blocked';
feedback.dataset.moveStatus = 'dragging';
feedback.textContent = '이동할 조직에 놓으세요. 회사 간 이동도 가능합니다. 조직장·최상위 조직은 이동할 수 없습니다.';
}
function selectTarget(source: HTMLElement, target?: HTMLElement): void {
if (hover) delete hover.dataset.dropHover;
hover = target;
if (!target) return;
const policy = validate(requestFor(source, target));
target.dataset.dropHover = policy.allowed ? 'allowed' : 'blocked';
feedback.textContent = policy.allowed ? `${name(target)} · 이동 가능` : `${name(target)} · ${policy.message}`;
}
const hitTarget = (x: number, y: number) => {
const box = viewport.getBoundingClientRect();
if (x < box.left || x >= box.right || y < box.top || y >= box.bottom) return;
// The presenter binds before its fragment is attached to the host Shadow DOM.
// Resolve the live root at hit-test time instead of retaining that fragment.
const root = viewport.getRootNode();
const hit = (root instanceof view.ShadowRoot ? root : document).elementFromPoint(x, y);
const node = hit?.closest<HTMLElement>('.node[data-organization-id]');
return node && viewport.contains(node) ? node : undefined;
};
function drop(source: HTMLElement, target?: HTMLElement): boolean {
if (!enabled) { clear(); return false; }
const request = requestFor(source, target), policy = validate(request);
// The upstream otherdropped event intentionally skips its allowedDrop check.
// Always revalidate before entering either upstream drop-event route.
if (target && policy.allowed) engine.drop(source, target);
else move(request);
clear();
return policy.allowed && !!target;
}
viewport.addEventListener('dragstart', event => event.preventDefault(), { capture: true, signal });
viewport.addEventListener('pointerdown', event => {
if (!enabled || event.button !== 0 || !event.isPrimary || keyboardSource) return;
const source = sourceFor(event.target);
if (!source) return;
pending = { source, id: event.pointerId, x: event.clientX, y: event.clientY, moved: false };
}, { signal });
view.addEventListener('pointermove', event => {
if (!enabled || !pending || pending.id !== event.pointerId) return;
if (!pending.moved && Math.hypot(event.clientX - pending.x, event.clientY - pending.y) < 6) return;
if (!pending.moved) {
pending.moved = true; viewport.setPointerCapture(event.pointerId); start(pending.source);
}
event.preventDefault();
selectTarget(pending.source, hitTarget(event.clientX, event.clientY));
}, { passive: false, signal });
function finish(event: PointerEvent): void {
if (!pending || pending.id !== event.pointerId) return;
const drag = pending; pending = undefined;
if (viewport.hasPointerCapture(event.pointerId)) viewport.releasePointerCapture(event.pointerId);
if (!drag.moved) return;
suppressClick();
if (event.type === 'pointerup') drop(drag.source, hitTarget(event.clientX, event.clientY));
else { clear(); feedback.dataset.moveStatus = 'cancelled'; feedback.textContent = '이동을 취소했습니다.'; }
}
view.addEventListener('pointerup', finish, { signal });
view.addEventListener('pointercancel', finish, { signal });
viewport.addEventListener('lostpointercapture', event => {
// Touch initially captures its card. Transferring capture to the viewport
// releases that descendant and bubbles here; the viewport still owns Drag.
if (event.target !== viewport || !pending?.moved) return;
pending = undefined; clear(); feedback.textContent = '이동을 취소했습니다.';
}, { signal });
const cancel = (restoreFocus = true) => {
const source = keyboardSource;
if (pending?.moved) suppressClick();
const id = pending?.id; pending = undefined; keyboardSource = undefined;
if (id !== undefined && viewport.hasPointerCapture(id)) viewport.releasePointerCapture(id);
clear(); feedback.dataset.moveStatus = 'cancelled'; feedback.textContent = '이동을 취소했습니다.';
const focus = source?.matches('.node') ? source.querySelector<HTMLElement>('.hm-orgchart-expanded-toggle') : source;
if (restoreFocus) focus?.focus({ preventScroll: true });
};
view.addEventListener('blur', () => { if (pending || keyboardSource) cancel(); }, { signal });
viewport.addEventListener('keydown', event => {
if (!enabled) return;
if (event.key === 'Escape' && (keyboardSource || pending)) { event.preventDefault(); event.stopImmediatePropagation(); cancel(); return; }
if (!keyboardSource && event.altKey && event.key.toLowerCase() === 'm') {
const source = sourceFor(event.target);
if (!source) return;
event.preventDefault(); event.stopImmediatePropagation(); keyboardSource = source; start(source);
const targets = nodes(), target = targets.find(target => validate(requestFor(source, target)).allowed) ?? targets[0];
selectTarget(source, target);
if (target) { target.tabIndex = -1; target.focus({ preventScroll: true }); }
return;
}
if (!keyboardSource) return;
if (['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft', 'Home', 'End'].includes(event.key)) {
event.preventDefault(); event.stopImmediatePropagation();
const targets = nodes(), index = Math.max(0, targets.indexOf(hover!));
const next = event.key === 'Home' ? 0 : event.key === 'End' ? targets.length - 1
: (index + (['ArrowUp', 'ArrowLeft'].includes(event.key) ? -1 : 1) + targets.length) % targets.length;
selectTarget(keyboardSource, targets[next]);
if (hover) { hover.tabIndex = -1; hover.focus({ preventScroll: true }); }
} else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault(); event.stopImmediatePropagation(); const source = keyboardSource; keyboardSource = undefined;
if (!drop(source, hover)) {
const focus = source.matches('.node') ? source.querySelector<HTMLElement>('.hm-orgchart-expanded-toggle') : source;
focus?.focus({ preventScroll: true });
}
} else if (event.key === 'Tab') { event.preventDefault(); event.stopImmediatePropagation(); cancel(); }
}, { capture: true, signal });
signal.addEventListener('abort', () => {
const id = pending?.id; pending = undefined; keyboardSource = undefined;
if (id !== undefined && viewport.hasPointerCapture(id)) viewport.releasePointerCapture(id);
clear();
}, { once: true });
return {
setEnabled(next) {
if (enabled === next) return;
enabled = next;
if (!enabled) {
if (pending || keyboardSource) cancel(false);
else clear();
}
},
};
}
+61 -2
View File
@@ -1,7 +1,9 @@
/** dabeng owns recursive widths and connectors. Reserve the tallest content
* height per depth so child rows align with unequal staff counts. Observe the
* articles rather than reserved nodes to avoid ResizeObserver feedback. */
export function alignExpandedLevels(viewport: HTMLElement, signal: AbortSignal, layoutReady: () => void = () => {}): void {
import type { ResolvedAutoLayout } from './auto-layout-policy.js';
export function alignExpandedLevels(viewport: HTMLElement, signal: AbortSignal, layoutReady: () => void = () => {}, layout?: ResolvedAutoLayout): void {
const view = viewport.ownerDocument.defaultView;
if (!view || signal.aborted) return;
const nodes = [...viewport.querySelectorAll<HTMLElement>('.node[data-depth]')];
@@ -10,15 +12,20 @@ export function alignExpandedLevels(viewport: HTMLElement, signal: AbortSignal,
frame = 0;
if (signal.aborted) return;
const heights = new Map<string, number>();
const widths = new Map<string, number>();
for (const node of nodes) {
const height = node.querySelector<HTMLElement>('.hm-orgchart-organization')?.offsetHeight ?? 0;
heights.set(node.dataset.depth!, Math.max(heights.get(node.dataset.depth!) ?? 0, Math.ceil(height)));
const width = node.querySelector<HTMLElement>('.hm-orgchart-organization')?.offsetWidth ?? 0;
widths.set(node.dataset.depth!, Math.max(widths.get(node.dataset.depth!) ?? 0, Math.ceil(width)));
}
for (const node of nodes) {
node.style.minHeight = `${heights.get(node.dataset.depth!) ?? 0}px`;
node.style.minHeight = layout?.type === 'lr' || node.closest('.nodes.vertical') ? '0' : `${heights.get(node.dataset.depth!) ?? 0}px`;
if (layout?.type === 'lr') node.style.minWidth = `${widths.get(node.dataset.depth!) ?? 0}px`;
node.style.setProperty('--hm-orgchart-expanded-content-height', `${node.querySelector<HTMLElement>('.hm-orgchart-organization')?.offsetHeight ?? 0}px`);
}
drawEmployeeConnectors(viewport);
if (layout?.type === 'lr' || layout?.type === 'hybrid' || layout?.hybridOrganizationIds.length) drawOrganizationConnectors(viewport, layout);
layoutReady();
};
const schedule = () => {
@@ -36,6 +43,58 @@ export function alignExpandedLevels(viewport: HTMLElement, signal: AbortSignal,
schedule();
}
/** Adapt dabeng's existing hierarchy DOM to tall HM cards. These paths own only
* connector presentation; dabeng still creates and recursively lays out nodes. */
function drawOrganizationConnectors(viewport: HTMLElement, layout: ResolvedAutoLayout): void {
const document = viewport.ownerDocument;
for (const tree of viewport.querySelectorAll<HTMLElement>('.hm-orgchart-tree')) {
tree.querySelector(':scope > .hm-orgchart-organization-connectors')?.remove();
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.classList.add('hm-orgchart-organization-connectors');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('width', String(tree.offsetWidth));
svg.setAttribute('height', String(tree.offsetHeight));
const nodes = [...tree.querySelectorAll<HTMLElement>('.node[data-organization-id]')];
const byId = new Map(nodes.map(node => [node.dataset.organizationId!, node]));
const local = (element: HTMLElement) => {
const treeRect = tree.getBoundingClientRect(), rect = element.getBoundingClientRect();
// Forest scale is the only transform (LR's upstream rotation is scoped out).
const scale = Number(viewport.dataset.scale) || 1;
return { x: (rect.left - treeRect.left) / scale, y: (rect.top - treeRect.top) / scale,
width: element.offsetWidth, height: element.offsetHeight };
};
for (const node of nodes) {
const parent = byId.get(node.dataset.parentId!);
if (!parent) continue;
const article = parent.querySelector<HTMLElement>(':scope > .hm-orgchart-organization')!;
const heading = node.querySelector<HTMLElement>('.hm-orgchart-organization-name')!;
const from = local(article), to = local(heading);
const left = layout.type === 'lr' || !!node.closest('.nodes.vertical');
const path = document.createElementNS(svg.namespaceURI, 'path');
if (layout.type === 'lr') {
const parentHeading = local(parent.querySelector<HTMLElement>('.hm-orgchart-organization-name')!);
const x = parentHeading.x + parentHeading.width, y = parentHeading.y + parentHeading.height / 2;
const targetY = to.y + to.height / 2, rail = (x + to.x) / 2;
path.setAttribute('d', `M ${x} ${y} H ${rail} V ${targetY} H ${to.x}`);
} else if (left) {
const x = from.x + from.width / 2, y = from.y + from.height;
const rail = to.x - 16, targetY = to.y + to.height / 2;
const branchY = y + 12;
path.setAttribute('d', `M ${x} ${y} V ${branchY} H ${rail} V ${targetY} H ${to.x}`);
} else {
const x = from.x + from.width / 2, y = from.y + from.height;
const targetX = to.x + to.width / 2, rail = (y + to.y) / 2;
path.setAttribute('d', `M ${x} ${y} V ${rail} H ${targetX} V ${to.y}`);
}
path.setAttribute('data-organization-id', node.dataset.organizationId!);
path.setAttribute('data-parent-id', node.dataset.parentId!);
path.setAttribute('data-direction', left ? 'left' : 'top');
svg.append(path);
}
tree.prepend(svg);
}
}
/** Draw routes in unscaled article coordinates. The endpoint defines both the
* connector's incoming direction and the single accent side on the card. */
function drawEmployeeConnectors(viewport: HTMLElement): void {
@@ -5,20 +5,25 @@ export interface ExpandedViewport {
panBy(x: number, y: number): void;
fit(): void;
state(): OrgChartViewportState;
hasManualPosition(): boolean;
layoutReady(): void;
}
/** One transformed forest, shared by HTML and React; dialogs live in the shell. */
export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLElement, signal: AbortSignal,
config: { min: number; max: number; step: number }, initial?: OrgChartViewportState, fitInitially = true): ExpandedViewport {
config: { min: number; max: number; step: number; zoomEnabled?: boolean; panEnabled?: boolean }, initial?: OrgChartViewportState, centerInitially = true, initialManualPosition = false): ExpandedViewport {
const document = viewport.ownerDocument;
const view = document.defaultView!;
const forest = viewport.querySelector<HTMLElement>('.hm-orgchart-forest')!;
let { scale, x, y } = initial ?? { scale: 1, x: 0, y: 0 };
let initialFit = fitInitially;
let manualPosition = initialManualPosition;
let placement: 'center' | 'fit' | undefined = centerInitially && !manualPosition ? 'center' : undefined;
let drag: { id: number; x: number; y: number; moved: boolean } | undefined;
let suppressClick = false;
let focusFrame = 0;
const zoomEnabled = config.zoomEnabled !== false, panEnabled = config.panEnabled !== false;
viewport.dataset.zoomEnabled = String(zoomEnabled);
viewport.dataset.panEnabled = String(panEnabled);
const output = document.createElement('output');
output.setAttribute('aria-label', '현재 배율');
output.setAttribute('aria-live', 'polite');
@@ -29,35 +34,61 @@ export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLEleme
viewport.dataset.panX = String(x);
viewport.dataset.panY = String(y);
output.textContent = `${Math.round(scale * 100)}%`;
buttons[0].disabled = scale >= config.max;
buttons[1].disabled = scale <= config.min;
buttons[0].disabled = !zoomEnabled || scale >= config.max;
buttons[1].disabled = !zoomEnabled || scale <= config.min;
};
const clamp = (value: number) => Math.max(config.min, Math.min(config.max, value));
// Engine wrappers reserve asymmetric margins (LR) and empty row space (TB).
// Center the complete rendered content, including organization connector rails,
// in forest-local coordinates so Pan/Zoom do not affect the measured bounds.
const contentBounds = () => {
const origin = forest.getBoundingClientRect();
let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity;
for (const element of forest.querySelectorAll('.hm-orgchart-organization, .hm-orgchart-organization-connectors path')) {
const rect = element.getBoundingClientRect();
if ((!rect.width && !rect.height) || !element.getClientRects().length) continue;
left = Math.min(left, (rect.left - origin.left) / scale);
top = Math.min(top, (rect.top - origin.top) / scale);
right = Math.max(right, (rect.right - origin.left) / scale);
bottom = Math.max(bottom, (rect.bottom - origin.top) / scale);
}
return Number.isFinite(left) ? { left, top, width: right - left, height: bottom - top } : undefined;
};
const position = (fit: boolean) => {
const bounds = contentBounds();
if (!bounds || !bounds.width || !bounds.height || !viewport.clientWidth || !viewport.clientHeight) return;
if (fit) {
// Only an explicit Fit shrinks to contain, even below manual Zoom's min.
scale = Math.max(Number.EPSILON, Math.min((viewport.clientWidth - 32) / bounds.width, (viewport.clientHeight - 32) / bounds.height, 1));
}
x = (viewport.clientWidth - bounds.width * scale) / 2 - bounds.left * scale;
y = (fit ? Math.max(16, (viewport.clientHeight - bounds.height * scale) / 2) : 16) - bounds.top * scale;
apply();
};
const preservePosition = () => { manualPosition = true; placement = undefined; };
const controller: ExpandedViewport = {
zoomTo(next, anchor = { x: viewport.clientWidth / 2, y: viewport.clientHeight / 2 }) {
if (!zoomEnabled) return;
if (!Number.isFinite(next)) return;
initialFit = false;
preservePosition();
const value = clamp(next), factor = value / scale;
x = anchor.x - (anchor.x - x) * factor;
y = anchor.y - (anchor.y - y) * factor;
scale = value; apply();
},
panBy(dx, dy) { if (!Number.isFinite(dx) || !Number.isFinite(dy)) return; initialFit = false; x += dx; y += dy; apply(); },
panBy(dx, dy) { if (!panEnabled || !Number.isFinite(dx) || !Number.isFinite(dy)) return; preservePosition(); x += dx; y += dy; apply(); },
fit() {
if (!forest.offsetWidth || !forest.offsetHeight) return;
scale = clamp(Math.min((viewport.clientWidth - 32) / forest.offsetWidth, (viewport.clientHeight - 32) / forest.offsetHeight, 1));
x = (viewport.clientWidth - forest.offsetWidth * scale) / 2;
y = Math.max(16, (viewport.clientHeight - forest.offsetHeight * scale) / 2);
apply();
manualPosition = false; placement = 'fit'; position(true);
},
state: () => ({ scale, x, y }),
layoutReady() { if (initialFit) controller.fit(); },
hasManualPosition: () => manualPosition,
layoutReady() { if (placement) position(placement === 'fit'); },
};
const actions: [string, () => void][] = [
['확대', () => controller.zoomTo(scale * config.step)],
['축소', () => controller.zoomTo(scale / config.step)],
['100% 복원', () => { initialFit = false; scale = 1; x = 16; y = 16; apply(); }],
['Fit to View', () => { initialFit = true; controller.fit(); }],
['100% 복원', () => { preservePosition(); scale = 1; x = 16; y = 16; apply(); }],
['Fit to View', () => controller.fit()],
];
toolbar.setAttribute('role', 'group');
toolbar.setAttribute('aria-label', '조직도 확대·이동 도구');
@@ -69,9 +100,10 @@ export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLEleme
}
toolbar.append(output);
const hint = document.createElement('span');
hint.textContent = '드래그 이동 · Ctrl/⌘ + 휠 확대 · 방향키 이동';
hint.textContent = [panEnabled ? '드래그·방향키 이동' : '', zoomEnabled ? 'Ctrl/⌘ + 휠 확대' : ''].filter(Boolean).join(' · ');
toolbar.append(hint);
viewport.addEventListener('wheel', event => {
if (!zoomEnabled) return;
if (!event.ctrlKey && !event.metaKey) return;
event.preventDefault();
const box = viewport.getBoundingClientRect();
@@ -80,12 +112,13 @@ export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLEleme
viewport.addEventListener('keydown', event => {
if (event.target !== viewport) return;
const shifts: Record<string, [number, number]> = { ArrowLeft: [40, 0], ArrowRight: [-40, 0], ArrowUp: [0, 40], ArrowDown: [0, -40] };
if (shifts[event.key]) { event.preventDefault(); controller.panBy(...shifts[event.key]!); }
if (shifts[event.key]) { event.preventDefault(); if (panEnabled) controller.panBy(...shifts[event.key]!); }
else if (event.key === '+' || event.key === '=') { event.preventDefault(); controller.zoomTo(scale * config.step); }
else if (event.key === '-') { event.preventDefault(); controller.zoomTo(scale / config.step); }
}, { signal });
// Tab can reach cards outside a zoomed/panned forest. Reveal the focused
// control without changing scale or using the viewport's hidden scroll area.
// Keyboard focus visibility remains available when manual Pan is disabled.
viewport.addEventListener('focusin', event => {
const target = event.target;
if (!(target instanceof view.HTMLElement) || !forest.contains(target)) return;
@@ -99,11 +132,12 @@ export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLEleme
const right = left + viewport.clientWidth - 16, bottom = top + viewport.clientHeight - 16;
const dx = box.left < left ? left - box.left : box.right > right ? right - box.right : 0;
const dy = box.top < top ? top - box.top : box.bottom > bottom ? bottom - box.bottom : 0;
if (dx || dy) controller.panBy(dx, dy);
if (dx || dy) { preservePosition(); x += dx; y += dy; apply(); }
});
}, { signal });
viewport.addEventListener('pointerdown', event => {
if (event.button !== 0 || event.isPrimary === false || (event.target as Element).closest('button,a')) return;
if (!panEnabled) return;
if (event.button !== 0 || event.isPrimary === false || (event.target as Element).closest('button,a,.hm-orgchart-employee')) return;
suppressClick = false;
drag = { id: event.pointerId, x: event.clientX, y: event.clientY, moved: false };
}, { signal });
@@ -123,7 +157,7 @@ export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLEleme
};
viewport.addEventListener('pointerup', finish, { signal });
viewport.addEventListener('pointercancel', finish, { signal });
viewport.addEventListener('lostpointercapture', () => { drag = undefined; delete viewport.dataset.dragging; }, { signal });
viewport.addEventListener('lostpointercapture', event => { if (event.target === viewport) { drag = undefined; delete viewport.dataset.dragging; } }, { signal });
// Release outside the viewport must not leave a pending drag.
view.addEventListener('pointerup', finish, { signal });
viewport.addEventListener('click', event => {
@@ -133,7 +167,7 @@ export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLEleme
if (event.detail === 0) return;
event.preventDefault(); event.stopImmediatePropagation();
}, { capture: true, signal });
const observer = new view.ResizeObserver(() => { if (initialFit) controller.fit(); });
const observer = new view.ResizeObserver(() => { if (placement) position(placement === 'fit'); });
observer.observe(viewport);
signal.addEventListener('abort', () => { observer.disconnect(); drag = undefined; if (focusFrame) view.cancelAnimationFrame(focusFrame); }, { once: true });
apply();
+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(); },
};
@@ -8,6 +8,7 @@ import { createOrganizationTotals } from './organization-totals.js';
import { createAffiliationBar } from './affiliation-bar.js';
import { applyWorkdeskLayout } from './workdesk-layout.js';
import { addWorkdeskArtwork } from './workdesk-artwork.js';
import { workdeskCanvas } from './workdesk-layout.js';
type SummaryComponent = 'PositionSummary' | 'AffiliationSummary' | 'StatusSummary';
type ContentSlot = 'side' | 'footer' | 'belowTree';
@@ -115,6 +116,11 @@ export function createOrgChartContent(
if (slots.side.childElementCount) stage.append(slots.side);
else content.classList.add('hm-orgchart-composition-no-side');
if (slots.footer.childElementCount) stage.append(slots.footer);
if (stage.dataset.workdeskArtwork) {
heading.dataset.compositionRegion = slots.side.dataset.compositionRegion = 'top';
main.querySelector<HTMLElement>('.hm-orgchart-axis')!.dataset.compositionRegion = tree.dataset.compositionRegion = 'center';
slots.belowTree.dataset.compositionRegion = slots.footer.dataset.compositionRegion = 'bottom';
}
observeStageScale(content, stage, signal, readOnly);
return content;
}
@@ -129,7 +135,7 @@ function observeStageScale(wrapper: HTMLElement, stage: HTMLElement, signal: Abo
const view = wrapper.ownerDocument.defaultView;
if (!view) return;
let frame: number | undefined;
let applied = { scale: NaN, width: NaN, height: NaN };
let applied = { scale: NaN, width: NaN, height: NaN, canvasHeight: NaN, centerOffset: 0 };
const ancestors: HTMLElement[] = [];
const collectAncestors = () => {
// The constructor runs before insertion; discover the real host only once
@@ -175,23 +181,56 @@ function observeStageScale(wrapper: HTMLElement, stage: HTMLElement, signal: Abo
collectAncestors();
const available = wrapper.clientWidth; // integer floor: never overflows the host
const baseWidth = stage.offsetWidth;
const baseHeight = stage.offsetHeight;
const adaptive = fitHeight && stage.dataset.workdeskArtwork === 'true';
const baseHeight = adaptive ? workdeskCanvas.height : stage.offsetHeight;
if (!available || !baseWidth || !baseHeight) return;
let scale = Math.min(1, available / baseWidth);
const availableHeight = fitHeight ? heightLimit() : baseHeight * scale;
if (fitHeight) {
const availableHeight = heightLimit();
scale = Math.min(scale, availableHeight / baseHeight);
}
if (Math.abs(scale - applied.scale) < 0.0005 && baseWidth === applied.width && baseHeight === applied.height) return;
if (!adaptive && Math.abs(scale - applied.scale) < 0.0005 && baseWidth === applied.width && baseHeight === applied.height) return;
if (adaptive && !scale) return;
const canvasHeight = adaptive ? Math.max(baseHeight, availableHeight / scale) : baseHeight;
const scaleChanged = scale !== applied.scale;
applied = { scale, width: baseWidth, height: baseHeight };
stage.style.transform = scale < 1 ? `scale(${scale})` : '';
wrapper.style.height = `${baseHeight * scale}px`;
if (Math.abs(scale - applied.scale) >= 0.000001 || baseWidth !== applied.width || canvasHeight !== applied.canvasHeight) {
stage.style.transform = scale < 1 ? `scale(${scale})` : '';
if (adaptive) stage.style.setProperty('--hm-orgchart-workdesk-stage-height', `${canvasHeight}px`);
wrapper.style.height = `${canvasHeight * scale}px`;
}
let centerOffset = 0;
if (adaptive && canvasHeight > baseHeight + 0.01) {
const origin = stage.getBoundingClientRect();
const local = (element: Element) => {
const rect = element.getBoundingClientRect();
return { top: (rect.top - origin.top) / scale, bottom: (rect.bottom - origin.top) / scale };
};
const center = [...stage.querySelectorAll('.hm-orgchart-axis .hm-orgchart-employee,.hm-orgchart-hub,.hm-orgchart-viewport .hm-orgchart-employee,.hm-orgchart-viewport .hm-orgchart-organization-name,image[data-arrow]')]
.filter(element => element.getClientRects().length).map(local);
const top = [...stage.querySelectorAll('[data-composition-region="top"]')].map(local);
const bottom = [...stage.querySelectorAll('[data-composition-region="bottom"]')].filter(element => element.childElementCount).map(local);
if (center.length && top.length) {
const start = Math.min(...center.map(rect => rect.top)) - applied.centerOffset;
const end = Math.max(...center.map(rect => rect.bottom)) - applied.centerOffset;
const bottomAnchor = bottom.length ? Math.min(...bottom.map(rect => rect.top)) : canvasHeight - (parseFloat(view.getComputedStyle(stage).paddingBottom) || 0);
const target = (Math.max(...top.map(rect => rect.bottom)) + bottomAnchor - start - end) / 2;
// Keep the baseline intact when the screen is short; share only newly
// available height until the entire central chart fits between anchors.
centerOffset = Math.max(0, Math.min(canvasHeight - baseHeight, target));
}
}
if (adaptive && (Math.abs(centerOffset - applied.centerOffset) > 0.01 || !Number.isFinite(applied.scale))) {
stage.style.setProperty('--hm-orgchart-workdesk-center-offset', `${centerOffset.toFixed(4)}px`);
} else centerOffset = applied.centerOffset;
const positionChanged = adaptive && (Math.abs(canvasHeight - applied.canvasHeight) > 0.01 || Math.abs(centerOffset - applied.centerOffset) > 0.01);
applied = { scale, width: baseWidth, height: baseHeight, canvasHeight, centerOffset };
wrapper.dataset.scale = scale.toFixed(4);
wrapper.dataset.baseWidth = String(baseWidth);
wrapper.dataset.baseHeight = String(baseHeight);
// Open tooltips were positioned from pre-scale geometry; let them close.
if (scaleChanged) wrapper.dispatchEvent(new view.CustomEvent('hm-orgchart-scale', { bubbles: true }));
if (adaptive) { wrapper.dataset.canvasHeight = String(canvasHeight); wrapper.dataset.centerOffset = centerOffset.toFixed(4); }
// Open tooltips use the old card bounds. A bounded parent can move the
// central chart without changing scale or emitting a window resize.
if (scaleChanged || positionChanged) wrapper.dispatchEvent(new view.CustomEvent('hm-orgchart-scale', { bubbles: true }));
};
const schedule = () => { if (frame === undefined && !signal.aborted) frame = view.requestAnimationFrame(apply); };
const observer = new view.ResizeObserver(schedule);
+8 -1
View File
@@ -6,7 +6,8 @@ import { getDepartmentCardAsset } from './department-card-assets.js';
import type { OrgChartOrganizationLinks } from './types.js';
import type { CompanyColorPreset } from '../company-color-presets.js';
import { decorateExpandedNode } from './expanded-content.js';
import type { OrgChartConnectionDirection, OrgChartTopNode } from './types.js';
import type { OrgChartConnectionDirection, OrgChartTopNode, OrgChartInteractionOptions } from './types.js';
import type { ResolvedAutoLayout } from './auto-layout-policy.js';
const organizationNames = { root: '최상위 조직', division: '부문', office: '실', div: 'Div', cell: '셀', team: '팀' } as const;
@@ -25,6 +26,8 @@ export interface RenderContext {
collapsedOrganizations?: ReadonlySet<string>;
companyTopNodes?: Readonly<Record<string, OrgChartTopNode>>;
employeeConnections?: Readonly<Record<string, OrgChartConnectionDirection>>;
autoLayout?: ResolvedAutoLayout;
interactions?: Readonly<Required<OrgChartInteractionOptions>>;
toggleExpanded?: (organizationId: string) => void;
}
@@ -35,6 +38,10 @@ export function engineTree(roots: OrganizationTreeNode[], prefix: string, contex
context.orderedOrganizations.push(tree.organization);
return {
id: `${prefix}-org-${index}`, name: '', organizationIndex: index, depth, groupVariant: variant,
// Make public (root=Depth 1) semantics explicit rather than depending on
// upstream's detached-DOM ancestor inference during mode reconstruction.
...(context.autoLayout ? { level: depth + 1 } : {}),
...(context.autoLayout?.type === 'tb' && context.autoLayout.hybridOrganizationIds.includes(tree.organization.id) ? { hybrid: true } : {}),
...(tree.children.length && !context.collapsedOrganizations?.has(tree.organization.id) ? { children: tree.children.map((child, childIndex) => convert(child, depth + 1, context.readOnly ? variant : depth === startDepth ? childIndex % 6 : variant)) } : {}),
};
}
+49 -2
View File
@@ -1,4 +1,4 @@
import type { Affiliation, ApprovedAggregationPolicy, Employee, OrgChartDataSource, OrgChartSummary, Organization, ValidationIssue } from '../data/index.js';
import type { Affiliation, ApprovedAggregationPolicy, Employee, OrgChartDataSource, OrgChartDataset, OrgChartMoveRequest, OrgChartMoveResult, OrgChartSummary, Organization, ValidationIssue } from '../data/index.js';
export type OrgChartState =
| { readonly status: 'idle' | 'loading' | 'empty' | 'destroyed' }
@@ -14,6 +14,27 @@ export type OrgChartDisplayMode = 'interactive' | 'readOnly';
* with natural overflow; Fixed preserves the Workdesk composition. */
export type OrgChartLayoutMode = 'fixed' | 'auto';
/** Independent Auto presentation policies. Root organizations are Depth 1. */
export type OrgChartAutoLayoutType = 'tb' | 'lr' | 'hybrid';
export interface OrgChartAutoLayoutOptions {
type?: OrgChartAutoLayoutType;
/** Hybrid only. The first displayed depth rendered as parent-local lists. */
hybridStartDepth?: 2 | 3 | 4;
/** Initial expansion policy; changing it intentionally resets collapse state.
* Later user toggles survive layout and data changes for valid organizations. */
initialVisibleDepth?: 1 | 2 | 3 | 'all';
/** TB only. Each listed organization's descendants use a local vertical list.
* Cannot be combined with LR or global Hybrid. */
hybridOrganizationIds?: readonly string[];
}
export interface OrgChartInteractionOptions {
/** Auto only; each optional feature defaults to enabled and is independent. */
collapse?: boolean;
zoom?: boolean;
/** Disables manual pointer/arrow/API Pan; keyboard focus reveal stays available. */
pan?: boolean;
}
/** Links are keyed by the existing organization ID. Only 1Depth titles use them. */
export type OrgChartOrganizationLinks = Readonly<Record<string, string | undefined>>;
@@ -52,8 +73,15 @@ export interface OrgChartOptions {
companyId?: string;
/** Auto only. undefined means all companies; [] is an explicit empty selection. */
selectedCompanyIds?: readonly string[];
/** Auto only; Fixed ignores these independent presentation options. */
autoLayout?: OrgChartAutoLayoutOptions;
interactions?: OrgChartInteractionOptions;
/** Auto-only employee/group editing. Omission disables pointer, keyboard and
* handle moves; Pan and employee information remain independent. Fixed ignores it. */
dragDropEnabled?: boolean;
companyTopNodes?: Readonly<Record<string, OrgChartTopNode>>;
/** Actual staff connector route, default top. Leaders always connect from above. */
/** Actual TB/Hybrid staff connector route, default top; their leaders connect
* from above. LR always routes employees from the left. */
employeeConnections?: Readonly<Record<string, OrgChartConnectionDirection>>;
zoom?: { min?: number; max?: number; step?: number };
/** No entry (or an empty URL) keeps an ordinary title. HTTP(S) and relative URLs are supported. */
@@ -61,6 +89,9 @@ export interface OrgChartOptions {
/** Optional host-owned information UI. When supplied, it replaces the default read-only dialog.
* Called for every employee card in interactive and readOnly modes. */
onEmployeeClick?: (employee: Employee, context: OrgChartEmployeeClickContext) => void;
/** Auto-only browser-state editing. Called after a move or policy rejection;
* never saves to the source or a server. Consumer callback errors are isolated. */
onMove?: (result: OrgChartMoveResult) => void;
onStateChange?: (state: OrgChartState) => void;
}
@@ -75,6 +106,22 @@ export interface OrgChartHandle {
/** Auto view state is separate from the immutable source. Fixed rejects selection changes. */
setSelectedCompanies(ids?: readonly string[]): Promise<void>;
getSelectedCompanies(): readonly string[];
/** Merge an Auto-only presentation patch without remounting or losing valid collapse state. */
setAutoLayout(options: OrgChartAutoLayoutOptions): Promise<void>;
getAutoLayout(): Readonly<OrgChartAutoLayoutOptions>;
/** Auto only. Change popup ownership without rebuilding or changing view state.
* undefined restores the existing Core dialog. */
setEmployeeClickHandler(handler?: OrgChartOptions['onEmployeeClick']): void;
/** Auto only. Merge independent feature flags while retaining view state. */
setInteractions(options: OrgChartInteractionOptions): Promise<void>;
getInteractions(): Readonly<OrgChartInteractionOptions>;
/** Auto only. Cancel active editing without reloading or resetting completed moves. */
setDragDropEnabled(enabled: boolean): void;
getDragDropEnabled(): boolean;
/** Auto only. Uses the same policy as pointer and keyboard moves. */
move(request: OrgChartMoveRequest): Promise<OrgChartMoveResult>;
/** Immutable current Auto dataset, including hidden companies and local moves. */
getData(): OrgChartDataset | undefined;
zoomTo(scale: number): void;
panBy(x: number, y: number): void;
fitToView(): void;
+7
View File
@@ -16,6 +16,10 @@ declare module 'orgchart' {
draggable: boolean;
exportButton: boolean;
chartClass: string;
direction?: 't2b' | 'l2r';
verticalLevel?: number;
visibleLevel?: number;
dropCriteria?: (source: HTMLElement, parent: HTMLElement | null, target: HTMLElement) => boolean;
nodeTemplate: () => string;
createNode: (node: HTMLElement, data: EngineNode) => void;
}
@@ -34,5 +38,8 @@ declare module 'orgchart' {
unbindPan(): void;
unbindZoom(): void;
detachMinimap(): void;
filterAllowedDropNodes(source: HTMLElement): void;
dropHandler(event: { dropZone: HTMLElement }): Promise<void>;
dragendHandler(): void;
}
}
@@ -66,7 +66,16 @@ export function addWorkdeskArtwork(stage: HTMLElement, hub: HTMLElement, signal:
curves.setAttribute('transform', 'translate(162 202)');
overlay.append(curves);
for (const [id, d] of hubPaths) path(id, d, curves);
path('representative-to-hub', 'M935 142V286').setAttribute('transform', 'translate(0 -17)');
const representative = path('representative-to-hub', 'M935 142V286');
representative.setAttribute('transform', 'translate(0 -17)');
// Original Workdesk org-arrow-jang-head: a solid upward chevron, separate
// from the five team PNG arrows and the dashed representative connector.
const representativeHead = document.createElementNS(svgNamespace, 'path');
representativeHead.dataset.arrowHead = 'representative';
representativeHead.setAttribute('d', 'M0.00652573 5.54699C-0.0317927 5.092 0.0997532 4.66961 0.37176 4.32434L3.97332 0.436642C4.57335 -0.142366 5.40409 -0.144766 6.01239 0.424923L9.63348 4.33508C10.7765 5.85831 9.00989 7.82069 7.56414 6.63684L4.99774 3.88781L2.44207 6.62609C1.53083 7.44647 0.11378 6.8181 0.00652573 5.54699Z');
representativeHead.setAttribute('fill', '#45639F');
representativeHead.setAttribute('transform', 'translate(930 121.2969)');
overlay.append(representativeHead);
const business = path('hub-to-business-management', 'M834 204C650 204 250 235 105 256.2');
for (const [id, source, x, y, width, height] of arrows) {
const image = document.createElementNS(svgNamespace, 'image');
@@ -86,10 +95,22 @@ export function addWorkdeskArtwork(stage: HTMLElement, hub: HTMLElement, signal:
if (signal.aborted || !stage.isConnected) return;
const header = stage.querySelector<HTMLElement>('.node[data-organization-id="business-management"] .hm-orgchart-organization-name');
if (!header) return;
const origin = stage.getBoundingClientRect();
// The central chart and overlay move together inside the adaptive stage.
// Measuring against the overlay avoids adding that offset a second time.
const origin = overlay.getBoundingClientRect();
const bounds = header.getBoundingClientRect();
const scale = origin.width / stage.offsetWidth;
const scale = origin.width / workdeskCanvas.width;
if (!scale) return;
const card = stage.querySelector<HTMLElement>('.hm-orgchart-top-leaders .hm-orgchart-employee');
if (card) {
const leader = card.getBoundingClientRect();
const axis = (leader.left + leader.width / 2 - origin.left) / scale;
// Attach the upward tip to the live card's bottom edge in the same
// overlay coordinates, including proportional scale and center offset.
const headTop = (leader.bottom - origin.top) / scale;
representativeHead.setAttribute('transform', `translate(${(axis - 5).toFixed(4)} ${headTop.toFixed(4)})`);
representative.setAttribute('d', `M${axis.toFixed(4)} ${(headTop + 7 + 17).toFixed(4)}V286`);
}
const x = (bounds.left + bounds.width / 2 - origin.left) / scale;
const y = (bounds.top - origin.top) / scale + 2;
business.setAttribute('d', `M834 204C650 204 250 235 ${x.toFixed(2)} ${y.toFixed(2)}`);
@@ -0,0 +1,111 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fixture from '../../../../examples/org-chart/data-expanded-large.json';
import { companyColorPresets, type CompanyId } from '../company-color-presets.js';
import {
calculateSummary, createOrgChartExampleDataset, JsonOrgChartDataSource,
normalizeOrgChartDataset, snapshotOrgChartDataset, validateOrgChartDataset,
type OrgChartDataset, type OrgChartExampleEmployeeCount,
} from './index.js';
const base = fixture as OrgChartDataset;
const companies = Object.keys(companyColorPresets) as CompanyId[];
const sizes: OrgChartExampleEmployeeCount[] = [20, 100, 300];
function assertContract(dataset: OrgChartDataset) {
const result = validateOrgChartDataset(dataset);
assert.equal(result.valid, true, result.valid ? undefined : JSON.stringify(result.issues));
assert.equal(new Set(dataset.employees.map(employee => employee.id)).size, dataset.employees.length);
assert.equal(new Set(dataset.organizations.map(org => org.id)).size, dataset.organizations.length);
assert.equal(new Set(dataset.affiliations.map(affiliation => affiliation.id)).size, dataset.affiliations.length);
const summary = calculateSummary(dataset).raw;
assert.equal(summary.total, dataset.employees.length, 'leaders count once as employees');
const pending = normalizeOrgChartDataset(dataset).roots.map(node => ({ node, depth: 1 }));
let maximumDepth = 0;
while (pending.length) {
const { node, depth } = pending.pop()!;
maximumDepth = Math.max(maximumDepth, depth);
pending.push(...node.children.map(child => ({ node: child, depth: depth + 1 })));
}
assert.ok(maximumDepth >= 3);
assert.ok(new Set(Object.values(summary.byOrganization).map(value => value.direct)).size > 1, 'unequal organization membership');
}
for (const employeeCount of sizes) {
test(`total ${employeeCount} contains exactly that many unique employees across all seven preset companies`, () => {
const dataset = createOrgChartExampleDataset(base, { scope: 'total', employeeCount });
assertContract(dataset);
assert.equal(dataset.employees.length, employeeCount);
assert.deepEqual(dataset.affiliations.map(item => item.id), companies);
for (const companyId of companies) assert.ok(dataset.employees.some(employee => employee.affiliationId === companyId));
});
test(`per-company ${employeeCount} supports every company, pairs, and the full seven-company selection`, () => {
for (const companyId of companies) {
const dataset = createOrgChartExampleDataset(base, { scope: 'per-company', employeeCount, companyIds: [companyId] });
assertContract(dataset);
assert.equal(dataset.employees.length, employeeCount);
assert.ok(dataset.organizations.every(org => org.companyId === companyId));
assert.ok(dataset.employees.every(employee => employee.affiliationId === companyId));
}
for (const companyIds of [['hanmac', 'saman'], companies] as CompanyId[][]) {
const dataset = createOrgChartExampleDataset(base, { scope: 'per-company', employeeCount, companyIds });
assertContract(dataset);
assert.equal(dataset.employees.length, employeeCount * companyIds.length);
for (const id of companyIds) assert.equal(dataset.employees.filter(employee => employee.affiliationId === id).length, employeeCount);
}
});
}
test('total company filtering preserves company allocation without redistribution or lost ancestors', () => {
for (const employeeCount of sizes) {
const complete = createOrgChartExampleDataset(base, { scope: 'total', employeeCount });
const selected = createOrgChartExampleDataset(base, { scope: 'total', employeeCount, companyIds: ['saman', 'hanmac'] });
assertContract(selected);
assert.deepEqual(selected.employees, complete.employees.filter(employee => ['hanmac', 'saman'].includes(employee.affiliationId)));
assert.deepEqual(selected.organizations.map(org => org.id), complete.organizations.filter(org => ['hanmac', 'saman'].includes(org.companyId!)).map(org => org.id));
}
});
test('total 300 reuses every P5-3 large employee record and the original organization IDs', () => {
const dataset = createOrgChartExampleDataset(base, { scope: 'total', employeeCount: 300 });
const originalById = new Map(base.employees.map(employee => [employee.id, employee]));
assert.equal(dataset.employees.length, base.employees.length);
for (const employee of dataset.employees) assert.deepEqual(employee, originalById.get(employee.id));
assert.deepEqual(dataset.organizations, base.organizations);
});
test('selections are deterministic sets and snapshots never mutate the source or another result', async () => {
const before = structuredClone(base);
const frozen = snapshotOrgChartDataset(base);
const first = createOrgChartExampleDataset(frozen, { scope: 'per-company', employeeCount: 100, companyIds: ['saman', 'hanmac', 'saman'] });
const equivalent = createOrgChartExampleDataset(frozen, { scope: 'per-company', employeeCount: 100, companyIds: ['hanmac', 'saman'] });
assert.deepEqual(first, equivalent);
assert.notEqual(first, equivalent);
assert.notEqual(first.employees[0], equivalent.employees[0]);
assert.ok(Object.isFrozen(first) && Object.isFrozen(first.organizations) && Object.isFrozen(first.employees[0]));
assert.throws(() => { first.employees[0]!.name = 'changed'; }, TypeError);
assert.deepEqual(base, before);
assert.deepEqual(await new JsonOrgChartDataSource(first).load(), first);
const single = createOrgChartExampleDataset(base, { scope: 'per-company', employeeCount: 100, companyIds: ['saman'] });
assert.deepEqual(single.employees, first.employees.filter(employee => employee.affiliationId === 'saman'));
});
test('expansion resolves source IDs that already use the generated employee namespace', () => {
const template = structuredClone(base);
const employee = template.employees.find(employee => employee.affiliationId === 'hanmac' && !template.organizations.some(org => org.leaderId === employee.id))!;
employee.id = 'p7-virtual-hanmac-050';
const dataset = createOrgChartExampleDataset(template, { scope: 'per-company', employeeCount: 100 });
assertContract(dataset);
assert.ok(dataset.employees.some(employee => employee.id === 'p7-virtual-hanmac-050-example'));
});
test('invalid scale, company IDs, empty view selection, and incomplete templates fail explicitly', () => {
assert.throws(() => createOrgChartExampleDataset(base, { scope: 'invalid' as 'total', employeeCount: 20 }), /scope/);
assert.throws(() => createOrgChartExampleDataset(base, { scope: 'total', employeeCount: 99 as 20 }), /employeeCount/);
assert.throws(() => createOrgChartExampleDataset(base, { scope: 'total', employeeCount: 20, companyIds: ['unknown' as CompanyId] }), /presets/);
assert.throws(() => createOrgChartExampleDataset(base, { scope: 'total', employeeCount: 20, companyIds: [] }), /empty view selection/);
const incomplete = structuredClone(base);
incomplete.employees = incomplete.employees.filter(employee => employee.affiliationId !== 'baron');
incomplete.organizations.filter(org => org.companyId === 'baron').forEach(org => { org.leaderId = null; });
assert.throws(() => createOrgChartExampleDataset(incomplete, { scope: 'total', employeeCount: 20 }), /all seven/);
});
+124
View File
@@ -0,0 +1,124 @@
import { companyColorPresets, type CompanyId } from '../company-color-presets.js';
import { normalizeOrgChartDataset } from './normalize.js';
import { snapshotOrgChartDataset } from './snapshot.js';
import { validateOrgChartDataset } from './validate.js';
import type { Employee, OrgChartDataset, Organization } from './types.js';
export type OrgChartExampleScope = 'total' | 'per-company';
export type OrgChartExampleEmployeeCount = 20 | 100 | 300;
export interface OrgChartExampleSelection {
/** Total is allocated across all seven companies before company filtering. */
scope: OrgChartExampleScope;
employeeCount: OrgChartExampleEmployeeCount;
/** Omission selects all companies. An empty selection belongs to view state. */
companyIds?: readonly CompanyId[];
}
const companyIds = Object.keys(companyColorPresets) as CompanyId[];
/** Deterministic virtual examples derived from an existing seven-company fixture.
* This provider is independent of layout, React and popup policy. Use its result
* with the existing JsonOrgChartDataSource; no production/API source is replaced.
*
* The P5-3 large fixture's hierarchy and employee records are reused. Larger
* company examples add virtual, company-namespaced employees. Neither the fixture
* nor a previously returned dataset is modified. Total-company selection never
* redistributes unselected companies' staff into a remaining company.
*/
export function createOrgChartExampleDataset(template: OrgChartDataset, selection: OrgChartExampleSelection): OrgChartDataset {
if (!selection || !['total', 'per-company'].includes(selection.scope)) throw new RangeError('Example scope must be total or per-company.');
if (![20, 100, 300].includes(selection.employeeCount)) throw new RangeError('Example employeeCount must be 20, 100 or 300.');
const requested = selection.companyIds ?? companyIds;
if (!Array.isArray(requested) || !requested.length) throw new RangeError('Example data requires at least one company; use an empty view selection to hide all companies.');
if (requested.some(id => !Object.hasOwn(companyColorPresets, id))) throw new RangeError('Example companyIds require registered company presets.');
// Preset order makes equivalent set selections identical, including IDs/order.
const selected = companyIds.filter(id => requested.includes(id));
const validation = validateOrgChartDataset(template);
if (!validation.valid) throw new TypeError('Example template must be a valid OrgChart dataset.');
const base = validation.data;
const organizationCompanies = resolveTemplateCompanies(base);
const rosters = new Map<CompanyId, Employee[]>(companyIds.map(id => [id, []]));
for (const employee of base.employees) {
const company = organizationCompanies.get(employee.organizationId)!;
if (employee.affiliationId !== company) throw new TypeError('Example employees must belong to their organization company affiliation.');
rosters.get(company)!.push(employee);
}
if (companyIds.some(id => !rosters.get(id)!.length)) throw new TypeError('Example template must contain employees from all seven companies.');
const counts = selection.scope === 'total'
? allocateTotal(selection.employeeCount, companyIds.map(id => rosters.get(id)!.length))
: companyIds.map(() => selection.employeeCount);
const chosen = new Set<CompanyId>(selected);
const organizations: Organization[] = base.organizations.filter(org => chosen.has(organizationCompanies.get(org.id)!))
.map(org => ({ ...org, companyId: organizationCompanies.get(org.id)! }));
const employees: Employee[] = [];
const existingIds = new Set(base.employees.map(employee => employee.id));
const organizationPool = new Map<CompanyId, Organization[]>(selected.map(id => [id, organizations.filter(org => org.companyId === id && org.parentId !== null)]));
for (const id of selected) {
const roster = rosters.get(id)!;
const count = counts[companyIds.indexOf(id)]!;
employees.push(...roster.slice(0, count).map(employee => ({ ...employee })));
// Unequal weighted placement reuses the original tree instead of cloning it.
const pool = organizationPool.get(id)!;
const weighted = (pool.length ? pool : organizations.filter(org => org.companyId === id))
.flatMap((org, index) => Array.from({ length: 1 + index % 4 }, () => org));
for (let index = roster.length; index < count; index++) {
let employeeId = `p7-virtual-${id}-${String(index + 1).padStart(3, '0')}`;
// A caller fixture may already use our namespace. Resolve collisions without
// changing the input or tying identity to the other selected companies.
while (existingIds.has(employeeId)) employeeId += '-example';
existingIds.add(employeeId);
const organization = weighted[(index - roster.length) % weighted.length]!;
employees.push({
id: employeeId, organizationId: organization.id, affiliationId: id,
name: `가상직원 ${companyColorPresets[id].companyName} ${String(index + 1).padStart(3, '0')}`,
position: ['사원', '선임', '책임', '수석'][index % 4]!, jobTitle: null,
profileImage: null, workStatus: 'active',
});
}
}
const employeeIds = new Set(employees.map(employee => employee.id));
for (const organization of organizations) if (organization.leaderId && !employeeIds.has(organization.leaderId)) organization.leaderId = null;
const organizationIds = new Set(organizations.map(org => org.id));
return snapshotOrgChartDataset({
...base, organizations, employees,
affiliations: base.affiliations.filter(affiliation => chosen.has(affiliation.id as CompanyId)),
...(base.relations ? { relations: base.relations.filter(relation => organizationIds.has(relation.sourceOrganizationId) && organizationIds.has(relation.targetOrganizationId)) } : {}),
...(base.presentation ? { presentation: { ...base.presentation, organizationOrder: base.presentation.organizationOrder.filter(id => organizationIds.has(id)) } } : {}),
metadata: { ...base.metadata, dataVersion: `p7-example-${selection.scope}-${selection.employeeCount}-${selected.join('_')}`, updateNote: '가상 예시 데이터 · 운영 정보 아님' },
});
}
function resolveTemplateCompanies(data: OrgChartDataset): Map<string, CompanyId> {
const employees = new Map(data.employees.map(employee => [employee.id, employee]));
const result = new Map<string, CompanyId>();
const pending = normalizeOrgChartDataset(data).roots.map(node => ({ node, inherited: undefined as CompanyId | undefined }));
while (pending.length) {
const { node, inherited } = pending.pop()!;
const company = node.organization.companyId ?? inherited ?? (node.organization.leaderId ? employees.get(node.organization.leaderId)?.affiliationId : undefined);
if (!company || !Object.hasOwn(companyColorPresets, company) || (inherited && company !== inherited)) throw new TypeError('Example template requires a consistent registered company on each organization tree.');
result.set(node.organization.id, company as CompanyId);
pending.push(...node.children.map(child => ({ node: child, inherited: company as CompanyId })));
}
return result;
}
/** Largest-remainder allocation, with at least one employee in every company. */
function allocateTotal(total: number, weights: number[]): number[] {
const sum = weights.reduce((value, weight) => value + weight, 0);
const targets = weights.map(weight => total * weight / sum);
const counts = targets.map(target => Math.max(1, Math.floor(target)));
let current = counts.reduce((value, count) => value + count, 0);
while (current !== total) {
const adding = current < total;
let candidate = -1;
let greatest = -Infinity;
counts.forEach((count, index) => {
if (!adding && count <= 1) return;
const difference = adding ? targets[index]! - count : count - targets[index]!;
if (difference > greatest) { greatest = difference; candidate = index; }
});
counts[candidate]! += adding ? 1 : -1;
current += adding ? 1 : -1;
}
return counts;
}
+4
View File
@@ -4,3 +4,7 @@ export { normalizeOrgChartDataset } from './normalize.js';
export { calculateSummary } from './summary.js';
export { snapshotOrgChartDataset } from './snapshot.js';
export { JsonOrgChartDataSource, OrgChartSourceError, type OrgChartSourceErrorCode, type JsonOrgChartDataSourceOptions } from './source.js';
export { createOrgChartExampleDataset } from './examples.js';
export type { OrgChartExampleScope, OrgChartExampleEmployeeCount, OrgChartExampleSelection } from './examples.js';
export { createOrgChartMoveValidator, validateOrgChartMove, applyOrgChartMove } from './moves.js';
export type { OrgChartMoveRequest, OrgChartMoveFailureCode, OrgChartMoveFailure, OrgChartMoveValidation, OrgChartMoveResult } from './moves.js';
+214
View File
@@ -0,0 +1,214 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyOrgChartMove, createOrgChartMoveValidator, validateOrgChartMove, type OrgChartMoveFailureCode, type OrgChartMoveRequest } from './moves.js';
import { calculateSummary } from './summary.js';
import { normalizeOrgChartDataset } from './normalize.js';
import { snapshotOrgChartDataset } from './snapshot.js';
import { validateOrgChartDataset } from './validate.js';
import type { Employee, OrgChartDataset } from './types.js';
function member(id: string, organizationId: string, affiliationId = 'company-a'): Employee {
return { id, organizationId, affiliationId, name: `가상 ${id}`, position: '선임', jobTitle: null, profileImage: './profiles/virtual.png', workStatus: 'active' };
}
function fixture(): OrgChartDataset {
return {
schemaVersion: '0.1-draft',
organizations: [
{ id: 'a-root', companyId: 'company-a', parentId: null, name: '가상 A', type: 'root', leaderId: 'root-leader', displayOrder: 0 },
{ id: 'a-division', parentId: 'a-root', name: '가상 본부', type: 'division', leaderId: null, displayOrder: 4 },
{ id: 'a-team', parentId: 'a-division', name: '가상 팀', type: 'team', leaderId: 'team-leader', displayOrder: 8 },
{ id: 'a-cell', parentId: 'a-team', name: '가상 셀', type: 'cell', leaderId: null, displayOrder: 7 },
{ id: 'a-other', parentId: 'a-root', name: '가상 다른 본부', type: 'division', leaderId: null, displayOrder: 2 },
{ id: 'b-root', companyId: 'company-b', parentId: null, name: '가상 B', type: 'root', leaderId: null, displayOrder: 1 },
{ id: 'b-team', parentId: 'b-root', name: '가상 B 팀', type: 'team', leaderId: null, displayOrder: 1 },
],
employees: [member('root-leader', 'a-root'), member('team-leader', 'a-team'), member('staff', 'a-team', 'company-b'), member('cell-staff', 'a-cell'), member('b-staff', 'b-team', 'company-b')],
affiliations: [{ id: 'company-a', name: '가상 A 회사' }, { id: 'company-b', name: '가상 B 회사' }],
relations: [{ id: 'support', sourceOrganizationId: 'a-team', targetOrganizationId: 'a-other', type: 'support' }],
presentation: {
title: '가상 조직도', description: null, hubText: null,
groups: { showEmployees: true, showSummary: true, showDispatched: true, showOnLeave: true, showAffiliations: true },
organizationOrder: ['a-other', 'a-team'],
},
metadata: { asOfDate: '2026-10-13', dataVersion: 'move-test' },
};
}
function request(kind: OrgChartMoveRequest['kind'], sourceId: string, targetOrganizationId: string): OrgChartMoveRequest {
return { kind, sourceId, targetOrganizationId };
}
function expectRejected(data: OrgChartDataset, move: OrgChartMoveRequest, code: OrgChartMoveFailureCode) {
const before = JSON.stringify(data);
const policy = validateOrgChartMove(data, move);
assert.equal(policy.allowed, false);
if (!policy.allowed) { assert.equal(policy.code, code); assert.ok(policy.message.length > 0); }
const result = applyOrgChartMove(data, move);
assert.equal(result.moved, false);
if (!result.moved) assert.equal(result.code, code);
assert.equal(JSON.stringify(data), before, 'rejected movement retains every input record');
}
test('employee move changes organizationId alone, retains affiliation and IDs, updates counts, and owns frozen state', () => {
const original = snapshotOrgChartDataset(fixture());
const before = JSON.stringify(original);
const move = request('employee', 'staff', 'a-other');
const result = applyOrgChartMove(original, move);
assert.equal(result.moved, true);
if (!result.moved) return;
assert.equal(result.previousOrganizationId, 'a-team');
assert.deepEqual(result.request, move);
assert.deepEqual(result.data.employees.find(employee => employee.id === 'staff'), { ...original.employees.find(employee => employee.id === 'staff')!, organizationId: 'a-other' });
assert.deepEqual(result.data, { ...original, employees: original.employees.map(employee => employee.id === 'staff' ? { ...employee, organizationId: 'a-other' } : employee) });
assert.equal(JSON.stringify(original), before);
assert.ok(Object.isFrozen(result.data) && Object.isFrozen(result.data.employees) && Object.isFrozen(result.data.employees[0]) && Object.isFrozen(result.request));
assert.notEqual(result.data.employees[0], original.employees[0]);
const normalized = normalizeOrgChartDataset(result.data);
assert.equal(normalized.employeesByOrganization.get('a-team')!.some(employee => employee.id === 'staff'), false);
assert.equal(normalized.employeesByOrganization.get('a-other')!.filter(employee => employee.id === 'staff').length, 1);
const beforeCounts = calculateSummary(original).raw;
const afterCounts = calculateSummary(result.data).raw;
assert.equal(afterCounts.total, beforeCounts.total);
assert.equal(afterCounts.byOrganization['a-team']!.direct, beforeCounts.byOrganization['a-team']!.direct - 1);
assert.equal(afterCounts.byOrganization['a-other']!.inclusive, beforeCounts.byOrganization['a-other']!.inclusive + 1);
assert.equal(new Set(result.data.employees.map(employee => employee.id)).size, original.employees.length);
assert.equal(validateOrgChartDataset(result.data).valid, true);
});
test('group move changes parentId alone, preserving subtree, leaders, sorting, company, relations and employee total', () => {
const original = snapshotOrgChartDataset(fixture());
const before = JSON.stringify(original);
const result = applyOrgChartMove(original, request('organization', 'a-team', 'a-other'));
assert.equal(result.moved, true);
if (!result.moved) return;
assert.equal(result.previousOrganizationId, 'a-division');
assert.deepEqual(result.data, { ...original, organizations: original.organizations.map(org => org.id === 'a-team' ? { ...org, parentId: 'a-other' } : org) });
assert.equal(JSON.stringify(original), before);
const normalized = normalizeOrgChartDataset(result.data);
assert.equal(normalized.organizationsById.get('a-cell')!.parentId, 'a-team');
assert.equal(normalized.organizationsById.get('a-team')!.leaderId, 'team-leader');
assert.equal(normalized.organizationsById.get('a-team')!.displayOrder, 8);
assert.equal(new Set(result.data.organizations.map(org => org.id)).size, original.organizations.length);
const beforeCounts = calculateSummary(original).raw;
const afterCounts = calculateSummary(result.data).raw;
assert.equal(afterCounts.total, beforeCounts.total);
assert.equal(afterCounts.byOrganization['a-team']!.inclusive, beforeCounts.byOrganization['a-team']!.inclusive);
assert.equal(afterCounts.byOrganization['a-division']!.inclusive, 0);
assert.equal(afterCounts.byOrganization['a-other']!.inclusive, beforeCounts.byOrganization['a-team']!.inclusive);
assert.equal(validateOrgChartDataset(result.data).valid, true);
});
test('employee policy rejects leaders, absent IDs and existing membership', () => {
const data = fixture();
expectRejected(data, request('employee', 'team-leader', 'a-other'), 'LEADER_PROTECTED');
expectRejected(data, request('employee', 'missing', 'a-other'), 'SOURCE_NOT_FOUND');
expectRejected(data, request('employee', 'staff', 'missing'), 'TARGET_NOT_FOUND');
expectRejected(data, request('employee', 'staff', 'a-team'), 'SAME_ORGANIZATION');
});
test('group policy rejects roots, self, descendants, existing parent and absent IDs', () => {
const data = fixture();
expectRejected(data, request('organization', 'a-root', 'a-other'), 'ROOT_PROTECTED');
expectRejected(data, request('organization', 'a-team', 'a-team'), 'SELF_TARGET');
expectRejected(data, request('organization', 'a-team', 'a-cell'), 'DESCENDANT_TARGET');
expectRejected(data, request('organization', 'a-team', 'a-division'), 'SAME_ORGANIZATION');
expectRejected(data, request('organization', 'missing', 'a-other'), 'SOURCE_NOT_FOUND');
expectRejected(data, request('organization', 'a-team', 'missing'), 'TARGET_NOT_FOUND');
});
test('cross-company employee move updates existing organization and affiliation references, company totals and immutable state', () => {
const data = fixture(); data.employees[2]!.affiliationId = 'company-a';
const original = snapshotOrgChartDataset(data), before = JSON.stringify(original);
const move = request('employee', 'staff', 'b-team');
assert.deepEqual(validateOrgChartMove(original, move), { allowed: true, previousOrganizationId: 'a-team', previousCompanyId: 'company-a', companyId: 'company-b' });
const result = applyOrgChartMove(original, move);
assert.equal(result.moved, true);
if (!result.moved) return;
assert.deepEqual(result.data, { ...original, employees: original.employees.map(employee => employee.id === 'staff'
? { ...employee, organizationId: 'b-team', affiliationId: 'company-b' } : employee) });
const beforeCounts = calculateSummary(original).raw, afterCounts = calculateSummary(result.data).raw;
assert.equal(afterCounts.total, beforeCounts.total);
assert.equal(afterCounts.byOrganization['a-root']!.inclusive, beforeCounts.byOrganization['a-root']!.inclusive - 1);
assert.equal(afterCounts.byOrganization['b-root']!.inclusive, beforeCounts.byOrganization['b-root']!.inclusive + 1);
assert.equal(afterCounts.byAffiliation['company-a'], beforeCounts.byAffiliation['company-a']! - 1);
assert.equal(afterCounts.byAffiliation['company-b'], beforeCounts.byAffiliation['company-b']! + 1);
assert.equal(new Set(result.data.employees.map(employee => employee.id)).size, original.employees.length);
assert.ok(Object.isFrozen(result.data.employees[2]));
assert.equal(JSON.stringify(original), before);
assert.equal(validateOrgChartDataset(result.data).valid, true);
});
test('cross-company group move transfers all descendant companies and employee affiliations while retaining subtree and leaders', () => {
const original = snapshotOrgChartDataset(fixture()), before = JSON.stringify(original);
const result = applyOrgChartMove(original, request('organization', 'a-team', 'b-team'));
assert.equal(result.moved, true);
if (!result.moved) return;
const subtree = new Set(['a-team', 'a-cell']);
assert.deepEqual(result.data, { ...original,
organizations: original.organizations.map(org => org.id === 'a-team' ? { ...org, parentId: 'b-team', companyId: 'company-b' }
: subtree.has(org.id) ? { ...org, companyId: 'company-b' } : org),
employees: original.employees.map(employee => subtree.has(employee.organizationId) ? { ...employee, affiliationId: 'company-b' } : employee),
});
assert.equal(result.data.organizations.find(org => org.id === 'a-cell')!.parentId, 'a-team');
assert.equal(result.data.organizations.find(org => org.id === 'a-team')!.leaderId, 'team-leader');
assert.equal(result.data.employees.find(employee => employee.id === 'team-leader')!.organizationId, 'a-team');
const beforeCounts = calculateSummary(original).raw, afterCounts = calculateSummary(result.data).raw;
const transferred = beforeCounts.byOrganization['a-team']!.inclusive;
assert.equal(afterCounts.total, beforeCounts.total);
assert.equal(afterCounts.byOrganization['a-root']!.inclusive, beforeCounts.byOrganization['a-root']!.inclusive - transferred);
assert.equal(afterCounts.byOrganization['b-root']!.inclusive, beforeCounts.byOrganization['b-root']!.inclusive + transferred);
assert.equal(afterCounts.byAffiliation['company-b'], 4);
assert.equal(new Set(result.data.organizations.map(org => org.id)).size, original.organizations.length);
assert.equal(new Set(result.data.employees.map(employee => employee.id)).size, original.employees.length);
assert.equal(JSON.stringify(original), before);
assert.equal(validateOrgChartDataset(result.data).valid, true);
assert.equal(validateOrgChartMove(result.data, request('organization', 'a-cell', 'a-other')).allowed, true, 'transferred subtree can subsequently move back');
});
test('cross-company moves reject unknown target affiliation rather than inventing a new company record', () => {
const data = fixture(); data.organizations[5]!.companyId = 'unregistered-company';
assert.equal(validateOrgChartDataset(data).valid, true);
expectRejected(data, request('employee', 'staff', 'b-team'), 'TARGET_AFFILIATION_MISSING');
expectRejected(data, request('organization', 'a-team', 'b-team'), 'TARGET_AFFILIATION_MISSING');
});
test('policy rejects malformed request, duplicate IDs, invalid source relationships and company hierarchy contradictions', () => {
expectRejected(fixture(), { kind: 'unknown', sourceId: 'staff', targetOrganizationId: 'a-other' } as unknown as OrgChartMoveRequest, 'INVALID_REQUEST');
const duplicate = fixture(); duplicate.employees.push({ ...duplicate.employees[2]! });
expectRejected(duplicate, request('employee', 'staff', 'a-other'), 'INVALID_DATA');
const cycle = fixture(); cycle.organizations[1]!.parentId = 'a-cell';
expectRejected(cycle, request('employee', 'staff', 'a-other'), 'INVALID_DATA');
const invalidEmployee = fixture(); invalidEmployee.employees[2]!.organizationId = 'missing';
expectRejected(invalidEmployee, request('employee', 'staff', 'a-other'), 'INVALID_DATA');
const conflict = fixture(); conflict.organizations[2]!.companyId = 'company-b';
expectRejected(conflict, request('organization', 'a-team', 'a-other'), 'INVALID_DATA');
});
test('legacy roots derive company through their leader but unresolved company identity prevents moves', () => {
const legacy = fixture(); delete legacy.organizations[0]!.companyId;
assert.equal(validateOrgChartMove(legacy, request('employee', 'staff', 'a-other')).allowed, true);
legacy.organizations[0]!.leaderId = null;
expectRejected(legacy, request('employee', 'staff', 'a-other'), 'COMPANY_UNKNOWN');
});
test('drop-target validator snapshots once and retains its policy if a caller later mutates the dataset', () => {
const data = fixture();
const policy = createOrgChartMoveValidator(data);
data.employees[2]!.organizationId = 'b-team';
assert.deepEqual(policy(request('employee', 'staff', 'a-other')), { allowed: true, previousOrganizationId: 'a-team', previousCompanyId: 'company-a', companyId: 'company-a' });
assert.deepEqual(validateOrgChartMove(data, request('employee', 'staff', 'a-other')), { allowed: true, previousOrganizationId: 'b-team', previousCompanyId: 'company-b', companyId: 'company-a' });
});
test('opaque IDs remain safe move keys without duplicate or synthetic records', () => {
const data = fixture();
data.organizations[4]!.id = '__proto__';
data.relations![0]!.targetOrganizationId = '__proto__';
data.presentation!.organizationOrder[0] = '__proto__';
const result = applyOrgChartMove(data, request('employee', 'staff', '__proto__'));
assert.equal(result.moved, true);
if (!result.moved) return;
assert.equal(result.data.employees.find(employee => employee.id === 'staff')!.organizationId, '__proto__');
assert.equal(result.data.employees.length, data.employees.length);
assert.equal(validateOrgChartDataset(result.data).valid, true);
});
+150
View File
@@ -0,0 +1,150 @@
import { normalizeOrgChartDataset } from './normalize.js';
import { validateOrgChartDataset } from './validate.js';
import type { OrgChartDataset } from './types.js';
/** Source IDs retain their original collection identity after a browser-state move. */
export interface OrgChartMoveRequest {
kind: 'employee' | 'organization';
sourceId: string;
targetOrganizationId: string;
}
export type OrgChartMoveFailureCode =
| 'EDITING_DISABLED'
| 'INVALID_DATA' | 'INVALID_REQUEST' | 'SOURCE_NOT_FOUND' | 'TARGET_NOT_FOUND'
| 'COMPANY_UNKNOWN' | 'TARGET_AFFILIATION_MISSING' | 'LEADER_PROTECTED' | 'ROOT_PROTECTED'
| 'SELF_TARGET' | 'DESCENDANT_TARGET' | 'SAME_ORGANIZATION';
export interface OrgChartMoveFailure {
code: OrgChartMoveFailureCode;
message: string;
}
export type OrgChartMoveValidation =
| { allowed: true; previousOrganizationId: string; previousCompanyId: string; companyId: string }
| ({ allowed: false } & OrgChartMoveFailure);
export type OrgChartMoveResult =
| { moved: true; data: OrgChartDataset; request: OrgChartMoveRequest; previousOrganizationId: string }
| ({ moved: false } & OrgChartMoveFailure);
const messages: Record<OrgChartMoveFailureCode, string> = {
EDITING_DISABLED: 'Drag & Drop이 OFF입니다. 조직 이동을 사용하려면 ON으로 설정하세요.',
INVALID_DATA: '조직 데이터의 관계가 유효하지 않아 이동할 수 없습니다.',
INVALID_REQUEST: '이동 요청이 올바르지 않습니다.',
SOURCE_NOT_FOUND: '이동할 직원 또는 조직이 존재하지 않습니다.',
TARGET_NOT_FOUND: '이동할 대상 조직이 존재하지 않습니다.',
COMPANY_UNKNOWN: '회사 소속을 확인할 수 없어 이동할 수 없습니다.',
TARGET_AFFILIATION_MISSING: '대상 회사의 소속 정보가 없어 회사 간 이동을 완료할 수 없습니다.',
LEADER_PROTECTED: '조직장은 조직장 지정 정책을 먼저 변경해야 이동할 수 있습니다.',
ROOT_PROTECTED: '회사 최상위 조직은 이동할 수 없습니다.',
SELF_TARGET: '조직을 자기 자신에게 이동할 수 없습니다.',
DESCENDANT_TARGET: '하위 조직으로 이동하면 순환 관계가 생겨 이동할 수 없습니다.',
SAME_ORGANIZATION: '이미 해당 조직에 속해 있습니다.',
};
function deny(code: OrgChartMoveFailureCode): OrgChartMoveValidation {
return { allowed: false, code, message: messages[code] };
}
/** Validate once for all highlighted drop targets. The closure owns an immutable
* dataset snapshot, so later consumer mutations cannot change a drag's policy.
* Company is the organization tree's identity, not an employee's affiliation.
* Legacy roots may establish it through their leader's affiliation; no arbitrary
* fallback company is invented when ownership cannot be established.
*/
export function createOrgChartMoveValidator(data: OrgChartDataset): (request: OrgChartMoveRequest) => OrgChartMoveValidation {
const validation = validateOrgChartDataset(data);
if (!validation.valid) return () => deny('INVALID_DATA');
const owned = validation.data;
const normalized = normalizeOrgChartDataset(owned);
const employees = new Map(owned.employees.map(employee => [employee.id, employee]));
const affiliations = new Set(owned.affiliations.map(affiliation => affiliation.id));
const leaders = new Set(owned.organizations.flatMap(org => org.leaderId === null ? [] : [org.leaderId]));
const companies = new Map<string, string | undefined>();
const pending = normalized.roots.map(node => ({ node, inherited: undefined as string | undefined }));
while (pending.length) {
const { node, inherited } = pending.pop()!;
const org = node.organization;
const company = org.companyId ?? inherited ?? (org.parentId === null && org.leaderId ? employees.get(org.leaderId)?.affiliationId : undefined);
if (inherited && company !== inherited) return () => deny('INVALID_DATA');
companies.set(org.id, company);
pending.push(...node.children.map(child => ({ node: child, inherited: company })));
}
return request => {
if (!request || !['employee', 'organization'].includes(request.kind)
|| typeof request.sourceId !== 'string' || !request.sourceId
|| typeof request.targetOrganizationId !== 'string' || !request.targetOrganizationId) return deny('INVALID_REQUEST');
const source = request.kind === 'employee' ? employees.get(request.sourceId) : normalized.organizationsById.get(request.sourceId);
if (!source) return deny('SOURCE_NOT_FOUND');
const target = normalized.organizationsById.get(request.targetOrganizationId);
if (!target) return deny('TARGET_NOT_FOUND');
const sourceOrganization = request.kind === 'employee'
? normalized.organizationsById.get(employees.get(request.sourceId)!.organizationId)!
: normalized.organizationsById.get(request.sourceId)!;
const previousOrganizationId = request.kind === 'employee' ? sourceOrganization.id : sourceOrganization.parentId;
if (request.kind === 'employee') {
if (sourceOrganization.id === target.id) return deny('SAME_ORGANIZATION');
if (leaders.has(request.sourceId)) return deny('LEADER_PROTECTED');
} else {
if (sourceOrganization.id === target.id) return deny('SELF_TARGET');
if (sourceOrganization.parentId === null || sourceOrganization.type === 'root') return deny('ROOT_PROTECTED');
if (sourceOrganization.parentId === target.id) return deny('SAME_ORGANIZATION');
let ancestor: typeof target | undefined = target;
while (ancestor) {
if (ancestor.id === sourceOrganization.id) return deny('DESCENDANT_TARGET');
ancestor = ancestor.parentId === null ? undefined : normalized.organizationsById.get(ancestor.parentId);
}
}
const sourceCompany = companies.get(sourceOrganization.id);
const targetCompany = companies.get(target.id);
if (!sourceCompany || !targetCompany) return deny('COMPANY_UNKNOWN');
if (sourceCompany !== targetCompany && !affiliations.has(targetCompany)) return deny('TARGET_AFFILIATION_MISSING');
return { allowed: true, previousOrganizationId: previousOrganizationId!, previousCompanyId: sourceCompany, companyId: targetCompany };
};
}
/** Pure policy check for a single keyboard or pointer move. */
export function validateOrgChartMove(data: OrgChartDataset, request: OrgChartMoveRequest): OrgChartMoveValidation {
return createOrgChartMoveValidator(data)(request);
}
/** Create a new validated, deeply frozen browser-state dataset. Same-company
* moves change membership alone. Cross-company employee moves also change the
* existing affiliationId; group moves retarget companyId for its whole subtree
* and affiliationId for every subtree employee. No new contract fields are used.
* IDs, descendants, leaders, personal data and display ordering are retained.
* Failed moves return no replacement data and never alter the input.
*/
export function applyOrgChartMove(data: OrgChartDataset, request: OrgChartMoveRequest): OrgChartMoveResult {
const policy = validateOrgChartMove(data, request);
if (!policy.allowed) return { moved: false, code: policy.code, message: policy.message };
const crossCompany = policy.previousCompanyId !== policy.companyId;
let candidate: OrgChartDataset;
if (request.kind === 'employee') {
candidate = { ...data, employees: data.employees.map(employee => employee.id === request.sourceId
? { ...employee, organizationId: request.targetOrganizationId, ...(crossCompany ? { affiliationId: policy.companyId } : {}) } : employee) };
} else {
const subtree = new Set<string>([request.sourceId]);
if (crossCompany) {
const children = new Map<string, string[]>();
for (const org of data.organizations) {
if (org.parentId === null) continue;
const siblings = children.get(org.parentId) ?? [];
siblings.push(org.id); children.set(org.parentId, siblings);
}
const pending = [request.sourceId];
while (pending.length) for (const id of children.get(pending.pop()!) ?? []) { subtree.add(id); pending.push(id); }
}
candidate = {
...data,
organizations: data.organizations.map(org => org.id === request.sourceId
? { ...org, parentId: request.targetOrganizationId, ...(crossCompany ? { companyId: policy.companyId } : {}) }
: crossCompany && subtree.has(org.id) ? { ...org, companyId: policy.companyId } : org),
employees: crossCompany ? data.employees.map(employee => subtree.has(employee.organizationId)
? { ...employee, affiliationId: policy.companyId } : employee) : data.employees,
};
}
const validation = validateOrgChartDataset(candidate);
if (!validation.valid) return { moved: false, code: 'INVALID_DATA', message: messages.INVALID_DATA };
return { moved: true, data: validation.data, request: Object.freeze({ ...request }), previousOrganizationId: policy.previousOrganizationId };
}
+1 -1
View File
@@ -2,6 +2,6 @@ export * from './data/index.js';
export { companyColorPresets, getCompanyColorPreset } from './company-color-presets.js';
export type { CompanyColorPreset, CompanyId } from './company-color-presets.js';
export { createOrgChart } from './core/index.js';
export type { OrgChartConnectionDirection, OrgChartTopNode, OrgChartViewportState, OrgChartDisplayMode, OrgChartLayoutMode, OrgChartEmployeeClickContext, OrgChartHandle, OrgChartOptions, OrgChartOrganizationLinks, OrgChartState } from './core/types.js';
export type { OrgChartInteractionOptions, OrgChartAutoLayoutType, OrgChartAutoLayoutOptions, OrgChartConnectionDirection, OrgChartTopNode, OrgChartViewportState, OrgChartDisplayMode, OrgChartLayoutMode, OrgChartEmployeeClickContext, OrgChartHandle, OrgChartOptions, OrgChartOrganizationLinks, OrgChartState } from './core/types.js';
export { createOrgChartTree, createPositionSummary, createAffiliationSummary, createStatusSummary } from './components.js';
export type { OrgChartSummaryComponentOptions } from './components.js';
@@ -6,8 +6,7 @@
}
.hm-orgchart-organization-link:hover { text-decoration: underline; }
.hm-orgchart-organization-link:focus-visible,
.hm-orgchart-employee[role="button"]:focus-visible,
.hm-orgchart-employee-dialog-close:focus-visible {
.hm-orgchart-employee[role="button"]:focus-visible {
outline: 2px solid var(--hm-orgchart-resolved-stroke-input-focus);
outline-offset: 3px;
}
@@ -27,16 +26,54 @@
.hm-orgchart-employee-dialog::backdrop {
background: color-mix(in srgb, var(--hm-orgchart-resolved-fg-primary) 38%, transparent);
}
.hm-orgchart-employee-dialog-header { display: flex; align-items: center; justify-content: space-between; gap: var(--hm-orgchart-resolved-spacing-lg); }
.hm-orgchart-employee-dialog-header {
display: flex; align-items: center; justify-content: space-between; gap: var(--hm-orgchart-resolved-spacing-lg);
/* Reserve the former text button's height so the title, body and dialog size stay put. */
min-height: calc(1lh + 2 * var(--hm-orgchart-resolved-spacing-xs) + 2 * var(--hm-orgchart-resolved-border-width));
}
.hm-orgchart-employee-dialog-header h2 { margin: 0; font-size: 18px; font-weight: var(--hm-orgchart-resolved-font-weight-bold); }
.hm-orgchart-employee-dialog-close {
padding: var(--hm-orgchart-resolved-spacing-xs) var(--hm-orgchart-resolved-spacing-sm);
border: var(--hm-orgchart-resolved-border-width) solid var(--hm-orgchart-resolved-stroke-default);
border-radius: var(--hm-orgchart-resolved-radius-control-md);
background: var(--hm-orgchart-resolved-bg-content);
color: inherit;
font: inherit;
cursor: pointer;
/* Match HM Dialog's ghost / icon-sm Button without a React or theme dependency.
Fallbacks are the declared HM defaults (theme/default.css and primitives.css). */
--hm-orgchart-dialog-close-ink: var(--foreground, var(--hm-sem-fg-primary, #0a221d));
--hm-orgchart-dialog-close-hover: var(--muted, var(--hm-sem-bg-subtle, #f4f1ea));
--hm-orgchart-dialog-close-ring: var(--ring, var(--hm-sem-stroke-input-focus, #266651));
position: absolute;
top: calc(var(--spacing, .25rem) * 2);
right: calc(var(--spacing, .25rem) * 2);
display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;
box-sizing: border-box;
width: calc(var(--spacing, .25rem) * 7);
height: calc(var(--spacing, .25rem) * 7);
padding: 0;
border: 1px solid transparent;
border-radius: min(var(--radius-md, calc(var(--radius, var(--hm-radius-container, 4px)) * .8)), 12px);
background: transparent;
background-clip: padding-box;
color: var(--hm-orgchart-dialog-close-ink);
font: inherit; font-size: .875rem; line-height: calc(var(--spacing, .25rem) * 5); font-weight: 500;
outline: none; outline-offset: 0; box-shadow: none;
cursor: default; user-select: none; white-space: nowrap;
transition: all 150ms cubic-bezier(.4, 0, .2, 1);
}
.hm-orgchart-employee-dialog-close svg {
width: calc(var(--spacing, .25rem) * 4);
height: calc(var(--spacing, .25rem) * 4);
pointer-events: none; flex-shrink: 0;
}
.hm-orgchart-employee-dialog-close:hover {
background-color: var(--hm-orgchart-dialog-close-hover);
}
:host-context(.dark) .hm-orgchart-employee-dialog-close:hover,
.dark .hm-orgchart-employee-dialog-close:hover {
background-color: color-mix(in oklab, var(--hm-orgchart-dialog-close-hover) 50%, transparent);
}
.hm-orgchart-employee-dialog-close:focus-visible {
border-color: var(--hm-orgchart-dialog-close-ring);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--hm-orgchart-dialog-close-ring) 50%, transparent);
}
.hm-orgchart-employee-dialog-close:active {
transform: translateY(1px);
}
.hm-orgchart-employee-dialog-fields {
display: grid;
@@ -124,6 +124,9 @@
.hm-orgchart-department-colors > p { grid-column: 1 / -1; }
.hm-orgchart-representative-preview { grid-column: 1 / -1; }
.hm-orgchart-color-preview { min-width: 0; padding: var(--hm-orgchart-resolved-spacing-lg); border: var(--hm-orgchart-resolved-border-width) solid var(--hm-orgchart-current-border); border-radius: var(--hm-orgchart-resolved-radius-container); background: var(--hm-orgchart-current-card-background); overflow-wrap: anywhere; }
/* Align title, provenance, legend, cards and contrast blocks across each row
of specimens even when their descriptions wrap to different heights. */
.hm-orgchart-color-preview { display: grid; grid-template-rows: subgrid; grid-row: span 5; row-gap: 0; }
.hm-orgchart-color-department-name { display: flex; align-items: center; justify-content: center; min-height: var(--hm-orgchart-title-height, 40px); padding: 6px 12px; font-size: var(--hm-orgchart-title-font-size, 18px); line-height: var(--hm-orgchart-title-line-height, 26px); font-weight: var(--hm-orgchart-resolved-font-weight-bold); background: var(--hm-orgchart-current-gradient); color: var(--hm-orgchart-current-department-color); border-radius: var(--hm-orgchart-resolved-radius-container); }
.hm-orgchart-color-legend { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: var(--hm-orgchart-resolved-spacing-sm); }
.hm-orgchart-color-legend dd { margin: 0; display: grid; gap: 4px; }
@@ -14,7 +14,10 @@
width: 100%;
min-width: 0;
padding: var(--hm-orgchart-resolved-spacing-lg);
background: var(--hm-orgchart-resolved-bg-content);
/* Optional host review frame inputs affect only this outer Auto container. */
background: var(--hm-orgchart-expanded-preview-background, var(--hm-orgchart-resolved-bg-content));
border: var(--hm-orgchart-expanded-preview-border, 0);
border-radius: var(--hm-orgchart-expanded-preview-radius, 0);
}
.hm-orgchart-expanded .hm-orgchart-heading { margin: 0; }
.hm-orgchart-expanded .hm-orgchart-viewport {
@@ -69,7 +72,7 @@
min-height: 0;
padding: 0;
background: var(--hm-orgchart-expanded-primary);
color: var(--hm-orgchart-expanded-title-ink);
color: #ffffff;
font-size: var(--hm-orgchart-resolved-font-size-base);
font-weight: var(--hm-orgchart-resolved-font-weight-bold);
line-height: 1.5;
@@ -143,7 +146,7 @@
}
.hm-orgchart-expanded-toggle:not(:disabled):hover,
.hm-orgchart-expanded-link:hover { background: color-mix(in srgb, currentColor 12%, transparent); }
.hm-orgchart-expanded-toggle:disabled { cursor: default; opacity: 0.75; }
.hm-orgchart-expanded-toggle:is(:disabled, [aria-disabled="true"]) { cursor: default; opacity: 0.75; }
.hm-orgchart-expanded-link { display: inline-flex; align-items: center; padding-inline: var(--hm-orgchart-resolved-spacing-sm); color: inherit; }
.hm-orgchart-expanded-toggle:focus-visible, .hm-orgchart-expanded-link:focus-visible,
.hm-orgchart-expanded-toolbar button:focus-visible {
@@ -161,6 +164,7 @@
.hm-orgchart-expanded-selection, .hm-orgchart-top-node-title { margin: 0; }
.hm-orgchart-expanded-empty { padding: var(--hm-orgchart-resolved-spacing-lg); }
.hm-orgchart-expanded .hm-orgchart-viewport[data-dragging] { cursor: grabbing; user-select: none; }
.hm-orgchart-expanded .hm-orgchart-viewport[data-pan-enabled="false"] { cursor: default; }
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] { cursor: default; }
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employees[data-connection-direction="left"],
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employees[data-connection-direction="right"] {
@@ -198,3 +202,74 @@
.hm-orgchart-expanded .hm-orgchart-person-text { gap: var(--hm-orgchart-expanded-person-gap); }
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-person-name { font-size: var(--hm-orgchart-expanded-name-size); font-weight: var(--hm-orgchart-expanded-name-weight); }
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-person-role { font-size: var(--hm-orgchart-expanded-position-size); }
/* P7: direction/vertical adapters keep dabeng's recursive hierarchy and the
existing HM card geometry. Plain TB continues to use its original rules. */
.hm-orgchart-expanded .hm-orgchart-tree { position: relative; flex: none; }
.hm-orgchart-expanded[data-auto-layout="lr"] .hm-orgchart-forest { flex-direction: column; }
.hm-orgchart-expanded[data-auto-layout="lr"] .hm-orgchart-engine.orgchart.l2r {
position: relative; transform: none; transform-origin: 0 0;
}
.hm-orgchart-expanded[data-auto-layout="lr"] .hm-orgchart-engine.orgchart .hierarchy {
display: flex; flex-direction: row; align-items: center;
}
.hm-orgchart-expanded[data-auto-layout="lr"] .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth]) > .nodes {
flex-direction: column; align-items: flex-start;
}
.hm-orgchart-expanded[data-auto-layout="lr"] .hm-orgchart-engine.orgchart .node[data-depth] {
flex: none; height: auto; min-height: 0;
margin: calc(var(--hm-orgchart-expanded-sibling-gap) / 2) var(--hm-orgchart-expanded-level-gap) calc(var(--hm-orgchart-expanded-sibling-gap) / 2) 0;
}
.hm-orgchart-expanded[data-auto-layout="lr"] .hm-orgchart-engine.orgchart .node[data-depth] > .hm-orgchart-organization {
margin-inline: auto; padding-inline: var(--hm-orgchart-resolved-spacing-xxl);
}
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .nodes.vertical,
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .nodes.vertical .nodes,
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth]) > .nodes.vertical {
display: flex; flex-direction: column; align-items: flex-start;
width: max-content; padding-left: var(--hm-orgchart-resolved-spacing-xxl);
gap: calc(var(--hm-orgchart-expanded-level-gap) / 2);
}
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .nodes.vertical {
margin-inline: auto;
}
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .nodes.vertical .nodes {
padding-top: calc(var(--hm-orgchart-expanded-level-gap) / 2);
}
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .nodes.vertical .hierarchy:has(> .node[data-depth]) > .nodes {
flex-direction: column; align-items: flex-start;
}
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .nodes.vertical .node[data-depth] {
margin: 0; min-height: 0;
}
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .nodes.vertical .node[data-depth] > .hm-orgchart-organization {
padding-inline: var(--hm-orgchart-resolved-spacing-xxl);
}
/* Measured Auto connector routes replace upstream fixed-card pseudo geometry
in LR/global Hybrid/TB per-group Hybrid only. */
.hm-orgchart-expanded:is([data-auto-layout="lr"], [data-auto-layout="hybrid"], [data-group-hybrid="true"]) .hm-orgchart-engine.orgchart .hierarchy::before,
.hm-orgchart-expanded:is([data-auto-layout="lr"], [data-auto-layout="hybrid"], [data-group-hybrid="true"]) .hm-orgchart-engine.orgchart .hierarchy::after,
.hm-orgchart-expanded:is([data-auto-layout="lr"], [data-auto-layout="hybrid"], [data-group-hybrid="true"]) .hm-orgchart-engine.orgchart .node[data-depth]::before,
.hm-orgchart-expanded:is([data-auto-layout="lr"], [data-auto-layout="hybrid"], [data-group-hybrid="true"]) .hm-orgchart-engine.orgchart .node[data-depth]::after {
content: none;
}
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node > .toggleBtn { display: none; }
.hm-orgchart-expanded .hm-orgchart-organization-connectors {
position: absolute; inset: 0; overflow: visible; pointer-events: none;
}
.hm-orgchart-expanded .hm-orgchart-organization-connectors path {
fill: none; stroke: var(--hm-orgchart-expanded-line-color); stroke-width: var(--hm-orgchart-expanded-line-width);
}
/* P9.3: outlines do not resize cards or replace company accents/connectors. */
.hm-orgchart-expanded .hm-orgchart-viewport[data-drag-drop-enabled="true"] :is(.hm-orgchart-employee, .hm-orgchart-expanded-toggle) { cursor: grab; }
.hm-orgchart-expanded .hm-orgchart-viewport[data-drag-drop-enabled="false"] :is(.hm-orgchart-employee, .hm-orgchart-expanded-toggle) { cursor: pointer; }
.hm-orgchart-expanded .hm-orgchart-move-feedback { margin: 0; overflow-wrap: anywhere; color: var(--hm-orgchart-resolved-fg-secondary); }
.hm-orgchart-expanded .hm-orgchart-move-feedback[data-move-status="success"] { color: var(--hm-orgchart-resolved-action-primary); font-weight: var(--hm-orgchart-resolved-font-weight-bold); }
.hm-orgchart-expanded .hm-orgchart-move-feedback[data-move-status="failure"] { color: var(--hm-orgchart-resolved-fg-primary); text-decoration: underline; }
.hm-orgchart-expanded .hm-orgchart-viewport[data-edit-dragging] { cursor: grabbing; user-select: none; }
.hm-orgchart-expanded [data-edit-source="true"] { opacity: 0.55; }
.hm-orgchart-expanded .node[data-drop-state="allowed"] > .hm-orgchart-organization { outline: 2px dashed var(--hm-orgchart-resolved-stroke-input-focus); outline-offset: 2px; }
.hm-orgchart-expanded .node[data-drop-state="blocked"] > .hm-orgchart-organization { outline: 1px dotted var(--hm-orgchart-resolved-stroke-default); outline-offset: 2px; }
.hm-orgchart-expanded .node[data-drop-hover="allowed"] > .hm-orgchart-organization { outline: 3px solid var(--hm-orgchart-resolved-action-primary); }
.hm-orgchart-expanded .node[data-drop-hover="blocked"] > .hm-orgchart-organization { outline: 3px double var(--hm-orgchart-resolved-fg-secondary); }
@@ -1,7 +1,7 @@
/* P3 read-only summary presenters. Values are from Workdesk index.css:
headcount-panel / bold-text / sidebar- / #summary-table and the bottom
.section legend. These are standalone OrgChart content, not HM overrides. */
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-organization-totals {
:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-organization-totals {
display: flex;
flex-direction: column;
align-items: flex-end;
@@ -13,7 +13,7 @@
color: #18253a;
font-size: 14px;
}
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-organization-totals-title {
:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-organization-totals-title {
width: 176px;
margin: 0;
padding: 4px 0;
@@ -67,7 +67,7 @@
.hm-orgchart-workdesk-position-table tfoot :is(th, td) { height: 20px; }
.hm-orgchart-workdesk-count-cell { font-variant-numeric: tabular-nums; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar {
:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar {
display: flex;
align-items: center;
justify-content: center;
@@ -81,11 +81,11 @@
box-shadow: 0 5.4px 14.3px rgba(44, 68, 96, 0.08);
color: #1c2534;
}
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar-list {
:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar-list {
flex-wrap: nowrap;
gap: 26.8px;
}
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar-item {
:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar-item {
position: relative;
height: 42.9px;
min-height: 42.9px;
@@ -95,8 +95,8 @@
font-weight: 500;
line-height: 19.7px;
}
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar-item + .hm-orgchart-affiliation-bar-item { border: 0; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar-item + .hm-orgchart-affiliation-bar-item::before {
:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar-item + .hm-orgchart-affiliation-bar-item { border: 0; }
:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar-item + .hm-orgchart-affiliation-bar-item::before {
content: '';
position: absolute;
top: 50%;
@@ -106,14 +106,22 @@
background: rgba(113, 142, 176, 0.48);
transform: translateY(-50%);
}
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar-item dt { gap: 0.9px; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar .hm-orgchart-affiliation-mark {
:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar-item dt { gap: 0.9px; }
:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar .hm-orgchart-affiliation-mark {
width: 39.3px;
height: 39.3px;
flex-basis: 39.3px;
}
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar .hm-orgchart-affiliation-mark img { object-fit: cover; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar-total {
:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar .hm-orgchart-affiliation-mark img { object-fit: cover; }
:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar-total {
color: #071d3a;
font-weight: 700;
}
/* Overview shares the bar's internal design, but lets its items wrap in the
page flow. No Fixed stage/absolute positioning is copied into the preview. */
.hm-orgchart-shell.hm-orgchart-summary-preview .hm-orgchart-affiliation-bar { height: auto; padding-block: 6.25px; }
.hm-orgchart-shell.hm-orgchart-summary-preview .hm-orgchart-affiliation-bar-list { flex-wrap: wrap; row-gap: 8px; }
@media (max-width: 767px) {
.hm-orgchart-shell.hm-orgchart-summary-preview .hm-orgchart-affiliation-bar-item + .hm-orgchart-affiliation-bar-item::before { display: none; }
}
@@ -157,8 +157,8 @@
/* Workdesk compact artwork. Preserve flow placement for unknown organizations;
the measured preset alone enables this original 1920×914.5 coordinate layer. */
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] { grid-template-rows: 224.2px auto 1fr auto; height: 914.5px; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-axis { width: 198px; height: 224.2px; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] { grid-template-rows: 224.2px auto 1fr auto; height: var(--hm-orgchart-workdesk-stage-height, 914.5px); }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-axis { width: 198px; height: 224.2px; z-index: 12; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-top-leaders { position: absolute; top: 35.3px; z-index: 35; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-hub {
position: absolute;
@@ -192,7 +192,7 @@
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-slot-footer { position: relative; z-index: 7; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-slot-side { position: relative; z-index: 18; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-slot-belowTree { position: relative; z-index: 32; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-slot-belowTree { position: absolute; left: 32px; bottom: 70px; width: 240px; margin: 0; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-slot-belowTree { position: absolute; left: 32px; bottom: 82px; width: 240px; margin: 0; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-status-summary { gap: 12px; flex-wrap: nowrap; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-slot-footer { position: absolute; left: 57px; right: 57px; bottom: 12px; margin: 0; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-person-role,
@@ -207,6 +207,12 @@
.hm-orgchart-workdesk-meeting { left: 68px; bottom: 32px; width: 557px; height: 344px; opacity: 0.8; mix-blend-mode: multiply; }
.hm-orgchart-workdesk-grid { left: 62px; bottom: 18.9px; width: 1768px; height: 275px; }
.hm-orgchart-workdesk-hub-frame { inset: 0; width: 100%; height: 100%; z-index: -1; object-position: top; }
/* One base-coordinate central layer; top and bottom modules keep their own
anchors. Decorations retain their original size and 914.5px coordinates. */
.hm-orgchart-stage[data-workdesk-artwork] :is(.hm-orgchart-axis,.hm-orgchart-viewport,.hm-orgchart-workdesk-overlay,.hm-orgchart-workdesk-decoration:not(.hm-orgchart-workdesk-hub-frame)) { transform: translateY(var(--hm-orgchart-workdesk-center-offset, 0px)); }
.hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-workdesk-sky { bottom: auto; height: 914.5px; }
.hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-workdesk-meeting { top: 538.5px; bottom: auto; }
.hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-workdesk-grid { top: 620.6px; bottom: auto; }
/* P3: artwork owns representative/hub/top-level routes. Hide every engine
hierarchy route in readOnly only, including upstream pseudo-element paths. */
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .hierarchy::before,