feat(org-chart): implement expandable layout and multi-company navigation
Integrate P5-1B through P5-4 shared Core layout, collapse, zoom/pan/fit, company roots and keyboard accessibility. Include React/HTML examples, virtual 20/100/300 employee fixtures, focused checks and the additive data contract.
This commit is contained in:
1 parent
a92913d6bc
commit
064517ec46
30 files changed
+7532
-183
No files matched your search
@@ -13,6 +13,8 @@ export interface EmployeeCardOptions {
|
||||
cardAsset?: DepartmentCardAsset | RepresentativeCardAsset;
|
||||
/** Full Composition presentation only; the employee dataset is untouched. */
|
||||
workdeskPresentation?: boolean;
|
||||
/** Auto content presentation, independent of Fixed SVG geometry. */
|
||||
expandedPresentation?: boolean;
|
||||
/** Compact read-only status groups already convey this label in their header. */
|
||||
hideStatusLabel?: boolean;
|
||||
}
|
||||
@@ -31,15 +33,16 @@ export function createEmployeeCard(
|
||||
const leader = options.kind !== 'employee';
|
||||
const representative = options.kind === 'topLeader' || options.cardAsset?.size === 'representative';
|
||||
const compactStatus = Boolean(options.workdeskPresentation && options.hideStatusLabel && !options.cardAsset);
|
||||
const styledCard = Boolean(options.cardAsset) || compactStatus;
|
||||
const presentationName = options.workdeskPresentation || representative;
|
||||
const styledCard = Boolean(options.cardAsset) || compactStatus || options.expandedPresentation;
|
||||
const presentationName = options.workdeskPresentation || (representative && !options.expandedPresentation);
|
||||
const displayName = presentationName ? '홍길동' : employee.name;
|
||||
const displayedName = presentationName ? '홍 길 동' : displayName;
|
||||
const displayedName = presentationName ? '홍 길 동' : options.expandedPresentation && /^[가-힣]{3}$/u.test(displayName) ? [...displayName].join(' ') : displayName;
|
||||
const card = document.createElement('div');
|
||||
card.className = `hm-orgchart-employee${leader ? ' hm-orgchart-leader' : ''}`;
|
||||
card.dataset.employeeId = employee.id;
|
||||
card.dataset.cardKind = options.kind;
|
||||
card.dataset.workStatus = employee.workStatus;
|
||||
if (options.expandedPresentation) card.dataset.cardPresentation = 'expanded';
|
||||
if (options.workdeskPresentation) card.dataset.workdeskPresentation = 'true';
|
||||
if (compactStatus) card.dataset.cardLayout = 'compact';
|
||||
if (options.cardAsset) {
|
||||
@@ -81,14 +84,15 @@ export function createEmployeeCard(
|
||||
|
||||
const details = document.createElement('div');
|
||||
details.className = 'hm-orgchart-employee-details';
|
||||
const displayedPosition = representative ? '대표이사' : employee.position.replace(/\s+/gu, '');
|
||||
const displayedPosition = representative && !options.expandedPresentation ? '대표이사' : employee.position.replace(/\s+/gu, '');
|
||||
const position = text(document, 'span', 'hm-orgchart-person-role', displayedPosition);
|
||||
const name = text(document, 'strong', 'hm-orgchart-person-name', displayedName);
|
||||
if (presentationName) {
|
||||
position.setAttribute('aria-label', representative ? displayedPosition : employee.position);
|
||||
name.setAttribute('aria-label', displayName);
|
||||
}
|
||||
const inlineRoleBadge = styledCard && !representative;
|
||||
if (options.expandedPresentation) position.setAttribute('aria-label', employee.position);
|
||||
const inlineRoleBadge = styledCard && !representative && !options.expandedPresentation;
|
||||
const positionRow = inlineRoleBadge ? document.createElement('div') : null;
|
||||
if (positionRow) {
|
||||
positionRow.className = 'hm-orgchart-position-row';
|
||||
@@ -104,7 +108,7 @@ export function createEmployeeCard(
|
||||
// Representative cards show only position/name in every consumer. Raw employee
|
||||
// roles remain available to dialogs/statistics; other cards keep their policy.
|
||||
const hideExecutiveBadge = options.workdeskPresentation && employee.jobTitle?.trim() === '경영진';
|
||||
if (!representative && !hideExecutiveBadge && (employee.jobTitle || (leader && !options.cardAsset))) {
|
||||
if (!representative && !options.expandedPresentation && !hideExecutiveBadge && (employee.jobTitle || (leader && !options.cardAsset))) {
|
||||
const rawBadge = employee.jobTitle || '조직장';
|
||||
const badge = text(document, 'span', 'hm-orgchart-job-title', options.workdeskPresentation ? rawBadge.replace(/\s+/gu, '') : rawBadge);
|
||||
if (options.workdeskPresentation) badge.setAttribute('aria-label', employee.jobTitle || '조직장');
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { validateOrgChartDataset, normalizeOrgChartDataset } from '../data/index.js';
|
||||
import { resolveExpandedCompanies, validateTopNodes } from './expanded-company.js';
|
||||
import { createOrgChart } from './index.js';
|
||||
|
||||
test('fixed JSON fixtures have exact unique counts, valid ancestry and company memberships', () => {
|
||||
for (const [size, count, companies, depth] of [['small', 20, 3, 2], ['medium', 100, 5, 3], ['large', 300, 7, 4]] as const) {
|
||||
const input = JSON.parse(readFileSync(`examples/org-chart/data-expanded-${size}.json`, 'utf8'));
|
||||
const validation = validateOrgChartDataset(input);
|
||||
assert.equal(validation.valid, true, JSON.stringify(validation));
|
||||
if (!validation.valid) continue;
|
||||
const data = validation.data, normalized = normalizeOrgChartDataset(data);
|
||||
const resolved = resolveExpandedCompanies(data, normalized.roots, 'hanmac');
|
||||
assert.equal(data.employees.length, count);
|
||||
assert.equal(new Set(data.employees.map(employee => employee.id)).size, count);
|
||||
assert.equal(resolved.companyIds.length, companies);
|
||||
assert.equal(resolved.issues.length, 0);
|
||||
for (const employee of data.employees) assert.equal(resolved.companies.get(employee.organizationId), employee.affiliationId);
|
||||
let maxDepth = 0;
|
||||
const visit = (nodes: typeof normalized.roots, level: number) => { maxDepth = Math.max(maxDepth, level); nodes.forEach(node => { if (node.children.length) visit(node.children, level + 1); }); };
|
||||
visit(normalized.roots, 0); assert.equal(maxDepth, depth);
|
||||
assert.equal(validateTopNodes(data, normalized.roots, resolved.companies, { hanmac: { title: '회장', display: 'card' } }).length, 0);
|
||||
assert.ok(validateTopNodes(data, normalized.roots, resolved.companies, { hanmac: { employeeId: 'missing' } }).length);
|
||||
assert.ok(validateTopNodes(data, normalized.roots, resolved.companies, { hanmac: { profileImage: 'javascript:alert(1)' } }).length);
|
||||
}
|
||||
});
|
||||
|
||||
test('inherited identity stays stable; cross-company ancestry fails instead of mixing companies', () => {
|
||||
const data = JSON.parse(readFileSync('examples/org-chart/data-expanded-small.json', 'utf8'));
|
||||
delete data.organizations[1].companyId;
|
||||
let roots = normalizeOrgChartDataset(data).roots;
|
||||
assert.equal(resolveExpandedCompanies(data, roots, 'saman').companies.get(data.organizations[1].id), 'hanmac');
|
||||
data.organizations[1].companyId = 'saman'; roots = normalizeOrgChartDataset(data).roots;
|
||||
assert.equal(resolveExpandedCompanies(data, roots, 'hanmac').issues[0]?.code, 'COMPANY_HIERARCHY');
|
||||
});
|
||||
|
||||
test('invalid Auto view configuration fails early, leaving Fixed untouched', () => {
|
||||
const options = { dataSource: { load: async () => ({}) }, stylesheetUrls: { engine: '/engine.css', core: '/style.css' } };
|
||||
assert.throws(() => createOrgChart({ ...options, layoutMode: 'auto', selectedCompanyIds: ['unknown'] }), /selectedCompanyIds/);
|
||||
assert.throws(() => createOrgChart({ ...options, layoutMode: 'auto', zoom: { min: 2 } }), /zoom/);
|
||||
const fixed = createOrgChart({ ...options, layoutMode: 'fixed', selectedCompanyIds: ['unknown'], companyTopNodes: { unknown: { title: 'legacy' } } });
|
||||
assert.equal(fixed.getViewportState(), undefined); fixed.destroy();
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { getCompanyColorPreset } from '../company-color-presets.js';
|
||||
import { isSafeImageUrl, type OrgChartDataset, type OrganizationTreeNode, type ValidationIssue } from '../data/index.js';
|
||||
import type { OrgChartTopNode } from './types.js';
|
||||
|
||||
export function resolveExpandedCompanies(data: OrgChartDataset, roots: OrganizationTreeNode[], fallback: string) {
|
||||
const companies = new Map<string, string>();
|
||||
const issues: ValidationIssue[] = [];
|
||||
const visit = (node: OrganizationTreeNode, inherited?: string) => {
|
||||
const org = node.organization;
|
||||
const leader = data.employees.find(employee => employee.id === org.leaderId);
|
||||
const company = org.companyId ?? inherited ?? (leader && getCompanyColorPreset(leader.affiliationId) ? leader.affiliationId : fallback);
|
||||
if (!getCompanyColorPreset(company)) issues.push({ code: 'COMPANY_PRESET', path: `organizations.${org.id}.companyId`, message: 'Auto company requires a registered color preset.' });
|
||||
if (inherited && company !== inherited) issues.push({ code: 'COMPANY_HIERARCHY', path: `organizations.${org.id}.companyId`, message: 'A child cannot belong to another company tree.' });
|
||||
companies.set(org.id, company);
|
||||
node.children.forEach(child => visit(child, company));
|
||||
};
|
||||
roots.forEach(root => visit(root));
|
||||
return { companies, issues, companyIds: [...new Set(roots.map(root => companies.get(root.organization.id)!))] };
|
||||
}
|
||||
|
||||
export function validateTopNodes(data: OrgChartDataset, roots: OrganizationTreeNode[], companies: ReadonlyMap<string, string>, configs: Readonly<Record<string, OrgChartTopNode>>): ValidationIssue[] {
|
||||
const issues: ValidationIssue[] = [];
|
||||
for (const [id, config] of Object.entries(configs)) {
|
||||
const path = `companyTopNodes.${id}`;
|
||||
if (!getCompanyColorPreset(id) || !config || typeof config !== 'object' || Array.isArray(config)) {
|
||||
issues.push({ code: 'TOP_NODE_CONFIG', path, message: 'Expected a registered company and top node object.' }); continue;
|
||||
}
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
const valid = key === 'display' ? ['card', 'titleOnly', 'hidden'].includes(value as string)
|
||||
: ['employeeId', 'profileImage'].includes(key) && value === null ? true
|
||||
: ['employeeId', 'profileImage', 'title', 'name', 'organizationName'].includes(key) && typeof value === 'string' && !!value.trim() && !/[<>\u0000-\u001f]/u.test(value);
|
||||
if (!valid || (key === 'profileImage' && typeof value === 'string' && !isSafeImageUrl(value))) issues.push({ code: 'TOP_NODE_CONFIG', path: `${path}.${key}`, message: 'Invalid top node presentation value.' });
|
||||
}
|
||||
const companyRoots = roots.filter(root => companies.get(root.organization.id) === id);
|
||||
if (config.employeeId && companyRoots.length && !companyRoots.some(root => data.employees.some(employee => employee.id === config.employeeId && employee.organizationId === root.organization.id))) {
|
||||
issues.push({ code: 'TOP_NODE_REFERENCE', path, message: 'Top employee must belong to a root of this company.' });
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
@@ -1,47 +1,99 @@
|
||||
import { createEmployeeCard } from './employee-card.js';
|
||||
import { createHeading, type RenderContext } from './render.js';
|
||||
import { representativeCardAsset } from './representative-card-asset.js';
|
||||
import type { RenderEngineNode } from './engine.js';
|
||||
import { getCompanyColorPreset } from '../company-color-presets.js';
|
||||
import { alignExpandedLevels } from './expanded-layout.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 {
|
||||
/** Auto-only presenter. The source and popup identity remain unchanged. */
|
||||
export function decorateExpandedNode(node: HTMLElement, value: RenderEngineNode, context: RenderContext, signal: AbortSignal): void {
|
||||
const organization = context.orderedOrganizations[Number(value.organizationIndex)]!;
|
||||
const companyId = context.organizationCompanies!.get(organization.id)!;
|
||||
const preset = getCompanyColorPreset(companyId)!;
|
||||
const document = node.ownerDocument;
|
||||
const article = node.querySelector<HTMLElement>('.hm-orgchart-organization')!;
|
||||
const collapsed = context.collapsedOrganizations!.has(organization.id);
|
||||
const top = organization.parentId === null ? context.companyTopNodes?.[companyId] : undefined;
|
||||
node.dataset.organizationId = organization.id;
|
||||
node.dataset.parentId = organization.parentId ?? '';
|
||||
node.dataset.depth = node.dataset.organizationDepth = String(value.depth);
|
||||
node.dataset.organizationType = organization.type;
|
||||
node.dataset.companyId = companyId;
|
||||
node.dataset.collapsed = String(collapsed);
|
||||
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));
|
||||
for (const edge of node.querySelectorAll('.edge')) edge.setAttribute('aria-hidden', 'true');
|
||||
const leaderId = top?.employeeId === undefined ? organization.leaderId : top.employeeId;
|
||||
const leader = context.data.employees.find(employee => employee.id === leaderId && employee.organizationId === organization.id);
|
||||
const staff = context.data.presentation?.groups.showEmployees === false ? [] : (context.employeesByOrganization.get(organization.id) ?? []).filter(employee => employee.id !== leaderId);
|
||||
const hasChildren = context.data.organizations.some(org => org.parentId === organization.id) || staff.length > 0;
|
||||
const heading = document.createElement('h3');
|
||||
heading.className = 'hm-orgchart-organization-name';
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
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-expanded', String(!collapsed));
|
||||
button.disabled = !hasChildren;
|
||||
button.addEventListener('click', event => { event.stopPropagation(); context.toggleExpanded?.(organization.id); }, { signal });
|
||||
heading.append(button);
|
||||
const href = context.organizationLinks?.[organization.id];
|
||||
if (href) {
|
||||
const link = document.createElement('a');
|
||||
link.className = 'hm-orgchart-expanded-link';
|
||||
link.href = href;
|
||||
link.textContent = '↗';
|
||||
link.setAttribute('aria-label', `${organization.name} 페이지 이동`);
|
||||
link.addEventListener('click', event => event.stopPropagation(), { signal });
|
||||
heading.append(link);
|
||||
}
|
||||
article.append(heading);
|
||||
const colorCard = (card: HTMLElement, affiliationId: string) => {
|
||||
const employeePreset = getCompanyColorPreset(affiliationId) ?? preset;
|
||||
card.dataset.companyId = employeePreset.companyId;
|
||||
card.style.setProperty('--hm-orgchart-expanded-card-primary', `var(--hm-orgchart-company-${employeePreset.companyId})`);
|
||||
return card;
|
||||
};
|
||||
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';
|
||||
article.append(card);
|
||||
} else if (leader && top?.display === 'titleOnly') {
|
||||
const title = document.createElement('p');
|
||||
title.className = 'hm-orgchart-top-node-title';
|
||||
title.textContent = `${top.title ?? leader.position} · ${top.name ?? leader.name}`;
|
||||
article.append(title);
|
||||
}
|
||||
if (!collapsed && staff.length) {
|
||||
const employees = document.createElement('div');
|
||||
employees.className = 'hm-orgchart-employees';
|
||||
const direction = context.employeeConnections?.[organization.id] ?? 'top';
|
||||
employees.dataset.connectionDirection = direction;
|
||||
for (const employee of staff) {
|
||||
const card = colorCard(createEmployeeCard(document, employee, { kind: 'employee', expandedPresentation: true, hideStatusLabel: true }, signal), employee.affiliationId);
|
||||
card.dataset.connectionDirection = direction;
|
||||
employees.append(card);
|
||||
}
|
||||
article.append(employees);
|
||||
}
|
||||
}
|
||||
|
||||
export function createExpandedOrgChartContent(document: Document, context: RenderContext, tree: HTMLElement, signal: AbortSignal, layoutReady: () => void): 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));
|
||||
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)])) {
|
||||
const preset = getCompanyColorPreset(company);
|
||||
if (preset) content.style.setProperty(`--hm-orgchart-company-${company}`, preset.primary);
|
||||
}
|
||||
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);
|
||||
tree.setAttribute('aria-label', '확장형 조직 계층도 · Ctrl 또는 Command + 휠로 확대 · 드래그로 이동');
|
||||
content.append(createHeading(document, context), tree);
|
||||
alignExpandedLevels(tree, signal, layoutReady);
|
||||
return content;
|
||||
}
|
||||
@@ -1,29 +1,31 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { expandedPreviewRoots } from './expanded-layout.js';
|
||||
import { createOrgChart } from './index.js';
|
||||
import { engineTree } from './render.js';
|
||||
import type { RenderContext } from './render.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,
|
||||
const node = (id: string, parentId: string | null, children: OrganizationTreeNode[] = []): OrganizationTreeNode => ({
|
||||
organization: { id, parentId, name: id, type: parentId ? 'office' : 'root', 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])];
|
||||
test('Auto engine projection preserves root, nested ancestry, sibling order and depths without mutation', () => {
|
||||
const roots = [node('root', null, [node('first', 'root', [node('cell', 'first', [node('team', 'cell')])]), node('second', 'root')])];
|
||||
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, []);
|
||||
const context = { orderedOrganizations: [], readOnly: true } as unknown as RenderContext;
|
||||
const result = engineTree(roots, 'test', context);
|
||||
assert.equal(result[0]?.depth, 0);
|
||||
assert.equal(result[0]?.children?.[0]?.children?.[0]?.children?.[0]?.depth, 3);
|
||||
assert.deepEqual(context.orderedOrganizations.map(item => item.id), ['root', 'first', 'cell', 'team', 'second']);
|
||||
assert.deepEqual(result[0]?.children?.map(item => item.organizationIndex), [1, 4]);
|
||||
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([]), []);
|
||||
test('independent roots remain independent and empty hierarchy stays empty', () => {
|
||||
const context = { orderedOrganizations: [], readOnly: true } as unknown as RenderContext;
|
||||
const roots = [node('first', null), node('second', null)];
|
||||
assert.equal(engineTree(roots, 'test', context).length, 2);
|
||||
assert.deepEqual(engineTree([], 'empty', context), []);
|
||||
});
|
||||
|
||||
const options = { dataSource: { load: async () => ({}) }, stylesheetUrls: { engine: '/engine.css', core: '/style.css' } };
|
||||
|
||||
@@ -1,10 +1,85 @@
|
||||
import type { OrganizationTreeNode } from '../data/index.js';
|
||||
/** 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 {
|
||||
const view = viewport.ownerDocument.defaultView;
|
||||
if (!view || signal.aborted) return;
|
||||
const nodes = [...viewport.querySelectorAll<HTMLElement>('.node[data-depth]')];
|
||||
let frame = 0;
|
||||
const align = () => {
|
||||
frame = 0;
|
||||
if (signal.aborted) return;
|
||||
const heights = 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)));
|
||||
}
|
||||
for (const node of nodes) {
|
||||
node.style.minHeight = `${heights.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);
|
||||
layoutReady();
|
||||
};
|
||||
const schedule = () => {
|
||||
if (!frame && !signal.aborted) frame = view.requestAnimationFrame(align);
|
||||
};
|
||||
const observer = new view.ResizeObserver(schedule);
|
||||
for (const node of nodes) {
|
||||
const article = node.querySelector('.hm-orgchart-organization');
|
||||
if (article) observer.observe(article);
|
||||
}
|
||||
signal.addEventListener('abort', () => {
|
||||
observer.disconnect();
|
||||
if (frame) view.cancelAnimationFrame(frame);
|
||||
}, { once: true });
|
||||
schedule();
|
||||
}
|
||||
|
||||
/** 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: [] }));
|
||||
/** 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 {
|
||||
const document = viewport.ownerDocument;
|
||||
for (const article of viewport.querySelectorAll<HTMLElement>('.hm-orgchart-organization')) {
|
||||
article.querySelector('.hm-orgchart-employee-connectors')?.remove();
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.classList.add('hm-orgchart-employee-connectors');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
svg.setAttribute('width', String(article.offsetWidth));
|
||||
svg.setAttribute('height', String(article.offsetHeight));
|
||||
let previous = article.querySelector<HTMLElement>('.hm-orgchart-top-node-title') ?? article.querySelector<HTMLElement>('.hm-orgchart-organization-name')!;
|
||||
const local = (element: HTMLElement) => {
|
||||
let x = 0, y = 0, current: HTMLElement | null = element;
|
||||
while (current && current !== article) { x += current.offsetLeft; y += current.offsetTop; current = current.offsetParent as HTMLElement | null; }
|
||||
return { x, y, width: element.offsetWidth, height: element.offsetHeight };
|
||||
};
|
||||
const heading = local(previous);
|
||||
let source = { x: heading.x + heading.width / 2, y: heading.y + heading.height };
|
||||
let staffSource = source;
|
||||
for (const card of article.querySelectorAll<HTMLElement>('.hm-orgchart-employee')) {
|
||||
const rect = local(card);
|
||||
const direction = card.dataset.connectionDirection;
|
||||
const target = direction === 'left' ? { x: rect.x, y: rect.y + rect.height / 2 }
|
||||
: direction === 'right' ? { x: rect.x + rect.width, y: rect.y + rect.height / 2 }
|
||||
: { x: rect.x + rect.width / 2, y: rect.y };
|
||||
const path = document.createElementNS(svg.namespaceURI, 'path');
|
||||
if (direction === 'left' || direction === 'right') {
|
||||
const rail = direction === 'left' ? rect.x / 2 : rect.x + rect.width + (article.offsetWidth - rect.x - rect.width) / 2;
|
||||
const bend = staffSource.y + (rect.y - staffSource.y) / 2;
|
||||
path.setAttribute('d', `M ${staffSource.x} ${staffSource.y} V ${bend} H ${rail} V ${target.y} H ${target.x}`);
|
||||
// All staff branch off the same rail above the list, avoiding crossings.
|
||||
staffSource = { x: rail, y: bend };
|
||||
} else {
|
||||
path.setAttribute('d', `M ${source.x} ${source.y} V ${(source.y + target.y) / 2} H ${target.x} V ${target.y}`);
|
||||
}
|
||||
path.setAttribute('data-employee-id', card.dataset.employeeId!);
|
||||
path.setAttribute('data-direction', direction ?? 'top');
|
||||
path.setAttribute('data-end-x', String(target.x));
|
||||
path.setAttribute('data-end-y', String(target.y));
|
||||
svg.append(path);
|
||||
source = { x: rect.x + rect.width / 2, y: rect.y + rect.height };
|
||||
if (!card.closest('.hm-orgchart-employees')) staffSource = source;
|
||||
}
|
||||
article.prepend(svg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { OrgChartViewportState } from './types.js';
|
||||
|
||||
export interface ExpandedViewport {
|
||||
zoomTo(scale: number, anchor?: { x: number; y: number }): void;
|
||||
panBy(x: number, y: number): void;
|
||||
fit(): void;
|
||||
state(): OrgChartViewportState;
|
||||
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 {
|
||||
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 drag: { id: number; x: number; y: number; moved: boolean } | undefined;
|
||||
let suppressClick = false;
|
||||
let focusFrame = 0;
|
||||
const output = document.createElement('output');
|
||||
output.setAttribute('aria-label', '현재 배율');
|
||||
output.setAttribute('aria-live', 'polite');
|
||||
const buttons: HTMLButtonElement[] = [];
|
||||
const apply = () => {
|
||||
forest.style.transform = `translate(${x}px, ${y}px) scale(${scale})`;
|
||||
viewport.dataset.scale = String(scale);
|
||||
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;
|
||||
};
|
||||
const clamp = (value: number) => Math.max(config.min, Math.min(config.max, value));
|
||||
const controller: ExpandedViewport = {
|
||||
zoomTo(next, anchor = { x: viewport.clientWidth / 2, y: viewport.clientHeight / 2 }) {
|
||||
if (!Number.isFinite(next)) return;
|
||||
initialFit = false;
|
||||
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(); },
|
||||
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();
|
||||
},
|
||||
state: () => ({ scale, x, y }),
|
||||
layoutReady() { if (initialFit) controller.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(); }],
|
||||
];
|
||||
toolbar.setAttribute('role', 'group');
|
||||
toolbar.setAttribute('aria-label', '조직도 확대·이동 도구');
|
||||
for (const [label, action] of actions) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button'; button.textContent = label;
|
||||
button.addEventListener('click', action, { signal });
|
||||
buttons.push(button); toolbar.append(button);
|
||||
}
|
||||
toolbar.append(output);
|
||||
const hint = document.createElement('span');
|
||||
hint.textContent = '드래그 이동 · Ctrl/⌘ + 휠 확대 · 방향키 이동';
|
||||
toolbar.append(hint);
|
||||
viewport.addEventListener('wheel', event => {
|
||||
if (!event.ctrlKey && !event.metaKey) return;
|
||||
event.preventDefault();
|
||||
const box = viewport.getBoundingClientRect();
|
||||
controller.zoomTo(scale * Math.exp(-event.deltaY * 0.002), { x: event.clientX - box.left, y: event.clientY - box.top });
|
||||
}, { passive: false, signal });
|
||||
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]!); }
|
||||
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.
|
||||
viewport.addEventListener('focusin', event => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof view.HTMLElement) || !forest.contains(target)) return;
|
||||
if (focusFrame) view.cancelAnimationFrame(focusFrame);
|
||||
focusFrame = view.requestAnimationFrame(() => {
|
||||
focusFrame = 0;
|
||||
if (signal.aborted || !target.isConnected) return;
|
||||
viewport.scrollLeft = viewport.scrollTop = 0;
|
||||
const outer = viewport.getBoundingClientRect(), box = target.getBoundingClientRect();
|
||||
const left = outer.left + viewport.clientLeft + 8, top = outer.top + viewport.clientTop + 8;
|
||||
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);
|
||||
});
|
||||
}, { signal });
|
||||
viewport.addEventListener('pointerdown', event => {
|
||||
if (event.button !== 0 || event.isPrimary === false || (event.target as Element).closest('button,a')) return;
|
||||
suppressClick = false;
|
||||
drag = { id: event.pointerId, x: event.clientX, y: event.clientY, moved: false };
|
||||
}, { signal });
|
||||
viewport.addEventListener('pointermove', event => {
|
||||
if (!drag || drag.id !== event.pointerId) return;
|
||||
const dx = event.clientX - drag.x, dy = event.clientY - drag.y;
|
||||
if (!drag.moved && Math.hypot(dx, dy) < 6) return;
|
||||
if (!drag.moved) { viewport.setPointerCapture(event.pointerId); viewport.dataset.dragging = 'true'; }
|
||||
drag.moved = true;
|
||||
controller.panBy(dx, dy); drag.x = event.clientX; drag.y = event.clientY;
|
||||
}, { signal });
|
||||
const finish = (event: PointerEvent) => {
|
||||
if (!drag || drag.id !== event.pointerId) return;
|
||||
suppressClick = drag.moved;
|
||||
if (viewport.hasPointerCapture(event.pointerId)) viewport.releasePointerCapture(event.pointerId);
|
||||
drag = undefined; delete viewport.dataset.dragging;
|
||||
};
|
||||
viewport.addEventListener('pointerup', finish, { signal });
|
||||
viewport.addEventListener('pointercancel', finish, { signal });
|
||||
viewport.addEventListener('lostpointercapture', () => { 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 => {
|
||||
if (!suppressClick) return;
|
||||
suppressClick = false;
|
||||
// A drag suppresses its pointer click, never a later Enter/Space activation.
|
||||
if (event.detail === 0) return;
|
||||
event.preventDefault(); event.stopImmediatePropagation();
|
||||
}, { capture: true, signal });
|
||||
const observer = new view.ResizeObserver(() => { if (initialFit) controller.fit(); });
|
||||
observer.observe(viewport);
|
||||
signal.addEventListener('abort', () => { observer.disconnect(); drag = undefined; if (focusFrame) view.cancelAnimationFrame(focusFrame); }, { once: true });
|
||||
apply();
|
||||
return controller;
|
||||
}
|
||||
@@ -6,8 +6,9 @@ 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 { resolveExpandedCompanies, validateTopNodes } from './expanded-company.js';
|
||||
import { createExpandedViewport, type ExpandedViewport } from './expanded-viewport.js';
|
||||
import { createPositionSummary } from './position-summary.js';
|
||||
import { createAffiliationSummary } from './affiliation-summary.js';
|
||||
import { createStatusSummary } from './status-summary.js';
|
||||
@@ -55,6 +56,22 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
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 topNodes = expanded ? freeze(structuredClone(options.companyTopNodes ?? {})) : {};
|
||||
const employeeConnections = expanded ? freeze(structuredClone(options.employeeConnections ?? {})) : {};
|
||||
if (Object.values(employeeConnections).some(value => !['top', 'left', 'right'].includes(value))) throw new TypeError('employeeConnections must use top, left or right');
|
||||
const zoom = { min: options.zoom?.min ?? 0.05, max: options.zoom?.max ?? 3, step: options.zoom?.step ?? 1.2 };
|
||||
if (expanded && (!Number.isFinite(zoom.min) || !Number.isFinite(zoom.max) || !Number.isFinite(zoom.step) || zoom.min <= 0 || zoom.min > 1 || zoom.max < 1 || zoom.min >= zoom.max || zoom.step <= 1)) throw new TypeError('zoom requires 0 < min <= 1 <= max and step > 1');
|
||||
const copySelection = (ids?: readonly string[]) => {
|
||||
if (ids !== undefined && (!Array.isArray(ids) || ids.some(id => typeof id !== 'string' || !getCompanyColorPreset(id)))) throw new TypeError('selectedCompanyIds must contain registered company IDs');
|
||||
return ids === undefined ? undefined : [...new Set(ids)];
|
||||
};
|
||||
let selectedCompanies = expanded ? copySelection(options.selectedCompanyIds) : undefined;
|
||||
let availableCompanies: string[] = [];
|
||||
let latestData: unknown;
|
||||
const collapsedOrganizations = 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');
|
||||
@@ -134,6 +151,8 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
engine = undefined;
|
||||
renderContext = undefined;
|
||||
viewport = undefined;
|
||||
expandedViewport = undefined;
|
||||
latestData = undefined;
|
||||
content?.replaceChildren();
|
||||
}
|
||||
|
||||
@@ -206,18 +225,36 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
if (!validation.valid) { fail(version, validation.issues); return; }
|
||||
const data = freeze(validation.data);
|
||||
const normalized = normalizeOrgChartDataset(data);
|
||||
const companyState = expanded ? resolveExpandedCompanies(data, normalized.roots, expandedCompanyPreset!.companyId) : undefined;
|
||||
if (companyState) {
|
||||
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);
|
||||
latestData = 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 };
|
||||
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;
|
||||
if (collapsedOrganizations.has(id)) collapsedOrganizations.delete(id); else collapsedOrganizations.add(id);
|
||||
focusOrganization = id; fitNext = false;
|
||||
const { version } = begin();
|
||||
void apply(latestData, version).catch(() => fail(version, [{ code: 'CORE_RENDER', path: 'core', message: 'Expanded rerender failed.' }]));
|
||||
},
|
||||
};
|
||||
// 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 roots = expanded ? expandedPreviewRoots(normalized.roots)
|
||||
const roots = expanded ? normalized.roots.filter(node => selectedCompanies === undefined || selectedCompanies.includes(companyState!.companies.get(node.organization.id)!))
|
||||
: flattened ? normalized.roots.flatMap(node => flattenRoot(node) ? node.children : [node]) : normalized.roots;
|
||||
const tree = engineTree(roots, prefix, nextContext, expanded || flattened ? 1 : 0);
|
||||
const tree = engineTree(roots, prefix, nextContext, !expanded && flattened ? 1 : 0);
|
||||
const styleIssues = await styleReady;
|
||||
if (!active(version)) return;
|
||||
if (styleIssues) { fail(version, styleIssues); return; }
|
||||
@@ -226,6 +263,7 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
if (!active(version)) return;
|
||||
if (LoadedEngine) Engine = LoadedEngine;
|
||||
const document = content!.ownerDocument;
|
||||
const previousViewport = expandedViewport?.state();
|
||||
employeeDialog?.close();
|
||||
contentGeneration?.abort();
|
||||
const Abort = document.defaultView?.AbortController ?? AbortController;
|
||||
@@ -244,16 +282,39 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
}
|
||||
// If regeneration removed keyboard focus, offer the stable viewport next.
|
||||
const nextContent = view === 'composition' ? expanded
|
||||
? createExpandedOrgChartContent(document, nextContext, viewport!, contentGeneration.signal)
|
||||
? createExpandedOrgChartContent(document, nextContext, viewport!, contentGeneration.signal, () => expandedViewport?.layoutReady())
|
||||
: createOrgChartContent(document, nextContext, viewport!, contentGeneration.signal)
|
||||
: view === 'tree' ? viewport!
|
||||
: view === 'positionSummary' ? createPositionSummary(document, nextContext)
|
||||
: view === 'affiliationSummary' ? createAffiliationSummary(document, nextContext)
|
||||
: createStatusSummary(document, nextContext, contentGeneration.signal);
|
||||
if (expanded) {
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'hm-orgchart-expanded-toolbar';
|
||||
nextContent.insertBefore(toolbar, viewport!);
|
||||
expandedViewport = createExpandedViewport(viewport!, toolbar, contentGeneration.signal, zoom, previousViewport, fitNext);
|
||||
fitNext = false;
|
||||
const selection = document.createElement('p');
|
||||
selection.className = 'hm-orgchart-expanded-selection';
|
||||
selection.setAttribute('aria-live', 'polite');
|
||||
const selected = new Set(roots.map(root => companyState!.companies.get(root.organization.id)!));
|
||||
const employeeCount = data.employees.filter(employee => selected.has(companyState!.companies.get(employee.organizationId)!)).length;
|
||||
selection.textContent = `선택 ${selected.size}개 회사 · ${employeeCount}명 / 원본 ${data.employees.length}명`;
|
||||
nextContent.insertBefore(selection, toolbar);
|
||||
if (!roots.length) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'hm-orgchart-expanded-empty';
|
||||
empty.textContent = '선택된 회사가 없습니다. 표시할 회사를 선택해 주세요.';
|
||||
viewport!.append(empty);
|
||||
} else viewport!.querySelector('.hm-orgchart-expanded-empty')?.remove();
|
||||
}
|
||||
prepareEmployeeCards(nextContent);
|
||||
content!.replaceChildren(nextContent);
|
||||
const restoreFocus = !!activeElement && (activeElement === viewport || !activeElement.isConnected);
|
||||
if (restoreFocus) (readOnly ? shell! : viewport ?? content!.querySelector<HTMLElement>('.hm-orgchart-summary-scroll') ?? shell!).focus({ preventScroll: true });
|
||||
const focusTarget = focusOrganization ? [...content!.querySelectorAll<HTMLButtonElement>('.hm-orgchart-expanded-toggle')].find(button => button.dataset.organizationId === focusOrganization) : 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 });
|
||||
shell!.setAttribute('aria-busy', 'false');
|
||||
status!.textContent = '';
|
||||
if (!active(version)) return;
|
||||
@@ -262,6 +323,7 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
|
||||
async function reload(nextSource?: OrgChartDataSource): Promise<void> {
|
||||
assertMounted();
|
||||
if (expanded) fitNext = true;
|
||||
if (nextSource) {
|
||||
if (typeof nextSource.load !== 'function') throw new TypeError('An OrgChartDataSource is required');
|
||||
source = nextSource;
|
||||
@@ -375,5 +437,16 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or
|
||||
return () => { listeners.delete(listener); };
|
||||
},
|
||||
getState() { return state; },
|
||||
async setSelectedCompanies(ids) {
|
||||
assertMounted();
|
||||
if (!expanded) throw new Error('Company selection is available only in Auto mode');
|
||||
selectedCompanies = copySelection(ids); fitNext = true;
|
||||
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); },
|
||||
fitToView() { expandedViewport?.fit(); },
|
||||
getViewportState() { return expandedViewport?.state(); },
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { applyOrganizationStyle, organizationStylePresets } from './style-preset
|
||||
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';
|
||||
|
||||
const organizationNames = { root: '최상위 조직', division: '부문', office: '실', div: 'Div', cell: '셀', team: '팀' } as const;
|
||||
|
||||
@@ -19,6 +21,11 @@ export interface RenderContext {
|
||||
organizationLinks?: OrgChartOrganizationLinks;
|
||||
/** Only the Expanded presenter supplies company header colors. */
|
||||
expandedCompanyPreset?: CompanyColorPreset;
|
||||
organizationCompanies?: ReadonlyMap<string, string>;
|
||||
collapsedOrganizations?: ReadonlySet<string>;
|
||||
companyTopNodes?: Readonly<Record<string, OrgChartTopNode>>;
|
||||
employeeConnections?: Readonly<Record<string, OrgChartConnectionDirection>>;
|
||||
toggleExpanded?: (organizationId: string) => void;
|
||||
}
|
||||
|
||||
export function engineTree(roots: OrganizationTreeNode[], prefix: string, context: RenderContext, startDepth = 0): RenderEngineNode[] {
|
||||
@@ -28,7 +35,7 @@ export function engineTree(roots: OrganizationTreeNode[], prefix: string, contex
|
||||
context.orderedOrganizations.push(tree.organization);
|
||||
return {
|
||||
id: `${prefix}-org-${index}`, name: '', organizationIndex: index, depth, groupVariant: variant,
|
||||
...(tree.children.length ? { children: tree.children.map((child, childIndex) => convert(child, depth + 1, context.readOnly ? variant : depth === startDepth ? childIndex % 6 : variant)) } : {}),
|
||||
...(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)) } : {}),
|
||||
};
|
||||
}
|
||||
// Multiple actual roots stay independent; no fictitious organizational parent.
|
||||
@@ -48,6 +55,10 @@ export function decorateNode(
|
||||
): void {
|
||||
const organization = context.orderedOrganizations[Number(value.organizationIndex)];
|
||||
if (!organization) return;
|
||||
if (context.expandedCompanyPreset) {
|
||||
decorateExpandedNode(node, value, context, signal);
|
||||
return;
|
||||
}
|
||||
const document = node.ownerDocument;
|
||||
const card = node.querySelector('.hm-orgchart-organization')!;
|
||||
node.dataset.organizationId = organization.id;
|
||||
@@ -64,14 +75,6 @@ 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,13 +10,25 @@ 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. */
|
||||
/** Layout is independent of displayMode. Auto uses the complete hierarchy
|
||||
* with natural overflow; Fixed preserves the Workdesk composition. */
|
||||
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>>;
|
||||
|
||||
export type OrgChartConnectionDirection = 'top' | 'left' | 'right';
|
||||
export interface OrgChartTopNode {
|
||||
/** Defaults to the root's leaderId. null removes its top card. Must belong to that root. */
|
||||
employeeId?: string | null;
|
||||
title?: string;
|
||||
name?: string;
|
||||
profileImage?: string | null;
|
||||
organizationName?: string;
|
||||
display?: 'card' | 'titleOnly' | 'hidden';
|
||||
}
|
||||
export interface OrgChartViewportState { scale: number; x: number; y: number }
|
||||
|
||||
export interface OrgChartEmployeeClickContext {
|
||||
readonly organization: Organization | undefined;
|
||||
readonly affiliation: Affiliation | undefined;
|
||||
@@ -38,6 +50,12 @@ export interface OrgChartOptions {
|
||||
/** Expanded-only company preset ID. Defaults to hanmac; ignored by Fixed.
|
||||
* Unknown IDs are rejected in Auto rather than silently replacing colors. */
|
||||
companyId?: string;
|
||||
/** Auto only. undefined means all companies; [] is an explicit empty selection. */
|
||||
selectedCompanyIds?: readonly string[];
|
||||
companyTopNodes?: Readonly<Record<string, OrgChartTopNode>>;
|
||||
/** Actual staff connector route, default top. Leaders always connect from above. */
|
||||
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. */
|
||||
organizationLinks?: OrgChartOrganizationLinks;
|
||||
/** Optional host-owned information UI. When supplied, it replaces the default read-only dialog.
|
||||
@@ -54,4 +72,11 @@ export interface OrgChartHandle {
|
||||
destroy(): void;
|
||||
subscribe(listener: (state: OrgChartState) => void): () => void;
|
||||
getState(): OrgChartState;
|
||||
/** Auto view state is separate from the immutable source. Fixed rejects selection changes. */
|
||||
setSelectedCompanies(ids?: readonly string[]): Promise<void>;
|
||||
getSelectedCompanies(): readonly string[];
|
||||
zoomTo(scale: number): void;
|
||||
panBy(x: number, y: number): void;
|
||||
fitToView(): void;
|
||||
getViewportState(): OrgChartViewportState | undefined;
|
||||
}
|
||||
@@ -6,6 +6,8 @@ export type RelationType = 'cooperation' | 'support';
|
||||
|
||||
export interface Organization {
|
||||
id: ID;
|
||||
/** Auto company identity. Descendants inherit it; legacy datasets may omit it. */
|
||||
companyId?: ID;
|
||||
parentId: ID | null;
|
||||
name: string;
|
||||
type: OrganizationType;
|
||||
|
||||
@@ -65,7 +65,8 @@ export function validateOrgChartDataset(input: unknown): ValidationResult {
|
||||
};
|
||||
if (!object(input, '$', ['schemaVersion', 'organizations', 'employees', 'affiliations', 'relations', 'presentation', 'metadata'])) return { valid: false, issues };
|
||||
if (input.schemaVersion !== SCHEMA_VERSION) issue('SCHEMA_VERSION', '$.schemaVersion', 'Unsupported or missing schema version.');
|
||||
collection(input.organizations, '$.organizations', ['id', 'parentId', 'name', 'type', 'leaderId', 'displayOrder'], (org, path) => {
|
||||
collection(input.organizations, '$.organizations', ['id', 'companyId', 'parentId', 'name', 'type', 'leaderId', 'displayOrder'], (org, path) => {
|
||||
if (org.companyId !== undefined) text(org.companyId, `${path}.companyId`);
|
||||
text(org.parentId, `${path}.parentId`, true); text(org.name, `${path}.name`); text(org.leaderId, `${path}.leaderId`, true);
|
||||
choice(org.type, `${path}.type`, organizationTypes, 'INVALID_TYPE');
|
||||
if (!Number.isSafeInteger(org.displayOrder) || (org.displayOrder as number) < 0) issue('REQUIRED_VALUE', `${path}.displayOrder`, 'Expected a non-negative safe integer.');
|
||||
|
||||
@@ -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 { OrgChartDisplayMode, OrgChartLayoutMode, OrgChartEmployeeClickContext, OrgChartHandle, OrgChartOptions, OrgChartOrganizationLinks, OrgChartState } from './core/types.js';
|
||||
export type { 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';
|
||||
@@ -1,92 +1,195 @@
|
||||
/* P5-1A flow preview only. Every rule is under the new presenter namespace;
|
||||
the fixed Workdesk composition and approved card internals stay unchanged.
|
||||
Ordinary flex wrapping is not the P5-1B automatic hierarchy layout. */
|
||||
/* Auto-only layout. P5-4 retains the current card geometry by user decision;
|
||||
no external SVG reconstruction is required for this presentation. */
|
||||
.hm-orgchart-expanded {
|
||||
--hm-orgchart-expanded-sibling-gap: var(--hm-orgchart-resolved-spacing-xxl);
|
||||
--hm-orgchart-expanded-level-gap: calc(2 * var(--hm-orgchart-resolved-spacing-xxl));
|
||||
--hm-orgchart-expanded-title-gap: var(--hm-orgchart-resolved-spacing-md);
|
||||
--hm-orgchart-expanded-employee-gap: var(--hm-orgchart-resolved-spacing-md);
|
||||
--hm-orgchart-expanded-line-width: var(--hm-orgchart-resolved-border-width);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--hm-orgchart-resolved-spacing-sm);
|
||||
gap: var(--hm-orgchart-resolved-spacing-lg);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: var(--hm-orgchart-resolved-spacing-lg);
|
||||
background: var(--hm-orgchart-resolved-bg-content);
|
||||
}
|
||||
.hm-orgchart-expanded .hm-orgchart-heading { margin: 0; }
|
||||
.hm-orgchart-expanded .hm-orgchart-expanded-stage-note {
|
||||
margin: 0;
|
||||
color: var(--hm-orgchart-resolved-fg-primary);
|
||||
font-weight: var(--hm-orgchart-resolved-font-weight-bold);
|
||||
}
|
||||
.hm-orgchart-expanded .hm-orgchart-expanded-description {
|
||||
margin: 0;
|
||||
color: var(--hm-orgchart-resolved-fg-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.hm-orgchart-expanded .hm-orgchart-expanded-top-leaders {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--hm-orgchart-resolved-spacing-lg);
|
||||
padding: var(--hm-orgchart-resolved-spacing-lg) 0;
|
||||
}
|
||||
.hm-orgchart-expanded .hm-orgchart-viewport {
|
||||
padding: var(--hm-orgchart-resolved-spacing-lg);
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
height: clamp(320px, 65vh, 800px);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
background: var(--hm-orgchart-resolved-bg-surface);
|
||||
}
|
||||
.hm-orgchart-expanded .hm-orgchart-forest {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: max-content;
|
||||
min-width: 0;
|
||||
transform-origin: 0 0;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: var(--hm-orgchart-resolved-spacing-xxl);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: var(--hm-orgchart-expanded-sibling-gap);
|
||||
}
|
||||
.hm-orgchart-expanded .hm-orgchart-tree { flex: 0 0 auto; }
|
||||
.hm-orgchart-expanded .hm-orgchart-engine.orgchart {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
.hm-orgchart-expanded .hm-orgchart-engine.orgchart { min-width: 0; padding: 0; }
|
||||
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth]) > .nodes {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] {
|
||||
vertical-align: top;
|
||||
width: max-content;
|
||||
min-width: 146px;
|
||||
margin: 0;
|
||||
min-width: var(--hm-orgchart-resolved-card-width-leader);
|
||||
margin: 0 calc(var(--hm-orgchart-expanded-sibling-gap) / 2) var(--hm-orgchart-expanded-level-gap);
|
||||
}
|
||||
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-organization {
|
||||
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] > .hm-orgchart-organization {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: auto;
|
||||
gap: var(--hm-orgchart-expanded-title-gap);
|
||||
width: max-content;
|
||||
min-width: var(--hm-orgchart-resolved-card-width-leader);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
position: relative;
|
||||
}
|
||||
/* Same approved 1Depth title geometry; color resolves from the auto-only
|
||||
company variables supplied by the shared node decorator. */
|
||||
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-organization-name {
|
||||
align-self: stretch;
|
||||
min-width: 146px;
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
margin: 0;
|
||||
padding: 0 12px;
|
||||
background: var(--hm-orgchart-current-gradient);
|
||||
color: var(--hm-orgchart-resolved-on-action-primary);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 36px;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
background: var(--hm-orgchart-expanded-primary);
|
||||
color: var(--hm-orgchart-expanded-title-ink);
|
||||
font-size: var(--hm-orgchart-resolved-font-size-base);
|
||||
font-weight: var(--hm-orgchart-resolved-font-weight-bold);
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee { margin: 0; }
|
||||
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee.hm-orgchart-leader { margin: 8px 0 0; }
|
||||
.hm-orgchart-expanded .hm-orgchart-organization-link { color: inherit; }
|
||||
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employees {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
gap: var(--hm-orgchart-expanded-employee-gap);
|
||||
margin: 0;
|
||||
}
|
||||
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employees:empty { display: none; }
|
||||
/* Shared neutral cards, isolated from Fixed asset backgrounds and geometry. */
|
||||
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-card-presentation="expanded"] {
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
border: var(--hm-orgchart-expanded-card-border-width, var(--hm-orgchart-resolved-border-width)) solid var(--hm-orgchart-resolved-stroke-default);
|
||||
background: var(--hm-orgchart-resolved-bg-surface);
|
||||
color: var(--hm-orgchart-resolved-fg-primary);
|
||||
}
|
||||
.hm-orgchart-expanded .hm-orgchart-employee[data-card-presentation="expanded"]::before { content: none; }
|
||||
.hm-orgchart-expanded .hm-orgchart-employee[data-card-presentation="expanded"] .hm-orgchart-avatar { border-radius: 0; }
|
||||
.hm-orgchart-expanded .hm-orgchart-person-text { display: flex; flex-direction: column; gap: 0; }
|
||||
.hm-orgchart-expanded .hm-orgchart-employee::after { content: none; }
|
||||
/* Keep dabeng's first/last/only-child horizontal branch geometry. Only change
|
||||
connector length and offsets to agree with the Auto vertical gap. */
|
||||
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth]) > .nodes > .hierarchy::before {
|
||||
top: calc(-1 * var(--hm-orgchart-expanded-level-gap) / 2);
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
border-width: var(--hm-orgchart-expanded-line-width) 0 0;
|
||||
}
|
||||
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth]) > .nodes > .hierarchy:first-child::before { left: calc(50% - 1px); width: calc(50% + 1px); }
|
||||
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth]) > .nodes > .hierarchy:last-child::before { width: calc(50% + 1px); }
|
||||
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth]) > .nodes > .hierarchy:only-child::before { left: calc(50% - 1px); width: var(--hm-orgchart-expanded-line-width); }
|
||||
.hm-orgchart-expanded .hm-orgchart-engine.orgchart > ul > li > ul li > .node::before {
|
||||
top: calc(-1 * var(--hm-orgchart-expanded-level-gap) / 2);
|
||||
height: calc(var(--hm-orgchart-expanded-level-gap) / 2);
|
||||
width: var(--hm-orgchart-expanded-line-width);
|
||||
}
|
||||
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .hierarchy:not(.couple) > .node:has(+ .nodes)::after {
|
||||
bottom: calc(-1 * var(--hm-orgchart-expanded-level-gap) / 2);
|
||||
height: calc(100% - var(--hm-orgchart-expanded-content-height) + var(--hm-orgchart-expanded-level-gap) / 2);
|
||||
width: var(--hm-orgchart-expanded-line-width);
|
||||
}
|
||||
|
||||
/* Standalone content variables, independent of Fixed and HM React controls. */
|
||||
.hm-orgchart-expanded {
|
||||
--hm-orgchart-expanded-card-border-width: 1px;
|
||||
--hm-orgchart-expanded-card-accent-width: 3px;
|
||||
--hm-orgchart-expanded-card-padding: 8px;
|
||||
--hm-orgchart-expanded-card-radius: var(--hm-orgchart-resolved-radius-container);
|
||||
--hm-orgchart-expanded-photo-size: 36px;
|
||||
--hm-orgchart-expanded-name-size: 13px;
|
||||
--hm-orgchart-expanded-position-size: 10px;
|
||||
--hm-orgchart-expanded-name-weight: var(--hm-orgchart-resolved-font-weight-bold);
|
||||
--hm-orgchart-expanded-person-gap: 0px;
|
||||
--hm-orgchart-expanded-title-padding: var(--hm-orgchart-resolved-spacing-sm) var(--hm-orgchart-resolved-spacing-md);
|
||||
--hm-orgchart-expanded-title-radius: var(--hm-orgchart-resolved-radius-control-md);
|
||||
}
|
||||
.hm-orgchart-expanded .hm-orgchart-organization-name { display: flex; border-radius: var(--hm-orgchart-expanded-title-radius); }
|
||||
.hm-orgchart-expanded-toggle {
|
||||
flex: 1; border: 0; background: none; color: inherit; font: inherit;
|
||||
cursor: pointer; padding: var(--hm-orgchart-expanded-title-padding); white-space: nowrap;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.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-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 {
|
||||
outline: 3px solid var(--hm-orgchart-resolved-action-primary); outline-offset: 3px;
|
||||
}
|
||||
.hm-orgchart-expanded-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--hm-orgchart-resolved-spacing-sm); }
|
||||
.hm-orgchart-expanded-toolbar button {
|
||||
font: inherit; padding: var(--hm-orgchart-resolved-spacing-sm) var(--hm-orgchart-resolved-spacing-md);
|
||||
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-surface);
|
||||
color: var(--hm-orgchart-resolved-fg-primary); cursor: pointer;
|
||||
}
|
||||
.hm-orgchart-expanded-toolbar button:disabled { opacity: 0.5; cursor: default; }
|
||||
.hm-orgchart-expanded-toolbar span { color: var(--hm-orgchart-resolved-fg-secondary); font-size: var(--hm-orgchart-resolved-font-size-meta); }
|
||||
.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-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"] {
|
||||
padding-inline: var(--hm-orgchart-resolved-spacing-xxl);
|
||||
}
|
||||
.hm-orgchart-expanded .hm-orgchart-employee-connectors { position: absolute; inset: 0; overflow: visible; pointer-events: none; }
|
||||
.hm-orgchart-expanded .hm-orgchart-employee-connectors path { fill: none; stroke: var(--hm-orgchart-resolved-stroke-default); stroke-width: var(--hm-orgchart-expanded-line-width); }
|
||||
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-card-presentation="expanded"] {
|
||||
padding: var(--hm-orgchart-expanded-card-padding);
|
||||
border-radius: var(--hm-orgchart-expanded-card-radius);
|
||||
width: var(--hm-orgchart-expanded-card-width, var(--hm-orgchart-resolved-card-width-employee));
|
||||
min-width: 0;
|
||||
min-height: var(--hm-orgchart-expanded-card-height, var(--hm-orgchart-resolved-card-height-employee));
|
||||
}
|
||||
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-leader[data-card-presentation="expanded"] {
|
||||
width: var(--hm-orgchart-expanded-leader-width, var(--hm-orgchart-resolved-card-width-leader));
|
||||
min-height: var(--hm-orgchart-expanded-leader-height, var(--hm-orgchart-resolved-card-height-leader));
|
||||
}
|
||||
/* Inset accents preserve all outer dimensions even when accent width changes. */
|
||||
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-card-presentation="expanded"][data-connection-direction="top"] {
|
||||
box-shadow: inset 0 var(--hm-orgchart-expanded-card-accent-width) 0 var(--hm-orgchart-expanded-card-primary);
|
||||
border-top-color: var(--hm-orgchart-expanded-card-primary);
|
||||
}
|
||||
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-card-presentation="expanded"][data-connection-direction="left"] {
|
||||
box-shadow: inset var(--hm-orgchart-expanded-card-accent-width) 0 0 var(--hm-orgchart-expanded-card-primary);
|
||||
border-left-color: var(--hm-orgchart-expanded-card-primary);
|
||||
}
|
||||
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-card-presentation="expanded"][data-connection-direction="right"] {
|
||||
box-shadow: inset calc(-1 * var(--hm-orgchart-expanded-card-accent-width)) 0 0 var(--hm-orgchart-expanded-card-primary);
|
||||
border-right-color: var(--hm-orgchart-expanded-card-primary);
|
||||
}
|
||||
.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee .hm-orgchart-avatar {
|
||||
width: var(--hm-orgchart-expanded-photo-size); height: var(--hm-orgchart-expanded-photo-size); flex-basis: var(--hm-orgchart-expanded-photo-size);
|
||||
}
|
||||
.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); }
|
||||
Reference in new issue
Block a user