feat(org-chart): add core cards and fixed-layout integration
This commit is contained in:
1 parent
d18e3c3380
commit
61c6f8d1f1
161 files changed
+13044
-1
No files matched your search
@@ -0,0 +1,37 @@
|
||||
import { selectAffiliationSummary } from '../data/content-selectors.js';
|
||||
import type { RenderContext } from './render.js';
|
||||
import { createAffiliationMark } from './affiliation-mark.js';
|
||||
|
||||
/** Read-only composition presenter: one untitled line of logo · name · count and
|
||||
* the total. The standalone AffiliationSummary keeps its titled card layout;
|
||||
* both use the same selector and Summary counts. */
|
||||
export function createAffiliationBar(document: Document, context: RenderContext, signal: AbortSignal): HTMLElement {
|
||||
const section = document.createElement('section');
|
||||
section.className = 'hm-orgchart-affiliation-bar';
|
||||
section.dataset.contentSection = 'affiliation-bar';
|
||||
section.setAttribute('aria-label', '관계사별 인원 현황');
|
||||
const list = document.createElement('dl');
|
||||
list.className = 'hm-orgchart-affiliation-bar-list';
|
||||
for (const { affiliation, count } of selectAffiliationSummary(context.data, context.summary)) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'hm-orgchart-affiliation-bar-item';
|
||||
item.dataset.affiliationId = affiliation.id;
|
||||
item.dataset.employeeCount = String(count);
|
||||
const name = document.createElement('dt');
|
||||
name.textContent = affiliation.name;
|
||||
name.prepend(createAffiliationMark(document, affiliation.id, affiliation.name, signal));
|
||||
const value = document.createElement('dd');
|
||||
value.textContent = `${count}명`;
|
||||
item.append(name, value);
|
||||
list.append(item);
|
||||
}
|
||||
const total = document.createElement('div');
|
||||
total.className = 'hm-orgchart-affiliation-bar-item hm-orgchart-affiliation-bar-total';
|
||||
total.dataset.totalCount = String(context.summary.raw.total);
|
||||
const label = document.createElement('dt'); label.textContent = '총계';
|
||||
const value = document.createElement('dd'); value.textContent = `${context.summary.raw.total}명`;
|
||||
total.append(label, value);
|
||||
list.append(total);
|
||||
section.append(list);
|
||||
return section;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import hanmac from '../assets/affiliations/hanmac.png?inline';
|
||||
import saman from '../assets/affiliations/saman.png?inline';
|
||||
import jangheon from '../assets/affiliations/jangheon.png?inline';
|
||||
import jangheonIndustry from '../assets/affiliations/jangheon-industry.png?inline';
|
||||
import ptc from '../assets/affiliations/ptc.png?inline';
|
||||
import halla from '../assets/affiliations/halla.png?inline';
|
||||
import baron from '../assets/affiliations/baron.png?inline';
|
||||
|
||||
// Asset mapping is an internal presentation concern; the employee/affiliation
|
||||
// JSON contract continues to use only its existing id and name fields.
|
||||
const logos = new Map<string, string>([
|
||||
['hanmac', hanmac], ['saman', saman], ['jangheon', jangheon],
|
||||
['jangheon-industry', jangheonIndustry], ['ptc', ptc], ['halla', halla], ['baron', baron],
|
||||
]);
|
||||
|
||||
/** Shared by employee cards and the affiliation summary footer. */
|
||||
export function createAffiliationMark(
|
||||
document: Document, affiliationId: string, name: string, signal?: AbortSignal,
|
||||
): HTMLElement {
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'hm-orgchart-affiliation-mark';
|
||||
mark.dataset.affiliationId = affiliationId;
|
||||
mark.setAttribute('role', 'img');
|
||||
mark.setAttribute('aria-label', `관계사 ${name}`);
|
||||
mark.title = name;
|
||||
const fallback = document.createElement('span');
|
||||
fallback.className = 'hm-orgchart-affiliation-fallback';
|
||||
fallback.textContent = Array.from(name.trim()).slice(0, 2).join('') || '?';
|
||||
mark.append(fallback);
|
||||
|
||||
const logo = logos.get(affiliationId);
|
||||
mark.dataset.logoAvailable = String(Boolean(logo));
|
||||
if (logo) {
|
||||
const image = document.createElement('img');
|
||||
image.alt = '';
|
||||
image.decoding = 'async';
|
||||
image.addEventListener('error', () => {
|
||||
image.remove();
|
||||
mark.dataset.logoAvailable = 'false';
|
||||
}, { once: true, ...(signal ? { signal } : {}) });
|
||||
image.src = logo;
|
||||
mark.append(image);
|
||||
}
|
||||
return mark;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { selectAffiliationSummary } from '../data/content-selectors.js';
|
||||
import type { RenderContext } from './render.js';
|
||||
import { createAffiliationMark } from './affiliation-mark.js';
|
||||
|
||||
export function createAffiliationSummary(document: Document, context: RenderContext): HTMLElement {
|
||||
const section = document.createElement('section');
|
||||
section.className = 'hm-orgchart-affiliation-summary';
|
||||
section.dataset.contentSection = 'affiliation-summary';
|
||||
section.setAttribute('aria-label', '관계사별 원본 인원 현황');
|
||||
const heading = document.createElement('h3');
|
||||
heading.className = 'hm-orgchart-summary-title';
|
||||
heading.textContent = '관계사별 인원 현황';
|
||||
const total = document.createElement('p');
|
||||
total.className = 'hm-orgchart-raw-total';
|
||||
total.textContent = `Raw Count · 전체 ${context.summary.raw.total}명`;
|
||||
section.append(heading, total);
|
||||
const list = document.createElement('dl');
|
||||
list.className = 'hm-orgchart-affiliation-list';
|
||||
for (const { affiliation, count } of selectAffiliationSummary(context.data, context.summary)) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'hm-orgchart-affiliation-item';
|
||||
item.dataset.affiliationId = affiliation.id;
|
||||
const name = document.createElement('dt');
|
||||
name.className = 'hm-orgchart-affiliation-name';
|
||||
// The contract has no logo field. The declared name is the safe textual fallback.
|
||||
name.textContent = affiliation.name;
|
||||
name.prepend(createAffiliationMark(document, affiliation.id, affiliation.name));
|
||||
const value = document.createElement('dd');
|
||||
value.className = 'hm-orgchart-affiliation-count';
|
||||
value.textContent = `${count}명`;
|
||||
item.append(name, value);
|
||||
list.append(item);
|
||||
}
|
||||
section.append(list);
|
||||
if (!context.data.affiliations.length) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'hm-orgchart-content-empty';
|
||||
empty.textContent = '등록된 관계사가 없습니다.';
|
||||
section.append(empty);
|
||||
}
|
||||
return section;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { contrastRatio, evaluateGradientContrast, parseOpaqueColor, parseVerticalGradient, relativeLuminance, textContrastThreshold } from './contrast.js';
|
||||
|
||||
const near = (actual: number, expected: number, tolerance = 0.001) => assert.ok(Math.abs(actual - expected) < tolerance, `${actual} ≈ ${expected}`);
|
||||
const integrated = 'linear-gradient(180deg, #8c67b6 0%, #5d3494 100%)';
|
||||
const design = 'linear-gradient(180deg, rgb(9, 167, 162) 0%, rgb(0, 132, 127) 100%)';
|
||||
|
||||
test('standard sRGB luminance and contrast use unrounded channels with symmetric ratios', () => {
|
||||
near(relativeLuminance([0, 0, 0]), 0);
|
||||
near(relativeLuminance([255, 255, 255]), 1);
|
||||
near(contrastRatio([0, 0, 0], [255, 255, 255]), 21);
|
||||
near(contrastRatio([16, 36, 59], [255, 255, 255]), 15.7005);
|
||||
near(contrastRatio([89, 101, 117], [244, 247, 251]), 5.5160);
|
||||
assert.equal(contrastRatio([16, 36, 59], [255, 255, 255]), contrastRatio([255, 255, 255], [16, 36, 59]));
|
||||
});
|
||||
|
||||
test('color parser accepts opaque HEX and computed RGB, rejects transparent/unknown compositing', () => {
|
||||
assert.deepEqual(parseOpaqueColor('#09a7a2'), [9, 167, 162]);
|
||||
assert.deepEqual(parseOpaqueColor('#fff'), [255, 255, 255]);
|
||||
assert.deepEqual(parseOpaqueColor('rgb(16 36 59)'), [16, 36, 59]);
|
||||
assert.deepEqual(parseOpaqueColor('rgba(16, 36, 59, 1)'), [16, 36, 59]);
|
||||
for (const value of ['rgba(0, 0, 0, 0.5)', '#ffffff80', 'transparent', 'red', 'rgb(100% 0% 0%)', 'color(display-p3 1 0 0)', 'rgb(256, 0, 0)']) assert.equal(parseOpaqueColor(value), null);
|
||||
});
|
||||
|
||||
test('large text threshold uses measured size/weight and does not treat 15px bold as large', () => {
|
||||
assert.equal(textContrastThreshold(15, 700), 4.5);
|
||||
assert.equal(textContrastThreshold(19.5, 700), 3);
|
||||
assert.equal(textContrastThreshold(18.6, 700), 4.5);
|
||||
assert.equal(textContrastThreshold(14 * 96 / 72, 700), 3);
|
||||
assert.equal(textContrastThreshold(19.5, 500), 4.5);
|
||||
assert.equal(textContrastThreshold(24, 400), 3);
|
||||
});
|
||||
|
||||
test('restricted gradient parser preserves RGB commas and explicit ordered percent stops', () => {
|
||||
assert.equal(parseVerticalGradient(design)!.stops.length, 2);
|
||||
assert.deepEqual(parseVerticalGradient(design)!.stops[0]!.color, [9, 167, 162]);
|
||||
assert.equal(parseVerticalGradient('linear-gradient(180deg, #000 0%, #fff 25%, #000 100%)')!.stops[1]!.offset, 0.25);
|
||||
for (const value of ['linear-gradient(90deg, #000 0%, #fff 100%)', 'linear-gradient(180deg in oklab, #000 0%, #fff 100%)', 'linear-gradient(180deg, #000, #fff)', 'linear-gradient(180deg, #000 0px, #fff 100px)', 'linear-gradient(180deg, #000 0%, #fff 50%)', 'linear-gradient(180deg, #000 0%, #fff 0%, #000 100%)', 'linear-gradient(180deg, #000 0%, #fff 100%), url(x)', 'url(image.png)', 'repeating-linear-gradient(180deg, #000 0%, #fff 100%)']) assert.equal(parseVerticalGradient(value), null, value);
|
||||
});
|
||||
|
||||
test('browser-computed omitted default angle is equivalent to 180deg, without inferring omitted stops', () => {
|
||||
const computed = 'linear-gradient(rgb(9, 167, 162) 0%, rgb(0, 132, 127) 100%)';
|
||||
assert.deepEqual(parseVerticalGradient(computed), parseVerticalGradient(design));
|
||||
const input = { foreground: '#fff', ranges: [{ top: 6, bottom: 24 }], backgroundHeight: 30 };
|
||||
assert.deepEqual(evaluateGradientContrast({ ...input, backgroundImage: computed }), evaluateGradientContrast({ ...input, backgroundImage: design }));
|
||||
assert.equal(parseVerticalGradient('linear-gradient(rgb(9, 167, 162), rgb(0, 132, 127))'), null);
|
||||
});
|
||||
|
||||
test('inset actual text model can pass while a conservative stop value is below 4.5', () => {
|
||||
const result = evaluateGradientContrast({ foreground: '#fff', backgroundImage: integrated, ranges: [{ top: 6, bottom: 24 }], backgroundHeight: 30 });
|
||||
assert.equal(result.status, 'MODEL');
|
||||
near(result.stopMinimum!, 4.4391);
|
||||
assert.ok(result.stopMinimum! < 4.5);
|
||||
assert.ok(result.textRegionMinimum! > 5);
|
||||
assert.ok(result.textRegionMinimum! > result.fullGradientMinimum!);
|
||||
});
|
||||
|
||||
test('current large title and normal Full title thresholds remain independent for the same actual area', () => {
|
||||
const result = evaluateGradientContrast({ foreground: '#fff', backgroundImage: design, ranges: [{ top: 6, bottom: 24 }], backgroundHeight: 30 });
|
||||
assert.ok(result.textRegionMinimum! >= textContrastThreshold(19.5, 700));
|
||||
assert.ok(result.textRegionMinimum! < textContrastThreshold(15, 700));
|
||||
const black = evaluateGradientContrast({ foreground: '#000', backgroundImage: design, ranges: [{ top: 0, bottom: 30 }], backgroundHeight: 30 });
|
||||
near(black.fullGradientMinimum!, 4.6070);
|
||||
assert.ok(black.fullGradientMinimum! >= 4.5);
|
||||
});
|
||||
|
||||
test('all actual line ranges are included, preserving a weak wrapped line', () => {
|
||||
const first = evaluateGradientContrast({ foreground: '#fff', backgroundImage: integrated, ranges: [{ top: 60, bottom: 80 }], backgroundHeight: 100 });
|
||||
const wrapped = evaluateGradientContrast({ foreground: '#fff', backgroundImage: integrated, ranges: [{ top: 60, bottom: 80 }, { top: 0, bottom: 10 }], backgroundHeight: 100 });
|
||||
assert.ok(first.textRegionMinimum! > 4.5);
|
||||
near(wrapped.textRegionMinimum!, wrapped.stopMinimum!);
|
||||
assert.ok(wrapped.textRegionMinimum! < first.textRegionMinimum!);
|
||||
});
|
||||
|
||||
test('stop minima never substitute for whole-gradient sampling when foreground meets an interior color', () => {
|
||||
const result = evaluateGradientContrast({ foreground: '#777', backgroundImage: 'linear-gradient(180deg, #000 0%, #fff 100%)', ranges: [{ top: 40, bottom: 55 }], backgroundHeight: 100 });
|
||||
assert.ok(result.stopMinimum! > 4);
|
||||
assert.ok(result.fullGradientMinimum! < 1.02);
|
||||
assert.ok(result.textRegionMinimum! < 1.03);
|
||||
});
|
||||
|
||||
test('unsupported backgrounds, alpha, missing/clipped ranges and unbounded sample requests remain NOT VERIFIED', () => {
|
||||
const valid = { foreground: '#fff', backgroundImage: integrated, ranges: [{ top: 2, bottom: 20 }], backgroundHeight: 30 };
|
||||
for (const input of [
|
||||
{ ...valid, foreground: 'rgba(255, 255, 255, 0.5)' }, { ...valid, backgroundImage: 'url(x)' },
|
||||
{ ...valid, ranges: [] }, { ...valid, ranges: [{ top: -1, bottom: 20 }] },
|
||||
{ ...valid, ranges: [{ top: 2, bottom: 31 }] }, { ...valid, ranges: [{ top: 20, bottom: 2 }] },
|
||||
{ ...valid, backgroundHeight: NaN }, { ...valid, backgroundHeight: 0 }, { ...valid, sampleStep: 0 },
|
||||
{ ...valid, sampleStep: 0.000001 },
|
||||
]) {
|
||||
const result = evaluateGradientContrast(input);
|
||||
assert.equal(result.status, 'NOT VERIFIED');
|
||||
assert.equal(result.textRegionMinimum, undefined);
|
||||
assert.ok(result.reason);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/** Internal contrast arithmetic. Results model opaque legacy sRGB colors;
|
||||
* they are not pixel measurements or a claim about every rendered instance. */
|
||||
export type Rgb = readonly [number, number, number];
|
||||
export interface VerticalGradient { stops: readonly { color: Rgb; offset: number }[] }
|
||||
export interface ContrastModel {
|
||||
status: 'MODEL' | 'NOT VERIFIED';
|
||||
reason?: string;
|
||||
stopMinimum?: number;
|
||||
fullGradientMinimum?: number;
|
||||
textRegionMinimum?: number;
|
||||
}
|
||||
|
||||
export function parseOpaqueColor(value: string): Rgb | null {
|
||||
const text = value.trim();
|
||||
if (/^#[\da-f]{6}$/i.test(text)) return [1, 3, 5].map(index => parseInt(text.slice(index, index + 2), 16)) as unknown as Rgb;
|
||||
if (/^#[\da-f]{3}$/i.test(text)) return [...text.slice(1)].map(part => parseInt(part + part, 16)) as unknown as Rgb;
|
||||
const match = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+))?\s*\)$/i.exec(text);
|
||||
if (!match || (match[4] !== undefined && Number(match[4]) !== 1)) return null;
|
||||
const channels = match.slice(1, 4).map(Number);
|
||||
return channels.every(channel => Number.isFinite(channel) && channel >= 0 && channel <= 255) ? channels as unknown as Rgb : null;
|
||||
}
|
||||
|
||||
export function relativeLuminance(color: Rgb): number {
|
||||
const channels = color.map(channel => {
|
||||
const encoded = channel / 255;
|
||||
return encoded <= 0.04045 ? encoded / 12.92 : ((encoded + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722;
|
||||
}
|
||||
|
||||
export function contrastRatio(foreground: Rgb, background: Rgb): number {
|
||||
const first = relativeLuminance(foreground), second = relativeLuminance(background);
|
||||
return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05);
|
||||
}
|
||||
|
||||
/** Restricted intentionally: no alpha, images, multiple layers, omitted stops,
|
||||
* other angles, color spaces, relative units, or repeating gradients.
|
||||
* Computed CSS may omit the default 180deg angle; that is equivalent. */
|
||||
export function parseVerticalGradient(value: string): VerticalGradient | null {
|
||||
const outer = /^linear-gradient\(\s*(?:180deg\s*,\s*)?(.*)\)$/i.exec(value.trim());
|
||||
if (!outer) return null;
|
||||
const parts = outer[1]!.split(/,(?![^()]*\))/);
|
||||
const stops: { color: Rgb; offset: number }[] = [];
|
||||
for (const part of parts) {
|
||||
const match = /^\s*(#[\da-f]+|rgba?\([^()]*\))\s+([\d.]+)%\s*$/i.exec(part);
|
||||
const color = match && parseOpaqueColor(match[1]!);
|
||||
const offset = match ? Number(match[2]) / 100 : NaN;
|
||||
if (!color || !Number.isFinite(offset) || offset < 0 || offset > 1 || (stops.length > 0 && offset <= stops.at(-1)!.offset)) return null;
|
||||
stops.push({ color, offset });
|
||||
}
|
||||
return stops.length >= 2 && stops[0]!.offset === 0 && stops.at(-1)!.offset === 1 ? { stops } : null;
|
||||
}
|
||||
|
||||
function gradientColor(gradient: VerticalGradient, offset: number): Rgb {
|
||||
let index = 1;
|
||||
while (index < gradient.stops.length - 1 && offset > gradient.stops[index]!.offset) index++;
|
||||
const before = gradient.stops[index - 1]!, after = gradient.stops[index]!;
|
||||
const amount = (offset - before.offset) / (after.offset - before.offset);
|
||||
return before.color.map((channel, channelIndex) => channel + (after.color[channelIndex]! - channel) * amount) as unknown as Rgb;
|
||||
}
|
||||
|
||||
export function textContrastThreshold(fontSizePx: number, fontWeight: number): 3 | 4.5 {
|
||||
return fontSizePx >= 24 || (fontWeight >= 700 && fontSizePx >= 14 * 96 / 72) ? 3 : 4.5;
|
||||
}
|
||||
|
||||
/** Ranges are actual text line rectangles in CSS px relative to the gradient
|
||||
* painting box, not the padded heading rectangle. The whole-gradient sample
|
||||
* and stop sample are reported separately; neither substitutes for text area. */
|
||||
export function evaluateGradientContrast(input: {
|
||||
foreground: string;
|
||||
backgroundImage: string;
|
||||
ranges: readonly { top: number; bottom: number }[];
|
||||
backgroundHeight: number;
|
||||
sampleStep?: number;
|
||||
}): ContrastModel {
|
||||
const foreground = parseOpaqueColor(input.foreground), gradient = parseVerticalGradient(input.backgroundImage);
|
||||
if (!foreground || !gradient) return { status: 'NOT VERIFIED', reason: '지원하지 않는 색상/gradient: 불투명 sRGB 180deg 및 명시적 % stop만 지원' };
|
||||
const height = input.backgroundHeight, step = input.sampleStep ?? 1;
|
||||
if (!Number.isFinite(height) || height <= 0 || !Number.isFinite(step) || step <= 0 || height / step > 10000 || input.ranges.length === 0 || input.ranges.some(range => !Number.isFinite(range.top) || !Number.isFinite(range.bottom) || range.top < 0 || range.bottom > height || range.bottom <= range.top)) {
|
||||
return { status: 'NOT VERIFIED', reason: '텍스트 범위/배경 높이/샘플 간격 확인 불가' };
|
||||
}
|
||||
const ratioAt = (offset: number) => contrastRatio(foreground, gradientColor(gradient, offset));
|
||||
const stopMinimum = Math.min(...gradient.stops.map(stop => contrastRatio(foreground, stop.color)));
|
||||
let fullGradientMinimum = stopMinimum;
|
||||
for (let index = 0; index <= 256; index++) fullGradientMinimum = Math.min(fullGradientMinimum, ratioAt(index / 256));
|
||||
let textRegionMinimum = Infinity;
|
||||
for (const range of input.ranges) {
|
||||
textRegionMinimum = Math.min(textRegionMinimum, ratioAt(range.top / height), ratioAt(range.bottom / height));
|
||||
for (let y = range.top + step; y < range.bottom; y += step) textRegionMinimum = Math.min(textRegionMinimum, ratioAt(y / height));
|
||||
for (const stop of gradient.stops) if (stop.offset * height >= range.top && stop.offset * height <= range.bottom) textRegionMinimum = Math.min(textRegionMinimum, ratioAt(stop.offset));
|
||||
}
|
||||
return { status: 'MODEL', stopMinimum, fullGradientMinimum, textRegionMinimum };
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import businessLeader from '../assets/cards/경영기획팀.svg?inline';
|
||||
import businessEmployee from '../assets/cards/경영기획팀-sm.svg?inline';
|
||||
import managementLeader from '../assets/cards/사업관리팀.svg?inline';
|
||||
import managementEmployee from '../assets/cards/사업관리팀-sm.svg?inline';
|
||||
import designLeader from '../assets/cards/디자인기획팀.svg?inline';
|
||||
import designEmployee from '../assets/cards/디자인기획팀-sm.svg?inline';
|
||||
import solutionLeader from '../assets/cards/솔루션통합팀.svg?inline';
|
||||
import solutionEmployee from '../assets/cards/솔루션통합팀-sm.svg?inline';
|
||||
import talentLeader from '../assets/cards/인재성장팀.svg?inline';
|
||||
import talentEmployee from '../assets/cards/인재성장팀-sm.svg?inline';
|
||||
import systemLeader from '../assets/cards/통합시스템 디비전.svg?inline';
|
||||
import systemEmployee from '../assets/cards/통합시스템 디비전-sm.svg?inline';
|
||||
|
||||
export type DepartmentCardSize = 'leader' | 'employee';
|
||||
export interface DepartmentCardAsset {
|
||||
departmentId: string;
|
||||
label: string;
|
||||
size: DepartmentCardSize;
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// Presentation-only mapping. The ten supplied SVGs are copied byte-for-byte;
|
||||
// business-management uses vectors generated from the supplied PNG's colors.
|
||||
// Vite embeds both in ESM/IIFE bundles, including file:// consumers.
|
||||
// User-approved alias: 솔루션통합팀 assets -> existing solution-sales data.
|
||||
const departments = new Map([
|
||||
['business-management', { label: '사업관리팀', leader: managementLeader, employee: managementEmployee }],
|
||||
['business-planning', { label: '경영기획팀', leader: businessLeader, employee: businessEmployee }],
|
||||
['design-planning', { label: '디자인기획팀', leader: designLeader, employee: designEmployee }],
|
||||
['solution-sales', { label: '솔루션통합팀', leader: solutionLeader, employee: solutionEmployee }],
|
||||
['talent-growth', { label: '인재성장팀', leader: talentLeader, employee: talentEmployee }],
|
||||
['integrated-system', { label: '통합시스템 Div', leader: systemLeader, employee: systemEmployee }],
|
||||
]);
|
||||
|
||||
/** Resolve actual ancestry; do not infer a department from ID spelling/colors.
|
||||
* Unknown organizations and departments without supplied assets stay unchanged. */
|
||||
export function getDepartmentCardAsset(
|
||||
departmentId: string, size: DepartmentCardSize,
|
||||
organizations: readonly { id: string; parentId: string | null }[] = [],
|
||||
): DepartmentCardAsset | undefined {
|
||||
const visited = new Set<string>();
|
||||
let current: string | null = departmentId;
|
||||
while (current && !visited.has(current)) {
|
||||
visited.add(current);
|
||||
const entry = departments.get(current);
|
||||
if (entry) return { departmentId: current, label: entry.label, size, url: entry[size],
|
||||
width: size === 'leader' ? 146 : 108, height: size === 'leader' ? 68 : 52 };
|
||||
current = organizations.find(item => item.id === current)?.parentId ?? null;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { RenderContext } from './render';
|
||||
import { applyOrganizationStyle, organizationStylePresets } from './style-presets';
|
||||
import { createEmployeeCard } from './employee-card';
|
||||
import { contrastRatio, evaluateGradientContrast, parseOpaqueColor, textContrastThreshold } from './contrast';
|
||||
import { getDepartmentCardAsset } from './department-card-assets';
|
||||
import { representativeCardAsset } from './representative-card-asset';
|
||||
|
||||
export function createDepartmentColors(document: Document, context: RenderContext, signal: AbortSignal): HTMLElement {
|
||||
const section = document.createElement('section'); section.className = 'hm-orgchart-department-colors';
|
||||
const note = document.createElement('p');
|
||||
note.textContent = 'OrgChart 독립 디자인. 대표자 전용 SVG 146×74, 부서 조직장 SVG 146×68, 일반 직원 SVG 108×52를 구분합니다. 5개 조직은 전달된 원본 SVG이며 사업관리는 전달 PNG의 색상·투명도를 추출해 제작한 SVG입니다. 솔루션통합팀 자산은 기존 솔루션영업에 매핑합니다. 아래 색상 견본은 기존 헤더·기본 카드 CSS 값이며 SVG 픽셀 색상이 아닙니다. 공통 HM Theme Token을 사용하지 않습니다. 직원과 인원 통계는 가상 데이터입니다.';
|
||||
section.append(note);
|
||||
const contrastNote = document.createElement('p'); contrastNote.className = 'hm-orgchart-contrast-note';
|
||||
contrastNote.textContent = '대비는 불투명 sRGB 180deg gradient와 실제 텍스트 Range의 줄별 영역을 사용한 계산 MODEL입니다(픽셀 실측 아님). 전체 stop 최소값은 실제 글자 영역 판정과 별도로 표시합니다. 큰 Story 제목의 PASS는 Full Composition 15px 일반 텍스트의 PASS를 뜻하지 않습니다. 후보는 미승인·운영 미적용이며 브랜드 gradient를 유지하더라도 텍스트/배경 변경 승인이 필요합니다.';
|
||||
section.append(contrastNote);
|
||||
const representative = context.data.employees.find(item => context.topLeaderIds.has(item.id));
|
||||
if (representative) {
|
||||
const preview = document.createElement('section'); preview.className = 'hm-orgchart-representative-preview';
|
||||
const title = document.createElement('h2'); title.textContent = '대표자 전용 카드 · 146×74';
|
||||
const caption = document.createElement('p'); caption.textContent = '최상단 단독 리더 전용 SVG이며 부서 조직장(146×68)과 별도 종류입니다.';
|
||||
preview.append(title, createEmployeeCard(document, representative, { kind: 'topLeader', cardAsset: representativeCardAsset,
|
||||
affiliationName: context.data.affiliations.find(item => item.id === representative.affiliationId)?.name }, signal), caption);
|
||||
section.append(preview);
|
||||
}
|
||||
for (let index = 0; index < organizationStylePresets.length; index++) {
|
||||
const preset = organizationStylePresets[index]!;
|
||||
const row = document.createElement('section'); row.className = 'hm-orgchart-color-preview';
|
||||
applyOrganizationStyle(row, index);
|
||||
const heading = document.createElement('h2'); heading.className = 'hm-orgchart-color-department-name'; heading.textContent = preset.label;
|
||||
heading.dataset.contrastTarget = 'current';
|
||||
const source = document.createElement('p'); source.textContent = `${preset.source} · ${preset.observedOn} · ${preset.version} · ${preset.selector}`;
|
||||
const legend = document.createElement('dl'); legend.className = 'hm-orgchart-color-legend';
|
||||
const entries = [
|
||||
['카드 배경', 'card-background', 'current-card-background', 'background-color', 'DESIGN_ADAPTATION: OrgChart 기본값'],
|
||||
['직원 텍스트', 'card-color', 'current-card-color', 'color', 'DESIGN_ADAPTATION: OrgChart 기본값'],
|
||||
['부서명 텍스트', 'department-color', 'current-department-color', 'color', `WORKDESK_COMPUTED: ${preset.departmentColor}`],
|
||||
['테두리', 'border', 'current-border', 'background-color', `DESIGN_ADAPTATION: ${preset.borderColor}, 헤더 시작색 재사용; 원본 카드 테두리 NOT VERIFIED`],
|
||||
['Gradient', 'gradient', 'current-gradient', 'background-image', `WORKDESK_COMPUTED: ${preset.backgroundImage}`],
|
||||
];
|
||||
for (const [label, suffix, resolved, property, evidence] of entries) {
|
||||
const term = document.createElement('dt'); term.textContent = label!;
|
||||
const detail = document.createElement('dd');
|
||||
const swatch = document.createElement('span'); swatch.className = 'hm-orgchart-color-swatch';
|
||||
swatch.style.setProperty(property!, `var(--hm-orgchart-${resolved})`);
|
||||
if (property === 'color') {
|
||||
swatch.textContent = 'Aa';
|
||||
swatch.style.background = suffix === 'department-color' ? 'var(--hm-orgchart-current-gradient)' : 'var(--hm-orgchart-current-card-background)';
|
||||
}
|
||||
swatch.dataset.colorVariable = `${preset.variablePrefix}-${suffix}`; swatch.dataset.cssProperty = property;
|
||||
const variable = document.createElement('code'); variable.textContent = swatch.dataset.colorVariable;
|
||||
const value = document.createElement('span'); value.className = 'hm-orgchart-color-value';
|
||||
const provenance = document.createElement('span'); provenance.textContent = `${evidence} · OrgChart 전용 변수`;
|
||||
detail.append(swatch, variable, value, provenance); legend.append(term, detail);
|
||||
}
|
||||
const cards = document.createElement('div'); cards.className = 'hm-orgchart-preview-grid';
|
||||
const organization = context.data.organizations.find(item => item.id === preset.organizationId);
|
||||
const descendants = new Set<string>([preset.organizationId]);
|
||||
for (const item of context.orderedOrganizations) if (item.parentId && descendants.has(item.parentId)) descendants.add(item.id);
|
||||
const leader = context.data.employees.find(item => item.id === organization?.leaderId);
|
||||
const leaderIds = new Set(context.data.organizations.map(item => item.leaderId).filter(Boolean));
|
||||
const employee = context.data.employees.find(item => descendants.has(item.organizationId) && !leaderIds.has(item.id));
|
||||
for (const [person, size] of [[leader, 'leader'], [employee, 'employee']] as const) {
|
||||
if (!person) continue;
|
||||
const asset = getDepartmentCardAsset(preset.organizationId, size);
|
||||
const specimen = document.createElement('div'); specimen.className = 'hm-orgchart-card-specimen';
|
||||
const caption = document.createElement('p'); caption.className = 'hm-orgchart-card-caption';
|
||||
caption.textContent = asset ? `${asset.label} · ${size === 'leader' ? '조직장' : '일반 직원'} ${asset.width}×${asset.height} · ${asset.departmentId === 'business-management' ? 'PNG 색상 추출 SVG' : '원본 SVG'}` : `${preset.label} · ${size === 'leader' ? '조직장' : '일반 직원'} · 기존 기본 카드(전달 자산 없음)`;
|
||||
specimen.append(createEmployeeCard(document, person, { kind: size, cardAsset: asset, affiliationName: context.data.affiliations.find(item => item.id === person.affiliationId)?.name }, signal), caption);
|
||||
cards.append(specimen);
|
||||
}
|
||||
const contrast = document.createElement('section'); contrast.className = 'hm-orgchart-contrast-comparison';
|
||||
const comparisonTitle = document.createElement('h3'); comparisonTitle.textContent = '텍스트 대비 MODEL · 현재 / 일반 제목 / 미적용 후보';
|
||||
const normal = document.createElement('div'); normal.className = 'hm-orgchart-contrast-normal'; normal.dataset.contrastTarget = 'full-normal'; normal.textContent = preset.label;
|
||||
const candidate = document.createElement('div'); candidate.className = 'hm-orgchart-contrast-candidate'; candidate.dataset.contrastTarget = 'candidate'; candidate.dataset.designStatus = 'UNAPPROVED_NOT_APPLIED'; candidate.textContent = preset.label;
|
||||
candidate.style.color = 'var(--hm-orgchart-contrast-candidate-color, #10243b)';
|
||||
const chip = document.createElement('div'); chip.className = 'hm-orgchart-contrast-chip'; chip.dataset.designStatus = 'UNAPPROVED_NOT_APPLIED';
|
||||
const chipLabel = document.createElement('span'); chipLabel.className = 'hm-orgchart-contrast-chip-label'; chipLabel.dataset.contrastTarget = 'candidate-chip'; chipLabel.textContent = preset.label;
|
||||
chipLabel.style.color = 'var(--hm-orgchart-contrast-candidate-color, #10243b)'; chipLabel.style.backgroundColor = 'var(--hm-orgchart-contrast-candidate-chip-background, #ffffff)'; chip.append(chipLabel);
|
||||
const results = document.createElement('dl'); results.className = 'hm-orgchart-contrast-results';
|
||||
for (const [key, label] of [['current', '현재 Story 제목: 실제 크기 기준'], ['full-normal', 'Full 제목 동등 예제: 15px bold 일반 텍스트'], ['candidate', '후보 #10243b: 미승인·미적용'], ['candidate-chip', '후보 white label + #10243b: 미승인·미적용']]) {
|
||||
const term = document.createElement('dt'); term.textContent = label;
|
||||
const result = document.createElement('dd'); result.className = 'hm-orgchart-contrast-result'; result.dataset.contrastFor = key; result.dataset.contrastStatus = 'NOT VERIFIED'; result.textContent = 'NOT VERIFIED · CSS/텍스트 영역 측정 대기'; results.append(term, result);
|
||||
}
|
||||
const stops = document.createElement('p'); stops.className = 'hm-orgchart-contrast-stops'; stops.textContent = '전체 gradient stop 및 흑색 대안 MODEL: NOT VERIFIED';
|
||||
contrast.append(comparisonTitle, normal, candidate, chip, results, stops);
|
||||
row.append(heading, source, legend, cards, contrast); section.append(row);
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
export function updateDepartmentColorValues(root: HTMLElement): void {
|
||||
const window = root.ownerDocument.defaultView;
|
||||
if (!window) return;
|
||||
for (const swatch of root.querySelectorAll<HTMLElement>('[data-color-variable]')) {
|
||||
const raw = window.getComputedStyle(swatch).getPropertyValue(swatch.dataset.cssProperty!);
|
||||
const value = swatch.parentElement!.querySelector('.hm-orgchart-color-value')!;
|
||||
const text = raw.replace(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/g, (_, r: string, g: string, b: string) => `#${[r, g, b].map(part => Number(part).toString(16).padStart(2, '0')).join('')}`);
|
||||
if (value.textContent !== text) value.textContent = text;
|
||||
}
|
||||
updateDepartmentContrastValues(root);
|
||||
}
|
||||
|
||||
/** Runtime DOM geometry feeds the pure model. Unknown compositing stays NV;
|
||||
* this deliberately does not claim to inspect browser-rendered pixel colors. */
|
||||
export function updateDepartmentContrastValues(root: HTMLElement): void {
|
||||
const window = root.ownerDocument.defaultView;
|
||||
if (!window) return;
|
||||
for (const row of root.querySelectorAll<HTMLElement>('.hm-orgchart-color-preview')) {
|
||||
for (const target of row.querySelectorAll<HTMLElement>('[data-contrast-target]')) {
|
||||
const output = row.querySelector<HTMLElement>(`[data-contrast-for="${target.dataset.contrastTarget}"]`);
|
||||
if (!output) continue;
|
||||
const measurement = measureTextContrast(target);
|
||||
output.dataset.contrastStatus = measurement.status;
|
||||
output.dataset.contrastModel = measurement.model;
|
||||
if (output.textContent !== measurement.text) output.textContent = measurement.text;
|
||||
for (const key of ['contrastRatio', 'contrastThreshold', 'contrastFontSize', 'contrastFontWeight'] as const) delete output.dataset[key];
|
||||
if (measurement.ratio !== undefined) output.dataset.contrastRatio = String(measurement.ratio);
|
||||
if (measurement.threshold !== undefined) output.dataset.contrastThreshold = String(measurement.threshold);
|
||||
if (measurement.fontSize !== undefined) output.dataset.contrastFontSize = String(measurement.fontSize);
|
||||
if (measurement.fontWeight !== undefined) output.dataset.contrastFontWeight = String(measurement.fontWeight);
|
||||
}
|
||||
const heading = row.querySelector<HTMLElement>('[data-contrast-target="current"]')!;
|
||||
const style = window.getComputedStyle(heading), height = heading.getBoundingClientRect().height;
|
||||
const current = evaluateGradientContrast({ foreground: style.color, backgroundImage: style.backgroundImage, ranges: [{ top: 0, bottom: height }], backgroundHeight: height });
|
||||
const black = evaluateGradientContrast({ foreground: '#000000', backgroundImage: style.backgroundImage, ranges: [{ top: 0, bottom: height }], backgroundHeight: height });
|
||||
const stops = row.querySelector<HTMLElement>('.hm-orgchart-contrast-stops')!;
|
||||
stops.dataset.contrastModel = current.status;
|
||||
const stopText = current.status === 'MODEL' && black.status === 'MODEL'
|
||||
? `전체 stop 최소 ${current.stopMinimum!.toFixed(2)}:1 · 전체 gradient 257지점 MODEL 최소 ${current.fullGradientMinimum!.toFixed(2)}:1 (실제 텍스트 영역 아님; 일괄 판정하지 않음). 미적용 #000 대안 전체 MODEL 최소 ${black.fullGradientMinimum!.toFixed(3)}:1. 디자인기획 기본 gradient의 #000 대안은 약 4.607:1이며 승인 전 미적용.`
|
||||
: `전체 gradient MODEL NOT VERIFIED · ${current.reason ?? black.reason}`;
|
||||
if (stops.textContent !== stopText) stops.textContent = stopText;
|
||||
if (current.stopMinimum !== undefined) stops.dataset.contrastStopMinimum = String(current.stopMinimum); else delete stops.dataset.contrastStopMinimum;
|
||||
if (black.fullGradientMinimum !== undefined) stops.dataset.contrastBlackMinimum = String(black.fullGradientMinimum); else delete stops.dataset.contrastBlackMinimum;
|
||||
}
|
||||
}
|
||||
|
||||
interface TextMeasurement { status: 'PASS' | 'REVIEW' | 'NOT VERIFIED'; model: 'MODEL' | 'NOT VERIFIED'; text: string; ratio?: number; threshold?: number; fontSize?: number; fontWeight?: number }
|
||||
function measureTextContrast(target: HTMLElement): TextMeasurement {
|
||||
const window = target.ownerDocument.defaultView!;
|
||||
const unknown = (reason: string): TextMeasurement => ({ status: 'NOT VERIFIED', model: 'NOT VERIFIED', text: `NOT VERIFIED · ${reason}` });
|
||||
for (let element: HTMLElement | null = target; element;) {
|
||||
const style = window.getComputedStyle(element);
|
||||
if (Number(style.opacity) !== 1 || style.filter !== 'none' || style.transform !== 'none' || style.mixBlendMode !== 'normal' || (style.backdropFilter && style.backdropFilter !== 'none')) return unknown('opacity/filter/transform/blend의 색상 합성 지원 안 함');
|
||||
const tree = element.getRootNode();
|
||||
element = element.parentElement ?? ((tree as ShadowRoot).host as HTMLElement | undefined) ?? null;
|
||||
}
|
||||
const style = window.getComputedStyle(target), box = target.getBoundingClientRect();
|
||||
if (style.textShadow !== 'none') return unknown('text-shadow 지원 안 함');
|
||||
const fontSize = Number.parseFloat(style.fontSize), fontWeight = style.fontWeight === 'bold' ? 700 : style.fontWeight === 'normal' ? 400 : Number(style.fontWeight);
|
||||
if (!Number.isFinite(fontSize) || !Number.isFinite(fontWeight)) return unknown('텍스트 크기/굵기 확인 불가');
|
||||
const threshold = textContrastThreshold(fontSize, fontWeight);
|
||||
const ranges: { top: number; bottom: number }[] = [];
|
||||
const walker = target.ownerDocument.createTreeWalker(target, 4 /* SHOW_TEXT */);
|
||||
const lineRects: DOMRect[] = [];
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (!node.textContent?.trim()) continue;
|
||||
const range = target.ownerDocument.createRange(); range.selectNodeContents(node);
|
||||
lineRects.push(...Array.from(range.getClientRects()).filter(rect => rect.width > 0 && rect.height > 0));
|
||||
}
|
||||
if (lineRects.length === 0 || box.height <= 0) return unknown('실제 텍스트 Range 없음');
|
||||
let ratio: number | undefined;
|
||||
if (style.backgroundImage === 'none') {
|
||||
const foreground = parseOpaqueColor(style.color), background = parseOpaqueColor(style.backgroundColor);
|
||||
if (!foreground || !background) return unknown('불투명 solid 배경/텍스트만 지원');
|
||||
if (lineRects.some(rect => rect.left < box.left || rect.right > box.right || rect.top < box.top || rect.bottom > box.bottom)) return unknown('텍스트가 solid 배경 영역 밖에 있음');
|
||||
ratio = contrastRatio(foreground, background);
|
||||
} else {
|
||||
if (!['auto', 'auto auto'].includes(style.backgroundSize) || style.backgroundPosition !== '0% 0%' || !['border-box', 'padding-box', 'content-box'].includes(style.backgroundOrigin)) return unknown('background size/position/origin 지원 안 함');
|
||||
const borderTop = Number.parseFloat(style.borderTopWidth), borderBottom = Number.parseFloat(style.borderBottomWidth);
|
||||
const borderLeft = Number.parseFloat(style.borderLeftWidth), borderRight = Number.parseFloat(style.borderRightWidth);
|
||||
const content = style.backgroundOrigin === 'content-box', includeBorder = style.backgroundOrigin === 'border-box';
|
||||
const top = box.top + (includeBorder ? 0 : borderTop) + (content ? Number.parseFloat(style.paddingTop) : 0);
|
||||
const bottom = box.bottom - (includeBorder ? 0 : borderBottom) - (content ? Number.parseFloat(style.paddingBottom) : 0);
|
||||
const left = box.left + (includeBorder ? 0 : borderLeft) + (content ? Number.parseFloat(style.paddingLeft) : 0);
|
||||
const right = box.right - (includeBorder ? 0 : borderRight) - (content ? Number.parseFloat(style.paddingRight) : 0);
|
||||
if (lineRects.some(rect => rect.left < left || rect.right > right)) return unknown('텍스트가 gradient painting box 밖에 있음');
|
||||
for (const rect of lineRects) ranges.push({ top: rect.top - top, bottom: rect.bottom - top });
|
||||
const model = evaluateGradientContrast({ foreground: style.color, backgroundImage: style.backgroundImage, ranges, backgroundHeight: bottom - top });
|
||||
if (model.status !== 'MODEL') return unknown(model.reason!);
|
||||
ratio = model.textRegionMinimum!;
|
||||
}
|
||||
const status = ratio >= threshold ? 'PASS' : 'REVIEW';
|
||||
return { status, model: 'MODEL', ratio, threshold, fontSize, fontWeight, text: `${status} · 실제 Range 영역 MODEL 최소 ${ratio.toFixed(2)}:1 / 기준 ${threshold}:1 · ${fontSize}px / ${fontWeight}. 픽셀 실측 아님; 현재 예제 영역에만 해당.` };
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Employee } from '../data/index.js';
|
||||
import personPlaceholder from '../assets/person-placeholder.svg?inline';
|
||||
import { createAffiliationMark } from './affiliation-mark.js';
|
||||
import type { DepartmentCardAsset } from './department-card-assets.js';
|
||||
import type { RepresentativeCardAsset } from './representative-card-asset.js';
|
||||
import { observePersonNameOverflow } from './person-name-overflow.js';
|
||||
|
||||
const statusNames = { active: '재직', dispatched: '파견', onLeave: '휴직' } as const;
|
||||
|
||||
export interface EmployeeCardOptions {
|
||||
kind: 'topLeader' | 'leader' | 'employee';
|
||||
affiliationName?: string;
|
||||
cardAsset?: DepartmentCardAsset | RepresentativeCardAsset;
|
||||
/** Full Composition presentation only; the employee dataset is untouched. */
|
||||
workdeskPresentation?: boolean;
|
||||
/** Compact read-only status groups already convey this label in their header. */
|
||||
hideStatusLabel?: boolean;
|
||||
}
|
||||
|
||||
function text(document: Document, tag: string, className: string, value: string): HTMLElement {
|
||||
const element = document.createElement(tag);
|
||||
element.className = className;
|
||||
element.textContent = value;
|
||||
return element;
|
||||
}
|
||||
|
||||
/** Content renderer shared by the hierarchy and supplementary status groups. */
|
||||
export function createEmployeeCard(
|
||||
document: Document, employee: Employee, options: EmployeeCardOptions, signal: AbortSignal,
|
||||
): HTMLElement {
|
||||
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 displayName = presentationName ? '홍길동' : employee.name;
|
||||
const displayedName = presentationName ? '홍 길 동' : 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.workdeskPresentation) card.dataset.workdeskPresentation = 'true';
|
||||
if (compactStatus) card.dataset.cardLayout = 'compact';
|
||||
if (options.cardAsset) {
|
||||
const asset = options.cardAsset;
|
||||
card.dataset.cardAsset = 'departmentId' in asset ? asset.departmentId : 'representative';
|
||||
card.dataset.cardAssetSize = asset.size;
|
||||
const background = document.createElement('img');
|
||||
background.className = 'hm-orgchart-card-background';
|
||||
background.alt = '';
|
||||
background.setAttribute('aria-hidden', 'true');
|
||||
background.width = asset.width;
|
||||
background.height = asset.height;
|
||||
background.src = asset.url;
|
||||
card.append(background);
|
||||
}
|
||||
|
||||
// One neutral icon is shared by every card type and both runtime adapters.
|
||||
// The approved rectangular photo slots keep their existing dimensions.
|
||||
const avatar = document.createElement('span');
|
||||
avatar.className = 'hm-orgchart-avatar';
|
||||
avatar.setAttribute('aria-hidden', 'true');
|
||||
const image = document.createElement('img');
|
||||
image.alt = '';
|
||||
image.loading = 'lazy';
|
||||
image.decoding = 'async';
|
||||
image.referrerPolicy = 'no-referrer';
|
||||
const showPlaceholder = () => {
|
||||
card.dataset.photoState = 'placeholder';
|
||||
image.dataset.photoPlaceholder = 'true';
|
||||
image.src = personPlaceholder;
|
||||
};
|
||||
if (employee.profileImage) {
|
||||
card.dataset.photoState = 'image';
|
||||
image.addEventListener('error', showPlaceholder, { once: true, signal });
|
||||
// Images have already passed the dataset URL validation. No HTML parsing.
|
||||
image.src = employee.profileImage;
|
||||
} else showPlaceholder();
|
||||
avatar.append(image);
|
||||
|
||||
const details = document.createElement('div');
|
||||
details.className = 'hm-orgchart-employee-details';
|
||||
const displayedPosition = representative ? '대표이사' : 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;
|
||||
const positionRow = inlineRoleBadge ? document.createElement('div') : null;
|
||||
if (positionRow) {
|
||||
positionRow.className = 'hm-orgchart-position-row';
|
||||
positionRow.append(position);
|
||||
}
|
||||
if (styledCard) {
|
||||
const personText = document.createElement('div'); personText.className = 'hm-orgchart-person-text';
|
||||
personText.append(positionRow ?? position, name); details.append(personText);
|
||||
observePersonNameOverflow(card, name, signal);
|
||||
} else details.append(position, name);
|
||||
const attributes = document.createElement('div');
|
||||
attributes.className = 'hm-orgchart-person-attributes';
|
||||
// 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))) {
|
||||
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 || '조직장');
|
||||
badge.title = badge.textContent!;
|
||||
(positionRow ?? attributes).append(badge);
|
||||
}
|
||||
if (!representative && !options.hideStatusLabel) attributes.append(text(document, 'span', 'hm-orgchart-status-label', statusNames[employee.workStatus]));
|
||||
if (styledCard) {
|
||||
card.append(avatar, details);
|
||||
if (attributes.childElementCount) card.append(attributes);
|
||||
} else {
|
||||
if (attributes.childElementCount) details.append(attributes);
|
||||
card.append(avatar, details);
|
||||
}
|
||||
|
||||
if (options.affiliationName) {
|
||||
const affiliation = createAffiliationMark(document, employee.affiliationId, options.affiliationName, signal);
|
||||
card.dataset.hasAffiliation = 'true';
|
||||
card.append(affiliation);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Employee } from '../data/index.js';
|
||||
import type { OrgChartEmployeeClickContext } from './types.js';
|
||||
|
||||
const statusNames = { active: '재직', dispatched: '파견', onLeave: '휴직' } as const;
|
||||
|
||||
export interface EmployeeInfoDialog {
|
||||
show(employee: Employee, context: OrgChartEmployeeClickContext): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/** The native modal lives in the unscaled shell and enters the browser's top
|
||||
* layer. It keeps the Core shared by HTML and React, including focus handling. */
|
||||
export function createEmployeeInfoDialog(
|
||||
shell: HTMLElement, signal: AbortSignal, prefix: string, readOnly: boolean,
|
||||
): EmployeeInfoDialog {
|
||||
const document = shell.ownerDocument;
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.className = 'hm-orgchart-employee-dialog';
|
||||
dialog.dataset.component = 'EmployeeInfoDialog';
|
||||
dialog.setAttribute('aria-modal', 'true');
|
||||
const header = document.createElement('div');
|
||||
header.className = 'hm-orgchart-employee-dialog-header';
|
||||
const title = document.createElement('h2');
|
||||
title.id = `${prefix}-employee-dialog-title`;
|
||||
title.textContent = '직원 정보';
|
||||
dialog.setAttribute('aria-labelledby', title.id);
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.type = 'button';
|
||||
closeButton.className = 'hm-orgchart-employee-dialog-close';
|
||||
closeButton.textContent = '닫기';
|
||||
closeButton.setAttribute('aria-label', '직원 정보 닫기');
|
||||
const fields = document.createElement('dl');
|
||||
fields.className = 'hm-orgchart-employee-dialog-fields';
|
||||
header.append(title, closeButton);
|
||||
dialog.append(header, fields);
|
||||
shell.append(dialog);
|
||||
let trigger: HTMLElement | undefined;
|
||||
|
||||
function restoreFocus(): void {
|
||||
const previous = trigger;
|
||||
// close() restores synchronously, while the native close event is queued.
|
||||
// Only a real activation owns a return target: a second close notification
|
||||
// (or routine render cleanup before the dialog ever opens) must not focus
|
||||
// the shell and displace the card's restored focus.
|
||||
if (!previous) return;
|
||||
trigger = undefined;
|
||||
if (signal.aborted || !shell.isConnected) return;
|
||||
(previous?.isConnected ? previous : shell).focus({ preventScroll: true });
|
||||
}
|
||||
function close(): void {
|
||||
if (dialog.open) dialog.close();
|
||||
restoreFocus();
|
||||
}
|
||||
closeButton.addEventListener('click', close, { signal });
|
||||
dialog.addEventListener('cancel', event => {
|
||||
event.preventDefault();
|
||||
close();
|
||||
}, { signal });
|
||||
dialog.addEventListener('close', () => { if (!dialog.open) restoreFocus(); }, { signal });
|
||||
dialog.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Tab') return;
|
||||
const focusable = [...dialog.querySelectorAll<HTMLElement>('button:not(:disabled), a[href], [tabindex="0"]')];
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = (dialog.getRootNode() as ShadowRoot).activeElement;
|
||||
if ((event.shiftKey && active === first) || (!event.shiftKey && active === last) || !dialog.contains(active)) {
|
||||
event.preventDefault();
|
||||
(event.shiftKey ? last : first)?.focus();
|
||||
}
|
||||
}, { signal });
|
||||
signal.addEventListener('abort', () => {
|
||||
trigger = undefined;
|
||||
if (dialog.open) dialog.close();
|
||||
dialog.remove();
|
||||
}, { once: true });
|
||||
|
||||
return {
|
||||
close,
|
||||
show(employee, context) {
|
||||
if (signal.aborted) return;
|
||||
// Never synthesize contact details, dates or other personal information.
|
||||
const entries: [string, string | null | undefined][] = [
|
||||
['이름', readOnly ? context.trigger.querySelector('.hm-orgchart-person-name')?.textContent ?? employee.name : employee.name],
|
||||
['직위', employee.position],
|
||||
['소속', context.organization?.name],
|
||||
['직책', employee.jobTitle],
|
||||
['관계사', context.affiliation?.name],
|
||||
['근무 상태', statusNames[employee.workStatus]],
|
||||
];
|
||||
fields.replaceChildren();
|
||||
for (const [label, value] of entries) {
|
||||
if (!value) continue;
|
||||
const term = document.createElement('dt');
|
||||
term.textContent = label;
|
||||
const detail = document.createElement('dd');
|
||||
detail.textContent = value;
|
||||
fields.append(term, detail);
|
||||
}
|
||||
dialog.dataset.employeeId = employee.id;
|
||||
trigger = context.trigger;
|
||||
if (!dialog.open) dialog.showModal();
|
||||
closeButton.focus({ preventScroll: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type OrgChart from 'orgchart';
|
||||
import type { EngineNode } from 'orgchart';
|
||||
|
||||
export type RenderEngineNode = EngineNode;
|
||||
export interface EngineAdapter {
|
||||
update(data: RenderEngineNode[]): void;
|
||||
toggle(node: HTMLElement): boolean | undefined;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/** Importing the public facade never evaluates the upstream browser-only module. */
|
||||
export async function loadEngine() {
|
||||
const { default: Upstream } = await import('orgchart');
|
||||
// 6.0.0 has no destroy(). Its init observer is local to triggerInitEvent.
|
||||
// Override that narrow hook on our subclass to keep the resource disposable.
|
||||
return class ManagedOrgChart extends Upstream {
|
||||
declare initObserver?: MutationObserver;
|
||||
override init(options?: Parameters<OrgChart['init']>[0]): OrgChart {
|
||||
try {
|
||||
return super.init(options);
|
||||
} catch (error) {
|
||||
this.disconnectInitObserver();
|
||||
this.chart?.remove();
|
||||
this.chart = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
override triggerInitEvent(): void {
|
||||
this.disconnectInitObserver();
|
||||
const Observer = this.chartContainer?.ownerDocument.defaultView?.MutationObserver;
|
||||
if (!Observer || !this.chartContainer) return;
|
||||
const observer = new Observer(() => {
|
||||
observer.disconnect();
|
||||
if (this.initObserver === observer) this.initObserver = undefined;
|
||||
if (this.chart) this.handleCompactNodes();
|
||||
});
|
||||
this.initObserver = observer;
|
||||
observer.observe(this.chartContainer, { childList: true });
|
||||
}
|
||||
disconnectInitObserver(): void {
|
||||
this.initObserver?.disconnect();
|
||||
this.initObserver = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type EngineConstructor = Awaited<ReturnType<typeof loadEngine>>;
|
||||
|
||||
export function createEngineAdapter(
|
||||
Engine: EngineConstructor,
|
||||
viewport: HTMLElement,
|
||||
data: RenderEngineNode[],
|
||||
decorate: (node: HTMLElement, data: RenderEngineNode, signal: AbortSignal) => void,
|
||||
): EngineAdapter {
|
||||
const Abort = viewport.ownerDocument.defaultView?.AbortController ?? AbortController;
|
||||
let generation = new Abort();
|
||||
let instances: (OrgChart & { disconnectInitObserver(): void })[] = [];
|
||||
let disposed = false;
|
||||
const forest = viewport.ownerDocument.createElement('div');
|
||||
forest.className = 'hm-orgchart-forest';
|
||||
viewport.append(forest);
|
||||
function releaseGeneration(): void {
|
||||
generation.abort();
|
||||
for (const instance of instances) {
|
||||
instance.disconnectInitObserver();
|
||||
// Keep the old chart reference until upstream feature cleanup is complete.
|
||||
instance.unbindPan(); instance.unbindZoom(); instance.detachMinimap();
|
||||
}
|
||||
}
|
||||
function disposeInstances(): void {
|
||||
releaseGeneration();
|
||||
for (const instance of instances) {
|
||||
instance.chart?.remove();
|
||||
instance.chart = null; instance.chartContainer = null;
|
||||
instance.opts = {}; instance.options = {};
|
||||
}
|
||||
instances = [];
|
||||
forest.replaceChildren();
|
||||
}
|
||||
function build(roots: RenderEngineNode[]): void {
|
||||
try {
|
||||
for (const root of roots) {
|
||||
const treeContainer = viewport.ownerDocument.createElement('div');
|
||||
treeContainer.className = 'hm-orgchart-tree';
|
||||
forest.append(treeContainer);
|
||||
const instance = new Engine(treeContainer, {
|
||||
data: root, pan: false, zoom: false, minimap: false, draggable: false, exportButton: false,
|
||||
chartClass: 'hm-orgchart-engine',
|
||||
nodeTemplate: () => '<article class="hm-orgchart-organization"></article>',
|
||||
createNode: (node, value) => decorate(node, value, generation.signal),
|
||||
});
|
||||
instances.push(instance);
|
||||
}
|
||||
} catch (error) { disposeInstances(); throw error; }
|
||||
}
|
||||
function syncButtons(): void {
|
||||
for (const instance of instances) {
|
||||
for (const node of instance.chart?.querySelectorAll<HTMLElement>('.node') ?? []) {
|
||||
const button = node.querySelector<HTMLButtonElement>('.hm-orgchart-toggle');
|
||||
if (!button) continue;
|
||||
const expanded = instance.getNodeState(node, 'children').visible && !node.closest('.hierarchy')?.classList.contains('isChildrenCollapsed');
|
||||
button.setAttribute('aria-expanded', String(expanded));
|
||||
const label = expanded ? '하위 조직 접기' : '하위 조직 펼치기';
|
||||
button.textContent = label;
|
||||
button.setAttribute('aria-label', `${node.querySelector('.hm-orgchart-organization-name')?.textContent ?? ''} ${label}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
function bindGeneration(): void {
|
||||
forest.addEventListener('transitionend', syncButtons, { signal: generation.signal });
|
||||
}
|
||||
build(data);
|
||||
bindGeneration();
|
||||
return {
|
||||
update(next) {
|
||||
if (disposed) throw new Error('OrgChart engine is disposed');
|
||||
releaseGeneration();
|
||||
generation = new Abort();
|
||||
if (next.length === instances.length) {
|
||||
for (let index = 0; index < next.length; index++) instances[index].init({ data: next[index] });
|
||||
} else {
|
||||
disposeInstances();
|
||||
generation = new Abort();
|
||||
build(next);
|
||||
}
|
||||
bindGeneration();
|
||||
},
|
||||
toggle(node) {
|
||||
const instance = instances.find(candidate => candidate.chart?.contains(node));
|
||||
if (!instance || node.closest('.hierarchy')?.querySelector('.sliding')) return undefined;
|
||||
const expanded = instance.getNodeState(node, 'children').visible;
|
||||
if (expanded) instance.hideChildren(node); else instance.showChildren(node);
|
||||
syncButtons();
|
||||
return !expanded;
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
disposeInstances();
|
||||
viewport.replaceChildren();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import { calculateSummary, normalizeOrgChartDataset, validateOrgChartDataset } from '../data/index.js';
|
||||
import type { 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';
|
||||
import { createOrgChartTree } from './org-chart-tree.js';
|
||||
import { createOrgChartContent } from './org-chart-content.js';
|
||||
import { createPositionSummary } from './position-summary.js';
|
||||
import { createAffiliationSummary } from './affiliation-summary.js';
|
||||
import { createStatusSummary } from './status-summary.js';
|
||||
import { createEmployeeInfoDialog } from './employee-info-dialog.js';
|
||||
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';
|
||||
|
||||
const owners = new WeakMap<HTMLElement, symbol>();
|
||||
let instanceSequence = 0;
|
||||
|
||||
function freeze<T>(value: T, seen = new WeakSet<object>()): T {
|
||||
if (value && typeof value === 'object' && !seen.has(value)) {
|
||||
seen.add(value);
|
||||
for (const child of Object.values(value)) freeze(child, seen);
|
||||
Object.freeze(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function snapshot(input: unknown): unknown {
|
||||
// JSON objects are captured before any async boundary. A failure is controlled.
|
||||
return freeze(structuredClone(input));
|
||||
}
|
||||
|
||||
/** Framework-independent facade; construction has no DOM or request side effects. */
|
||||
export function createOrgChart(options: OrgChartOptions): OrgChartHandle {
|
||||
return createOrgChartView(options, 'composition');
|
||||
}
|
||||
|
||||
export type CoreView = 'composition' | 'tree' | 'positionSummary' | 'affiliationSummary' | 'statusSummary';
|
||||
|
||||
/** One source/validation/lifecycle controller for full and standalone views.
|
||||
* Summary-only views never load the engine or require its stylesheet. */
|
||||
export function createOrgChartView(options: OrgChartOptions, view: CoreView): OrgChartHandle {
|
||||
const needsEngine = view === 'composition' || view === 'tree';
|
||||
if (!options.dataSource || typeof options.dataSource.load !== 'function') throw new TypeError('An OrgChartDataSource is required');
|
||||
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"');
|
||||
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;
|
||||
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');
|
||||
const href = value.trim();
|
||||
if (!href) continue;
|
||||
let url: URL;
|
||||
try { url = new URL(href, 'https://orgchart.invalid/'); }
|
||||
catch { throw new TypeError('organizationLinks must contain valid HTTP(S) or relative URLs'); }
|
||||
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new TypeError('organizationLinks must contain valid HTTP(S) or relative URLs');
|
||||
organizationLinks[id] = href;
|
||||
}
|
||||
let preferredContainer = options.container;
|
||||
let source: OrgChartDataSource | undefined = options.dataSource;
|
||||
let policy = options.aggregationPolicy ? freeze(structuredClone(options.aggregationPolicy)) : undefined;
|
||||
let state: OrgChartState = freeze({ status: 'idle' });
|
||||
let destroyed = false;
|
||||
let revision = 0;
|
||||
let request: AbortController | undefined;
|
||||
let container: HTMLElement | undefined;
|
||||
let root: HTMLElement | undefined;
|
||||
let shell: HTMLElement | undefined;
|
||||
let status: HTMLElement | undefined;
|
||||
let content: HTMLElement | undefined;
|
||||
let viewport: HTMLElement | undefined;
|
||||
let engine: EngineAdapter | undefined;
|
||||
let renderContext: RenderContext | undefined;
|
||||
let Engine: EngineConstructor | undefined;
|
||||
let styleReady: Promise<readonly ValidationIssue[] | undefined> | undefined;
|
||||
let cancelStyles: (() => void) | undefined;
|
||||
let contentGeneration: AbortController | undefined;
|
||||
let interactions: AbortController | undefined;
|
||||
let employeeDialog: EmployeeInfoDialog | undefined;
|
||||
const owner = Symbol('hm-orgchart-owner');
|
||||
const prefix = `hm-orgchart-${++instanceSequence}`;
|
||||
const listeners = new Set<(state: OrgChartState) => void>();
|
||||
if (options.onStateChange) listeners.add(options.onStateChange);
|
||||
|
||||
function active(version: number): boolean { return !destroyed && version === revision; }
|
||||
|
||||
function notify(next: OrgChartState): void {
|
||||
state = freeze(next);
|
||||
const notifiedState = state;
|
||||
for (const listener of [...listeners]) {
|
||||
// Reentrant update/reload/destroy may supersede this notification.
|
||||
if (destroyed || state !== notifiedState) break;
|
||||
if (!listeners.has(listener)) continue;
|
||||
try { listener(notifiedState); } catch { /* Consumer callbacks do not break Core lifecycle. */ }
|
||||
}
|
||||
}
|
||||
|
||||
function assertMounted(): void {
|
||||
if (destroyed) throw new Error('OrgChart handle is destroyed; create a new handle');
|
||||
if (!container) throw new Error('Mount OrgChart before update or reload');
|
||||
}
|
||||
|
||||
function begin(): { version: number; signal: AbortSignal } {
|
||||
request?.abort();
|
||||
const Abort = container?.ownerDocument.defaultView?.AbortController ?? AbortController;
|
||||
request = new Abort();
|
||||
const version = ++revision;
|
||||
status!.textContent = '조직도를 불러오는 중입니다.';
|
||||
shell!.setAttribute('aria-busy', 'true');
|
||||
const signal = request.signal;
|
||||
notify({ status: 'loading' });
|
||||
return { version, signal };
|
||||
}
|
||||
|
||||
function releaseView(): void {
|
||||
employeeDialog?.close();
|
||||
contentGeneration?.abort();
|
||||
contentGeneration = undefined;
|
||||
engine?.dispose();
|
||||
engine = undefined;
|
||||
renderContext = undefined;
|
||||
viewport = undefined;
|
||||
content?.replaceChildren();
|
||||
}
|
||||
|
||||
function activateEmployee(event: MouseEvent | KeyboardEvent): void {
|
||||
if (event.defaultPrevented || !renderContext || !content) return;
|
||||
const Element = content.ownerDocument.defaultView?.Element;
|
||||
if (!Element || !(event.target instanceof Element)) return;
|
||||
// Links, collapse buttons and any future nested controls own their events.
|
||||
if (event.target.closest('a, button, input, select, textarea')) return;
|
||||
const card = event.target.closest<HTMLElement>('.hm-orgchart-employee');
|
||||
if (!card || !content.contains(card)) return;
|
||||
const employee = renderContext.data.employees.find(item => item.id === card.dataset.employeeId);
|
||||
if (!employee) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
card.focus({ preventScroll: true });
|
||||
// A name tooltip can be open from pointer hover. Close it before opening
|
||||
// information UI; the existing hover/focus tooltip behavior stays intact.
|
||||
for (const tip of shell?.querySelectorAll<HTMLElement>('.hm-orgchart-person-name-tooltip') ?? []) tip.hidden = true;
|
||||
for (const name of content.querySelectorAll<HTMLElement>('[data-tooltip-open="true"]')) name.dataset.tooltipOpen = 'false';
|
||||
const context: OrgChartEmployeeClickContext = {
|
||||
organization: renderContext.data.organizations.find(item => item.id === employee.organizationId),
|
||||
affiliation: renderContext.data.affiliations.find(item => item.id === employee.affiliationId),
|
||||
trigger: card,
|
||||
originalEvent: event,
|
||||
};
|
||||
if (employeeClickCallback) {
|
||||
try { employeeClickCallback(employee, context); }
|
||||
catch { /* Consumer callbacks do not interrupt Core event/lifecycle ownership. */ }
|
||||
} else employeeDialog?.show(employee, context);
|
||||
}
|
||||
|
||||
function prepareEmployeeCards(element: HTMLElement): void {
|
||||
for (const card of element.querySelectorAll<HTMLElement>('.hm-orgchart-employee')) {
|
||||
card.tabIndex = 0;
|
||||
card.setAttribute('role', 'button');
|
||||
if (!employeeClickCallback) card.setAttribute('aria-haspopup', 'dialog');
|
||||
const name = card.querySelector('.hm-orgchart-person-name')?.textContent ?? '';
|
||||
const position = card.querySelector('.hm-orgchart-person-role')?.textContent ?? '';
|
||||
card.setAttribute('aria-label', `${name} ${position} 직원 정보 보기`);
|
||||
}
|
||||
}
|
||||
|
||||
function fail(version: number, issues: readonly ValidationIssue[]): void {
|
||||
if (!active(version)) return;
|
||||
releaseView();
|
||||
shell!.setAttribute('aria-busy', 'false');
|
||||
status!.textContent = '조직도를 표시할 수 없습니다.';
|
||||
const document = content!.ownerDocument;
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'hm-orgchart-issues';
|
||||
for (const issue of issues) {
|
||||
const row = document.createElement('li');
|
||||
row.textContent = `${issue.code} · ${issue.path}: ${issue.message}`;
|
||||
list.append(row);
|
||||
}
|
||||
content!.append(list);
|
||||
notify({ status: 'error', issues });
|
||||
}
|
||||
|
||||
async function apply(input: unknown, version: number): Promise<void> {
|
||||
if (!active(version)) return;
|
||||
let captured: unknown;
|
||||
try { captured = snapshot(input); }
|
||||
catch {
|
||||
fail(version, [{ code: 'INPUT_SNAPSHOT', path: '$', message: 'Input must be a cloneable JSON dataset' }]);
|
||||
return;
|
||||
}
|
||||
const validation = validateOrgChartDataset(captured);
|
||||
if (!validation.valid) { fail(version, validation.issues); return; }
|
||||
const data = freeze(validation.data);
|
||||
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 };
|
||||
// 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 styleIssues = await styleReady;
|
||||
if (!active(version)) return;
|
||||
if (styleIssues) { fail(version, styleIssues); return; }
|
||||
const LoadedEngine = needsEngine ? Engine ?? await loadEngine() : undefined;
|
||||
// Last-started operation wins even if an upstream engine import was pending.
|
||||
if (!active(version)) return;
|
||||
if (LoadedEngine) Engine = LoadedEngine;
|
||||
const document = content!.ownerDocument;
|
||||
employeeDialog?.close();
|
||||
contentGeneration?.abort();
|
||||
const Abort = document.defaultView?.AbortController ?? AbortController;
|
||||
contentGeneration = new Abort();
|
||||
renderContext = nextContext;
|
||||
if (needsEngine && !viewport) {
|
||||
viewport = createOrgChartTree(document);
|
||||
content!.append(viewport);
|
||||
}
|
||||
const activeElement = root!.shadowRoot?.activeElement;
|
||||
if (needsEngine) {
|
||||
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));
|
||||
});
|
||||
}
|
||||
// If regeneration removed keyboard focus, offer the stable viewport next.
|
||||
const nextContent = view === 'composition' ? createOrgChartContent(document, nextContext, viewport!, contentGeneration.signal)
|
||||
: view === 'tree' ? viewport!
|
||||
: view === 'positionSummary' ? createPositionSummary(document, nextContext)
|
||||
: view === 'affiliationSummary' ? createAffiliationSummary(document, nextContext)
|
||||
: createStatusSummary(document, nextContext, contentGeneration.signal);
|
||||
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 });
|
||||
shell!.setAttribute('aria-busy', 'false');
|
||||
status!.textContent = '';
|
||||
if (!active(version)) return;
|
||||
notify({ status: 'ready', dataVersion: data.metadata.dataVersion, summary, aggregation: summary.aggregation });
|
||||
}
|
||||
|
||||
async function reload(nextSource?: OrgChartDataSource): Promise<void> {
|
||||
assertMounted();
|
||||
if (nextSource) {
|
||||
if (typeof nextSource.load !== 'function') throw new TypeError('An OrgChartDataSource is required');
|
||||
source = nextSource;
|
||||
}
|
||||
const { version, signal } = begin();
|
||||
if (!active(version)) return;
|
||||
let input: unknown;
|
||||
try {
|
||||
input = await source!.load({ signal });
|
||||
} catch (error) {
|
||||
if (!active(version) || signal.aborted) return;
|
||||
const sourceCode = error && typeof error === 'object' && 'code' in error && ['SOURCE_LOAD', 'SOURCE_PARSE', 'SOURCE_MAP', 'SOURCE_URL', 'SOURCE_SNAPSHOT'].includes(String(error.code)) ? String(error.code) : 'SOURCE_LOAD';
|
||||
fail(version, [{ code: sourceCode, path: 'dataSource', message: 'Data source or chart rendering failed; verify the source and CSS configuration' }]);
|
||||
return;
|
||||
}
|
||||
try { if (active(version)) await apply(input, version); }
|
||||
catch { fail(version, [{ code: 'CORE_RENDER', path: 'core', message: 'Chart rendering failed' }]); }
|
||||
}
|
||||
|
||||
return {
|
||||
async mount(nextContainer = preferredContainer) {
|
||||
if (destroyed) throw new Error('OrgChart handle is destroyed; create a new handle');
|
||||
if (container) throw new Error('OrgChart is already mounted');
|
||||
const Element = nextContainer?.ownerDocument.defaultView?.HTMLElement;
|
||||
if (!Element || !(nextContainer instanceof Element) || !nextContainer.isConnected) throw new TypeError('Mount requires a connected HTMLElement');
|
||||
if (owners.has(nextContainer)) throw new Error('Another OrgChart instance owns this container');
|
||||
container = nextContainer;
|
||||
owners.set(container, owner);
|
||||
const document = container.ownerDocument;
|
||||
root = document.createElement('div');
|
||||
root.className = 'hm-orgchart-host';
|
||||
root.dataset.orgchartInstance = prefix;
|
||||
const shadow = root.attachShadow({ mode: 'open' });
|
||||
const styleDisposers: (() => void)[] = [];
|
||||
const styles = [...(needsEngine ? [{ key: 'engine', href: stylesheetUrls.engine }] : []), { key: 'core', href: stylesheetUrls.core }];
|
||||
styleReady = Promise.all(styles.map(({ key, href }) => new Promise<ValidationIssue | undefined>(resolve => {
|
||||
const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = href;
|
||||
let settled = false;
|
||||
const finish = (issue?: ValidationIssue) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
link.removeEventListener('load', loaded); link.removeEventListener('error', failed);
|
||||
resolve(issue);
|
||||
};
|
||||
const loaded = () => finish();
|
||||
const failed = () => finish({ code: 'CORE_STYLE', path: `stylesheetUrls.${key}`, message: 'Required OrgChart stylesheet failed to load' });
|
||||
link.addEventListener('load', loaded); link.addEventListener('error', failed);
|
||||
styleDisposers.push(() => finish());
|
||||
shadow.append(link);
|
||||
}))).then(results => {
|
||||
const issues = results.filter((issue): issue is ValidationIssue => !!issue);
|
||||
return issues.length ? issues : undefined;
|
||||
});
|
||||
cancelStyles = () => { for (const dispose of styleDisposers) dispose(); };
|
||||
shell = document.createElement('section');
|
||||
shell.className = 'hm-orgchart-shell';
|
||||
// The Core's Korean labels must shape identically in ko HTML and en Storybook hosts.
|
||||
shell.lang = 'ko';
|
||||
shell.tabIndex = -1;
|
||||
shell.setAttribute('aria-label', '조직도 콘텐츠');
|
||||
status = document.createElement('p'); status.className = 'hm-orgchart-state'; status.setAttribute('role', 'status'); status.setAttribute('aria-live', 'polite');
|
||||
content = document.createElement('div'); content.className = 'hm-orgchart-content';
|
||||
shell.append(status, content); shadow.append(shell); container.append(root);
|
||||
const Abort = document.defaultView?.AbortController ?? AbortController;
|
||||
interactions = new Abort();
|
||||
employeeDialog = createEmployeeInfoDialog(shell, interactions.signal, prefix, readOnly);
|
||||
content.addEventListener('click', activateEmployee, { signal: interactions.signal });
|
||||
content.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') activateEmployee(event);
|
||||
}, { signal: interactions.signal });
|
||||
await reload();
|
||||
},
|
||||
async update(input) {
|
||||
assertMounted();
|
||||
// Capture before begin() invokes callbacks that can mutate consumer input.
|
||||
let captured: unknown;
|
||||
try { captured = snapshot(input); }
|
||||
catch {
|
||||
const { version } = begin();
|
||||
fail(version, [{ code: 'INPUT_SNAPSHOT', path: '$', message: 'Input must be a cloneable JSON dataset' }]);
|
||||
return;
|
||||
}
|
||||
const { version } = begin();
|
||||
if (!active(version)) return;
|
||||
try { await apply(captured, version); }
|
||||
catch { fail(version, [{ code: 'CORE_RENDER', path: 'core', message: 'Chart rendering failed' }]); }
|
||||
},
|
||||
reload,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
++revision;
|
||||
request?.abort(); request = undefined;
|
||||
cancelStyles?.(); cancelStyles = undefined; styleReady = undefined;
|
||||
interactions?.abort(); interactions = undefined;
|
||||
employeeDialog = undefined;
|
||||
releaseView();
|
||||
root?.remove();
|
||||
if (container && owners.get(container) === owner) owners.delete(container);
|
||||
container = undefined; preferredContainer = undefined;
|
||||
root = undefined; shell = undefined; status = undefined; content = undefined;
|
||||
source = undefined; policy = undefined; Engine = undefined;
|
||||
listeners.clear();
|
||||
state = freeze({ status: 'destroyed' });
|
||||
},
|
||||
subscribe(listener) {
|
||||
if (destroyed) throw new Error('OrgChart handle is destroyed; create a new handle');
|
||||
if (typeof listener !== 'function') throw new TypeError('Listener must be a function');
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
},
|
||||
getState() { return state; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { RenderContext } from './render';
|
||||
import { createAffiliationMark } from './affiliation-mark';
|
||||
|
||||
export function createLogoLegend(document: Document, context: RenderContext, signal: AbortSignal): HTMLElement {
|
||||
const section = document.createElement('section');
|
||||
section.className = 'hm-orgchart-logo-legend';
|
||||
const heading = document.createElement('h2'); heading.textContent = '관계사·회사 로고';
|
||||
const list = document.createElement('ul'); list.className = 'hm-orgchart-logo-list';
|
||||
for (const affiliation of context.data.affiliations) {
|
||||
const item = document.createElement('li');
|
||||
const name = document.createElement('span'); name.textContent = affiliation.name;
|
||||
item.append(createAffiliationMark(document, affiliation.id, affiliation.name, signal), name);
|
||||
list.append(item);
|
||||
}
|
||||
section.append(heading, list);
|
||||
return section;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { createEmployeeCard } from './employee-card.js';
|
||||
import { createHeading, type RenderContext } from './render.js';
|
||||
import { createPositionSummary } from './position-summary.js';
|
||||
import { createAffiliationSummary } from './affiliation-summary.js';
|
||||
import { createStatusSummary } from './status-summary.js';
|
||||
import { representativeCardAsset } from './representative-card-asset.js';
|
||||
import { createOrganizationTotals } from './organization-totals.js';
|
||||
import { createAffiliationBar } from './affiliation-bar.js';
|
||||
import { applyWorkdeskLayout } from './workdesk-layout.js';
|
||||
import { addWorkdeskArtwork } from './workdesk-artwork.js';
|
||||
|
||||
type SummaryComponent = 'PositionSummary' | 'AffiliationSummary' | 'StatusSummary';
|
||||
type ContentSlot = 'side' | 'footer' | 'belowTree';
|
||||
export interface ContentComposition {
|
||||
placements: Record<SummaryComponent, ContentSlot>;
|
||||
}
|
||||
/** Internal composition contract: move modules here without editing the tree,
|
||||
* recalculating data, adding React UI, or changing the npm public API. */
|
||||
export const defaultComposition: ContentComposition = {
|
||||
placements: { PositionSummary: 'side', AffiliationSummary: 'footer', StatusSummary: 'belowTree' },
|
||||
};
|
||||
|
||||
export function createOrgChartContent(
|
||||
document: Document, context: RenderContext, tree: HTMLElement,
|
||||
signal: AbortSignal, composition: ContentComposition = defaultComposition,
|
||||
): HTMLElement {
|
||||
// Scale wrapper (`content`) > Base Stage (`stage`). Only the stage is transformed.
|
||||
const readOnly = context.readOnly === true;
|
||||
const content = document.createElement('div');
|
||||
content.className = 'hm-orgchart-composition';
|
||||
content.dataset.component = 'OrgChartContent';
|
||||
content.dataset.displayMode = readOnly ? 'readOnly' : 'interactive';
|
||||
const stage = document.createElement('div');
|
||||
stage.className = 'hm-orgchart-stage';
|
||||
content.append(stage);
|
||||
const main = document.createElement('div');
|
||||
main.className = 'hm-orgchart-main';
|
||||
const heading = createHeading(document, context);
|
||||
const leaders = document.createElement('section');
|
||||
leaders.className = 'hm-orgchart-top-leaders';
|
||||
leaders.setAttribute('aria-label', '최상위 조직 리더');
|
||||
for (const id of context.topLeaderIds) {
|
||||
const employee = context.data.employees.find(item => item.id === id)!;
|
||||
leaders.append(createEmployeeCard(document, employee, {
|
||||
kind: 'topLeader',
|
||||
workdeskPresentation: readOnly,
|
||||
cardAsset: representativeCardAsset,
|
||||
affiliationName: context.data.affiliations.find(item => item.id === employee.affiliationId)?.name,
|
||||
}, signal));
|
||||
}
|
||||
const hub = document.createElement('p');
|
||||
hub.className = 'hm-orgchart-hub';
|
||||
hub.dataset.component = 'CollaborationHub';
|
||||
hub.textContent = context.data.presentation?.hubText ?? '';
|
||||
if (readOnly && hub.textContent) {
|
||||
// Actual inline icon from Workdesk index.html .hub-icon; Frame2 is decoration.
|
||||
const label = document.createElement('span');
|
||||
label.className = 'hm-orgchart-hub-label';
|
||||
label.textContent = hub.textContent;
|
||||
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
icon.classList.add('hm-orgchart-hub-icon');
|
||||
icon.setAttribute('viewBox', '0 0 24 24');
|
||||
icon.setAttribute('fill', 'none');
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.setAttribute('focusable', 'false');
|
||||
for (const d of ['M8.5 11.2h7', 'M8.5 15h4.7', 'M7.2 18.6 4 21v-4.7a7.2 7.2 0 0 1-1.6-4.5C2.4 7.6 6.2 4.2 11 4.2s8.6 3.4 8.6 7.6-3.8 7.6-8.6 7.6c-1.3 0-2.6-.3-3.8-.8Z']) {
|
||||
const path = document.createElementNS(icon.namespaceURI, 'path');
|
||||
path.setAttribute('d', d);
|
||||
icon.append(path);
|
||||
}
|
||||
hub.replaceChildren(icon, label);
|
||||
}
|
||||
if (readOnly) {
|
||||
// Title on the left; the top leader and hub share the canvas axis column.
|
||||
const axis = document.createElement('div');
|
||||
axis.className = 'hm-orgchart-axis';
|
||||
if (leaders.childElementCount) axis.append(leaders);
|
||||
if (hub.textContent) axis.append(hub);
|
||||
main.append(heading, axis);
|
||||
// No scroll container in the fitted canvas: a labelled, non-focusable region.
|
||||
tree.removeAttribute('tabindex');
|
||||
tree.setAttribute('aria-label', '조직 계층도');
|
||||
if (applyWorkdeskLayout(tree)) addWorkdeskArtwork(stage, hub, signal);
|
||||
} else {
|
||||
const intro = document.createElement('div');
|
||||
intro.className = 'hm-orgchart-intro';
|
||||
intro.append(heading);
|
||||
if (leaders.childElementCount) intro.append(leaders);
|
||||
main.append(intro);
|
||||
if (hub.textContent) main.append(hub);
|
||||
}
|
||||
main.append(tree);
|
||||
const slots = { side: document.createElement('aside'), footer: document.createElement('div'), belowTree: document.createElement('div') };
|
||||
for (const [slot, element] of Object.entries(slots)) {
|
||||
element.className = `hm-orgchart-slot-${slot}`;
|
||||
element.dataset.contentSlot = slot;
|
||||
}
|
||||
slots.side.setAttribute('aria-label', '조직도 인원 현황');
|
||||
const groups = context.data.presentation?.groups;
|
||||
// Each module consumes the same context and can be hidden independently.
|
||||
// Read-only uses compact presenters of the same Summary; standalone components are unchanged.
|
||||
const modules: [SummaryComponent, boolean, () => HTMLElement][] = [
|
||||
['PositionSummary', groups?.showSummary !== false, () => readOnly ? createOrganizationTotals(document, context) : createPositionSummary(document, context)],
|
||||
['AffiliationSummary', groups?.showAffiliations !== false, () => readOnly ? createAffiliationBar(document, context, signal) : createAffiliationSummary(document, context)],
|
||||
['StatusSummary', groups?.showDispatched !== false || groups?.showOnLeave !== false, () => createStatusSummary(document, context, signal, { compact: readOnly })],
|
||||
];
|
||||
for (const [name, visible, create] of modules) {
|
||||
if (!visible) continue;
|
||||
const element = create();
|
||||
element.dataset.component = name;
|
||||
slots[composition.placements[name]].append(element);
|
||||
}
|
||||
if (slots.belowTree.childElementCount) main.append(slots.belowTree);
|
||||
stage.append(main);
|
||||
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);
|
||||
observeStageScale(content, stage, signal, readOnly);
|
||||
return content;
|
||||
}
|
||||
|
||||
/** Proportional fit: one uniform scale for the whole stage, never above 1.
|
||||
* The stage's offsetWidth/Height are unscaled layout sizes, so the wrapper
|
||||
* height is derived as height * scale. One observer and one pending frame per
|
||||
* instance; both end when the content generation aborts.
|
||||
* `fitHeight` fits the actual containing content box and viewport. It never
|
||||
* assumes a host Header or reserves an arbitrary 48px. */
|
||||
function observeStageScale(wrapper: HTMLElement, stage: HTMLElement, signal: AbortSignal, fitHeight = false): void {
|
||||
const view = wrapper.ownerDocument.defaultView;
|
||||
if (!view) return;
|
||||
let frame: number | undefined;
|
||||
let applied = { scale: NaN, width: NaN, height: NaN };
|
||||
const ancestors: HTMLElement[] = [];
|
||||
const collectAncestors = () => {
|
||||
// The constructor runs before insertion; discover the real host only once
|
||||
// the wrapper is connected. Refresh when a host reparents its chart too.
|
||||
const next: HTMLElement[] = [];
|
||||
let ancestor: HTMLElement | null = wrapper.parentElement;
|
||||
while (ancestor) {
|
||||
next.push(ancestor);
|
||||
const parent: HTMLElement | null = ancestor.parentElement;
|
||||
const root = ancestor.getRootNode();
|
||||
ancestor = parent ?? (root instanceof view.ShadowRoot ? root.host as HTMLElement : null);
|
||||
}
|
||||
if (fitHeight) {
|
||||
for (const element of ancestors) if (!next.includes(element)) observer.unobserve(element);
|
||||
for (const element of next) if (!ancestors.includes(element)) observer.observe(element);
|
||||
}
|
||||
ancestors.splice(0, ancestors.length, ...next);
|
||||
};
|
||||
const heightLimit = () => {
|
||||
const top = wrapper.getBoundingClientRect().top;
|
||||
let bottom = view.innerHeight;
|
||||
let trailingPadding = 0;
|
||||
for (const element of ancestors) {
|
||||
const css = view.getComputedStyle(element);
|
||||
const padding = parseFloat(css.paddingBottom) || 0;
|
||||
trailingPadding += padding;
|
||||
// Typed OM keeps 'auto' distinct from a resolved natural pixel height.
|
||||
// Falling back to an inline height still supports explicit host containers.
|
||||
const typed = (element as HTMLElement & { computedStyleMap?: () => Map<string, { toString(): string }> }).computedStyleMap?.();
|
||||
const height = typed?.get('height')?.toString() ?? element.style.height;
|
||||
const boundedHeight = (!!height && height !== 'auto') || css.maxHeight !== 'none';
|
||||
const constrained = boundedHeight && element !== wrapper.ownerDocument.body && element !== wrapper.ownerDocument.documentElement;
|
||||
if (constrained) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
bottom = Math.min(bottom, rect.bottom - (parseFloat(css.borderBottomWidth) || 0) - trailingPadding);
|
||||
}
|
||||
}
|
||||
return Math.max(0, Math.min(bottom - top, view.innerHeight - top - trailingPadding));
|
||||
};
|
||||
const apply = () => {
|
||||
frame = undefined;
|
||||
if (signal.aborted || !wrapper.isConnected) return;
|
||||
collectAncestors();
|
||||
const available = wrapper.clientWidth; // integer floor: never overflows the host
|
||||
const baseWidth = stage.offsetWidth;
|
||||
const baseHeight = stage.offsetHeight;
|
||||
if (!available || !baseWidth || !baseHeight) return;
|
||||
let scale = Math.min(1, available / baseWidth);
|
||||
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;
|
||||
const scaleChanged = scale !== applied.scale;
|
||||
applied = { scale, width: baseWidth, height: baseHeight };
|
||||
stage.style.transform = scale < 1 ? `scale(${scale})` : '';
|
||||
wrapper.style.height = `${baseHeight * scale}px`;
|
||||
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 }));
|
||||
};
|
||||
const schedule = () => { if (frame === undefined && !signal.aborted) frame = view.requestAnimationFrame(apply); };
|
||||
const observer = new view.ResizeObserver(schedule);
|
||||
observer.observe(wrapper);
|
||||
observer.observe(stage);
|
||||
wrapper.ownerDocument.fonts?.addEventListener('loadingdone', schedule, { signal });
|
||||
// Viewport height changes do not resize the wrapper; observe them directly.
|
||||
if (fitHeight) view.addEventListener('resize', schedule, { signal });
|
||||
signal.addEventListener('abort', () => {
|
||||
observer.disconnect();
|
||||
if (frame !== undefined) view.cancelAnimationFrame(frame);
|
||||
frame = undefined;
|
||||
}, { once: true });
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Stable engine/focus boundary; summaries never become engine nodes. */
|
||||
export function createOrgChartTree(document: Document): HTMLElement {
|
||||
const viewport = document.createElement('div');
|
||||
viewport.className = 'hm-orgchart-viewport';
|
||||
viewport.dataset.component = 'OrgChartTree';
|
||||
viewport.tabIndex = 0;
|
||||
viewport.setAttribute('role', 'region');
|
||||
viewport.setAttribute('aria-label', '조직 계층도 가로 스크롤 영역');
|
||||
return viewport;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { selectMajorOrganizationTotals, selectOrganizationPositionCounts } from '../data/content-selectors.js';
|
||||
import type { RenderContext } from './render.js';
|
||||
|
||||
function text(document: Document, tag: string, className: string, value: string): HTMLElement {
|
||||
const element = document.createElement(tag);
|
||||
element.className = className;
|
||||
element.textContent = value;
|
||||
return element;
|
||||
}
|
||||
|
||||
/** Read-only Workdesk matrix: disjoint major organization rows, position
|
||||
* columns and one subtotal. The independent PositionSummary retains its
|
||||
* detailed direct/inclusive counts, aggregation policy and public API. */
|
||||
export function createOrganizationTotals(document: Document, context: RenderContext): HTMLElement {
|
||||
const section = document.createElement('section');
|
||||
section.className = 'hm-orgchart-organization-totals';
|
||||
section.dataset.contentSection = 'organization-totals';
|
||||
const title = text(document, 'h3', 'hm-orgchart-organization-totals-title', '직위별 인원 현황 (팀별)');
|
||||
section.append(title);
|
||||
|
||||
// Keep the source's five position columns where they exist. Additional
|
||||
// declared positions remain visible rather than being dropped or relabelled.
|
||||
const declaredPositions = Object.keys(context.summary.raw.byPosition);
|
||||
const preferredPositions = ['경영진', '수석', '책임', '선임', '연구'];
|
||||
const positions = [
|
||||
...preferredPositions.filter(position => declaredPositions.includes(position)),
|
||||
...declaredPositions.filter(position => !preferredPositions.includes(position)),
|
||||
];
|
||||
section.style.setProperty('--hm-orgchart-workdesk-summary-width', `${84 + (positions.length + 1) * 41}px`);
|
||||
const matrix = selectOrganizationPositionCounts(context.data);
|
||||
const children = new Map<string, string[]>();
|
||||
for (const organization of context.data.organizations) {
|
||||
if (organization.parentId === null) continue;
|
||||
const siblings = children.get(organization.parentId) ?? [];
|
||||
siblings.push(organization.id);
|
||||
children.set(organization.parentId, siblings);
|
||||
}
|
||||
const frame = document.createElement('div');
|
||||
frame.className = 'hm-orgchart-workdesk-summary-frame';
|
||||
const table = document.createElement('table');
|
||||
table.className = 'hm-orgchart-workdesk-position-table';
|
||||
table.setAttribute('aria-label', '직위별 인원 현황 (팀별)');
|
||||
const columns = document.createElement('colgroup');
|
||||
for (let index = 0; index < positions.length + 2; index++) {
|
||||
const column = document.createElement('col');
|
||||
column.className = index === 0 ? 'hm-orgchart-workdesk-label-column' : 'hm-orgchart-workdesk-value-column';
|
||||
columns.append(column);
|
||||
}
|
||||
const head = document.createElement('thead');
|
||||
const header = document.createElement('tr');
|
||||
for (const label of ['팀원', ...positions, '소계']) {
|
||||
const cell = document.createElement('th');
|
||||
cell.scope = 'col';
|
||||
cell.textContent = label;
|
||||
header.append(cell);
|
||||
}
|
||||
head.append(header);
|
||||
const body = document.createElement('tbody');
|
||||
for (const { organization, scope, count } of selectMajorOrganizationTotals(context.data, context.summary)) {
|
||||
const row = document.createElement('tr');
|
||||
row.dataset.organizationId = organization.id;
|
||||
row.dataset.countScope = scope;
|
||||
const label = document.createElement('th');
|
||||
label.scope = 'row';
|
||||
label.textContent = organization.name;
|
||||
row.append(label);
|
||||
const organizations = [organization.id];
|
||||
if (scope === 'inclusive') {
|
||||
for (let index = 0; index < organizations.length; index++) organizations.push(...children.get(organizations[index]!) ?? []);
|
||||
}
|
||||
for (const position of positions) {
|
||||
const positionCount = organizations.reduce((sum, id) => sum + (matrix.get(id)?.get(position) ?? 0), 0);
|
||||
const cell = text(document, 'td', 'hm-orgchart-workdesk-count-cell', String(positionCount));
|
||||
cell.dataset.position = position;
|
||||
row.append(cell);
|
||||
}
|
||||
row.append(text(document, 'td', 'hm-orgchart-workdesk-count-cell hm-orgchart-workdesk-subtotal', String(count)));
|
||||
body.append(row);
|
||||
}
|
||||
const foot = document.createElement('tfoot');
|
||||
const total = document.createElement('tr');
|
||||
const totalLabel = document.createElement('th');
|
||||
totalLabel.scope = 'row';
|
||||
totalLabel.textContent = '계';
|
||||
total.append(totalLabel);
|
||||
for (const position of positions) total.append(text(document, 'td', 'hm-orgchart-workdesk-count-cell', String(context.summary.raw.byPosition[position] ?? 0)));
|
||||
const totalCount = text(document, 'td', 'hm-orgchart-workdesk-count-cell hm-orgchart-workdesk-subtotal', String(context.summary.raw.total));
|
||||
totalCount.dataset.totalCount = String(context.summary.raw.total);
|
||||
total.append(totalCount);
|
||||
foot.append(total);
|
||||
table.append(columns, head, body, foot);
|
||||
frame.append(table);
|
||||
section.append(frame);
|
||||
return section;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
let tooltipSequence = 0;
|
||||
|
||||
/** Presentation-only overflow. Keep the complete text node for accessibility;
|
||||
* cropped names enter the tab order and expose a hoverable/dismissible tooltip.
|
||||
* Each observer/listener/tooltip belongs to the existing card abort boundary. */
|
||||
export function observePersonNameOverflow(card: HTMLElement, name: HTMLElement, signal: AbortSignal): void {
|
||||
const document = card.ownerDocument;
|
||||
const window = document.defaultView;
|
||||
if (!window) return;
|
||||
const tooltip = document.createElement('span');
|
||||
tooltip.id = `hm-orgchart-person-name-tip-${++tooltipSequence}`;
|
||||
tooltip.className = 'hm-orgchart-person-name-tooltip';
|
||||
tooltip.setAttribute('role', 'tooltip');
|
||||
tooltip.textContent = name.textContent;
|
||||
tooltip.hidden = true;
|
||||
let timer: number | undefined;
|
||||
let hoveringTooltip = false;
|
||||
let scaleListening = false;
|
||||
const focused = () => (name.getRootNode() as ShadowRoot).activeElement === name;
|
||||
function cancelTimer(): void { if (timer !== undefined) window!.clearTimeout(timer); timer = undefined; }
|
||||
function hide(): void { cancelTimer(); tooltip.hidden = true; name.dataset.tooltipOpen = 'false'; }
|
||||
function refresh(): void {
|
||||
if (signal.aborted || !name.isConnected || name.clientWidth === 0) return;
|
||||
const overflow = name.scrollWidth > name.clientWidth;
|
||||
name.dataset.nameOverflow = String(overflow);
|
||||
if (overflow) {
|
||||
name.tabIndex = 0;
|
||||
name.setAttribute('aria-describedby', tooltip.id);
|
||||
// Fixed positioning avoids adding hidden content to tree/document scroll
|
||||
// extents. Append inside the same Shadow Root for IDREF accessibility.
|
||||
const shell = card.closest('.hm-orgchart-shell');
|
||||
shell?.append(tooltip);
|
||||
// Full Composition scale changes move cards without a window resize.
|
||||
if (shell && !scaleListening) { scaleListening = true; shell.addEventListener('hm-orgchart-scale', hide, { signal }); }
|
||||
} else {
|
||||
name.removeAttribute('tabindex');
|
||||
name.removeAttribute('aria-describedby');
|
||||
hide();
|
||||
tooltip.remove();
|
||||
}
|
||||
}
|
||||
function show(): void {
|
||||
cancelTimer();
|
||||
refresh();
|
||||
if (name.dataset.nameOverflow !== 'true') return;
|
||||
tooltip.hidden = false;
|
||||
name.dataset.tooltipOpen = 'true';
|
||||
const bounds = name.getBoundingClientRect();
|
||||
const tip = tooltip.getBoundingClientRect();
|
||||
const left = Math.max(8, Math.min(bounds.left, window!.innerWidth - tip.width - 8));
|
||||
const below = bounds.bottom + 6;
|
||||
const top = below + tip.height <= window!.innerHeight - 8 ? below : Math.max(8, bounds.top - tip.height - 6);
|
||||
tooltip.style.left = `${left}px`;
|
||||
tooltip.style.top = `${top}px`;
|
||||
}
|
||||
name.addEventListener('mouseenter', show, { signal });
|
||||
name.addEventListener('mouseleave', () => {
|
||||
if (!focused()) timer = window.setTimeout(() => { if (!hoveringTooltip) hide(); }, 180);
|
||||
}, { signal });
|
||||
name.addEventListener('focus', show, { signal });
|
||||
name.addEventListener('blur', () => { if (!hoveringTooltip) hide(); }, { signal });
|
||||
name.addEventListener('keydown', event => { if (event.key === 'Escape') hide(); }, { signal });
|
||||
tooltip.addEventListener('mouseenter', () => { hoveringTooltip = true; cancelTimer(); }, { signal });
|
||||
tooltip.addEventListener('mouseleave', () => { hoveringTooltip = false; if (!focused()) hide(); }, { signal });
|
||||
window.addEventListener('scroll', hide, { capture: true, signal });
|
||||
window.addEventListener('resize', hide, { signal });
|
||||
document.fonts.addEventListener('loadingdone', refresh, { signal });
|
||||
const observer = new window.ResizeObserver(refresh);
|
||||
observer.observe(name);
|
||||
void document.fonts.ready.then(refresh);
|
||||
signal.addEventListener('abort', () => { cancelTimer(); observer.disconnect(); tooltip.remove(); }, { once: true });
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { selectOrganizationPositionCounts } from '../data/content-selectors.js';
|
||||
import type { RenderContext } from './render.js';
|
||||
|
||||
function text(document: Document, tag: string, className: string, value: string): HTMLElement {
|
||||
const element = document.createElement(tag);
|
||||
element.className = className;
|
||||
element.textContent = value;
|
||||
return element;
|
||||
}
|
||||
|
||||
/** Independent, passive content component; composition controls its visibility and placement. */
|
||||
export function createPositionSummary(document: Document, context: RenderContext): HTMLElement {
|
||||
const section = document.createElement('section');
|
||||
section.className = 'hm-orgchart-summary hm-orgchart-position-summary';
|
||||
section.setAttribute('aria-label', '조직 및 직위별 원본 인원 현황');
|
||||
section.dataset.contentSection = 'position-summary';
|
||||
section.append(text(document, 'h3', 'hm-orgchart-summary-title', '조직 · 직위별 인원 현황'));
|
||||
section.append(text(document, 'p', 'hm-orgchart-raw-total', `Raw Count · 원본 직원 전체 ${context.summary.raw.total}명`));
|
||||
section.append(text(document, 'p', 'hm-orgchart-policy', '원본 직원 ID를 직접 소속 조직에서 한 번 집계합니다. 조직장 · 파견 · 휴직을 포함하며 업무 총원 정책을 뜻하지 않습니다.'));
|
||||
section.append(text(document, 'p', 'hm-orgchart-policy', context.summary.aggregation.status === 'ready'
|
||||
? `승인 정책 ${context.summary.aggregation.policyVersion}: 총원 ${context.summary.aggregation.counts.total}명`
|
||||
: '정책 총원 미확정: 집계 정책 승인이 필요합니다.'));
|
||||
|
||||
const scroll = document.createElement('div');
|
||||
scroll.className = 'hm-orgchart-summary-scroll';
|
||||
scroll.tabIndex = 0;
|
||||
scroll.setAttribute('role', 'region');
|
||||
scroll.setAttribute('aria-label', '조직 및 직위별 인원 표, 가로 스크롤 가능');
|
||||
const table = document.createElement('table');
|
||||
table.className = 'hm-orgchart-position-table';
|
||||
const caption = document.createElement('caption');
|
||||
caption.textContent = '직위별 교차 인원과 소계는 직접 소속 기준입니다. 하위 포함 수는 서로 합산하지 않습니다.';
|
||||
const positions = Object.keys(context.summary.raw.byPosition);
|
||||
const matrix = selectOrganizationPositionCounts(context.data);
|
||||
const head = document.createElement('thead');
|
||||
const headerRow = document.createElement('tr');
|
||||
for (const label of ['조직', ...positions, '소계 (직접)', '하위 포함']) {
|
||||
const cell = document.createElement('th');
|
||||
cell.scope = 'col';
|
||||
cell.textContent = label;
|
||||
headerRow.append(cell);
|
||||
}
|
||||
head.append(headerRow);
|
||||
const body = document.createElement('tbody');
|
||||
for (const organization of context.orderedOrganizations) {
|
||||
const row = document.createElement('tr');
|
||||
row.dataset.organizationId = organization.id;
|
||||
const name = document.createElement('th');
|
||||
name.scope = 'row';
|
||||
name.textContent = organization.name;
|
||||
row.append(name);
|
||||
for (const position of positions) row.append(text(document, 'td', 'hm-orgchart-count-cell', String(matrix.get(organization.id)?.get(position) ?? 0)));
|
||||
const counts = context.summary.raw.byOrganization[organization.id];
|
||||
row.append(text(document, 'td', 'hm-orgchart-count-cell hm-orgchart-subtotal', String(counts?.direct ?? 0)));
|
||||
row.append(text(document, 'td', 'hm-orgchart-count-cell hm-orgchart-inclusive', String(counts?.inclusive ?? 0)));
|
||||
body.append(row);
|
||||
}
|
||||
const foot = document.createElement('tfoot');
|
||||
const totalRow = document.createElement('tr');
|
||||
const totalLabel = document.createElement('th');
|
||||
totalLabel.scope = 'row';
|
||||
totalLabel.textContent = '전체 합계';
|
||||
totalRow.append(totalLabel);
|
||||
for (const position of positions) totalRow.append(text(document, 'td', 'hm-orgchart-count-cell', String(context.summary.raw.byPosition[position] ?? 0)));
|
||||
totalRow.append(text(document, 'td', 'hm-orgchart-count-cell', String(context.summary.raw.total)));
|
||||
const inclusiveTotal = text(document, 'td', 'hm-orgchart-inclusive', '—');
|
||||
inclusiveTotal.setAttribute('aria-label', '하위 포함 수는 중복되어 합산하지 않음');
|
||||
totalRow.append(inclusiveTotal);
|
||||
foot.append(totalRow);
|
||||
table.append(caption, head, body, foot);
|
||||
scroll.append(table);
|
||||
section.append(scroll);
|
||||
|
||||
section.append(text(document, 'h4', 'hm-orgchart-group-title', '근무 상태별 원본 직원'));
|
||||
const statuses = document.createElement('dl');
|
||||
statuses.className = 'hm-orgchart-summary-group hm-orgchart-status-totals';
|
||||
for (const [key, label] of [['active', '재직'], ['dispatched', '파견'], ['onLeave', '휴직']] as const) {
|
||||
statuses.append(text(document, 'dt', '', label), text(document, 'dd', '', `${context.summary.raw.byStatus[key]}명`));
|
||||
}
|
||||
section.append(statuses);
|
||||
return section;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { Employee, OrgChartDataset, OrgChartSummary, OrganizationTreeNode } from '../data/index.js';
|
||||
import type { RenderEngineNode } from './engine.js';
|
||||
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';
|
||||
|
||||
const organizationNames = { root: '최상위 조직', division: '부문', office: '실', div: 'Div', cell: '셀', team: '팀' } as const;
|
||||
|
||||
export interface RenderContext {
|
||||
data: OrgChartDataset;
|
||||
summary: OrgChartSummary;
|
||||
orderedOrganizations: OrganizationTreeNode['organization'][];
|
||||
employeesByOrganization: ReadonlyMap<string, readonly Employee[]>;
|
||||
topLeaderIds: ReadonlySet<string>;
|
||||
/** View-only composition: no collapse controls or diagnostic text are created. */
|
||||
readOnly?: boolean;
|
||||
organizationLinks?: OrgChartOrganizationLinks;
|
||||
}
|
||||
|
||||
export function engineTree(roots: OrganizationTreeNode[], prefix: string, context: RenderContext, startDepth = 0): RenderEngineNode[] {
|
||||
function convert(tree: OrganizationTreeNode, depth: number, variant: number): RenderEngineNode {
|
||||
variant = organizationStylePresets.find(preset => preset.organizationId === tree.organization.id)?.id ?? variant;
|
||||
const index = context.orderedOrganizations.length;
|
||||
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)) } : {}),
|
||||
};
|
||||
}
|
||||
// Multiple actual roots stay independent; no fictitious organizational parent.
|
||||
return roots.map((tree, index) => convert(tree, startDepth, index % 6));
|
||||
}
|
||||
|
||||
function text(document: Document, tag: string, className: string, value: string): HTMLElement {
|
||||
const element = document.createElement(tag);
|
||||
element.className = className;
|
||||
element.textContent = value;
|
||||
return element;
|
||||
}
|
||||
|
||||
export function decorateNode(
|
||||
node: HTMLElement, value: RenderEngineNode, context: RenderContext,
|
||||
signal: AbortSignal, toggle: (node: HTMLElement) => boolean | undefined,
|
||||
): void {
|
||||
const organization = context.orderedOrganizations[Number(value.organizationIndex)];
|
||||
if (!organization) return;
|
||||
const document = node.ownerDocument;
|
||||
const card = node.querySelector('.hm-orgchart-organization')!;
|
||||
node.dataset.organizationId = organization.id;
|
||||
node.dataset.depth = String(value.depth);
|
||||
// Engine roots can be flattened for composition. Card policy uses the
|
||||
// original ancestry rather than the engine's display depth or job title.
|
||||
let organizationDepth = 0;
|
||||
let ancestor = organization;
|
||||
while (ancestor.parentId) {
|
||||
const parent = context.data.organizations.find(item => item.id === ancestor.parentId);
|
||||
if (!parent) break;
|
||||
organizationDepth++;
|
||||
ancestor = parent;
|
||||
}
|
||||
node.dataset.organizationDepth = String(organizationDepth);
|
||||
applyOrganizationStyle(node, Number(value.groupVariant));
|
||||
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';
|
||||
// Hide upstream mouse-only arrows, retaining the engine's hierarchy connectors.
|
||||
for (const edge of node.querySelectorAll('.edge')) edge.setAttribute('aria-hidden', 'true');
|
||||
const readOnly = context.readOnly === true;
|
||||
// Kind, ID, raw counts and vacancy notes are diagnostic; read-only shows the header and people only.
|
||||
if (!readOnly) card.append(text(document, 'span', 'hm-orgchart-kind', organizationNames[organization.type]));
|
||||
const heading = text(document, 'h3', 'hm-orgchart-organization-name', organization.name);
|
||||
const href = organizationDepth === 1 ? context.organizationLinks?.[organization.id] : undefined;
|
||||
if (href) {
|
||||
const link = document.createElement('a');
|
||||
link.className = 'hm-orgchart-organization-link';
|
||||
link.href = href;
|
||||
link.textContent = organization.name;
|
||||
heading.replaceChildren(link);
|
||||
// Native links retain keyboard and browser navigation semantics; this
|
||||
// click must not also reach the upstream node selection/collapse handler.
|
||||
link.addEventListener('click', event => event.stopPropagation(), { signal });
|
||||
link.addEventListener('keydown', event => event.stopPropagation(), { signal });
|
||||
}
|
||||
card.append(heading);
|
||||
if (!readOnly) {
|
||||
const id = text(document, 'span', 'hm-orgchart-domain-id', organization.id);
|
||||
id.title = organization.id;
|
||||
card.append(id);
|
||||
const counts = context.summary.raw.byOrganization[organization.id];
|
||||
card.append(text(document, 'p', 'hm-orgchart-node-count', `원본 직원: 직접 ${counts?.direct ?? 0}명 · 하위 포함 ${counts?.inclusive ?? 0}명`));
|
||||
}
|
||||
const leader = organization.leaderId ? context.data.employees.find(employee => employee.id === organization.leaderId) : undefined;
|
||||
const affiliationName = (employee: Employee) => context.data.affiliations.find(item => item.id === employee.affiliationId)?.name;
|
||||
if (leader && !context.topLeaderIds.has(leader.id)) card.append(createEmployeeCard(document, leader, {
|
||||
kind: 'leader', affiliationName: affiliationName(leader), workdeskPresentation: readOnly,
|
||||
cardAsset: getDepartmentCardAsset(organization.id, readOnly && organizationDepth !== 1 ? 'employee' : 'leader', context.data.organizations),
|
||||
}, signal));
|
||||
else if (readOnly) { /* The top leader card or an empty slot needs no placeholder text. */ }
|
||||
else if (leader) card.append(text(document, 'p', 'hm-orgchart-vacancy', '조직장: 상단 독립 카드'));
|
||||
else card.append(text(document, 'p', 'hm-orgchart-vacancy', '조직장 미지정'));
|
||||
if (context.data.presentation?.groups.showEmployees !== false) {
|
||||
const employees = document.createElement('div');
|
||||
employees.className = 'hm-orgchart-employees';
|
||||
for (const employee of context.employeesByOrganization.get(organization.id) ?? []) {
|
||||
if (employee.id !== leader?.id && !context.topLeaderIds.has(employee.id)) employees.append(createEmployeeCard(document, employee, { kind: 'employee', workdeskPresentation: readOnly, affiliationName: affiliationName(employee), cardAsset: getDepartmentCardAsset(organization.id, 'employee', context.data.organizations) }, signal));
|
||||
}
|
||||
card.append(employees);
|
||||
}
|
||||
// Read-only hierarchy is always fully expanded; no toggle (and no leftover space) is created.
|
||||
if (value.children?.length && !readOnly) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'hm-orgchart-toggle';
|
||||
button.textContent = '하위 조직 접기';
|
||||
button.setAttribute('aria-label', `${organization.name} 하위 조직 접기`);
|
||||
button.setAttribute('aria-expanded', 'true');
|
||||
button.addEventListener('click', event => {
|
||||
event.stopPropagation();
|
||||
const expanded = toggle(node);
|
||||
if (expanded === undefined) return;
|
||||
button.setAttribute('aria-expanded', String(expanded));
|
||||
const label = expanded ? '하위 조직 접기' : '하위 조직 펼치기';
|
||||
button.textContent = label;
|
||||
button.setAttribute('aria-label', `${organization.name} ${label}`);
|
||||
}, { signal });
|
||||
card.append(button);
|
||||
}
|
||||
}
|
||||
|
||||
export function createHeading(document: Document, context: RenderContext): HTMLElement {
|
||||
const header = document.createElement('header');
|
||||
header.className = 'hm-orgchart-heading';
|
||||
header.append(text(document, 'h2', 'hm-orgchart-title', context.data.presentation?.title ?? '조직도'));
|
||||
// Read-only keeps the title and as-of date; description and relation notes are explanatory text.
|
||||
if (context.data.presentation?.description && !context.readOnly) header.append(text(document, 'p', 'hm-orgchart-description', context.data.presentation.description));
|
||||
header.append(text(document, 'p', 'hm-orgchart-date', `기준일 ${context.data.metadata.asOfDate}`));
|
||||
if (context.data.relations?.length && !context.readOnly) header.append(text(document, 'p', 'hm-orgchart-description', `추가 관계 ${context.data.relations.length}건은 이번 기본 조직 계층선에 포함되지 않습니다.`));
|
||||
return header;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import url from '../assets/cards/대표자.svg?inline';
|
||||
|
||||
export interface RepresentativeCardAsset {
|
||||
size: 'representative';
|
||||
label: string;
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Separate from department mapping: only the standalone topLeader uses it. */
|
||||
export const representativeCardAsset: RepresentativeCardAsset = {
|
||||
size: 'representative', label: '대표자 전용', url, width: 146, height: 74,
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { selectStatusGroups } from '../data/content-selectors.js';
|
||||
import { createEmployeeCard } from './employee-card.js';
|
||||
import type { RenderContext } from './render.js';
|
||||
|
||||
/** `compact` is the read-only composition form: label-only headers and no policy note. */
|
||||
export function createStatusSummary(document: Document, context: RenderContext, signal: AbortSignal, { compact = false } = {}): HTMLElement {
|
||||
const section = document.createElement('section');
|
||||
section.className = 'hm-orgchart-status-summary';
|
||||
section.dataset.contentSection = 'status-summary';
|
||||
section.setAttribute('aria-label', '파견 및 휴직 직원');
|
||||
const affiliations = new Map(context.data.affiliations.map(affiliation => [affiliation.id, affiliation.name]));
|
||||
for (const { status, employees, count } of selectStatusGroups(context.data, context.summary)) {
|
||||
const group = document.createElement('section');
|
||||
group.className = 'hm-orgchart-status-group';
|
||||
group.dataset.workStatus = status;
|
||||
const label = status === 'dispatched' ? '파견' : '휴직';
|
||||
group.setAttribute('aria-label', `${label} 직원`);
|
||||
const heading = document.createElement('h3');
|
||||
heading.className = 'hm-orgchart-summary-title';
|
||||
heading.textContent = compact ? label : `${label} · ${count}명`;
|
||||
if (compact) heading.setAttribute('aria-label', `${label} ${count}명`);
|
||||
group.append(heading);
|
||||
const cards = document.createElement('div');
|
||||
cards.className = 'hm-orgchart-status-employees';
|
||||
for (const employee of employees) {
|
||||
cards.append(createEmployeeCard(document, employee, { kind: 'employee', workdeskPresentation: context.readOnly,
|
||||
hideStatusLabel: compact && context.readOnly, affiliationName: affiliations.get(employee.affiliationId) }, signal));
|
||||
}
|
||||
group.append(cards);
|
||||
if (!employees.length) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'hm-orgchart-content-empty';
|
||||
empty.textContent = `${label} 직원이 없습니다.`;
|
||||
group.append(empty);
|
||||
}
|
||||
if (!compact) {
|
||||
const note = document.createElement('p');
|
||||
note.className = 'hm-orgchart-policy';
|
||||
note.textContent = '원본 직원의 상태별 별도 표시입니다. 전체 인원에 중복 합산하지 않습니다.';
|
||||
group.append(note);
|
||||
}
|
||||
section.append(group);
|
||||
}
|
||||
return section;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/** Presentation-only colors measured from Workdesk organization headers.
|
||||
* Header measurements do not verify employee card edges/fills. Standalone
|
||||
* OrgChart defaults and employee JSON remain separate from this local config. */
|
||||
const status = 'WORKDESK_COMPUTED' as const;
|
||||
const source = 'https://workdesk.hmac.kr/' as const;
|
||||
const observedOn = '2026-10-12' as const;
|
||||
const version = 'v1.2610.9c5e' as const;
|
||||
const foregroundVariable = '--hm-orgchart-resolved-on-action-primary' as const;
|
||||
const cardBackground = 'var(--hm-orgchart-resolved-bg-surface)' as const;
|
||||
const departmentColor = '#ffffff' as const;
|
||||
const cardStyleStatus = 'DESIGN_ADAPTATION' as const;
|
||||
const verificationScope = 'Organization header background only; employee card edges/fills NOT VERIFIED' as const;
|
||||
|
||||
export const organizationStylePresets = [
|
||||
{
|
||||
id: 0, label: '사업관리', accentColor: '#194d82',
|
||||
organizationId: 'business-management', variablePrefix: '--hm-orgchart-business-management',
|
||||
cardBackground, departmentColor, borderColor: '#194d82', cardStyleStatus,
|
||||
backgroundImage: 'linear-gradient(180deg, #194d82 0%, #1d395c 100%)',
|
||||
foregroundVariable, status, source, observedOn, version, selector: '.h33', verificationScope,
|
||||
},
|
||||
{
|
||||
id: 1, label: '경영기획', accentColor: '#2164a8',
|
||||
organizationId: 'business-planning', variablePrefix: '--hm-orgchart-business-planning',
|
||||
cardBackground, departmentColor, borderColor: '#2164a8', cardStyleStatus,
|
||||
backgroundImage: 'linear-gradient(180deg, #2164a8 0%, #254975 100%)',
|
||||
foregroundVariable, status, source, observedOn, version, selector: '.h33.org-team-link', verificationScope,
|
||||
},
|
||||
{
|
||||
id: 2, label: '인재성장', accentColor: '#0f6ace',
|
||||
organizationId: 'talent-growth', variablePrefix: '--hm-orgchart-talent-growth',
|
||||
cardBackground, departmentColor, borderColor: '#0f6ace', cardStyleStatus,
|
||||
backgroundImage: 'linear-gradient(180deg, #0f6ace 0%, #044a94 100%)',
|
||||
foregroundVariable, status, source, observedOn, version, selector: '.h38.org-team-link', verificationScope,
|
||||
},
|
||||
{
|
||||
id: 3, label: '통합시스템 Div', accentColor: '#8c67b6',
|
||||
organizationId: 'integrated-system', variablePrefix: '--hm-orgchart-integrated-system',
|
||||
cardBackground, departmentColor, borderColor: '#8c67b6', cardStyleStatus,
|
||||
backgroundImage: 'linear-gradient(180deg, #8c67b6 0%, #5d3494 100%)',
|
||||
foregroundVariable, status, source, observedOn, version, selector: '.h310.org-team-link', verificationScope,
|
||||
},
|
||||
{
|
||||
id: 4, label: '디자인기획', accentColor: '#09a7a2',
|
||||
organizationId: 'design-planning', variablePrefix: '--hm-orgchart-design-planning',
|
||||
cardBackground, departmentColor, borderColor: '#09a7a2', cardStyleStatus,
|
||||
backgroundImage: 'linear-gradient(180deg, #09a7a2 0%, #00847f 100%)',
|
||||
foregroundVariable, status, source, observedOn, version, selector: '.h312.org-team-link', verificationScope,
|
||||
},
|
||||
{
|
||||
id: 5, label: '솔루션영업', accentColor: '#5a963c',
|
||||
organizationId: 'solution-sales', variablePrefix: '--hm-orgchart-solution-sales',
|
||||
cardBackground, departmentColor, borderColor: '#5a963c', cardStyleStatus,
|
||||
backgroundImage: 'linear-gradient(180deg, #5a963c 0%, #3f7629 100%)',
|
||||
foregroundVariable, status, source, observedOn, version, selector: '.h36.org-team-link', verificationScope,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function applyOrganizationStyle(element: HTMLElement, variant: number): void {
|
||||
const preset = organizationStylePresets[variant] ?? organizationStylePresets[0];
|
||||
element.dataset.groupVariant = String(preset.id);
|
||||
const prefix = preset.variablePrefix;
|
||||
element.style.setProperty('--hm-orgchart-current-card-background',
|
||||
`var(${prefix}-card-background, var(--hm-orgchart-card-background, ${preset.cardBackground}))`);
|
||||
element.style.setProperty('--hm-orgchart-current-card-color',
|
||||
`var(${prefix}-card-color, var(--hm-orgchart-card-color, var(--hm-orgchart-resolved-fg-primary)))`);
|
||||
element.style.setProperty('--hm-orgchart-current-department-color',
|
||||
`var(${prefix}-department-color, var(--hm-orgchart-department-color, ${preset.departmentColor}))`);
|
||||
element.style.setProperty('--hm-orgchart-current-border',
|
||||
`var(${prefix}-border, var(--hm-orgchart-border, ${preset.borderColor}))`);
|
||||
element.style.setProperty('--hm-orgchart-current-gradient',
|
||||
`var(${prefix}-gradient, var(--hm-orgchart-gradient, ${preset.backgroundImage}))`);
|
||||
// Compatibility aliases remain local to this presentation element.
|
||||
element.style.setProperty('--hm-orgchart-accent', 'var(--hm-orgchart-current-border)');
|
||||
element.style.setProperty('--hm-orgchart-group-background', 'var(--hm-orgchart-current-gradient)');
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Affiliation, ApprovedAggregationPolicy, Employee, OrgChartDataSource, OrgChartSummary, Organization, ValidationIssue } from '../data/index.js';
|
||||
|
||||
export type OrgChartState =
|
||||
| { readonly status: 'idle' | 'loading' | 'empty' | 'destroyed' }
|
||||
| { readonly status: 'ready'; readonly dataVersion: string; readonly summary: OrgChartSummary; readonly aggregation: OrgChartSummary['aggregation'] }
|
||||
| { readonly status: 'error'; readonly issues: readonly ValidationIssue[] };
|
||||
|
||||
/** 'interactive' (default) keeps collapse controls and diagnostic text.
|
||||
* 'readOnly' is the view-only Workdesk composition: expanded hierarchy, no
|
||||
* controls or development text, compact summaries on a fitted design canvas. */
|
||||
export type OrgChartDisplayMode = 'interactive' | 'readOnly';
|
||||
|
||||
/** Links are keyed by the existing organization ID. Only 1Depth titles use them. */
|
||||
export type OrgChartOrganizationLinks = Readonly<Record<string, string | undefined>>;
|
||||
|
||||
export interface OrgChartEmployeeClickContext {
|
||||
readonly organization: Organization | undefined;
|
||||
readonly affiliation: Affiliation | undefined;
|
||||
/** Focusable card that activated this request, outside the default dialog. */
|
||||
readonly trigger: HTMLElement;
|
||||
readonly originalEvent: MouseEvent | KeyboardEvent;
|
||||
}
|
||||
|
||||
export interface OrgChartOptions {
|
||||
container?: HTMLElement;
|
||||
dataSource: OrgChartDataSource;
|
||||
aggregationPolicy?: ApprovedAggregationPolicy;
|
||||
/** Resolved CSS URLs inserted in the instance's Shadow DOM, in this order. */
|
||||
stylesheetUrls: { engine: string; core: string };
|
||||
/** Full composition presentation only; standalone views ignore it. */
|
||||
displayMode?: OrgChartDisplayMode;
|
||||
/** 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.
|
||||
* Called for every employee card in interactive and readOnly modes. */
|
||||
onEmployeeClick?: (employee: Employee, context: OrgChartEmployeeClickContext) => void;
|
||||
onStateChange?: (state: OrgChartState) => void;
|
||||
}
|
||||
|
||||
export interface OrgChartHandle {
|
||||
mount(container?: HTMLElement): Promise<void>;
|
||||
update(input: unknown): Promise<void>;
|
||||
reload(source?: OrgChartDataSource): Promise<void>;
|
||||
/** Terminal, idempotent disposal. Create a new handle for a later mount. */
|
||||
destroy(): void;
|
||||
subscribe(listener: (state: OrgChartState) => void): () => void;
|
||||
getState(): OrgChartState;
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
declare module '*.svg?inline' {
|
||||
const url: string;
|
||||
export default url;
|
||||
}
|
||||
declare module '*.png?inline' {
|
||||
const url: string;
|
||||
export default url;
|
||||
}
|
||||
declare module 'orgchart' {
|
||||
export interface EngineNode { id: string; name: string; children?: EngineNode[]; [key: string]: unknown }
|
||||
export interface EngineOptions {
|
||||
data: EngineNode | EngineNode[];
|
||||
pan: boolean;
|
||||
zoom: boolean;
|
||||
minimap: boolean;
|
||||
draggable: boolean;
|
||||
exportButton: boolean;
|
||||
chartClass: string;
|
||||
nodeTemplate: () => string;
|
||||
createNode: (node: HTMLElement, data: EngineNode) => void;
|
||||
}
|
||||
export default class OrgChart {
|
||||
constructor(container: HTMLElement, options: EngineOptions);
|
||||
chart: HTMLElement | null;
|
||||
chartContainer: HTMLElement | null;
|
||||
opts: Partial<EngineOptions>;
|
||||
options: Partial<EngineOptions>;
|
||||
init(options?: Partial<EngineOptions>): OrgChart;
|
||||
triggerInitEvent(): void;
|
||||
handleCompactNodes(): void;
|
||||
getNodeState(node: HTMLElement, relation: string): { visible: boolean };
|
||||
hideChildren(node: HTMLElement): void;
|
||||
showChildren(node: HTMLElement): void;
|
||||
unbindPan(): void;
|
||||
unbindZoom(): void;
|
||||
detachMinimap(): void;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import ring from '../assets/workdesk/Group.svg?inline';
|
||||
import sky from '../assets/workdesk/bg_sky.png?inline';
|
||||
import meeting from '../assets/workdesk/bg_main.png?inline';
|
||||
import hubFrame from '../assets/workdesk/Frame2@3x.png?inline';
|
||||
import grid from '../assets/workdesk/1-8-4x-1@2x.png?inline';
|
||||
import blueShort from '../assets/workdesk/team-arrow-blue-short.png?inline';
|
||||
import bluePurple from '../assets/workdesk/team-arrow-blue-purple.png?inline';
|
||||
import purpleTeal from '../assets/workdesk/team-arrow-purple-teal.png?inline';
|
||||
import tealGreen from '../assets/workdesk/team-arrow-teal-green.png?inline';
|
||||
import { workdeskCanvas } from './workdesk-layout.js';
|
||||
|
||||
const svgNamespace = 'http://www.w3.org/2000/svg';
|
||||
const hubPaths = [
|
||||
['hub-to-business', 'M411.134 66.9559C456.067 45.0289 560.802 1.17493 695.801 1.17493'],
|
||||
['hub-to-design-solution', 'M1258.34 66.9559C1203.65 45.0289 1044.265 1.17493 848.038 1.17493'],
|
||||
['hub-to-teams-arc', 'M143.631104 74.8701C350.174 18.7487 874.423 -59.8214 1660.47 74.8701'],
|
||||
] as const;
|
||||
const arrows = [
|
||||
['blue-short-1', blueShort, 194.15, 311.75, 66.25, 19.8],
|
||||
['blue-short-2', blueShort, 430.40, 311.75, 66.25, 19.8],
|
||||
['blue-purple', bluePurple, 666.65, 302, 183.75, 42],
|
||||
['purple-teal', purpleTeal, 1044, 302, 258.916, 42],
|
||||
['teal-green', tealGreen, 1520.55, 305, 135.349, 36],
|
||||
] as const;
|
||||
|
||||
/** Local, byte-preserved Workdesk artwork embedded by the package bundler.
|
||||
* All geometry belongs to the same stage as the cards (one uniform scale).
|
||||
* Original compact screenD paths are from org-dashboard-data.js; the obsolete
|
||||
* static HTML fourth hub path and hidden executive branch are not rendered. */
|
||||
export function addWorkdeskArtwork(stage: HTMLElement, hub: HTMLElement, signal: AbortSignal): void {
|
||||
const document = stage.ownerDocument;
|
||||
stage.dataset.workdeskArtwork = 'true';
|
||||
const decoration = (id: string, source: string, parent = stage) => {
|
||||
const image = document.createElement('img');
|
||||
image.className = `hm-orgchart-workdesk-decoration hm-orgchart-workdesk-${id}`;
|
||||
image.dataset.workdeskAsset = id;
|
||||
image.alt = '';
|
||||
image.setAttribute('aria-hidden', 'true');
|
||||
image.draggable = false;
|
||||
image.src = source;
|
||||
parent.append(image);
|
||||
};
|
||||
decoration('ring', ring);
|
||||
decoration('sky', sky);
|
||||
decoration('meeting', meeting);
|
||||
decoration('grid', grid);
|
||||
decoration('hub-frame', hubFrame, hub);
|
||||
|
||||
const overlay = document.createElementNS(svgNamespace, 'svg');
|
||||
overlay.classList.add('hm-orgchart-workdesk-overlay');
|
||||
overlay.setAttribute('viewBox', `0 0 ${workdeskCanvas.width} ${workdeskCanvas.height}`);
|
||||
overlay.setAttribute('aria-hidden', 'true');
|
||||
overlay.setAttribute('focusable', 'false');
|
||||
const path = (id: string, d: string, parent: SVGElement = overlay) => {
|
||||
const element = document.createElementNS(svgNamespace, 'path');
|
||||
element.dataset.connector = id;
|
||||
element.setAttribute('d', d);
|
||||
element.setAttribute('fill', 'none');
|
||||
element.setAttribute('stroke', '#45639F');
|
||||
element.setAttribute('stroke-width', '0.893023');
|
||||
element.setAttribute('stroke-dasharray', '1.79 1.79');
|
||||
parent.append(element);
|
||||
return element;
|
||||
};
|
||||
const curves = document.createElementNS(svgNamespace, 'g');
|
||||
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 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');
|
||||
image.dataset.arrow = id;
|
||||
for (const [key, value] of Object.entries({ x, y, width, height, href: source, preserveAspectRatio: 'none' })) image.setAttribute(key, String(value));
|
||||
overlay.append(image);
|
||||
}
|
||||
stage.append(overlay);
|
||||
|
||||
// The original management curve follows the live header's top-center.
|
||||
// Convert transformed DOM bounds back to the stage coordinate system.
|
||||
const view = document.defaultView;
|
||||
if (!view) return;
|
||||
let frame: number | undefined;
|
||||
const update = () => {
|
||||
frame = undefined;
|
||||
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();
|
||||
const bounds = header.getBoundingClientRect();
|
||||
const scale = origin.width / stage.offsetWidth;
|
||||
if (!scale) return;
|
||||
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)}`);
|
||||
};
|
||||
const schedule = () => { if (frame === undefined && !signal.aborted) frame = view.requestAnimationFrame(update); };
|
||||
const observer = new view.ResizeObserver(schedule);
|
||||
observer.observe(stage);
|
||||
document.fonts?.addEventListener('loadingdone', schedule, { signal });
|
||||
view.addEventListener('resize', schedule, { signal });
|
||||
signal.addEventListener('abort', () => { observer.disconnect(); if (frame !== undefined) view.cancelAnimationFrame(frame); }, { once: true });
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/** Read-only composition geometry measured on the authenticated Workdesk
|
||||
* organization chart (v1.2610.9c5e, 2026-10-12). Presentation-only: keyed by
|
||||
* organization ID like style-presets; data, parentId and order stay untouched.
|
||||
* Organizations without an entry fall back to the centered flow layout. */
|
||||
export const workdeskCanvas = { width: 1920, height: 914.5, axisX: 935 } as const;
|
||||
|
||||
/** Header width equals the leader card (146px); columns are placed by header center. */
|
||||
const headerWidth = 146;
|
||||
|
||||
export interface WorkdeskColumnLayout {
|
||||
organizationId: string;
|
||||
/** Measured header left edge (px) on the 1920 canvas. */
|
||||
headerX: number;
|
||||
/** Child cells/teams: vertical stack (default) or one horizontal row. */
|
||||
children: 'vertical' | 'horizontal';
|
||||
}
|
||||
|
||||
export const workdeskColumns: readonly WorkdeskColumnLayout[] = [
|
||||
{ organizationId: 'business-management', headerX: 32, children: 'vertical' },
|
||||
{ organizationId: 'business-planning', headerX: 268, children: 'vertical' },
|
||||
{ organizationId: 'talent-growth', headerX: 505, children: 'vertical' },
|
||||
{ organizationId: 'integrated-system', headerX: 858, children: 'horizontal' },
|
||||
{ organizationId: 'design-planning', headerX: 1336, children: 'horizontal' },
|
||||
{ organizationId: 'solution-sales', headerX: 1689, children: 'vertical' },
|
||||
];
|
||||
|
||||
/** Applies column centers and child arrangement to the rendered engine forest.
|
||||
* Runs after every engine (re)build; returns false when any top-level
|
||||
* organization has no measured column, so the caller keeps the flow layout. */
|
||||
export function applyWorkdeskLayout(viewport: HTMLElement): boolean {
|
||||
const forest = viewport.querySelector<HTMLElement>('.hm-orgchart-forest');
|
||||
if (!forest) return false;
|
||||
const trees = [...forest.querySelectorAll<HTMLElement>(':scope > .hm-orgchart-tree')];
|
||||
const columns = trees.map(tree => {
|
||||
const id = tree.querySelector<HTMLElement>('.node[data-organization-id]')?.dataset.organizationId;
|
||||
return workdeskColumns.find(column => column.organizationId === id);
|
||||
});
|
||||
for (const node of forest.querySelectorAll<HTMLElement>('.node[data-organization-id]')) {
|
||||
const column = workdeskColumns.find(item => item.organizationId === node.dataset.organizationId);
|
||||
if (column?.children === 'horizontal') node.dataset.childrenLayout = 'horizontal';
|
||||
else delete node.dataset.childrenLayout;
|
||||
}
|
||||
const placed = trees.length > 0 && columns.every(Boolean);
|
||||
if (!placed) {
|
||||
delete forest.dataset.layout;
|
||||
for (const tree of trees) tree.style.removeProperty('--hm-orgchart-column-center');
|
||||
return false;
|
||||
}
|
||||
const centers = columns.map(column => column!.headerX + headerWidth / 2);
|
||||
trees.forEach((tree, index) => tree.style.setProperty('--hm-orgchart-column-center', `${centers[index]}px`));
|
||||
forest.dataset.layout = 'workdesk';
|
||||
forest.style.setProperty('--hm-orgchart-branch-start', `${Math.min(...centers)}px`);
|
||||
forest.style.setProperty('--hm-orgchart-branch-end', `${Math.max(...centers)}px`);
|
||||
return true;
|
||||
}
|
||||
Reference in new issue
Block a user