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

+77 -4
View File
@@ -73,8 +73,9 @@ organizationName and display (`card`, `titleOnly`, `hidden`). An explicit employ
must belong to that company's root; null removes top-card assignment. Overrides
affect presentation only, preserving employee IDs, popup data and parentId.
`employeeConnections` maps organization IDs to top/left/right staff routes (default
top). Leaders connect from their title above. SVG paths use actual article/card
`employeeConnections` maps organization IDs to top/left/right staff routes in
TB/Hybrid (default top). Their leaders connect from the title above; LR routes all
employees from the left. SVG paths use actual article/card
coordinates; each endpoint shares its direction with the corresponding single-side
border accent. Employee preset follows its known affiliation, otherwise its tree.
All remaining borders are neutral; an inset accent width variable preserves size.
@@ -92,8 +93,9 @@ const chart = createOrgChart({
});
```
Options are captured at construction: create a new handle to change the layout or
company/top-node/connection/zoom configuration. Selection uses its own setter.
Layout mode/company/top-node/connection/zoom ranges are captured at construction.
Auto `autoLayout`, `interactions`, company selection and the employee callback use
their own setters on the existing handle. See the P7 matrix in the usage guide.
Standalone Tree/Summary factories keep their existing rendering paths.
`examples/org-chart/data-expanded-{small,medium,large}.json` contain exactly
@@ -105,6 +107,51 @@ company controls without filtering JSON in the Story. Native Core controls are
the explicit framework-independent HTML adapter, not imitations of HM React UI.
### Virtual example selection (P7)
`createOrgChartExampleDataset(template, selection)` is a pure data provider for
both Vanilla and React. Supply the existing P5-3 **large** JSON fixture, which
contains all seven company presets. The old Small/Medium/Large files and their
historical 3/5/7-company policies remain unchanged; P7 selection does not require
new JSON files. The helper validates and snapshots its input, reuses organization
IDs and employee records, and adds deterministic virtual employees when a company
needs more staff than the original fixture contains.
```js
import { createOrgChartExampleDataset, JsonOrgChartDataSource } from '@hm-design/org-chart';
// template is the parsed examples/org-chart/data-expanded-large.json fixture.
const allCompanyExample = createOrgChartExampleDataset(template, {
scope: 'total', employeeCount: 100,
}); // All seven companies together: exactly 100 unique employees.
const twoCompanyExample = createOrgChartExampleDataset(template, {
scope: 'per-company', employeeCount: 100,
companyIds: ['hanmac', 'saman'],
}); // Exactly 100 employees per company, 200 in this source.
await chart.reload(new JsonOrgChartDataSource(twoCompanyExample));
```
Supported scales are exactly `20 | 100 | 300`. Scope `'total'` allocates that
count across all seven companies using the original fixture's unequal company
proportions. An optional company subset then filters those allocations **without
redistributing** them: a single selected company therefore displays only its
share of the total example. Scope `'per-company'` gives every selected company
the requested count, up to 2,100 employees for seven companies at 300 each.
Omitting `companyIds` selects all seven; repeated/out-of-order IDs are normalized
to preset order. All returned datasets are independent, deeply frozen snapshots.
Leader IDs remain references to existing employees and are never counted twice.
Missing sampled leaders are cleared while the complete, valid parent hierarchy
is retained, including the original deeper and unequal branches.
The helper rejects an empty `companyIds` list because the data contract requires
organizations. To display no companies, retain a valid source and call
`setSelectedCompanies([])` (or pass React `selectedCompanyIds={[]}`). Runtime
layout, collapse state, employee popup policy, and actual API sources remain
separate from example selection. The 2,100-person **data count** is distinct from
rendering/heap performance; the earlier 300-person result does not verify it.
### Historical P5-1B verification and local runtime
Core/React builds and Typechecks pass. Focused browser evidence covers the
@@ -198,3 +245,29 @@ Open `http://127.0.0.1:4178/index.html` for bundled ESM + external JSON. Open `e
`npm run test:org-chart-browser` expects Playwright supplied by the verification environment. If not installed locally, set `ORGCHART_PLAYWRIGHT_MODULE` to its absolute `index.mjs`/`index.js` and optionally `ORGCHART_CHROME` to a Chrome executable. Production has no browser-test dependency. Evidence is written under `test-results/org-chart-browser/`.
The React wrapper owns only ref/effect lifecycle, source/props updates and cleanup, including forwarding lookup and Auto view options; HTML and React render the same Core. Standalone `createOrgChartTree`, `createPositionSummary`, `createAffiliationSummary` and `createStatusSummary` factories are exported at the package root and return the existing `OrgChartHandle`. The four public OrgChart Stories are Logo · Personnel Summary, Department Colors, Full Composition and Expanded OrgChart. Original verification Stories and independent component Stories remain available under the excluded `internal` tag.
# P9.3 Auto 소속 변경
P9.4부터 `dragDropEnabled?: boolean`의 기본값은 `false`다. 아래 포인터·키보드·
`handle.move()` 소속 변경은 `dragDropEnabled: true`일 때만 허용하며 OFF에서는
`EDITING_DISABLED` 결과를 반환한다. 순수 데이터 helper의 이동 검증·적용 계약은 유지한다.
`handle.setDragDropEnabled(boolean)` / `handle.getDragDropEnabled()`로 같은 Auto
인스턴스를 갱신·조회한다. OFF 전환은 진행 중 이동과 강조·Pointer capture를 취소하고
완료된 이동·데이터·Zoom/Pan·접기 상태를 유지한다. Source 재요청·레이아웃 재생성은 없다.
Fixed는 생성 옵션을 무시하며 setter는 Auto 전용 사용 오류를 반환한다.
Expanded Auto는 빈 공간 Pan과 직원 카드·그룹 제목 Drag를 구분한다. 6px 이동부터
Drag를 시작하며 단순 클릭은 기존 정보 팝업·접기 동작이다. `Alt+M` → 방향키 →
Enter로도 이동하며 Esc로 취소한다. Fixed에는 편집을 적용하지 않는다.
`handle.move({ kind: 'employee' | 'organization', sourceId, targetOrganizationId })`는
포인터·키보드와 같은 검증 정책을 사용한다. `handle.getData()`는 현재 Auto의 동결된
데이터를 반환하며 `onMove(result)`로 성공(`moved: true`)·거절 결과를 받는다.
회사 간 직원 이동은 `organizationId`와 `affiliationId`를 대상 조직·회사로 변경한다.
회사 간 그룹 이동은 `parentId`, 전체 하위 조직의 `companyId`, 해당 조직 직원들의
`affiliationId`를 함께 변경한다. 같은 회사 내 이동은 기존 관계사 소속을 유지한다.
회사 Root·조직장 직원·자기 자신·자손으로의 이동은 제한한다. 성공 후 기존 엔진으로
재배치하며 대상 회사 프리셋과 새로운 조직/관계사 인원 집계를 적용한다.
데이터는 브라우저 인스턴스에만 유지한다. 회사 표시 선택은 이동 내용을 보존하고,
새 source의 reload/update는 새 데이터로 대체한다. JSON 파일·서버 저장은 수행하지 않는다.
@@ -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,