feat(org-chart): separate fixed and auto layout previews
This commit is contained in:
1 parent
80583eb4ca
commit
a92913d6bc
18 files changed
+1211
-7
No files matched your search
@@ -0,0 +1,47 @@
|
||||
import { createEmployeeCard } from './employee-card.js';
|
||||
import { createHeading, type RenderContext } from './render.js';
|
||||
import { representativeCardAsset } from './representative-card-asset.js';
|
||||
|
||||
/** P5-1A presenter boundary. The shared Core supplies the initial engine groups;
|
||||
* this presenter never assigns coordinates or interprets parentId. A later
|
||||
* layout adapter can replace group placement without duplicating card or data
|
||||
* rendering in React and Vanilla consumers. */
|
||||
export function createExpandedOrgChartContent(
|
||||
document: Document, context: RenderContext, tree: HTMLElement, signal: AbortSignal,
|
||||
): HTMLElement {
|
||||
const content = document.createElement('div');
|
||||
content.className = 'hm-orgchart-expanded';
|
||||
content.dataset.component = 'ExpandedOrgChartContent';
|
||||
content.dataset.layoutMode = 'auto';
|
||||
content.dataset.layoutStatus = 'preview';
|
||||
|
||||
const heading = createHeading(document, context);
|
||||
const status = document.createElement('p');
|
||||
status.className = 'hm-orgchart-expanded-stage-note';
|
||||
status.textContent = 'P5-1A · 확장형 준비 화면';
|
||||
const description = document.createElement('p');
|
||||
description.className = 'hm-orgchart-expanded-description';
|
||||
description.textContent = '대표자와 1Depth 예시 그룹을 표시합니다. 하위 계층 자동 배치와 접기·펼치기, Zoom/Pan은 후속 단계에서 구현합니다.';
|
||||
content.append(heading, status, description);
|
||||
|
||||
const leaders = document.createElement('section');
|
||||
leaders.className = 'hm-orgchart-expanded-top-leaders';
|
||||
leaders.setAttribute('aria-label', '최상위 조직 리더');
|
||||
for (const id of context.topLeaderIds) {
|
||||
const employee = context.data.employees.find(item => item.id === id);
|
||||
if (!employee) continue;
|
||||
leaders.append(createEmployeeCard(document, employee, {
|
||||
kind: 'topLeader',
|
||||
workdeskPresentation: true,
|
||||
cardAsset: representativeCardAsset,
|
||||
affiliationName: context.data.affiliations.find(item => item.id === employee.affiliationId)?.name,
|
||||
}, signal));
|
||||
}
|
||||
if (leaders.childElementCount) content.append(leaders);
|
||||
|
||||
// Keep the stable Core viewport for keyboard access and ordinary scrolling.
|
||||
// This is a flow preview, with no fixed-stage scale observer or artwork.
|
||||
tree.setAttribute('aria-label', '확장형 조직도 초기 그룹 영역');
|
||||
content.append(tree);
|
||||
return content;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { expandedPreviewRoots } from './expanded-layout.js';
|
||||
import { createOrgChart } from './index.js';
|
||||
import type { OrganizationTreeNode } from '../data/index.js';
|
||||
|
||||
const node = (id: string, parentId: string | null, type: 'root' | 'office' = 'office', children: OrganizationTreeNode[] = []): OrganizationTreeNode => ({
|
||||
organization: { id, parentId, name: id, type, leaderId: null, displayOrder: 0 }, children,
|
||||
});
|
||||
|
||||
test('initial preview keeps actual first-depth ancestry and defers descendants without mutating hierarchy', () => {
|
||||
const grandchild = node('depth-2', 'depth-1');
|
||||
const child = node('depth-1', 'root', 'office', [grandchild]);
|
||||
const roots = [node('root', null, 'root', [child])];
|
||||
const before = structuredClone(roots);
|
||||
const projected = expandedPreviewRoots(roots);
|
||||
assert.deepEqual(projected.map(item => item.organization.id), ['depth-1']);
|
||||
assert.equal(projected[0]!.organization.parentId, 'root');
|
||||
assert.deepEqual(projected[0]!.children, []);
|
||||
assert.deepEqual(roots, before);
|
||||
});
|
||||
|
||||
test('independent roots and normalized order stay independent in the initial preview', () => {
|
||||
const roots = [node('first', null), node('root', null, 'root', [node('second', 'root'), node('third', 'root')])];
|
||||
assert.deepEqual(expandedPreviewRoots(roots).map(item => item.organization.id), ['first', 'second', 'third']);
|
||||
assert.deepEqual(expandedPreviewRoots([]), []);
|
||||
});
|
||||
|
||||
const options = { dataSource: { load: async () => ({}) }, stylesheetUrls: { engine: '/engine.css', core: '/style.css' } };
|
||||
|
||||
test('layout defaults remain compatible and displayMode is an independent public option', () => {
|
||||
for (const displayMode of ['interactive', 'readOnly'] as const) {
|
||||
for (const layoutMode of [undefined, 'fixed', 'auto'] as const) {
|
||||
const handle = createOrgChart({ ...options, displayMode, layoutMode });
|
||||
assert.equal(handle.getState().status, 'idle');
|
||||
handle.destroy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid layout and unknown Auto company fail explicitly, while Fixed ignores company configuration', () => {
|
||||
assert.throws(() => createOrgChart({ ...options, layoutMode: 'invalid' as 'auto' }), /layoutMode/);
|
||||
assert.throws(() => createOrgChart({ ...options, layoutMode: 'auto', companyId: 'unknown' }), /companyId/);
|
||||
const fixed = createOrgChart({ ...options, layoutMode: 'fixed', companyId: 'unknown' });
|
||||
assert.equal(fixed.getState().status, 'idle');
|
||||
fixed.destroy();
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { OrganizationTreeNode } from '../data/index.js';
|
||||
|
||||
/** P5-1A preview projection only. Keep the validated hierarchy intact and
|
||||
* expose the root's immediate groups without pretending to place descendants.
|
||||
* P5-1B can replace this boundary with a recursive layout result shared by
|
||||
* every runtime; neither React nor the Story owns a layout algorithm. */
|
||||
export function expandedPreviewRoots(roots: readonly OrganizationTreeNode[]): OrganizationTreeNode[] {
|
||||
return roots.flatMap(root => root.organization.type === 'root' ? root.children : [root])
|
||||
.map(node => ({ organization: node.organization, children: [] }));
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import type { EngineAdapter, EngineConstructor } from './engine.js';
|
||||
import { decorateNode, engineTree } from './render.js';
|
||||
import { createOrgChartTree } from './org-chart-tree.js';
|
||||
import { createOrgChartContent } from './org-chart-content.js';
|
||||
import { createExpandedOrgChartContent } from './expanded-content.js';
|
||||
import { expandedPreviewRoots } from './expanded-layout.js';
|
||||
import { getCompanyColorPreset } from '../company-color-presets.js';
|
||||
import { createPositionSummary } from './position-summary.js';
|
||||
import { createAffiliationSummary } from './affiliation-summary.js';
|
||||
import { createStatusSummary } from './status-summary.js';
|
||||
@@ -13,7 +16,7 @@ import type { EmployeeInfoDialog } from './employee-info-dialog.js';
|
||||
import type { RenderContext } from './render.js';
|
||||
import type { OrgChartEmployeeClickContext, OrgChartHandle, OrgChartOptions, OrgChartState } from './types.js';
|
||||
|
||||
export type { OrgChartEmployeeClickContext, OrgChartHandle, OrgChartOptions, OrgChartOrganizationLinks, OrgChartState } from './types.js';
|
||||
export type { OrgChartLayoutMode, OrgChartEmployeeClickContext, OrgChartHandle, OrgChartOptions, OrgChartOrganizationLinks, OrgChartState } from './types.js';
|
||||
|
||||
const owners = new WeakMap<HTMLElement, symbol>();
|
||||
let instanceSequence = 0;
|
||||
@@ -47,6 +50,11 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
if (!options.stylesheetUrls?.core || (needsEngine && !options.stylesheetUrls.engine)) throw new TypeError('Provide the required OrgChart stylesheet URLs');
|
||||
const stylesheetUrls = { ...options.stylesheetUrls };
|
||||
if (options.displayMode !== undefined && options.displayMode !== 'interactive' && options.displayMode !== 'readOnly') throw new TypeError('displayMode must be "interactive" or "readOnly"');
|
||||
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';
|
||||
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 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');
|
||||
@@ -200,14 +208,16 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
const normalized = normalizeOrgChartDataset(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, organizationLinks };
|
||||
const nextContext: RenderContext = { data, summary, orderedOrganizations: [], employeesByOrganization: normalized.employeesByOrganization, topLeaderIds, readOnly: readOnly || expanded, organizationLinks, expandedCompanyPreset };
|
||||
// Composition shows the root leader as the standalone top card, so the root's direct
|
||||
// organizations become the horizontal engine roots (data and parentId stay untouched).
|
||||
// A root with other direct employees keeps its node so those employees stay visible.
|
||||
const flattenRoot = (node: typeof normalized.roots[number]) => view === 'composition' && node.organization.type === 'root' && !!node.organization.leaderId
|
||||
&& (normalized.employeesByOrganization.get(node.organization.id) ?? []).every(employee => employee.id === node.organization.leaderId);
|
||||
const flattened = normalized.roots.some(flattenRoot);
|
||||
const tree = engineTree(flattened ? normalized.roots.flatMap(node => flattenRoot(node) ? node.children : [node]) : normalized.roots, prefix, nextContext, flattened ? 1 : 0);
|
||||
const roots = expanded ? expandedPreviewRoots(normalized.roots)
|
||||
: flattened ? normalized.roots.flatMap(node => flattenRoot(node) ? node.children : [node]) : normalized.roots;
|
||||
const tree = engineTree(roots, prefix, nextContext, expanded || flattened ? 1 : 0);
|
||||
const styleIssues = await styleReady;
|
||||
if (!active(version)) return;
|
||||
if (styleIssues) { fail(version, styleIssues); return; }
|
||||
@@ -233,7 +243,9 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
});
|
||||
}
|
||||
// If regeneration removed keyboard focus, offer the stable viewport next.
|
||||
const nextContent = view === 'composition' ? createOrgChartContent(document, nextContext, viewport!, contentGeneration.signal)
|
||||
const nextContent = view === 'composition' ? expanded
|
||||
? createExpandedOrgChartContent(document, nextContext, viewport!, contentGeneration.signal)
|
||||
: createOrgChartContent(document, nextContext, viewport!, contentGeneration.signal)
|
||||
: view === 'tree' ? viewport!
|
||||
: view === 'positionSummary' ? createPositionSummary(document, nextContext)
|
||||
: view === 'affiliationSummary' ? createAffiliationSummary(document, nextContext)
|
||||
@@ -306,6 +318,7 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
cancelStyles = () => { for (const dispose of styleDisposers) dispose(); };
|
||||
shell = document.createElement('section');
|
||||
shell.className = 'hm-orgchart-shell';
|
||||
shell.dataset.layoutMode = view === 'composition' ? layoutMode : 'fixed';
|
||||
// The Core's Korean labels must shape identically in ko HTML and en Storybook hosts.
|
||||
shell.lang = 'ko';
|
||||
shell.tabIndex = -1;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createEmployeeCard } from './employee-card.js';
|
||||
import { applyOrganizationStyle, organizationStylePresets } from './style-presets.js';
|
||||
import { getDepartmentCardAsset } from './department-card-assets.js';
|
||||
import type { OrgChartOrganizationLinks } from './types.js';
|
||||
import type { CompanyColorPreset } from '../company-color-presets.js';
|
||||
|
||||
const organizationNames = { root: '최상위 조직', division: '부문', office: '실', div: 'Div', cell: '셀', team: '팀' } as const;
|
||||
|
||||
@@ -16,6 +17,8 @@ export interface RenderContext {
|
||||
/** View-only composition: no collapse controls or diagnostic text are created. */
|
||||
readOnly?: boolean;
|
||||
organizationLinks?: OrgChartOrganizationLinks;
|
||||
/** Only the Expanded presenter supplies company header colors. */
|
||||
expandedCompanyPreset?: CompanyColorPreset;
|
||||
}
|
||||
|
||||
export function engineTree(roots: OrganizationTreeNode[], prefix: string, context: RenderContext, startDepth = 0): RenderEngineNode[] {
|
||||
@@ -61,6 +64,14 @@ export function decorateNode(
|
||||
}
|
||||
node.dataset.organizationDepth = String(organizationDepth);
|
||||
applyOrganizationStyle(node, Number(value.groupVariant));
|
||||
if (context.expandedCompanyPreset) {
|
||||
const preset = context.expandedCompanyPreset;
|
||||
node.dataset.companyId = preset.companyId;
|
||||
node.style.setProperty('--hm-orgchart-current-gradient', preset.primary);
|
||||
node.style.setProperty('--hm-orgchart-expanded-primary', preset.primary);
|
||||
// Preserve every existing secondary value for the later Expanded theme.
|
||||
preset.secondary.forEach((color, index) => node.style.setProperty(`--hm-orgchart-expanded-secondary-${index + 1}`, color));
|
||||
}
|
||||
node.dataset.organizationType = organization.type;
|
||||
// Header role is styling-only: depth 0-1 are organization titles, deeper nodes are cell/team headers.
|
||||
node.dataset.headerRole = (context.readOnly ? organizationDepth : Number(value.depth)) <= 1 ? 'organization' : 'group';
|
||||
|
||||
@@ -10,6 +10,10 @@ export type OrgChartState =
|
||||
* controls or development text, compact summaries on a fitted design canvas. */
|
||||
export type OrgChartDisplayMode = 'interactive' | 'readOnly';
|
||||
|
||||
/** Layout is independent of displayMode. Auto currently exposes only the
|
||||
* P5-1A initial groups; recursive placement is reserved for P5-1B. */
|
||||
export type OrgChartLayoutMode = 'fixed' | 'auto';
|
||||
|
||||
/** Links are keyed by the existing organization ID. Only 1Depth titles use them. */
|
||||
export type OrgChartOrganizationLinks = Readonly<Record<string, string | undefined>>;
|
||||
|
||||
@@ -29,6 +33,11 @@ export interface OrgChartOptions {
|
||||
stylesheetUrls: { engine: string; core: string };
|
||||
/** Full composition presentation only; standalone views ignore it. */
|
||||
displayMode?: OrgChartDisplayMode;
|
||||
/** Full composition only. Omission preserves the existing Fixed renderer. */
|
||||
layoutMode?: OrgChartLayoutMode;
|
||||
/** Expanded-only company preset ID. Defaults to hanmac; ignored by Fixed.
|
||||
* Unknown IDs are rejected in Auto rather than silently replacing colors. */
|
||||
companyId?: string;
|
||||
/** No entry (or an empty URL) keeps an ordinary title. HTTP(S) and relative URLs are supported. */
|
||||
organizationLinks?: OrgChartOrganizationLinks;
|
||||
/** Optional host-owned information UI. When supplied, it replaces the default read-only dialog.
|
||||
|
||||
Reference in new issue
Block a user