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)}`);