feat(org-chart): finalize chart features and AI generation rules

This commit is contained in:
shkim4 committed 2026-10-13 14:39:42 +09:00
1 parent 7614507583
commit d0828a7ff4
80 files changed
+8152 -221

No files matched your search

+18 -5
View File
@@ -1,6 +1,6 @@
# @hm-design/org-chart-react v0.1
React 19 lifecycle adapter for `@hm-design/org-chart@0.1.0`. The wrapper renders one empty host container and delegates all organization DOM, engine work, validation, summaries, interactions and Shadow DOM styles to the existing Vanilla Core. It has no navigation, page layout, fetch client or separate node design.
React 19 lifecycle adapter for `@hm-design/org-chart@0.1.0`. The wrapper delegates organization DOM, engine work, validation, summaries, interactions and Shadow DOM styles to the existing Vanilla Core. Auto can optionally show employee information with the public HM Dialog in a separate React Portal. It has no navigation, page layout, fetch client or separate node design.
## Usage
@@ -33,7 +33,7 @@ export function OrganizationContent({ data }: { data: OrgChartDataset }) {
}
```
The `?url` imports above are Vite asset imports, not a universal browser feature. Other hosts resolve the two Core public CSS entries to actual deployment URLs. Importing CSS only into the document does not style the Core's Shadow DOM. Engine CSS is linked first, then standalone Core CSS. P1 owns its colors, typography and geometry without depending on HM Theme. Set public `--hm-orgchart-*` properties on the wrapper container to override only this instance. No HM common variables are redefined. The wrapper does not depend on `@hm-design/ui`; P3 adds lookup Props without replacing the Core renderer. For independent Tree/Summary integration, use the [Core factories](https://gitea.hmac.kr/b25027/HMDesign/src/commit/d2b76e766466e1f156f6a0f8f63710e023af900e/docs/org-chart/standalone-components-p1.md) in a ref/effect without copying their UI.
The `?url` imports above are Vite asset imports, not a universal browser feature. Other hosts resolve the two Core public CSS entries to actual deployment URLs. Importing CSS only into the document does not style the Core's Shadow DOM. Engine CSS is linked first, then standalone Core CSS. P1 owns its colors, typography and geometry without depending on HM Theme. Set public `--hm-orgchart-*` properties on the wrapper container to override only this instance. No HM common variables are redefined. Default Core rendering does not load `@hm-design/ui`; opting into `employeePopup="hm-dialog"` loads the optional peer and requires the consumer's HM Theme CSS. Bundlers may resolve that optional dynamic chunk during a build even for a default consumer. For independent Tree/Summary integration, use the [Core factories](https://gitea.hmac.kr/b25027/HMDesign/src/commit/d2b76e766466e1f156f6a0f8f63710e023af900e/docs/org-chart/standalone-components-p1.md) in a ref/effect without copying their UI.
## Public API
@@ -52,9 +52,13 @@ Keyboard focus reveals offscreen controls at the current Zoom; group-title Hover
feedback and drag/keyboard activation are implemented in the shared Core.
- Optional `companyId`: legacy Auto fallback company preset (default `hanmac`). Explicit/inherited organization company identity takes priority; Fixed ignores company colors. Changing it recreates the handle. See [Core README](https://gitea.hmac.kr/b25027/HMDesign/src/commit/d2b76e766466e1f156f6a0f8f63710e023af900e/packages/org-chart/README.md).
- Optional `selectedCompanyIds`: undefined selects all, [] selects none. Changes call Core selection on the existing instance, preserving per-company collapse state and source counts.
- Optional `autoLayout`: `{ type: 'tb' | 'lr' | 'hybrid', hybridStartDepth: 2 | 3 | 4, initialVisibleDepth: 1 | 2 | 3 | 'all', hybridOrganizationIds?: readonly string[] }` (each field optional). Changes use Core `setAutoLayout` on the existing instance; TB/LR/Hybrid changes retain valid collapse state. Per-group hybrid IDs apply only to TB; clear them when selecting LR/global Hybrid. Omission follows Core defaults (TB, Hybrid depth 3, all visible).
- Optional `interactions`: `{ collapse?: boolean, zoom?: boolean, pan?: boolean }`, all enabled by default. Auto independently enables/disables group toggles, Zoom controls/wheel Zoom and drag Pan via Core `setInteractions` on the same instance. Omitted fields are enabled, and removing the prop restores all three defaults. Existing collapse and viewport state remain; TB/LR/Hybrid has no interaction prerequisite. Fixed ignores this option and keeps its existing behavior.
- Optional `employeePopup`: `'core'` (default), `'hm-dialog'`, or `'none'`. Applies only to Auto; Fixed retains its existing Core popup. HM Dialog requires the optional `@hm-design/ui@0.1.0` peer and the consumer's real HM Theme/Tailwind CSS entry. It is loaded lazily so default/Core-only consumers do not load HM UI. An explicit `onEmployeeClick` always takes precedence and suppresses either default popup. HM popup state closes on source/company changes and resolves the employee's replacement card in the original ShadowRoot when returning focus after a layout render. Auto uses Core `setEmployeeClickHandler` to change popup ownership on the same instance, retaining company selection, layout, collapse and viewport state.
- Optional `companyTopNodes`, `employeeConnections`, `zoom`: immutable Core configuration maps/range. Identity changes recreate the handle; memoize these objects. Names, positions and profile overrides affect only Auto presentation, not callback data.
- Optional `organizationLinks`: existing organization-ID-to-URL map, applied only to original 1Depth titles. Missing/blank values retain plain titles. Core validates HTTP(S)/relative URLs and rejects invalid URLs, other schemes and credentials. Treat the map as immutable and memoize it; a new map recreates the handle.
- Optional `onEmployeeClick(employee, context)`: host-owned information UI, replacing the Core's built-in popup. `context` contains the existing `organization`, `affiliation`, focusable `trigger` card and `originalEvent`. It works in both modes and receives untouched Employee data.
- Optional `onMove(result: OrgChartMoveResult)`: latest committed callback receives Auto employee/group move outcomes (`moved` distinguishes success from rejection). Successful moves close an open HM employee Dialog, so its employee context cannot become stale. Changing this callback does not recreate or reload Core. Movement changes the Core's in-memory snapshot; no fixture or server is written.
- Optional `aggregationPolicy`: the Core's `ApprovedAggregationPolicy`.
- Optional `className` and `style`: applied only to React's outer container.
- Optional `onStateChange(state: OrgChartState)`: latest committed callback receives actual Core state changes.
@@ -62,7 +66,7 @@ feedback and drag/keyboard activation are implemented in the shared Core.
The container is owned by React; its dedicated children are owned by Core. There are no React children, imperative handle, title, employee or engine manipulation Props. Titles, descriptions, presentation groups, organization order and metadata use the common data contract. An omitted lifecycle error callback leaves unexpected operation errors handled but does not produce a second error UI or console log. Consumer state callback errors follow the Core's isolation behavior; lifecycle error callback exceptions are also contained.
Without `onEmployeeClick`, all employee cards open the same Core read-only information dialog on click, Enter or Space. It uses only existing employee/organization/affiliation fields and omits missing values. readOnly names match the displayed `홍 길 동` card while source data remains unchanged. The native modal sits outside the scaled Stage and supports Close, ESC, focus trapping and return to the card. Group-link events remain separate; a visible name tooltip closes before popup activation. A custom callback owns its popup's accessibility and cleanup.
Without `onEmployeeClick`, employee cards follow `employeePopup` in Auto: Core read-only information dialog (default), public HM Dialog, or no popup. Fixed always retains the Core popup. Both dialogs use only existing employee/organization/affiliation fields and omit missing values. Core readOnly names match the displayed card while source data remains unchanged; HM Dialog shows source employee data. The native modal sits outside the scaled Stage and HM Dialog uses a React Portal; both support Close, ESC, focus trapping and return to the card. Group-link events remain separate; a visible name tooltip closes before popup activation. A custom callback owns its popup's accessibility and cleanup.
```tsx
<OrgChart
@@ -78,11 +82,20 @@ Both map and popup function above are supplied by the host; no real destination
## Lifecycle and updates
P9.4 adds `dragDropEnabled?: boolean` (default `false`) for Auto. Pass `true` to
enable employee/group pointer, keyboard and public Core moves. Changing this prop
calls the shared Core `setDragDropEnabled` synchronously on the existing handle;
it neither remounts nor reloads the source. Switching OFF cancels active movement,
clears highlights and retains completed moves, popup, collapse and viewport state.
Pan/Zoom/Fit remain independent. Fixed ignores the prop.
Mount creates one new Core handle, subscribes to its state and calls `mount(container)`. Mount already loads the source; the wrapper does not perform an additional initial reload. The wrapper does not synthesize `idle`, `destroyed` or `empty` callbacks. Current Core emits `loading`, then `ready`/`error`; employees `[]` is ready, organizations `[]` is error. An `empty` type member does not establish a supported Empty State.
A new `dataSource` object triggers the existing handle's `reload(nextSource)`. This starts Core's latest revision and aborts or ignores older responses. Updating fields inside the same source object is not a reload signal. `JsonOrgChartDataSource` captures inline input at its construction; create a new source for a new immutable snapshot. Memoize sources rather than constructing one on every render.
Changing callback identity updates the latest committed callback without reload or recreation. Adding/removing `onEmployeeClick` switches built-in/custom popup ownership and recreates the handle. Abandoned concurrent renders do not update callbacks. Policy or `organizationLinks` identity changes, displayMode changes, or either stylesheet URL string value changes dispose the previous handle and create a new one because the Core offers no configuration setter. Replacing the stylesheet URL object with the same two strings does not recreate. Treat policy and link-map objects as immutable and memoize them; changing an object internally is not a configuration update signal.
Changing callback identity updates the latest committed callback without reload or recreation. In Auto, adding/removing `onEmployeeClick` or changing `employeePopup` updates Core's employee handler without replacing the handle. Fixed retains its original ownership policy: adding/removing `onEmployeeClick` recreates the handle. Abandoned concurrent renders do not update callbacks. Policy or `organizationLinks` identity changes, displayMode changes, or either stylesheet URL string value changes dispose the previous handle and create a new one because these settings have no Core setter. Replacing the stylesheet URL object with the same two strings does not recreate. Treat policy and link-map objects as immutable and memoize them; changing an object internally is not a configuration update signal.
Auto separates blank-space Pan from employee-card/group-title movement. Click continues to open employee information or toggle a group; employees and groups may move within or across companies. Employee moves update `organizationId`; group moves update `parentId` and retain the subtree and IDs. Within-company moves preserve existing affiliations, including dispatched employees. Cross-company employee moves additionally update `affiliationId` to the target company; cross-company group moves update the moved group's and every descendant's `companyId` and their employees' `affiliationId`. The target company controls colors and counts; leaders and company roots remain protected. Focus a card/title and press Alt+M to begin keyboard movement, arrow keys to choose a target, Enter to confirm, or ESC to cancel. Employee cards retain a keyboard focus entry point when `employeePopup="none"`. Fixed has no movement editing.
Cleanup invalidates the wrapper generation, unsubscribes, calls Core's terminal `destroy()` and clears its handle reference. React Strict Mode's setup → cleanup → setup uses a fresh handle. Old generations cannot deliver state/lifecycle callbacks to the new instance; superseded operation rejection handlers are also ignored. Cleanup runs before replacement, leaving at most one active Core per wrapper container. The wrapper does not repeat engine disposal or DOM removal itself.
@@ -92,7 +105,7 @@ Committed callback publication, Core ownership and source updates use layout eff
## Package boundary and verification limits
The build emits ESM `dist/index.js` plus TypeScript declarations, preserves the `"use client"` boundary and leaves React (including `react/jsx-runtime`) and `@hm-design/org-chart` external. React is a `^19.0.0` peer, matching the repository's existing React package convention; Core `0.1.0` is a runtime dependency. React DOM is supplied by the consuming app and is not imported by this package. Core must be built before wrapper typecheck/build in this workspace because dependency resolution uses its public declarations.
The build emits ESM `dist/index.js`, a lazy HM Dialog chunk, and TypeScript declarations. It preserves the `"use client"` boundary and leaves React (including `react/jsx-runtime`), `@hm-design/org-chart`, and optional `@hm-design/ui` external. React is a `^19.0.0` peer, matching the repository's existing React package convention; Core `0.1.0` is a runtime dependency. React DOM is supplied by the consuming app and is not imported by this package. Core must be built before wrapper typecheck/build in this workspace because dependency resolution uses its public declarations.
From the repository root:
+2 -1
View File
@@ -20,5 +20,6 @@
"prepack": "npm run build"
},
"dependencies": { "@hm-design/org-chart": "0.1.0" },
"peerDependencies": { "react": "^19.0.0" }
"peerDependencies": { "react": "^19.0.0", "@hm-design/ui": "0.1.0" },
"peerDependenciesMeta": { "@hm-design/ui": { "optional": true } }
}
@@ -0,0 +1,82 @@
"use client";
import { useLayoutEffect, useRef } from 'react';
import {
Avatar, AvatarFallback, AvatarImage, Button, Dialog, DialogBody, DialogClose,
DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@hm-design/ui';
import type { Employee, OrgChartEmployeeClickContext } from '@hm-design/org-chart';
export interface EmployeeDialogSelection {
employee: Employee;
context: OrgChartEmployeeClickContext;
}
const statusNames = { active: '재직', dispatched: '파견', onLeave: '휴직' } as const;
/** Lazy React-only adapter. The Core and Vanilla remain independent of HM UI. */
export default function HMEmployeeDialog({ selection, onClose }: {
selection: EmployeeDialogSelection | null;
onClose: () => void;
}) {
const returnTarget = useRef<EmployeeDialogSelection | null>(null);
const returnRoot = useRef<ShadowRoot | null>(null);
const returnContainer = useRef<HTMLElement | null>(null);
useLayoutEffect(() => {
if (!selection) return;
returnTarget.current = selection;
const root = selection.context.trigger.getRootNode();
const Shadow = selection.context.trigger.ownerDocument.defaultView?.ShadowRoot;
returnRoot.current = Shadow && root instanceof Shadow ? root : null;
returnContainer.current = returnRoot.current?.host.parentElement ?? null;
}, [selection]);
function restoreFocus(event: Event) {
event.preventDefault();
const previous = returnTarget.current;
returnTarget.current = null;
if (!previous) return;
const { trigger } = previous.context;
// A mode/selection render can replace the card while the Portal is open.
// Retain its ShadowRoot before replacement and resolve the same employee.
const shadow = returnRoot.current;
const currentRoot = shadow?.host.isConnected ? shadow : returnContainer.current?.firstElementChild?.shadowRoot;
const replacement = [...currentRoot?.querySelectorAll<HTMLElement>('.hm-orgchart-employee') ?? []]
.find(card => card.dataset.employeeId === previous.employee.id);
const target = trigger.isConnected ? trigger : replacement
?? currentRoot?.querySelector<HTMLElement>('.hm-orgchart-shell');
target?.focus({ preventScroll: true });
}
const displayed = selection ?? returnTarget.current;
const employee = displayed?.employee;
const context = displayed?.context;
const image = employee?.profileImage
?? context?.trigger.querySelector<HTMLImageElement>('.hm-orgchart-avatar img')?.src;
const fields: [string, string | null | undefined][] = employee ? [
['이름', employee.name], ['직위', employee.position],
['소속 회사', context?.affiliation?.name], ['소속 조직', context?.organization?.name],
['직책', employee.jobTitle], ['근무 상태', statusNames[employee.workStatus]],
] : [];
return <Dialog open={selection !== null} onOpenChange={open => { if (!open) onClose(); }}>
<DialogContent onCloseAutoFocus={restoreFocus} data-component="HMOrgChartEmployeeDialog" data-employee-id={employee?.id}>
<DialogHeader>
<DialogTitle>직원 정보</DialogTitle>
<DialogDescription>조직도에서 선택한 직원의 정보를 확인합니다.</DialogDescription>
</DialogHeader>
<DialogBody>
<Avatar size="lg">
{image ? <AvatarImage src={image} alt="" referrerPolicy="no-referrer" /> : null}
<AvatarFallback>{employee?.name.slice(0, 1)}</AvatarFallback>
</Avatar>
<dl style={{ display: 'grid', gap: '0.75rem', margin: '1rem 0 0' }}>{fields.filter(([, value]) => Boolean(value)).map(([label, value]) => <div key={label} style={{ display: 'grid', gridTemplateColumns: '5rem minmax(0, 1fr)', gap: '0.75rem' }}>
<dt style={{ fontWeight: 600 }}>{label}</dt><dd style={{ margin: 0, minWidth: 0, overflowWrap: 'anywhere' }}>{value}</dd>
</div>)}</dl>
</DialogBody>
<DialogFooter>
<DialogClose asChild><Button variant="cancel" appearance="solid">닫기</Button></DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>;
}
+134 -11
View File
@@ -1,14 +1,39 @@
"use client";
import { useLayoutEffect, useRef } from 'react';
import { lazy, Suspense, useLayoutEffect, useRef, useState } from 'react';
import { createOrgChart } from '@hm-design/org-chart';
import type { OrgChartDataSource, OrgChartHandle } from '@hm-design/org-chart';
import type { OrgChartAutoLayoutOptions, OrgChartDataSource, OrgChartHandle, OrgChartInteractionOptions, OrgChartOptions } from '@hm-design/org-chart';
import type { OrgChartProps } from './types.js';
import type { EmployeeDialogSelection } from './HMEmployeeDialog.js';
const HMEmployeeDialog = lazy(() => import('./HMEmployeeDialog.js'));
function annotateEmployeeCards(shadow: ShadowRoot, popup: NonNullable<OrgChartProps['employeePopup']>, customCallback: boolean) {
const actionable = customCallback || popup !== 'none';
for (const card of shadow.querySelectorAll<HTMLElement>('.hm-orgchart-employee')) {
const name = card.querySelector('.hm-orgchart-person-name')?.textContent ?? '';
const position = card.querySelector('.hm-orgchart-person-role')?.textContent ?? '';
// Auto cards also provide keyboard movement when the information popup is
// disabled; popup ownership must not remove that Core keyboard entry point.
card.tabIndex = 0;
if (actionable) card.setAttribute('role', 'button');
else card.removeAttribute('role');
card.setAttribute('aria-label', `${name} ${position}${actionable ? ' 직원 정보 보기' : ''}`.trim());
if (actionable && !customCallback) card.setAttribute('aria-haspopup', 'dialog');
else card.removeAttribute('aria-haspopup');
}
}
interface Instance {
readonly handle: OrgChartHandle;
readonly generation: number;
source: OrgChartDataSource;
autoLayout: OrgChartAutoLayoutOptions | undefined;
interactions: OrgChartInteractionOptions | undefined;
dragDropEnabled: boolean;
readonly employeeClick: NonNullable<OrgChartOptions['onEmployeeClick']>;
employeeCallbackEnabled: boolean;
popupMode: 'core' | 'hm-dialog' | 'none';
operation: number;
}
@@ -19,6 +44,9 @@ export function OrgChart({
stylesheetUrls,
displayMode,
layoutMode,
autoLayout,
interactions,
dragDropEnabled = false,
companyId,
selectedCompanyIds,
companyTopNodes,
@@ -26,6 +54,8 @@ export function OrgChart({
zoom,
organizationLinks,
onEmployeeClick,
onMove,
employeePopup = 'core',
className,
style,
onStateChange,
@@ -34,17 +64,22 @@ export function OrgChart({
const containerRef = useRef<HTMLDivElement>(null);
const instanceRef = useRef<Instance | null>(null);
const generationRef = useRef(0);
const committedRef = useRef({ dataSource, onStateChange, onLifecycleError, onEmployeeClick });
const [employeeSelection, setEmployeeSelection] = useState<EmployeeDialogSelection | null>(null);
const committedRef = useRef({ dataSource, onStateChange, onLifecycleError, onEmployeeClick, onMove, employeePopup });
// Publish committed values before Core effects run, without letting callback
// identity changes or abandoned concurrent renders alter Core ownership.
useLayoutEffect(() => {
committedRef.current = { dataSource, onStateChange, onLifecycleError, onEmployeeClick };
}, [dataSource, onStateChange, onLifecycleError, onEmployeeClick]);
committedRef.current = { dataSource, onStateChange, onLifecycleError, onEmployeeClick, onMove, employeePopup };
}, [dataSource, onStateChange, onLifecycleError, onEmployeeClick, onMove, employeePopup]);
const engineStylesheetUrl = stylesheetUrls.engine;
const coreStylesheetUrl = stylesheetUrls.core;
const hasEmployeeCallback = typeof onEmployeeClick === 'function';
const popupMode = layoutMode === 'auto' ? employeePopup : 'core';
const adapterOwnsPopup = popupMode !== 'core';
const hasAdapterCallback = hasEmployeeCallback || popupMode !== 'core';
const fixedHasEmployeeCallback = layoutMode !== 'auto' && hasEmployeeCallback;
// Ownership changes are synchronous with the commit: an old request cannot
// notify between a committed unmount/source change and a passive cleanup.
@@ -54,6 +89,7 @@ export function OrgChart({
const generation = ++generationRef.current;
let handle: OrgChartHandle | undefined;
let unsubscribe: (() => void) | undefined;
let popupObserver: MutationObserver | undefined;
const reportError = (error: unknown) => {
if (generationRef.current !== generation) return;
@@ -63,23 +99,35 @@ export function OrgChart({
try {
const source = committedRef.current.dataSource;
const employeeClick: NonNullable<OrgChartOptions['onEmployeeClick']> = (employee, context) => {
if (generationRef.current !== generation) return;
const callback = committedRef.current.onEmployeeClick;
if (callback) callback(employee, context);
else if (committedRef.current.employeePopup === 'hm-dialog') setEmployeeSelection({ employee, context });
};
handle = createOrgChart({
dataSource: source,
aggregationPolicy,
stylesheetUrls: { engine: engineStylesheetUrl, core: coreStylesheetUrl },
displayMode,
layoutMode,
autoLayout,
interactions,
dragDropEnabled,
companyId,
selectedCompanyIds,
companyTopNodes,
employeeConnections,
zoom,
organizationLinks,
onEmployeeClick: hasEmployeeCallback ? (employee, context) => {
if (generationRef.current === generation) committedRef.current.onEmployeeClick?.(employee, context);
} : undefined,
onEmployeeClick: hasAdapterCallback ? employeeClick : undefined,
onMove: result => {
if (generationRef.current !== generation) return;
if (result.moved) setEmployeeSelection(null);
committedRef.current.onMove?.(result);
},
});
const instance: Instance = { handle, source, generation, operation: 1 };
const instance: Instance = { handle, source, autoLayout, interactions, dragDropEnabled, employeeClick, employeeCallbackEnabled: hasAdapterCallback, popupMode, generation, operation: 1 };
instanceRef.current = instance;
unsubscribe = handle.subscribe(state => {
if (instanceRef.current === instance && generationRef.current === generation) {
@@ -92,6 +140,18 @@ export function OrgChart({
void handle.mount(container).catch(error => {
if (instanceRef.current === instance && instance.operation === operation) reportError(error);
});
const shadow = container.firstElementChild?.shadowRoot;
if (shadow && layoutMode === 'auto') {
const annotateCards = () => {
annotateEmployeeCards(shadow, committedRef.current.employeePopup, Boolean(committedRef.current.onEmployeeClick));
};
const Observer = container.ownerDocument.defaultView?.MutationObserver;
if (Observer) {
popupObserver = new Observer(annotateCards);
popupObserver.observe(shadow, { childList: true, subtree: true });
}
annotateCards();
}
} catch (error) {
reportError(error);
}
@@ -100,6 +160,7 @@ export function OrgChart({
// Invalidate callbacks and promise continuations before terminal disposal.
if (generationRef.current === generation) ++generationRef.current;
if (instanceRef.current?.generation === generation) instanceRef.current = null;
popupObserver?.disconnect();
try { unsubscribe?.(); }
catch (error) {
try { committedRef.current.onLifecycleError?.(error); }
@@ -113,7 +174,64 @@ export function OrgChart({
}
}
};
}, [aggregationPolicy, engineStylesheetUrl, coreStylesheetUrl, displayMode, layoutMode, companyId, companyTopNodes, employeeConnections, zoom, organizationLinks, hasEmployeeCallback]);
}, [aggregationPolicy, engineStylesheetUrl, coreStylesheetUrl, displayMode, layoutMode, companyId, companyTopNodes, employeeConnections, zoom, organizationLinks, fixedHasEmployeeCallback]);
useLayoutEffect(() => {
const instance = instanceRef.current;
if (!instance || layoutMode !== 'auto' || (instance.employeeCallbackEnabled === hasAdapterCallback && instance.popupMode === popupMode)) return;
try {
instance.handle.setEmployeeClickHandler(hasAdapterCallback ? instance.employeeClick : undefined);
instance.employeeCallbackEnabled = hasAdapterCallback;
instance.popupMode = popupMode;
} catch (error) {
try { committedRef.current.onLifecycleError?.(error); }
catch { /* Match Core's callback ownership isolation. */ }
}
}, [hasAdapterCallback, popupMode, layoutMode]);
useLayoutEffect(() => {
// Close a host-owned popup before committing a changed source/company view;
// no asynchronous fixture request can leave a stale employee displayed.
setEmployeeSelection(null);
const shadow = containerRef.current?.firstElementChild?.shadowRoot;
if (shadow && layoutMode === 'auto') {
annotateEmployeeCards(shadow, popupMode, hasEmployeeCallback);
}
}, [dataSource, selectedCompanyIds, popupMode, hasEmployeeCallback, layoutMode, adapterOwnsPopup]);
useLayoutEffect(() => {
const instance = instanceRef.current;
if (!instance || layoutMode !== 'auto' || instance.autoLayout === autoLayout) return;
instance.autoLayout = autoLayout;
void instance.handle.setAutoLayout(autoLayout ?? { type: 'tb', hybridStartDepth: 3, initialVisibleDepth: 'all', hybridOrganizationIds: [] }).catch(error => {
if (instanceRef.current !== instance) return;
try { committedRef.current.onLifecycleError?.(error); }
catch { /* Keep layout setter failures in the lifecycle error contract. */ }
});
}, [autoLayout, layoutMode]);
useLayoutEffect(() => {
const instance = instanceRef.current;
if (!instance || layoutMode !== 'auto' || instance.dragDropEnabled === dragDropEnabled) return;
try {
instance.handle.setDragDropEnabled(dragDropEnabled);
instance.dragDropEnabled = dragDropEnabled;
} catch (error) {
try { committedRef.current.onLifecycleError?.(error); }
catch { /* Keep option changes in the lifecycle error contract. */ }
}
}, [dragDropEnabled, layoutMode]);
useLayoutEffect(() => {
const instance = instanceRef.current;
if (!instance || layoutMode !== 'auto' || instance.interactions === interactions) return;
instance.interactions = interactions;
void instance.handle.setInteractions({ collapse: true, zoom: true, pan: true, ...interactions }).catch(error => {
if (instanceRef.current !== instance) return;
try { committedRef.current.onLifecycleError?.(error); }
catch { /* Interaction controls use the existing lifecycle error contract. */ }
});
}, [interactions, layoutMode]);
useLayoutEffect(() => {
const instance = instanceRef.current;
@@ -139,5 +257,10 @@ export function OrgChart({
});
}, [dataSource]);
return <div ref={containerRef} className={className} style={style} />;
return <>
<div ref={containerRef} className={className} style={style} />
{popupMode === 'hm-dialog' && !hasEmployeeCallback ? <Suspense fallback={null}>
<HMEmployeeDialog selection={employeeSelection} onClose={() => setEmployeeSelection(null)} />
</Suspense> : null}
</>;
}
+6 -2
View File
@@ -1,11 +1,15 @@
import type { CSSProperties } from 'react';
import type { OrgChartOptions, OrgChartState } from '@hm-design/org-chart';
/** The Core owns data, rendering, organization links and employee information
* interactions; React owns its dedicated container. */
/** The Core owns data, organization rendering and interactions; React owns its
* dedicated container and the optional Portal-based HM employee information UI. */
export interface OrgChartProps extends Omit<OrgChartOptions, 'container' | 'onStateChange'> {
className?: string;
style?: CSSProperties;
/** Auto only. Opt into the public HM Dialog (requires @hm-design/ui + its
* Theme), retain the Vanilla popup, or suppress the default information UI.
* An explicit onEmployeeClick callback always owns the popup instead. */
employeePopup?: 'core' | 'hm-dialog' | 'none';
/** Latest committed callback. Controlled data/style failures arrive as error states. */
onStateChange?: (state: OrgChartState) => void;
/** Unexpected create/mount/reload/cleanup errors, rather than controlled error states. */
+77 -4
View File
@@ -73,8 +73,9 @@ organizationName and display (`card`, `titleOnly`, `hidden`). An explicit employ
must belong to that company's root; null removes top-card assignment. Overrides
affect presentation only, preserving employee IDs, popup data and parentId.
`employeeConnections` maps organization IDs to top/left/right staff routes (default
top). Leaders connect from their title above. SVG paths use actual article/card
`employeeConnections` maps organization IDs to top/left/right staff routes in
TB/Hybrid (default top). Their leaders connect from the title above; LR routes all
employees from the left. SVG paths use actual article/card
coordinates; each endpoint shares its direction with the corresponding single-side
border accent. Employee preset follows its known affiliation, otherwise its tree.
All remaining borders are neutral; an inset accent width variable preserves size.
@@ -92,8 +93,9 @@ const chart = createOrgChart({
});
```
Options are captured at construction: create a new handle to change the layout or
company/top-node/connection/zoom configuration. Selection uses its own setter.
Layout mode/company/top-node/connection/zoom ranges are captured at construction.
Auto `autoLayout`, `interactions`, company selection and the employee callback use
their own setters on the existing handle. See the P7 matrix in the usage guide.
Standalone Tree/Summary factories keep their existing rendering paths.
`examples/org-chart/data-expanded-{small,medium,large}.json` contain exactly
@@ -105,6 +107,51 @@ company controls without filtering JSON in the Story. Native Core controls are
the explicit framework-independent HTML adapter, not imitations of HM React UI.
### Virtual example selection (P7)
`createOrgChartExampleDataset(template, selection)` is a pure data provider for
both Vanilla and React. Supply the existing P5-3 **large** JSON fixture, which
contains all seven company presets. The old Small/Medium/Large files and their
historical 3/5/7-company policies remain unchanged; P7 selection does not require
new JSON files. The helper validates and snapshots its input, reuses organization
IDs and employee records, and adds deterministic virtual employees when a company
needs more staff than the original fixture contains.
```js
import { createOrgChartExampleDataset, JsonOrgChartDataSource } from '@hm-design/org-chart';
// template is the parsed examples/org-chart/data-expanded-large.json fixture.
const allCompanyExample = createOrgChartExampleDataset(template, {
scope: 'total', employeeCount: 100,
}); // All seven companies together: exactly 100 unique employees.
const twoCompanyExample = createOrgChartExampleDataset(template, {
scope: 'per-company', employeeCount: 100,
companyIds: ['hanmac', 'saman'],
}); // Exactly 100 employees per company, 200 in this source.
await chart.reload(new JsonOrgChartDataSource(twoCompanyExample));
```
Supported scales are exactly `20 | 100 | 300`. Scope `'total'` allocates that
count across all seven companies using the original fixture's unequal company
proportions. An optional company subset then filters those allocations **without
redistributing** them: a single selected company therefore displays only its
share of the total example. Scope `'per-company'` gives every selected company
the requested count, up to 2,100 employees for seven companies at 300 each.
Omitting `companyIds` selects all seven; repeated/out-of-order IDs are normalized
to preset order. All returned datasets are independent, deeply frozen snapshots.
Leader IDs remain references to existing employees and are never counted twice.
Missing sampled leaders are cleared while the complete, valid parent hierarchy
is retained, including the original deeper and unequal branches.
The helper rejects an empty `companyIds` list because the data contract requires
organizations. To display no companies, retain a valid source and call
`setSelectedCompanies([])` (or pass React `selectedCompanyIds={[]}`). Runtime
layout, collapse state, employee popup policy, and actual API sources remain
separate from example selection. The 2,100-person **data count** is distinct from
rendering/heap performance; the earlier 300-person result does not verify it.
### Historical P5-1B verification and local runtime
Core/React builds and Typechecks pass. Focused browser evidence covers the
@@ -198,3 +245,29 @@ Open `http://127.0.0.1:4178/index.html` for bundled ESM + external JSON. Open `e
`npm run test:org-chart-browser` expects Playwright supplied by the verification environment. If not installed locally, set `ORGCHART_PLAYWRIGHT_MODULE` to its absolute `index.mjs`/`index.js` and optionally `ORGCHART_CHROME` to a Chrome executable. Production has no browser-test dependency. Evidence is written under `test-results/org-chart-browser/`.
The React wrapper owns only ref/effect lifecycle, source/props updates and cleanup, including forwarding lookup and Auto view options; HTML and React render the same Core. Standalone `createOrgChartTree`, `createPositionSummary`, `createAffiliationSummary` and `createStatusSummary` factories are exported at the package root and return the existing `OrgChartHandle`. The four public OrgChart Stories are Logo · Personnel Summary, Department Colors, Full Composition and Expanded OrgChart. Original verification Stories and independent component Stories remain available under the excluded `internal` tag.
# P9.3 Auto 소속 변경
P9.4부터 `dragDropEnabled?: boolean`의 기본값은 `false`다. 아래 포인터·키보드·
`handle.move()` 소속 변경은 `dragDropEnabled: true`일 때만 허용하며 OFF에서는
`EDITING_DISABLED` 결과를 반환한다. 순수 데이터 helper의 이동 검증·적용 계약은 유지한다.
`handle.setDragDropEnabled(boolean)` / `handle.getDragDropEnabled()`로 같은 Auto
인스턴스를 갱신·조회한다. OFF 전환은 진행 중 이동과 강조·Pointer capture를 취소하고
완료된 이동·데이터·Zoom/Pan·접기 상태를 유지한다. Source 재요청·레이아웃 재생성은 없다.
Fixed는 생성 옵션을 무시하며 setter는 Auto 전용 사용 오류를 반환한다.
Expanded Auto는 빈 공간 Pan과 직원 카드·그룹 제목 Drag를 구분한다. 6px 이동부터
Drag를 시작하며 단순 클릭은 기존 정보 팝업·접기 동작이다. `Alt+M` → 방향키 →
Enter로도 이동하며 Esc로 취소한다. Fixed에는 편집을 적용하지 않는다.
`handle.move({ kind: 'employee' | 'organization', sourceId, targetOrganizationId })`는
포인터·키보드와 같은 검증 정책을 사용한다. `handle.getData()`는 현재 Auto의 동결된
데이터를 반환하며 `onMove(result)`로 성공(`moved: true`)·거절 결과를 받는다.
회사 간 직원 이동은 `organizationId`와 `affiliationId`를 대상 조직·회사로 변경한다.
회사 간 그룹 이동은 `parentId`, 전체 하위 조직의 `companyId`, 해당 조직 직원들의
`affiliationId`를 함께 변경한다. 같은 회사 내 이동은 기존 관계사 소속을 유지한다.
회사 Root·조직장 직원·자기 자신·자손으로의 이동은 제한한다. 성공 후 기존 엔진으로
재배치하며 대상 회사 프리셋과 새로운 조직/관계사 인원 집계를 적용한다.
데이터는 브라우저 인스턴스에만 유지한다. 회사 표시 선택은 이동 내용을 보존하고,
새 source의 reload/update는 새 데이터로 대체한다. JSON 파일·서버 저장은 수행하지 않는다.
@@ -0,0 +1,88 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyInitialVisibility, autoEngineOptions, resolveAutoLayout, resolveInteractions } from './auto-layout-policy.js';
import { createOrgChart } from './index.js';
import { engineTree, type RenderContext } from './render.js';
import type { Organization, OrganizationTreeNode } from '../data/index.js';
const organization = (id: string, parentId: string | null): Organization => ({ id, parentId, name: id, type: parentId ? 'team' : 'root', leaderId: null, displayOrder: 0 });
const organizations = [organization('root', null), organization('a', 'root'), organization('b', 'root'), organization('a1', 'a'), organization('a2', 'a1')];
test('each independent layout policy selects compatible installed engine options', () => {
assert.deepEqual(autoEngineOptions(resolveAutoLayout()), { direction: 't2b', visibleLevel: 999 });
assert.deepEqual(autoEngineOptions(resolveAutoLayout({ type: 'lr', initialVisibleDepth: 1 })), { direction: 'l2r', visibleLevel: 999 });
for (const depth of [2, 3, 4] as const) assert.deepEqual(autoEngineOptions(resolveAutoLayout({ type: 'hybrid', hybridStartDepth: depth })), { direction: 't2b', visibleLevel: 999, verticalLevel: depth });
});
test('collapse, Zoom and Pan have independent validated defaults', () => {
assert.deepEqual(resolveInteractions(), { collapse: true, zoom: true, pan: true });
assert.deepEqual(resolveInteractions({ collapse: false }), { collapse: false, zoom: true, pan: true });
assert.deepEqual(resolveInteractions({ zoom: false }), { collapse: true, zoom: false, pan: true });
assert.deepEqual(resolveInteractions({ pan: false }), { collapse: true, zoom: true, pan: false });
assert.throws(() => resolveInteractions({ zoom: 0 as unknown as boolean }), /interactions.zoom must be boolean/);
});
test('incompatible per-group/global/directional combinations fail explicitly', () => {
assert.throws(() => resolveAutoLayout({ type: 'lr', hybridOrganizationIds: ['a'] }), /supported only with TB/);
assert.throws(() => resolveAutoLayout({ type: 'hybrid', hybridOrganizationIds: ['a'] }), /supported only with TB/);
assert.throws(() => resolveAutoLayout({ initialVisibleDepth: 0 as 1 }), /initialVisibleDepth/);
assert.throws(() => resolveAutoLayout({ hybridStartDepth: 5 as 2 }), /hybridStartDepth/);
assert.throws(() => resolveAutoLayout({ hybridOrganizationIds: [''] }), /nonempty/);
const input = ['a', 'a'];
const resolved = resolveAutoLayout({ hybridOrganizationIds: input });
input.push('b');
assert.deepEqual(resolved.hybridOrganizationIds, ['a']);
assert.ok(Object.isFrozen(resolved.hybridOrganizationIds));
});
test('initial depth is one-based and user expansion survives subsequent renders and new data', () => {
const collapsed = new Set<string>(), initialized = new Set<string>();
applyInitialVisibility(organizations, collapsed, initialized, 2);
assert.deepEqual([...collapsed], ['a', 'b', 'a1', 'a2']);
collapsed.delete('a');
applyInitialVisibility([...organizations, organization('b1', 'b')], collapsed, initialized, 2);
assert.equal(collapsed.has('a'), false);
assert.equal(collapsed.has('b1'), true);
applyInitialVisibility(organizations, collapsed, initialized, 'all', true);
assert.equal(collapsed.size, 0);
collapsed.add('a1');
applyInitialVisibility(organizations.filter(item => item.id !== 'a2'), collapsed, initialized, 'all');
assert.equal(collapsed.has('a1'), true);
assert.equal(initialized.has('a2'), false);
});
test('Depth 1 projection keeps independent company roots while collapsed descendants stay out of DOM', () => {
const roots: OrganizationTreeNode[] = [{ organization: organizations[0]!, children: [{ organization: organizations[1]!, children: [{ organization: organizations[3]!, children: [] }] }] }];
const collapsed = new Set<string>(), initialized = new Set<string>();
applyInitialVisibility(organizations, collapsed, initialized, 1);
const context = { orderedOrganizations: [], readOnly: true, collapsedOrganizations: collapsed, autoLayout: resolveAutoLayout() } as unknown as RenderContext;
const tree = engineTree(roots, 'initial', context);
assert.equal(tree.length, 1);
assert.equal(tree[0]!.children, undefined);
assert.deepEqual(context.orderedOrganizations.map(item => item.id), ['root']);
});
test('per-group Hybrid projection marks only the selected parent and leaves source immutable', () => {
const roots: OrganizationTreeNode[] = [{ organization: organizations[0]!, children: [{ organization: organizations[1]!, children: [{ organization: organizations[3]!, children: [] }] }, { organization: organizations[2]!, children: [] }] }];
const before = structuredClone(roots);
const context = { orderedOrganizations: [], readOnly: true, autoLayout: resolveAutoLayout({ hybridOrganizationIds: ['a'] }) } as unknown as RenderContext;
const tree = engineTree(roots, 'groups', context);
assert.equal(tree[0]!.hybrid, undefined);
assert.equal(tree[0]!.level, 1);
assert.equal(tree[0]!.children![0]!.hybrid, true);
assert.equal(tree[0]!.children![0]!.level, 2);
assert.equal(tree[0]!.children![0]!.children![0]!.level, 3);
assert.equal(tree[0]!.children![1]!.hybrid, undefined);
assert.deepEqual(roots, before);
});
test('Fixed ignores Auto configuration and facade defaults keep existing TB fully expanded', () => {
const options = { dataSource: { load: async () => ({}) }, stylesheetUrls: { engine: '/engine.css', core: '/style.css' } };
const fixed = createOrgChart({ ...options, autoLayout: { type: 'invalid' as 'tb' } });
assert.equal(fixed.getAutoLayout().type, 'tb');
fixed.destroy();
const expanded = createOrgChart({ ...options, layoutMode: 'auto' });
assert.equal(expanded.getAutoLayout().initialVisibleDepth, 'all');
assert.equal(expanded.getAutoLayout().type, 'tb');
expanded.destroy();
});
@@ -0,0 +1,59 @@
import type { Organization } from '../data/index.js';
import type { OrgChartAutoLayoutOptions, OrgChartAutoLayoutType, OrgChartInteractionOptions } from './types.js';
export function resolveInteractions(options: OrgChartInteractionOptions = {}): Readonly<Required<OrgChartInteractionOptions>> {
for (const key of ['collapse', 'zoom', 'pan'] as const) {
if (options[key] !== undefined && typeof options[key] !== 'boolean') throw new TypeError(`interactions.${key} must be boolean`);
}
return Object.freeze({ collapse: options.collapse ?? true, zoom: options.zoom ?? true, pan: options.pan ?? true });
}
export interface ResolvedAutoLayout {
readonly type: OrgChartAutoLayoutType;
readonly hybridStartDepth: 2 | 3 | 4;
readonly initialVisibleDepth: 1 | 2 | 3 | 'all';
readonly hybridOrganizationIds: readonly string[];
}
/** Match installed dabeng 6's mutually exclusive directional/vertical policies. */
export function resolveAutoLayout(options: OrgChartAutoLayoutOptions = {}): ResolvedAutoLayout {
const type = options.type ?? 'tb';
const hybridStartDepth = options.hybridStartDepth ?? 3;
const initialVisibleDepth = options.initialVisibleDepth ?? 'all';
if (!['tb', 'lr', 'hybrid'].includes(type)) throw new TypeError('autoLayout.type must be tb, lr or hybrid');
if (![2, 3, 4].includes(hybridStartDepth)) throw new TypeError('autoLayout.hybridStartDepth must be 2, 3 or 4');
if (![1, 2, 3, 'all'].includes(initialVisibleDepth)) throw new TypeError('autoLayout.initialVisibleDepth must be 1, 2, 3 or all');
if (options.hybridOrganizationIds !== undefined && (!Array.isArray(options.hybridOrganizationIds) || options.hybridOrganizationIds.some(id => typeof id !== 'string' || !id.trim()))) throw new TypeError('autoLayout.hybridOrganizationIds must contain nonempty organization IDs');
const hybridOrganizationIds = Object.freeze([...new Set(options.hybridOrganizationIds ?? [])]);
if (type !== 'tb' && hybridOrganizationIds.length) throw new TypeError('Per-group hybridOrganizationIds are supported only with TB; clear them before selecting LR or Hybrid');
return Object.freeze({ type, hybridStartDepth, initialVisibleDepth, hybridOrganizationIds });
}
export function autoEngineOptions(layout: ResolvedAutoLayout): { direction: 't2b' | 'l2r'; verticalLevel?: number; visibleLevel: number } {
// Core owns collapse state and prunes its projection. Upstream visibleLevel
// would retain hidden descendants that disagree with Core keyboard/state APIs.
return { direction: layout.type === 'lr' ? 'l2r' : 't2b', visibleLevel: 999,
...(layout.type === 'hybrid' ? { verticalLevel: layout.hybridStartDepth } : {}) };
}
/** Apply the visibleLevel policy to new IDs only, keeping user changes stable. */
export function applyInitialVisibility(organizations: readonly Organization[], collapsed: Set<string>, initialized: Set<string>, visibleDepth: ResolvedAutoLayout['initialVisibleDepth'], reset = false): void {
const valid = new Set(organizations.map(organization => organization.id));
for (const id of collapsed) if (!valid.has(id) || reset) collapsed.delete(id);
for (const id of initialized) if (!valid.has(id) || reset) initialized.delete(id);
const byId = new Map(organizations.map(organization => [organization.id, organization]));
for (const organization of organizations) {
if (initialized.has(organization.id)) continue;
initialized.add(organization.id);
if (visibleDepth === 'all') continue;
let depth = 1;
let current = organization;
const seen = new Set([current.id]);
while (current.parentId) {
const parent = byId.get(current.parentId);
if (!parent || seen.has(parent.id)) break;
seen.add(parent.id); current = parent; depth++;
}
if (depth >= visibleDepth) collapsed.add(organization.id);
}
}
@@ -27,8 +27,20 @@ export function createEmployeeInfoDialog(
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'hm-orgchart-employee-dialog-close';
closeButton.textContent = '닫기';
closeButton.setAttribute('aria-label', '직원 정보 닫기');
// HM Dialog's Lucide XIcon, kept as native SVG for the shared Vanilla Core.
const closeIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
for (const [name, value] of Object.entries({
width: '24', height: '24', viewBox: '0 0 24 24', fill: 'none',
stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round',
'stroke-linejoin': 'round', 'aria-hidden': 'true', focusable: 'false',
})) closeIcon.setAttribute(name, value);
for (const d of ['M18 6 6 18', 'm6 6 12 12']) {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', d);
closeIcon.append(path);
}
closeButton.append(closeIcon);
const fields = document.createElement('dl');
fields.className = 'hm-orgchart-employee-dialog-fields';
header.append(title, closeButton);
+53 -1
View File
@@ -1,10 +1,17 @@
import type OrgChart from 'orgchart';
import type { EngineNode } from 'orgchart';
import type { EngineNode, EngineOptions } from 'orgchart';
export type RenderEngineNode = EngineNode;
export interface EngineEditing {
canDrop(source: HTMLElement, target: HTMLElement): boolean;
onDrop(source: HTMLElement, target: HTMLElement): void;
}
export interface EngineAdapter {
update(data: RenderEngineNode[]): void;
toggle(node: HTMLElement): boolean | undefined;
prepareDrag(source: HTMLElement): void;
drop(source: HTMLElement, target: HTMLElement): void;
cancelDrag(): void;
dispose(): void;
}
@@ -51,6 +58,8 @@ export function createEngineAdapter(
viewport: HTMLElement,
data: RenderEngineNode[],
decorate: (node: HTMLElement, data: RenderEngineNode, signal: AbortSignal) => void,
layoutOptions: Pick<EngineOptions, 'direction' | 'verticalLevel' | 'visibleLevel'> = {},
editing?: EngineEditing,
): EngineAdapter {
const Abort = viewport.ownerDocument.defaultView?.AbortController ?? AbortController;
let generation = new Abort();
@@ -59,7 +68,12 @@ export function createEngineAdapter(
const forest = viewport.ownerDocument.createElement('div');
forest.className = 'hm-orgchart-forest';
viewport.append(forest);
function cancelDrag(): void {
if (!editing) return;
for (const instance of instances) instance.dragendHandler();
}
function releaseGeneration(): void {
cancelDrag();
generation.abort();
for (const instance of instances) {
instance.disconnectInitObserver();
@@ -84,8 +98,10 @@ export function createEngineAdapter(
treeContainer.className = 'hm-orgchart-tree';
forest.append(treeContainer);
const instance = new Engine(treeContainer, {
...layoutOptions,
data: root, pan: false, zoom: false, minimap: false, draggable: false, exportButton: false,
chartClass: 'hm-orgchart-engine',
...(editing ? { dropCriteria: (source: HTMLElement, _parent: HTMLElement | null, target: HTMLElement) => editing.canDrop(source, target) } : {}),
nodeTemplate: () => '<article class="hm-orgchart-organization"></article>',
createNode: (node, value) => decorate(node, value, generation.signal),
});
@@ -108,6 +124,23 @@ export function createEngineAdapter(
}
function bindGeneration(): void {
forest.addEventListener('transitionend', syncButtons, { signal: generation.signal });
if (!editing) return;
for (const instance of instances) {
// Core owns immutable data and rerendering. Reuse upstream drop events,
// but cancel its DOM/data mutation before either horizontal or hybrid path.
const commitDrop = (event: Event) => {
event.preventDefault();
const detail = (event as CustomEvent<{ draggedNode?: HTMLElement; draggedItem?: HTMLElement; dropZone: HTMLElement }>).detail;
if (!detail) return;
const source = detail.draggedNode ?? detail.draggedItem;
const target = detail.dropZone;
if (source && target && instance.chart?.contains(source) && instances.some(candidate => candidate.chart?.contains(target)) && editing.canDrop(source, target)) {
editing.onDrop(source, target);
}
};
instance.chart?.addEventListener('nodedrop.orgchart', commitDrop, { signal: generation.signal });
instance.chart?.addEventListener('otherdropped.orgchart', commitDrop, { signal: generation.signal });
}
}
build(data);
bindGeneration();
@@ -133,6 +166,25 @@ export function createEngineAdapter(
syncButtons();
return !expanded;
},
prepareDrag(source) {
if (disposed || !editing) return;
cancelDrag();
if (!instances.some(candidate => candidate.chart?.contains(source))) return;
// Independent company trees share one editing policy. Upstream caches the
// source and filters targets within its own chart, so filter each tree.
for (const instance of instances) instance.filterAllowedDropNodes(source);
},
drop(source, target) {
if (disposed || !editing) return;
const instance = instances.find(candidate => candidate.chart?.contains(source));
if (!instance || !instances.some(candidate => candidate.chart?.contains(target)) || !editing.canDrop(source, target)) { cancelDrag(); return; }
// Refresh upstream's cached source and allowed targets for the final data
// policy check. Pointer coordinates and thresholds stay in the Core UI.
for (const candidate of instances) candidate.filterAllowedDropNodes(source);
void instance.dropHandler({ dropZone: target });
cancelDrag();
},
cancelDrag,
dispose() {
if (disposed) return;
disposed = true;
@@ -19,6 +19,13 @@ export function decorateExpandedNode(node: HTMLElement, value: RenderEngineNode,
node.dataset.organizationType = organization.type;
node.dataset.companyId = companyId;
node.dataset.collapsed = String(collapsed);
node.draggable = false;
const counts = context.summary.raw.byOrganization[organization.id];
node.dataset.directEmployeeCount = String(counts?.direct ?? 0);
node.dataset.inclusiveEmployeeCount = String(counts?.inclusive ?? 0);
const vertical = value.vertical === true || (context.autoLayout?.type === 'hybrid' && Number(value.depth) + 1 >= context.autoLayout.hybridStartDepth);
node.dataset.verticalLayout = String(vertical);
const layoutDirection = context.autoLayout?.type === 'lr' || vertical ? 'left' : 'top';
node.style.setProperty('--hm-orgchart-expanded-primary', preset.primary);
node.style.setProperty('--hm-orgchart-expanded-title-ink', ['saman', 'jangheon-industry'].includes(companyId) ? 'var(--hm-orgchart-resolved-fg-primary)' : 'var(--hm-orgchart-resolved-on-action-primary)');
preset.secondary.forEach((color, index) => node.style.setProperty(`--hm-orgchart-expanded-secondary-${index + 1}`, color));
@@ -34,10 +41,11 @@ export function decorateExpandedNode(node: HTMLElement, value: RenderEngineNode,
button.className = 'hm-orgchart-expanded-toggle';
button.dataset.organizationId = organization.id;
button.textContent = `${top?.organizationName ?? organization.name} ${hasChildren ? collapsed ? '▸' : '▾' : '·'}`;
button.setAttribute('aria-label', `${top?.organizationName ?? organization.name} 하위 조직·직원`);
button.setAttribute('aria-label', `${top?.organizationName ?? organization.name} 하위 조직·직원 · 직접 ${counts?.direct ?? 0}명 · 하위 포함 ${counts?.inclusive ?? 0}명`);
button.setAttribute('aria-expanded', String(!collapsed));
button.disabled = !hasChildren;
button.addEventListener('click', event => { event.stopPropagation(); context.toggleExpanded?.(organization.id); }, { signal });
// Empty titles remain focusable move sources/targets. Collapse is independent.
button.setAttribute('aria-disabled', String(!hasChildren || context.interactions?.collapse === false));
button.addEventListener('click', event => { event.stopPropagation(); if (hasChildren && context.interactions?.collapse !== false) context.toggleExpanded?.(organization.id); }, { signal });
heading.append(button);
const href = context.organizationLinks?.[organization.id];
if (href) {
@@ -51,6 +59,7 @@ export function decorateExpandedNode(node: HTMLElement, value: RenderEngineNode,
}
article.append(heading);
const colorCard = (card: HTMLElement, affiliationId: string) => {
card.draggable = false;
const employeePreset = getCompanyColorPreset(affiliationId) ?? preset;
card.dataset.companyId = employeePreset.companyId;
card.style.setProperty('--hm-orgchart-expanded-card-primary', `var(--hm-orgchart-company-${employeePreset.companyId})`);
@@ -59,7 +68,7 @@ export function decorateExpandedNode(node: HTMLElement, value: RenderEngineNode,
if (leader && top?.display !== 'hidden' && top?.display !== 'titleOnly') {
const presentation = top ? { ...leader, position: top.title ?? leader.position, name: top.name ?? leader.name, profileImage: top.profileImage === undefined ? leader.profileImage : top.profileImage } : leader;
const card = colorCard(createEmployeeCard(document, presentation, { kind: organization.parentId === null ? 'topLeader' : 'leader', expandedPresentation: true, hideStatusLabel: true }, signal), leader.affiliationId);
card.dataset.connectionDirection = 'top';
card.dataset.connectionDirection = context.autoLayout?.type === 'lr' ? 'left' : 'top';
article.append(card);
} else if (leader && top?.display === 'titleOnly') {
const title = document.createElement('p');
@@ -70,7 +79,7 @@ export function decorateExpandedNode(node: HTMLElement, value: RenderEngineNode,
if (!collapsed && staff.length) {
const employees = document.createElement('div');
employees.className = 'hm-orgchart-employees';
const direction = context.employeeConnections?.[organization.id] ?? 'top';
const direction = context.employeeConnections?.[organization.id] ?? layoutDirection;
employees.dataset.connectionDirection = direction;
for (const employee of staff) {
const card = colorCard(createEmployeeCard(document, employee, { kind: 'employee', expandedPresentation: true, hideStatusLabel: true }, signal), employee.affiliationId);
@@ -86,6 +95,10 @@ export function createExpandedOrgChartContent(document: Document, context: Rende
content.className = 'hm-orgchart-expanded';
content.dataset.component = 'ExpandedOrgChartContent';
content.dataset.layoutMode = 'auto';
content.dataset.autoLayout = context.autoLayout?.type ?? 'tb';
content.dataset.hybridStartDepth = String(context.autoLayout?.hybridStartDepth ?? 3);
content.dataset.initialVisibleDepth = String(context.autoLayout?.initialVisibleDepth ?? 'all');
content.dataset.groupHybrid = String(!!context.autoLayout?.hybridOrganizationIds.length);
content.dataset.layoutStatus = 'hierarchical';
content.dataset.cardDesignStatus = 'current-presentation';
for (const company of new Set([...context.organizationCompanies!.values(), ...context.data.employees.map(employee => employee.affiliationId)])) {
@@ -94,6 +107,6 @@ export function createExpandedOrgChartContent(document: Document, context: Rende
}
tree.setAttribute('aria-label', '확장형 조직 계층도 · Ctrl 또는 Command + 휠로 확대 · 드래그로 이동');
content.append(createHeading(document, context), tree);
alignExpandedLevels(tree, signal, layoutReady);
alignExpandedLevels(tree, signal, layoutReady, context.autoLayout);
return content;
}
@@ -0,0 +1,161 @@
import type { OrgChartMoveRequest, OrgChartMoveValidation } from '../data/index.js';
import type { EngineAdapter } from './engine.js';
export interface ExpandedEditing {
setEnabled(enabled: boolean): void;
}
/** The pointer threshold is shared conceptually with Pan (6 screen pixels).
* Upstream owns allowedDrop and drop events; Core owns immutable relationships.
* Browser hit testing already includes the forest's Zoom/Pan transform. */
export function createExpandedEditing(viewport: HTMLElement, feedback: HTMLElement, signal: AbortSignal,
engine: EngineAdapter, validate: (request: OrgChartMoveRequest) => OrgChartMoveValidation,
move: (request: OrgChartMoveRequest) => void, suppressClick: () => void, initiallyEnabled = false): ExpandedEditing {
const document = viewport.ownerDocument, view = document.defaultView!;
let enabled = initiallyEnabled;
const nodes = () => [...viewport.querySelectorAll<HTMLElement>('.node[data-organization-id]')];
let pending: { source: HTMLElement; id: number; x: number; y: number; moved: boolean } | undefined;
let keyboardSource: HTMLElement | undefined;
let hover: HTMLElement | undefined;
const sourceFor = (target: EventTarget | null): HTMLElement | undefined => {
if (!(target instanceof view.Element)) return;
const employee = target.closest<HTMLElement>('.hm-orgchart-employee');
if (employee && viewport.contains(employee)) return employee;
const title = target.closest('.hm-orgchart-expanded-toggle');
if (title && viewport.contains(title)) return title.closest<HTMLElement>('.node') ?? undefined;
};
const requestFor = (source: HTMLElement, target?: HTMLElement): OrgChartMoveRequest => ({
kind: source.matches('.hm-orgchart-employee') ? 'employee' : 'organization',
sourceId: source.dataset.employeeId ?? source.dataset.organizationId!,
targetOrganizationId: target?.dataset.organizationId ?? '',
});
const name = (node: HTMLElement) => node.querySelector('.hm-orgchart-expanded-toggle')?.textContent ?? node.dataset.organizationId!;
function clear(): void {
engine.cancelDrag();
for (const element of viewport.querySelectorAll<HTMLElement>('[data-drop-state], [data-drop-hover], [data-edit-source]')) {
delete element.dataset.dropState; delete element.dataset.dropHover; delete element.dataset.editSource;
}
hover = undefined; delete viewport.dataset.editDragging;
}
function start(source: HTMLElement): void {
clear(); engine.prepareDrag(source);
source.dataset.editSource = 'true'; viewport.dataset.editDragging = 'true';
for (const target of nodes()) target.dataset.dropState = validate(requestFor(source, target)).allowed ? 'allowed' : 'blocked';
feedback.dataset.moveStatus = 'dragging';
feedback.textContent = '이동할 조직에 놓으세요. 회사 간 이동도 가능합니다. 조직장·최상위 조직은 이동할 수 없습니다.';
}
function selectTarget(source: HTMLElement, target?: HTMLElement): void {
if (hover) delete hover.dataset.dropHover;
hover = target;
if (!target) return;
const policy = validate(requestFor(source, target));
target.dataset.dropHover = policy.allowed ? 'allowed' : 'blocked';
feedback.textContent = policy.allowed ? `${name(target)} · 이동 가능` : `${name(target)} · ${policy.message}`;
}
const hitTarget = (x: number, y: number) => {
const box = viewport.getBoundingClientRect();
if (x < box.left || x >= box.right || y < box.top || y >= box.bottom) return;
// The presenter binds before its fragment is attached to the host Shadow DOM.
// Resolve the live root at hit-test time instead of retaining that fragment.
const root = viewport.getRootNode();
const hit = (root instanceof view.ShadowRoot ? root : document).elementFromPoint(x, y);
const node = hit?.closest<HTMLElement>('.node[data-organization-id]');
return node && viewport.contains(node) ? node : undefined;
};
function drop(source: HTMLElement, target?: HTMLElement): boolean {
if (!enabled) { clear(); return false; }
const request = requestFor(source, target), policy = validate(request);
// The upstream otherdropped event intentionally skips its allowedDrop check.
// Always revalidate before entering either upstream drop-event route.
if (target && policy.allowed) engine.drop(source, target);
else move(request);
clear();
return policy.allowed && !!target;
}
viewport.addEventListener('dragstart', event => event.preventDefault(), { capture: true, signal });
viewport.addEventListener('pointerdown', event => {
if (!enabled || event.button !== 0 || !event.isPrimary || keyboardSource) return;
const source = sourceFor(event.target);
if (!source) return;
pending = { source, id: event.pointerId, x: event.clientX, y: event.clientY, moved: false };
}, { signal });
view.addEventListener('pointermove', event => {
if (!enabled || !pending || pending.id !== event.pointerId) return;
if (!pending.moved && Math.hypot(event.clientX - pending.x, event.clientY - pending.y) < 6) return;
if (!pending.moved) {
pending.moved = true; viewport.setPointerCapture(event.pointerId); start(pending.source);
}
event.preventDefault();
selectTarget(pending.source, hitTarget(event.clientX, event.clientY));
}, { passive: false, signal });
function finish(event: PointerEvent): void {
if (!pending || pending.id !== event.pointerId) return;
const drag = pending; pending = undefined;
if (viewport.hasPointerCapture(event.pointerId)) viewport.releasePointerCapture(event.pointerId);
if (!drag.moved) return;
suppressClick();
if (event.type === 'pointerup') drop(drag.source, hitTarget(event.clientX, event.clientY));
else { clear(); feedback.dataset.moveStatus = 'cancelled'; feedback.textContent = '이동을 취소했습니다.'; }
}
view.addEventListener('pointerup', finish, { signal });
view.addEventListener('pointercancel', finish, { signal });
viewport.addEventListener('lostpointercapture', event => {
// Touch initially captures its card. Transferring capture to the viewport
// releases that descendant and bubbles here; the viewport still owns Drag.
if (event.target !== viewport || !pending?.moved) return;
pending = undefined; clear(); feedback.textContent = '이동을 취소했습니다.';
}, { signal });
const cancel = (restoreFocus = true) => {
const source = keyboardSource;
if (pending?.moved) suppressClick();
const id = pending?.id; pending = undefined; keyboardSource = undefined;
if (id !== undefined && viewport.hasPointerCapture(id)) viewport.releasePointerCapture(id);
clear(); feedback.dataset.moveStatus = 'cancelled'; feedback.textContent = '이동을 취소했습니다.';
const focus = source?.matches('.node') ? source.querySelector<HTMLElement>('.hm-orgchart-expanded-toggle') : source;
if (restoreFocus) focus?.focus({ preventScroll: true });
};
view.addEventListener('blur', () => { if (pending || keyboardSource) cancel(); }, { signal });
viewport.addEventListener('keydown', event => {
if (!enabled) return;
if (event.key === 'Escape' && (keyboardSource || pending)) { event.preventDefault(); event.stopImmediatePropagation(); cancel(); return; }
if (!keyboardSource && event.altKey && event.key.toLowerCase() === 'm') {
const source = sourceFor(event.target);
if (!source) return;
event.preventDefault(); event.stopImmediatePropagation(); keyboardSource = source; start(source);
const targets = nodes(), target = targets.find(target => validate(requestFor(source, target)).allowed) ?? targets[0];
selectTarget(source, target);
if (target) { target.tabIndex = -1; target.focus({ preventScroll: true }); }
return;
}
if (!keyboardSource) return;
if (['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft', 'Home', 'End'].includes(event.key)) {
event.preventDefault(); event.stopImmediatePropagation();
const targets = nodes(), index = Math.max(0, targets.indexOf(hover!));
const next = event.key === 'Home' ? 0 : event.key === 'End' ? targets.length - 1
: (index + (['ArrowUp', 'ArrowLeft'].includes(event.key) ? -1 : 1) + targets.length) % targets.length;
selectTarget(keyboardSource, targets[next]);
if (hover) { hover.tabIndex = -1; hover.focus({ preventScroll: true }); }
} else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault(); event.stopImmediatePropagation(); const source = keyboardSource; keyboardSource = undefined;
if (!drop(source, hover)) {
const focus = source.matches('.node') ? source.querySelector<HTMLElement>('.hm-orgchart-expanded-toggle') : source;
focus?.focus({ preventScroll: true });
}
} else if (event.key === 'Tab') { event.preventDefault(); event.stopImmediatePropagation(); cancel(); }
}, { capture: true, signal });
signal.addEventListener('abort', () => {
const id = pending?.id; pending = undefined; keyboardSource = undefined;
if (id !== undefined && viewport.hasPointerCapture(id)) viewport.releasePointerCapture(id);
clear();
}, { once: true });
return {
setEnabled(next) {
if (enabled === next) return;
enabled = next;
if (!enabled) {
if (pending || keyboardSource) cancel(false);
else clear();
}
},
};
}
+61 -2
View File
@@ -1,7 +1,9 @@
/** dabeng owns recursive widths and connectors. Reserve the tallest content
* height per depth so child rows align with unequal staff counts. Observe the
* articles rather than reserved nodes to avoid ResizeObserver feedback. */
export function alignExpandedLevels(viewport: HTMLElement, signal: AbortSignal, layoutReady: () => void = () => {}): void {
import type { ResolvedAutoLayout } from './auto-layout-policy.js';
export function alignExpandedLevels(viewport: HTMLElement, signal: AbortSignal, layoutReady: () => void = () => {}, layout?: ResolvedAutoLayout): void {
const view = viewport.ownerDocument.defaultView;
if (!view || signal.aborted) return;
const nodes = [...viewport.querySelectorAll<HTMLElement>('.node[data-depth]')];
@@ -10,15 +12,20 @@ export function alignExpandedLevels(viewport: HTMLElement, signal: AbortSignal,
frame = 0;
if (signal.aborted) return;
const heights = new Map<string, number>();
const widths = new Map<string, number>();
for (const node of nodes) {
const height = node.querySelector<HTMLElement>('.hm-orgchart-organization')?.offsetHeight ?? 0;
heights.set(node.dataset.depth!, Math.max(heights.get(node.dataset.depth!) ?? 0, Math.ceil(height)));
const width = node.querySelector<HTMLElement>('.hm-orgchart-organization')?.offsetWidth ?? 0;
widths.set(node.dataset.depth!, Math.max(widths.get(node.dataset.depth!) ?? 0, Math.ceil(width)));
}
for (const node of nodes) {
node.style.minHeight = `${heights.get(node.dataset.depth!) ?? 0}px`;
node.style.minHeight = layout?.type === 'lr' || node.closest('.nodes.vertical') ? '0' : `${heights.get(node.dataset.depth!) ?? 0}px`;
if (layout?.type === 'lr') node.style.minWidth = `${widths.get(node.dataset.depth!) ?? 0}px`;
node.style.setProperty('--hm-orgchart-expanded-content-height', `${node.querySelector<HTMLElement>('.hm-orgchart-organization')?.offsetHeight ?? 0}px`);
}
drawEmployeeConnectors(viewport);
if (layout?.type === 'lr' || layout?.type === 'hybrid' || layout?.hybridOrganizationIds.length) drawOrganizationConnectors(viewport, layout);
layoutReady();
};
const schedule = () => {
@@ -36,6 +43,58 @@ export function alignExpandedLevels(viewport: HTMLElement, signal: AbortSignal,
schedule();
}
/** Adapt dabeng's existing hierarchy DOM to tall HM cards. These paths own only
* connector presentation; dabeng still creates and recursively lays out nodes. */
function drawOrganizationConnectors(viewport: HTMLElement, layout: ResolvedAutoLayout): void {
const document = viewport.ownerDocument;
for (const tree of viewport.querySelectorAll<HTMLElement>('.hm-orgchart-tree')) {
tree.querySelector(':scope > .hm-orgchart-organization-connectors')?.remove();
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.classList.add('hm-orgchart-organization-connectors');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('width', String(tree.offsetWidth));
svg.setAttribute('height', String(tree.offsetHeight));
const nodes = [...tree.querySelectorAll<HTMLElement>('.node[data-organization-id]')];
const byId = new Map(nodes.map(node => [node.dataset.organizationId!, node]));
const local = (element: HTMLElement) => {
const treeRect = tree.getBoundingClientRect(), rect = element.getBoundingClientRect();
// Forest scale is the only transform (LR's upstream rotation is scoped out).
const scale = Number(viewport.dataset.scale) || 1;
return { x: (rect.left - treeRect.left) / scale, y: (rect.top - treeRect.top) / scale,
width: element.offsetWidth, height: element.offsetHeight };
};
for (const node of nodes) {
const parent = byId.get(node.dataset.parentId!);
if (!parent) continue;
const article = parent.querySelector<HTMLElement>(':scope > .hm-orgchart-organization')!;
const heading = node.querySelector<HTMLElement>('.hm-orgchart-organization-name')!;
const from = local(article), to = local(heading);
const left = layout.type === 'lr' || !!node.closest('.nodes.vertical');
const path = document.createElementNS(svg.namespaceURI, 'path');
if (layout.type === 'lr') {
const parentHeading = local(parent.querySelector<HTMLElement>('.hm-orgchart-organization-name')!);
const x = parentHeading.x + parentHeading.width, y = parentHeading.y + parentHeading.height / 2;
const targetY = to.y + to.height / 2, rail = (x + to.x) / 2;
path.setAttribute('d', `M ${x} ${y} H ${rail} V ${targetY} H ${to.x}`);
} else if (left) {
const x = from.x + from.width / 2, y = from.y + from.height;
const rail = to.x - 16, targetY = to.y + to.height / 2;
const branchY = y + 12;
path.setAttribute('d', `M ${x} ${y} V ${branchY} H ${rail} V ${targetY} H ${to.x}`);
} else {
const x = from.x + from.width / 2, y = from.y + from.height;
const targetX = to.x + to.width / 2, rail = (y + to.y) / 2;
path.setAttribute('d', `M ${x} ${y} V ${rail} H ${targetX} V ${to.y}`);
}
path.setAttribute('data-organization-id', node.dataset.organizationId!);
path.setAttribute('data-parent-id', node.dataset.parentId!);
path.setAttribute('data-direction', left ? 'left' : 'top');
svg.append(path);
}
tree.prepend(svg);
}
}
/** Draw routes in unscaled article coordinates. The endpoint defines both the
* connector's incoming direction and the single accent side on the card. */
function drawEmployeeConnectors(viewport: HTMLElement): void {
@@ -5,20 +5,25 @@ export interface ExpandedViewport {
panBy(x: number, y: number): void;
fit(): void;
state(): OrgChartViewportState;
hasManualPosition(): boolean;
layoutReady(): void;
}
/** One transformed forest, shared by HTML and React; dialogs live in the shell. */
export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLElement, signal: AbortSignal,
config: { min: number; max: number; step: number }, initial?: OrgChartViewportState, fitInitially = true): ExpandedViewport {
config: { min: number; max: number; step: number; zoomEnabled?: boolean; panEnabled?: boolean }, initial?: OrgChartViewportState, centerInitially = true, initialManualPosition = false): ExpandedViewport {
const document = viewport.ownerDocument;
const view = document.defaultView!;
const forest = viewport.querySelector<HTMLElement>('.hm-orgchart-forest')!;
let { scale, x, y } = initial ?? { scale: 1, x: 0, y: 0 };
let initialFit = fitInitially;
let manualPosition = initialManualPosition;
let placement: 'center' | 'fit' | undefined = centerInitially && !manualPosition ? 'center' : undefined;
let drag: { id: number; x: number; y: number; moved: boolean } | undefined;
let suppressClick = false;
let focusFrame = 0;
const zoomEnabled = config.zoomEnabled !== false, panEnabled = config.panEnabled !== false;
viewport.dataset.zoomEnabled = String(zoomEnabled);
viewport.dataset.panEnabled = String(panEnabled);
const output = document.createElement('output');
output.setAttribute('aria-label', '현재 배율');
output.setAttribute('aria-live', 'polite');
@@ -29,35 +34,61 @@ export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLEleme
viewport.dataset.panX = String(x);
viewport.dataset.panY = String(y);
output.textContent = `${Math.round(scale * 100)}%`;
buttons[0].disabled = scale >= config.max;
buttons[1].disabled = scale <= config.min;
buttons[0].disabled = !zoomEnabled || scale >= config.max;
buttons[1].disabled = !zoomEnabled || scale <= config.min;
};
const clamp = (value: number) => Math.max(config.min, Math.min(config.max, value));
// Engine wrappers reserve asymmetric margins (LR) and empty row space (TB).
// Center the complete rendered content, including organization connector rails,
// in forest-local coordinates so Pan/Zoom do not affect the measured bounds.
const contentBounds = () => {
const origin = forest.getBoundingClientRect();
let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity;
for (const element of forest.querySelectorAll('.hm-orgchart-organization, .hm-orgchart-organization-connectors path')) {
const rect = element.getBoundingClientRect();
if ((!rect.width && !rect.height) || !element.getClientRects().length) continue;
left = Math.min(left, (rect.left - origin.left) / scale);
top = Math.min(top, (rect.top - origin.top) / scale);
right = Math.max(right, (rect.right - origin.left) / scale);
bottom = Math.max(bottom, (rect.bottom - origin.top) / scale);
}
return Number.isFinite(left) ? { left, top, width: right - left, height: bottom - top } : undefined;
};
const position = (fit: boolean) => {
const bounds = contentBounds();
if (!bounds || !bounds.width || !bounds.height || !viewport.clientWidth || !viewport.clientHeight) return;
if (fit) {
// Only an explicit Fit shrinks to contain, even below manual Zoom's min.
scale = Math.max(Number.EPSILON, Math.min((viewport.clientWidth - 32) / bounds.width, (viewport.clientHeight - 32) / bounds.height, 1));
}
x = (viewport.clientWidth - bounds.width * scale) / 2 - bounds.left * scale;
y = (fit ? Math.max(16, (viewport.clientHeight - bounds.height * scale) / 2) : 16) - bounds.top * scale;
apply();
};
const preservePosition = () => { manualPosition = true; placement = undefined; };
const controller: ExpandedViewport = {
zoomTo(next, anchor = { x: viewport.clientWidth / 2, y: viewport.clientHeight / 2 }) {
if (!zoomEnabled) return;
if (!Number.isFinite(next)) return;
initialFit = false;
preservePosition();
const value = clamp(next), factor = value / scale;
x = anchor.x - (anchor.x - x) * factor;
y = anchor.y - (anchor.y - y) * factor;
scale = value; apply();
},
panBy(dx, dy) { if (!Number.isFinite(dx) || !Number.isFinite(dy)) return; initialFit = false; x += dx; y += dy; apply(); },
panBy(dx, dy) { if (!panEnabled || !Number.isFinite(dx) || !Number.isFinite(dy)) return; preservePosition(); x += dx; y += dy; apply(); },
fit() {
if (!forest.offsetWidth || !forest.offsetHeight) return;
scale = clamp(Math.min((viewport.clientWidth - 32) / forest.offsetWidth, (viewport.clientHeight - 32) / forest.offsetHeight, 1));
x = (viewport.clientWidth - forest.offsetWidth * scale) / 2;
y = Math.max(16, (viewport.clientHeight - forest.offsetHeight * scale) / 2);
apply();
manualPosition = false; placement = 'fit'; position(true);
},
state: () => ({ scale, x, y }),
layoutReady() { if (initialFit) controller.fit(); },
hasManualPosition: () => manualPosition,
layoutReady() { if (placement) position(placement === 'fit'); },
};
const actions: [string, () => void][] = [
['확대', () => controller.zoomTo(scale * config.step)],
['축소', () => controller.zoomTo(scale / config.step)],
['100% 복원', () => { initialFit = false; scale = 1; x = 16; y = 16; apply(); }],
['Fit to View', () => { initialFit = true; controller.fit(); }],
['100% 복원', () => { preservePosition(); scale = 1; x = 16; y = 16; apply(); }],
['Fit to View', () => controller.fit()],
];
toolbar.setAttribute('role', 'group');
toolbar.setAttribute('aria-label', '조직도 확대·이동 도구');
@@ -69,9 +100,10 @@ export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLEleme
}
toolbar.append(output);
const hint = document.createElement('span');
hint.textContent = '드래그 이동 · Ctrl/⌘ + 휠 확대 · 방향키 이동';
hint.textContent = [panEnabled ? '드래그·방향키 이동' : '', zoomEnabled ? 'Ctrl/⌘ + 휠 확대' : ''].filter(Boolean).join(' · ');
toolbar.append(hint);
viewport.addEventListener('wheel', event => {
if (!zoomEnabled) return;
if (!event.ctrlKey && !event.metaKey) return;
event.preventDefault();
const box = viewport.getBoundingClientRect();
@@ -80,12 +112,13 @@ export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLEleme
viewport.addEventListener('keydown', event => {
if (event.target !== viewport) return;
const shifts: Record<string, [number, number]> = { ArrowLeft: [40, 0], ArrowRight: [-40, 0], ArrowUp: [0, 40], ArrowDown: [0, -40] };
if (shifts[event.key]) { event.preventDefault(); controller.panBy(...shifts[event.key]!); }
if (shifts[event.key]) { event.preventDefault(); if (panEnabled) controller.panBy(...shifts[event.key]!); }
else if (event.key === '+' || event.key === '=') { event.preventDefault(); controller.zoomTo(scale * config.step); }
else if (event.key === '-') { event.preventDefault(); controller.zoomTo(scale / config.step); }
}, { signal });
// Tab can reach cards outside a zoomed/panned forest. Reveal the focused
// control without changing scale or using the viewport's hidden scroll area.
// Keyboard focus visibility remains available when manual Pan is disabled.
viewport.addEventListener('focusin', event => {
const target = event.target;
if (!(target instanceof view.HTMLElement) || !forest.contains(target)) return;
@@ -99,11 +132,12 @@ export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLEleme
const right = left + viewport.clientWidth - 16, bottom = top + viewport.clientHeight - 16;
const dx = box.left < left ? left - box.left : box.right > right ? right - box.right : 0;
const dy = box.top < top ? top - box.top : box.bottom > bottom ? bottom - box.bottom : 0;
if (dx || dy) controller.panBy(dx, dy);
if (dx || dy) { preservePosition(); x += dx; y += dy; apply(); }
});
}, { signal });
viewport.addEventListener('pointerdown', event => {
if (event.button !== 0 || event.isPrimary === false || (event.target as Element).closest('button,a')) return;
if (!panEnabled) return;
if (event.button !== 0 || event.isPrimary === false || (event.target as Element).closest('button,a,.hm-orgchart-employee')) return;
suppressClick = false;
drag = { id: event.pointerId, x: event.clientX, y: event.clientY, moved: false };
}, { signal });
@@ -123,7 +157,7 @@ export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLEleme
};
viewport.addEventListener('pointerup', finish, { signal });
viewport.addEventListener('pointercancel', finish, { signal });
viewport.addEventListener('lostpointercapture', () => { drag = undefined; delete viewport.dataset.dragging; }, { signal });
viewport.addEventListener('lostpointercapture', event => { if (event.target === viewport) { drag = undefined; delete viewport.dataset.dragging; } }, { signal });
// Release outside the viewport must not leave a pending drag.
view.addEventListener('pointerup', finish, { signal });
viewport.addEventListener('click', event => {
@@ -133,7 +167,7 @@ export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLEleme
if (event.detail === 0) return;
event.preventDefault(); event.stopImmediatePropagation();
}, { capture: true, signal });
const observer = new view.ResizeObserver(() => { if (initialFit) controller.fit(); });
const observer = new view.ResizeObserver(() => { if (placement) position(placement === 'fit'); });
observer.observe(viewport);
signal.addEventListener('abort', () => { observer.disconnect(); drag = undefined; if (focusFrame) view.cancelAnimationFrame(focusFrame); }, { once: true });
apply();
+164 -14
View File
@@ -1,5 +1,5 @@
import { calculateSummary, normalizeOrgChartDataset, validateOrgChartDataset } from '../data/index.js';
import type { OrgChartDataSource, ValidationIssue } from '../data/index.js';
import { applyOrgChartMove, calculateSummary, createOrgChartMoveValidator, normalizeOrgChartDataset, validateOrgChartDataset } from '../data/index.js';
import type { OrgChartDataset, OrgChartMoveRequest, OrgChartMoveResult, OrgChartMoveValidation, OrgChartDataSource, ValidationIssue } from '../data/index.js';
import { createEngineAdapter, loadEngine } from './engine.js';
import type { EngineAdapter, EngineConstructor } from './engine.js';
import { decorateNode, engineTree } from './render.js';
@@ -9,6 +9,8 @@ import { createExpandedOrgChartContent } from './expanded-content.js';
import { getCompanyColorPreset } from '../company-color-presets.js';
import { resolveExpandedCompanies, validateTopNodes } from './expanded-company.js';
import { createExpandedViewport, type ExpandedViewport } from './expanded-viewport.js';
import { createExpandedEditing, type ExpandedEditing } from './expanded-editing.js';
import { applyInitialVisibility, autoEngineOptions, resolveAutoLayout, resolveInteractions } from './auto-layout-policy.js';
import { createPositionSummary } from './position-summary.js';
import { createAffiliationSummary } from './affiliation-summary.js';
import { createStatusSummary } from './status-summary.js';
@@ -54,6 +56,13 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
if (options.layoutMode !== undefined && options.layoutMode !== 'fixed' && options.layoutMode !== 'auto') throw new TypeError('layoutMode must be "fixed" or "auto"');
const layoutMode = options.layoutMode ?? 'fixed';
const expanded = view === 'composition' && layoutMode === 'auto';
if (expanded && options.dragDropEnabled !== undefined && typeof options.dragDropEnabled !== 'boolean') throw new TypeError('dragDropEnabled must be a boolean');
let dragDropEnabled = expanded && (options.dragDropEnabled ?? false);
let expandedEditing: ExpandedEditing | undefined;
let autoLayout = resolveAutoLayout(expanded ? options.autoLayout : undefined);
let interactionOptions = resolveInteractions(expanded ? options.interactions : undefined);
let resetVisibility = false;
let renderedLayoutKey: string | undefined;
const expandedCompanyPreset = expanded ? getCompanyColorPreset(options.companyId ?? 'hanmac') : undefined;
if (expanded && !expandedCompanyPreset) throw new TypeError('companyId must be a registered company preset in Auto mode');
const topNodes = expanded ? freeze(structuredClone(options.companyTopNodes ?? {})) : {};
@@ -67,15 +76,22 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
};
let selectedCompanies = expanded ? copySelection(options.selectedCompanyIds) : undefined;
let availableCompanies: string[] = [];
let latestData: unknown;
let latestData: OrgChartDataset | undefined;
let moveValidator: ((request: OrgChartMoveRequest) => OrgChartMoveValidation) | undefined;
let moveMessage = '';
let moveStatus = 'idle';
let blockPointerClick = false;
let focusEmployee: string | undefined;
const collapsedOrganizations = new Set<string>();
const initializedOrganizations = new Set<string>();
let expandedViewport: ExpandedViewport | undefined;
let fitNext = true;
let focusOrganization: string | undefined;
const readOnly = view === 'composition' && options.displayMode === 'readOnly';
const organizationLinks: Record<string, string> = Object.create(null);
if (options.onEmployeeClick !== undefined && typeof options.onEmployeeClick !== 'function') throw new TypeError('onEmployeeClick must be a function');
const employeeClickCallback = options.onEmployeeClick;
if (options.onMove !== undefined && typeof options.onMove !== 'function') throw new TypeError('onMove must be a function');
let employeeClickCallback = options.onEmployeeClick;
for (const [id, value] of Object.entries(options.organizationLinks ?? {})) {
if (value === undefined || value === '') continue;
if (typeof value !== 'string') throw new TypeError('organizationLinks values must be URL strings');
@@ -131,6 +147,49 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
if (!container) throw new Error('Mount OrgChart before update or reload');
}
const moveRequest = (source: HTMLElement, target: HTMLElement): OrgChartMoveRequest => ({
kind: source.matches('.hm-orgchart-employee') ? 'employee' : 'organization',
sourceId: source.dataset.employeeId ?? source.dataset.organizationId!, targetOrganizationId: target.dataset.organizationId!,
});
function validateMove(request: OrgChartMoveRequest): OrgChartMoveValidation {
if (!dragDropEnabled) return { allowed: false, code: 'EDITING_DISABLED', message: 'Drag & Drop이 OFF입니다. 조직 이동을 사용하려면 ON으로 설정하세요.' };
// An explicitly configured company top card also owns a protected reference.
if (request?.kind === 'employee' && Object.values(topNodes).some(top => top.employeeId === request.sourceId)) {
return { allowed: false, code: 'LEADER_PROTECTED', message: '회사 상단에 지정된 직원은 상단 직원 지정 정책을 먼저 변경해야 이동할 수 있습니다.' };
}
return moveValidator?.(request) ?? { allowed: false, code: 'INVALID_DATA', message: '조직 데이터를 불러온 뒤 이동하세요.' };
}
function notifyMove(result: OrgChartMoveResult): void {
try { options.onMove?.(result); } catch { /* Host callbacks never own the Core transaction. */ }
}
async function move(request: OrgChartMoveRequest): Promise<OrgChartMoveResult> {
assertMounted();
if (!expanded) throw new Error('Organization editing is available only in Auto mode');
const policy = validateMove(request);
let result: OrgChartMoveResult = !latestData || state.status !== 'ready'
? { moved: false, code: 'INVALID_DATA', message: '조직 데이터를 불러온 뒤 이동하세요.' }
: !policy.allowed ? { moved: false, code: policy.code, message: policy.message }
: applyOrgChartMove(latestData, request);
if (!result.moved) {
moveMessage = `이동 실패 · ${result.message}`; moveStatus = 'failure';
const feedback = content?.querySelector<HTMLElement>('.hm-orgchart-move-feedback');
if (feedback) { feedback.textContent = moveMessage; feedback.dataset.moveStatus = moveStatus; }
notifyMove(result); return result;
}
const label = request.kind === 'employee' ? result.data.employees.find(item => item.id === request.sourceId)?.name
: result.data.organizations.find(item => item.id === request.sourceId)?.name;
const target = result.data.organizations.find(item => item.id === request.targetOrganizationId)!;
moveMessage = `이동 완료 · ${label} → ${target.name}`; moveStatus = 'success';
focusEmployee = request.kind === 'employee' ? request.sourceId : undefined;
focusOrganization = request.targetOrganizationId; fitNext = false;
const { version } = begin();
try { await apply(result.data, version); }
catch { fail(version, [{ code: 'CORE_RENDER', path: 'core', message: 'Move rerender failed' }]); }
if (!active(version) || state.status !== 'ready') result = { moved: false, code: 'INVALID_DATA', message: '이동 중 다른 데이터 요청이 시작되었거나 화면을 갱신할 수 없습니다.' };
if (active(version)) notifyMove(result);
return result;
}
function begin(): { version: number; signal: AbortSignal } {
request?.abort();
const Abort = container?.ownerDocument.defaultView?.AbortController ?? AbortController;
@@ -147,12 +206,14 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
employeeDialog?.close();
contentGeneration?.abort();
contentGeneration = undefined;
expandedEditing = undefined;
engine?.dispose();
engine = undefined;
renderContext = undefined;
viewport = undefined;
expandedViewport = undefined;
latestData = undefined;
moveValidator = undefined;
content?.replaceChildren();
}
@@ -190,6 +251,7 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
card.tabIndex = 0;
card.setAttribute('role', 'button');
if (!employeeClickCallback) card.setAttribute('aria-haspopup', 'dialog');
else card.removeAttribute('aria-haspopup');
const name = card.querySelector('.hm-orgchart-person-name')?.textContent ?? '';
const position = card.querySelector('.hm-orgchart-person-role')?.textContent ?? '';
card.setAttribute('aria-label', `${name} ${position} 직원 정보 보기`);
@@ -230,16 +292,17 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
const issues = [...companyState.issues, ...validateTopNodes(data, normalized.roots, companyState.companies, topNodes)];
if (issues.length) { fail(version, issues); return; }
availableCompanies = companyState.companyIds;
const validIds = new Set(data.organizations.map(org => org.id));
for (const id of collapsedOrganizations) if (!validIds.has(id)) collapsedOrganizations.delete(id);
applyInitialVisibility(data.organizations, collapsedOrganizations, initializedOrganizations, autoLayout.initialVisibleDepth, resetVisibility);
resetVisibility = false;
latestData = data;
moveValidator = createOrgChartMoveValidator(data);
}
const summary = freeze(calculateSummary(data, policy));
const topLeaderIds = new Set(view === 'composition' ? normalized.roots.filter(node => node.organization.type === 'root' && node.organization.leaderId).map(node => node.organization.leaderId!) : []);
const nextContext: RenderContext = { data, summary, orderedOrganizations: [], employeesByOrganization: normalized.employeesByOrganization, topLeaderIds, readOnly: readOnly || expanded, organizationLinks, expandedCompanyPreset,
organizationCompanies: companyState?.companies, collapsedOrganizations: expanded ? collapsedOrganizations : undefined,
companyTopNodes: topNodes, employeeConnections, toggleExpanded: id => {
if (destroyed || !latestData) return;
companyTopNodes: topNodes, employeeConnections, autoLayout: expanded ? autoLayout : undefined, interactions: expanded ? interactionOptions : undefined, toggleExpanded: id => {
if (destroyed || !latestData || !interactionOptions.collapse) return;
if (collapsedOrganizations.has(id)) collapsedOrganizations.delete(id); else collapsedOrganizations.add(id);
focusOrganization = id; fitNext = false;
const { version } = begin();
@@ -264,6 +327,7 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
if (LoadedEngine) Engine = LoadedEngine;
const document = content!.ownerDocument;
const previousViewport = expandedViewport?.state();
const previousManualPosition = expandedViewport?.hasManualPosition();
employeeDialog?.close();
contentGeneration?.abort();
const Abort = document.defaultView?.AbortController ?? AbortController;
@@ -275,10 +339,18 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
}
const activeElement = root!.shadowRoot?.activeElement;
if (needsEngine) {
const layoutKey = expanded ? `${autoLayout.type}:${autoLayout.hybridStartDepth}` : 'fixed';
// Upstream init merges old options. Rebuild only the engine when changing
// direction/vertical policy so obsolete verticalLevel cannot survive LR.
if (engine && renderedLayoutKey !== layoutKey) { engine.dispose(); engine = undefined; }
if (engine) engine.update(tree);
else engine = createEngineAdapter(Engine!, viewport!, tree, (node, value, signal) => {
if (renderContext) decorateNode(node, value, renderContext, signal, target => engine?.toggle(target));
});
}, expanded ? autoEngineOptions(autoLayout) : undefined, expanded ? {
canDrop: (source, target) => validateMove(moveRequest(source, target)).allowed,
onDrop: (source, target) => { void move(moveRequest(source, target)); },
} : undefined);
renderedLayoutKey = layoutKey;
}
// If regeneration removed keyboard focus, offer the stable viewport next.
const nextContent = view === 'composition' ? expanded
@@ -292,8 +364,25 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
const toolbar = document.createElement('div');
toolbar.className = 'hm-orgchart-expanded-toolbar';
nextContent.insertBefore(toolbar, viewport!);
expandedViewport = createExpandedViewport(viewport!, toolbar, contentGeneration.signal, zoom, previousViewport, fitNext);
expandedViewport = createExpandedViewport(viewport!, toolbar, contentGeneration.signal,
{ ...zoom, zoomEnabled: interactionOptions.zoom, panEnabled: interactionOptions.pan }, previousViewport, fitNext, previousManualPosition);
fitNext = false;
const feedback = document.createElement('p');
feedback.className = 'hm-orgchart-move-feedback'; feedback.id = `${prefix}-move-feedback`;
feedback.setAttribute('role', 'status'); feedback.setAttribute('aria-live', 'polite'); feedback.setAttribute('aria-atomic', 'true');
feedback.dataset.moveStatus = moveStatus;
feedback.textContent = moveMessage || (dragDropEnabled
? 'Drag & Drop ON · 카드/제목: 이동 · Alt+M: 키보드 이동 · 빈 공간: Pan'
: 'Drag & Drop OFF · 직원 정보·그룹 접기·빈 공간 Pan 사용 가능');
nextContent.insertBefore(feedback, viewport!);
viewport!.dataset.dragDropEnabled = String(dragDropEnabled);
expandedEditing = createExpandedEditing(viewport!, feedback, contentGeneration.signal, engine!, validateMove,
request => { void move(request); }, () => { blockPointerClick = true; }, dragDropEnabled);
for (const source of nextContent.querySelectorAll<HTMLElement>('.hm-orgchart-employee, .hm-orgchart-expanded-toggle')) {
if (dragDropEnabled) source.setAttribute('aria-keyshortcuts', 'Alt+M');
else source.removeAttribute('aria-keyshortcuts');
source.setAttribute('aria-describedby', feedback.id);
}
const selection = document.createElement('p');
selection.className = 'hm-orgchart-expanded-selection';
selection.setAttribute('aria-live', 'polite');
@@ -311,7 +400,9 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
prepareEmployeeCards(nextContent);
content!.replaceChildren(nextContent);
const restoreFocus = !!activeElement && (activeElement === viewport || !activeElement.isConnected);
const focusTarget = focusOrganization ? [...content!.querySelectorAll<HTMLButtonElement>('.hm-orgchart-expanded-toggle')].find(button => button.dataset.organizationId === focusOrganization) : undefined;
const focusTarget = (focusEmployee ? [...content!.querySelectorAll<HTMLElement>('.hm-orgchart-employee')].find(card => card.dataset.employeeId === focusEmployee) : undefined)
?? (focusOrganization ? [...content!.querySelectorAll<HTMLButtonElement>('.hm-orgchart-expanded-toggle')].find(button => button.dataset.organizationId === focusOrganization) : undefined);
focusEmployee = undefined;
focusOrganization = undefined;
if (focusTarget) focusTarget.focus({ preventScroll: true });
else if (restoreFocus) (expanded ? viewport! : readOnly ? shell! : viewport ?? content!.querySelector<HTMLElement>('.hm-orgchart-summary-scroll') ?? shell!).focus({ preventScroll: true });
@@ -323,7 +414,7 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
async function reload(nextSource?: OrgChartDataSource): Promise<void> {
assertMounted();
if (expanded) fitNext = true;
if (expanded) { fitNext = true; moveMessage = ''; moveStatus = 'idle'; }
if (nextSource) {
if (typeof nextSource.load !== 'function') throw new TypeError('An OrgChartDataSource is required');
source = nextSource;
@@ -391,6 +482,14 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
const Abort = document.defaultView?.AbortController ?? AbortController;
interactions = new Abort();
employeeDialog = createEmployeeInfoDialog(shell, interactions.signal, prefix, readOnly);
if (expanded) {
// This capture guard survives the generation replaced by a successful drop.
shell.addEventListener('pointerdown', () => { blockPointerClick = false; }, { capture: true, signal: interactions.signal });
shell.addEventListener('click', event => {
if (!blockPointerClick || event.detail === 0) return;
blockPointerClick = false; event.preventDefault(); event.stopImmediatePropagation();
}, { capture: true, signal: interactions.signal });
}
content.addEventListener('click', activateEmployee, { signal: interactions.signal });
content.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ' ') activateEmployee(event);
@@ -399,6 +498,7 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
},
async update(input) {
assertMounted();
if (expanded) fitNext = true;
// Capture before begin() invokes callbacks that can mutate consumer input.
let captured: unknown;
try { captured = snapshot(input); }
@@ -444,8 +544,58 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
if (latestData) { const { version } = begin(); await apply(latestData, version); }
},
getSelectedCompanies() { return Object.freeze([...(selectedCompanies ?? availableCompanies)]); },
zoomTo(scale) { expandedViewport?.zoomTo(scale); },
panBy(x, y) { expandedViewport?.panBy(x, y); },
async setAutoLayout(patch) {
assertMounted();
if (!expanded) throw new Error('Auto layout configuration is available only in Auto mode');
const next = resolveAutoLayout({ ...autoLayout, ...patch });
resetVisibility = resetVisibility || next.initialVisibleDepth !== autoLayout.initialVisibleDepth;
autoLayout = next; fitNext = true;
if (latestData) { const { version } = begin(); await apply(latestData, version); }
},
getAutoLayout() { return autoLayout; },
setEmployeeClickHandler(handler) {
assertMounted();
if (!expanded) throw new Error('Popup ownership changes are available only in Auto mode');
if (handler !== undefined && typeof handler !== 'function') throw new TypeError('Employee click handler must be a function or undefined');
employeeDialog?.close();
employeeClickCallback = handler;
if (content) prepareEmployeeCards(content);
},
async setInteractions(patch) {
assertMounted();
if (!expanded) throw new Error('Interaction configuration is available only in Auto mode');
interactionOptions = resolveInteractions({ ...interactionOptions, ...patch });
fitNext = false;
if (latestData) { const { version } = begin(); await apply(latestData, version); }
},
getInteractions() { return interactionOptions; },
setDragDropEnabled(enabled) {
assertMounted();
if (!expanded) throw new Error('Drag & Drop configuration is available only in Auto mode');
if (typeof enabled !== 'boolean') throw new TypeError('dragDropEnabled must be a boolean');
if (enabled === dragDropEnabled) return;
dragDropEnabled = enabled;
expandedEditing?.setEnabled(enabled);
if (viewport) viewport.dataset.dragDropEnabled = String(enabled);
const feedback = content?.querySelector<HTMLElement>('.hm-orgchart-move-feedback');
const cancelled = feedback?.dataset.moveStatus === 'cancelled';
moveMessage = ''; moveStatus = cancelled ? 'cancelled' : 'idle';
if (feedback) {
feedback.dataset.moveStatus = moveStatus;
feedback.textContent = (cancelled ? '이동을 취소했습니다. ' : '') + (enabled
? 'Drag & Drop ON · 카드/제목: 이동 · Alt+M: 키보드 이동 · 빈 공간: Pan'
: 'Drag & Drop OFF · 직원 정보·그룹 접기·빈 공간 Pan 사용 가능');
}
for (const source of content?.querySelectorAll<HTMLElement>('.hm-orgchart-employee, .hm-orgchart-expanded-toggle') ?? []) {
if (enabled) source.setAttribute('aria-keyshortcuts', 'Alt+M');
else source.removeAttribute('aria-keyshortcuts');
}
},
getDragDropEnabled() { return dragDropEnabled; },
move,
getData() { return latestData; },
zoomTo(scale) { if (interactionOptions.zoom) expandedViewport?.zoomTo(scale); },
panBy(x, y) { if (interactionOptions.pan) expandedViewport?.panBy(x, y); },
fitToView() { expandedViewport?.fit(); },
getViewportState() { return expandedViewport?.state(); },
};
@@ -8,6 +8,7 @@ import { createOrganizationTotals } from './organization-totals.js';
import { createAffiliationBar } from './affiliation-bar.js';
import { applyWorkdeskLayout } from './workdesk-layout.js';
import { addWorkdeskArtwork } from './workdesk-artwork.js';
import { workdeskCanvas } from './workdesk-layout.js';
type SummaryComponent = 'PositionSummary' | 'AffiliationSummary' | 'StatusSummary';
type ContentSlot = 'side' | 'footer' | 'belowTree';
@@ -115,6 +116,11 @@ export function createOrgChartContent(
if (slots.side.childElementCount) stage.append(slots.side);
else content.classList.add('hm-orgchart-composition-no-side');
if (slots.footer.childElementCount) stage.append(slots.footer);
if (stage.dataset.workdeskArtwork) {
heading.dataset.compositionRegion = slots.side.dataset.compositionRegion = 'top';
main.querySelector<HTMLElement>('.hm-orgchart-axis')!.dataset.compositionRegion = tree.dataset.compositionRegion = 'center';
slots.belowTree.dataset.compositionRegion = slots.footer.dataset.compositionRegion = 'bottom';
}
observeStageScale(content, stage, signal, readOnly);
return content;
}
@@ -129,7 +135,7 @@ function observeStageScale(wrapper: HTMLElement, stage: HTMLElement, signal: Abo
const view = wrapper.ownerDocument.defaultView;
if (!view) return;
let frame: number | undefined;
let applied = { scale: NaN, width: NaN, height: NaN };
let applied = { scale: NaN, width: NaN, height: NaN, canvasHeight: NaN, centerOffset: 0 };
const ancestors: HTMLElement[] = [];
const collectAncestors = () => {
// The constructor runs before insertion; discover the real host only once
@@ -175,23 +181,56 @@ function observeStageScale(wrapper: HTMLElement, stage: HTMLElement, signal: Abo
collectAncestors();
const available = wrapper.clientWidth; // integer floor: never overflows the host
const baseWidth = stage.offsetWidth;
const baseHeight = stage.offsetHeight;
const adaptive = fitHeight && stage.dataset.workdeskArtwork === 'true';
const baseHeight = adaptive ? workdeskCanvas.height : stage.offsetHeight;
if (!available || !baseWidth || !baseHeight) return;
let scale = Math.min(1, available / baseWidth);
const availableHeight = fitHeight ? heightLimit() : baseHeight * scale;
if (fitHeight) {
const availableHeight = heightLimit();
scale = Math.min(scale, availableHeight / baseHeight);
}
if (Math.abs(scale - applied.scale) < 0.0005 && baseWidth === applied.width && baseHeight === applied.height) return;
if (!adaptive && Math.abs(scale - applied.scale) < 0.0005 && baseWidth === applied.width && baseHeight === applied.height) return;
if (adaptive && !scale) return;
const canvasHeight = adaptive ? Math.max(baseHeight, availableHeight / scale) : baseHeight;
const scaleChanged = scale !== applied.scale;
applied = { scale, width: baseWidth, height: baseHeight };
stage.style.transform = scale < 1 ? `scale(${scale})` : '';
wrapper.style.height = `${baseHeight * scale}px`;
if (Math.abs(scale - applied.scale) >= 0.000001 || baseWidth !== applied.width || canvasHeight !== applied.canvasHeight) {
stage.style.transform = scale < 1 ? `scale(${scale})` : '';
if (adaptive) stage.style.setProperty('--hm-orgchart-workdesk-stage-height', `${canvasHeight}px`);
wrapper.style.height = `${canvasHeight * scale}px`;
}
let centerOffset = 0;
if (adaptive && canvasHeight > baseHeight + 0.01) {
const origin = stage.getBoundingClientRect();
const local = (element: Element) => {
const rect = element.getBoundingClientRect();
return { top: (rect.top - origin.top) / scale, bottom: (rect.bottom - origin.top) / scale };
};
const center = [...stage.querySelectorAll('.hm-orgchart-axis .hm-orgchart-employee,.hm-orgchart-hub,.hm-orgchart-viewport .hm-orgchart-employee,.hm-orgchart-viewport .hm-orgchart-organization-name,image[data-arrow]')]
.filter(element => element.getClientRects().length).map(local);
const top = [...stage.querySelectorAll('[data-composition-region="top"]')].map(local);
const bottom = [...stage.querySelectorAll('[data-composition-region="bottom"]')].filter(element => element.childElementCount).map(local);
if (center.length && top.length) {
const start = Math.min(...center.map(rect => rect.top)) - applied.centerOffset;
const end = Math.max(...center.map(rect => rect.bottom)) - applied.centerOffset;
const bottomAnchor = bottom.length ? Math.min(...bottom.map(rect => rect.top)) : canvasHeight - (parseFloat(view.getComputedStyle(stage).paddingBottom) || 0);
const target = (Math.max(...top.map(rect => rect.bottom)) + bottomAnchor - start - end) / 2;
// Keep the baseline intact when the screen is short; share only newly
// available height until the entire central chart fits between anchors.
centerOffset = Math.max(0, Math.min(canvasHeight - baseHeight, target));
}
}
if (adaptive && (Math.abs(centerOffset - applied.centerOffset) > 0.01 || !Number.isFinite(applied.scale))) {
stage.style.setProperty('--hm-orgchart-workdesk-center-offset', `${centerOffset.toFixed(4)}px`);
} else centerOffset = applied.centerOffset;
const positionChanged = adaptive && (Math.abs(canvasHeight - applied.canvasHeight) > 0.01 || Math.abs(centerOffset - applied.centerOffset) > 0.01);
applied = { scale, width: baseWidth, height: baseHeight, canvasHeight, centerOffset };
wrapper.dataset.scale = scale.toFixed(4);
wrapper.dataset.baseWidth = String(baseWidth);
wrapper.dataset.baseHeight = String(baseHeight);
// Open tooltips were positioned from pre-scale geometry; let them close.
if (scaleChanged) wrapper.dispatchEvent(new view.CustomEvent('hm-orgchart-scale', { bubbles: true }));
if (adaptive) { wrapper.dataset.canvasHeight = String(canvasHeight); wrapper.dataset.centerOffset = centerOffset.toFixed(4); }
// Open tooltips use the old card bounds. A bounded parent can move the
// central chart without changing scale or emitting a window resize.
if (scaleChanged || positionChanged) wrapper.dispatchEvent(new view.CustomEvent('hm-orgchart-scale', { bubbles: true }));
};
const schedule = () => { if (frame === undefined && !signal.aborted) frame = view.requestAnimationFrame(apply); };
const observer = new view.ResizeObserver(schedule);
+8 -1
View File
@@ -6,7 +6,8 @@ import { getDepartmentCardAsset } from './department-card-assets.js';
import type { OrgChartOrganizationLinks } from './types.js';
import type { CompanyColorPreset } from '../company-color-presets.js';
import { decorateExpandedNode } from './expanded-content.js';
import type { OrgChartConnectionDirection, OrgChartTopNode } from './types.js';
import type { OrgChartConnectionDirection, OrgChartTopNode, OrgChartInteractionOptions } from './types.js';
import type { ResolvedAutoLayout } from './auto-layout-policy.js';
const organizationNames = { root: '최상위 조직', division: '부문', office: '실', div: 'Div', cell: '셀', team: '팀' } as const;
@@ -25,6 +26,8 @@ export interface RenderContext {
collapsedOrganizations?: ReadonlySet<string>;
companyTopNodes?: Readonly<Record<string, OrgChartTopNode>>;
employeeConnections?: Readonly<Record<string, OrgChartConnectionDirection>>;
autoLayout?: ResolvedAutoLayout;
interactions?: Readonly<Required<OrgChartInteractionOptions>>;
toggleExpanded?: (organizationId: string) => void;
}
@@ -35,6 +38,10 @@ export function engineTree(roots: OrganizationTreeNode[], prefix: string, contex
context.orderedOrganizations.push(tree.organization);
return {
id: `${prefix}-org-${index}`, name: '', organizationIndex: index, depth, groupVariant: variant,
// Make public (root=Depth 1) semantics explicit rather than depending on
// upstream's detached-DOM ancestor inference during mode reconstruction.
...(context.autoLayout ? { level: depth + 1 } : {}),
...(context.autoLayout?.type === 'tb' && context.autoLayout.hybridOrganizationIds.includes(tree.organization.id) ? { hybrid: true } : {}),
...(tree.children.length && !context.collapsedOrganizations?.has(tree.organization.id) ? { children: tree.children.map((child, childIndex) => convert(child, depth + 1, context.readOnly ? variant : depth === startDepth ? childIndex % 6 : variant)) } : {}),
};
}
+49 -2
View File
@@ -1,4 +1,4 @@
import type { Affiliation, ApprovedAggregationPolicy, Employee, OrgChartDataSource, OrgChartSummary, Organization, ValidationIssue } from '../data/index.js';
import type { Affiliation, ApprovedAggregationPolicy, Employee, OrgChartDataSource, OrgChartDataset, OrgChartMoveRequest, OrgChartMoveResult, OrgChartSummary, Organization, ValidationIssue } from '../data/index.js';
export type OrgChartState =
| { readonly status: 'idle' | 'loading' | 'empty' | 'destroyed' }
@@ -14,6 +14,27 @@ export type OrgChartDisplayMode = 'interactive' | 'readOnly';
* with natural overflow; Fixed preserves the Workdesk composition. */
export type OrgChartLayoutMode = 'fixed' | 'auto';
/** Independent Auto presentation policies. Root organizations are Depth 1. */
export type OrgChartAutoLayoutType = 'tb' | 'lr' | 'hybrid';
export interface OrgChartAutoLayoutOptions {
type?: OrgChartAutoLayoutType;
/** Hybrid only. The first displayed depth rendered as parent-local lists. */
hybridStartDepth?: 2 | 3 | 4;
/** Initial expansion policy; changing it intentionally resets collapse state.
* Later user toggles survive layout and data changes for valid organizations. */
initialVisibleDepth?: 1 | 2 | 3 | 'all';
/** TB only. Each listed organization's descendants use a local vertical list.
* Cannot be combined with LR or global Hybrid. */
hybridOrganizationIds?: readonly string[];
}
export interface OrgChartInteractionOptions {
/** Auto only; each optional feature defaults to enabled and is independent. */
collapse?: boolean;
zoom?: boolean;
/** Disables manual pointer/arrow/API Pan; keyboard focus reveal stays available. */
pan?: boolean;
}
/** Links are keyed by the existing organization ID. Only 1Depth titles use them. */
export type OrgChartOrganizationLinks = Readonly<Record<string, string | undefined>>;
@@ -52,8 +73,15 @@ export interface OrgChartOptions {
companyId?: string;
/** Auto only. undefined means all companies; [] is an explicit empty selection. */
selectedCompanyIds?: readonly string[];
/** Auto only; Fixed ignores these independent presentation options. */
autoLayout?: OrgChartAutoLayoutOptions;
interactions?: OrgChartInteractionOptions;
/** Auto-only employee/group editing. Omission disables pointer, keyboard and
* handle moves; Pan and employee information remain independent. Fixed ignores it. */
dragDropEnabled?: boolean;
companyTopNodes?: Readonly<Record<string, OrgChartTopNode>>;
/** Actual staff connector route, default top. Leaders always connect from above. */
/** Actual TB/Hybrid staff connector route, default top; their leaders connect
* from above. LR always routes employees from the left. */
employeeConnections?: Readonly<Record<string, OrgChartConnectionDirection>>;
zoom?: { min?: number; max?: number; step?: number };
/** No entry (or an empty URL) keeps an ordinary title. HTTP(S) and relative URLs are supported. */
@@ -61,6 +89,9 @@ export interface OrgChartOptions {
/** Optional host-owned information UI. When supplied, it replaces the default read-only dialog.
* Called for every employee card in interactive and readOnly modes. */
onEmployeeClick?: (employee: Employee, context: OrgChartEmployeeClickContext) => void;
/** Auto-only browser-state editing. Called after a move or policy rejection;
* never saves to the source or a server. Consumer callback errors are isolated. */
onMove?: (result: OrgChartMoveResult) => void;
onStateChange?: (state: OrgChartState) => void;
}
@@ -75,6 +106,22 @@ export interface OrgChartHandle {
/** Auto view state is separate from the immutable source. Fixed rejects selection changes. */
setSelectedCompanies(ids?: readonly string[]): Promise<void>;
getSelectedCompanies(): readonly string[];
/** Merge an Auto-only presentation patch without remounting or losing valid collapse state. */
setAutoLayout(options: OrgChartAutoLayoutOptions): Promise<void>;
getAutoLayout(): Readonly<OrgChartAutoLayoutOptions>;
/** Auto only. Change popup ownership without rebuilding or changing view state.
* undefined restores the existing Core dialog. */
setEmployeeClickHandler(handler?: OrgChartOptions['onEmployeeClick']): void;
/** Auto only. Merge independent feature flags while retaining view state. */
setInteractions(options: OrgChartInteractionOptions): Promise<void>;
getInteractions(): Readonly<OrgChartInteractionOptions>;
/** Auto only. Cancel active editing without reloading or resetting completed moves. */
setDragDropEnabled(enabled: boolean): void;
getDragDropEnabled(): boolean;
/** Auto only. Uses the same policy as pointer and keyboard moves. */
move(request: OrgChartMoveRequest): Promise<OrgChartMoveResult>;
/** Immutable current Auto dataset, including hidden companies and local moves. */
getData(): OrgChartDataset | undefined;
zoomTo(scale: number): void;
panBy(x: number, y: number): void;
fitToView(): void;
+7
View File
@@ -16,6 +16,10 @@ declare module 'orgchart' {
draggable: boolean;
exportButton: boolean;
chartClass: string;
direction?: 't2b' | 'l2r';
verticalLevel?: number;
visibleLevel?: number;
dropCriteria?: (source: HTMLElement, parent: HTMLElement | null, target: HTMLElement) => boolean;
nodeTemplate: () => string;
createNode: (node: HTMLElement, data: EngineNode) => void;
}
@@ -34,5 +38,8 @@ declare module 'orgchart' {
unbindPan(): void;
unbindZoom(): void;
detachMinimap(): void;
filterAllowedDropNodes(source: HTMLElement): void;
dropHandler(event: { dropZone: HTMLElement }): Promise<void>;
dragendHandler(): void;
}
}
@@ -66,7 +66,16 @@ export function addWorkdeskArtwork(stage: HTMLElement, hub: HTMLElement, signal:
curves.setAttribute('transform', 'translate(162 202)');
overlay.append(curves);
for (const [id, d] of hubPaths) path(id, d, curves);
path('representative-to-hub', 'M935 142V286').setAttribute('transform', 'translate(0 -17)');
const representative = path('representative-to-hub', 'M935 142V286');
representative.setAttribute('transform', 'translate(0 -17)');
// Original Workdesk org-arrow-jang-head: a solid upward chevron, separate
// from the five team PNG arrows and the dashed representative connector.
const representativeHead = document.createElementNS(svgNamespace, 'path');
representativeHead.dataset.arrowHead = 'representative';
representativeHead.setAttribute('d', 'M0.00652573 5.54699C-0.0317927 5.092 0.0997532 4.66961 0.37176 4.32434L3.97332 0.436642C4.57335 -0.142366 5.40409 -0.144766 6.01239 0.424923L9.63348 4.33508C10.7765 5.85831 9.00989 7.82069 7.56414 6.63684L4.99774 3.88781L2.44207 6.62609C1.53083 7.44647 0.11378 6.8181 0.00652573 5.54699Z');
representativeHead.setAttribute('fill', '#45639F');
representativeHead.setAttribute('transform', 'translate(930 121.2969)');
overlay.append(representativeHead);
const business = path('hub-to-business-management', 'M834 204C650 204 250 235 105 256.2');
for (const [id, source, x, y, width, height] of arrows) {
const image = document.createElementNS(svgNamespace, 'image');
@@ -86,10 +95,22 @@ export function addWorkdeskArtwork(stage: HTMLElement, hub: HTMLElement, signal:
if (signal.aborted || !stage.isConnected) return;
const header = stage.querySelector<HTMLElement>('.node[data-organization-id="business-management"] .hm-orgchart-organization-name');
if (!header) return;
const origin = stage.getBoundingClientRect();
// The central chart and overlay move together inside the adaptive stage.
// Measuring against the overlay avoids adding that offset a second time.
const origin = overlay.getBoundingClientRect();
const bounds = header.getBoundingClientRect();
const scale = origin.width / stage.offsetWidth;
const scale = origin.width / workdeskCanvas.width;
if (!scale) return;
const card = stage.querySelector<HTMLElement>('.hm-orgchart-top-leaders .hm-orgchart-employee');
if (card) {
const leader = card.getBoundingClientRect();
const axis = (leader.left + leader.width / 2 - origin.left) / scale;
// Attach the upward tip to the live card's bottom edge in the same
// overlay coordinates, including proportional scale and center offset.
const headTop = (leader.bottom - origin.top) / scale;
representativeHead.setAttribute('transform', `translate(${(axis - 5).toFixed(4)} ${headTop.toFixed(4)})`);
representative.setAttribute('d', `M${axis.toFixed(4)} ${(headTop + 7 + 17).toFixed(4)}V286`);
}
const x = (bounds.left + bounds.width / 2 - origin.left) / scale;
const y = (bounds.top - origin.top) / scale + 2;
business.setAttribute('d', `M834 204C650 204 250 235 ${x.toFixed(2)} ${y.toFixed(2)}`);
@@ -0,0 +1,111 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fixture from '../../../../examples/org-chart/data-expanded-large.json';
import { companyColorPresets, type CompanyId } from '../company-color-presets.js';
import {
calculateSummary, createOrgChartExampleDataset, JsonOrgChartDataSource,
normalizeOrgChartDataset, snapshotOrgChartDataset, validateOrgChartDataset,
type OrgChartDataset, type OrgChartExampleEmployeeCount,
} from './index.js';
const base = fixture as OrgChartDataset;
const companies = Object.keys(companyColorPresets) as CompanyId[];
const sizes: OrgChartExampleEmployeeCount[] = [20, 100, 300];
function assertContract(dataset: OrgChartDataset) {
const result = validateOrgChartDataset(dataset);
assert.equal(result.valid, true, result.valid ? undefined : JSON.stringify(result.issues));
assert.equal(new Set(dataset.employees.map(employee => employee.id)).size, dataset.employees.length);
assert.equal(new Set(dataset.organizations.map(org => org.id)).size, dataset.organizations.length);
assert.equal(new Set(dataset.affiliations.map(affiliation => affiliation.id)).size, dataset.affiliations.length);
const summary = calculateSummary(dataset).raw;
assert.equal(summary.total, dataset.employees.length, 'leaders count once as employees');
const pending = normalizeOrgChartDataset(dataset).roots.map(node => ({ node, depth: 1 }));
let maximumDepth = 0;
while (pending.length) {
const { node, depth } = pending.pop()!;
maximumDepth = Math.max(maximumDepth, depth);
pending.push(...node.children.map(child => ({ node: child, depth: depth + 1 })));
}
assert.ok(maximumDepth >= 3);
assert.ok(new Set(Object.values(summary.byOrganization).map(value => value.direct)).size > 1, 'unequal organization membership');
}
for (const employeeCount of sizes) {
test(`total ${employeeCount} contains exactly that many unique employees across all seven preset companies`, () => {
const dataset = createOrgChartExampleDataset(base, { scope: 'total', employeeCount });
assertContract(dataset);
assert.equal(dataset.employees.length, employeeCount);
assert.deepEqual(dataset.affiliations.map(item => item.id), companies);
for (const companyId of companies) assert.ok(dataset.employees.some(employee => employee.affiliationId === companyId));
});
test(`per-company ${employeeCount} supports every company, pairs, and the full seven-company selection`, () => {
for (const companyId of companies) {
const dataset = createOrgChartExampleDataset(base, { scope: 'per-company', employeeCount, companyIds: [companyId] });
assertContract(dataset);
assert.equal(dataset.employees.length, employeeCount);
assert.ok(dataset.organizations.every(org => org.companyId === companyId));
assert.ok(dataset.employees.every(employee => employee.affiliationId === companyId));
}
for (const companyIds of [['hanmac', 'saman'], companies] as CompanyId[][]) {
const dataset = createOrgChartExampleDataset(base, { scope: 'per-company', employeeCount, companyIds });
assertContract(dataset);
assert.equal(dataset.employees.length, employeeCount * companyIds.length);
for (const id of companyIds) assert.equal(dataset.employees.filter(employee => employee.affiliationId === id).length, employeeCount);
}
});
}
test('total company filtering preserves company allocation without redistribution or lost ancestors', () => {
for (const employeeCount of sizes) {
const complete = createOrgChartExampleDataset(base, { scope: 'total', employeeCount });
const selected = createOrgChartExampleDataset(base, { scope: 'total', employeeCount, companyIds: ['saman', 'hanmac'] });
assertContract(selected);
assert.deepEqual(selected.employees, complete.employees.filter(employee => ['hanmac', 'saman'].includes(employee.affiliationId)));
assert.deepEqual(selected.organizations.map(org => org.id), complete.organizations.filter(org => ['hanmac', 'saman'].includes(org.companyId!)).map(org => org.id));
}
});
test('total 300 reuses every P5-3 large employee record and the original organization IDs', () => {
const dataset = createOrgChartExampleDataset(base, { scope: 'total', employeeCount: 300 });
const originalById = new Map(base.employees.map(employee => [employee.id, employee]));
assert.equal(dataset.employees.length, base.employees.length);
for (const employee of dataset.employees) assert.deepEqual(employee, originalById.get(employee.id));
assert.deepEqual(dataset.organizations, base.organizations);
});
test('selections are deterministic sets and snapshots never mutate the source or another result', async () => {
const before = structuredClone(base);
const frozen = snapshotOrgChartDataset(base);
const first = createOrgChartExampleDataset(frozen, { scope: 'per-company', employeeCount: 100, companyIds: ['saman', 'hanmac', 'saman'] });
const equivalent = createOrgChartExampleDataset(frozen, { scope: 'per-company', employeeCount: 100, companyIds: ['hanmac', 'saman'] });
assert.deepEqual(first, equivalent);
assert.notEqual(first, equivalent);
assert.notEqual(first.employees[0], equivalent.employees[0]);
assert.ok(Object.isFrozen(first) && Object.isFrozen(first.organizations) && Object.isFrozen(first.employees[0]));
assert.throws(() => { first.employees[0]!.name = 'changed'; }, TypeError);
assert.deepEqual(base, before);
assert.deepEqual(await new JsonOrgChartDataSource(first).load(), first);
const single = createOrgChartExampleDataset(base, { scope: 'per-company', employeeCount: 100, companyIds: ['saman'] });
assert.deepEqual(single.employees, first.employees.filter(employee => employee.affiliationId === 'saman'));
});
test('expansion resolves source IDs that already use the generated employee namespace', () => {
const template = structuredClone(base);
const employee = template.employees.find(employee => employee.affiliationId === 'hanmac' && !template.organizations.some(org => org.leaderId === employee.id))!;
employee.id = 'p7-virtual-hanmac-050';
const dataset = createOrgChartExampleDataset(template, { scope: 'per-company', employeeCount: 100 });
assertContract(dataset);
assert.ok(dataset.employees.some(employee => employee.id === 'p7-virtual-hanmac-050-example'));
});
test('invalid scale, company IDs, empty view selection, and incomplete templates fail explicitly', () => {
assert.throws(() => createOrgChartExampleDataset(base, { scope: 'invalid' as 'total', employeeCount: 20 }), /scope/);
assert.throws(() => createOrgChartExampleDataset(base, { scope: 'total', employeeCount: 99 as 20 }), /employeeCount/);
assert.throws(() => createOrgChartExampleDataset(base, { scope: 'total', employeeCount: 20, companyIds: ['unknown' as CompanyId] }), /presets/);
assert.throws(() => createOrgChartExampleDataset(base, { scope: 'total', employeeCount: 20, companyIds: [] }), /empty view selection/);
const incomplete = structuredClone(base);
incomplete.employees = incomplete.employees.filter(employee => employee.affiliationId !== 'baron');
incomplete.organizations.filter(org => org.companyId === 'baron').forEach(org => { org.leaderId = null; });
assert.throws(() => createOrgChartExampleDataset(incomplete, { scope: 'total', employeeCount: 20 }), /all seven/);
});
+124
View File
@@ -0,0 +1,124 @@
import { companyColorPresets, type CompanyId } from '../company-color-presets.js';
import { normalizeOrgChartDataset } from './normalize.js';
import { snapshotOrgChartDataset } from './snapshot.js';
import { validateOrgChartDataset } from './validate.js';
import type { Employee, OrgChartDataset, Organization } from './types.js';
export type OrgChartExampleScope = 'total' | 'per-company';
export type OrgChartExampleEmployeeCount = 20 | 100 | 300;
export interface OrgChartExampleSelection {
/** Total is allocated across all seven companies before company filtering. */
scope: OrgChartExampleScope;
employeeCount: OrgChartExampleEmployeeCount;
/** Omission selects all companies. An empty selection belongs to view state. */
companyIds?: readonly CompanyId[];
}
const companyIds = Object.keys(companyColorPresets) as CompanyId[];
/** Deterministic virtual examples derived from an existing seven-company fixture.
* This provider is independent of layout, React and popup policy. Use its result
* with the existing JsonOrgChartDataSource; no production/API source is replaced.
*
* The P5-3 large fixture's hierarchy and employee records are reused. Larger
* company examples add virtual, company-namespaced employees. Neither the fixture
* nor a previously returned dataset is modified. Total-company selection never
* redistributes unselected companies' staff into a remaining company.
*/
export function createOrgChartExampleDataset(template: OrgChartDataset, selection: OrgChartExampleSelection): OrgChartDataset {
if (!selection || !['total', 'per-company'].includes(selection.scope)) throw new RangeError('Example scope must be total or per-company.');
if (![20, 100, 300].includes(selection.employeeCount)) throw new RangeError('Example employeeCount must be 20, 100 or 300.');
const requested = selection.companyIds ?? companyIds;
if (!Array.isArray(requested) || !requested.length) throw new RangeError('Example data requires at least one company; use an empty view selection to hide all companies.');
if (requested.some(id => !Object.hasOwn(companyColorPresets, id))) throw new RangeError('Example companyIds require registered company presets.');
// Preset order makes equivalent set selections identical, including IDs/order.
const selected = companyIds.filter(id => requested.includes(id));
const validation = validateOrgChartDataset(template);
if (!validation.valid) throw new TypeError('Example template must be a valid OrgChart dataset.');
const base = validation.data;
const organizationCompanies = resolveTemplateCompanies(base);
const rosters = new Map<CompanyId, Employee[]>(companyIds.map(id => [id, []]));
for (const employee of base.employees) {
const company = organizationCompanies.get(employee.organizationId)!;
if (employee.affiliationId !== company) throw new TypeError('Example employees must belong to their organization company affiliation.');
rosters.get(company)!.push(employee);
}
if (companyIds.some(id => !rosters.get(id)!.length)) throw new TypeError('Example template must contain employees from all seven companies.');
const counts = selection.scope === 'total'
? allocateTotal(selection.employeeCount, companyIds.map(id => rosters.get(id)!.length))
: companyIds.map(() => selection.employeeCount);
const chosen = new Set<CompanyId>(selected);
const organizations: Organization[] = base.organizations.filter(org => chosen.has(organizationCompanies.get(org.id)!))
.map(org => ({ ...org, companyId: organizationCompanies.get(org.id)! }));
const employees: Employee[] = [];
const existingIds = new Set(base.employees.map(employee => employee.id));
const organizationPool = new Map<CompanyId, Organization[]>(selected.map(id => [id, organizations.filter(org => org.companyId === id && org.parentId !== null)]));
for (const id of selected) {
const roster = rosters.get(id)!;
const count = counts[companyIds.indexOf(id)]!;
employees.push(...roster.slice(0, count).map(employee => ({ ...employee })));
// Unequal weighted placement reuses the original tree instead of cloning it.
const pool = organizationPool.get(id)!;
const weighted = (pool.length ? pool : organizations.filter(org => org.companyId === id))
.flatMap((org, index) => Array.from({ length: 1 + index % 4 }, () => org));
for (let index = roster.length; index < count; index++) {
let employeeId = `p7-virtual-${id}-${String(index + 1).padStart(3, '0')}`;
// A caller fixture may already use our namespace. Resolve collisions without
// changing the input or tying identity to the other selected companies.
while (existingIds.has(employeeId)) employeeId += '-example';
existingIds.add(employeeId);
const organization = weighted[(index - roster.length) % weighted.length]!;
employees.push({
id: employeeId, organizationId: organization.id, affiliationId: id,
name: `가상직원 ${companyColorPresets[id].companyName} ${String(index + 1).padStart(3, '0')}`,
position: ['사원', '선임', '책임', '수석'][index % 4]!, jobTitle: null,
profileImage: null, workStatus: 'active',
});
}
}
const employeeIds = new Set(employees.map(employee => employee.id));
for (const organization of organizations) if (organization.leaderId && !employeeIds.has(organization.leaderId)) organization.leaderId = null;
const organizationIds = new Set(organizations.map(org => org.id));
return snapshotOrgChartDataset({
...base, organizations, employees,
affiliations: base.affiliations.filter(affiliation => chosen.has(affiliation.id as CompanyId)),
...(base.relations ? { relations: base.relations.filter(relation => organizationIds.has(relation.sourceOrganizationId) && organizationIds.has(relation.targetOrganizationId)) } : {}),
...(base.presentation ? { presentation: { ...base.presentation, organizationOrder: base.presentation.organizationOrder.filter(id => organizationIds.has(id)) } } : {}),
metadata: { ...base.metadata, dataVersion: `p7-example-${selection.scope}-${selection.employeeCount}-${selected.join('_')}`, updateNote: '가상 예시 데이터 · 운영 정보 아님' },
});
}
function resolveTemplateCompanies(data: OrgChartDataset): Map<string, CompanyId> {
const employees = new Map(data.employees.map(employee => [employee.id, employee]));
const result = new Map<string, CompanyId>();
const pending = normalizeOrgChartDataset(data).roots.map(node => ({ node, inherited: undefined as CompanyId | undefined }));
while (pending.length) {
const { node, inherited } = pending.pop()!;
const company = node.organization.companyId ?? inherited ?? (node.organization.leaderId ? employees.get(node.organization.leaderId)?.affiliationId : undefined);
if (!company || !Object.hasOwn(companyColorPresets, company) || (inherited && company !== inherited)) throw new TypeError('Example template requires a consistent registered company on each organization tree.');
result.set(node.organization.id, company as CompanyId);
pending.push(...node.children.map(child => ({ node: child, inherited: company as CompanyId })));
}
return result;
}
/** Largest-remainder allocation, with at least one employee in every company. */
function allocateTotal(total: number, weights: number[]): number[] {
const sum = weights.reduce((value, weight) => value + weight, 0);
const targets = weights.map(weight => total * weight / sum);
const counts = targets.map(target => Math.max(1, Math.floor(target)));
let current = counts.reduce((value, count) => value + count, 0);
while (current !== total) {
const adding = current < total;
let candidate = -1;
let greatest = -Infinity;
counts.forEach((count, index) => {
if (!adding && count <= 1) return;
const difference = adding ? targets[index]! - count : count - targets[index]!;
if (difference > greatest) { greatest = difference; candidate = index; }
});
counts[candidate]! += adding ? 1 : -1;
current += adding ? 1 : -1;
}
return counts;
}
+4
View File
@@ -4,3 +4,7 @@ export { normalizeOrgChartDataset } from './normalize.js';
export { calculateSummary } from './summary.js';
export { snapshotOrgChartDataset } from './snapshot.js';
export { JsonOrgChartDataSource, OrgChartSourceError, type OrgChartSourceErrorCode, type JsonOrgChartDataSourceOptions } from './source.js';
export { createOrgChartExampleDataset } from './examples.js';
export type { OrgChartExampleScope, OrgChartExampleEmployeeCount, OrgChartExampleSelection } from './examples.js';
export { createOrgChartMoveValidator, validateOrgChartMove, applyOrgChartMove } from './moves.js';
export type { OrgChartMoveRequest, OrgChartMoveFailureCode, OrgChartMoveFailure, OrgChartMoveValidation, OrgChartMoveResult } from './moves.js';
+214
View File
@@ -0,0 +1,214 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyOrgChartMove, createOrgChartMoveValidator, validateOrgChartMove, type OrgChartMoveFailureCode, type OrgChartMoveRequest } from './moves.js';
import { calculateSummary } from './summary.js';
import { normalizeOrgChartDataset } from './normalize.js';
import { snapshotOrgChartDataset } from './snapshot.js';
import { validateOrgChartDataset } from './validate.js';
import type { Employee, OrgChartDataset } from './types.js';
function member(id: string, organizationId: string, affiliationId = 'company-a'): Employee {
return { id, organizationId, affiliationId, name: `가상 ${id}`, position: '선임', jobTitle: null, profileImage: './profiles/virtual.png', workStatus: 'active' };
}
function fixture(): OrgChartDataset {
return {
schemaVersion: '0.1-draft',
organizations: [
{ id: 'a-root', companyId: 'company-a', parentId: null, name: '가상 A', type: 'root', leaderId: 'root-leader', displayOrder: 0 },
{ id: 'a-division', parentId: 'a-root', name: '가상 본부', type: 'division', leaderId: null, displayOrder: 4 },
{ id: 'a-team', parentId: 'a-division', name: '가상 팀', type: 'team', leaderId: 'team-leader', displayOrder: 8 },
{ id: 'a-cell', parentId: 'a-team', name: '가상 셀', type: 'cell', leaderId: null, displayOrder: 7 },
{ id: 'a-other', parentId: 'a-root', name: '가상 다른 본부', type: 'division', leaderId: null, displayOrder: 2 },
{ id: 'b-root', companyId: 'company-b', parentId: null, name: '가상 B', type: 'root', leaderId: null, displayOrder: 1 },
{ id: 'b-team', parentId: 'b-root', name: '가상 B 팀', type: 'team', leaderId: null, displayOrder: 1 },
],
employees: [member('root-leader', 'a-root'), member('team-leader', 'a-team'), member('staff', 'a-team', 'company-b'), member('cell-staff', 'a-cell'), member('b-staff', 'b-team', 'company-b')],
affiliations: [{ id: 'company-a', name: '가상 A 회사' }, { id: 'company-b', name: '가상 B 회사' }],
relations: [{ id: 'support', sourceOrganizationId: 'a-team', targetOrganizationId: 'a-other', type: 'support' }],
presentation: {
title: '가상 조직도', description: null, hubText: null,
groups: { showEmployees: true, showSummary: true, showDispatched: true, showOnLeave: true, showAffiliations: true },
organizationOrder: ['a-other', 'a-team'],
},
metadata: { asOfDate: '2026-10-13', dataVersion: 'move-test' },
};
}
function request(kind: OrgChartMoveRequest['kind'], sourceId: string, targetOrganizationId: string): OrgChartMoveRequest {
return { kind, sourceId, targetOrganizationId };
}
function expectRejected(data: OrgChartDataset, move: OrgChartMoveRequest, code: OrgChartMoveFailureCode) {
const before = JSON.stringify(data);
const policy = validateOrgChartMove(data, move);
assert.equal(policy.allowed, false);
if (!policy.allowed) { assert.equal(policy.code, code); assert.ok(policy.message.length > 0); }
const result = applyOrgChartMove(data, move);
assert.equal(result.moved, false);
if (!result.moved) assert.equal(result.code, code);
assert.equal(JSON.stringify(data), before, 'rejected movement retains every input record');
}
test('employee move changes organizationId alone, retains affiliation and IDs, updates counts, and owns frozen state', () => {
const original = snapshotOrgChartDataset(fixture());
const before = JSON.stringify(original);
const move = request('employee', 'staff', 'a-other');
const result = applyOrgChartMove(original, move);
assert.equal(result.moved, true);
if (!result.moved) return;
assert.equal(result.previousOrganizationId, 'a-team');
assert.deepEqual(result.request, move);
assert.deepEqual(result.data.employees.find(employee => employee.id === 'staff'), { ...original.employees.find(employee => employee.id === 'staff')!, organizationId: 'a-other' });
assert.deepEqual(result.data, { ...original, employees: original.employees.map(employee => employee.id === 'staff' ? { ...employee, organizationId: 'a-other' } : employee) });
assert.equal(JSON.stringify(original), before);
assert.ok(Object.isFrozen(result.data) && Object.isFrozen(result.data.employees) && Object.isFrozen(result.data.employees[0]) && Object.isFrozen(result.request));
assert.notEqual(result.data.employees[0], original.employees[0]);
const normalized = normalizeOrgChartDataset(result.data);
assert.equal(normalized.employeesByOrganization.get('a-team')!.some(employee => employee.id === 'staff'), false);
assert.equal(normalized.employeesByOrganization.get('a-other')!.filter(employee => employee.id === 'staff').length, 1);
const beforeCounts = calculateSummary(original).raw;
const afterCounts = calculateSummary(result.data).raw;
assert.equal(afterCounts.total, beforeCounts.total);
assert.equal(afterCounts.byOrganization['a-team']!.direct, beforeCounts.byOrganization['a-team']!.direct - 1);
assert.equal(afterCounts.byOrganization['a-other']!.inclusive, beforeCounts.byOrganization['a-other']!.inclusive + 1);
assert.equal(new Set(result.data.employees.map(employee => employee.id)).size, original.employees.length);
assert.equal(validateOrgChartDataset(result.data).valid, true);
});
test('group move changes parentId alone, preserving subtree, leaders, sorting, company, relations and employee total', () => {
const original = snapshotOrgChartDataset(fixture());
const before = JSON.stringify(original);
const result = applyOrgChartMove(original, request('organization', 'a-team', 'a-other'));
assert.equal(result.moved, true);
if (!result.moved) return;
assert.equal(result.previousOrganizationId, 'a-division');
assert.deepEqual(result.data, { ...original, organizations: original.organizations.map(org => org.id === 'a-team' ? { ...org, parentId: 'a-other' } : org) });
assert.equal(JSON.stringify(original), before);
const normalized = normalizeOrgChartDataset(result.data);
assert.equal(normalized.organizationsById.get('a-cell')!.parentId, 'a-team');
assert.equal(normalized.organizationsById.get('a-team')!.leaderId, 'team-leader');
assert.equal(normalized.organizationsById.get('a-team')!.displayOrder, 8);
assert.equal(new Set(result.data.organizations.map(org => org.id)).size, original.organizations.length);
const beforeCounts = calculateSummary(original).raw;
const afterCounts = calculateSummary(result.data).raw;
assert.equal(afterCounts.total, beforeCounts.total);
assert.equal(afterCounts.byOrganization['a-team']!.inclusive, beforeCounts.byOrganization['a-team']!.inclusive);
assert.equal(afterCounts.byOrganization['a-division']!.inclusive, 0);
assert.equal(afterCounts.byOrganization['a-other']!.inclusive, beforeCounts.byOrganization['a-team']!.inclusive);
assert.equal(validateOrgChartDataset(result.data).valid, true);
});
test('employee policy rejects leaders, absent IDs and existing membership', () => {
const data = fixture();
expectRejected(data, request('employee', 'team-leader', 'a-other'), 'LEADER_PROTECTED');
expectRejected(data, request('employee', 'missing', 'a-other'), 'SOURCE_NOT_FOUND');
expectRejected(data, request('employee', 'staff', 'missing'), 'TARGET_NOT_FOUND');
expectRejected(data, request('employee', 'staff', 'a-team'), 'SAME_ORGANIZATION');
});
test('group policy rejects roots, self, descendants, existing parent and absent IDs', () => {
const data = fixture();
expectRejected(data, request('organization', 'a-root', 'a-other'), 'ROOT_PROTECTED');
expectRejected(data, request('organization', 'a-team', 'a-team'), 'SELF_TARGET');
expectRejected(data, request('organization', 'a-team', 'a-cell'), 'DESCENDANT_TARGET');
expectRejected(data, request('organization', 'a-team', 'a-division'), 'SAME_ORGANIZATION');
expectRejected(data, request('organization', 'missing', 'a-other'), 'SOURCE_NOT_FOUND');
expectRejected(data, request('organization', 'a-team', 'missing'), 'TARGET_NOT_FOUND');
});
test('cross-company employee move updates existing organization and affiliation references, company totals and immutable state', () => {
const data = fixture(); data.employees[2]!.affiliationId = 'company-a';
const original = snapshotOrgChartDataset(data), before = JSON.stringify(original);
const move = request('employee', 'staff', 'b-team');
assert.deepEqual(validateOrgChartMove(original, move), { allowed: true, previousOrganizationId: 'a-team', previousCompanyId: 'company-a', companyId: 'company-b' });
const result = applyOrgChartMove(original, move);
assert.equal(result.moved, true);
if (!result.moved) return;
assert.deepEqual(result.data, { ...original, employees: original.employees.map(employee => employee.id === 'staff'
? { ...employee, organizationId: 'b-team', affiliationId: 'company-b' } : employee) });
const beforeCounts = calculateSummary(original).raw, afterCounts = calculateSummary(result.data).raw;
assert.equal(afterCounts.total, beforeCounts.total);
assert.equal(afterCounts.byOrganization['a-root']!.inclusive, beforeCounts.byOrganization['a-root']!.inclusive - 1);
assert.equal(afterCounts.byOrganization['b-root']!.inclusive, beforeCounts.byOrganization['b-root']!.inclusive + 1);
assert.equal(afterCounts.byAffiliation['company-a'], beforeCounts.byAffiliation['company-a']! - 1);
assert.equal(afterCounts.byAffiliation['company-b'], beforeCounts.byAffiliation['company-b']! + 1);
assert.equal(new Set(result.data.employees.map(employee => employee.id)).size, original.employees.length);
assert.ok(Object.isFrozen(result.data.employees[2]));
assert.equal(JSON.stringify(original), before);
assert.equal(validateOrgChartDataset(result.data).valid, true);
});
test('cross-company group move transfers all descendant companies and employee affiliations while retaining subtree and leaders', () => {
const original = snapshotOrgChartDataset(fixture()), before = JSON.stringify(original);
const result = applyOrgChartMove(original, request('organization', 'a-team', 'b-team'));
assert.equal(result.moved, true);
if (!result.moved) return;
const subtree = new Set(['a-team', 'a-cell']);
assert.deepEqual(result.data, { ...original,
organizations: original.organizations.map(org => org.id === 'a-team' ? { ...org, parentId: 'b-team', companyId: 'company-b' }
: subtree.has(org.id) ? { ...org, companyId: 'company-b' } : org),
employees: original.employees.map(employee => subtree.has(employee.organizationId) ? { ...employee, affiliationId: 'company-b' } : employee),
});
assert.equal(result.data.organizations.find(org => org.id === 'a-cell')!.parentId, 'a-team');
assert.equal(result.data.organizations.find(org => org.id === 'a-team')!.leaderId, 'team-leader');
assert.equal(result.data.employees.find(employee => employee.id === 'team-leader')!.organizationId, 'a-team');
const beforeCounts = calculateSummary(original).raw, afterCounts = calculateSummary(result.data).raw;
const transferred = beforeCounts.byOrganization['a-team']!.inclusive;
assert.equal(afterCounts.total, beforeCounts.total);
assert.equal(afterCounts.byOrganization['a-root']!.inclusive, beforeCounts.byOrganization['a-root']!.inclusive - transferred);
assert.equal(afterCounts.byOrganization['b-root']!.inclusive, beforeCounts.byOrganization['b-root']!.inclusive + transferred);
assert.equal(afterCounts.byAffiliation['company-b'], 4);
assert.equal(new Set(result.data.organizations.map(org => org.id)).size, original.organizations.length);
assert.equal(new Set(result.data.employees.map(employee => employee.id)).size, original.employees.length);
assert.equal(JSON.stringify(original), before);
assert.equal(validateOrgChartDataset(result.data).valid, true);
assert.equal(validateOrgChartMove(result.data, request('organization', 'a-cell', 'a-other')).allowed, true, 'transferred subtree can subsequently move back');
});
test('cross-company moves reject unknown target affiliation rather than inventing a new company record', () => {
const data = fixture(); data.organizations[5]!.companyId = 'unregistered-company';
assert.equal(validateOrgChartDataset(data).valid, true);
expectRejected(data, request('employee', 'staff', 'b-team'), 'TARGET_AFFILIATION_MISSING');
expectRejected(data, request('organization', 'a-team', 'b-team'), 'TARGET_AFFILIATION_MISSING');
});
test('policy rejects malformed request, duplicate IDs, invalid source relationships and company hierarchy contradictions', () => {
expectRejected(fixture(), { kind: 'unknown', sourceId: 'staff', targetOrganizationId: 'a-other' } as unknown as OrgChartMoveRequest, 'INVALID_REQUEST');
const duplicate = fixture(); duplicate.employees.push({ ...duplicate.employees[2]! });
expectRejected(duplicate, request('employee', 'staff', 'a-other'), 'INVALID_DATA');
const cycle = fixture(); cycle.organizations[1]!.parentId = 'a-cell';
expectRejected(cycle, request('employee', 'staff', 'a-other'), 'INVALID_DATA');
const invalidEmployee = fixture(); invalidEmployee.employees[2]!.organizationId = 'missing';
expectRejected(invalidEmployee, request('employee', 'staff', 'a-other'), 'INVALID_DATA');
const conflict = fixture(); conflict.organizations[2]!.companyId = 'company-b';
expectRejected(conflict, request('organization', 'a-team', 'a-other'), 'INVALID_DATA');
});
test('legacy roots derive company through their leader but unresolved company identity prevents moves', () => {
const legacy = fixture(); delete legacy.organizations[0]!.companyId;
assert.equal(validateOrgChartMove(legacy, request('employee', 'staff', 'a-other')).allowed, true);
legacy.organizations[0]!.leaderId = null;
expectRejected(legacy, request('employee', 'staff', 'a-other'), 'COMPANY_UNKNOWN');
});
test('drop-target validator snapshots once and retains its policy if a caller later mutates the dataset', () => {
const data = fixture();
const policy = createOrgChartMoveValidator(data);
data.employees[2]!.organizationId = 'b-team';
assert.deepEqual(policy(request('employee', 'staff', 'a-other')), { allowed: true, previousOrganizationId: 'a-team', previousCompanyId: 'company-a', companyId: 'company-a' });
assert.deepEqual(validateOrgChartMove(data, request('employee', 'staff', 'a-other')), { allowed: true, previousOrganizationId: 'b-team', previousCompanyId: 'company-b', companyId: 'company-a' });
});
test('opaque IDs remain safe move keys without duplicate or synthetic records', () => {
const data = fixture();
data.organizations[4]!.id = '__proto__';
data.relations![0]!.targetOrganizationId = '__proto__';
data.presentation!.organizationOrder[0] = '__proto__';
const result = applyOrgChartMove(data, request('employee', 'staff', '__proto__'));
assert.equal(result.moved, true);
if (!result.moved) return;
assert.equal(result.data.employees.find(employee => employee.id === 'staff')!.organizationId, '__proto__');
assert.equal(result.data.employees.length, data.employees.length);
assert.equal(validateOrgChartDataset(result.data).valid, true);
});
+150
View File
@@ -0,0 +1,150 @@
import { normalizeOrgChartDataset } from './normalize.js';
import { validateOrgChartDataset } from './validate.js';
import type { OrgChartDataset } from './types.js';
/** Source IDs retain their original collection identity after a browser-state move. */
export interface OrgChartMoveRequest {
kind: 'employee' | 'organization';
sourceId: string;
targetOrganizationId: string;
}
export type OrgChartMoveFailureCode =
| 'EDITING_DISABLED'
| 'INVALID_DATA' | 'INVALID_REQUEST' | 'SOURCE_NOT_FOUND' | 'TARGET_NOT_FOUND'
| 'COMPANY_UNKNOWN' | 'TARGET_AFFILIATION_MISSING' | 'LEADER_PROTECTED' | 'ROOT_PROTECTED'
| 'SELF_TARGET' | 'DESCENDANT_TARGET' | 'SAME_ORGANIZATION';
export interface OrgChartMoveFailure {
code: OrgChartMoveFailureCode;
message: string;
}
export type OrgChartMoveValidation =
| { allowed: true; previousOrganizationId: string; previousCompanyId: string; companyId: string }
| ({ allowed: false } & OrgChartMoveFailure);
export type OrgChartMoveResult =
| { moved: true; data: OrgChartDataset; request: OrgChartMoveRequest; previousOrganizationId: string }
| ({ moved: false } & OrgChartMoveFailure);
const messages: Record<OrgChartMoveFailureCode, string> = {
EDITING_DISABLED: 'Drag & Drop이 OFF입니다. 조직 이동을 사용하려면 ON으로 설정하세요.',
INVALID_DATA: '조직 데이터의 관계가 유효하지 않아 이동할 수 없습니다.',
INVALID_REQUEST: '이동 요청이 올바르지 않습니다.',
SOURCE_NOT_FOUND: '이동할 직원 또는 조직이 존재하지 않습니다.',
TARGET_NOT_FOUND: '이동할 대상 조직이 존재하지 않습니다.',
COMPANY_UNKNOWN: '회사 소속을 확인할 수 없어 이동할 수 없습니다.',
TARGET_AFFILIATION_MISSING: '대상 회사의 소속 정보가 없어 회사 간 이동을 완료할 수 없습니다.',
LEADER_PROTECTED: '조직장은 조직장 지정 정책을 먼저 변경해야 이동할 수 있습니다.',
ROOT_PROTECTED: '회사 최상위 조직은 이동할 수 없습니다.',
SELF_TARGET: '조직을 자기 자신에게 이동할 수 없습니다.',
DESCENDANT_TARGET: '하위 조직으로 이동하면 순환 관계가 생겨 이동할 수 없습니다.',
SAME_ORGANIZATION: '이미 해당 조직에 속해 있습니다.',
};
function deny(code: OrgChartMoveFailureCode): OrgChartMoveValidation {
return { allowed: false, code, message: messages[code] };
}
/** Validate once for all highlighted drop targets. The closure owns an immutable
* dataset snapshot, so later consumer mutations cannot change a drag's policy.
* Company is the organization tree's identity, not an employee's affiliation.
* Legacy roots may establish it through their leader's affiliation; no arbitrary
* fallback company is invented when ownership cannot be established.
*/
export function createOrgChartMoveValidator(data: OrgChartDataset): (request: OrgChartMoveRequest) => OrgChartMoveValidation {
const validation = validateOrgChartDataset(data);
if (!validation.valid) return () => deny('INVALID_DATA');
const owned = validation.data;
const normalized = normalizeOrgChartDataset(owned);
const employees = new Map(owned.employees.map(employee => [employee.id, employee]));
const affiliations = new Set(owned.affiliations.map(affiliation => affiliation.id));
const leaders = new Set(owned.organizations.flatMap(org => org.leaderId === null ? [] : [org.leaderId]));
const companies = new Map<string, string | undefined>();
const pending = normalized.roots.map(node => ({ node, inherited: undefined as string | undefined }));
while (pending.length) {
const { node, inherited } = pending.pop()!;
const org = node.organization;
const company = org.companyId ?? inherited ?? (org.parentId === null && org.leaderId ? employees.get(org.leaderId)?.affiliationId : undefined);
if (inherited && company !== inherited) return () => deny('INVALID_DATA');
companies.set(org.id, company);
pending.push(...node.children.map(child => ({ node: child, inherited: company })));
}
return request => {
if (!request || !['employee', 'organization'].includes(request.kind)
|| typeof request.sourceId !== 'string' || !request.sourceId
|| typeof request.targetOrganizationId !== 'string' || !request.targetOrganizationId) return deny('INVALID_REQUEST');
const source = request.kind === 'employee' ? employees.get(request.sourceId) : normalized.organizationsById.get(request.sourceId);
if (!source) return deny('SOURCE_NOT_FOUND');
const target = normalized.organizationsById.get(request.targetOrganizationId);
if (!target) return deny('TARGET_NOT_FOUND');
const sourceOrganization = request.kind === 'employee'
? normalized.organizationsById.get(employees.get(request.sourceId)!.organizationId)!
: normalized.organizationsById.get(request.sourceId)!;
const previousOrganizationId = request.kind === 'employee' ? sourceOrganization.id : sourceOrganization.parentId;
if (request.kind === 'employee') {
if (sourceOrganization.id === target.id) return deny('SAME_ORGANIZATION');
if (leaders.has(request.sourceId)) return deny('LEADER_PROTECTED');
} else {
if (sourceOrganization.id === target.id) return deny('SELF_TARGET');
if (sourceOrganization.parentId === null || sourceOrganization.type === 'root') return deny('ROOT_PROTECTED');
if (sourceOrganization.parentId === target.id) return deny('SAME_ORGANIZATION');
let ancestor: typeof target | undefined = target;
while (ancestor) {
if (ancestor.id === sourceOrganization.id) return deny('DESCENDANT_TARGET');
ancestor = ancestor.parentId === null ? undefined : normalized.organizationsById.get(ancestor.parentId);
}
}
const sourceCompany = companies.get(sourceOrganization.id);
const targetCompany = companies.get(target.id);
if (!sourceCompany || !targetCompany) return deny('COMPANY_UNKNOWN');
if (sourceCompany !== targetCompany && !affiliations.has(targetCompany)) return deny('TARGET_AFFILIATION_MISSING');
return { allowed: true, previousOrganizationId: previousOrganizationId!, previousCompanyId: sourceCompany, companyId: targetCompany };
};
}
/** Pure policy check for a single keyboard or pointer move. */
export function validateOrgChartMove(data: OrgChartDataset, request: OrgChartMoveRequest): OrgChartMoveValidation {
return createOrgChartMoveValidator(data)(request);
}
/** Create a new validated, deeply frozen browser-state dataset. Same-company
* moves change membership alone. Cross-company employee moves also change the
* existing affiliationId; group moves retarget companyId for its whole subtree
* and affiliationId for every subtree employee. No new contract fields are used.
* IDs, descendants, leaders, personal data and display ordering are retained.
* Failed moves return no replacement data and never alter the input.
*/
export function applyOrgChartMove(data: OrgChartDataset, request: OrgChartMoveRequest): OrgChartMoveResult {
const policy = validateOrgChartMove(data, request);
if (!policy.allowed) return { moved: false, code: policy.code, message: policy.message };
const crossCompany = policy.previousCompanyId !== policy.companyId;
let candidate: OrgChartDataset;
if (request.kind === 'employee') {
candidate = { ...data, employees: data.employees.map(employee => employee.id === request.sourceId
? { ...employee, organizationId: request.targetOrganizationId, ...(crossCompany ? { affiliationId: policy.companyId } : {}) } : employee) };
} else {
const subtree = new Set<string>([request.sourceId]);
if (crossCompany) {
const children = new Map<string, string[]>();
for (const org of data.organizations) {
if (org.parentId === null) continue;
const siblings = children.get(org.parentId) ?? [];
siblings.push(org.id); children.set(org.parentId, siblings);
}
const pending = [request.sourceId];
while (pending.length) for (const id of children.get(pending.pop()!) ?? []) { subtree.add(id); pending.push(id); }
}
candidate = {
...data,
organizations: data.organizations.map(org => org.id === request.sourceId
? { ...org, parentId: request.targetOrganizationId, ...(crossCompany ? { companyId: policy.companyId } : {}) }
: crossCompany && subtree.has(org.id) ? { ...org, companyId: policy.companyId } : org),
employees: crossCompany ? data.employees.map(employee => subtree.has(employee.organizationId)
? { ...employee, affiliationId: policy.companyId } : employee) : data.employees,
};
}
const validation = validateOrgChartDataset(candidate);
if (!validation.valid) return { moved: false, code: 'INVALID_DATA', message: messages.INVALID_DATA };
return { moved: true, data: validation.data, request: Object.freeze({ ...request }), previousOrganizationId: policy.previousOrganizationId };
}
+1 -1
View File
@@ -2,6 +2,6 @@ export * from './data/index.js';
export { companyColorPresets, getCompanyColorPreset } from './company-color-presets.js';
export type { CompanyColorPreset, CompanyId } from './company-color-presets.js';
export { createOrgChart } from './core/index.js';
export type { OrgChartConnectionDirection, OrgChartTopNode, OrgChartViewportState, OrgChartDisplayMode, OrgChartLayoutMode, OrgChartEmployeeClickContext, OrgChartHandle, OrgChartOptions, OrgChartOrganizationLinks, OrgChartState } from './core/types.js';
export type { OrgChartInteractionOptions, OrgChartAutoLayoutType, OrgChartAutoLayoutOptions, OrgChartConnectionDirection, OrgChartTopNode, OrgChartViewportState, OrgChartDisplayMode, OrgChartLayoutMode, OrgChartEmployeeClickContext, OrgChartHandle, OrgChartOptions, OrgChartOrganizationLinks, OrgChartState } from './core/types.js';
export { createOrgChartTree, createPositionSummary, createAffiliationSummary, createStatusSummary } from './components.js';
export type { OrgChartSummaryComponentOptions } from './components.js';
@@ -6,8 +6,7 @@
}
.hm-orgchart-organization-link:hover { text-decoration: underline; }
.hm-orgchart-organization-link:focus-visible,
.hm-orgchart-employee[role="button"]:focus-visible,
.hm-orgchart-employee-dialog-close:focus-visible {
.hm-orgchart-employee[role="button"]:focus-visible {
outline: 2px solid var(--hm-orgchart-resolved-stroke-input-focus);
outline-offset: 3px;
}
@@ -27,16 +26,54 @@
.hm-orgchart-employee-dialog::backdrop {
background: color-mix(in srgb, var(--hm-orgchart-resolved-fg-primary) 38%, transparent);
}
.hm-orgchart-employee-dialog-header { display: flex; align-items: center; justify-content: space-between; gap: var(--hm-orgchart-resolved-spacing-lg); }
.hm-orgchart-employee-dialog-header {
display: flex; align-items: center; justify-content: space-between; gap: var(--hm-orgchart-resolved-spacing-lg);
/* Reserve the former text button's height so the title, body and dialog size stay put. */
min-height: calc(1lh + 2 * var(--hm-orgchart-resolved-spacing-xs) + 2 * var(--hm-orgchart-resolved-border-width));
}
.hm-orgchart-employee-dialog-header h2 { margin: 0; font-size: 18px; font-weight: var(--hm-orgchart-resolved-font-weight-bold); }
.hm-orgchart-employee-dialog-close {
padding: var(--hm-orgchart-resolved-spacing-xs) var(--hm-orgchart-resolved-spacing-sm);
border: var(--hm-orgchart-resolved-border-width) solid var(--hm-orgchart-resolved-stroke-default);
border-radius: var(--hm-orgchart-resolved-radius-control-md);
background: var(--hm-orgchart-resolved-bg-content);
color: inherit;
font: inherit;
cursor: pointer;
/* Match HM Dialog's ghost / icon-sm Button without a React or theme dependency.
Fallbacks are the declared HM defaults (theme/default.css and primitives.css). */
--hm-orgchart-dialog-close-ink: var(--foreground, var(--hm-sem-fg-primary, #0a221d));
--hm-orgchart-dialog-close-hover: var(--muted, var(--hm-sem-bg-subtle, #f4f1ea));
--hm-orgchart-dialog-close-ring: var(--ring, var(--hm-sem-stroke-input-focus, #266651));
position: absolute;
top: calc(var(--spacing, .25rem) * 2);
right: calc(var(--spacing, .25rem) * 2);
display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0;
box-sizing: border-box;
width: calc(var(--spacing, .25rem) * 7);
height: calc(var(--spacing, .25rem) * 7);
padding: 0;
border: 1px solid transparent;
border-radius: min(var(--radius-md, calc(var(--radius, var(--hm-radius-container, 4px)) * .8)), 12px);
background: transparent;
background-clip: padding-box;
color: var(--hm-orgchart-dialog-close-ink);
font: inherit; font-size: .875rem; line-height: calc(var(--spacing, .25rem) * 5); font-weight: 500;
outline: none; outline-offset: 0; box-shadow: none;
cursor: default; user-select: none; white-space: nowrap;
transition: all 150ms cubic-bezier(.4, 0, .2, 1);
}
.hm-orgchart-employee-dialog-close svg {
width: calc(var(--spacing, .25rem) * 4);
height: calc(var(--spacing, .25rem) * 4);
pointer-events: none; flex-shrink: 0;
}
.hm-orgchart-employee-dialog-close:hover {
background-color: var(--hm-orgchart-dialog-close-hover);
}
:host-context(.dark) .hm-orgchart-employee-dialog-close:hover,
.dark .hm-orgchart-employee-dialog-close:hover {
background-color: color-mix(in oklab, var(--hm-orgchart-dialog-close-hover) 50%, transparent);
}
.hm-orgchart-employee-dialog-close:focus-visible {
border-color: var(--hm-orgchart-dialog-close-ring);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--hm-orgchart-dialog-close-ring) 50%, transparent);
}
.hm-orgchart-employee-dialog-close:active {
transform: translateY(1px);
}
.hm-orgchart-employee-dialog-fields {
display: grid;
@@ -124,6 +124,9 @@
.hm-orgchart-department-colors > p { grid-column: 1 / -1; }
.hm-orgchart-representative-preview { grid-column: 1 / -1; }
.hm-orgchart-color-preview { min-width: 0; padding: var(--hm-orgchart-resolved-spacing-lg); border: var(--hm-orgchart-resolved-border-width) solid var(--hm-orgchart-current-border); border-radius: var(--hm-orgchart-resolved-radius-container); background: var(--hm-orgchart-current-card-background); overflow-wrap: anywhere; }
/* Align title, provenance, legend, cards and contrast blocks across each row
of specimens even when their descriptions wrap to different heights. */
.hm-orgchart-color-preview { display: grid; grid-template-rows: subgrid; grid-row: span 5; row-gap: 0; }
.hm-orgchart-color-department-name { display: flex; align-items: center; justify-content: center; min-height: var(--hm-orgchart-title-height, 40px); padding: 6px 12px; font-size: var(--hm-orgchart-title-font-size, 18px); line-height: var(--hm-orgchart-title-line-height, 26px); font-weight: var(--hm-orgchart-resolved-font-weight-bold); background: var(--hm-orgchart-current-gradient); color: var(--hm-orgchart-current-department-color); border-radius: var(--hm-orgchart-resolved-radius-container); }
.hm-orgchart-color-legend { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: var(--hm-orgchart-resolved-spacing-sm); }
.hm-orgchart-color-legend dd { margin: 0; display: grid; gap: 4px; }
@@ -14,7 +14,10 @@
width: 100%;
min-width: 0;
padding: var(--hm-orgchart-resolved-spacing-lg);
background: var(--hm-orgchart-resolved-bg-content);
/* Optional host review frame inputs affect only this outer Auto container. */
background: var(--hm-orgchart-expanded-preview-background, var(--hm-orgchart-resolved-bg-content));
border: var(--hm-orgchart-expanded-preview-border, 0);
border-radius: var(--hm-orgchart-expanded-preview-radius, 0);
}
.hm-orgchart-expanded .hm-orgchart-heading { margin: 0; }
.hm-orgchart-expanded .hm-orgchart-viewport {
@@ -69,7 +72,7 @@
min-height: 0;
padding: 0;
background: var(--hm-orgchart-expanded-primary);
color: var(--hm-orgchart-expanded-title-ink);
color: #ffffff;
font-size: var(--hm-orgchart-resolved-font-size-base);
font-weight: var(--hm-orgchart-resolved-font-weight-bold);
line-height: 1.5;
@@ -143,7 +146,7 @@
}
.hm-orgchart-expanded-toggle:not(:disabled):hover,
.hm-orgchart-expanded-link:hover { background: color-mix(in srgb, currentColor 12%, transparent); }
.hm-orgchart-expanded-toggle:disabled { cursor: default; opacity: 0.75; }
.hm-orgchart-expanded-toggle:is(:disabled, [aria-disabled="true"]) { cursor: default; opacity: 0.75; }
.hm-orgchart-expanded-link { display: inline-flex; align-items: center; padding-inline: var(--hm-orgchart-resolved-spacing-sm); color: inherit; }
.hm-orgchart-expanded-toggle:focus-visible, .hm-orgchart-expanded-link:focus-visible,
.hm-orgchart-expanded-toolbar button:focus-visible {
@@ -161,6 +164,7 @@
.hm-orgchart-expanded-selection, .hm-orgchart-top-node-title { margin: 0; }
.hm-orgchart-expanded-empty { padding: var(--hm-orgchart-resolved-spacing-lg); }
.hm-orgchart-expanded .hm-orgchart-viewport[data-dragging] { cursor: grabbing; user-select: none; }
.hm-orgchart-expanded .hm-orgchart-viewport[data-pan-enabled="false"] { cursor: default; }
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] { cursor: default; }
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employees[data-connection-direction="left"],
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employees[data-connection-direction="right"] {
@@ -198,3 +202,74 @@
.hm-orgchart-expanded .hm-orgchart-person-text { gap: var(--hm-orgchart-expanded-person-gap); }
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-person-name { font-size: var(--hm-orgchart-expanded-name-size); font-weight: var(--hm-orgchart-expanded-name-weight); }
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-person-role { font-size: var(--hm-orgchart-expanded-position-size); }
/* P7: direction/vertical adapters keep dabeng's recursive hierarchy and the
existing HM card geometry. Plain TB continues to use its original rules. */
.hm-orgchart-expanded .hm-orgchart-tree { position: relative; flex: none; }
.hm-orgchart-expanded[data-auto-layout="lr"] .hm-orgchart-forest { flex-direction: column; }
.hm-orgchart-expanded[data-auto-layout="lr"] .hm-orgchart-engine.orgchart.l2r {
position: relative; transform: none; transform-origin: 0 0;
}
.hm-orgchart-expanded[data-auto-layout="lr"] .hm-orgchart-engine.orgchart .hierarchy {
display: flex; flex-direction: row; align-items: center;
}
.hm-orgchart-expanded[data-auto-layout="lr"] .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth]) > .nodes {
flex-direction: column; align-items: flex-start;
}
.hm-orgchart-expanded[data-auto-layout="lr"] .hm-orgchart-engine.orgchart .node[data-depth] {
flex: none; height: auto; min-height: 0;
margin: calc(var(--hm-orgchart-expanded-sibling-gap) / 2) var(--hm-orgchart-expanded-level-gap) calc(var(--hm-orgchart-expanded-sibling-gap) / 2) 0;
}
.hm-orgchart-expanded[data-auto-layout="lr"] .hm-orgchart-engine.orgchart .node[data-depth] > .hm-orgchart-organization {
margin-inline: auto; padding-inline: var(--hm-orgchart-resolved-spacing-xxl);
}
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .nodes.vertical,
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .nodes.vertical .nodes,
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth]) > .nodes.vertical {
display: flex; flex-direction: column; align-items: flex-start;
width: max-content; padding-left: var(--hm-orgchart-resolved-spacing-xxl);
gap: calc(var(--hm-orgchart-expanded-level-gap) / 2);
}
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .nodes.vertical {
margin-inline: auto;
}
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .nodes.vertical .nodes {
padding-top: calc(var(--hm-orgchart-expanded-level-gap) / 2);
}
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .nodes.vertical .hierarchy:has(> .node[data-depth]) > .nodes {
flex-direction: column; align-items: flex-start;
}
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .nodes.vertical .node[data-depth] {
margin: 0; min-height: 0;
}
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .nodes.vertical .node[data-depth] > .hm-orgchart-organization {
padding-inline: var(--hm-orgchart-resolved-spacing-xxl);
}
/* Measured Auto connector routes replace upstream fixed-card pseudo geometry
in LR/global Hybrid/TB per-group Hybrid only. */
.hm-orgchart-expanded:is([data-auto-layout="lr"], [data-auto-layout="hybrid"], [data-group-hybrid="true"]) .hm-orgchart-engine.orgchart .hierarchy::before,
.hm-orgchart-expanded:is([data-auto-layout="lr"], [data-auto-layout="hybrid"], [data-group-hybrid="true"]) .hm-orgchart-engine.orgchart .hierarchy::after,
.hm-orgchart-expanded:is([data-auto-layout="lr"], [data-auto-layout="hybrid"], [data-group-hybrid="true"]) .hm-orgchart-engine.orgchart .node[data-depth]::before,
.hm-orgchart-expanded:is([data-auto-layout="lr"], [data-auto-layout="hybrid"], [data-group-hybrid="true"]) .hm-orgchart-engine.orgchart .node[data-depth]::after {
content: none;
}
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node > .toggleBtn { display: none; }
.hm-orgchart-expanded .hm-orgchart-organization-connectors {
position: absolute; inset: 0; overflow: visible; pointer-events: none;
}
.hm-orgchart-expanded .hm-orgchart-organization-connectors path {
fill: none; stroke: var(--hm-orgchart-expanded-line-color); stroke-width: var(--hm-orgchart-expanded-line-width);
}
/* P9.3: outlines do not resize cards or replace company accents/connectors. */
.hm-orgchart-expanded .hm-orgchart-viewport[data-drag-drop-enabled="true"] :is(.hm-orgchart-employee, .hm-orgchart-expanded-toggle) { cursor: grab; }
.hm-orgchart-expanded .hm-orgchart-viewport[data-drag-drop-enabled="false"] :is(.hm-orgchart-employee, .hm-orgchart-expanded-toggle) { cursor: pointer; }
.hm-orgchart-expanded .hm-orgchart-move-feedback { margin: 0; overflow-wrap: anywhere; color: var(--hm-orgchart-resolved-fg-secondary); }
.hm-orgchart-expanded .hm-orgchart-move-feedback[data-move-status="success"] { color: var(--hm-orgchart-resolved-action-primary); font-weight: var(--hm-orgchart-resolved-font-weight-bold); }
.hm-orgchart-expanded .hm-orgchart-move-feedback[data-move-status="failure"] { color: var(--hm-orgchart-resolved-fg-primary); text-decoration: underline; }
.hm-orgchart-expanded .hm-orgchart-viewport[data-edit-dragging] { cursor: grabbing; user-select: none; }
.hm-orgchart-expanded [data-edit-source="true"] { opacity: 0.55; }
.hm-orgchart-expanded .node[data-drop-state="allowed"] > .hm-orgchart-organization { outline: 2px dashed var(--hm-orgchart-resolved-stroke-input-focus); outline-offset: 2px; }
.hm-orgchart-expanded .node[data-drop-state="blocked"] > .hm-orgchart-organization { outline: 1px dotted var(--hm-orgchart-resolved-stroke-default); outline-offset: 2px; }
.hm-orgchart-expanded .node[data-drop-hover="allowed"] > .hm-orgchart-organization { outline: 3px solid var(--hm-orgchart-resolved-action-primary); }
.hm-orgchart-expanded .node[data-drop-hover="blocked"] > .hm-orgchart-organization { outline: 3px double var(--hm-orgchart-resolved-fg-secondary); }
@@ -1,7 +1,7 @@
/* P3 read-only summary presenters. Values are from Workdesk index.css:
headcount-panel / bold-text / sidebar- / #summary-table and the bottom
.section legend. These are standalone OrgChart content, not HM overrides. */
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-organization-totals {
:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-organization-totals {
display: flex;
flex-direction: column;
align-items: flex-end;
@@ -13,7 +13,7 @@
color: #18253a;
font-size: 14px;
}
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-organization-totals-title {
:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-organization-totals-title {
width: 176px;
margin: 0;
padding: 4px 0;
@@ -67,7 +67,7 @@
.hm-orgchart-workdesk-position-table tfoot :is(th, td) { height: 20px; }
.hm-orgchart-workdesk-count-cell { font-variant-numeric: tabular-nums; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar {
:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar {
display: flex;
align-items: center;
justify-content: center;
@@ -81,11 +81,11 @@
box-shadow: 0 5.4px 14.3px rgba(44, 68, 96, 0.08);
color: #1c2534;
}
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar-list {
:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar-list {
flex-wrap: nowrap;
gap: 26.8px;
}
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar-item {
:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar-item {
position: relative;
height: 42.9px;
min-height: 42.9px;
@@ -95,8 +95,8 @@
font-weight: 500;
line-height: 19.7px;
}
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar-item + .hm-orgchart-affiliation-bar-item { border: 0; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar-item + .hm-orgchart-affiliation-bar-item::before {
:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar-item + .hm-orgchart-affiliation-bar-item { border: 0; }
:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar-item + .hm-orgchart-affiliation-bar-item::before {
content: '';
position: absolute;
top: 50%;
@@ -106,14 +106,22 @@
background: rgba(113, 142, 176, 0.48);
transform: translateY(-50%);
}
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar-item dt { gap: 0.9px; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar .hm-orgchart-affiliation-mark {
:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar-item dt { gap: 0.9px; }
:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar .hm-orgchart-affiliation-mark {
width: 39.3px;
height: 39.3px;
flex-basis: 39.3px;
}
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar .hm-orgchart-affiliation-mark img { object-fit: cover; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-affiliation-bar-total {
:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar .hm-orgchart-affiliation-mark img { object-fit: cover; }
:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar-total {
color: #071d3a;
font-weight: 700;
}
/* Overview shares the bar's internal design, but lets its items wrap in the
page flow. No Fixed stage/absolute positioning is copied into the preview. */
.hm-orgchart-shell.hm-orgchart-summary-preview .hm-orgchart-affiliation-bar { height: auto; padding-block: 6.25px; }
.hm-orgchart-shell.hm-orgchart-summary-preview .hm-orgchart-affiliation-bar-list { flex-wrap: wrap; row-gap: 8px; }
@media (max-width: 767px) {
.hm-orgchart-shell.hm-orgchart-summary-preview .hm-orgchart-affiliation-bar-item + .hm-orgchart-affiliation-bar-item::before { display: none; }
}
@@ -157,8 +157,8 @@
/* Workdesk compact artwork. Preserve flow placement for unknown organizations;
the measured preset alone enables this original 1920×914.5 coordinate layer. */
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] { grid-template-rows: 224.2px auto 1fr auto; height: 914.5px; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-axis { width: 198px; height: 224.2px; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] { grid-template-rows: 224.2px auto 1fr auto; height: var(--hm-orgchart-workdesk-stage-height, 914.5px); }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-axis { width: 198px; height: 224.2px; z-index: 12; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-top-leaders { position: absolute; top: 35.3px; z-index: 35; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-hub {
position: absolute;
@@ -192,7 +192,7 @@
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-slot-footer { position: relative; z-index: 7; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-slot-side { position: relative; z-index: 18; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-slot-belowTree { position: relative; z-index: 32; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-slot-belowTree { position: absolute; left: 32px; bottom: 70px; width: 240px; margin: 0; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-slot-belowTree { position: absolute; left: 32px; bottom: 82px; width: 240px; margin: 0; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-status-summary { gap: 12px; flex-wrap: nowrap; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-slot-footer { position: absolute; left: 57px; right: 57px; bottom: 12px; margin: 0; }
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-person-role,
@@ -207,6 +207,12 @@
.hm-orgchart-workdesk-meeting { left: 68px; bottom: 32px; width: 557px; height: 344px; opacity: 0.8; mix-blend-mode: multiply; }
.hm-orgchart-workdesk-grid { left: 62px; bottom: 18.9px; width: 1768px; height: 275px; }
.hm-orgchart-workdesk-hub-frame { inset: 0; width: 100%; height: 100%; z-index: -1; object-position: top; }
/* One base-coordinate central layer; top and bottom modules keep their own
anchors. Decorations retain their original size and 914.5px coordinates. */
.hm-orgchart-stage[data-workdesk-artwork] :is(.hm-orgchart-axis,.hm-orgchart-viewport,.hm-orgchart-workdesk-overlay,.hm-orgchart-workdesk-decoration:not(.hm-orgchart-workdesk-hub-frame)) { transform: translateY(var(--hm-orgchart-workdesk-center-offset, 0px)); }
.hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-workdesk-sky { bottom: auto; height: 914.5px; }
.hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-workdesk-meeting { top: 538.5px; bottom: auto; }
.hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-workdesk-grid { top: 620.6px; bottom: auto; }
/* P3: artwork owns representative/hub/top-level routes. Hide every engine
hierarchy route in readOnly only, including upstream pseudo-element paths. */
.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .hierarchy::before,