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