feat(orgchart): integrate chart-only viewport and scoped guard fixes

Add initial fit and direct wheel zoom with preserved default presentation. Integrate five scoped GuardFix findings and AI generation preselection policy. Exclude Cell experiments and retain five historical guard failures.
This commit is contained in:
shkim4 committed 2026-10-13 18:41:14 +09:00
1 parent 0a8daf7ee7
commit d56d587fd2
28 files changed
+1170 -77

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
# OrgChart Chart Only example
Standalone Vanilla and React consumers of the public Core option
`presentationMode: 'chart-only'` (Auto only). Each page is one white canvas that
fills the window: no title, as-of date, summary, toolbar or guidance. The chart
opens fitted and centered, and the plain mouse wheel zooms at the pointer.
Not a Storybook page.
```bash
npm run build:org-chart
```
```bash
npm run build:ui
```
```bash
npm run build:org-chart-react
```
```bash
npx vite --config examples/org-chart-chart-only/vite.config.ts
```
Pages (port 6041): `vanilla.html`, `react.html` (default entry, Core popup) and
`react-hm-dialog.html` (optional `/hm-dialog` entry with the HM theme).
Query: `data=20|100|500` (20/100 are the existing multi-company fixtures, 500 is a
virtual single-company dataset with 6 divisions and 5·2·4·1·3·2 teams),
`layout=tb|lr|hybrid`, `embed=1` (verification only: a 480px canvas inside a
scrolling page).
Browser verification against the running server. Playwright and a browser are
resolved through `ORGCHART_PLAYWRIGHT_MODULE` / `ORGCHART_CHROME` as in the other
OrgChart checks; evidence goes to the ignored `test-results/org-chart-chart-only/`.
```bash
node examples/org-chart-chart-only/verify.mjs
```
`verify-storybook.mjs` checks the four public OrgChart Storybook menus against
`STORYBOOK_URL` (default `http://127.0.0.1:6044`) and, with `BASELINE_URL`,
compares DOM and pixels with a baseline Storybook.
@@ -0,0 +1,6 @@
/* Page layout only: the chart canvas fills the window. */
html, body { height: 100%; margin: 0; background: #ffffff; }
#chart { height: 100%; }
/* Verification only: page content above and below a bounded canvas. */
body.embed { height: auto; }
body.embed #chart { height: 480px; margin: 600px 0 1400px; }
+54
View File
@@ -0,0 +1,54 @@
import type { Employee, OrgChartDataset, Organization } from '@hm-design/org-chart';
import small from '../org-chart/data-expanded-small.json';
import medium from '../org-chart/data-expanded-medium.json';
/** Deterministic single-company dataset: 6 divisions with 5·2·4·1·3·2 teams.
* Virtual names only; no live data. Leaders are counted in `total`. */
export function createSamanDataset(total = 500): OrgChartDataset {
const organizations: Organization[] = [];
const employees: Employee[] = [];
const add = (organizationId: string, position: string) => {
const id = `saman-e${String(employees.length + 1).padStart(3, '0')}`;
employees.push({ id, organizationId, affiliationId: 'saman', name: `가상직원 ${String(employees.length + 1).padStart(3, '0')}`, position, jobTitle: null, profileImage: null, workStatus: 'active' });
return id;
};
const org = (id: string, parentId: string | null, name: string, type: Organization['type'], displayOrder: number) => {
const node: Organization = { id, companyId: 'saman', parentId, name, type, leaderId: null, displayOrder };
organizations.push(node);
return node;
};
const root = org('saman-root', null, '삼안', 'root', 0);
root.leaderId = add(root.id, '대표이사');
const teams: Organization[] = [];
[5, 2, 4, 1, 3, 2].forEach((teamCount, d) => {
const division = org(`saman-d${d + 1}`, root.id, `가상 ${d + 1}본부`, 'division', d);
division.leaderId = add(division.id, '본부장');
for (let t = 0; t < teamCount; t++) {
const team = org(`saman-d${d + 1}-t${t + 1}`, division.id, `가상 ${d + 1}-${t + 1}팀`, 'team', t);
team.leaderId = add(team.id, '팀장');
teams.push(team);
}
});
let cursor = 0;
while (employees.length < total) add(teams[cursor++ % teams.length]!.id, ['사원', '선임', '책임', '수석'][cursor % 4]!);
return {
schemaVersion: '0.1-draft', organizations, employees, affiliations: [{ id: 'saman', name: '삼안' }], relations: [],
presentation: { title: '삼안 조직도 · 가상 데이터', description: null, hubText: null,
groups: { showEmployees: true, showSummary: false, showDispatched: false, showOnLeave: false, showAffiliations: false }, organizationOrder: [] },
metadata: { asOfDate: '2026-10-13', dataVersion: `chart-only-saman-${total}`, updateNote: '가상 데이터 · 운영 정보 아님' },
} as OrgChartDataset;
}
export const datasets: Record<string, () => OrgChartDataset> = {
'20': () => structuredClone(small) as OrgChartDataset,
'100': () => structuredClone(medium) as OrgChartDataset,
'500': () => createSamanDataset(500),
};
export function readQuery() {
const query = new URLSearchParams(location.search);
const data = datasets[query.get('data') ?? ''] ? query.get('data')! : '20';
const layout = (['tb', 'lr', 'hybrid'] as const).find(type => type === query.get('layout')) ?? 'tb';
// embed=1 is a verification layout: a bounded canvas inside a scrolling page.
return { data, layout, embed: query.get('embed') === '1' };
}
@@ -0,0 +1,4 @@
/* Optional HM Dialog integration only; the default entry needs none of this. */
@import "@hm-design/ui/theme.css";
@import "tailwindcss" source("./");
@import "@hm-design/ui/tailwind.css";
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="data:," />
<title>OrgChart Chart Only · React HM Dialog</title>
</head>
<body>
<div id="chart"></div>
<script type="module" src="./react-hm-dialog.tsx"></script>
</body>
</html>
@@ -0,0 +1,20 @@
import { StrictMode, useMemo } from 'react';
import { createRoot } from 'react-dom/client';
import { OrgChart } from '@hm-design/org-chart-react/hm-dialog';
import { JsonOrgChartDataSource } from '@hm-design/org-chart';
import engineUrl from '@hm-design/org-chart/engine.css?url';
import coreUrl from '@hm-design/org-chart/style.css?url';
import { datasets, readQuery } from './data';
import './hm-theme.css';
import './chart-only.css';
const { data, layout } = readQuery();
const stylesheetUrls = { engine: engineUrl, core: coreUrl };
function ChartOnlyPage() {
const dataSource = useMemo(() => new JsonOrgChartDataSource(datasets[data]!()), []);
return <OrgChart dataSource={dataSource} stylesheetUrls={stylesheetUrls} displayMode="readOnly" layoutMode="auto"
autoLayout={{ type: layout }} presentationMode="chart-only" employeePopup="hm-dialog" style={{ height: '100%' }} />;
}
createRoot(document.getElementById('chart')!).render(<StrictMode><ChartOnlyPage /></StrictMode>);
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="data:," />
<title>OrgChart Chart Only · React</title>
</head>
<body>
<div id="chart"></div>
<script type="module" src="./react.tsx"></script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
import { StrictMode, useMemo, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { OrgChart } from '@hm-design/org-chart-react';
import { JsonOrgChartDataSource } from '@hm-design/org-chart';
import engineUrl from '@hm-design/org-chart/engine.css?url';
import coreUrl from '@hm-design/org-chart/style.css?url';
import { datasets, readQuery } from './data';
import './chart-only.css';
const { data, layout, embed } = readQuery();
if (embed) document.body.classList.add('embed');
const stylesheetUrls = { engine: engineUrl, core: coreUrl };
let replace: (key: string) => void = () => {};
let rerender: () => void = () => {};
function ChartOnlyPage() {
const [key, setKey] = useState(data);
const [, setTick] = useState(0);
replace = setKey;
rerender = () => setTick(value => value + 1);
const dataSource = useMemo(() => new JsonOrgChartDataSource(datasets[key]!()), [key]);
// Inline option literals are intentional: equal values must not refit on re-render.
return <OrgChart dataSource={dataSource} stylesheetUrls={stylesheetUrls} displayMode="readOnly" layoutMode="auto"
autoLayout={{ type: layout }} presentationMode="chart-only"
zoom={new URLSearchParams(location.search).has('verify-min') ? { min: 0.8 } : undefined}
style={{ height: '100%' }} />;
}
createRoot(document.getElementById('chart')!).render(<StrictMode><ChartOnlyPage /></StrictMode>);
// Verification seam only.
Object.assign(window, { replaceData: (key: string) => replace(key), rerenderPage: () => rerender() });
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.app.json",
"compilerOptions": { "composite": false, "noEmit": true, "types": ["vite/client"], "resolveJsonModule": true, "tsBuildInfoFile": "../../_local/org-chart-chart-only.tsbuildinfo" },
"include": ["*.ts", "*.tsx"]
}
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="data:," />
<title>OrgChart Chart Only · Vanilla</title>
</head>
<body>
<div id="chart"></div>
<script type="module" src="./vanilla.ts"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
import { createOrgChart, JsonOrgChartDataSource } from '@hm-design/org-chart';
import engineUrl from '@hm-design/org-chart/engine.css?url';
import coreUrl from '@hm-design/org-chart/style.css?url';
import { datasets, readQuery } from './data';
import './chart-only.css';
const { data, layout, embed } = readQuery();
if (embed) document.body.classList.add('embed');
const chart = createOrgChart({
container: document.getElementById('chart')!,
dataSource: new JsonOrgChartDataSource(datasets[data]!()),
stylesheetUrls: { engine: engineUrl, core: coreUrl },
displayMode: 'readOnly',
layoutMode: 'auto',
autoLayout: { type: layout },
// Chart Only implies initialView "fit" and wheelZoom "direct".
presentationMode: 'chart-only',
// Regression fixture: Fit below the manual zoom floor, without huge data.
zoom: new URLSearchParams(location.search).has('verify-min') ? { min: 0.8 } : undefined,
});
void chart.mount();
// Verification seam only: replace the whole dataset through the public handle.
Object.assign(window, { orgChart: chart, replaceData: (key: string) => chart.update(datasets[key]!()) });
@@ -0,0 +1,154 @@
// Storybook regression for the Chart Only change. Compares this worktree's
// Storybook (STORYBOOK_URL) with a baseline Storybook (BASELINE_URL, optional)
// and checks that Expanded keeps its controls and Ctrl/⌘ + wheel policy.
import assert from 'node:assert/strict';
import path from 'node:path';
import { mkdir, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-chart-only/storybook');
await mkdir(output, { recursive: true });
const target = process.env.STORYBOOK_URL ?? 'http://127.0.0.1:6044';
const baseline = process.env.BASELINE_URL;
const browser = await launchChromium();
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 });
const results = [];
const consoleErrors = [];
const wheelOnly = process.argv.includes('--wheel-only');
const record = (name, pass, detail) => { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}`, detail === undefined ? '' : JSON.stringify(detail)); };
const check = async (name, fn) => { try { record(name, true, await fn()); } catch (error) { record(name, false, error.message); } };
const stories = ['orgchart--logo-personnel-summary', 'orgchart--department-colors', 'orgchart--full-composition', 'orgchart--expanded-org-chart'];
async function open(origin, id) {
const page = await context.newPage();
page.on('console', message => { if (message.type() === 'error') consoleErrors.push({ origin, id, text: message.text() }); });
page.on('pageerror', error => consoleErrors.push({ origin, id, text: String(error) }));
await page.goto(`${origin}/iframe.html?id=${id}&viewMode=story`);
await page.waitForFunction(() => {
const hosts = [...document.querySelectorAll('.hm-orgchart-host')];
// Overview renders summaries without an OrgChart host.
return document.getElementById('storybook-root')?.childElementCount && hosts.every(host => host.shadowRoot?.querySelector('.hm-orgchart-shell')?.getAttribute('aria-busy') === 'false');
}, null, { timeout: 60000 });
await page.evaluate(() => document.fonts.ready);
await page.waitForTimeout(800);
return page;
}
/** Story DOM plus every OrgChart shadow root, without instance/React IDs. */
const snapshot = page => page.evaluate(() => [document.getElementById('storybook-root').innerHTML,
...[...document.querySelectorAll('.hm-orgchart-host')].map(host => host.shadowRoot.innerHTML)].join('\n')
.replace(/hm-orgchart-\d+/g, 'hm-orgchart-N').replace(/«[^»]*»|_r_[0-9a-z]+_/g, 'react-id'));
async function pixelDiff(a, b) {
const page = await context.newPage();
const result = await page.evaluate(async ([ua, ub]) => {
const load = src => new Promise(resolve => { const image = new Image(); image.onload = () => resolve(image); image.src = src; });
const [ia, ib] = await Promise.all([load(ua), load(ub)]);
if (ia.width !== ib.width || ia.height !== ib.height) return { size: [ia.width, ia.height, ib.width, ib.height], diffPixels: Infinity };
const read = image => { const canvas = document.createElement('canvas'); canvas.width = image.width; canvas.height = image.height; const c = canvas.getContext('2d'); c.drawImage(image, 0, 0); return c.getImageData(0, 0, image.width, image.height).data; };
const da = read(ia), db = read(ib); let diffPixels = 0, maxDelta = 0; const box = [Infinity, Infinity, -1, -1];
for (let i = 0; i < da.length; i += 4) {
const delta = Math.max(Math.abs(da[i] - db[i]), Math.abs(da[i + 1] - db[i + 1]), Math.abs(da[i + 2] - db[i + 2]));
if (!delta) continue;
diffPixels++; maxDelta = Math.max(maxDelta, delta);
const p = i / 4, x = p % ia.width, y = Math.floor(p / ia.width);
box[0] = Math.min(box[0], x); box[1] = Math.min(box[1], y); box[2] = Math.max(box[2], x); box[3] = Math.max(box[3], y);
}
return { size: [ia.width, ia.height], diffPixels, maxDelta, box };
}, [`data:image/png;base64,${a.toString('base64')}`, `data:image/png;base64,${b.toString('base64')}`]);
await page.close();
return result;
}
const index = await (await fetch(`${target}/index.json`)).json();
if (!wheelOnly) await check('Storybook OrgChart public menus', () => {
const visible = Object.values(index.entries).filter(entry => entry.title.startsWith('OrgChart/') && !(entry.tags ?? []).includes('internal')).map(entry => `${entry.title} › ${entry.name}`);
assert.deepEqual(visible.sort(), ['OrgChart/Department Colors › Department Colors', 'OrgChart/Expanded OrgChart › Expanded OrgChart', 'OrgChart/Full Composition › Full Composition', 'OrgChart/Overview › Overview']);
return visible;
});
for (const id of wheelOnly ? ['orgchart--expanded-org-chart'] : stories) {
const page = await open(target, id);
const buffer = await page.screenshot({ path: path.join(output, `${id}.png`), fullPage: true });
const dom = await snapshot(page);
if (baseline) {
const reference = await open(baseline, id);
const referenceBuffer = await reference.screenshot({ path: path.join(output, `${id}.baseline.png`), fullPage: true });
const referenceDom = await snapshot(reference);
// Pixel identity is reported, not assumed: when it differs, a second
// baseline capture shows whether the baseline itself is deterministic.
const pixels = buffer.equals(referenceBuffer) ? { identical: true } : {
target: await pixelDiff(buffer, referenceBuffer),
baselineSelf: await pixelDiff(referenceBuffer, await (await open(baseline, id)).screenshot({ fullPage: true })),
};
await check(`${id} matches baseline (DOM + pixels)`, () => {
assert.equal(dom, referenceDom, 'shadow DOM differs');
// Identical DOM with a handful of ±1 channel pixels is rasterization
// jitter of the scaled Fixed stage, not a design change.
if (!pixels.identical) assert.ok(pixels.target.diffPixels <= 50 && pixels.target.maxDelta <= 2, `pixel diff ${JSON.stringify(pixels)}`);
return { domLength: dom.length, pixels };
});
await reference.close();
}
if (id === 'orgchart--expanded-org-chart') {
const state = () => page.evaluate(() => {
const sr = document.querySelector('.hm-orgchart-host').shadowRoot;
const vp = sr.querySelector('.hm-orgchart-viewport');
return { scale: +vp.dataset.scale, x: +vp.dataset.panX, y: +vp.dataset.panY,
heading: sr.querySelector('.hm-orgchart-title')?.textContent, date: sr.querySelector('.hm-orgchart-date')?.textContent,
selection: sr.querySelector('.hm-orgchart-expanded-selection')?.textContent,
toolbar: [...sr.querySelectorAll('.hm-orgchart-expanded-toolbar > *')].map(e => e.textContent),
feedback: sr.querySelector('.hm-orgchart-move-feedback')?.textContent, presentation: sr.querySelector('.hm-orgchart-shell').dataset.presentationMode ?? null };
});
const before = await state();
if (!wheelOnly) await check('Expanded keeps heading, date, summary, toolbar and guidance', () => {
assert.equal(before.presentation, null);
assert.ok(before.heading && before.date?.startsWith('기준일') && before.selection?.startsWith('선택'));
assert.deepEqual(before.toolbar.slice(0, 4), ['확대', '축소', '100% 복원', 'Fit to View']);
assert.match(before.toolbar[4], /^\d+%$/);
assert.equal(before.toolbar[5], '드래그·방향키 이동 · Ctrl/⌘ + 휠 확대');
assert.match(before.feedback, /Drag & Drop OFF/);
return before;
});
const canvas = page.locator('.hm-orgchart-viewport');
await canvas.evaluate(element => element.scrollIntoView({ block: 'center', behavior: 'instant' }));
const box = await canvas.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, -100);
await page.waitForTimeout(150);
const plain = await state();
const plainScroll = await page.evaluate(() => scrollY);
// A plain wheel scrolls the story page; aim at the canvas again.
await canvas.evaluate(element => element.scrollIntoView({ block: 'center', behavior: 'instant' }));
const moved = await canvas.boundingBox();
await page.mouse.move(moved.x + moved.width / 2, moved.y + moved.height / 2);
await page.keyboard.down('Control'); await page.mouse.wheel(0, -100); await page.keyboard.up('Control');
await page.waitForTimeout(150);
const ctrl = await state();
await check('Expanded plain wheel does not zoom; Ctrl + wheel zooms', () => {
assert.equal(plain.scale, before.scale);
assert.ok(ctrl.scale > plain.scale);
return { before: before.scale, plain: plain.scale, ctrl: +ctrl.scale.toFixed(4), pageScrolledByPlainWheel: plainScroll };
});
if (!wheelOnly) {
await page.getByRole('button', { name: 'Fit to View' }).click();
const fitted = await state();
await check('Expanded Fit to View button still works', () => { assert.ok(fitted.scale <= 1 && fitted.scale !== ctrl.scale); return { scale: +fitted.scale.toFixed(4) }; });
}
}
if (id === 'orgchart--full-composition') {
await check('Full Composition keeps Fixed composition', () => page.evaluate(() => {
const shells = [...document.querySelectorAll('.hm-orgchart-host')].map(host => host.shadowRoot.querySelector('.hm-orgchart-shell'));
if (!shells.length || shells.some(shell => shell.dataset.presentationMode)) throw new Error('unexpected presentation mode');
return shells.map(shell => ({ layout: shell.dataset.layoutMode, heading: !!shell.querySelector('.hm-orgchart-heading'), stage: !!shell.querySelector('.hm-orgchart-stage') }));
}));
}
await page.close();
}
await browser.close();
const failed = results.filter(result => !result.pass);
await writeFile(path.join(output, wheelOnly ? 'results-wheel.json' : 'results.json'), JSON.stringify({ target, baseline, results, consoleErrors }, null, 2));
console.log(`\n${results.length - failed.length}/${results.length} PASS · console errors ${consoleErrors.length}`);
for (const error of consoleErrors) console.log('console:', error.origin, error.id, error.text);
if (failed.length || consoleErrors.length) process.exitCode = 1;
+400
View File
@@ -0,0 +1,400 @@
// Chart Only browser verification against a running preview server:
// npx vite --config examples/org-chart-chart-only/vite.config.ts
// node examples/org-chart-chart-only/verify.mjs
// CHART_ONLY_URL (default http://127.0.0.1:6041) selects the server.
import assert from 'node:assert/strict';
import path from 'node:path';
import { mkdir, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs';
const root = fileURLToPath(new URL('../../', import.meta.url));
const output = path.join(root, 'test-results/org-chart-chart-only');
await mkdir(output, { recursive: true });
const base = process.env.CHART_ONLY_URL ?? 'http://127.0.0.1:6041';
const browser = await launchChromium();
const results = [];
const consoleErrors = [];
const record = (id, name, pass, detail) => { results.push({ id, name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${name}`, detail === undefined ? '' : JSON.stringify(detail)); };
const check = (id, name, fn) => async (...args) => {
try { const detail = await fn(...args); record(id, name, true, detail); }
catch (error) { record(id, name, false, error.message); }
};
/** All geometry in CSS pixels relative to the canvas; bounds include connectors. */
const measure = page => page.evaluate(() => {
const sr = document.querySelector('.hm-orgchart-host').shadowRoot;
const vp = sr.querySelector('.hm-orgchart-viewport'); const box = vp.getBoundingClientRect();
let l = Infinity, t = Infinity, r = -Infinity, b = -Infinity;
for (const e of sr.querySelectorAll('.hm-orgchart-forest .hm-orgchart-organization, .hm-orgchart-forest .hm-orgchart-organization-connectors path')) {
const x = e.getBoundingClientRect(); if (!x.width && !x.height) continue;
l = Math.min(l, x.left); t = Math.min(t, x.top); r = Math.max(r, x.right); b = Math.max(b, x.bottom);
}
const css = getComputedStyle;
const feedback = sr.querySelector('.hm-orgchart-move-feedback')?.getBoundingClientRect();
return {
canvas: { left: box.left, top: box.top, width: box.width, height: box.height },
scale: +vp.dataset.scale, x: +vp.dataset.panX, y: +vp.dataset.panY, pending: vp.dataset.placementPending ?? null,
dragDrop: vp.dataset.dragDropEnabled,
margins: { l: l - box.left, t: t - box.top, r: box.right - r, b: box.bottom - b },
centerErrX: (l + r) / 2 - (box.left + box.width / 2), centerErrY: (t + b) / 2 - (box.top + box.height / 2),
inside: l >= box.left - 0.5 && t >= box.top - 0.5 && r <= box.right + 0.5 && b <= box.bottom + 0.5,
cards: sr.querySelectorAll('.hm-orgchart-employee').length,
organizations: sr.querySelectorAll('.hm-orgchart-organization').length,
connectors: sr.querySelectorAll('.hm-orgchart-forest .edge, .hm-orgchart-organization-connectors path, .hm-orgchart-employee-connectors path').length,
external: {
heading: !!sr.querySelector('.hm-orgchart-heading'), date: !!sr.querySelector('.hm-orgchart-date'),
selection: !!sr.querySelector('.hm-orgchart-expanded-selection'), toolbar: !!sr.querySelector('.hm-orgchart-expanded-toolbar'),
zoomRatio: !!sr.querySelector('.hm-orgchart-expanded output'),
feedbackVisible: !!feedback && feedback.width > 1 && feedback.height > 1,
siblingsOfCanvas: [...sr.querySelector('.hm-orgchart-expanded').children].filter(e => e !== vp && e.getBoundingClientRect().width > 1).map(e => e.className),
},
colors: { page: css(document.body).backgroundColor, shell: css(sr.querySelector('.hm-orgchart-shell')).backgroundColor, content: css(sr.querySelector('.hm-orgchart-expanded')).backgroundColor, canvas: css(vp).backgroundColor, canvasBorder: css(vp).borderTopWidth, contentPadding: css(sr.querySelector('.hm-orgchart-expanded')).paddingTop },
companyColors: [...new Set([...sr.querySelectorAll('.node[data-company-id]')].map(n => `${n.dataset.companyId}:${n.style.getPropertyValue('--hm-orgchart-expanded-primary')}`))],
pageOverflowX: document.documentElement.scrollWidth > innerWidth,
};
});
async function ready(page) {
await page.waitForFunction(() => {
const sr = document.querySelector('.hm-orgchart-host')?.shadowRoot;
const vp = sr?.querySelector('.hm-orgchart-viewport');
return vp && !vp.dataset.placementPending && sr.querySelector('.hm-orgchart-employee') && sr.querySelector('.hm-orgchart-shell')?.getAttribute('aria-busy') === 'false';
}, null, { timeout: 30000 });
// Two settled frames: no later Fit may move the view.
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
}
/** Samples the visible transform from navigation until settle; repeated
* visible Fits would show up as more than one distinct transform. */
async function visibleTransforms(page, ms = 1500) {
return page.evaluate(ms => new Promise(resolve => {
const seen = []; const start = performance.now();
const tick = () => {
const vp = document.querySelector('.hm-orgchart-host')?.shadowRoot?.querySelector('.hm-orgchart-viewport');
const forest = vp?.querySelector('.hm-orgchart-forest');
if (forest && !vp.dataset.placementPending) { const value = forest.style.transform; if (seen.at(-1) !== value) seen.push(value); }
if (performance.now() - start < ms) requestAnimationFrame(tick); else resolve(seen);
};
tick();
}), ms);
}
async function open(context, url) {
const page = await context.newPage();
page.on('console', message => { if (message.type() === 'error') consoleErrors.push({ url, text: message.text() }); });
page.on('pageerror', error => consoleErrors.push({ url, text: String(error) }));
await page.goto(url);
return page;
}
const near = (a, b, tolerance) => Math.abs(a - b) <= tolerance;
const desktop = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
// A/B: output and initial Fit for both consumers and all sizes.
for (const kind of ['vanilla', 'react']) {
for (const size of ['20', '100', '500']) {
const url = `${base}/${kind}.html?data=${size}`;
const page = await open(desktop, url);
const transforms = await visibleTransforms(page);
await ready(page);
const m = await measure(page);
await page.screenshot({ path: path.join(output, `${kind}-${size}-desktop.png`) });
const label = `${kind} ${size}`;
await check('A', `${label} Chart Only output`, () => {
assert.deepEqual(Object.entries(m.external).filter(([key, value]) => key === 'siblingsOfCanvas' ? value.length : value), []);
assert.equal(m.colors.canvas, 'rgb(255, 255, 255)'); assert.equal(m.colors.content, 'rgb(255, 255, 255)'); assert.equal(m.colors.shell, 'rgb(255, 255, 255)');
assert.equal(m.colors.canvasBorder, '0px'); assert.equal(m.colors.contentPadding, '0px');
assert.ok(m.cards > 0 && m.organizations > 0 && m.connectors > 0);
assert.ok(near(m.canvas.width, 1440, 1) && near(m.canvas.height, 900, 1), 'canvas fills window');
return { cards: m.cards, organizations: m.organizations, connectors: m.connectors, colors: m.companyColors };
})();
await check('B', `${label} initial Fit and center`, () => {
assert.ok(m.inside, 'bounds inside canvas');
assert.ok(Math.abs(m.centerErrX) <= 1 && Math.abs(m.centerErrY) <= 1, `center error ${m.centerErrX}, ${m.centerErrY}`);
assert.ok(m.scale <= 1);
assert.ok(Math.min(m.margins.l, m.margins.r, m.margins.t, m.margins.b) >= 15.5, 'edge margin');
assert.equal(transforms.length, 1, `visible transforms ${JSON.stringify(transforms)}`);
return { scale: +m.scale.toFixed(4), centerErrX: +m.centerErrX.toFixed(3), centerErrY: +m.centerErrY.toFixed(3), margins: Object.fromEntries(Object.entries(m.margins).map(([k, v]) => [k, +v.toFixed(2)])), visibleTransforms: transforms.length };
})();
await page.close();
}
}
// LR / Hybrid keep their layout and still fit.
for (const layout of ['lr', 'hybrid']) {
const page = await open(desktop, `${base}/vanilla.html?data=100&layout=${layout}`);
await ready(page);
const m = await measure(page);
await page.screenshot({ path: path.join(output, `vanilla-100-${layout}.png`) });
await check('B', `vanilla 100 ${layout} Fit and center`, () => {
assert.ok(m.inside && Math.abs(m.centerErrX) <= 1 && Math.abs(m.centerErrY) <= 1, JSON.stringify(m.margins));
return { scale: +m.scale.toFixed(4), centerErrX: +m.centerErrX.toFixed(3), centerErrY: +m.centerErrY.toFixed(3) };
})();
await page.close();
}
// C/D: wheel, Pan, popup, groups and replacement on the 500-person chart.
for (const kind of ['vanilla', 'react']) {
const page = await open(desktop, `${base}/${kind}.html?data=500`);
await ready(page);
const fitted = await measure(page);
const card = page.locator('.hm-orgchart-employee').nth(30);
const cardBox = await card.boundingBox();
const point = { x: cardBox.x + cardBox.width / 2, y: cardBox.y + cardBox.height / 2 };
const local = m => ({ x: (point.x - m.canvas.left - m.x) / m.scale, y: (point.y - m.canvas.top - m.y) / m.scale });
await page.mouse.move(point.x, point.y);
await page.mouse.wheel(0, -100);
await page.waitForTimeout(50);
const zoomedIn = await measure(page);
await check('C', `${kind} plain wheel up zooms in (no Ctrl/⌘)`, () => {
assert.ok(zoomedIn.scale > fitted.scale * 1.1, `${fitted.scale} -> ${zoomedIn.scale}`);
return { from: +fitted.scale.toFixed(4), to: +zoomedIn.scale.toFixed(4) };
})();
await check('C', `${kind} zoom anchors at pointer`, () => {
const before = local(fitted), after = local(zoomedIn);
const error = Math.hypot(before.x - after.x, before.y - after.y) * zoomedIn.scale;
const box = { x: cardBox.x, y: cardBox.y };
assert.ok(error < 0.5, `screen drift ${error}`);
return { screenDriftPx: +error.toFixed(4), card: box };
})();
await page.mouse.wheel(0, 100);
await page.waitForTimeout(50);
const zoomedOut = await measure(page);
await check('C', `${kind} plain wheel down zooms out`, () => {
assert.ok(zoomedOut.scale < zoomedIn.scale);
return { from: +zoomedIn.scale.toFixed(4), to: +zoomedOut.scale.toFixed(4) };
})();
for (let i = 0; i < 40; i++) await page.mouse.wheel(0, -120);
await page.waitForTimeout(50);
const maximum = (await measure(page)).scale;
for (let i = 0; i < 80; i++) await page.mouse.wheel(0, 120);
await page.waitForTimeout(50);
const minimum = (await measure(page)).scale;
await check('C', `${kind} min/max limits`, () => {
assert.equal(maximum, 3);
assert.ok(near(minimum, Math.min(0.05, fitted.scale), 1e-9), `min ${minimum} fit ${fitted.scale}`);
return { max: maximum, min: +minimum.toFixed(4), fitScale: +fitted.scale.toFixed(4) };
})();
// Back to a readable zoom around the same card, then Pan from empty space.
await page.mouse.move(point.x, point.y);
for (let i = 0; i < 6; i++) await page.mouse.wheel(0, -100);
await page.waitForTimeout(50);
const beforePan = await measure(page);
const empty = { x: beforePan.canvas.left + 6, y: beforePan.canvas.top + beforePan.canvas.height - 6 };
const hit = await page.evaluate(({ x, y }) => { const sr = document.querySelector('.hm-orgchart-host').shadowRoot; const e = sr.elementFromPoint(x, y); return e?.closest('.hm-orgchart-employee, button') ? 'control' : e?.className ?? ''; }, empty);
await page.mouse.move(empty.x, empty.y); await page.mouse.down();
await page.mouse.move(empty.x + 60, empty.y - 40, { steps: 4 }); await page.mouse.move(empty.x + 120, empty.y - 80, { steps: 4 });
await page.mouse.up();
const afterPan = await measure(page);
await check('C', `${kind} Pan after Zoom`, () => {
assert.notEqual(hit, 'control');
assert.ok(near(afterPan.x - beforePan.x, 120, 0.5) && near(afterPan.y - beforePan.y, -80, 0.5), `${afterPan.x - beforePan.x}, ${afterPan.y - beforePan.y}`);
assert.equal(afterPan.scale, beforePan.scale);
return { dx: afterPan.x - beforePan.x, dy: afterPan.y - beforePan.y, scale: +afterPan.scale.toFixed(4) };
})();
await page.waitForTimeout(600);
const settled = await measure(page);
await check('C', `${kind} no automatic Fit after user Zoom/Pan`, () => {
assert.deepEqual([settled.scale, settled.x, settled.y], [afterPan.scale, afterPan.x, afterPan.y]);
})();
// Ctrl + wheel also zooms in the canvas and the browser page zoom is not used.
await page.keyboard.down('Control'); await page.mouse.move(point.x, point.y); await page.mouse.wheel(0, -100); await page.keyboard.up('Control');
await page.waitForTimeout(50);
const ctrlZoom = await measure(page);
const pageZoom = await page.evaluate(() => [visualViewport.scale, innerWidth]);
await check('C', `${kind} Ctrl + wheel stays inside the canvas`, () => {
assert.ok(ctrlZoom.scale > settled.scale); assert.deepEqual(pageZoom, [1, 1440]);
return { scale: +ctrlZoom.scale.toFixed(4) };
})();
// D: employee popup keeps the view.
const view = await measure(page);
const visibleCard = page.locator('.hm-orgchart-employee').filter({ hasNot: page.locator('[hidden]') }).nth(30);
await visibleCard.click();
const dialog = page.locator('dialog.hm-orgchart-employee-dialog');
const opened = await dialog.evaluate(element => element.open).catch(() => false);
const dialogText = opened ? (await dialog.innerText()).replace(/\s+/g, ' ').slice(0, 80) : '';
await page.keyboard.press('Escape');
await page.waitForTimeout(100);
const closed = !(await dialog.evaluate(element => element.open));
const afterDialog = await measure(page);
await check('D', `${kind} employee popup open/close keeps Zoom/Pan`, () => {
assert.ok(opened, 'dialog opened'); assert.ok(closed, 'dialog closed');
assert.deepEqual([afterDialog.scale, afterDialog.x, afterDialog.y], [view.scale, view.x, view.y]);
return { dialogText };
})();
// D: group collapse/expand keeps the view and does not Fit.
const toggle = page.locator('.hm-orgchart-expanded-toggle[aria-disabled="false"]').nth(3);
const id = await toggle.getAttribute('data-organization-id');
const cardsBefore = afterDialog.cards;
await toggle.click();
await page.waitForFunction(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`)?.getAttribute('aria-expanded') === 'false', id);
await ready(page);
const collapsed = await measure(page);
await page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`).click();
await page.waitForFunction(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`)?.getAttribute('aria-expanded') === 'true', id);
await ready(page);
const expanded = await measure(page);
await check('D', `${kind} group collapse/expand without auto Fit`, () => {
assert.ok(collapsed.cards < cardsBefore && expanded.cards === cardsBefore, `${cardsBefore} -> ${collapsed.cards} -> ${expanded.cards}`);
assert.deepEqual([collapsed.scale, expanded.scale], [view.scale, view.scale]);
assert.deepEqual([expanded.x, expanded.y], [view.x, view.y]);
return { cards: [cardsBefore, collapsed.cards, expanded.cards], scale: +view.scale.toFixed(4) };
})();
// D: Drag & Drop defaults to OFF; a card drag never changes membership.
const dragSource = page.locator('.hm-orgchart-employee').nth(40);
const sourceId = await dragSource.getAttribute('data-employee-id');
const ownerBefore = await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-employee[data-employee-id="${id}"]`)?.closest('.node')?.dataset.organizationId, sourceId);
const sourceBox = await dragSource.boundingBox();
const targetBox = await page.locator('.hm-orgchart-expanded-toggle').nth(1).boundingBox();
await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2); await page.mouse.down();
await page.mouse.move(targetBox.x + 10, targetBox.y + 5, { steps: 10 }); await page.mouse.up();
await page.waitForTimeout(200);
const afterDrag = await measure(page);
const owner = await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-employee[data-employee-id="${id}"]`)?.closest('.node')?.dataset.organizationId, sourceId);
await page.keyboard.press('Escape');
await check('D', `${kind} Drag & Drop default OFF`, () => {
assert.equal(afterDrag.dragDrop, 'false');
assert.ok(owner && owner === ownerBefore, `${ownerBefore} -> ${owner}`);
return { dragDropEnabled: afterDrag.dragDrop, owner };
})();
// B16 / React re-render: user view survives a plain re-render, replacement Fits again.
if (kind === 'react') {
const userView = await measure(page);
await page.evaluate(() => window.rerenderPage());
await page.waitForTimeout(400);
const rerendered = await measure(page);
await check('C', 'react plain re-render keeps user view (no Fit)', () => {
assert.deepEqual([rerendered.scale, rerendered.x, rerendered.y], [userView.scale, userView.x, userView.y]);
})();
}
await page.evaluate(() => window.replaceData('100'));
await page.waitForFunction(() => document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-employee').length === 100);
await ready(page);
const replaced = await measure(page);
await page.screenshot({ path: path.join(output, `${kind}-replaced-100.png`) });
await check('B', `${kind} full data replacement Fits again`, () => {
assert.ok(replaced.inside && Math.abs(replaced.centerErrX) <= 1 && Math.abs(replaced.centerErrY) <= 1, JSON.stringify(replaced.margins));
return { scale: +replaced.scale.toFixed(4), centerErrX: +replaced.centerErrX.toFixed(3), centerErrY: +replaced.centerErrY.toFixed(3) };
})();
await page.close();
}
// C23: outside the canvas the page scrolls; inside, the wheel zooms.
for (const kind of ['vanilla', 'react']) {
const page = await open(desktop, `${base}/${kind}.html?data=100&embed=1`);
await ready(page);
await page.mouse.move(700, 300);
await page.mouse.wheel(0, 200);
await page.waitForTimeout(150);
const scrolled = await page.evaluate(() => scrollY);
const box = await page.locator('#chart').boundingBox();
const before = await measure(page);
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, -100);
await page.waitForTimeout(150);
const after = await measure(page);
const scrollAfter = await page.evaluate(() => scrollY);
await check('C', `${kind} page scroll outside canvas / zoom inside`, () => {
assert.ok(scrolled >= 150, `outside scrollY ${scrolled}`);
assert.equal(scrollAfter, scrolled, 'inside wheel does not scroll the page');
assert.ok(after.scale > before.scale);
return { outsideScrollY: scrolled, insideScrollY: scrollAfter, scale: [+before.scale.toFixed(4), +after.scale.toFixed(4)] };
})();
await page.close();
}
// D28: optional HM Dialog adapter.
{
const page = await open(desktop, `${base}/react-hm-dialog.html?data=20`);
await ready(page);
const view = await measure(page);
await page.locator('.hm-orgchart-employee').nth(4).click();
const hmDialog = page.locator('[data-slot="dialog-content"]');
await hmDialog.waitFor({ timeout: 5000 });
await page.waitForTimeout(400);
await page.screenshot({ path: path.join(output, 'react-hm-dialog-open.png') });
const text = (await hmDialog.innerText()).replace(/\s+/g, ' ').slice(0, 80);
const coreDialogOpen = await page.locator('dialog.hm-orgchart-employee-dialog').evaluate(element => element.open);
await page.keyboard.press('Escape');
await hmDialog.waitFor({ state: 'detached', timeout: 5000 });
const after = await measure(page);
await check('D', 'react /hm-dialog Chart Only uses HM Dialog', () => {
assert.equal(coreDialogOpen, false);
assert.deepEqual([after.scale, after.x, after.y], [view.scale, view.x, view.y]);
return { text };
})();
await page.close();
}
// Preserve the untouched initial Fit through controller recreation. The manual
// minimum is deliberately above Fit to exercise the saved floor without 2,100+ data.
for (const kind of ['vanilla', 'react']) {
const page = await open(desktop, `${base}/${kind}.html?data=500&verify-min=1`);
await ready(page);
const initial = await measure(page);
const title = page.locator('.hm-orgchart-expanded-toggle').nth(1);
await title.click();
await page.waitForTimeout(250);
const collapsed = await measure(page);
await check('R1', `${kind} untouched Fit survives collapse`, () => {
assert.ok(initial.scale < 0.8);
assert.deepEqual([collapsed.scale, collapsed.x, collapsed.y], [initial.scale, initial.x, initial.y]);
})();
await title.click(); await page.waitForTimeout(250);
const expanded = await measure(page);
const viewport = page.locator('.hm-orgchart-viewport'); const box = await viewport.boundingBox();
await page.mouse.move(box.x + 8, box.y + box.height - 8); await page.mouse.wheel(0, 120);
await page.waitForTimeout(150);
const zoomed = await measure(page);
await check('R2', `${kind} recreated controller retains below-min Fit floor`, () => {
assert.deepEqual([expanded.scale, expanded.x, expanded.y], [initial.scale, initial.x, initial.y]);
assert.equal(zoomed.scale, initial.scale, 'wheel out must not jump to 0.8');
})();
if (kind === 'vanilla') {
await page.evaluate(() => window.orgChart.setInteractions({ zoom: false }));
await page.evaluate(() => window.orgChart.setInteractions({ zoom: true }));
const changed = await measure(page);
await check('R3', 'Vanilla interaction recreation retains initial view', () => {
assert.deepEqual([changed.scale, changed.x, changed.y], [initial.scale, initial.x, initial.y]);
})();
}
await page.close();
}
await desktop.close();
// Mobile: initial Fit, touch Pan and layout only (no wheel claims).
const mobile = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true });
for (const kind of ['vanilla', 'react']) {
for (const size of ['20', '100', '500']) {
const page = await open(mobile, `${base}/${kind}.html?data=${size}`);
await ready(page);
const m = await measure(page);
if (size !== '100') await page.screenshot({ path: path.join(output, `${kind}-${size}-mobile.png`) });
let touch;
if (size === '20') {
const cdp = await mobile.newCDPSession(page);
const start = { x: m.canvas.left + 8, y: m.canvas.top + m.canvas.height - 8 };
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [start] });
for (let i = 1; i <= 5; i++) await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: start.x + i * 12, y: start.y - i * 10 }] });
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
const moved = await measure(page);
touch = { dx: moved.x - m.x, dy: moved.y - m.y };
}
await check('B', `${kind} ${size} mobile Fit${touch ? ' + touch Pan' : ''}`, () => {
assert.ok(m.inside && Math.abs(m.centerErrX) <= 1 && Math.abs(m.centerErrY) <= 1, JSON.stringify(m.margins));
assert.equal(m.pageOverflowX, false);
if (touch) assert.ok(Math.abs(touch.dx) > 20 && Math.abs(touch.dy) > 20, JSON.stringify(touch));
return { scale: +m.scale.toFixed(4), centerErrX: +m.centerErrX.toFixed(3), centerErrY: +m.centerErrY.toFixed(3), touch };
})();
await page.close();
}
}
await mobile.close();
await browser.close();
const failed = results.filter(result => !result.pass);
await writeFile(path.join(output, 'results.json'), JSON.stringify({ base, results, consoleErrors }, null, 2));
console.log(`\n${results.length - failed.length}/${results.length} PASS · console errors ${consoleErrors.length}`);
for (const error of consoleErrors) console.log('console:', error.url, error.text);
if (failed.length || consoleErrors.length) process.exitCode = 1;
@@ -0,0 +1,18 @@
import path from 'node:path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
// Chart Only consumer preview: Vanilla and React use the same public Core.
const root = path.resolve(import.meta.dirname);
export default defineConfig({
root,
base: './',
publicDir: path.resolve(root, '../../public'),
plugins: [react(), tailwindcss()],
server: { host: '127.0.0.1', port: 6041 },
build: {
outDir: 'dist', emptyOutDir: true,
rolldownOptions: { input: { vanilla: path.resolve(root, 'vanilla.html'), react: path.resolve(root, 'react.html'), 'react-hm-dialog': path.resolve(root, 'react-hm-dialog.html') } },
},
});