From 064517ec46cb91ceb7cecdc35dbcef84ce91d24d Mon Sep 17 00:00:00 2001 From: b25027 Date: Mon, 12 Oct 2026 21:59:09 +0900 Subject: [PATCH] feat(org-chart): implement expandable layout and multi-company navigation Integrate P5-1B through P5-4 shared Core layout, collapse, zoom/pan/fit, company roots and keyboard accessibility. Include React/HTML examples, virtual 20/100/300 employee fixtures, focused checks and the additive data contract. --- docs/org-chart/data-contract.md | 1 + .../org-chart/create-expanded-fixtures.mjs | 48 + examples/org-chart/data-expanded-large.json | 3749 +++++++++++++++++ examples/org-chart/data-expanded-medium.json | 1408 +++++++ examples/org-chart/data-expanded-small.json | 375 ++ examples/org-chart/data-expanded.json | 347 +- examples/org-chart/expanded-app.js | 33 + examples/org-chart/expanded.css | 9 + examples/org-chart/expanded.html | 6 + examples/org-chart/prepare.mjs | 2 + examples/org-chart/verify-p5-1b.mjs | 377 ++ examples/org-chart/verify-p5-23.mjs | 224 + examples/org-chart/verify-p5-4.mjs | 221 + packages/org-chart-react/src/OrgChart.tsx | 20 +- packages/org-chart/src/core/employee-card.ts | 16 +- .../src/core/expanded-company.test.ts | 45 + .../org-chart/src/core/expanded-company.ts | 40 + .../org-chart/src/core/expanded-content.ts | 126 +- .../src/core/expanded-layout.test.ts | 32 +- .../org-chart/src/core/expanded-layout.ts | 91 +- .../org-chart/src/core/expanded-viewport.ts | 141 + packages/org-chart/src/core/index.ts | 85 +- packages/org-chart/src/core/render.ts | 21 +- packages/org-chart/src/core/types.ts | 29 +- packages/org-chart/src/data/types.ts | 2 + packages/org-chart/src/data/validate.ts | 3 +- packages/org-chart/src/index.ts | 2 +- .../src/styles/org-chart-expanded.css | 211 +- src/org-chart/org-chart-expanded.stories.tsx | 47 +- src/org-chart/org-chart-stories.css | 4 + 30 files changed, 7532 insertions(+), 183 deletions(-) create mode 100644 examples/org-chart/create-expanded-fixtures.mjs create mode 100644 examples/org-chart/data-expanded-large.json create mode 100644 examples/org-chart/data-expanded-medium.json create mode 100644 examples/org-chart/data-expanded-small.json create mode 100644 examples/org-chart/expanded-app.js create mode 100644 examples/org-chart/expanded.css create mode 100644 examples/org-chart/expanded.html create mode 100644 examples/org-chart/verify-p5-1b.mjs create mode 100644 examples/org-chart/verify-p5-23.mjs create mode 100644 examples/org-chart/verify-p5-4.mjs create mode 100644 packages/org-chart/src/core/expanded-company.test.ts create mode 100644 packages/org-chart/src/core/expanded-company.ts create mode 100644 packages/org-chart/src/core/expanded-viewport.ts diff --git a/docs/org-chart/data-contract.md b/docs/org-chart/data-contract.md index d407973..91bebed 100644 --- a/docs/org-chart/data-contract.md +++ b/docs/org-chart/data-contract.md @@ -14,6 +14,7 @@ type RelationType = "cooperation" | "support"; interface Organization { id: ID; + companyId?: ID; // P5-2/P5-3 Auto 회사 Root 식별. 하위 조직은 상속, 기존 JSON 생략 허용. parentId: ID | null; name: string; type: OrganizationType; diff --git a/examples/org-chart/create-expanded-fixtures.mjs b/examples/org-chart/create-expanded-fixtures.mjs new file mode 100644 index 0000000..8f37638 --- /dev/null +++ b/examples/org-chart/create-expanded-fixtures.mjs @@ -0,0 +1,48 @@ +import fs from 'node:fs/promises'; +const companies = [ + ['hanmac', '한맥기술', '회장'], ['saman', '삼안', '사업총괄'], ['jangheon-industry', '장헌산업', '본부장'], + ['jangheon', '장헌', '대표이사'], ['ptc', '피티씨', '총괄책임자'], ['halla', '한라산업개발', '사장'], ['baron', '바론컨설턴트', null], +]; +for (const [size, count, companyCount, depth] of [['small', 20, 3, 2], ['medium', 100, 5, 3], ['large', 300, 7, 4]]) { + const organizations = [], employees = [], affiliations = []; + const addEmployee = (org, position, name) => { + const id = `virtual-${String(employees.length + 1).padStart(3, '0')}`; + employees.push({ id, organizationId: org.id, affiliationId: org.companyId, name: name ?? (employees.length === 5 ? '홍길동' : `가상직원 ${String(employees.length + 1).padStart(3, '0')}`), position, jobTitle: null, profileImage: null, workStatus: 'active' }); + return id; + }; + for (const [index, [companyId, companyName, position]] of companies.slice(0, companyCount).entries()) { + affiliations.push({ id: companyId, name: companyName }); + const org = (suffix, parentId, name, type, order) => { + const node = { id: `${companyId}-${suffix}`, companyId, parentId, name, type, leaderId: null, displayOrder: order }; + organizations.push(node); return node; + }; + const root = org('root', null, `${companyName} · 가상 조직`, 'root', index); + if (position) root.leaderId = addEmployee(root, position, `가상책임자 ${index + 1}`); + for (let branch = 0; branch < 2; branch++) { + const division = org(`division-${branch}`, root.id, `가상 ${branch ? '지원' : '사업'}본부`, 'division', branch); + if (count > 20) division.leaderId = addEmployee(division, '본부장'); + const office = org(`office-${branch}`, division.id, `가상 ${branch + 1}실`, 'office', 0); + if (depth >= 3) { + const cell = org(`cell-${branch}`, office.id, `가상 ${branch + 1}셀`, 'cell', 0); + if (depth >= 4) { + org(`team-${branch}`, cell.id, `가상 ${branch + 1}팀`, 'team', 0); + if (branch === 0) org('team-sibling', cell.id, '가상 협력팀', 'team', 1); + } + } + if (count > 20 && branch === 0) org('office-sibling', division.id, '가상 계획실', 'office', 1); + } + } + // Unequal groups: deterministic weighted membership; no random or live data. + const pool = organizations.filter(org => org.parentId !== null); + const weighted = pool.flatMap((org, index) => Array.from({ length: 1 + index % 4 }, () => org)); + let cursor = 0; + while (employees.length < count) { + const org = weighted[cursor++ % weighted.length]; + addEmployee(org, ['사원', '선임', '책임', '수석'][cursor % 4]); + } + const data = { schemaVersion: '0.1-draft', organizations, employees, affiliations, relations: [], + presentation: { title: '확장형 조직도 · 가상 데이터', description: '실제 직원 정보가 아닌 고정된 테스트 데이터입니다.', hubText: null, + groups: { showEmployees: true, showSummary: false, showDispatched: false, showOnLeave: false, showAffiliations: false }, organizationOrder: [] }, + metadata: { asOfDate: '2026-10-12', dataVersion: `p5-23-${size}-${count}`, updateNote: '가상 데이터 · 운영 정보 아님' } }; + await fs.writeFile(new URL(`./data-expanded-${size}.json`, import.meta.url), JSON.stringify(data, null, 2) + '\n'); +} diff --git a/examples/org-chart/data-expanded-large.json b/examples/org-chart/data-expanded-large.json new file mode 100644 index 0000000..0364465 --- /dev/null +++ b/examples/org-chart/data-expanded-large.json @@ -0,0 +1,3749 @@ +{ + "schemaVersion": "0.1-draft", + "organizations": [ + { + "id": "hanmac-root", + "companyId": "hanmac", + "parentId": null, + "name": "한맥기술 · 가상 조직", + "type": "root", + "leaderId": "virtual-001", + "displayOrder": 0 + }, + { + "id": "hanmac-division-0", + "companyId": "hanmac", + "parentId": "hanmac-root", + "name": "가상 사업본부", + "type": "division", + "leaderId": "virtual-002", + "displayOrder": 0 + }, + { + "id": "hanmac-office-0", + "companyId": "hanmac", + "parentId": "hanmac-division-0", + "name": "가상 1실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "hanmac-cell-0", + "companyId": "hanmac", + "parentId": "hanmac-office-0", + "name": "가상 1셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "hanmac-team-0", + "companyId": "hanmac", + "parentId": "hanmac-cell-0", + "name": "가상 1팀", + "type": "team", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "hanmac-team-sibling", + "companyId": "hanmac", + "parentId": "hanmac-cell-0", + "name": "가상 협력팀", + "type": "team", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "hanmac-office-sibling", + "companyId": "hanmac", + "parentId": "hanmac-division-0", + "name": "가상 계획실", + "type": "office", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "hanmac-division-1", + "companyId": "hanmac", + "parentId": "hanmac-root", + "name": "가상 지원본부", + "type": "division", + "leaderId": "virtual-003", + "displayOrder": 1 + }, + { + "id": "hanmac-office-1", + "companyId": "hanmac", + "parentId": "hanmac-division-1", + "name": "가상 2실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "hanmac-cell-1", + "companyId": "hanmac", + "parentId": "hanmac-office-1", + "name": "가상 2셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "hanmac-team-1", + "companyId": "hanmac", + "parentId": "hanmac-cell-1", + "name": "가상 2팀", + "type": "team", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "saman-root", + "companyId": "saman", + "parentId": null, + "name": "삼안 · 가상 조직", + "type": "root", + "leaderId": "virtual-004", + "displayOrder": 1 + }, + { + "id": "saman-division-0", + "companyId": "saman", + "parentId": "saman-root", + "name": "가상 사업본부", + "type": "division", + "leaderId": "virtual-005", + "displayOrder": 0 + }, + { + "id": "saman-office-0", + "companyId": "saman", + "parentId": "saman-division-0", + "name": "가상 1실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "saman-cell-0", + "companyId": "saman", + "parentId": "saman-office-0", + "name": "가상 1셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "saman-team-0", + "companyId": "saman", + "parentId": "saman-cell-0", + "name": "가상 1팀", + "type": "team", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "saman-team-sibling", + "companyId": "saman", + "parentId": "saman-cell-0", + "name": "가상 협력팀", + "type": "team", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "saman-office-sibling", + "companyId": "saman", + "parentId": "saman-division-0", + "name": "가상 계획실", + "type": "office", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "saman-division-1", + "companyId": "saman", + "parentId": "saman-root", + "name": "가상 지원본부", + "type": "division", + "leaderId": "virtual-006", + "displayOrder": 1 + }, + { + "id": "saman-office-1", + "companyId": "saman", + "parentId": "saman-division-1", + "name": "가상 2실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "saman-cell-1", + "companyId": "saman", + "parentId": "saman-office-1", + "name": "가상 2셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "saman-team-1", + "companyId": "saman", + "parentId": "saman-cell-1", + "name": "가상 2팀", + "type": "team", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "jangheon-industry-root", + "companyId": "jangheon-industry", + "parentId": null, + "name": "장헌산업 · 가상 조직", + "type": "root", + "leaderId": "virtual-007", + "displayOrder": 2 + }, + { + "id": "jangheon-industry-division-0", + "companyId": "jangheon-industry", + "parentId": "jangheon-industry-root", + "name": "가상 사업본부", + "type": "division", + "leaderId": "virtual-008", + "displayOrder": 0 + }, + { + "id": "jangheon-industry-office-0", + "companyId": "jangheon-industry", + "parentId": "jangheon-industry-division-0", + "name": "가상 1실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "jangheon-industry-cell-0", + "companyId": "jangheon-industry", + "parentId": "jangheon-industry-office-0", + "name": "가상 1셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "jangheon-industry-team-0", + "companyId": "jangheon-industry", + "parentId": "jangheon-industry-cell-0", + "name": "가상 1팀", + "type": "team", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "jangheon-industry-team-sibling", + "companyId": "jangheon-industry", + "parentId": "jangheon-industry-cell-0", + "name": "가상 협력팀", + "type": "team", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "jangheon-industry-office-sibling", + "companyId": "jangheon-industry", + "parentId": "jangheon-industry-division-0", + "name": "가상 계획실", + "type": "office", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "jangheon-industry-division-1", + "companyId": "jangheon-industry", + "parentId": "jangheon-industry-root", + "name": "가상 지원본부", + "type": "division", + "leaderId": "virtual-009", + "displayOrder": 1 + }, + { + "id": "jangheon-industry-office-1", + "companyId": "jangheon-industry", + "parentId": "jangheon-industry-division-1", + "name": "가상 2실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "jangheon-industry-cell-1", + "companyId": "jangheon-industry", + "parentId": "jangheon-industry-office-1", + "name": "가상 2셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "jangheon-industry-team-1", + "companyId": "jangheon-industry", + "parentId": "jangheon-industry-cell-1", + "name": "가상 2팀", + "type": "team", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "jangheon-root", + "companyId": "jangheon", + "parentId": null, + "name": "장헌 · 가상 조직", + "type": "root", + "leaderId": "virtual-010", + "displayOrder": 3 + }, + { + "id": "jangheon-division-0", + "companyId": "jangheon", + "parentId": "jangheon-root", + "name": "가상 사업본부", + "type": "division", + "leaderId": "virtual-011", + "displayOrder": 0 + }, + { + "id": "jangheon-office-0", + "companyId": "jangheon", + "parentId": "jangheon-division-0", + "name": "가상 1실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "jangheon-cell-0", + "companyId": "jangheon", + "parentId": "jangheon-office-0", + "name": "가상 1셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "jangheon-team-0", + "companyId": "jangheon", + "parentId": "jangheon-cell-0", + "name": "가상 1팀", + "type": "team", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "jangheon-team-sibling", + "companyId": "jangheon", + "parentId": "jangheon-cell-0", + "name": "가상 협력팀", + "type": "team", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "jangheon-office-sibling", + "companyId": "jangheon", + "parentId": "jangheon-division-0", + "name": "가상 계획실", + "type": "office", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "jangheon-division-1", + "companyId": "jangheon", + "parentId": "jangheon-root", + "name": "가상 지원본부", + "type": "division", + "leaderId": "virtual-012", + "displayOrder": 1 + }, + { + "id": "jangheon-office-1", + "companyId": "jangheon", + "parentId": "jangheon-division-1", + "name": "가상 2실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "jangheon-cell-1", + "companyId": "jangheon", + "parentId": "jangheon-office-1", + "name": "가상 2셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "jangheon-team-1", + "companyId": "jangheon", + "parentId": "jangheon-cell-1", + "name": "가상 2팀", + "type": "team", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "ptc-root", + "companyId": "ptc", + "parentId": null, + "name": "피티씨 · 가상 조직", + "type": "root", + "leaderId": "virtual-013", + "displayOrder": 4 + }, + { + "id": "ptc-division-0", + "companyId": "ptc", + "parentId": "ptc-root", + "name": "가상 사업본부", + "type": "division", + "leaderId": "virtual-014", + "displayOrder": 0 + }, + { + "id": "ptc-office-0", + "companyId": "ptc", + "parentId": "ptc-division-0", + "name": "가상 1실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "ptc-cell-0", + "companyId": "ptc", + "parentId": "ptc-office-0", + "name": "가상 1셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "ptc-team-0", + "companyId": "ptc", + "parentId": "ptc-cell-0", + "name": "가상 1팀", + "type": "team", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "ptc-team-sibling", + "companyId": "ptc", + "parentId": "ptc-cell-0", + "name": "가상 협력팀", + "type": "team", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "ptc-office-sibling", + "companyId": "ptc", + "parentId": "ptc-division-0", + "name": "가상 계획실", + "type": "office", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "ptc-division-1", + "companyId": "ptc", + "parentId": "ptc-root", + "name": "가상 지원본부", + "type": "division", + "leaderId": "virtual-015", + "displayOrder": 1 + }, + { + "id": "ptc-office-1", + "companyId": "ptc", + "parentId": "ptc-division-1", + "name": "가상 2실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "ptc-cell-1", + "companyId": "ptc", + "parentId": "ptc-office-1", + "name": "가상 2셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "ptc-team-1", + "companyId": "ptc", + "parentId": "ptc-cell-1", + "name": "가상 2팀", + "type": "team", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "halla-root", + "companyId": "halla", + "parentId": null, + "name": "한라산업개발 · 가상 조직", + "type": "root", + "leaderId": "virtual-016", + "displayOrder": 5 + }, + { + "id": "halla-division-0", + "companyId": "halla", + "parentId": "halla-root", + "name": "가상 사업본부", + "type": "division", + "leaderId": "virtual-017", + "displayOrder": 0 + }, + { + "id": "halla-office-0", + "companyId": "halla", + "parentId": "halla-division-0", + "name": "가상 1실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "halla-cell-0", + "companyId": "halla", + "parentId": "halla-office-0", + "name": "가상 1셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "halla-team-0", + "companyId": "halla", + "parentId": "halla-cell-0", + "name": "가상 1팀", + "type": "team", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "halla-team-sibling", + "companyId": "halla", + "parentId": "halla-cell-0", + "name": "가상 협력팀", + "type": "team", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "halla-office-sibling", + "companyId": "halla", + "parentId": "halla-division-0", + "name": "가상 계획실", + "type": "office", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "halla-division-1", + "companyId": "halla", + "parentId": "halla-root", + "name": "가상 지원본부", + "type": "division", + "leaderId": "virtual-018", + "displayOrder": 1 + }, + { + "id": "halla-office-1", + "companyId": "halla", + "parentId": "halla-division-1", + "name": "가상 2실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "halla-cell-1", + "companyId": "halla", + "parentId": "halla-office-1", + "name": "가상 2셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "halla-team-1", + "companyId": "halla", + "parentId": "halla-cell-1", + "name": "가상 2팀", + "type": "team", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "baron-root", + "companyId": "baron", + "parentId": null, + "name": "바론컨설턴트 · 가상 조직", + "type": "root", + "leaderId": null, + "displayOrder": 6 + }, + { + "id": "baron-division-0", + "companyId": "baron", + "parentId": "baron-root", + "name": "가상 사업본부", + "type": "division", + "leaderId": "virtual-019", + "displayOrder": 0 + }, + { + "id": "baron-office-0", + "companyId": "baron", + "parentId": "baron-division-0", + "name": "가상 1실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "baron-cell-0", + "companyId": "baron", + "parentId": "baron-office-0", + "name": "가상 1셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "baron-team-0", + "companyId": "baron", + "parentId": "baron-cell-0", + "name": "가상 1팀", + "type": "team", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "baron-team-sibling", + "companyId": "baron", + "parentId": "baron-cell-0", + "name": "가상 협력팀", + "type": "team", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "baron-office-sibling", + "companyId": "baron", + "parentId": "baron-division-0", + "name": "가상 계획실", + "type": "office", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "baron-division-1", + "companyId": "baron", + "parentId": "baron-root", + "name": "가상 지원본부", + "type": "division", + "leaderId": "virtual-020", + "displayOrder": 1 + }, + { + "id": "baron-office-1", + "companyId": "baron", + "parentId": "baron-division-1", + "name": "가상 2실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "baron-cell-1", + "companyId": "baron", + "parentId": "baron-office-1", + "name": "가상 2셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "baron-team-1", + "companyId": "baron", + "parentId": "baron-cell-1", + "name": "가상 2팀", + "type": "team", + "leaderId": null, + "displayOrder": 0 + } + ], + "employees": [ + { + "id": "virtual-001", + "organizationId": "hanmac-root", + "affiliationId": "hanmac", + "name": "가상책임자 1", + "position": "회장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-002", + "organizationId": "hanmac-division-0", + "affiliationId": "hanmac", + "name": "가상직원 002", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-003", + "organizationId": "hanmac-division-1", + "affiliationId": "hanmac", + "name": "가상직원 003", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-004", + "organizationId": "saman-root", + "affiliationId": "saman", + "name": "가상책임자 2", + "position": "사업총괄", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-005", + "organizationId": "saman-division-0", + "affiliationId": "saman", + "name": "가상직원 005", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-006", + "organizationId": "saman-division-1", + "affiliationId": "saman", + "name": "홍길동", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-007", + "organizationId": "jangheon-industry-root", + "affiliationId": "jangheon-industry", + "name": "가상책임자 3", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-008", + "organizationId": "jangheon-industry-division-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 008", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-009", + "organizationId": "jangheon-industry-division-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 009", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-010", + "organizationId": "jangheon-root", + "affiliationId": "jangheon", + "name": "가상책임자 4", + "position": "대표이사", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-011", + "organizationId": "jangheon-division-0", + "affiliationId": "jangheon", + "name": "가상직원 011", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-012", + "organizationId": "jangheon-division-1", + "affiliationId": "jangheon", + "name": "가상직원 012", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-013", + "organizationId": "ptc-root", + "affiliationId": "ptc", + "name": "가상책임자 5", + "position": "총괄책임자", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-014", + "organizationId": "ptc-division-0", + "affiliationId": "ptc", + "name": "가상직원 014", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-015", + "organizationId": "ptc-division-1", + "affiliationId": "ptc", + "name": "가상직원 015", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-016", + "organizationId": "halla-root", + "affiliationId": "halla", + "name": "가상책임자 6", + "position": "사장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-017", + "organizationId": "halla-division-0", + "affiliationId": "halla", + "name": "가상직원 017", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-018", + "organizationId": "halla-division-1", + "affiliationId": "halla", + "name": "가상직원 018", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-019", + "organizationId": "baron-division-0", + "affiliationId": "baron", + "name": "가상직원 019", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-020", + "organizationId": "baron-division-1", + "affiliationId": "baron", + "name": "가상직원 020", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-021", + "organizationId": "hanmac-division-0", + "affiliationId": "hanmac", + "name": "가상직원 021", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-022", + "organizationId": "hanmac-office-0", + "affiliationId": "hanmac", + "name": "가상직원 022", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-023", + "organizationId": "hanmac-office-0", + "affiliationId": "hanmac", + "name": "가상직원 023", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-024", + "organizationId": "hanmac-cell-0", + "affiliationId": "hanmac", + "name": "가상직원 024", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-025", + "organizationId": "hanmac-cell-0", + "affiliationId": "hanmac", + "name": "가상직원 025", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-026", + "organizationId": "hanmac-cell-0", + "affiliationId": "hanmac", + "name": "가상직원 026", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-027", + "organizationId": "hanmac-team-0", + "affiliationId": "hanmac", + "name": "가상직원 027", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-028", + "organizationId": "hanmac-team-0", + "affiliationId": "hanmac", + "name": "가상직원 028", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-029", + "organizationId": "hanmac-team-0", + "affiliationId": "hanmac", + "name": "가상직원 029", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-030", + "organizationId": "hanmac-team-0", + "affiliationId": "hanmac", + "name": "가상직원 030", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-031", + "organizationId": "hanmac-team-sibling", + "affiliationId": "hanmac", + "name": "가상직원 031", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-032", + "organizationId": "hanmac-office-sibling", + "affiliationId": "hanmac", + "name": "가상직원 032", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-033", + "organizationId": "hanmac-office-sibling", + "affiliationId": "hanmac", + "name": "가상직원 033", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-034", + "organizationId": "hanmac-division-1", + "affiliationId": "hanmac", + "name": "가상직원 034", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-035", + "organizationId": "hanmac-division-1", + "affiliationId": "hanmac", + "name": "가상직원 035", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-036", + "organizationId": "hanmac-division-1", + "affiliationId": "hanmac", + "name": "가상직원 036", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-037", + "organizationId": "hanmac-office-1", + "affiliationId": "hanmac", + "name": "가상직원 037", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-038", + "organizationId": "hanmac-office-1", + "affiliationId": "hanmac", + "name": "가상직원 038", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-039", + "organizationId": "hanmac-office-1", + "affiliationId": "hanmac", + "name": "가상직원 039", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-040", + "organizationId": "hanmac-office-1", + "affiliationId": "hanmac", + "name": "가상직원 040", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-041", + "organizationId": "hanmac-cell-1", + "affiliationId": "hanmac", + "name": "가상직원 041", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-042", + "organizationId": "hanmac-team-1", + "affiliationId": "hanmac", + "name": "가상직원 042", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-043", + "organizationId": "hanmac-team-1", + "affiliationId": "hanmac", + "name": "가상직원 043", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-044", + "organizationId": "saman-division-0", + "affiliationId": "saman", + "name": "가상직원 044", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-045", + "organizationId": "saman-division-0", + "affiliationId": "saman", + "name": "가상직원 045", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-046", + "organizationId": "saman-division-0", + "affiliationId": "saman", + "name": "가상직원 046", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-047", + "organizationId": "saman-office-0", + "affiliationId": "saman", + "name": "가상직원 047", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-048", + "organizationId": "saman-office-0", + "affiliationId": "saman", + "name": "가상직원 048", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-049", + "organizationId": "saman-office-0", + "affiliationId": "saman", + "name": "가상직원 049", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-050", + "organizationId": "saman-office-0", + "affiliationId": "saman", + "name": "가상직원 050", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-051", + "organizationId": "saman-cell-0", + "affiliationId": "saman", + "name": "가상직원 051", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-052", + "organizationId": "saman-team-0", + "affiliationId": "saman", + "name": "가상직원 052", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-053", + "organizationId": "saman-team-0", + "affiliationId": "saman", + "name": "가상직원 053", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-054", + "organizationId": "saman-team-sibling", + "affiliationId": "saman", + "name": "가상직원 054", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-055", + "organizationId": "saman-team-sibling", + "affiliationId": "saman", + "name": "가상직원 055", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-056", + "organizationId": "saman-team-sibling", + "affiliationId": "saman", + "name": "가상직원 056", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-057", + "organizationId": "saman-office-sibling", + "affiliationId": "saman", + "name": "가상직원 057", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-058", + "organizationId": "saman-office-sibling", + "affiliationId": "saman", + "name": "가상직원 058", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-059", + "organizationId": "saman-office-sibling", + "affiliationId": "saman", + "name": "가상직원 059", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-060", + "organizationId": "saman-office-sibling", + "affiliationId": "saman", + "name": "가상직원 060", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-061", + "organizationId": "saman-division-1", + "affiliationId": "saman", + "name": "가상직원 061", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-062", + "organizationId": "saman-office-1", + "affiliationId": "saman", + "name": "가상직원 062", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-063", + "organizationId": "saman-office-1", + "affiliationId": "saman", + "name": "가상직원 063", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-064", + "organizationId": "saman-cell-1", + "affiliationId": "saman", + "name": "가상직원 064", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-065", + "organizationId": "saman-cell-1", + "affiliationId": "saman", + "name": "가상직원 065", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-066", + "organizationId": "saman-cell-1", + "affiliationId": "saman", + "name": "가상직원 066", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-067", + "organizationId": "saman-team-1", + "affiliationId": "saman", + "name": "가상직원 067", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-068", + "organizationId": "saman-team-1", + "affiliationId": "saman", + "name": "가상직원 068", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-069", + "organizationId": "saman-team-1", + "affiliationId": "saman", + "name": "가상직원 069", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-070", + "organizationId": "saman-team-1", + "affiliationId": "saman", + "name": "가상직원 070", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-071", + "organizationId": "jangheon-industry-division-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 071", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-072", + "organizationId": "jangheon-industry-office-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 072", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-073", + "organizationId": "jangheon-industry-office-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 073", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-074", + "organizationId": "jangheon-industry-cell-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 074", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-075", + "organizationId": "jangheon-industry-cell-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 075", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-076", + "organizationId": "jangheon-industry-cell-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 076", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-077", + "organizationId": "jangheon-industry-team-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 077", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-078", + "organizationId": "jangheon-industry-team-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 078", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-079", + "organizationId": "jangheon-industry-team-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 079", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-080", + "organizationId": "jangheon-industry-team-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 080", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-081", + "organizationId": "jangheon-industry-team-sibling", + "affiliationId": "jangheon-industry", + "name": "가상직원 081", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-082", + "organizationId": "jangheon-industry-office-sibling", + "affiliationId": "jangheon-industry", + "name": "가상직원 082", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-083", + "organizationId": "jangheon-industry-office-sibling", + "affiliationId": "jangheon-industry", + "name": "가상직원 083", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-084", + "organizationId": "jangheon-industry-division-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 084", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-085", + "organizationId": "jangheon-industry-division-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 085", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-086", + "organizationId": "jangheon-industry-division-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 086", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-087", + "organizationId": "jangheon-industry-office-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 087", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-088", + "organizationId": "jangheon-industry-office-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 088", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-089", + "organizationId": "jangheon-industry-office-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 089", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-090", + "organizationId": "jangheon-industry-office-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 090", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-091", + "organizationId": "jangheon-industry-cell-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 091", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-092", + "organizationId": "jangheon-industry-team-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 092", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-093", + "organizationId": "jangheon-industry-team-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 093", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-094", + "organizationId": "jangheon-division-0", + "affiliationId": "jangheon", + "name": "가상직원 094", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-095", + "organizationId": "jangheon-division-0", + "affiliationId": "jangheon", + "name": "가상직원 095", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-096", + "organizationId": "jangheon-division-0", + "affiliationId": "jangheon", + "name": "가상직원 096", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-097", + "organizationId": "jangheon-office-0", + "affiliationId": "jangheon", + "name": "가상직원 097", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-098", + "organizationId": "jangheon-office-0", + "affiliationId": "jangheon", + "name": "가상직원 098", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-099", + "organizationId": "jangheon-office-0", + "affiliationId": "jangheon", + "name": "가상직원 099", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-100", + "organizationId": "jangheon-office-0", + "affiliationId": "jangheon", + "name": "가상직원 100", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-101", + "organizationId": "jangheon-cell-0", + "affiliationId": "jangheon", + "name": "가상직원 101", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-102", + "organizationId": "jangheon-team-0", + "affiliationId": "jangheon", + "name": "가상직원 102", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-103", + "organizationId": "jangheon-team-0", + "affiliationId": "jangheon", + "name": "가상직원 103", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-104", + "organizationId": "jangheon-team-sibling", + "affiliationId": "jangheon", + "name": "가상직원 104", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-105", + "organizationId": "jangheon-team-sibling", + "affiliationId": "jangheon", + "name": "가상직원 105", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-106", + "organizationId": "jangheon-team-sibling", + "affiliationId": "jangheon", + "name": "가상직원 106", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-107", + "organizationId": "jangheon-office-sibling", + "affiliationId": "jangheon", + "name": "가상직원 107", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-108", + "organizationId": "jangheon-office-sibling", + "affiliationId": "jangheon", + "name": "가상직원 108", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-109", + "organizationId": "jangheon-office-sibling", + "affiliationId": "jangheon", + "name": "가상직원 109", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-110", + "organizationId": "jangheon-office-sibling", + "affiliationId": "jangheon", + "name": "가상직원 110", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-111", + "organizationId": "jangheon-division-1", + "affiliationId": "jangheon", + "name": "가상직원 111", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-112", + "organizationId": "jangheon-office-1", + "affiliationId": "jangheon", + "name": "가상직원 112", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-113", + "organizationId": "jangheon-office-1", + "affiliationId": "jangheon", + "name": "가상직원 113", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-114", + "organizationId": "jangheon-cell-1", + "affiliationId": "jangheon", + "name": "가상직원 114", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-115", + "organizationId": "jangheon-cell-1", + "affiliationId": "jangheon", + "name": "가상직원 115", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-116", + "organizationId": "jangheon-cell-1", + "affiliationId": "jangheon", + "name": "가상직원 116", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-117", + "organizationId": "jangheon-team-1", + "affiliationId": "jangheon", + "name": "가상직원 117", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-118", + "organizationId": "jangheon-team-1", + "affiliationId": "jangheon", + "name": "가상직원 118", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-119", + "organizationId": "jangheon-team-1", + "affiliationId": "jangheon", + "name": "가상직원 119", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-120", + "organizationId": "jangheon-team-1", + "affiliationId": "jangheon", + "name": "가상직원 120", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-121", + "organizationId": "ptc-division-0", + "affiliationId": "ptc", + "name": "가상직원 121", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-122", + "organizationId": "ptc-office-0", + "affiliationId": "ptc", + "name": "가상직원 122", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-123", + "organizationId": "ptc-office-0", + "affiliationId": "ptc", + "name": "가상직원 123", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-124", + "organizationId": "ptc-cell-0", + "affiliationId": "ptc", + "name": "가상직원 124", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-125", + "organizationId": "ptc-cell-0", + "affiliationId": "ptc", + "name": "가상직원 125", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-126", + "organizationId": "ptc-cell-0", + "affiliationId": "ptc", + "name": "가상직원 126", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-127", + "organizationId": "ptc-team-0", + "affiliationId": "ptc", + "name": "가상직원 127", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-128", + "organizationId": "ptc-team-0", + "affiliationId": "ptc", + "name": "가상직원 128", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-129", + "organizationId": "ptc-team-0", + "affiliationId": "ptc", + "name": "가상직원 129", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-130", + "organizationId": "ptc-team-0", + "affiliationId": "ptc", + "name": "가상직원 130", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-131", + "organizationId": "ptc-team-sibling", + "affiliationId": "ptc", + "name": "가상직원 131", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-132", + "organizationId": "ptc-office-sibling", + "affiliationId": "ptc", + "name": "가상직원 132", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-133", + "organizationId": "ptc-office-sibling", + "affiliationId": "ptc", + "name": "가상직원 133", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-134", + "organizationId": "ptc-division-1", + "affiliationId": "ptc", + "name": "가상직원 134", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-135", + "organizationId": "ptc-division-1", + "affiliationId": "ptc", + "name": "가상직원 135", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-136", + "organizationId": "ptc-division-1", + "affiliationId": "ptc", + "name": "가상직원 136", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-137", + "organizationId": "ptc-office-1", + "affiliationId": "ptc", + "name": "가상직원 137", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-138", + "organizationId": "ptc-office-1", + "affiliationId": "ptc", + "name": "가상직원 138", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-139", + "organizationId": "ptc-office-1", + "affiliationId": "ptc", + "name": "가상직원 139", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-140", + "organizationId": "ptc-office-1", + "affiliationId": "ptc", + "name": "가상직원 140", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-141", + "organizationId": "ptc-cell-1", + "affiliationId": "ptc", + "name": "가상직원 141", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-142", + "organizationId": "ptc-team-1", + "affiliationId": "ptc", + "name": "가상직원 142", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-143", + "organizationId": "ptc-team-1", + "affiliationId": "ptc", + "name": "가상직원 143", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-144", + "organizationId": "halla-division-0", + "affiliationId": "halla", + "name": "가상직원 144", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-145", + "organizationId": "halla-division-0", + "affiliationId": "halla", + "name": "가상직원 145", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-146", + "organizationId": "halla-division-0", + "affiliationId": "halla", + "name": "가상직원 146", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-147", + "organizationId": "halla-office-0", + "affiliationId": "halla", + "name": "가상직원 147", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-148", + "organizationId": "halla-office-0", + "affiliationId": "halla", + "name": "가상직원 148", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-149", + "organizationId": "halla-office-0", + "affiliationId": "halla", + "name": "가상직원 149", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-150", + "organizationId": "halla-office-0", + "affiliationId": "halla", + "name": "가상직원 150", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-151", + "organizationId": "halla-cell-0", + "affiliationId": "halla", + "name": "가상직원 151", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-152", + "organizationId": "halla-team-0", + "affiliationId": "halla", + "name": "가상직원 152", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-153", + "organizationId": "halla-team-0", + "affiliationId": "halla", + "name": "가상직원 153", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-154", + "organizationId": "halla-team-sibling", + "affiliationId": "halla", + "name": "가상직원 154", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-155", + "organizationId": "halla-team-sibling", + "affiliationId": "halla", + "name": "가상직원 155", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-156", + "organizationId": "halla-team-sibling", + "affiliationId": "halla", + "name": "가상직원 156", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-157", + "organizationId": "halla-office-sibling", + "affiliationId": "halla", + "name": "가상직원 157", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-158", + "organizationId": "halla-office-sibling", + "affiliationId": "halla", + "name": "가상직원 158", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-159", + "organizationId": "halla-office-sibling", + "affiliationId": "halla", + "name": "가상직원 159", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-160", + "organizationId": "halla-office-sibling", + "affiliationId": "halla", + "name": "가상직원 160", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-161", + "organizationId": "halla-division-1", + "affiliationId": "halla", + "name": "가상직원 161", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-162", + "organizationId": "halla-office-1", + "affiliationId": "halla", + "name": "가상직원 162", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-163", + "organizationId": "halla-office-1", + "affiliationId": "halla", + "name": "가상직원 163", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-164", + "organizationId": "halla-cell-1", + "affiliationId": "halla", + "name": "가상직원 164", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-165", + "organizationId": "halla-cell-1", + "affiliationId": "halla", + "name": "가상직원 165", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-166", + "organizationId": "halla-cell-1", + "affiliationId": "halla", + "name": "가상직원 166", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-167", + "organizationId": "halla-team-1", + "affiliationId": "halla", + "name": "가상직원 167", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-168", + "organizationId": "halla-team-1", + "affiliationId": "halla", + "name": "가상직원 168", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-169", + "organizationId": "halla-team-1", + "affiliationId": "halla", + "name": "가상직원 169", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-170", + "organizationId": "halla-team-1", + "affiliationId": "halla", + "name": "가상직원 170", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-171", + "organizationId": "baron-division-0", + "affiliationId": "baron", + "name": "가상직원 171", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-172", + "organizationId": "baron-office-0", + "affiliationId": "baron", + "name": "가상직원 172", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-173", + "organizationId": "baron-office-0", + "affiliationId": "baron", + "name": "가상직원 173", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-174", + "organizationId": "baron-cell-0", + "affiliationId": "baron", + "name": "가상직원 174", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-175", + "organizationId": "baron-cell-0", + "affiliationId": "baron", + "name": "가상직원 175", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-176", + "organizationId": "baron-cell-0", + "affiliationId": "baron", + "name": "가상직원 176", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-177", + "organizationId": "baron-team-0", + "affiliationId": "baron", + "name": "가상직원 177", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-178", + "organizationId": "baron-team-0", + "affiliationId": "baron", + "name": "가상직원 178", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-179", + "organizationId": "baron-team-0", + "affiliationId": "baron", + "name": "가상직원 179", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-180", + "organizationId": "baron-team-0", + "affiliationId": "baron", + "name": "가상직원 180", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-181", + "organizationId": "baron-team-sibling", + "affiliationId": "baron", + "name": "가상직원 181", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-182", + "organizationId": "baron-office-sibling", + "affiliationId": "baron", + "name": "가상직원 182", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-183", + "organizationId": "baron-office-sibling", + "affiliationId": "baron", + "name": "가상직원 183", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-184", + "organizationId": "baron-division-1", + "affiliationId": "baron", + "name": "가상직원 184", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-185", + "organizationId": "baron-division-1", + "affiliationId": "baron", + "name": "가상직원 185", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-186", + "organizationId": "baron-division-1", + "affiliationId": "baron", + "name": "가상직원 186", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-187", + "organizationId": "baron-office-1", + "affiliationId": "baron", + "name": "가상직원 187", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-188", + "organizationId": "baron-office-1", + "affiliationId": "baron", + "name": "가상직원 188", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-189", + "organizationId": "baron-office-1", + "affiliationId": "baron", + "name": "가상직원 189", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-190", + "organizationId": "baron-office-1", + "affiliationId": "baron", + "name": "가상직원 190", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-191", + "organizationId": "baron-cell-1", + "affiliationId": "baron", + "name": "가상직원 191", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-192", + "organizationId": "baron-team-1", + "affiliationId": "baron", + "name": "가상직원 192", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-193", + "organizationId": "baron-team-1", + "affiliationId": "baron", + "name": "가상직원 193", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-194", + "organizationId": "hanmac-division-0", + "affiliationId": "hanmac", + "name": "가상직원 194", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-195", + "organizationId": "hanmac-office-0", + "affiliationId": "hanmac", + "name": "가상직원 195", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-196", + "organizationId": "hanmac-office-0", + "affiliationId": "hanmac", + "name": "가상직원 196", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-197", + "organizationId": "hanmac-cell-0", + "affiliationId": "hanmac", + "name": "가상직원 197", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-198", + "organizationId": "hanmac-cell-0", + "affiliationId": "hanmac", + "name": "가상직원 198", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-199", + "organizationId": "hanmac-cell-0", + "affiliationId": "hanmac", + "name": "가상직원 199", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-200", + "organizationId": "hanmac-team-0", + "affiliationId": "hanmac", + "name": "가상직원 200", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-201", + "organizationId": "hanmac-team-0", + "affiliationId": "hanmac", + "name": "가상직원 201", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-202", + "organizationId": "hanmac-team-0", + "affiliationId": "hanmac", + "name": "가상직원 202", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-203", + "organizationId": "hanmac-team-0", + "affiliationId": "hanmac", + "name": "가상직원 203", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-204", + "organizationId": "hanmac-team-sibling", + "affiliationId": "hanmac", + "name": "가상직원 204", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-205", + "organizationId": "hanmac-office-sibling", + "affiliationId": "hanmac", + "name": "가상직원 205", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-206", + "organizationId": "hanmac-office-sibling", + "affiliationId": "hanmac", + "name": "가상직원 206", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-207", + "organizationId": "hanmac-division-1", + "affiliationId": "hanmac", + "name": "가상직원 207", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-208", + "organizationId": "hanmac-division-1", + "affiliationId": "hanmac", + "name": "가상직원 208", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-209", + "organizationId": "hanmac-division-1", + "affiliationId": "hanmac", + "name": "가상직원 209", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-210", + "organizationId": "hanmac-office-1", + "affiliationId": "hanmac", + "name": "가상직원 210", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-211", + "organizationId": "hanmac-office-1", + "affiliationId": "hanmac", + "name": "가상직원 211", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-212", + "organizationId": "hanmac-office-1", + "affiliationId": "hanmac", + "name": "가상직원 212", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-213", + "organizationId": "hanmac-office-1", + "affiliationId": "hanmac", + "name": "가상직원 213", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-214", + "organizationId": "hanmac-cell-1", + "affiliationId": "hanmac", + "name": "가상직원 214", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-215", + "organizationId": "hanmac-team-1", + "affiliationId": "hanmac", + "name": "가상직원 215", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-216", + "organizationId": "hanmac-team-1", + "affiliationId": "hanmac", + "name": "가상직원 216", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-217", + "organizationId": "saman-division-0", + "affiliationId": "saman", + "name": "가상직원 217", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-218", + "organizationId": "saman-division-0", + "affiliationId": "saman", + "name": "가상직원 218", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-219", + "organizationId": "saman-division-0", + "affiliationId": "saman", + "name": "가상직원 219", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-220", + "organizationId": "saman-office-0", + "affiliationId": "saman", + "name": "가상직원 220", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-221", + "organizationId": "saman-office-0", + "affiliationId": "saman", + "name": "가상직원 221", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-222", + "organizationId": "saman-office-0", + "affiliationId": "saman", + "name": "가상직원 222", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-223", + "organizationId": "saman-office-0", + "affiliationId": "saman", + "name": "가상직원 223", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-224", + "organizationId": "saman-cell-0", + "affiliationId": "saman", + "name": "가상직원 224", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-225", + "organizationId": "saman-team-0", + "affiliationId": "saman", + "name": "가상직원 225", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-226", + "organizationId": "saman-team-0", + "affiliationId": "saman", + "name": "가상직원 226", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-227", + "organizationId": "saman-team-sibling", + "affiliationId": "saman", + "name": "가상직원 227", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-228", + "organizationId": "saman-team-sibling", + "affiliationId": "saman", + "name": "가상직원 228", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-229", + "organizationId": "saman-team-sibling", + "affiliationId": "saman", + "name": "가상직원 229", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-230", + "organizationId": "saman-office-sibling", + "affiliationId": "saman", + "name": "가상직원 230", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-231", + "organizationId": "saman-office-sibling", + "affiliationId": "saman", + "name": "가상직원 231", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-232", + "organizationId": "saman-office-sibling", + "affiliationId": "saman", + "name": "가상직원 232", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-233", + "organizationId": "saman-office-sibling", + "affiliationId": "saman", + "name": "가상직원 233", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-234", + "organizationId": "saman-division-1", + "affiliationId": "saman", + "name": "가상직원 234", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-235", + "organizationId": "saman-office-1", + "affiliationId": "saman", + "name": "가상직원 235", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-236", + "organizationId": "saman-office-1", + "affiliationId": "saman", + "name": "가상직원 236", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-237", + "organizationId": "saman-cell-1", + "affiliationId": "saman", + "name": "가상직원 237", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-238", + "organizationId": "saman-cell-1", + "affiliationId": "saman", + "name": "가상직원 238", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-239", + "organizationId": "saman-cell-1", + "affiliationId": "saman", + "name": "가상직원 239", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-240", + "organizationId": "saman-team-1", + "affiliationId": "saman", + "name": "가상직원 240", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-241", + "organizationId": "saman-team-1", + "affiliationId": "saman", + "name": "가상직원 241", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-242", + "organizationId": "saman-team-1", + "affiliationId": "saman", + "name": "가상직원 242", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-243", + "organizationId": "saman-team-1", + "affiliationId": "saman", + "name": "가상직원 243", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-244", + "organizationId": "jangheon-industry-division-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 244", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-245", + "organizationId": "jangheon-industry-office-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 245", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-246", + "organizationId": "jangheon-industry-office-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 246", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-247", + "organizationId": "jangheon-industry-cell-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 247", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-248", + "organizationId": "jangheon-industry-cell-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 248", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-249", + "organizationId": "jangheon-industry-cell-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 249", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-250", + "organizationId": "jangheon-industry-team-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 250", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-251", + "organizationId": "jangheon-industry-team-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 251", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-252", + "organizationId": "jangheon-industry-team-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 252", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-253", + "organizationId": "jangheon-industry-team-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 253", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-254", + "organizationId": "jangheon-industry-team-sibling", + "affiliationId": "jangheon-industry", + "name": "가상직원 254", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-255", + "organizationId": "jangheon-industry-office-sibling", + "affiliationId": "jangheon-industry", + "name": "가상직원 255", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-256", + "organizationId": "jangheon-industry-office-sibling", + "affiliationId": "jangheon-industry", + "name": "가상직원 256", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-257", + "organizationId": "jangheon-industry-division-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 257", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-258", + "organizationId": "jangheon-industry-division-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 258", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-259", + "organizationId": "jangheon-industry-division-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 259", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-260", + "organizationId": "jangheon-industry-office-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 260", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-261", + "organizationId": "jangheon-industry-office-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 261", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-262", + "organizationId": "jangheon-industry-office-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 262", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-263", + "organizationId": "jangheon-industry-office-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 263", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-264", + "organizationId": "jangheon-industry-cell-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 264", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-265", + "organizationId": "jangheon-industry-team-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 265", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-266", + "organizationId": "jangheon-industry-team-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 266", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-267", + "organizationId": "jangheon-division-0", + "affiliationId": "jangheon", + "name": "가상직원 267", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-268", + "organizationId": "jangheon-division-0", + "affiliationId": "jangheon", + "name": "가상직원 268", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-269", + "organizationId": "jangheon-division-0", + "affiliationId": "jangheon", + "name": "가상직원 269", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-270", + "organizationId": "jangheon-office-0", + "affiliationId": "jangheon", + "name": "가상직원 270", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-271", + "organizationId": "jangheon-office-0", + "affiliationId": "jangheon", + "name": "가상직원 271", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-272", + "organizationId": "jangheon-office-0", + "affiliationId": "jangheon", + "name": "가상직원 272", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-273", + "organizationId": "jangheon-office-0", + "affiliationId": "jangheon", + "name": "가상직원 273", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-274", + "organizationId": "jangheon-cell-0", + "affiliationId": "jangheon", + "name": "가상직원 274", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-275", + "organizationId": "jangheon-team-0", + "affiliationId": "jangheon", + "name": "가상직원 275", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-276", + "organizationId": "jangheon-team-0", + "affiliationId": "jangheon", + "name": "가상직원 276", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-277", + "organizationId": "jangheon-team-sibling", + "affiliationId": "jangheon", + "name": "가상직원 277", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-278", + "organizationId": "jangheon-team-sibling", + "affiliationId": "jangheon", + "name": "가상직원 278", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-279", + "organizationId": "jangheon-team-sibling", + "affiliationId": "jangheon", + "name": "가상직원 279", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-280", + "organizationId": "jangheon-office-sibling", + "affiliationId": "jangheon", + "name": "가상직원 280", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-281", + "organizationId": "jangheon-office-sibling", + "affiliationId": "jangheon", + "name": "가상직원 281", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-282", + "organizationId": "jangheon-office-sibling", + "affiliationId": "jangheon", + "name": "가상직원 282", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-283", + "organizationId": "jangheon-office-sibling", + "affiliationId": "jangheon", + "name": "가상직원 283", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-284", + "organizationId": "jangheon-division-1", + "affiliationId": "jangheon", + "name": "가상직원 284", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-285", + "organizationId": "jangheon-office-1", + "affiliationId": "jangheon", + "name": "가상직원 285", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-286", + "organizationId": "jangheon-office-1", + "affiliationId": "jangheon", + "name": "가상직원 286", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-287", + "organizationId": "jangheon-cell-1", + "affiliationId": "jangheon", + "name": "가상직원 287", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-288", + "organizationId": "jangheon-cell-1", + "affiliationId": "jangheon", + "name": "가상직원 288", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-289", + "organizationId": "jangheon-cell-1", + "affiliationId": "jangheon", + "name": "가상직원 289", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-290", + "organizationId": "jangheon-team-1", + "affiliationId": "jangheon", + "name": "가상직원 290", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-291", + "organizationId": "jangheon-team-1", + "affiliationId": "jangheon", + "name": "가상직원 291", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-292", + "organizationId": "jangheon-team-1", + "affiliationId": "jangheon", + "name": "가상직원 292", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-293", + "organizationId": "jangheon-team-1", + "affiliationId": "jangheon", + "name": "가상직원 293", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-294", + "organizationId": "ptc-division-0", + "affiliationId": "ptc", + "name": "가상직원 294", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-295", + "organizationId": "ptc-office-0", + "affiliationId": "ptc", + "name": "가상직원 295", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-296", + "organizationId": "ptc-office-0", + "affiliationId": "ptc", + "name": "가상직원 296", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-297", + "organizationId": "ptc-cell-0", + "affiliationId": "ptc", + "name": "가상직원 297", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-298", + "organizationId": "ptc-cell-0", + "affiliationId": "ptc", + "name": "가상직원 298", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-299", + "organizationId": "ptc-cell-0", + "affiliationId": "ptc", + "name": "가상직원 299", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-300", + "organizationId": "ptc-team-0", + "affiliationId": "ptc", + "name": "가상직원 300", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + } + ], + "affiliations": [ + { + "id": "hanmac", + "name": "한맥기술" + }, + { + "id": "saman", + "name": "삼안" + }, + { + "id": "jangheon-industry", + "name": "장헌산업" + }, + { + "id": "jangheon", + "name": "장헌" + }, + { + "id": "ptc", + "name": "피티씨" + }, + { + "id": "halla", + "name": "한라산업개발" + }, + { + "id": "baron", + "name": "바론컨설턴트" + } + ], + "relations": [], + "presentation": { + "title": "확장형 조직도 · 가상 데이터", + "description": "실제 직원 정보가 아닌 고정된 테스트 데이터입니다.", + "hubText": null, + "groups": { + "showEmployees": true, + "showSummary": false, + "showDispatched": false, + "showOnLeave": false, + "showAffiliations": false + }, + "organizationOrder": [] + }, + "metadata": { + "asOfDate": "2026-10-12", + "dataVersion": "p5-23-large-300", + "updateNote": "가상 데이터 · 운영 정보 아님" + } +} diff --git a/examples/org-chart/data-expanded-medium.json b/examples/org-chart/data-expanded-medium.json new file mode 100644 index 0000000..76911cc --- /dev/null +++ b/examples/org-chart/data-expanded-medium.json @@ -0,0 +1,1408 @@ +{ + "schemaVersion": "0.1-draft", + "organizations": [ + { + "id": "hanmac-root", + "companyId": "hanmac", + "parentId": null, + "name": "한맥기술 · 가상 조직", + "type": "root", + "leaderId": "virtual-001", + "displayOrder": 0 + }, + { + "id": "hanmac-division-0", + "companyId": "hanmac", + "parentId": "hanmac-root", + "name": "가상 사업본부", + "type": "division", + "leaderId": "virtual-002", + "displayOrder": 0 + }, + { + "id": "hanmac-office-0", + "companyId": "hanmac", + "parentId": "hanmac-division-0", + "name": "가상 1실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "hanmac-cell-0", + "companyId": "hanmac", + "parentId": "hanmac-office-0", + "name": "가상 1셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "hanmac-office-sibling", + "companyId": "hanmac", + "parentId": "hanmac-division-0", + "name": "가상 계획실", + "type": "office", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "hanmac-division-1", + "companyId": "hanmac", + "parentId": "hanmac-root", + "name": "가상 지원본부", + "type": "division", + "leaderId": "virtual-003", + "displayOrder": 1 + }, + { + "id": "hanmac-office-1", + "companyId": "hanmac", + "parentId": "hanmac-division-1", + "name": "가상 2실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "hanmac-cell-1", + "companyId": "hanmac", + "parentId": "hanmac-office-1", + "name": "가상 2셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "saman-root", + "companyId": "saman", + "parentId": null, + "name": "삼안 · 가상 조직", + "type": "root", + "leaderId": "virtual-004", + "displayOrder": 1 + }, + { + "id": "saman-division-0", + "companyId": "saman", + "parentId": "saman-root", + "name": "가상 사업본부", + "type": "division", + "leaderId": "virtual-005", + "displayOrder": 0 + }, + { + "id": "saman-office-0", + "companyId": "saman", + "parentId": "saman-division-0", + "name": "가상 1실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "saman-cell-0", + "companyId": "saman", + "parentId": "saman-office-0", + "name": "가상 1셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "saman-office-sibling", + "companyId": "saman", + "parentId": "saman-division-0", + "name": "가상 계획실", + "type": "office", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "saman-division-1", + "companyId": "saman", + "parentId": "saman-root", + "name": "가상 지원본부", + "type": "division", + "leaderId": "virtual-006", + "displayOrder": 1 + }, + { + "id": "saman-office-1", + "companyId": "saman", + "parentId": "saman-division-1", + "name": "가상 2실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "saman-cell-1", + "companyId": "saman", + "parentId": "saman-office-1", + "name": "가상 2셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "jangheon-industry-root", + "companyId": "jangheon-industry", + "parentId": null, + "name": "장헌산업 · 가상 조직", + "type": "root", + "leaderId": "virtual-007", + "displayOrder": 2 + }, + { + "id": "jangheon-industry-division-0", + "companyId": "jangheon-industry", + "parentId": "jangheon-industry-root", + "name": "가상 사업본부", + "type": "division", + "leaderId": "virtual-008", + "displayOrder": 0 + }, + { + "id": "jangheon-industry-office-0", + "companyId": "jangheon-industry", + "parentId": "jangheon-industry-division-0", + "name": "가상 1실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "jangheon-industry-cell-0", + "companyId": "jangheon-industry", + "parentId": "jangheon-industry-office-0", + "name": "가상 1셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "jangheon-industry-office-sibling", + "companyId": "jangheon-industry", + "parentId": "jangheon-industry-division-0", + "name": "가상 계획실", + "type": "office", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "jangheon-industry-division-1", + "companyId": "jangheon-industry", + "parentId": "jangheon-industry-root", + "name": "가상 지원본부", + "type": "division", + "leaderId": "virtual-009", + "displayOrder": 1 + }, + { + "id": "jangheon-industry-office-1", + "companyId": "jangheon-industry", + "parentId": "jangheon-industry-division-1", + "name": "가상 2실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "jangheon-industry-cell-1", + "companyId": "jangheon-industry", + "parentId": "jangheon-industry-office-1", + "name": "가상 2셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "jangheon-root", + "companyId": "jangheon", + "parentId": null, + "name": "장헌 · 가상 조직", + "type": "root", + "leaderId": "virtual-010", + "displayOrder": 3 + }, + { + "id": "jangheon-division-0", + "companyId": "jangheon", + "parentId": "jangheon-root", + "name": "가상 사업본부", + "type": "division", + "leaderId": "virtual-011", + "displayOrder": 0 + }, + { + "id": "jangheon-office-0", + "companyId": "jangheon", + "parentId": "jangheon-division-0", + "name": "가상 1실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "jangheon-cell-0", + "companyId": "jangheon", + "parentId": "jangheon-office-0", + "name": "가상 1셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "jangheon-office-sibling", + "companyId": "jangheon", + "parentId": "jangheon-division-0", + "name": "가상 계획실", + "type": "office", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "jangheon-division-1", + "companyId": "jangheon", + "parentId": "jangheon-root", + "name": "가상 지원본부", + "type": "division", + "leaderId": "virtual-012", + "displayOrder": 1 + }, + { + "id": "jangheon-office-1", + "companyId": "jangheon", + "parentId": "jangheon-division-1", + "name": "가상 2실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "jangheon-cell-1", + "companyId": "jangheon", + "parentId": "jangheon-office-1", + "name": "가상 2셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "ptc-root", + "companyId": "ptc", + "parentId": null, + "name": "피티씨 · 가상 조직", + "type": "root", + "leaderId": "virtual-013", + "displayOrder": 4 + }, + { + "id": "ptc-division-0", + "companyId": "ptc", + "parentId": "ptc-root", + "name": "가상 사업본부", + "type": "division", + "leaderId": "virtual-014", + "displayOrder": 0 + }, + { + "id": "ptc-office-0", + "companyId": "ptc", + "parentId": "ptc-division-0", + "name": "가상 1실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "ptc-cell-0", + "companyId": "ptc", + "parentId": "ptc-office-0", + "name": "가상 1셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "ptc-office-sibling", + "companyId": "ptc", + "parentId": "ptc-division-0", + "name": "가상 계획실", + "type": "office", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "ptc-division-1", + "companyId": "ptc", + "parentId": "ptc-root", + "name": "가상 지원본부", + "type": "division", + "leaderId": "virtual-015", + "displayOrder": 1 + }, + { + "id": "ptc-office-1", + "companyId": "ptc", + "parentId": "ptc-division-1", + "name": "가상 2실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "ptc-cell-1", + "companyId": "ptc", + "parentId": "ptc-office-1", + "name": "가상 2셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + } + ], + "employees": [ + { + "id": "virtual-001", + "organizationId": "hanmac-root", + "affiliationId": "hanmac", + "name": "가상책임자 1", + "position": "회장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-002", + "organizationId": "hanmac-division-0", + "affiliationId": "hanmac", + "name": "가상직원 002", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-003", + "organizationId": "hanmac-division-1", + "affiliationId": "hanmac", + "name": "가상직원 003", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-004", + "organizationId": "saman-root", + "affiliationId": "saman", + "name": "가상책임자 2", + "position": "사업총괄", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-005", + "organizationId": "saman-division-0", + "affiliationId": "saman", + "name": "가상직원 005", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-006", + "organizationId": "saman-division-1", + "affiliationId": "saman", + "name": "홍길동", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-007", + "organizationId": "jangheon-industry-root", + "affiliationId": "jangheon-industry", + "name": "가상책임자 3", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-008", + "organizationId": "jangheon-industry-division-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 008", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-009", + "organizationId": "jangheon-industry-division-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 009", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-010", + "organizationId": "jangheon-root", + "affiliationId": "jangheon", + "name": "가상책임자 4", + "position": "대표이사", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-011", + "organizationId": "jangheon-division-0", + "affiliationId": "jangheon", + "name": "가상직원 011", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-012", + "organizationId": "jangheon-division-1", + "affiliationId": "jangheon", + "name": "가상직원 012", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-013", + "organizationId": "ptc-root", + "affiliationId": "ptc", + "name": "가상책임자 5", + "position": "총괄책임자", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-014", + "organizationId": "ptc-division-0", + "affiliationId": "ptc", + "name": "가상직원 014", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-015", + "organizationId": "ptc-division-1", + "affiliationId": "ptc", + "name": "가상직원 015", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-016", + "organizationId": "hanmac-division-0", + "affiliationId": "hanmac", + "name": "가상직원 016", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-017", + "organizationId": "hanmac-office-0", + "affiliationId": "hanmac", + "name": "가상직원 017", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-018", + "organizationId": "hanmac-office-0", + "affiliationId": "hanmac", + "name": "가상직원 018", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-019", + "organizationId": "hanmac-cell-0", + "affiliationId": "hanmac", + "name": "가상직원 019", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-020", + "organizationId": "hanmac-cell-0", + "affiliationId": "hanmac", + "name": "가상직원 020", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-021", + "organizationId": "hanmac-cell-0", + "affiliationId": "hanmac", + "name": "가상직원 021", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-022", + "organizationId": "hanmac-office-sibling", + "affiliationId": "hanmac", + "name": "가상직원 022", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-023", + "organizationId": "hanmac-office-sibling", + "affiliationId": "hanmac", + "name": "가상직원 023", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-024", + "organizationId": "hanmac-office-sibling", + "affiliationId": "hanmac", + "name": "가상직원 024", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-025", + "organizationId": "hanmac-office-sibling", + "affiliationId": "hanmac", + "name": "가상직원 025", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-026", + "organizationId": "hanmac-division-1", + "affiliationId": "hanmac", + "name": "가상직원 026", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-027", + "organizationId": "hanmac-office-1", + "affiliationId": "hanmac", + "name": "가상직원 027", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-028", + "organizationId": "hanmac-office-1", + "affiliationId": "hanmac", + "name": "가상직원 028", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-029", + "organizationId": "hanmac-cell-1", + "affiliationId": "hanmac", + "name": "가상직원 029", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-030", + "organizationId": "hanmac-cell-1", + "affiliationId": "hanmac", + "name": "가상직원 030", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-031", + "organizationId": "hanmac-cell-1", + "affiliationId": "hanmac", + "name": "가상직원 031", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-032", + "organizationId": "saman-division-0", + "affiliationId": "saman", + "name": "가상직원 032", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-033", + "organizationId": "saman-division-0", + "affiliationId": "saman", + "name": "가상직원 033", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-034", + "organizationId": "saman-division-0", + "affiliationId": "saman", + "name": "가상직원 034", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-035", + "organizationId": "saman-division-0", + "affiliationId": "saman", + "name": "가상직원 035", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-036", + "organizationId": "saman-office-0", + "affiliationId": "saman", + "name": "가상직원 036", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-037", + "organizationId": "saman-cell-0", + "affiliationId": "saman", + "name": "가상직원 037", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-038", + "organizationId": "saman-cell-0", + "affiliationId": "saman", + "name": "가상직원 038", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-039", + "organizationId": "saman-office-sibling", + "affiliationId": "saman", + "name": "가상직원 039", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-040", + "organizationId": "saman-office-sibling", + "affiliationId": "saman", + "name": "가상직원 040", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-041", + "organizationId": "saman-office-sibling", + "affiliationId": "saman", + "name": "가상직원 041", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-042", + "organizationId": "saman-division-1", + "affiliationId": "saman", + "name": "가상직원 042", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-043", + "organizationId": "saman-division-1", + "affiliationId": "saman", + "name": "가상직원 043", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-044", + "organizationId": "saman-division-1", + "affiliationId": "saman", + "name": "가상직원 044", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-045", + "organizationId": "saman-division-1", + "affiliationId": "saman", + "name": "가상직원 045", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-046", + "organizationId": "saman-office-1", + "affiliationId": "saman", + "name": "가상직원 046", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-047", + "organizationId": "saman-cell-1", + "affiliationId": "saman", + "name": "가상직원 047", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-048", + "organizationId": "saman-cell-1", + "affiliationId": "saman", + "name": "가상직원 048", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-049", + "organizationId": "jangheon-industry-division-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 049", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-050", + "organizationId": "jangheon-industry-division-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 050", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-051", + "organizationId": "jangheon-industry-division-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 051", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-052", + "organizationId": "jangheon-industry-office-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 052", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-053", + "organizationId": "jangheon-industry-office-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 053", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-054", + "organizationId": "jangheon-industry-office-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 054", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-055", + "organizationId": "jangheon-industry-office-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 055", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-056", + "organizationId": "jangheon-industry-cell-0", + "affiliationId": "jangheon-industry", + "name": "가상직원 056", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-057", + "organizationId": "jangheon-industry-office-sibling", + "affiliationId": "jangheon-industry", + "name": "가상직원 057", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-058", + "organizationId": "jangheon-industry-office-sibling", + "affiliationId": "jangheon-industry", + "name": "가상직원 058", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-059", + "organizationId": "jangheon-industry-division-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 059", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-060", + "organizationId": "jangheon-industry-division-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 060", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-061", + "organizationId": "jangheon-industry-division-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 061", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-062", + "organizationId": "jangheon-industry-office-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 062", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-063", + "organizationId": "jangheon-industry-office-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 063", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-064", + "organizationId": "jangheon-industry-office-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 064", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-065", + "organizationId": "jangheon-industry-office-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 065", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-066", + "organizationId": "jangheon-industry-cell-1", + "affiliationId": "jangheon-industry", + "name": "가상직원 066", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-067", + "organizationId": "jangheon-division-0", + "affiliationId": "jangheon", + "name": "가상직원 067", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-068", + "organizationId": "jangheon-division-0", + "affiliationId": "jangheon", + "name": "가상직원 068", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-069", + "organizationId": "jangheon-office-0", + "affiliationId": "jangheon", + "name": "가상직원 069", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-070", + "organizationId": "jangheon-office-0", + "affiliationId": "jangheon", + "name": "가상직원 070", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-071", + "organizationId": "jangheon-office-0", + "affiliationId": "jangheon", + "name": "가상직원 071", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-072", + "organizationId": "jangheon-cell-0", + "affiliationId": "jangheon", + "name": "가상직원 072", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-073", + "organizationId": "jangheon-cell-0", + "affiliationId": "jangheon", + "name": "가상직원 073", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-074", + "organizationId": "jangheon-cell-0", + "affiliationId": "jangheon", + "name": "가상직원 074", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-075", + "organizationId": "jangheon-cell-0", + "affiliationId": "jangheon", + "name": "가상직원 075", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-076", + "organizationId": "jangheon-office-sibling", + "affiliationId": "jangheon", + "name": "가상직원 076", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-077", + "organizationId": "jangheon-division-1", + "affiliationId": "jangheon", + "name": "가상직원 077", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-078", + "organizationId": "jangheon-division-1", + "affiliationId": "jangheon", + "name": "가상직원 078", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-079", + "organizationId": "jangheon-office-1", + "affiliationId": "jangheon", + "name": "가상직원 079", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-080", + "organizationId": "jangheon-office-1", + "affiliationId": "jangheon", + "name": "가상직원 080", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-081", + "organizationId": "jangheon-office-1", + "affiliationId": "jangheon", + "name": "가상직원 081", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-082", + "organizationId": "jangheon-cell-1", + "affiliationId": "jangheon", + "name": "가상직원 082", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-083", + "organizationId": "jangheon-cell-1", + "affiliationId": "jangheon", + "name": "가상직원 083", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-084", + "organizationId": "jangheon-cell-1", + "affiliationId": "jangheon", + "name": "가상직원 084", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-085", + "organizationId": "jangheon-cell-1", + "affiliationId": "jangheon", + "name": "가상직원 085", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-086", + "organizationId": "ptc-division-0", + "affiliationId": "ptc", + "name": "가상직원 086", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-087", + "organizationId": "ptc-office-0", + "affiliationId": "ptc", + "name": "가상직원 087", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-088", + "organizationId": "ptc-office-0", + "affiliationId": "ptc", + "name": "가상직원 088", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-089", + "organizationId": "ptc-cell-0", + "affiliationId": "ptc", + "name": "가상직원 089", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-090", + "organizationId": "ptc-cell-0", + "affiliationId": "ptc", + "name": "가상직원 090", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-091", + "organizationId": "ptc-cell-0", + "affiliationId": "ptc", + "name": "가상직원 091", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-092", + "organizationId": "ptc-office-sibling", + "affiliationId": "ptc", + "name": "가상직원 092", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-093", + "organizationId": "ptc-office-sibling", + "affiliationId": "ptc", + "name": "가상직원 093", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-094", + "organizationId": "ptc-office-sibling", + "affiliationId": "ptc", + "name": "가상직원 094", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-095", + "organizationId": "ptc-office-sibling", + "affiliationId": "ptc", + "name": "가상직원 095", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-096", + "organizationId": "ptc-division-1", + "affiliationId": "ptc", + "name": "가상직원 096", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-097", + "organizationId": "ptc-office-1", + "affiliationId": "ptc", + "name": "가상직원 097", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-098", + "organizationId": "ptc-office-1", + "affiliationId": "ptc", + "name": "가상직원 098", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-099", + "organizationId": "ptc-cell-1", + "affiliationId": "ptc", + "name": "가상직원 099", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-100", + "organizationId": "ptc-cell-1", + "affiliationId": "ptc", + "name": "가상직원 100", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + } + ], + "affiliations": [ + { + "id": "hanmac", + "name": "한맥기술" + }, + { + "id": "saman", + "name": "삼안" + }, + { + "id": "jangheon-industry", + "name": "장헌산업" + }, + { + "id": "jangheon", + "name": "장헌" + }, + { + "id": "ptc", + "name": "피티씨" + } + ], + "relations": [], + "presentation": { + "title": "확장형 조직도 · 가상 데이터", + "description": "실제 직원 정보가 아닌 고정된 테스트 데이터입니다.", + "hubText": null, + "groups": { + "showEmployees": true, + "showSummary": false, + "showDispatched": false, + "showOnLeave": false, + "showAffiliations": false + }, + "organizationOrder": [] + }, + "metadata": { + "asOfDate": "2026-10-12", + "dataVersion": "p5-23-medium-100", + "updateNote": "가상 데이터 · 운영 정보 아님" + } +} diff --git a/examples/org-chart/data-expanded-small.json b/examples/org-chart/data-expanded-small.json new file mode 100644 index 0000000..e50ee6b --- /dev/null +++ b/examples/org-chart/data-expanded-small.json @@ -0,0 +1,375 @@ +{ + "schemaVersion": "0.1-draft", + "organizations": [ + { + "id": "hanmac-root", + "companyId": "hanmac", + "parentId": null, + "name": "한맥기술 · 가상 조직", + "type": "root", + "leaderId": "virtual-001", + "displayOrder": 0 + }, + { + "id": "hanmac-division-0", + "companyId": "hanmac", + "parentId": "hanmac-root", + "name": "가상 사업본부", + "type": "division", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "hanmac-office-0", + "companyId": "hanmac", + "parentId": "hanmac-division-0", + "name": "가상 1실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "hanmac-division-1", + "companyId": "hanmac", + "parentId": "hanmac-root", + "name": "가상 지원본부", + "type": "division", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "hanmac-office-1", + "companyId": "hanmac", + "parentId": "hanmac-division-1", + "name": "가상 2실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "saman-root", + "companyId": "saman", + "parentId": null, + "name": "삼안 · 가상 조직", + "type": "root", + "leaderId": "virtual-002", + "displayOrder": 1 + }, + { + "id": "saman-division-0", + "companyId": "saman", + "parentId": "saman-root", + "name": "가상 사업본부", + "type": "division", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "saman-office-0", + "companyId": "saman", + "parentId": "saman-division-0", + "name": "가상 1실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "saman-division-1", + "companyId": "saman", + "parentId": "saman-root", + "name": "가상 지원본부", + "type": "division", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "saman-office-1", + "companyId": "saman", + "parentId": "saman-division-1", + "name": "가상 2실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "jangheon-industry-root", + "companyId": "jangheon-industry", + "parentId": null, + "name": "장헌산업 · 가상 조직", + "type": "root", + "leaderId": "virtual-003", + "displayOrder": 2 + }, + { + "id": "jangheon-industry-division-0", + "companyId": "jangheon-industry", + "parentId": "jangheon-industry-root", + "name": "가상 사업본부", + "type": "division", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "jangheon-industry-office-0", + "companyId": "jangheon-industry", + "parentId": "jangheon-industry-division-0", + "name": "가상 1실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "jangheon-industry-division-1", + "companyId": "jangheon-industry", + "parentId": "jangheon-industry-root", + "name": "가상 지원본부", + "type": "division", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "jangheon-industry-office-1", + "companyId": "jangheon-industry", + "parentId": "jangheon-industry-division-1", + "name": "가상 2실", + "type": "office", + "leaderId": null, + "displayOrder": 0 + } + ], + "employees": [ + { + "id": "virtual-001", + "organizationId": "hanmac-root", + "affiliationId": "hanmac", + "name": "가상책임자 1", + "position": "회장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-002", + "organizationId": "saman-root", + "affiliationId": "saman", + "name": "가상책임자 2", + "position": "사업총괄", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-003", + "organizationId": "jangheon-industry-root", + "affiliationId": "jangheon-industry", + "name": "가상책임자 3", + "position": "본부장", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-004", + "organizationId": "hanmac-division-0", + "affiliationId": "hanmac", + "name": "가상직원 004", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-005", + "organizationId": "hanmac-office-0", + "affiliationId": "hanmac", + "name": "가상직원 005", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-006", + "organizationId": "hanmac-office-0", + "affiliationId": "hanmac", + "name": "홍길동", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-007", + "organizationId": "hanmac-division-1", + "affiliationId": "hanmac", + "name": "가상직원 007", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-008", + "organizationId": "hanmac-division-1", + "affiliationId": "hanmac", + "name": "가상직원 008", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-009", + "organizationId": "hanmac-division-1", + "affiliationId": "hanmac", + "name": "가상직원 009", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-010", + "organizationId": "hanmac-office-1", + "affiliationId": "hanmac", + "name": "가상직원 010", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-011", + "organizationId": "hanmac-office-1", + "affiliationId": "hanmac", + "name": "가상직원 011", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-012", + "organizationId": "hanmac-office-1", + "affiliationId": "hanmac", + "name": "가상직원 012", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-013", + "organizationId": "hanmac-office-1", + "affiliationId": "hanmac", + "name": "가상직원 013", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-014", + "organizationId": "saman-division-0", + "affiliationId": "saman", + "name": "가상직원 014", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-015", + "organizationId": "saman-office-0", + "affiliationId": "saman", + "name": "가상직원 015", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-016", + "organizationId": "saman-office-0", + "affiliationId": "saman", + "name": "가상직원 016", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-017", + "organizationId": "saman-division-1", + "affiliationId": "saman", + "name": "가상직원 017", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-018", + "organizationId": "saman-division-1", + "affiliationId": "saman", + "name": "가상직원 018", + "position": "수석", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-019", + "organizationId": "saman-division-1", + "affiliationId": "saman", + "name": "가상직원 019", + "position": "사원", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "virtual-020", + "organizationId": "saman-office-1", + "affiliationId": "saman", + "name": "가상직원 020", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + } + ], + "affiliations": [ + { + "id": "hanmac", + "name": "한맥기술" + }, + { + "id": "saman", + "name": "삼안" + }, + { + "id": "jangheon-industry", + "name": "장헌산업" + } + ], + "relations": [], + "presentation": { + "title": "확장형 조직도 · 가상 데이터", + "description": "실제 직원 정보가 아닌 고정된 테스트 데이터입니다.", + "hubText": null, + "groups": { + "showEmployees": true, + "showSummary": false, + "showDispatched": false, + "showOnLeave": false, + "showAffiliations": false + }, + "organizationOrder": [] + }, + "metadata": { + "asOfDate": "2026-10-12", + "dataVersion": "p5-23-small-20", + "updateNote": "가상 데이터 · 운영 정보 아님" + } +} diff --git a/examples/org-chart/data-expanded.json b/examples/org-chart/data-expanded.json index a1bcccc..5779548 100644 --- a/examples/org-chart/data-expanded.json +++ b/examples/org-chart/data-expanded.json @@ -1,43 +1,336 @@ { "schemaVersion": "0.1-draft", "organizations": [ - { "id": "general-planning", "parentId": null, "name": "총괄기획실", "type": "root", "leaderId": "expanded-representative", "displayOrder": 0 }, - { "id": "business-management", "parentId": "general-planning", "name": "사업관리", "type": "office", "leaderId": "expanded-leader-01", "displayOrder": 0 }, - { "id": "business-planning", "parentId": "general-planning", "name": "경영기획", "type": "office", "leaderId": "expanded-leader-02", "displayOrder": 1 }, - { "id": "talent-growth", "parentId": "general-planning", "name": "인재성장", "type": "office", "leaderId": "expanded-leader-03", "displayOrder": 2 } + { + "id": "general-planning", + "parentId": null, + "name": "총괄기획실", + "type": "root", + "leaderId": "expanded-representative", + "displayOrder": 0 + }, + { + "id": "business-management", + "parentId": "general-planning", + "name": "사업관리", + "type": "office", + "leaderId": "expanded-leader-01", + "displayOrder": 0 + }, + { + "id": "business-planning", + "parentId": "general-planning", + "name": "경영기획", + "type": "office", + "leaderId": "expanded-leader-02", + "displayOrder": 1 + }, + { + "id": "talent-growth", + "parentId": "general-planning", + "name": "인재성장", + "type": "office", + "leaderId": "expanded-leader-03", + "displayOrder": 2 + }, + { + "id": "business-control", + "parentId": "business-management", + "name": "사업관리 셀", + "type": "cell", + "leaderId": "expanded-cell-leader-01", + "displayOrder": 0 + }, + { + "id": "project-support", + "parentId": "business-management", + "name": "사업지원 셀", + "type": "cell", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "finance", + "parentId": "business-planning", + "name": "재무 셀", + "type": "cell", + "leaderId": "expanded-cell-leader-02", + "displayOrder": 2 + }, + { + "id": "planning-support", + "parentId": "business-planning", + "name": "기획지원 셀", + "type": "cell", + "leaderId": null, + "displayOrder": 3 + }, + { + "id": "people-support", + "parentId": "talent-growth", + "name": "성장지원 셀", + "type": "cell", + "leaderId": null, + "displayOrder": 4 + }, + { + "id": "project-review", + "parentId": "business-control", + "name": "사업검토 팀", + "type": "cell", + "leaderId": "expanded-team-leader-01", + "displayOrder": 5 + }, + { + "id": "review-support", + "parentId": "project-review", + "name": "검토지원 파트", + "type": "cell", + "leaderId": null, + "displayOrder": 6 + } ], "employees": [ - { "id": "expanded-representative", "organizationId": "general-planning", "affiliationId": "hanmac", "name": "홍길동", "position": "대표이사", "jobTitle": "대표이사", "profileImage": null, "workStatus": "active" }, - { "id": "expanded-leader-01", "organizationId": "business-management", "affiliationId": "hanmac", "name": "이도윤", "position": "수석", "jobTitle": "팀장", "profileImage": null, "workStatus": "active" }, - { "id": "expanded-employee-01", "organizationId": "business-management", "affiliationId": "saman", "name": "김하린", "position": "책임", "jobTitle": null, "profileImage": null, "workStatus": "active" }, - { "id": "expanded-employee-02", "organizationId": "business-management", "affiliationId": "jangheon-industry", "name": "박서준", "position": "선임", "jobTitle": null, "profileImage": null, "workStatus": "active" }, - { "id": "expanded-leader-02", "organizationId": "business-planning", "affiliationId": "jangheon", "name": "정지우", "position": "수석", "jobTitle": "팀장", "profileImage": null, "workStatus": "active" }, - { "id": "expanded-employee-03", "organizationId": "business-planning", "affiliationId": "ptc", "name": "최유진", "position": "책임", "jobTitle": null, "profileImage": null, "workStatus": "active" }, - { "id": "expanded-employee-04", "organizationId": "business-planning", "affiliationId": "hanmac", "name": "윤서아", "position": "연구", "jobTitle": null, "profileImage": null, "workStatus": "active" }, - { "id": "expanded-leader-03", "organizationId": "talent-growth", "affiliationId": "halla", "name": "강민준", "position": "수석", "jobTitle": "팀장", "profileImage": null, "workStatus": "active" }, - { "id": "expanded-employee-05", "organizationId": "talent-growth", "affiliationId": "baron", "name": "한예린", "position": "책임", "jobTitle": null, "profileImage": null, "workStatus": "active" }, - { "id": "expanded-employee-06", "organizationId": "talent-growth", "affiliationId": "saman", "name": "오하준", "position": "선임", "jobTitle": null, "profileImage": null, "workStatus": "active" } + { + "id": "expanded-representative", + "organizationId": "general-planning", + "affiliationId": "hanmac", + "name": "홍길동", + "position": "대표이사", + "jobTitle": "대표이사", + "profileImage": null, + "workStatus": "active" + }, + { + "id": "expanded-leader-01", + "organizationId": "business-management", + "affiliationId": "hanmac", + "name": "이도윤", + "position": "수석", + "jobTitle": "팀장", + "profileImage": null, + "workStatus": "active" + }, + { + "id": "expanded-employee-01", + "organizationId": "business-management", + "affiliationId": "saman", + "name": "김하린", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "expanded-employee-02", + "organizationId": "business-management", + "affiliationId": "jangheon-industry", + "name": "박서준", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "expanded-leader-02", + "organizationId": "business-planning", + "affiliationId": "jangheon", + "name": "정지우", + "position": "수석", + "jobTitle": "팀장", + "profileImage": null, + "workStatus": "active" + }, + { + "id": "expanded-employee-03", + "organizationId": "business-planning", + "affiliationId": "ptc", + "name": "최유진", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "expanded-employee-04", + "organizationId": "business-planning", + "affiliationId": "hanmac", + "name": "윤서아", + "position": "연구", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "expanded-leader-03", + "organizationId": "talent-growth", + "affiliationId": "halla", + "name": "강민준", + "position": "수석", + "jobTitle": "팀장", + "profileImage": null, + "workStatus": "active" + }, + { + "id": "expanded-employee-05", + "organizationId": "talent-growth", + "affiliationId": "baron", + "name": "한예린", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "expanded-employee-06", + "organizationId": "talent-growth", + "affiliationId": "saman", + "name": "오하준", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "expanded-cell-leader-01", + "organizationId": "business-control", + "affiliationId": "hanmac", + "name": "김민준", + "position": "팀장", + "jobTitle": "팀장", + "profileImage": null, + "workStatus": "active" + }, + { + "id": "expanded-cell-leader-02", + "organizationId": "finance", + "affiliationId": "hanmac", + "name": "김민준", + "position": "팀장", + "jobTitle": "팀장", + "profileImage": null, + "workStatus": "active" + }, + { + "id": "expanded-team-leader-01", + "organizationId": "project-review", + "affiliationId": "hanmac", + "name": "김민준", + "position": "팀장", + "jobTitle": "팀장", + "profileImage": null, + "workStatus": "active" + }, + { + "id": "expanded-nested-1", + "organizationId": "business-control", + "affiliationId": "hanmac", + "name": "서지우", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "expanded-nested-2", + "organizationId": "business-control", + "affiliationId": "hanmac", + "name": "서지우", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "expanded-nested-3", + "organizationId": "finance", + "affiliationId": "hanmac", + "name": "서지우", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "expanded-nested-4", + "organizationId": "project-review", + "affiliationId": "hanmac", + "name": "서지우", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "expanded-nested-5", + "organizationId": "review-support", + "affiliationId": "hanmac", + "name": "서지우", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + } ], "affiliations": [ - { "id": "hanmac", "name": "(주)한맥기술" }, - { "id": "saman", "name": "(주)삼안" }, - { "id": "jangheon", "name": "(주)장헌" }, - { "id": "jangheon-industry", "name": "장헌산업" }, - { "id": "ptc", "name": "PTC" }, - { "id": "halla", "name": "한라산업개발" }, - { "id": "baron", "name": "바론컨설턴트" } + { + "id": "hanmac", + "name": "(주)한맥기술" + }, + { + "id": "saman", + "name": "(주)삼안" + }, + { + "id": "jangheon", + "name": "(주)장헌" + }, + { + "id": "jangheon-industry", + "name": "장헌산업" + }, + { + "id": "ptc", + "name": "PTC" + }, + { + "id": "halla", + "name": "한라산업개발" + }, + { + "id": "baron", + "name": "바론컨설턴트" + } ], "relations": [], "presentation": { - "title": "확장형 조직도 · P5-1A", - "description": "대표자와 1Depth 그룹 초기 표시 · 자동 계층 배치 미구현 · 가상 직원 정보", + "title": "확장형 조직도", + "description": "자동 계층 배치 · 가상 직원 정보", "hubText": null, - "groups": { "showEmployees": true, "showSummary": false, "showDispatched": false, "showOnLeave": false, "showAffiliations": false }, - "organizationOrder": ["general-planning", "business-management", "business-planning", "talent-growth"] + "groups": { + "showEmployees": true, + "showSummary": false, + "showDispatched": false, + "showOnLeave": false, + "showAffiliations": false + }, + "organizationOrder": [ + "general-planning", + "business-management", + "business-planning", + "talent-growth", + "business-control", + "project-support", + "finance", + "planning-support", + "people-support", + "project-review", + "review-support" + ] }, "metadata": { "asOfDate": "2026-10-12", - "dataVersion": "p5-1a-expanded-initial-v1", - "updateNote": "P5-1A 초기 1Depth 구성 · 운영 HR 정보와 무관한 가상 데이터" + "dataVersion": "p5-1b-expanded-hierarchy-v1", + "updateNote": "P5-1B 최소 재귀 계층 데이터 · 운영 HR 정보와 무관" } } diff --git a/examples/org-chart/expanded-app.js b/examples/org-chart/expanded-app.js new file mode 100644 index 0000000..2782dc8 --- /dev/null +++ b/examples/org-chart/expanded-app.js @@ -0,0 +1,33 @@ +import { createOrgChart, JsonOrgChartDataSource } from './assets/index.js'; +const chart = document.getElementById('chart'); +const selector = document.getElementById('dataset'); +const fieldset = document.getElementById('companies'); +const error = document.getElementById('error'); +const fixtures = Object.fromEntries(await Promise.all(['small', 'medium', 'large'].map(async size => [size, await (await fetch(`./data-expanded-${size}.json`)).json()]))); +const connections = Object.fromEntries(fixtures.large.organizations.map((org, index) => [org.id, ['top', 'left', 'right'][index % 3]])); +const handle = createOrgChart({ container: chart, layoutMode: 'auto', displayMode: 'readOnly', dataSource: new JsonOrgChartDataSource(fixtures.small), + stylesheetUrls: { engine: './assets/engine.css', core: './assets/style.css' }, employeeConnections: connections, + companyTopNodes: { ptc: { title: '기술총괄', name: '가상책임자 5' } }, + onStateChange: state => { error.textContent = state.status === 'error' ? '조직도 데이터 또는 스타일을 확인해 주세요.' : ''; } }); +// Dev/example ownership only. Filtering is done by the public Core handle. +window.expandedHandle = handle; +async function selection() { + await handle.setSelectedCompanies([...fieldset.querySelectorAll('input:checked')].map(input => input.value)); +} +function companies() { + fieldset.querySelectorAll('label').forEach(label => label.remove()); + for (const company of fixtures[selector.value].affiliations) { + const label = document.createElement('label'), input = document.createElement('input'); + input.type = 'checkbox'; input.checked = true; input.value = company.id; + input.id = `company-${company.id}`; label.htmlFor = input.id; + label.append(input, document.createTextNode(company.name)); + input.addEventListener('change', () => { void selection(); }); fieldset.append(label); + } +} +selector.addEventListener('change', async () => { + companies(); await handle.setSelectedCompanies(); await handle.reload(new JsonOrgChartDataSource(fixtures[selector.value])); +}); +document.getElementById('all').addEventListener('click', async () => { fieldset.querySelectorAll('input').forEach(input => { input.checked = true; }); await handle.setSelectedCompanies(); }); +document.getElementById('none').addEventListener('click', async () => { fieldset.querySelectorAll('input').forEach(input => { input.checked = false; }); await selection(); }); +companies(); await handle.mount(); +window.addEventListener('pagehide', () => handle.destroy(), { once: true }); diff --git a/examples/org-chart/expanded.css b/examples/org-chart/expanded.css new file mode 100644 index 0000000..5f9bde8 --- /dev/null +++ b/examples/org-chart/expanded.css @@ -0,0 +1,9 @@ +* { box-sizing: border-box; } +body { margin: 0; font-family: 'Noto Sans KR', sans-serif; } +main { padding: 16px; min-width: 0; } +h1 { font-size: 22px; } +.controls, #companies { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; } +#companies { border: 0; padding: 16px 0; } +#companies label { display: flex; gap: 8px; align-items: center; } +#companies legend { padding: 8px 0; } +button, select { font: inherit; } diff --git a/examples/org-chart/expanded.html b/examples/org-chart/expanded.html new file mode 100644 index 0000000..38d30ba --- /dev/null +++ b/examples/org-chart/expanded.html @@ -0,0 +1,6 @@ + +확장형 조직도 · 가상 데이터 + +

확장형 조직도 · HTML Core 예시

모든 직원은 고정된 가상 데이터입니다.

+
+
표시할 회사 · 복수 선택 가능
diff --git a/examples/org-chart/prepare.mjs b/examples/org-chart/prepare.mjs index 9828190..041aef1 100644 --- a/examples/org-chart/prepare.mjs +++ b/examples/org-chart/prepare.mjs @@ -38,3 +38,5 @@ await writeFile(path.join(output, 'p1-fixture.js'), `window.OrgChartP1Fixture = const p1App = await readFile(path.join(example, 'p1-app.js'), 'utf8'); await writeFile(path.join(output, 'p1-app-classic.js'), p1App.replace('export async function startP1', 'async function startP1')); console.log(`OrgChart HTML example: ${output}`); +for (const file of ['expanded.html', 'expanded-app.js', 'expanded.css', 'data-expanded-small.json', 'data-expanded-medium.json', 'data-expanded-large.json']) + await cp(path.join(example, file), path.join(output, file)); diff --git a/examples/org-chart/verify-p5-1b.mjs b/examples/org-chart/verify-p5-1b.mjs new file mode 100644 index 0000000..732d91e --- /dev/null +++ b/examples/org-chart/verify-p5-1b.mjs @@ -0,0 +1,377 @@ +import http from 'node:http'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +// P5-1B focused integration suite. Preserve historical verification evidence. +// Copy the pre-change public Core dist to baseline-dist before rebuilding. +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-p5-1b'); +const storyOrigin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6053'; +const playwright = process.env.ORGCHART_PLAYWRIGHT_MODULE; +const { chromium } = await import(playwright ? pathToFileURL(playwright).href : 'playwright'); +const fixedFixture = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8')); +const expandedFixture = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-expanded.json'), 'utf8')); +const presets = [ + { id: 'hanmac', name: '한맥기술', primary: '#CB2E28', secondary: ['#1D4289'] }, + { id: 'saman', name: '삼안', primary: '#F08300', secondary: ['#6D6C6C'] }, + { id: 'jangheon-industry', name: '장헌산업', primary: '#4580A6', secondary: ['#92BED4'] }, + { id: 'jangheon', name: '장헌', primary: '#1D418D', secondary: ['#AE232A'] }, + { id: 'ptc', name: '피티씨', primary: '#84338E', secondary: ['#F7AF00', '#0066B5'] }, + { id: 'halla', name: '한라산업개발', primary: '#0068B7', secondary: ['#231815'] }, + { id: 'baron', name: '바론컨설턴트', primary: '#0C367F', secondary: [] }, +]; +const results = [], measurements = [], errors = [], warnings = []; +const check = (pass, name, detail) => results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); +const near = (a, b) => Number.isFinite(a) && Number.isFinite(b) && Math.abs(a - b) <= 0.35; +const rgb = hex => `rgb(${[1, 3, 5].map(offset => parseInt(hex.slice(offset, offset + 2), 16)).join(', ')})`; +const expectedCompanyIds = presets.map(preset => preset.id); +const mime = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ttf': 'font/ttf', '.svg': 'image/svg+xml', '.png': 'image/png' }; +const shellHtml = '
'; +await fs.mkdir(output, { recursive: true }); +const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url, 'http://127.0.0.1'); + if (url.pathname === '/__p51b') return response.writeHead(200, { 'Content-Type': 'text/html' }).end(shellHtml); + const pathname = decodeURIComponent(url.pathname); + const file = path.resolve(root, '.' + (pathname.startsWith('/fonts/') ? '/public' + pathname : pathname)); + const relative = path.relative(root, file); + if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end(); + const bytes = await fs.readFile(file); + response.writeHead(200, { 'Content-Type': mime[path.extname(file)] ?? 'application/octet-stream' }); + response.end(bytes); + } catch { response.writeHead(404).end(); } +}); +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +const origin = `http://127.0.0.1:${server.address().port}`; +const browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true }); + +async function newPage(name, viewport = { width: 1440, height: 900 }) { + const page = await browser.newPage({ viewport }); + page.on('pageerror', error => errors.push({ name, type: 'pageerror', message: error.message })); + page.on('console', message => { + if (message.type() === 'error') errors.push({ name, type: 'console', message: message.text() }); + if (message.type() === 'warning') warnings.push({ name, message: message.text() }); + }); + return page; +} +async function ready(page, mode) { + await page.waitForFunction(expected => { + const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; + const content = shadow?.querySelector(expected === 'auto' ? '.hm-orgchart-expanded' : '.hm-orgchart-composition'); + return content?.querySelector('.hm-orgchart-employee') && [...shadow.querySelectorAll('link[rel=stylesheet]')].every(link => link.sheet); + }, mode, { timeout: 25000 }); + await page.evaluate(async () => { + await document.fonts.load('600 24px "Noto Sans KR"', '홍 길 동'); + await document.fonts.ready; + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + const images = [...shadow.querySelectorAll('.hm-orgchart-employee img')]; + for (const image of images) image.loading = 'eager'; + await Promise.all(images.map(image => image.decode().catch(() => {}))); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }); +} +async function mount(page, { baseline = false, fixture = expandedFixture, options = {} } = {}) { + await page.goto(`${origin}/__p51b`); + await page.evaluate(async ({ base, fixture, options }) => { + window.hmModule = await import(`${base}/index.js`); + window.fixture = structuredClone(fixture); + window.handle = window.hmModule.createOrgChart({ + container: document.getElementById('chart'), + dataSource: new window.hmModule.JsonOrgChartDataSource(fixture), + stylesheetUrls: { engine: `${base}/engine.css`, core: `${base}/style.css` }, + ...options, + }); + await window.handle.mount(); + }, { base: `${origin}/${baseline ? 'test-results/org-chart-p5-1b/baseline-dist' : 'packages/org-chart/dist'}`, fixture, options }); + await ready(page, options.layoutMode === 'auto' ? 'auto' : 'fixed'); + return page.evaluate(() => window.handle.getState()); +} +async function measure(page) { + return page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + const stage = shadow.querySelector('.hm-orgchart-stage'); + const base = stage?.getBoundingClientRect() ?? shadow.querySelector('.hm-orgchart-expanded').getBoundingClientRect(); + const scale = stage ? base.width / stage.offsetWidth : 1; + const box = element => { + const bounds = element.getBoundingClientRect(); + return { x: (bounds.x - base.x) / scale, y: (bounds.y - base.y) / scale, width: bounds.width / scale, height: bounds.height / scale }; + }; + const cards = [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({ + key: `${card.closest('.hm-orgchart-status-group')?.dataset.workStatus ?? 'tree'}:${card.dataset.employeeId}`, + id: card.dataset.employeeId, kind: card.dataset.cardKind, size: card.dataset.cardAssetSize ?? null, + box: box(card), asset: card.querySelector('.hm-orgchart-card-background')?.src ?? null, + logo: card.querySelector('.hm-orgchart-affiliation-mark img')?.src ?? null, + name: card.querySelector('.hm-orgchart-person-name')?.textContent, + role: card.querySelector('.hm-orgchart-person-role')?.textContent, + font: getComputedStyle(card.querySelector('.hm-orgchart-person-name')).fontFamily, + badgeCount: card.querySelectorAll('.hm-orgchart-job-title,.hm-orgchart-status-label,.hm-orgchart-person-attributes').length, + placeholder: card.dataset.photoState, + })); + const nodes = [...shadow.querySelectorAll('.node[data-organization-id]')].map(node => { + const heading = node.querySelector('.hm-orgchart-organization-name'); + const style = getComputedStyle(heading); + return { id: node.dataset.organizationId, companyId: node.dataset.companyId ?? null, + depth: Number(node.dataset.organizationDepth), text: heading.textContent, box: box(heading), + backgroundColor: style.backgroundColor, backgroundImage: style.backgroundImage, + primary: node.style.getPropertyValue('--hm-orgchart-expanded-primary'), + secondary: [...node.style].filter(property => property.startsWith('--hm-orgchart-expanded-secondary-')).map(property => node.style.getPropertyValue(property)), + }; + }); + const collisions = []; + for (let index = 0; index < cards.length; index++) for (const other of cards.slice(index + 1)) { + const a = cards[index].box, b = other.box; + if (Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > 0.5 + && Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > 0.5) collisions.push([cards[index].key, other.key]); + } + const viewport = shadow.querySelector('.hm-orgchart-viewport'); + const expanded = shadow.querySelector('.hm-orgchart-expanded'); + const summaries = [...shadow.querySelectorAll('[data-component="PositionSummary"],[data-component="AffiliationSummary"],[data-component="StatusSummary"]')].map(element => ({ kind: element.dataset.component, text: element.textContent })); + return { scale, cards, nodes, summaries, collisions, + hostCount: document.querySelectorAll('.hm-orgchart-host').length, + compositionCount: shadow.querySelectorAll('.hm-orgchart-composition,.hm-orgchart-expanded').length, + controls: shadow.querySelectorAll('.hm-orgchart-toggle,.zoom-controls,.pan-controls,.minimap').length, + layoutStatus: expanded?.dataset.layoutStatus ?? null, + phaseText: expanded?.textContent ?? '', + viewport: viewport ? { clientWidth: viewport.clientWidth, scrollWidth: viewport.scrollWidth, clientHeight: viewport.clientHeight, scrollHeight: viewport.scrollHeight, overflowX: getComputedStyle(viewport).overflowX } : null, + document: { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, clientHeight: document.documentElement.clientHeight, scrollHeight: document.documentElement.scrollHeight }, + }; + }); +} +function compareFixed(before, after, name) { + check(JSON.stringify(before.cards.map(({ box, ...rest }) => rest)) === JSON.stringify(after.cards.map(({ box, ...rest }) => rest)), `${name}: card assets/content/typography/placeholder unchanged`); + check(before.cards.length === after.cards.length && before.cards.every((card, index) => ['x', 'y', 'width', 'height'].every(key => near(card.box[key], after.cards[index].box[key]))), `${name}: fixed card coordinates and dimensions unchanged`); + check(before.nodes.length === after.nodes.length && before.nodes.every((node, index) => { + const next = after.nodes[index]; + return node.id === next.id && node.depth === next.depth && node.text === next.text + && node.backgroundColor === next.backgroundColor && node.backgroundImage === next.backgroundImage + && ['x', 'y', 'width', 'height'].every(key => near(node.box[key], next.box[key])); + }), `${name}: hierarchy headers and Workdesk gradients unchanged`); + check(JSON.stringify(before.summaries) === JSON.stringify(after.summaries), `${name}: summary counts unchanged`); + check(after.controls === 0 && after.compositionCount === 1 && after.layoutStatus === null, `${name}: fixed read-only renderer retained`); +} +async function popup(page, name, id) { + const card = page.locator(`.hm-orgchart-employee[data-employee-id="${id}"]`).first(); + await card.focus(); + await card.press('Enter'); + await page.locator('dialog[open]').waitFor(); + const data = await page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + const dialog = shadow.querySelector('dialog[open]'); + return { employeeId: dialog.dataset.employeeId, text: dialog.textContent, count: shadow.querySelectorAll('dialog[open]').length, activeInside: dialog.contains(shadow.activeElement) }; + }); + check(data.employeeId === id && data.count === 1 && data.activeInside, `${name}: keyboard opens shared employee popup`, data); + await page.keyboard.press('Tab'); + check(await page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + return shadow.querySelector('dialog[open]').contains(shadow.activeElement); + }), `${name}: popup Tab focus remains inside`); + await page.keyboard.press('Escape'); + await page.locator('dialog[open]').waitFor({ state: 'detached' }); + check(await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId === id, id), `${name}: Escape restores initiating card focus`); + return data.text; +} +async function scenario(name, run) { + try { await run(); } + catch (error) { check(false, `${name}: verification completed`, { message: error.message, stack: error.stack }); } +} + +async function geometry(page) { + return page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + const rect = element => { const r = element.getBoundingClientRect(); return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom,cx:r.x+r.width/2}; }; + const pseudo = (element, name) => { + const s = getComputedStyle(element, name); + return {content:s.content,top:s.top,bottom:s.bottom,height:s.height,width:s.width,border:s.borderTopWidth,color:s.backgroundColor}; + }; + const nodes = [...shadow.querySelectorAll('.node[data-organization-id]')].map(node => { + const hierarchy = node.parentElement; + const childRow = [...hierarchy.children].find(item => item.classList.contains('nodes')); + const parent = hierarchy.parentElement.closest('.hierarchy')?.querySelector(':scope > .node'); + return {id:node.dataset.organizationId,parentId:node.dataset.parentId,domParent:parent?.dataset.organizationId ?? '',depth:Number(node.dataset.depth), + node:rect(node),article:rect(node.querySelector('.hm-orgchart-organization')),subtree:rect(hierarchy),children:childRow ? rect(childRow) : null, + upper:pseudo(node,'::before'),lower:pseudo(node,'::after'),bar:pseudo(hierarchy,'::before')}; + }); + return {nodes,tree:rect(shadow.querySelector('.hm-orgchart-forest'))}; + }); +} +async function assertHierarchy(page, name, fixture) { + const value=await measure(page), layout=await geometry(page); + measurements.push({name,...value,layout}); + check(value.nodes.length===fixture.organizations.length,`${name}: every organization rendered`); + const expected = fixture.employees.filter(employee => fixture.presentation?.groups.showEmployees !== false || fixture.organizations.some(org=>org.leaderId===employee.id)); + check(value.cards.length===expected.length && new Set(value.cards.map(c=>c.id)).size===value.cards.length,`${name}: employees once, no missing or duplicate leaders`); + check(value.collisions.length===0,`${name}: card collision count zero`,value.collisions); + check(value.controls===0 && value.layoutStatus==='hierarchical' && value.scale===1,`${name}: expanded hierarchy without scale/collapse/zoom/pan controls`); + for (const depth of new Set(layout.nodes.map(n=>n.depth))) { + const row=layout.nodes.filter(n=>n.depth===depth); + check(row.every(n=>near(n.node.y,row[0].node.y)),`${name}: depth ${depth} horizontal alignment`); + } + for (const n of layout.nodes) { + check(n.parentId===n.domParent,`${name}: ${n.id} actual DOM parent matches domain parent`); + if(n.parentId) { + const parent=layout.nodes.find(p=>p.id===n.parentId); + check(parent && n.node.y>parent.article.bottom,`${name}: ${n.id} below its parent content`); + check(n.upper.content!=='none' && parseFloat(n.upper.height)>0,`${name}: ${n.id} upward connector exists`); + } + if(n.children) { + check(near(n.node.cx,n.children.cx),`${name}: ${n.id} centered above own subtree`); + check(n.lower.content!=='none' && parseFloat(n.lower.height)>0,`${name}: ${n.id} downward connector`); + } else check(n.lower.content==='none',`${name}: ${n.id} leaf has no downward connector`); + } + for (let i=0;ic.name==='홍 길 동' && c.font.includes('Noto Sans KR') && !c.asset),`${name}: Auto content, shared font, no Fixed SVG assets`); + const rep=value.cards.filter(c=>c.kind==='topLeader'); + check(rep.length===1 && rep[0].badgeCount===0 && rep[0].role==='대표이사',`${name}: representative position and zero badges`); + check(value.document.scrollWidth<=value.document.clientWidth+1,`${name}: no document horizontal overflow`,value.document); + return {value,layout}; +} +try { + if(process.env.ORGCHART_VERIFY_RETRY === 'overflow') { + const page=await newPage('overflow-recheck',{width:1920,height:1080}); + try { + await mount(page,{options:{layoutMode:'auto',displayMode:'readOnly'}}); + const before=await geometry(page); + const grown=structuredClone(expandedFixture); + for(let i=0;i<12;i++) grown.employees.push({id:'many-'+i,organizationId:'business-management',affiliationId:'hanmac',name:'김지우',position:'선임',jobTitle:null,profileImage:null,workStatus:'active'}); + for(let i=0;i<8;i++) grown.organizations.push({id:'new-sibling-'+i,parentId:'business-planning',name:'추가 셀 '+i,type:'cell',leaderId:null,displayOrder:100+i}); + await page.evaluate(async data=>window.handle.update(data),grown);await ready(page,'auto'); + const after=await assertHierarchy(page,'Wider updated tree',grown); + check(after.layout.tree.width>before.tree.width && after.layout.tree.height>before.tree.height,'Organization/staff growth increases natural content width and height'); + check(after.value.viewport.scrollWidth>after.value.viewport.clientWidth && after.value.viewport.scrollHeight>after.value.viewport.clientHeight,'Large tree has horizontal and vertical inner overflow'); + const scroll=await page.evaluate(()=>{const v=document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-viewport');v.scrollLeft=v.scrollWidth;v.scrollTop=v.scrollHeight;return {x:v.scrollLeft,y:v.scrollTop};}); + check(scroll.x>0&&scroll.y>0,'Both overflow axes scroll to far content'); + await page.screenshot({path:path.join(output,'vanilla-overflow-scrolled.png'),fullPage:true}); + await page.goto(storyOrigin+'/iframe.html?id=orgchart--expanded-org-chart&viewMode=story');await ready(page,'auto'); + for(const width of [1280,1440]) { + await page.setViewportSize({width,height:900});await ready(page,'auto'); + const value=await measure(page),layout=await geometry(page); + check(value.document.scrollWidth<=value.document.clientWidth+1 && (layout.nodes[0].subtree.width<=value.viewport.clientWidth || value.viewport.scrollWidth>value.viewport.clientWidth),'React '+width+' wide tree accessible with horizontal scroll'); + } + // Full-tree capture: expand only this test's viewport height. All cards + // remain actual React DOM; the normal screenshots retain scroll policy. + await page.setViewportSize({width:1920,height:2000});await ready(page,'auto'); + await page.screenshot({path:path.join(output,'storybook-expanded-full-tree.png'),fullPage:true}); + } finally {await page.close();} + } else { + await scenario('Fixed minimal regression',async()=>{ + const page=await newPage('fixed-regression'); + try { + await mount(page,{baseline:true,fixture:fixedFixture,options:{displayMode:'readOnly'}}); + const before=await measure(page); + const beforePopup=await popup(page,'Fixed baseline','mock-leader-00'); + await mount(page,{fixture:fixedFixture,options:{displayMode:'readOnly'}}); + const after=await measure(page); + compareFixed(before,after,'Fixed baseline/current'); + check(await popup(page,'Fixed current','mock-leader-00')===beforePopup,'Fixed popup unchanged'); + await page.screenshot({path:path.join(output,'fixed-current-1440.png'),fullPage:true}); + } finally {await page.close();} + }); + let vanilla; + await scenario('Auto Core hierarchy and updates',async()=>{ + const page=await newPage('vanilla-auto',{width:1920,height:1080}); + try { + await mount(page,{options:{layoutMode:'auto',displayMode:'readOnly',organizationLinks:{'business-management':'#business'}}}); + vanilla=await assertHierarchy(page,'Vanilla initial',expandedFixture); + check(vanilla.layout.nodes.some(n=>n.depth===4),'4Depth recursive fixture rendered'); + check(await page.locator('.hm-orgchart-organization-link').count()===1,'1Depth native page link retained'); + await page.locator('.hm-orgchart-organization-link').click(); + check(await page.locator('.node[data-organization-id]').count()===expandedFixture.organizations.length && await page.locator('dialog[open]').count()===0,'Group link leaves hierarchy visible and no employee popup'); + await popup(page,'Vanilla nested employee','expanded-nested-5'); + check(vanilla.value.cards.every(c=>c.placeholder==='placeholder'),'Missing photos share person placeholder'); + const augmented=structuredClone(expandedFixture); + for(let i=0;i<12;i++) augmented.employees.push({id:`many-${i}`,organizationId:'business-management',affiliationId:'hanmac',name:'김지우',position:'선임',jobTitle:null,profileImage:null,workStatus:'active'}); + for(let i=0;i<8;i++) augmented.organizations.push({id:`new-sibling-${i}`,parentId:'business-planning',name:`추가 셀 ${i}`,type:'cell',leaderId:null,displayOrder:100+i}); + await page.evaluate(async data=>{await window.handle.update(data);window.currentData=data;},augmented); + await ready(page,'auto'); + const grown=await assertHierarchy(page,'Unequal staff + additional organizations',augmented); + check(grown.layout.tree.width>vanilla.layout.tree.width && grown.layout.tree.height>vanilla.layout.tree.height,'Organization/staff growth increases natural content width and height'); + check(grown.value.viewport.scrollWidth>grown.value.viewport.clientWidth && grown.value.viewport.scrollHeight>grown.value.viewport.clientHeight,'Large tree has horizontal and vertical inner overflow'); + const scrolled=await page.evaluate(()=>{const v=document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-viewport');v.scrollLeft=v.scrollWidth;v.scrollTop=v.scrollHeight;return {x:v.scrollLeft,y:v.scrollTop};}); + check(scrolled.x>0 && scrolled.y>0,'Both overflow axes scroll to far content'); + await page.screenshot({path:path.join(output,'vanilla-overflow-scrolled.png'),fullPage:true}); + const moved=structuredClone(augmented); + moved.employees=moved.employees.filter(e=>e.id!=='many-11'); + moved.employees.find(e=>e.id==='many-0').organizationId='finance'; + moved.organizations=moved.organizations.filter(o=>o.id!=='new-sibling-2'); + await page.evaluate(async data=>window.handle.update(data),moved); + await ready(page,'auto'); + await assertHierarchy(page,'Deletion and employee reassignment',moved); + check(await page.evaluate(()=>document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('[data-employee-id="many-0"]').closest('.node').dataset.organizationId)==='finance','Membership change moves the employee card'); + const imageData=structuredClone(expandedFixture); + imageData.employees.find(e=>e.id==='expanded-employee-01').profileImage='/packages/org-chart/src/assets/workdesk/bg_sky.png'; + await page.evaluate(async data=>window.handle.update(data),imageData); + await ready(page,'auto'); + check(await page.locator('[data-employee-id="expanded-employee-01"]').getAttribute('data-photo-state')==='image','Valid profile photo uses Employee data'); + const hidden=structuredClone(expandedFixture);hidden.presentation.groups.showEmployees=false; + await page.evaluate(async data=>window.handle.update(data),hidden); + await ready(page,'auto'); + await assertHierarchy(page,'Show employees false',hidden); + for(const [kind,code] of [['orphan','PARENT_REFERENCE'],['cycle','HIERARCHY_CYCLE']]) { + const bad=structuredClone(expandedFixture); + if(kind==='orphan')bad.organizations[1].parentId='missing';else bad.organizations[0].parentId='business-management'; + const state=await page.evaluate(async data=>{await window.handle.update(data);return window.handle.getState();},bad); + check(state.status==='error' && state.issues.some(i=>i.code===code),`Invalid ${kind} reports existing validation policy`); + check(await page.locator('.node[data-organization-id]').count()===0,`Invalid ${kind} clears stale tree without silent omission`); + } + await page.evaluate(async()=>window.handle.reload()); + await ready(page,'auto'); + await assertHierarchy(page,'Reload restores source',expandedFixture); + await page.evaluate(()=>window.handle.destroy()); + check(await page.locator('.hm-orgchart-host').count()===0,'Destroy removes Auto DOM and observers'); + } finally {await page.close();} + }); + await scenario('Actual React Storybook',async()=>{ + const page=await newPage('storybook-auto',{width:1920,height:1080}); + try { + const response=await page.request.get(`${storyOrigin}/index.json`);const index=await response.json(); + const visible=Object.values(index.entries).filter(e=>e.type==='story'&&e.title==='OrgChart'&&!e.tags?.includes('internal')).map(e=>e.id).sort(); + check(JSON.stringify(visible)===JSON.stringify(['orgchart--logo-personnel-summary','orgchart--department-colors','orgchart--full-composition','orgchart--expanded-org-chart'].sort()),'Storybook exactly four user menus',visible); + await page.goto(`${storyOrigin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`);await ready(page,'auto'); + const react=await assertHierarchy(page,'React 1920',expandedFixture); + const signature=value=>value.cards.map(c=>({id:c.id,kind:c.kind,width:c.box.width,height:c.box.height,name:c.name,role:c.role,font:c.font,asset:c.asset})); + check(JSON.stringify(signature(react.value))===JSON.stringify(signature(vanilla.value)),'HTML/React identical Core card content and geometry'); + await page.screenshot({path:path.join(output,'storybook-expanded-1920.png'),fullPage:true}); + for(const preset of presets) { + await page.getByRole('combobox',{name:'회사별 컬러 테마'}).click();await page.getByRole('option',{name:preset.name,exact:true}).click(); + await page.waitForFunction(id=>document.querySelector('.hm-orgchart-host')?.shadowRoot.querySelector('.node')?.dataset.companyId===id,preset.id); + await ready(page,'auto');const value=await measure(page); + check(value.nodes.every(n=>n.companyId===preset.id && n.primary===preset.primary && n.backgroundColor===rgb(preset.primary) && JSON.stringify(n.secondary)===JSON.stringify(preset.secondary)),`${preset.name} Main/all secondary preset variables`); + const style=await page.evaluate(()=>{const s=document.querySelector('.hm-orgchart-host').shadowRoot;return [...s.querySelectorAll('[data-card-presentation="expanded"]')].map(c=>getComputedStyle(c).borderTopColor);}); + check(style.every(c=>c===rgb(preset.primary)),`${preset.name} employee accent matches preset`); + } + await page.getByRole('combobox',{name:'회사별 컬러 테마'}).click();await page.getByRole('option',{name:'한맥기술',exact:true}).click();await ready(page,'auto'); + for(const width of [390,768,1280,1440]) { + await page.setViewportSize({width,height:width<1000?1024:900});await ready(page,'auto'); + const checked=await assertHierarchy(page,`React ${width}`,expandedFixture); + const scrolled=await page.evaluate(()=>{const v=document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-viewport');v.scrollLeft=v.scrollWidth;const x=v.scrollLeft;v.scrollLeft=0;return x;}); + check(checked.layout.nodes[0].subtree.width <= checked.value.viewport.clientWidth || scrolled>0,`React ${width} wide tree accessible with horizontal scroll`); + await page.screenshot({path:path.join(output,`storybook-expanded-${width}.png`),fullPage:true}); + } + await popup(page,'React nested card','expanded-team-leader-01'); + await page.goto(`${storyOrigin}/iframe.html?id=orgchart--full-composition&viewMode=story`);await ready(page,'fixed'); + const fixed=await measure(page); + check(fixed.layoutStatus===null && fixed.controls===0 && fixed.cards.some(c=>c.asset),'Existing Full Composition Story keeps Fixed path and assets'); + } finally {await page.close();} + }); + } +} finally { + await browser.close();await new Promise(resolve=>server.close(resolve)); + check(errors.length===0,'No new console/runtime errors',errors); + if(process.env.ORGCHART_VERIFY_RETRY === 'overflow') { + const previous=JSON.parse(await fs.readFile(path.join(output,'browser.json'),'utf8')); + const updates=new Map(results.map(result=>[result.name,result])); + const combined=previous.results.map(result=>updates.get(result.name)??result); + results.splice(0,results.length,...combined,...results.filter(result=>!previous.results.some(old=>old.name===result.name))); + measurements.unshift(...previous.measurements); + } + const summary={total:results.length,pass:results.filter(r=>r.status==='PASS').length,fail:results.filter(r=>r.status==='FAIL').length}; + await fs.writeFile(path.join(output,'browser.json'),JSON.stringify({scope:'P5-1B focused validation',summary,results,measurements,errors,warnings,notVerified:['Two reference SVGs and final design match','P5-2 collapse/zoom/pan','Historical full regression suite','Actual screen reader speech/touch/other browsers']},null,2)); + console.log(JSON.stringify(summary)); + console.log(JSON.stringify(results.filter(r=>r.status==='FAIL'),null,2)); + if(summary.fail)process.exitCode=1; +} diff --git a/examples/org-chart/verify-p5-23.mjs b/examples/org-chart/verify-p5-23.mjs new file mode 100644 index 0000000..532dcfe --- /dev/null +++ b/examples/org-chart/verify-p5-23.mjs @@ -0,0 +1,224 @@ +import fs from 'node:fs/promises'; +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-p5-23'); +const playwright = process.env.ORGCHART_PLAYWRIGHT_MODULE; +const { chromium } = await import(playwright ? pathToFileURL(playwright).href : 'playwright'); +const story = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6054'; +const html = process.env.ORGCHART_HTML_ORIGIN ?? 'http://127.0.0.1:4189'; +const fixtures = Object.fromEntries(await Promise.all(['small', 'medium', 'large'].map(async size => [size, JSON.parse(await fs.readFile(path.join(root, `examples/org-chart/data-expanded-${size}.json`), 'utf8'))]))); +const fixed = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8')); +const results = [], errors = [], timings = [], measurements = []; +const check = (pass, name, detail) => { results.push({ name, status: pass ? 'PASS' : 'FAIL', detail }); if (!pass) console.log('FAIL', name, JSON.stringify(detail)); }; +const mime = { '.js': 'text/javascript', '.css': 'text/css', '.ttf': 'font/ttf', '.json': 'application/json', '.png': 'image/png' }; +const server = http.createServer(async (request, response) => { + try { + const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname); + if (pathname === '/shell') return response.writeHead(200, { 'Content-Type': 'text/html' }).end('
'); + const file = path.resolve(root, '.' + pathname); + if (path.relative(root, file).startsWith('..')) return response.writeHead(403).end(); + response.writeHead(200, { 'Content-Type': mime[path.extname(file)] ?? 'application/octet-stream' }).end(await fs.readFile(file)); + } catch { response.writeHead(404).end(); } +}); +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +const origin = `http://127.0.0.1:${server.address().port}`; +await fs.mkdir(output, { recursive: true }); +const browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); +page.on('pageerror', error => errors.push(error.message)); +page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); +const settle = () => page.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(resolve)))); }); +async function ready(count) { + await page.waitForFunction(n => { + const s = document.querySelector('.hm-orgchart-host')?.shadowRoot; + return s?.querySelectorAll('.hm-orgchart-employee').length === n && s.querySelectorAll('link').length > 0 && [...s.querySelectorAll('link')].every(link => link.sheet); + }, count); await settle(); +} +async function mount(data, options = {}, baseline = false) { + await page.goto(origin + '/shell'); + const ms = await page.evaluate(async ({ origin, data, options, baseline }) => { + const start = performance.now(); + const base = origin + (baseline ? '/test-results/org-chart-p5-23/baseline-dist' : '/packages/org-chart/dist'); + const mod = await import(base + '/index.js'); + window.handle = mod.createOrgChart({ container: document.getElementById('chart'), dataSource: new mod.JsonOrgChartDataSource(data), stylesheetUrls: { engine: base + '/engine.css', core: base + '/style.css' }, ...options }); + await window.handle.mount(); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + return performance.now() - start; + }, { origin, data, options, baseline }); + await settle(); return ms; +} +async function measure() { + return page.evaluate(() => { + const s = document.querySelector('.hm-orgchart-host').shadowRoot; + const rect = el => { const r = el.getBoundingClientRect(); return { x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom,cx:r.x+r.width/2 }; }; + const nodes = [...s.querySelectorAll('.node[data-organization-id]')].map(n => ({ id:n.dataset.organizationId, parentId:n.dataset.parentId, company:n.dataset.companyId, depth:+n.dataset.depth, rect:rect(n), article:rect(n.querySelector('article')), subtree:rect(n.parentElement), domParent:n.parentElement.parentElement.closest('.hierarchy')?.querySelector(':scope > .node')?.dataset.organizationId ?? '', color:getComputedStyle(n.querySelector('h3')).backgroundColor, ink:getComputedStyle(n.querySelector('h3')).color })); + const cards = [...s.querySelectorAll('.hm-orgchart-employee')].map(c => { + const css = getComputedStyle(c), svg = c.closest('article')?.querySelector('svg.hm-orgchart-employee-connectors'); + const line = svg && [...svg.querySelectorAll('path')].find(p => p.dataset.employeeId === c.dataset.employeeId); + const direction = c.dataset.connectionDirection, box = rect(c), scale = +s.querySelector('.hm-orgchart-viewport').dataset.scale || 1; + const end = line ? { x:svg.getBoundingClientRect().x + +line.dataset.endX * scale, y:svg.getBoundingClientRect().y + +line.dataset.endY * scale } : null; + const target = direction==='left' ? {x:box.x,y:box.y+box.height/2} : direction==='right' ? {x:box.right,y:box.y+box.height/2} : {x:box.cx,y:box.y}; + return { id:c.dataset.employeeId, kind:c.dataset.cardKind, direction, company:c.dataset.companyId, box, border:[css.borderTopColor,css.borderRightColor,css.borderBottomColor,css.borderLeftColor], shadow:css.boxShadow, name:c.querySelector('.hm-orgchart-person-name')?.textContent, role:c.querySelector('.hm-orgchart-person-role')?.textContent, asset:c.dataset.cardAsset, endpointError:end?Math.hypot(end.x-target.x,end.y-target.y):null }; + }); + const collisions = []; + for (let i=0;i0.5 && Math.min(a.box.bottom,b.box.bottom)-Math.max(a.box.y,b.box.y)>0.5) collisions.push([a.id,b.id]); + } + const viewport = s.querySelector('.hm-orgchart-viewport'), forest=s.querySelector('.hm-orgchart-forest'); + return { nodes, cards, collisions, scale:+viewport.dataset.scale, forest:rect(forest), viewport:rect(viewport), viewportState:{x:+viewport.dataset.panX,y:+viewport.dataset.panY}, doc:{width:document.documentElement.clientWidth,scrollWidth:document.documentElement.scrollWidth,height:document.documentElement.clientHeight,scrollHeight:document.documentElement.scrollHeight}, controls:s.querySelectorAll('.hm-orgchart-expanded-toolbar button').length }; + }); +} +async function hierarchy(name, data) { + const m = await measure(); measurements.push({ name, value:m }); + check(m.nodes.length===data.organizations.length, name+' all organizations'); + check(m.cards.length===data.employees.length && new Set(m.cards.map(c=>c.id)).size===data.employees.length, name+' unique employees exactly once'); + check(m.collisions.length===0,name+' no card overlap',m.collisions); + check(m.nodes.every(n=>n.parentId===n.domParent),name+' DOM ancestry matches source'); + check(m.nodes.every(n=>!n.parentId || n.rect.y > m.nodes.find(p=>p.id===n.parentId).article.bottom),name+' children below own parent'); + check(m.nodes.every(n=>m.nodes.filter(p=>p.depth===n.depth).every(p=>Math.abs(p.rect.y-n.rect.y)<1)),name+' same depth horizontal alignment'); + check(m.cards.every(c=>c.endpointError!==null && c.endpointError<1),name+' actual connector endpoints match cards',m.cards.filter(c=>c.endpointError===null||c.endpointError>=1)); + check(m.cards.every(c=>c.border.filter((color,index)=>index!==({top:0,right:1,left:3}[c.direction])).every(color=>color===c.border[2]) && c.border[{top:0,right:1,left:3}[c.direction]]!==c.border[2]),name+' only incoming border side accented'); + check(m.doc.scrollWidth<=m.doc.width+1,name+' no document horizontal overflow'); + return m; +} +async function popup(id) { + const card = page.locator(`.hm-orgchart-employee[data-employee-id="${id}"]`).first(); + await card.focus(); await card.press('Enter'); await page.locator('dialog[open]').waitFor(); + const detail = await page.evaluate(()=> {const s=document.querySelector('.hm-orgchart-host').shadowRoot,d=s.querySelector('dialog');return {focused:d.contains(s.activeElement),transformed:!!d.closest('.hm-orgchart-forest'),text:d.textContent};}); + check(detail.focused && !detail.transformed,'Popup focus inside; outside transformed forest',detail); + await page.keyboard.press('Escape'); + check(await page.evaluate(id=>document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId===id,id),'Popup Escape restores card focus'); +} +async function timedToggle(id, key='Enter') { + const button=page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`); + await button.focus(); + await page.evaluate(()=>{window.toggleStart=performance.now();}); + await button.press(key); await settle(); + const ms=await page.evaluate(()=>performance.now()-window.toggleStart); + timings.push({action:'toggle '+id,ms}); return ms; +} +try { + if (!process.env.ORGCHART_VERIFY_ONLY) { + await mount(fixed,{displayMode:'readOnly'},true); const before=await measure(); + await mount(fixed,{displayMode:'readOnly'}); const after=await measure(); + const fixedSig=m=>m.cards.map(c=>({id:c.id,kind:c.kind,asset:c.asset,name:c.name,role:c.role,box:c.box,border:c.border})); + check(JSON.stringify(fixedSig(before))===JSON.stringify(fixedSig(after)),'Fixed baseline/current exact card geometry, SVG, typography, color'); + check(after.controls===0,'Fixed no Auto controls'); await popup(after.cards[0].id); + await page.screenshot({path:path.join(output,'fixed-1440.png'),fullPage:true}); + for(const [size,data] of Object.entries(fixtures)) { + const ms=await mount(data,{layoutMode:'auto',displayMode:'readOnly',employeeConnections:Object.fromEntries(data.organizations.map((org,index)=>[org.id,['top','left','right'][index%3]]))}); + timings.push({action:`${size} initial mount (module/styles + 2 animation frames)`,ms}); + await ready(data.employees.length); await hierarchy('HTML '+size,data); + await page.screenshot({path:path.join(output,`html-${size}-fit.png`),fullPage:true}); + } + const data=fixtures.large; + await timedToggle('hanmac-office-0'); + check(await page.locator('[data-organization-id="hanmac-office-0"].hm-orgchart-expanded-toggle').getAttribute('aria-expanded')==='false','Enter collapses group'); + await timedToggle('hanmac-division-0','Space'); await timedToggle('hanmac-division-0','Space'); + check(await page.locator('[data-organization-id="hanmac-office-0"].hm-orgchart-expanded-toggle').getAttribute('aria-expanded')==='false','Parent re-open preserves child collapse'); + check(await page.evaluate(()=>document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.organizationId==='hanmac-division-0'),'Toggle preserves initiating focus'); + await timedToggle('hanmac-office-0'); await ready(300); + const selectMs=await page.evaluate(async()=>{const t=performance.now();await window.handle.setSelectedCompanies(['hanmac']);await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));return performance.now()-t;});timings.push({action:'300 single company selection + layout',ms:selectMs}); + let m=await measure();check(new Set(m.nodes.map(n=>n.company)).size===1 && m.nodes.every(n=>n.company==='hanmac'),'Single company independent root'); + check(await page.evaluate(()=>window.handle.getState().summary.raw.total)===300,'Company filter preserves immutable source total'); + await page.evaluate(async()=>window.handle.setSelectedCompanies(['hanmac','ptc']));await settle();m=await measure();check(new Set(m.nodes.map(n=>n.company)).size===2,'Multiple companies without synthetic root'); + await page.evaluate(async()=>window.handle.setSelectedCompanies([]));await settle();check(await page.locator('.hm-orgchart-expanded-empty').count()===1 && await page.locator('.node[data-organization-id]').count()===0,'Explicit empty selection'); + await page.evaluate(async()=>window.handle.setSelectedCompanies());await ready(300); + await page.getByRole('button',{name:'100% 복원',exact:true}).click();let zoom=await measure();check(zoom.scale===1,'100% resets scale'); + await page.getByRole('button',{name:'확대',exact:true}).click();check((await measure()).scale>1,'Zoom in button'); + await page.getByRole('button',{name:'축소',exact:true}).click();check(Math.abs((await measure()).scale-1)<0.0001,'Zoom out button'); + const area=page.locator('.hm-orgchart-viewport'),box=await area.boundingBox(); + await page.mouse.move(box.x+box.width-50,box.y+50);const state=(await measure()).viewportState; + await page.mouse.down();await page.mouse.move(box.x+box.width-150,box.y+120,{steps:8});await page.mouse.up(); + const moved=(await measure()).viewportState;check(moved.x!==state.x&&moved.y!==state.y,'Mouse drag pans forest');check(await page.locator('dialog[open]').count()===0,'Drag does not open popup'); + await page.evaluate(()=> {window.handle.zoomTo(999);});check((await measure()).scale===3,'Zoom clamps maximum'); + await page.evaluate(()=> {window.handle.zoomTo(0);});check((await measure()).scale===0.05,'Zoom clamps minimum'); + await page.getByRole('button',{name:'Fit to View',exact:true}).click();zoom=await measure();check(zoom.forest.width<=zoom.viewport.width+1&&zoom.forest.height<=zoom.viewport.height+1,'Fit contains current forest'); + await popup('virtual-001'); + const beforeWheel=(await measure()).scale; + await page.mouse.move(box.x+box.width/2,box.y+box.height/2);await page.mouse.wheel(0,-150);await settle();check((await measure()).scale===beforeWheel,'Ordinary wheel does not zoom or consume scroll'); + await page.keyboard.down('Control');await page.mouse.wheel(0,-150);await page.keyboard.up('Control');await settle();check((await measure()).scale>beforeWheel,'Ctrl wheel zooms'); + await mount(data,{layoutMode:'auto',companyTopNodes:{hanmac:{title:'최고기술책임자',name:'가상리더',organizationName:'가상 기술회사'},saman:{display:'titleOnly'},halla:{display:'hidden'},baron:{employeeId:null}}}); + check(await page.locator('[data-employee-id="virtual-001"] .hm-orgchart-person-role').textContent()==='최고기술책임자','Custom top title uses data/presentation, not representative constant'); + check(await page.locator('.hm-orgchart-top-node-title').count()===1,'Top titleOnly mode'); + check(await page.locator('.node[data-organization-id="halla-root"] .hm-orgchart-employee').count()===0,'Hidden top card mode'); + check(await page.locator('.node[data-organization-id="baron-root"]').count()===1,'No top employee retains original organization root'); + } + if (process.env.ORGCHART_VERIFY_ONLY !== 'extras') { + const data=fixtures.large; + await page.goto(html+'/expanded.html');await ready(20);await page.selectOption('#dataset','large');await ready(300);const htmlValue=await hierarchy('Real HTML large',data); + await page.goto(story+'/iframe.html?id=orgchart--expanded-org-chart&viewMode=story');await ready(20); + await page.getByRole('combobox',{name:'가상 데이터'}).click();await page.getByRole('option',{name:'Large · 300명',exact:true}).click();await ready(300); + const react=await hierarchy('React large',data); + const signature=m=>m.cards.map(c=>({id:c.id,kind:c.kind,role:c.role,name:c.name,company:c.company,direction:c.direction,border:c.border,width:Math.round(c.box.width/m.scale),height:Math.round(c.box.height/m.scale)})); + check(JSON.stringify(signature(htmlValue))===JSON.stringify(signature(react)),'Actual HTML/React shared card geometry/content/color/direction'); + const index=await (await page.request.get(story+'/index.json')).json();check(Object.values(index.entries).filter(e=>e.type==='story'&&e.title==='OrgChart'&&!e.tags?.includes('internal')).length===4,'Four existing user Storybook menus retained'); + await page.getByRole('button',{name:'선택 해제',exact:true}).click();await page.locator('.hm-orgchart-expanded-empty').waitFor();await page.getByRole('checkbox',{name:'한맥기술',exact:true}).click();await settle();check((await measure()).nodes.every(n=>n.company==='hanmac'),'React single selection'); + await page.getByRole('checkbox',{name:'피티씨',exact:true}).click();await settle();check(new Set((await measure()).nodes.map(n=>n.company)).size===2,'React multiple selection'); + await page.getByRole('button',{name:'전체 회사',exact:true}).click();await ready(300); + await page.screenshot({path:path.join(output,'storybook-large-1440.png'),fullPage:true}); + for(const width of [390,768,1280,1440]) { + await page.setViewportSize({width,height:1000});await settle();const m=await measure();measurements.push({name:'React viewport '+width,value:m}); + check(m.doc.scrollWidth<=m.doc.width+1,'React '+width+' no page overflow',m.doc);check(m.controls===4,'React '+width+' zoom toolbar accessible'); + await page.screenshot({path:path.join(output,`storybook-${width}.png`),fullPage:true}); + } + } + if (!process.env.ORGCHART_VERIFY_ONLY || process.env.ORGCHART_VERIFY_ONLY === 'extras') { + const data=fixtures.large; + const directions=Object.fromEntries(data.organizations.map((org,i)=>[org.id,['top','left','right'][i%3]])); + await mount(data,{layoutMode:'auto',employeeConnections:directions,organizationLinks:{'hanmac-root':'#company-detail'}}); + const expected={hanmac:'#CB2E28',saman:'#F08300','jangheon-industry':'#4580A6',jangheon:'#1D418D',ptc:'#84338E',halla:'#0068B7',baron:'#0C367F'}; + const rgb=hex=>`rgb(${[1,3,5].map(i=>parseInt(hex.slice(i,i+2),16)).join(', ')})`; + const colors=await measure(); + check(colors.nodes.every(n=>n.color===rgb(expected[n.company])),'All seven group header preset colors exact'); + check(colors.cards.every(c=>c.border[{top:0,right:1,left:3}[c.direction]]===rgb(expected[c.company])),'All seven employee border preset colors exact'); + check(new Set(colors.cards.map(c=>c.direction)).size===3,'Real top/left/right connector examples present'); + const duration=await page.evaluate(async()=>{const t=performance.now();document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-expanded-toggle[data-organization-id="hanmac-office-0"]').click();await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));return performance.now()-t;}); + timings.push({action:'300 group collapse (native click + 2 frames)',ms:duration}); + await page.evaluate(()=>document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-expanded-toggle[data-organization-id="hanmac-office-0"]').click());await ready(300); + const zoomPan=await page.evaluate(async()=>{const t=performance.now();window.handle.zoomTo(1.2);for(let i=0;i<60;i++)window.handle.panBy(1,-1);await new Promise(r=>requestAnimationFrame(r));return performance.now()-t;}); + timings.push({action:'300 zoom + 60 pan updates + frame',ms:zoomPan}); + await page.evaluate(()=>window.handle.fitToView());await settle(); + const drag=await page.locator('.hm-orgchart-employee[data-employee-id="virtual-001"]').boundingBox(); + await page.mouse.move(drag.x+drag.width/2,drag.y+drag.height/2);await page.mouse.down();await page.mouse.move(drag.x+drag.width/2+30,drag.y+drag.height/2+30,{steps:5});await page.mouse.up(); + check(await page.locator('dialog[open]').count()===0,'Dragging an employee card suppresses popup click'); + const touch=await page.evaluate(()=>{const s=document.querySelector('.hm-orgchart-host').shadowRoot,v=s.querySelector('.hm-orgchart-viewport'),before=window.handle.getViewportState();v.setPointerCapture=()=>{};v.hasPointerCapture=()=>false;for(const [type,x,y] of [['pointerdown',50,50],['pointermove',100,100],['pointerup',100,100]])v.dispatchEvent(new PointerEvent(type,{pointerId:42,pointerType:'touch',isPrimary:true,button:0,clientX:x,clientY:y,bubbles:true}));const after=window.handle.getViewportState();return {before,after};}); + check(touch.after.x-touch.before.x===50&&touch.after.y-touch.before.y===50,'Touch pointer event route pans (synthetic; real device unverified)',touch); + await page.locator('.hm-orgchart-expanded-link').click();check(await page.locator('.node[data-organization-id]').count()===data.organizations.length,'Separate group page link does not collapse'); + await page.evaluate(async()=>window.handle.setSelectedCompanies(['hanmac']));await settle(); + await timedToggle('hanmac-office-0');await page.evaluate(async()=>window.handle.setSelectedCompanies(['saman']));await settle(); + check(await page.locator('.hm-orgchart-expanded-toggle[aria-expanded="false"]').count()===0,'Other company remains independently expanded'); + await page.evaluate(async()=>window.handle.setSelectedCompanies(['hanmac']));await settle(); + check(await page.locator('.hm-orgchart-expanded-toggle[data-organization-id="hanmac-office-0"]').getAttribute('aria-expanded')==='false','Company re-selection preserves its collapse state'); + const updated=structuredClone(data);updated.organizations.push({id:'hanmac-empty-leaf',companyId:'hanmac',parentId:'hanmac-root',name:'가상 빈 조직',type:'team',leaderId:null,displayOrder:5}); + await page.evaluate(async d=>window.handle.update(d),updated);await settle(); + check(await page.locator('.hm-orgchart-expanded-toggle[data-organization-id="hanmac-empty-leaf"]').isDisabled(),'Empty group title is disabled'); + await page.goto(story+'/iframe.html?id=orgchart--expanded-org-chart&viewMode=story');await ready(20); + await page.getByRole('button',{name:'선택 해제',exact:true}).click();await page.getByRole('checkbox',{name:'한맥기술',exact:true}).click();await settle(); + await page.getByRole('button',{name:'100% 복원',exact:true}).click(); + await page.screenshot({path:path.join(output,'storybook-small-hanmac-100.png'),fullPage:true}); + await page.locator('.hm-orgchart-expanded-toggle').first().focus();await page.locator('.hm-orgchart-expanded-toggle').first().press('Enter');await settle(); + check(await page.locator('.hm-orgchart-expanded-toggle').first().getAttribute('aria-expanded')==='false','React group keyboard collapse uses same Core'); + await page.locator('.hm-orgchart-expanded-toggle').first().press('Enter');await settle(); + await page.getByRole('checkbox',{name:'삼안',exact:true}).click();await settle(); + await page.getByRole('combobox',{name:'가상 데이터'}).click();await page.getByRole('option',{name:'Medium · 100명',exact:true}).click();await ready(100); + check((await measure()).cards.length===100,'React fixture switch restores all 100 employees after selections'); + } +} catch(error) {check(false,'Suite completed',{message:error.message,stack:error.stack});} +finally { + check(errors.length===0,'No new browser console/runtime errors',errors); + await browser.close();await new Promise(resolve=>server.close(resolve)); + const summary={total:results.length,pass:results.filter(r=>r.status==='PASS').length,fail:results.filter(r=>r.status==='FAIL').length}; + const file=path.join(output,'browser.json'); + if(process.env.ORGCHART_VERIFY_ONLY) { + const previous=JSON.parse(await fs.readFile(file,'utf8')); + const names=new Set(results.map(result=>result.name)); + results.unshift(...previous.results.filter(result=>result.name!=='Suite completed' && !names.has(result.name))); + timings.unshift(...previous.timings);measurements.unshift(...previous.measurements); + summary.total=results.length;summary.pass=results.filter(r=>r.status==='PASS').length;summary.fail=results.filter(r=>r.status==='FAIL').length; + } + await fs.writeFile(file,JSON.stringify({scope:'P5-2/P5-3 affected paths',summary,results,timings,measurements,errors,notVerified:['Reference SVG design match','Real touch device, screen reader and other browsers','Historical full regression suite']},null,2)); + console.log(JSON.stringify({summary,timings}));if(summary.fail)process.exitCode=1; +} diff --git a/examples/org-chart/verify-p5-4.mjs b/examples/org-chart/verify-p5-4.mjs new file mode 100644 index 0000000..fa9d39c --- /dev/null +++ b/examples/org-chart/verify-p5-4.mjs @@ -0,0 +1,221 @@ +// Focused P5-4 checks. Does not replay P5-1B/P5-23 suites or performance timings. +import fs from 'node:fs/promises'; +import { writeFileSync } from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-p5-4'); +const playwright = process.env.ORGCHART_PLAYWRIGHT_MODULE; +const { chromium } = await import(playwright ? pathToFileURL(playwright).href : 'playwright'); +const fixtures = Object.fromEntries(await Promise.all(['small', 'medium', 'large'].map(async size => [size, JSON.parse(await fs.readFile(path.join(root, `examples/org-chart/data-expanded-${size}.json`), 'utf8'))]))); +const fixed = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8')); +const results = [], errors = [], measurements = []; +const consoleOnly = process.env.ORGCHART_P54_RESUME === 'console'; +const resume = consoleOnly || process.env.ORGCHART_P54_RESUME === 'remaining'; +let baseline; +if (resume) { + const prior = JSON.parse(await fs.readFile(path.join(output, consoleOnly ? 'browser-before-console.json' : 'browser-initial.json'), 'utf8')); + results.push(...prior.results.filter(r => r.status === 'PASS')); + measurements.push(...prior.measurements); + baseline = prior.measurements.find(m => m.name === 'small geometry').value; +} +const check = (pass, name, detail) => { results.push({ name, status: pass ? 'PASS' : 'FAIL', detail }); writeFileSync(path.join(output,'progress.json'),JSON.stringify({results},null,2)); if (!pass) console.log('FAIL', name, JSON.stringify(detail)); }; +const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.ttf': 'font/ttf', '.json': 'application/json', '.svg': 'image/svg+xml' }; +const server = http.createServer(async (request, response) => { + try { + const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname); + if (pathname === '/favicon.ico') return response.writeHead(204).end(); + if (pathname === '/shell') return response.writeHead(200, { 'Content-Type': 'text/html' }).end('
'); + if (pathname === '/virtual-profile.svg') return response.writeHead(200, { 'Content-Type': 'image/svg+xml' }).end(''); + const file = path.resolve(root, pathname.startsWith('/assets/') ? 'test-results/org-chart-p5-4/react-build/dist'+pathname : '.' + pathname), relative = path.relative(root, file); + if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end(); + const data = await fs.readFile(file); + response.writeHead(200, { 'Content-Type': mime[path.extname(file)] ?? 'application/octet-stream' }).end(data); + } catch { response.writeHead(404).end(); } +}); +await fs.mkdir(output, { recursive: true }); +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +const origin = `http://127.0.0.1:${server.address().port}`; +const browser = await chromium.launch({ executablePath: process.env.ORGCHART_CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe', headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); +const track = p => { p.on('pageerror', e => errors.push(e.message)); p.on('console', m => { if (m.type() === 'error') errors.push({message:m.text(),location:m.location()}); }); }; +track(page); +const settle = p => p.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(resolve)))); }); +const shadow = '.hm-orgchart-host'; +async function ready(p, count) { + await p.waitForFunction(count => { + const s = document.querySelector('.hm-orgchart-host')?.shadowRoot; + return s?.querySelectorAll('.hm-orgchart-employee').length === count && [...s.querySelectorAll('link')].every(link => link.sheet); + }, count); await settle(p); +} +async function mount(data, options = {}, baseline = false) { + await page.goto(origin + '/shell'); + await page.evaluate(async ({ origin, data, options, baseline }) => { + const base = origin + (baseline ? '/test-results/org-chart-p5-4/baseline-dist' : '/packages/org-chart/dist'); + const mod = await import(base + '/index.js'); + window.handle = mod.createOrgChart({ container: document.getElementById('chart'), dataSource: new mod.JsonOrgChartDataSource(data), stylesheetUrls: { engine: base + '/engine.css', core: base + '/style.css' }, ...options }); + await window.handle.mount(); + }, { origin, data, options, baseline }); await settle(page); +} +async function measure(p = page) { + return p.evaluate(() => { + const s = document.querySelector('.hm-orgchart-host').shadowRoot; + const box = e => { const r = e.getBoundingClientRect(); return { x:r.x, y:r.y, width:r.width, height:r.height }; }; + const cards = [...s.querySelectorAll('.hm-orgchart-employee')].map(c => { + const cs = getComputedStyle(c), avatar = c.querySelector('.hm-orgchart-avatar'), name = c.querySelector('.hm-orgchart-person-name'), role = c.querySelector('.hm-orgchart-person-role'); + const article = c.closest('.hm-orgchart-organization'), endpoint = article?.querySelector(`path[data-employee-id="${c.dataset.employeeId}"]`); + let x = 0, y = 0, cur = c; + while (article && cur && cur !== article) { x += cur.offsetLeft; y += cur.offsetTop; cur = cur.offsetParent; } + const direction = c.dataset.connectionDirection; + const target = direction === 'left' ? [x, y+c.offsetHeight/2] : direction === 'right' ? [x+c.offsetWidth, y+c.offsetHeight/2] : [x+c.offsetWidth/2,y]; + return { id:c.dataset.employeeId, kind:c.dataset.cardKind, company:c.dataset.companyId, asset:c.dataset.cardAsset, name:name.textContent, role:role.textContent, + size:[c.offsetWidth,c.offsetHeight], box:box(c), avatar:[avatar.offsetWidth,avatar.offsetHeight], radius:getComputedStyle(avatar).borderRadius, + font:getComputedStyle(name).fontFamily, fontSize:[getComputedStyle(role).fontSize,getComputedStyle(name).fontSize], border:[cs.borderTopColor,cs.borderRightColor,cs.borderBottomColor,cs.borderLeftColor], direction, + endpointError:endpoint ? Math.hypot(Number(endpoint.dataset.endX)-target[0],Number(endpoint.dataset.endY)-target[1]) : null, + label:c.getAttribute('aria-label'), photo:c.dataset.photoState, image:c.querySelector('.hm-orgchart-avatar img').getAttribute('src'), background:cs.backgroundColor }; + }); + const v=s.querySelector('.hm-orgchart-viewport'), f=s.querySelector('.hm-orgchart-forest'); + return { cards, nodes:[...s.querySelectorAll('.node[data-depth]')].map(n=>({id:n.dataset.organizationId,parent:n.dataset.parentId,domParent:n.parentElement.parentElement.closest('.hierarchy')?.querySelector(':scope > .node')?.dataset.organizationId ?? '',depth:Number(n.dataset.depth),box:box(n),article:box(n.querySelector('.hm-orgchart-organization')),company:n.dataset.companyId})), + titles:[...s.querySelectorAll('.hm-orgchart-organization-name')].map(t=>({text:t.textContent,bg:getComputedStyle(t).backgroundColor,color:getComputedStyle(t).color})), + viewport:box(v), forest:box(f), scroll:[v.scrollLeft,v.scrollTop,v.scrollWidth,v.clientWidth,v.scrollHeight,v.clientHeight], + document:[document.documentElement.scrollWidth,innerWidth,document.documentElement.scrollHeight,document.documentElement.clientHeight], design:s.querySelector('[data-card-design-status]')?.dataset.cardDesignStatus }; + }); +} +function overlap(cards) { + return cards.some((a,i)=>cards.slice(i+1).some(b=>Math.min(a.box.x+a.box.width,b.box.x+b.box.width)-Math.max(a.box.x,b.box.x)>0.5 && Math.min(a.box.y+a.box.height,b.box.y+b.box.height)-Math.max(a.box.y,b.box.y)>0.5)); +} +async function popup(p, id, key = 'Enter') { + const card=p.locator(`${shadow} .hm-orgchart-employee[data-employee-id="${id}"]`); + await card.focus(); await settle(p); await card.press(key); await p.locator('dialog[open]').waitFor(); + const props=await p.evaluate(()=>{const s=document.querySelector('.hm-orgchart-host').shadowRoot,d=s.querySelector('dialog');return {title:s.getElementById(d.getAttribute('aria-labelledby'))?.textContent,close:d.querySelector('button').getAttribute('aria-label'),outside:!d.closest('.hm-orgchart-forest'),focus:d.contains(s.activeElement),scale:getComputedStyle(d).transform};}); + check(props.title==='직원 정보' && props.close==='직원 정보 닫기' && props.outside && props.focus && props.scale==='none','Popup title, close name, top layer and focus',props); + await p.keyboard.press('Escape'); await settle(p); + check(await p.evaluate(id=>document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId===id,id),'Popup Escape restores original focus'); +} +try { + if (consoleOnly) { + await page.goto((process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6054')+'/iframe.html?id=orgchart--expanded-org-chart&viewMode=story'); await ready(page,20); + await page.goto(origin+'/test-results/org-chart-p5-4/react-build/dist/index.html'); await ready(page,20); + await page.goto((process.env.ORGCHART_HTML_ORIGIN ?? 'http://127.0.0.1:4189')+'/expanded.html'); await ready(page,20); + check(errors.length===0,'No new console/runtime errors',errors); + } else { + let m; + if (!resume) { + await mount(fixtures.small,{layoutMode:'auto',displayMode:'readOnly'},true); baseline=await measure(); + await mount(fixtures.small,{layoutMode:'auto',displayMode:'readOnly'}); await ready(page,20); m=await measure(); + check(JSON.stringify(baseline.cards.map(c=>[c.id,c.kind,c.size,c.avatar,c.fontSize,c.border,c.background]))===JSON.stringify(m.cards.map(c=>[c.id,c.kind,c.size,c.avatar,c.fontSize,c.border,c.background])),'Auto baseline/current card size, photo, typography, color unchanged'); + check(m.design==='current-presentation','Current presentation status matches revised requirements'); + measurements.push({name:'small geometry',value:m}); + const toggle=page.locator('.hm-orgchart-expanded-toggle[data-organization-id="hanmac-office-0"]'); + await toggle.focus(); await settle(page); + const normal=await toggle.evaluate(e=>getComputedStyle(e).backgroundColor); await toggle.hover(); + const hover=await toggle.evaluate(e=>getComputedStyle(e).backgroundColor); + check(normal!==hover,'Group title Hover feedback',{normal,hover}); + await page.mouse.move(0,0); await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab'); await settle(page); + check(await toggle.evaluate(e=>e.matches(':focus-visible') && getComputedStyle(e).outlineStyle==='solid'),'Group title keyboard Focus outline'); + await toggle.press('Enter'); await settle(page); + check(await toggle.getAttribute('aria-expanded')==='false','Enter collapse aria-expanded'); + await page.screenshot({path:path.join(output,'group-collapsed.png'),fullPage:true}); + await toggle.press('Space'); await ready(page,20); + check(await toggle.getAttribute('aria-expanded')==='true','Space expand aria-expanded'); + check(await toggle.evaluate(e=>e.getRootNode().activeElement===e),'Toggle focus returns after rerender'); + await page.screenshot({path:path.join(output,'group-expanded.png'),fullPage:true}); + await page.evaluate(()=>{window.handle.zoomTo(1);window.handle.panBy(-4000,-2000);}); + const card=page.locator('.hm-orgchart-employee').first(); await card.focus(); await page.keyboard.press('Tab'); await settle(page); + const visible=await page.evaluate(()=>{const s=document.querySelector('.hm-orgchart-host').shadowRoot,v=s.querySelector('.hm-orgchart-viewport'),a=s.activeElement,b=a.getBoundingClientRect(),r=v.getBoundingClientRect();return {within:b.left>=r.left && b.top>=r.top && b.right<=r.right && b.bottom<=r.bottom,scale:Number(v.dataset.scale),scroll:[v.scrollLeft,v.scrollTop],active:a.className};}); + check(visible.within && visible.scale===1 && visible.scroll.every(n=>n===0),'Tab reveals offscreen control without Zoom or hidden scrolling',visible); + await page.evaluate(()=>window.handle.fitToView()); await settle(page); + const v=page.locator('.hm-orgchart-viewport'); const vb=await v.boundingBox(); + const panBefore=await page.evaluate(()=>window.handle.getViewportState()); + await page.mouse.move(vb.x+20,vb.y+20); await page.mouse.down(); await page.mouse.move(vb.x+70,vb.y+50); await page.mouse.up(); await settle(page); + const panAfter=await page.evaluate(()=>window.handle.getViewportState()); + check(panAfter.x!==panBefore.x && panAfter.y!==panBefore.y,'Trusted mouse Pan remains operational'); + await popup(page,m.cards[0].id,'Space'); + await page.evaluate(()=>window.handle.fitToView()); await settle(page); + for (const size of ['medium','large']) { + const data=fixtures[size],connections=Object.fromEntries(data.organizations.map((o,i)=>[o.id,['top','left','right'][i%3]])); + await mount(data,{layoutMode:'auto',displayMode:'readOnly',employeeConnections:connections}); await ready(page,data.employees.length); m=await measure(); + check(m.cards.length===data.employees.length && new Set(m.cards.map(c=>c.id)).size===data.employees.length,size+' unique complete employee rendering'); + check(!overlap(m.cards) && m.nodes.every(n=>n.parent===n.domParent && (!n.parent || m.nodes.some(p=>p.id===n.parent && p.article.y+p.article.heightc.endpointError!==null && c.endpointError<1),size+' actual endpoints aligned'); + check(m.cards.every(c=>{const i={top:0,right:1,left:3}[c.direction];return c.border[i]!==c.border[2] && c.border.every((b,j)=>j===i || b===c.border[2]);}),size+' top/left/right single-side accent'); + check(m.forest.x>=m.viewport.x-1 && m.forest.y>=m.viewport.y-1 && m.forest.x+m.forest.width<=m.viewport.x+m.viewport.width+1 && m.forest.y+m.forest.height<=m.viewport.y+m.viewport.height+1,size+' Fit includes all cards'); + measurements.push({name:size,value:m}); await page.screenshot({path:path.join(output,`html-${size}-fit.png`),fullPage:true}); + } + await page.evaluate(()=>window.handle.setSelectedCompanies(['hanmac','saman'])); await settle(page); m=await measure(); + check(new Set(m.nodes.map(n=>n.company)).size===2 && m.nodes.filter(n=>!n.parent).length===2,'Two selected companies retain independent roots'); + await page.screenshot({path:path.join(output,'two-companies.png'),fullPage:true}); + await page.evaluate(()=>window.handle.setSelectedCompanies(['hanmac'])); await settle(page); + check((await measure()).nodes.every(n=>n.company==='hanmac'),'Single company selection'); + await page.getByRole('button',{name:'100% 복원',exact:true}).click(); await settle(page); + await page.screenshot({path:path.join(output,'cards-100-percent.png'),fullPage:true}); + await mount(fixed,{displayMode:'readOnly'},true); const fixedBefore=await measure(); + await mount(fixed,{displayMode:'readOnly'}); const fixedAfter=await measure(); + const fixedSig=m=>m.cards.map(c=>[c.id,c.asset,c.box,c.name,c.role,c.border,c.avatar]); + check(JSON.stringify(fixedSig(fixedBefore))===JSON.stringify(fixedSig(fixedAfter)),'Fixed baseline exact geometry/SVG/text/color preservation'); + check(await page.locator('.hm-orgchart-expanded-toggle').count()===0,'Fixed has no Auto toggles'); + await popup(page,fixedAfter.cards[0].id); await page.screenshot({path:path.join(output,'fixed-1440.png'),fullPage:true}); + } else { + // Only the two failed layout assertions are retried, with subtree ancestry + // instead of an invalid bound requiring every sibling to overlap its parent. + for (const size of ['medium','large']) { + await mount(fixtures[size],{layoutMode:'auto',displayMode:'readOnly'}); await ready(page,fixtures[size].employees.length); m=await measure(); + check(!overlap(m.cards) && m.nodes.every(n=>n.parent===n.domParent && (!n.parent || m.nodes.some(p=>p.id===n.parent && p.article.y+p.article.heighte.dispatchEvent(new Event('error'))); await settle(page); + check((await measure()).cards[0].photo==='placeholder','Image failure returns common placeholder (error-event simulation)'); + // Real React Story runtime: same host controls and shared Core. + const story=process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6054'; + await page.goto(story+'/iframe.html?id=orgchart--expanded-org-chart&viewMode=story'); await ready(page,20); m=await measure(); + const expected=baseline.cards.map(c=>[c.id,c.size,c.avatar,c.name,c.role,c.fontSize,c.border[2]]); + check(JSON.stringify(m.cards.map(c=>[c.id,c.size,c.avatar,c.name,c.role,c.fontSize,c.border[2]]))===JSON.stringify(expected),'HTML/React same shared-card geometry, photo and typography'); + check(m.cards.every(c=>c.font.includes('Noto Sans KR') && c.label.includes('직원 정보 보기')),'React computed Noto family and meaningful card names'); + check(await page.getByRole('checkbox',{name:'한맥기술'}).count()===1 && await page.getByRole('combobox',{name:'가상 데이터'}).count()===1,'React company and data controls have connected names'); + const index=await (await fetch(story+'/index.json')).json(); + check(Object.values(index.entries).filter(e=>e.title==='OrgChart' && e.type==='story' && !e.tags?.includes('internal') && !e.tags?.includes('verification')).length===4,'Four public OrgChart Stories preserved'); + for(const width of [390,768,1280,1440]) { + await page.setViewportSize({width,height:1000}); await settle(page); const mm=await measure(); + check(mm.document[0]<=width+1 && mm.cards.length===20 && mm.scroll[0]===0 && mm.scroll[1]===0,`React ${width}px no horizontal document overflow or hidden viewport scroll`,{document:mm.document,scroll:mm.scroll}); + await page.screenshot({path:path.join(output,`storybook-${width}.png`),fullPage:true}); + } + await page.getByRole('combobox').click(); await page.getByRole('option',{name:'Large · 300명',exact:true}).click(); await ready(page,300); + await page.screenshot({path:path.join(output,'storybook-large.png'),fullPage:true}); + const largeReact=await measure(); check(largeReact.cards.length===300 && !overlap(largeReact.cards),'React Large selection renders 300 cards without overlap'); + await popup(page,largeReact.cards[0].id); + await page.goto(origin+'/test-results/org-chart-p5-4/react-build/dist/index.html'); await ready(page,20); + check((await measure()).design==='current-presentation','Built React bootstrap mounts revised shared Core'); + const touchContext=await browser.newContext({viewport:{width:390,height:844},isMobile:true,hasTouch:true}); + const touchPage=await touchContext.newPage(); track(touchPage); + await touchPage.goto((process.env.ORGCHART_HTML_ORIGIN ?? 'http://127.0.0.1:4189')+'/expanded.html'); await ready(touchPage,20); + const touchToggle=touchPage.locator('.hm-orgchart-expanded-toggle[data-organization-id="hanmac-office-0"]'); + await touchToggle.tap(); await settle(touchPage); check(await touchToggle.getAttribute('aria-expanded')==='false','Mobile emulation touch tap collapses title'); + await touchToggle.tap(); await ready(touchPage,20); + const ts=await touchPage.locator('.hm-orgchart-viewport').boundingBox(),cdp=await touchContext.newCDPSession(touchPage); + const touchBefore=await touchPage.evaluate(()=>window.expandedHandle.getViewportState()); + await cdp.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[{x:ts.x+20,y:ts.y+20}]}); + await cdp.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{x:ts.x+65,y:ts.y+55}]}); + await cdp.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]}); await settle(touchPage); + const touchAfter=await touchPage.evaluate(()=>window.expandedHandle.getViewportState()); + check(touchAfter.x!==touchBefore.x && touchAfter.y!==touchBefore.y,'Mobile emulation CDP touch Pan'); + await touchPage.getByRole('button',{name:'Fit to View',exact:true}).tap(); await settle(touchPage); + await touchPage.locator('.hm-orgchart-employee').first().tap(); await touchPage.locator('dialog[open]').waitFor(); + check(await touchPage.getByRole('button',{name:'직원 정보 닫기',exact:true}).count()===1,'Mobile emulation card tap opens named dialog'); + await touchPage.getByRole('button',{name:'직원 정보 닫기',exact:true}).tap(); await settle(touchPage); + await touchPage.screenshot({path:path.join(output,'touch-emulation.png'),fullPage:true}); await touchContext.close(); + check(errors.length===0,'No new console/runtime errors',errors); + } +} catch(error) { check(false,'Runner completed',error.stack); } +finally { + await fs.writeFile(path.join(output,'browser.json'),JSON.stringify({scope:'P5-4 change impact only',results,errors,measurements,notVerified:['Actual screen-reader speech','Physical mobile device','Other browser engines','Unsupported no-connection direction','Historical full regression'],summary:{pass:results.filter(r=>r.status==='PASS').length,fail:results.filter(r=>r.status==='FAIL').length}},null,2)); + await browser.close(); await new Promise(resolve=>server.close(resolve)); +} +console.log(JSON.stringify({pass:results.filter(r=>r.status==='PASS').length,fail:results.filter(r=>r.status==='FAIL').length,output})); +if(results.some(r=>r.status==='FAIL')) process.exitCode=1; diff --git a/packages/org-chart-react/src/OrgChart.tsx b/packages/org-chart-react/src/OrgChart.tsx index b2cdd09..e5c9cad 100644 --- a/packages/org-chart-react/src/OrgChart.tsx +++ b/packages/org-chart-react/src/OrgChart.tsx @@ -20,6 +20,10 @@ export function OrgChart({ displayMode, layoutMode, companyId, + selectedCompanyIds, + companyTopNodes, + employeeConnections, + zoom, organizationLinks, onEmployeeClick, className, @@ -66,6 +70,10 @@ export function OrgChart({ displayMode, layoutMode, companyId, + selectedCompanyIds, + companyTopNodes, + employeeConnections, + zoom, organizationLinks, onEmployeeClick: hasEmployeeCallback ? (employee, context) => { if (generationRef.current === generation) committedRef.current.onEmployeeClick?.(employee, context); @@ -105,7 +113,17 @@ export function OrgChart({ } } }; - }, [aggregationPolicy, engineStylesheetUrl, coreStylesheetUrl, displayMode, layoutMode, companyId, organizationLinks, hasEmployeeCallback]); + }, [aggregationPolicy, engineStylesheetUrl, coreStylesheetUrl, displayMode, layoutMode, companyId, companyTopNodes, employeeConnections, zoom, organizationLinks, hasEmployeeCallback]); + + useLayoutEffect(() => { + const instance = instanceRef.current; + if (!instance || layoutMode !== 'auto') return; + void instance.handle.setSelectedCompanies(selectedCompanyIds).catch(error => { + if (instanceRef.current !== instance) return; + try { committedRef.current.onLifecycleError?.(error); } + catch { /* Match other lifecycle callbacks: never leak a rejected Promise. */ } + }); + }, [selectedCompanyIds, layoutMode]); useLayoutEffect(() => { const instance = instanceRef.current; diff --git a/packages/org-chart/src/core/employee-card.ts b/packages/org-chart/src/core/employee-card.ts index 6493eef..18fbb7d 100644 --- a/packages/org-chart/src/core/employee-card.ts +++ b/packages/org-chart/src/core/employee-card.ts @@ -13,6 +13,8 @@ export interface EmployeeCardOptions { cardAsset?: DepartmentCardAsset | RepresentativeCardAsset; /** Full Composition presentation only; the employee dataset is untouched. */ workdeskPresentation?: boolean; + /** Auto content presentation, independent of Fixed SVG geometry. */ + expandedPresentation?: boolean; /** Compact read-only status groups already convey this label in their header. */ hideStatusLabel?: boolean; } @@ -31,15 +33,16 @@ export function createEmployeeCard( const leader = options.kind !== 'employee'; const representative = options.kind === 'topLeader' || options.cardAsset?.size === 'representative'; const compactStatus = Boolean(options.workdeskPresentation && options.hideStatusLabel && !options.cardAsset); - const styledCard = Boolean(options.cardAsset) || compactStatus; - const presentationName = options.workdeskPresentation || representative; + const styledCard = Boolean(options.cardAsset) || compactStatus || options.expandedPresentation; + const presentationName = options.workdeskPresentation || (representative && !options.expandedPresentation); const displayName = presentationName ? '홍길동' : employee.name; - const displayedName = presentationName ? '홍 길 동' : displayName; + const displayedName = presentationName ? '홍 길 동' : options.expandedPresentation && /^[가-힣]{3}$/u.test(displayName) ? [...displayName].join(' ') : displayName; const card = document.createElement('div'); card.className = `hm-orgchart-employee${leader ? ' hm-orgchart-leader' : ''}`; card.dataset.employeeId = employee.id; card.dataset.cardKind = options.kind; card.dataset.workStatus = employee.workStatus; + if (options.expandedPresentation) card.dataset.cardPresentation = 'expanded'; if (options.workdeskPresentation) card.dataset.workdeskPresentation = 'true'; if (compactStatus) card.dataset.cardLayout = 'compact'; if (options.cardAsset) { @@ -81,14 +84,15 @@ export function createEmployeeCard( const details = document.createElement('div'); details.className = 'hm-orgchart-employee-details'; - const displayedPosition = representative ? '대표이사' : employee.position.replace(/\s+/gu, ''); + const displayedPosition = representative && !options.expandedPresentation ? '대표이사' : employee.position.replace(/\s+/gu, ''); const position = text(document, 'span', 'hm-orgchart-person-role', displayedPosition); const name = text(document, 'strong', 'hm-orgchart-person-name', displayedName); if (presentationName) { position.setAttribute('aria-label', representative ? displayedPosition : employee.position); name.setAttribute('aria-label', displayName); } - const inlineRoleBadge = styledCard && !representative; + if (options.expandedPresentation) position.setAttribute('aria-label', employee.position); + const inlineRoleBadge = styledCard && !representative && !options.expandedPresentation; const positionRow = inlineRoleBadge ? document.createElement('div') : null; if (positionRow) { positionRow.className = 'hm-orgchart-position-row'; @@ -104,7 +108,7 @@ export function createEmployeeCard( // Representative cards show only position/name in every consumer. Raw employee // roles remain available to dialogs/statistics; other cards keep their policy. const hideExecutiveBadge = options.workdeskPresentation && employee.jobTitle?.trim() === '경영진'; - if (!representative && !hideExecutiveBadge && (employee.jobTitle || (leader && !options.cardAsset))) { + if (!representative && !options.expandedPresentation && !hideExecutiveBadge && (employee.jobTitle || (leader && !options.cardAsset))) { const rawBadge = employee.jobTitle || '조직장'; const badge = text(document, 'span', 'hm-orgchart-job-title', options.workdeskPresentation ? rawBadge.replace(/\s+/gu, '') : rawBadge); if (options.workdeskPresentation) badge.setAttribute('aria-label', employee.jobTitle || '조직장'); diff --git a/packages/org-chart/src/core/expanded-company.test.ts b/packages/org-chart/src/core/expanded-company.test.ts new file mode 100644 index 0000000..6486829 --- /dev/null +++ b/packages/org-chart/src/core/expanded-company.test.ts @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { validateOrgChartDataset, normalizeOrgChartDataset } from '../data/index.js'; +import { resolveExpandedCompanies, validateTopNodes } from './expanded-company.js'; +import { createOrgChart } from './index.js'; + +test('fixed JSON fixtures have exact unique counts, valid ancestry and company memberships', () => { + for (const [size, count, companies, depth] of [['small', 20, 3, 2], ['medium', 100, 5, 3], ['large', 300, 7, 4]] as const) { + const input = JSON.parse(readFileSync(`examples/org-chart/data-expanded-${size}.json`, 'utf8')); + const validation = validateOrgChartDataset(input); + assert.equal(validation.valid, true, JSON.stringify(validation)); + if (!validation.valid) continue; + const data = validation.data, normalized = normalizeOrgChartDataset(data); + const resolved = resolveExpandedCompanies(data, normalized.roots, 'hanmac'); + assert.equal(data.employees.length, count); + assert.equal(new Set(data.employees.map(employee => employee.id)).size, count); + assert.equal(resolved.companyIds.length, companies); + assert.equal(resolved.issues.length, 0); + for (const employee of data.employees) assert.equal(resolved.companies.get(employee.organizationId), employee.affiliationId); + let maxDepth = 0; + const visit = (nodes: typeof normalized.roots, level: number) => { maxDepth = Math.max(maxDepth, level); nodes.forEach(node => { if (node.children.length) visit(node.children, level + 1); }); }; + visit(normalized.roots, 0); assert.equal(maxDepth, depth); + assert.equal(validateTopNodes(data, normalized.roots, resolved.companies, { hanmac: { title: '회장', display: 'card' } }).length, 0); + assert.ok(validateTopNodes(data, normalized.roots, resolved.companies, { hanmac: { employeeId: 'missing' } }).length); + assert.ok(validateTopNodes(data, normalized.roots, resolved.companies, { hanmac: { profileImage: 'javascript:alert(1)' } }).length); + } +}); + +test('inherited identity stays stable; cross-company ancestry fails instead of mixing companies', () => { + const data = JSON.parse(readFileSync('examples/org-chart/data-expanded-small.json', 'utf8')); + delete data.organizations[1].companyId; + let roots = normalizeOrgChartDataset(data).roots; + assert.equal(resolveExpandedCompanies(data, roots, 'saman').companies.get(data.organizations[1].id), 'hanmac'); + data.organizations[1].companyId = 'saman'; roots = normalizeOrgChartDataset(data).roots; + assert.equal(resolveExpandedCompanies(data, roots, 'hanmac').issues[0]?.code, 'COMPANY_HIERARCHY'); +}); + +test('invalid Auto view configuration fails early, leaving Fixed untouched', () => { + const options = { dataSource: { load: async () => ({}) }, stylesheetUrls: { engine: '/engine.css', core: '/style.css' } }; + assert.throws(() => createOrgChart({ ...options, layoutMode: 'auto', selectedCompanyIds: ['unknown'] }), /selectedCompanyIds/); + assert.throws(() => createOrgChart({ ...options, layoutMode: 'auto', zoom: { min: 2 } }), /zoom/); + const fixed = createOrgChart({ ...options, layoutMode: 'fixed', selectedCompanyIds: ['unknown'], companyTopNodes: { unknown: { title: 'legacy' } } }); + assert.equal(fixed.getViewportState(), undefined); fixed.destroy(); +}); diff --git a/packages/org-chart/src/core/expanded-company.ts b/packages/org-chart/src/core/expanded-company.ts new file mode 100644 index 0000000..c7583bc --- /dev/null +++ b/packages/org-chart/src/core/expanded-company.ts @@ -0,0 +1,40 @@ +import { getCompanyColorPreset } from '../company-color-presets.js'; +import { isSafeImageUrl, type OrgChartDataset, type OrganizationTreeNode, type ValidationIssue } from '../data/index.js'; +import type { OrgChartTopNode } from './types.js'; + +export function resolveExpandedCompanies(data: OrgChartDataset, roots: OrganizationTreeNode[], fallback: string) { + const companies = new Map(); + const issues: ValidationIssue[] = []; + const visit = (node: OrganizationTreeNode, inherited?: string) => { + const org = node.organization; + const leader = data.employees.find(employee => employee.id === org.leaderId); + const company = org.companyId ?? inherited ?? (leader && getCompanyColorPreset(leader.affiliationId) ? leader.affiliationId : fallback); + if (!getCompanyColorPreset(company)) issues.push({ code: 'COMPANY_PRESET', path: `organizations.${org.id}.companyId`, message: 'Auto company requires a registered color preset.' }); + if (inherited && company !== inherited) issues.push({ code: 'COMPANY_HIERARCHY', path: `organizations.${org.id}.companyId`, message: 'A child cannot belong to another company tree.' }); + companies.set(org.id, company); + node.children.forEach(child => visit(child, company)); + }; + roots.forEach(root => visit(root)); + return { companies, issues, companyIds: [...new Set(roots.map(root => companies.get(root.organization.id)!))] }; +} + +export function validateTopNodes(data: OrgChartDataset, roots: OrganizationTreeNode[], companies: ReadonlyMap, configs: Readonly>): ValidationIssue[] { + const issues: ValidationIssue[] = []; + for (const [id, config] of Object.entries(configs)) { + const path = `companyTopNodes.${id}`; + if (!getCompanyColorPreset(id) || !config || typeof config !== 'object' || Array.isArray(config)) { + issues.push({ code: 'TOP_NODE_CONFIG', path, message: 'Expected a registered company and top node object.' }); continue; + } + for (const [key, value] of Object.entries(config)) { + const valid = key === 'display' ? ['card', 'titleOnly', 'hidden'].includes(value as string) + : ['employeeId', 'profileImage'].includes(key) && value === null ? true + : ['employeeId', 'profileImage', 'title', 'name', 'organizationName'].includes(key) && typeof value === 'string' && !!value.trim() && !/[<>\u0000-\u001f]/u.test(value); + if (!valid || (key === 'profileImage' && typeof value === 'string' && !isSafeImageUrl(value))) issues.push({ code: 'TOP_NODE_CONFIG', path: `${path}.${key}`, message: 'Invalid top node presentation value.' }); + } + const companyRoots = roots.filter(root => companies.get(root.organization.id) === id); + if (config.employeeId && companyRoots.length && !companyRoots.some(root => data.employees.some(employee => employee.id === config.employeeId && employee.organizationId === root.organization.id))) { + issues.push({ code: 'TOP_NODE_REFERENCE', path, message: 'Top employee must belong to a root of this company.' }); + } + } + return issues; +} diff --git a/packages/org-chart/src/core/expanded-content.ts b/packages/org-chart/src/core/expanded-content.ts index 761bd66..abe6995 100644 --- a/packages/org-chart/src/core/expanded-content.ts +++ b/packages/org-chart/src/core/expanded-content.ts @@ -1,47 +1,99 @@ import { createEmployeeCard } from './employee-card.js'; import { createHeading, type RenderContext } from './render.js'; -import { representativeCardAsset } from './representative-card-asset.js'; +import type { RenderEngineNode } from './engine.js'; +import { getCompanyColorPreset } from '../company-color-presets.js'; +import { alignExpandedLevels } from './expanded-layout.js'; -/** P5-1A presenter boundary. The shared Core supplies the initial engine groups; - * this presenter never assigns coordinates or interprets parentId. A later - * layout adapter can replace group placement without duplicating card or data - * rendering in React and Vanilla consumers. */ -export function createExpandedOrgChartContent( - document: Document, context: RenderContext, tree: HTMLElement, signal: AbortSignal, -): HTMLElement { +/** Auto-only presenter. The source and popup identity remain unchanged. */ +export function decorateExpandedNode(node: HTMLElement, value: RenderEngineNode, context: RenderContext, signal: AbortSignal): void { + const organization = context.orderedOrganizations[Number(value.organizationIndex)]!; + const companyId = context.organizationCompanies!.get(organization.id)!; + const preset = getCompanyColorPreset(companyId)!; + const document = node.ownerDocument; + const article = node.querySelector('.hm-orgchart-organization')!; + const collapsed = context.collapsedOrganizations!.has(organization.id); + const top = organization.parentId === null ? context.companyTopNodes?.[companyId] : undefined; + node.dataset.organizationId = organization.id; + node.dataset.parentId = organization.parentId ?? ''; + node.dataset.depth = node.dataset.organizationDepth = String(value.depth); + node.dataset.organizationType = organization.type; + node.dataset.companyId = companyId; + node.dataset.collapsed = String(collapsed); + node.style.setProperty('--hm-orgchart-expanded-primary', preset.primary); + node.style.setProperty('--hm-orgchart-expanded-title-ink', ['saman', 'jangheon-industry'].includes(companyId) ? 'var(--hm-orgchart-resolved-fg-primary)' : 'var(--hm-orgchart-resolved-on-action-primary)'); + preset.secondary.forEach((color, index) => node.style.setProperty(`--hm-orgchart-expanded-secondary-${index + 1}`, color)); + for (const edge of node.querySelectorAll('.edge')) edge.setAttribute('aria-hidden', 'true'); + const leaderId = top?.employeeId === undefined ? organization.leaderId : top.employeeId; + const leader = context.data.employees.find(employee => employee.id === leaderId && employee.organizationId === organization.id); + const staff = context.data.presentation?.groups.showEmployees === false ? [] : (context.employeesByOrganization.get(organization.id) ?? []).filter(employee => employee.id !== leaderId); + const hasChildren = context.data.organizations.some(org => org.parentId === organization.id) || staff.length > 0; + const heading = document.createElement('h3'); + heading.className = 'hm-orgchart-organization-name'; + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'hm-orgchart-expanded-toggle'; + button.dataset.organizationId = organization.id; + button.textContent = `${top?.organizationName ?? organization.name} ${hasChildren ? collapsed ? '▸' : '▾' : '·'}`; + button.setAttribute('aria-label', `${top?.organizationName ?? organization.name} 하위 조직·직원`); + button.setAttribute('aria-expanded', String(!collapsed)); + button.disabled = !hasChildren; + button.addEventListener('click', event => { event.stopPropagation(); context.toggleExpanded?.(organization.id); }, { signal }); + heading.append(button); + const href = context.organizationLinks?.[organization.id]; + if (href) { + const link = document.createElement('a'); + link.className = 'hm-orgchart-expanded-link'; + link.href = href; + link.textContent = '↗'; + link.setAttribute('aria-label', `${organization.name} 페이지 이동`); + link.addEventListener('click', event => event.stopPropagation(), { signal }); + heading.append(link); + } + article.append(heading); + const colorCard = (card: HTMLElement, affiliationId: string) => { + const employeePreset = getCompanyColorPreset(affiliationId) ?? preset; + card.dataset.companyId = employeePreset.companyId; + card.style.setProperty('--hm-orgchart-expanded-card-primary', `var(--hm-orgchart-company-${employeePreset.companyId})`); + return card; + }; + if (leader && top?.display !== 'hidden' && top?.display !== 'titleOnly') { + const presentation = top ? { ...leader, position: top.title ?? leader.position, name: top.name ?? leader.name, profileImage: top.profileImage === undefined ? leader.profileImage : top.profileImage } : leader; + const card = colorCard(createEmployeeCard(document, presentation, { kind: organization.parentId === null ? 'topLeader' : 'leader', expandedPresentation: true, hideStatusLabel: true }, signal), leader.affiliationId); + card.dataset.connectionDirection = 'top'; + article.append(card); + } else if (leader && top?.display === 'titleOnly') { + const title = document.createElement('p'); + title.className = 'hm-orgchart-top-node-title'; + title.textContent = `${top.title ?? leader.position} · ${top.name ?? leader.name}`; + article.append(title); + } + if (!collapsed && staff.length) { + const employees = document.createElement('div'); + employees.className = 'hm-orgchart-employees'; + const direction = context.employeeConnections?.[organization.id] ?? 'top'; + employees.dataset.connectionDirection = direction; + for (const employee of staff) { + const card = colorCard(createEmployeeCard(document, employee, { kind: 'employee', expandedPresentation: true, hideStatusLabel: true }, signal), employee.affiliationId); + card.dataset.connectionDirection = direction; + employees.append(card); + } + article.append(employees); + } +} + +export function createExpandedOrgChartContent(document: Document, context: RenderContext, tree: HTMLElement, signal: AbortSignal, layoutReady: () => void): HTMLElement { const content = document.createElement('div'); content.className = 'hm-orgchart-expanded'; content.dataset.component = 'ExpandedOrgChartContent'; content.dataset.layoutMode = 'auto'; - content.dataset.layoutStatus = 'preview'; - - const heading = createHeading(document, context); - const status = document.createElement('p'); - status.className = 'hm-orgchart-expanded-stage-note'; - status.textContent = 'P5-1A · 확장형 준비 화면'; - const description = document.createElement('p'); - description.className = 'hm-orgchart-expanded-description'; - description.textContent = '대표자와 1Depth 예시 그룹을 표시합니다. 하위 계층 자동 배치와 접기·펼치기, Zoom/Pan은 후속 단계에서 구현합니다.'; - content.append(heading, status, description); - - const leaders = document.createElement('section'); - leaders.className = 'hm-orgchart-expanded-top-leaders'; - leaders.setAttribute('aria-label', '최상위 조직 리더'); - for (const id of context.topLeaderIds) { - const employee = context.data.employees.find(item => item.id === id); - if (!employee) continue; - leaders.append(createEmployeeCard(document, employee, { - kind: 'topLeader', - workdeskPresentation: true, - cardAsset: representativeCardAsset, - affiliationName: context.data.affiliations.find(item => item.id === employee.affiliationId)?.name, - }, signal)); + content.dataset.layoutStatus = 'hierarchical'; + content.dataset.cardDesignStatus = 'current-presentation'; + for (const company of new Set([...context.organizationCompanies!.values(), ...context.data.employees.map(employee => employee.affiliationId)])) { + const preset = getCompanyColorPreset(company); + if (preset) content.style.setProperty(`--hm-orgchart-company-${company}`, preset.primary); } - if (leaders.childElementCount) content.append(leaders); - - // Keep the stable Core viewport for keyboard access and ordinary scrolling. - // This is a flow preview, with no fixed-stage scale observer or artwork. - tree.setAttribute('aria-label', '확장형 조직도 초기 그룹 영역'); - content.append(tree); + tree.setAttribute('aria-label', '확장형 조직 계층도 · Ctrl 또는 Command + 휠로 확대 · 드래그로 이동'); + content.append(createHeading(document, context), tree); + alignExpandedLevels(tree, signal, layoutReady); return content; } diff --git a/packages/org-chart/src/core/expanded-layout.test.ts b/packages/org-chart/src/core/expanded-layout.test.ts index d965f48..9eb74bb 100644 --- a/packages/org-chart/src/core/expanded-layout.test.ts +++ b/packages/org-chart/src/core/expanded-layout.test.ts @@ -1,29 +1,31 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { expandedPreviewRoots } from './expanded-layout.js'; import { createOrgChart } from './index.js'; +import { engineTree } from './render.js'; +import type { RenderContext } from './render.js'; import type { OrganizationTreeNode } from '../data/index.js'; -const node = (id: string, parentId: string | null, type: 'root' | 'office' = 'office', children: OrganizationTreeNode[] = []): OrganizationTreeNode => ({ - organization: { id, parentId, name: id, type, leaderId: null, displayOrder: 0 }, children, +const node = (id: string, parentId: string | null, children: OrganizationTreeNode[] = []): OrganizationTreeNode => ({ + organization: { id, parentId, name: id, type: parentId ? 'office' : 'root', leaderId: null, displayOrder: 0 }, children, }); -test('initial preview keeps actual first-depth ancestry and defers descendants without mutating hierarchy', () => { - const grandchild = node('depth-2', 'depth-1'); - const child = node('depth-1', 'root', 'office', [grandchild]); - const roots = [node('root', null, 'root', [child])]; +test('Auto engine projection preserves root, nested ancestry, sibling order and depths without mutation', () => { + const roots = [node('root', null, [node('first', 'root', [node('cell', 'first', [node('team', 'cell')])]), node('second', 'root')])]; const before = structuredClone(roots); - const projected = expandedPreviewRoots(roots); - assert.deepEqual(projected.map(item => item.organization.id), ['depth-1']); - assert.equal(projected[0]!.organization.parentId, 'root'); - assert.deepEqual(projected[0]!.children, []); + const context = { orderedOrganizations: [], readOnly: true } as unknown as RenderContext; + const result = engineTree(roots, 'test', context); + assert.equal(result[0]?.depth, 0); + assert.equal(result[0]?.children?.[0]?.children?.[0]?.children?.[0]?.depth, 3); + assert.deepEqual(context.orderedOrganizations.map(item => item.id), ['root', 'first', 'cell', 'team', 'second']); + assert.deepEqual(result[0]?.children?.map(item => item.organizationIndex), [1, 4]); assert.deepEqual(roots, before); }); -test('independent roots and normalized order stay independent in the initial preview', () => { - const roots = [node('first', null), node('root', null, 'root', [node('second', 'root'), node('third', 'root')])]; - assert.deepEqual(expandedPreviewRoots(roots).map(item => item.organization.id), ['first', 'second', 'third']); - assert.deepEqual(expandedPreviewRoots([]), []); +test('independent roots remain independent and empty hierarchy stays empty', () => { + const context = { orderedOrganizations: [], readOnly: true } as unknown as RenderContext; + const roots = [node('first', null), node('second', null)]; + assert.equal(engineTree(roots, 'test', context).length, 2); + assert.deepEqual(engineTree([], 'empty', context), []); }); const options = { dataSource: { load: async () => ({}) }, stylesheetUrls: { engine: '/engine.css', core: '/style.css' } }; diff --git a/packages/org-chart/src/core/expanded-layout.ts b/packages/org-chart/src/core/expanded-layout.ts index fef65ab..65c85ac 100644 --- a/packages/org-chart/src/core/expanded-layout.ts +++ b/packages/org-chart/src/core/expanded-layout.ts @@ -1,10 +1,85 @@ -import type { OrganizationTreeNode } from '../data/index.js'; +/** dabeng owns recursive widths and connectors. Reserve the tallest content + * height per depth so child rows align with unequal staff counts. Observe the + * articles rather than reserved nodes to avoid ResizeObserver feedback. */ +export function alignExpandedLevels(viewport: HTMLElement, signal: AbortSignal, layoutReady: () => void = () => {}): void { + const view = viewport.ownerDocument.defaultView; + if (!view || signal.aborted) return; + const nodes = [...viewport.querySelectorAll('.node[data-depth]')]; + let frame = 0; + const align = () => { + frame = 0; + if (signal.aborted) return; + const heights = new Map(); + for (const node of nodes) { + const height = node.querySelector('.hm-orgchart-organization')?.offsetHeight ?? 0; + heights.set(node.dataset.depth!, Math.max(heights.get(node.dataset.depth!) ?? 0, Math.ceil(height))); + } + for (const node of nodes) { + node.style.minHeight = `${heights.get(node.dataset.depth!) ?? 0}px`; + node.style.setProperty('--hm-orgchart-expanded-content-height', `${node.querySelector('.hm-orgchart-organization')?.offsetHeight ?? 0}px`); + } + drawEmployeeConnectors(viewport); + layoutReady(); + }; + const schedule = () => { + if (!frame && !signal.aborted) frame = view.requestAnimationFrame(align); + }; + const observer = new view.ResizeObserver(schedule); + for (const node of nodes) { + const article = node.querySelector('.hm-orgchart-organization'); + if (article) observer.observe(article); + } + signal.addEventListener('abort', () => { + observer.disconnect(); + if (frame) view.cancelAnimationFrame(frame); + }, { once: true }); + schedule(); +} -/** P5-1A preview projection only. Keep the validated hierarchy intact and - * expose the root's immediate groups without pretending to place descendants. - * P5-1B can replace this boundary with a recursive layout result shared by - * every runtime; neither React nor the Story owns a layout algorithm. */ -export function expandedPreviewRoots(roots: readonly OrganizationTreeNode[]): OrganizationTreeNode[] { - return roots.flatMap(root => root.organization.type === 'root' ? root.children : [root]) - .map(node => ({ organization: node.organization, children: [] })); +/** Draw routes in unscaled article coordinates. The endpoint defines both the + * connector's incoming direction and the single accent side on the card. */ +function drawEmployeeConnectors(viewport: HTMLElement): void { + const document = viewport.ownerDocument; + for (const article of viewport.querySelectorAll('.hm-orgchart-organization')) { + article.querySelector('.hm-orgchart-employee-connectors')?.remove(); + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.classList.add('hm-orgchart-employee-connectors'); + svg.setAttribute('aria-hidden', 'true'); + svg.setAttribute('width', String(article.offsetWidth)); + svg.setAttribute('height', String(article.offsetHeight)); + let previous = article.querySelector('.hm-orgchart-top-node-title') ?? article.querySelector('.hm-orgchart-organization-name')!; + const local = (element: HTMLElement) => { + let x = 0, y = 0, current: HTMLElement | null = element; + while (current && current !== article) { x += current.offsetLeft; y += current.offsetTop; current = current.offsetParent as HTMLElement | null; } + return { x, y, width: element.offsetWidth, height: element.offsetHeight }; + }; + const heading = local(previous); + let source = { x: heading.x + heading.width / 2, y: heading.y + heading.height }; + let staffSource = source; + for (const card of article.querySelectorAll('.hm-orgchart-employee')) { + const rect = local(card); + const direction = card.dataset.connectionDirection; + const target = direction === 'left' ? { x: rect.x, y: rect.y + rect.height / 2 } + : direction === 'right' ? { x: rect.x + rect.width, y: rect.y + rect.height / 2 } + : { x: rect.x + rect.width / 2, y: rect.y }; + const path = document.createElementNS(svg.namespaceURI, 'path'); + if (direction === 'left' || direction === 'right') { + const rail = direction === 'left' ? rect.x / 2 : rect.x + rect.width + (article.offsetWidth - rect.x - rect.width) / 2; + const bend = staffSource.y + (rect.y - staffSource.y) / 2; + path.setAttribute('d', `M ${staffSource.x} ${staffSource.y} V ${bend} H ${rail} V ${target.y} H ${target.x}`); + // All staff branch off the same rail above the list, avoiding crossings. + staffSource = { x: rail, y: bend }; + } else { + path.setAttribute('d', `M ${source.x} ${source.y} V ${(source.y + target.y) / 2} H ${target.x} V ${target.y}`); + } + path.setAttribute('data-employee-id', card.dataset.employeeId!); + path.setAttribute('data-direction', direction ?? 'top'); + path.setAttribute('data-end-x', String(target.x)); + path.setAttribute('data-end-y', String(target.y)); + svg.append(path); + source = { x: rect.x + rect.width / 2, y: rect.y + rect.height }; + if (!card.closest('.hm-orgchart-employees')) staffSource = source; + } + article.prepend(svg); + } } diff --git a/packages/org-chart/src/core/expanded-viewport.ts b/packages/org-chart/src/core/expanded-viewport.ts new file mode 100644 index 0000000..1e3ce63 --- /dev/null +++ b/packages/org-chart/src/core/expanded-viewport.ts @@ -0,0 +1,141 @@ +import type { OrgChartViewportState } from './types.js'; + +export interface ExpandedViewport { + zoomTo(scale: number, anchor?: { x: number; y: number }): void; + panBy(x: number, y: number): void; + fit(): void; + state(): OrgChartViewportState; + layoutReady(): void; +} + +/** One transformed forest, shared by HTML and React; dialogs live in the shell. */ +export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLElement, signal: AbortSignal, + config: { min: number; max: number; step: number }, initial?: OrgChartViewportState, fitInitially = true): ExpandedViewport { + const document = viewport.ownerDocument; + const view = document.defaultView!; + const forest = viewport.querySelector('.hm-orgchart-forest')!; + let { scale, x, y } = initial ?? { scale: 1, x: 0, y: 0 }; + let initialFit = fitInitially; + let drag: { id: number; x: number; y: number; moved: boolean } | undefined; + let suppressClick = false; + let focusFrame = 0; + const output = document.createElement('output'); + output.setAttribute('aria-label', '현재 배율'); + output.setAttribute('aria-live', 'polite'); + const buttons: HTMLButtonElement[] = []; + const apply = () => { + forest.style.transform = `translate(${x}px, ${y}px) scale(${scale})`; + viewport.dataset.scale = String(scale); + viewport.dataset.panX = String(x); + viewport.dataset.panY = String(y); + output.textContent = `${Math.round(scale * 100)}%`; + buttons[0].disabled = scale >= config.max; + buttons[1].disabled = scale <= config.min; + }; + const clamp = (value: number) => Math.max(config.min, Math.min(config.max, value)); + const controller: ExpandedViewport = { + zoomTo(next, anchor = { x: viewport.clientWidth / 2, y: viewport.clientHeight / 2 }) { + if (!Number.isFinite(next)) return; + initialFit = false; + const value = clamp(next), factor = value / scale; + x = anchor.x - (anchor.x - x) * factor; + y = anchor.y - (anchor.y - y) * factor; + scale = value; apply(); + }, + panBy(dx, dy) { if (!Number.isFinite(dx) || !Number.isFinite(dy)) return; initialFit = false; x += dx; y += dy; apply(); }, + fit() { + if (!forest.offsetWidth || !forest.offsetHeight) return; + scale = clamp(Math.min((viewport.clientWidth - 32) / forest.offsetWidth, (viewport.clientHeight - 32) / forest.offsetHeight, 1)); + x = (viewport.clientWidth - forest.offsetWidth * scale) / 2; + y = Math.max(16, (viewport.clientHeight - forest.offsetHeight * scale) / 2); + apply(); + }, + state: () => ({ scale, x, y }), + layoutReady() { if (initialFit) controller.fit(); }, + }; + const actions: [string, () => void][] = [ + ['확대', () => controller.zoomTo(scale * config.step)], + ['축소', () => controller.zoomTo(scale / config.step)], + ['100% 복원', () => { initialFit = false; scale = 1; x = 16; y = 16; apply(); }], + ['Fit to View', () => { initialFit = true; controller.fit(); }], + ]; + toolbar.setAttribute('role', 'group'); + toolbar.setAttribute('aria-label', '조직도 확대·이동 도구'); + for (const [label, action] of actions) { + const button = document.createElement('button'); + button.type = 'button'; button.textContent = label; + button.addEventListener('click', action, { signal }); + buttons.push(button); toolbar.append(button); + } + toolbar.append(output); + const hint = document.createElement('span'); + hint.textContent = '드래그 이동 · Ctrl/⌘ + 휠 확대 · 방향키 이동'; + toolbar.append(hint); + viewport.addEventListener('wheel', event => { + if (!event.ctrlKey && !event.metaKey) return; + event.preventDefault(); + const box = viewport.getBoundingClientRect(); + controller.zoomTo(scale * Math.exp(-event.deltaY * 0.002), { x: event.clientX - box.left, y: event.clientY - box.top }); + }, { passive: false, signal }); + viewport.addEventListener('keydown', event => { + if (event.target !== viewport) return; + const shifts: Record = { ArrowLeft: [40, 0], ArrowRight: [-40, 0], ArrowUp: [0, 40], ArrowDown: [0, -40] }; + if (shifts[event.key]) { event.preventDefault(); controller.panBy(...shifts[event.key]!); } + else if (event.key === '+' || event.key === '=') { event.preventDefault(); controller.zoomTo(scale * config.step); } + else if (event.key === '-') { event.preventDefault(); controller.zoomTo(scale / config.step); } + }, { signal }); + // Tab can reach cards outside a zoomed/panned forest. Reveal the focused + // control without changing scale or using the viewport's hidden scroll area. + viewport.addEventListener('focusin', event => { + const target = event.target; + if (!(target instanceof view.HTMLElement) || !forest.contains(target)) return; + if (focusFrame) view.cancelAnimationFrame(focusFrame); + focusFrame = view.requestAnimationFrame(() => { + focusFrame = 0; + if (signal.aborted || !target.isConnected) return; + viewport.scrollLeft = viewport.scrollTop = 0; + const outer = viewport.getBoundingClientRect(), box = target.getBoundingClientRect(); + const left = outer.left + viewport.clientLeft + 8, top = outer.top + viewport.clientTop + 8; + const right = left + viewport.clientWidth - 16, bottom = top + viewport.clientHeight - 16; + const dx = box.left < left ? left - box.left : box.right > right ? right - box.right : 0; + const dy = box.top < top ? top - box.top : box.bottom > bottom ? bottom - box.bottom : 0; + if (dx || dy) controller.panBy(dx, dy); + }); + }, { signal }); + viewport.addEventListener('pointerdown', event => { + if (event.button !== 0 || event.isPrimary === false || (event.target as Element).closest('button,a')) return; + suppressClick = false; + drag = { id: event.pointerId, x: event.clientX, y: event.clientY, moved: false }; + }, { signal }); + viewport.addEventListener('pointermove', event => { + if (!drag || drag.id !== event.pointerId) return; + const dx = event.clientX - drag.x, dy = event.clientY - drag.y; + if (!drag.moved && Math.hypot(dx, dy) < 6) return; + if (!drag.moved) { viewport.setPointerCapture(event.pointerId); viewport.dataset.dragging = 'true'; } + drag.moved = true; + controller.panBy(dx, dy); drag.x = event.clientX; drag.y = event.clientY; + }, { signal }); + const finish = (event: PointerEvent) => { + if (!drag || drag.id !== event.pointerId) return; + suppressClick = drag.moved; + if (viewport.hasPointerCapture(event.pointerId)) viewport.releasePointerCapture(event.pointerId); + drag = undefined; delete viewport.dataset.dragging; + }; + viewport.addEventListener('pointerup', finish, { signal }); + viewport.addEventListener('pointercancel', finish, { signal }); + viewport.addEventListener('lostpointercapture', () => { drag = undefined; delete viewport.dataset.dragging; }, { signal }); + // Release outside the viewport must not leave a pending drag. + view.addEventListener('pointerup', finish, { signal }); + viewport.addEventListener('click', event => { + if (!suppressClick) return; + suppressClick = false; + // A drag suppresses its pointer click, never a later Enter/Space activation. + if (event.detail === 0) return; + event.preventDefault(); event.stopImmediatePropagation(); + }, { capture: true, signal }); + const observer = new view.ResizeObserver(() => { if (initialFit) controller.fit(); }); + observer.observe(viewport); + signal.addEventListener('abort', () => { observer.disconnect(); drag = undefined; if (focusFrame) view.cancelAnimationFrame(focusFrame); }, { once: true }); + apply(); + return controller; +} diff --git a/packages/org-chart/src/core/index.ts b/packages/org-chart/src/core/index.ts index cbb0e46..3c6d019 100644 --- a/packages/org-chart/src/core/index.ts +++ b/packages/org-chart/src/core/index.ts @@ -6,8 +6,9 @@ import { decorateNode, engineTree } from './render.js'; import { createOrgChartTree } from './org-chart-tree.js'; import { createOrgChartContent } from './org-chart-content.js'; import { createExpandedOrgChartContent } from './expanded-content.js'; -import { expandedPreviewRoots } from './expanded-layout.js'; import { getCompanyColorPreset } from '../company-color-presets.js'; +import { resolveExpandedCompanies, validateTopNodes } from './expanded-company.js'; +import { createExpandedViewport, type ExpandedViewport } from './expanded-viewport.js'; import { createPositionSummary } from './position-summary.js'; import { createAffiliationSummary } from './affiliation-summary.js'; import { createStatusSummary } from './status-summary.js'; @@ -55,6 +56,22 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or const expanded = view === 'composition' && layoutMode === 'auto'; const expandedCompanyPreset = expanded ? getCompanyColorPreset(options.companyId ?? 'hanmac') : undefined; if (expanded && !expandedCompanyPreset) throw new TypeError('companyId must be a registered company preset in Auto mode'); + const topNodes = expanded ? freeze(structuredClone(options.companyTopNodes ?? {})) : {}; + const employeeConnections = expanded ? freeze(structuredClone(options.employeeConnections ?? {})) : {}; + if (Object.values(employeeConnections).some(value => !['top', 'left', 'right'].includes(value))) throw new TypeError('employeeConnections must use top, left or right'); + const zoom = { min: options.zoom?.min ?? 0.05, max: options.zoom?.max ?? 3, step: options.zoom?.step ?? 1.2 }; + if (expanded && (!Number.isFinite(zoom.min) || !Number.isFinite(zoom.max) || !Number.isFinite(zoom.step) || zoom.min <= 0 || zoom.min > 1 || zoom.max < 1 || zoom.min >= zoom.max || zoom.step <= 1)) throw new TypeError('zoom requires 0 < min <= 1 <= max and step > 1'); + const copySelection = (ids?: readonly string[]) => { + if (ids !== undefined && (!Array.isArray(ids) || ids.some(id => typeof id !== 'string' || !getCompanyColorPreset(id)))) throw new TypeError('selectedCompanyIds must contain registered company IDs'); + return ids === undefined ? undefined : [...new Set(ids)]; + }; + let selectedCompanies = expanded ? copySelection(options.selectedCompanyIds) : undefined; + let availableCompanies: string[] = []; + let latestData: unknown; + const collapsedOrganizations = new Set(); + let expandedViewport: ExpandedViewport | undefined; + let fitNext = true; + let focusOrganization: string | undefined; const readOnly = view === 'composition' && options.displayMode === 'readOnly'; const organizationLinks: Record = Object.create(null); if (options.onEmployeeClick !== undefined && typeof options.onEmployeeClick !== 'function') throw new TypeError('onEmployeeClick must be a function'); @@ -134,6 +151,8 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or engine = undefined; renderContext = undefined; viewport = undefined; + expandedViewport = undefined; + latestData = undefined; content?.replaceChildren(); } @@ -206,18 +225,36 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or if (!validation.valid) { fail(version, validation.issues); return; } const data = freeze(validation.data); const normalized = normalizeOrgChartDataset(data); + const companyState = expanded ? resolveExpandedCompanies(data, normalized.roots, expandedCompanyPreset!.companyId) : undefined; + if (companyState) { + const issues = [...companyState.issues, ...validateTopNodes(data, normalized.roots, companyState.companies, topNodes)]; + if (issues.length) { fail(version, issues); return; } + availableCompanies = companyState.companyIds; + const validIds = new Set(data.organizations.map(org => org.id)); + for (const id of collapsedOrganizations) if (!validIds.has(id)) collapsedOrganizations.delete(id); + latestData = data; + } const summary = freeze(calculateSummary(data, policy)); const topLeaderIds = new Set(view === 'composition' ? normalized.roots.filter(node => node.organization.type === 'root' && node.organization.leaderId).map(node => node.organization.leaderId!) : []); - const nextContext: RenderContext = { data, summary, orderedOrganizations: [], employeesByOrganization: normalized.employeesByOrganization, topLeaderIds, readOnly: readOnly || expanded, organizationLinks, expandedCompanyPreset }; + const nextContext: RenderContext = { data, summary, orderedOrganizations: [], employeesByOrganization: normalized.employeesByOrganization, topLeaderIds, readOnly: readOnly || expanded, organizationLinks, expandedCompanyPreset, + organizationCompanies: companyState?.companies, collapsedOrganizations: expanded ? collapsedOrganizations : undefined, + companyTopNodes: topNodes, employeeConnections, toggleExpanded: id => { + if (destroyed || !latestData) return; + if (collapsedOrganizations.has(id)) collapsedOrganizations.delete(id); else collapsedOrganizations.add(id); + focusOrganization = id; fitNext = false; + const { version } = begin(); + void apply(latestData, version).catch(() => fail(version, [{ code: 'CORE_RENDER', path: 'core', message: 'Expanded rerender failed.' }])); + }, + }; // Composition shows the root leader as the standalone top card, so the root's direct // organizations become the horizontal engine roots (data and parentId stay untouched). // A root with other direct employees keeps its node so those employees stay visible. const flattenRoot = (node: typeof normalized.roots[number]) => view === 'composition' && node.organization.type === 'root' && !!node.organization.leaderId && (normalized.employeesByOrganization.get(node.organization.id) ?? []).every(employee => employee.id === node.organization.leaderId); const flattened = normalized.roots.some(flattenRoot); - const roots = expanded ? expandedPreviewRoots(normalized.roots) + const roots = expanded ? normalized.roots.filter(node => selectedCompanies === undefined || selectedCompanies.includes(companyState!.companies.get(node.organization.id)!)) : flattened ? normalized.roots.flatMap(node => flattenRoot(node) ? node.children : [node]) : normalized.roots; - const tree = engineTree(roots, prefix, nextContext, expanded || flattened ? 1 : 0); + const tree = engineTree(roots, prefix, nextContext, !expanded && flattened ? 1 : 0); const styleIssues = await styleReady; if (!active(version)) return; if (styleIssues) { fail(version, styleIssues); return; } @@ -226,6 +263,7 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or if (!active(version)) return; if (LoadedEngine) Engine = LoadedEngine; const document = content!.ownerDocument; + const previousViewport = expandedViewport?.state(); employeeDialog?.close(); contentGeneration?.abort(); const Abort = document.defaultView?.AbortController ?? AbortController; @@ -244,16 +282,39 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or } // If regeneration removed keyboard focus, offer the stable viewport next. const nextContent = view === 'composition' ? expanded - ? createExpandedOrgChartContent(document, nextContext, viewport!, contentGeneration.signal) + ? createExpandedOrgChartContent(document, nextContext, viewport!, contentGeneration.signal, () => expandedViewport?.layoutReady()) : createOrgChartContent(document, nextContext, viewport!, contentGeneration.signal) : view === 'tree' ? viewport! : view === 'positionSummary' ? createPositionSummary(document, nextContext) : view === 'affiliationSummary' ? createAffiliationSummary(document, nextContext) : createStatusSummary(document, nextContext, contentGeneration.signal); + if (expanded) { + const toolbar = document.createElement('div'); + toolbar.className = 'hm-orgchart-expanded-toolbar'; + nextContent.insertBefore(toolbar, viewport!); + expandedViewport = createExpandedViewport(viewport!, toolbar, contentGeneration.signal, zoom, previousViewport, fitNext); + fitNext = false; + const selection = document.createElement('p'); + selection.className = 'hm-orgchart-expanded-selection'; + selection.setAttribute('aria-live', 'polite'); + const selected = new Set(roots.map(root => companyState!.companies.get(root.organization.id)!)); + const employeeCount = data.employees.filter(employee => selected.has(companyState!.companies.get(employee.organizationId)!)).length; + selection.textContent = `선택 ${selected.size}개 회사 · ${employeeCount}명 / 원본 ${data.employees.length}명`; + nextContent.insertBefore(selection, toolbar); + if (!roots.length) { + const empty = document.createElement('p'); + empty.className = 'hm-orgchart-expanded-empty'; + empty.textContent = '선택된 회사가 없습니다. 표시할 회사를 선택해 주세요.'; + viewport!.append(empty); + } else viewport!.querySelector('.hm-orgchart-expanded-empty')?.remove(); + } prepareEmployeeCards(nextContent); content!.replaceChildren(nextContent); const restoreFocus = !!activeElement && (activeElement === viewport || !activeElement.isConnected); - if (restoreFocus) (readOnly ? shell! : viewport ?? content!.querySelector('.hm-orgchart-summary-scroll') ?? shell!).focus({ preventScroll: true }); + const focusTarget = focusOrganization ? [...content!.querySelectorAll('.hm-orgchart-expanded-toggle')].find(button => button.dataset.organizationId === focusOrganization) : undefined; + focusOrganization = undefined; + if (focusTarget) focusTarget.focus({ preventScroll: true }); + else if (restoreFocus) (expanded ? viewport! : readOnly ? shell! : viewport ?? content!.querySelector('.hm-orgchart-summary-scroll') ?? shell!).focus({ preventScroll: true }); shell!.setAttribute('aria-busy', 'false'); status!.textContent = ''; if (!active(version)) return; @@ -262,6 +323,7 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or async function reload(nextSource?: OrgChartDataSource): Promise { assertMounted(); + if (expanded) fitNext = true; if (nextSource) { if (typeof nextSource.load !== 'function') throw new TypeError('An OrgChartDataSource is required'); source = nextSource; @@ -375,5 +437,16 @@ export function createOrgChartView(options: OrgChartOptions, view: CoreView): Or return () => { listeners.delete(listener); }; }, getState() { return state; }, + async setSelectedCompanies(ids) { + assertMounted(); + if (!expanded) throw new Error('Company selection is available only in Auto mode'); + selectedCompanies = copySelection(ids); fitNext = true; + if (latestData) { const { version } = begin(); await apply(latestData, version); } + }, + getSelectedCompanies() { return Object.freeze([...(selectedCompanies ?? availableCompanies)]); }, + zoomTo(scale) { expandedViewport?.zoomTo(scale); }, + panBy(x, y) { expandedViewport?.panBy(x, y); }, + fitToView() { expandedViewport?.fit(); }, + getViewportState() { return expandedViewport?.state(); }, }; } diff --git a/packages/org-chart/src/core/render.ts b/packages/org-chart/src/core/render.ts index b11bd74..d8830a3 100644 --- a/packages/org-chart/src/core/render.ts +++ b/packages/org-chart/src/core/render.ts @@ -5,6 +5,8 @@ import { applyOrganizationStyle, organizationStylePresets } from './style-preset import { getDepartmentCardAsset } from './department-card-assets.js'; import type { OrgChartOrganizationLinks } from './types.js'; import type { CompanyColorPreset } from '../company-color-presets.js'; +import { decorateExpandedNode } from './expanded-content.js'; +import type { OrgChartConnectionDirection, OrgChartTopNode } from './types.js'; const organizationNames = { root: '최상위 조직', division: '부문', office: '실', div: 'Div', cell: '셀', team: '팀' } as const; @@ -19,6 +21,11 @@ export interface RenderContext { organizationLinks?: OrgChartOrganizationLinks; /** Only the Expanded presenter supplies company header colors. */ expandedCompanyPreset?: CompanyColorPreset; + organizationCompanies?: ReadonlyMap; + collapsedOrganizations?: ReadonlySet; + companyTopNodes?: Readonly>; + employeeConnections?: Readonly>; + toggleExpanded?: (organizationId: string) => void; } export function engineTree(roots: OrganizationTreeNode[], prefix: string, context: RenderContext, startDepth = 0): RenderEngineNode[] { @@ -28,7 +35,7 @@ export function engineTree(roots: OrganizationTreeNode[], prefix: string, contex context.orderedOrganizations.push(tree.organization); return { id: `${prefix}-org-${index}`, name: '', organizationIndex: index, depth, groupVariant: variant, - ...(tree.children.length ? { children: tree.children.map((child, childIndex) => convert(child, depth + 1, context.readOnly ? variant : depth === startDepth ? childIndex % 6 : variant)) } : {}), + ...(tree.children.length && !context.collapsedOrganizations?.has(tree.organization.id) ? { children: tree.children.map((child, childIndex) => convert(child, depth + 1, context.readOnly ? variant : depth === startDepth ? childIndex % 6 : variant)) } : {}), }; } // Multiple actual roots stay independent; no fictitious organizational parent. @@ -48,6 +55,10 @@ export function decorateNode( ): void { const organization = context.orderedOrganizations[Number(value.organizationIndex)]; if (!organization) return; + if (context.expandedCompanyPreset) { + decorateExpandedNode(node, value, context, signal); + return; + } const document = node.ownerDocument; const card = node.querySelector('.hm-orgchart-organization')!; node.dataset.organizationId = organization.id; @@ -64,14 +75,6 @@ export function decorateNode( } node.dataset.organizationDepth = String(organizationDepth); applyOrganizationStyle(node, Number(value.groupVariant)); - if (context.expandedCompanyPreset) { - const preset = context.expandedCompanyPreset; - node.dataset.companyId = preset.companyId; - node.style.setProperty('--hm-orgchart-current-gradient', preset.primary); - node.style.setProperty('--hm-orgchart-expanded-primary', preset.primary); - // Preserve every existing secondary value for the later Expanded theme. - preset.secondary.forEach((color, index) => node.style.setProperty(`--hm-orgchart-expanded-secondary-${index + 1}`, color)); - } node.dataset.organizationType = organization.type; // Header role is styling-only: depth 0-1 are organization titles, deeper nodes are cell/team headers. node.dataset.headerRole = (context.readOnly ? organizationDepth : Number(value.depth)) <= 1 ? 'organization' : 'group'; diff --git a/packages/org-chart/src/core/types.ts b/packages/org-chart/src/core/types.ts index b73781a..9bb4d38 100644 --- a/packages/org-chart/src/core/types.ts +++ b/packages/org-chart/src/core/types.ts @@ -10,13 +10,25 @@ export type OrgChartState = * controls or development text, compact summaries on a fitted design canvas. */ export type OrgChartDisplayMode = 'interactive' | 'readOnly'; -/** Layout is independent of displayMode. Auto currently exposes only the - * P5-1A initial groups; recursive placement is reserved for P5-1B. */ +/** Layout is independent of displayMode. Auto uses the complete hierarchy + * with natural overflow; Fixed preserves the Workdesk composition. */ export type OrgChartLayoutMode = 'fixed' | 'auto'; /** Links are keyed by the existing organization ID. Only 1Depth titles use them. */ export type OrgChartOrganizationLinks = Readonly>; +export type OrgChartConnectionDirection = 'top' | 'left' | 'right'; +export interface OrgChartTopNode { + /** Defaults to the root's leaderId. null removes its top card. Must belong to that root. */ + employeeId?: string | null; + title?: string; + name?: string; + profileImage?: string | null; + organizationName?: string; + display?: 'card' | 'titleOnly' | 'hidden'; +} +export interface OrgChartViewportState { scale: number; x: number; y: number } + export interface OrgChartEmployeeClickContext { readonly organization: Organization | undefined; readonly affiliation: Affiliation | undefined; @@ -38,6 +50,12 @@ export interface OrgChartOptions { /** Expanded-only company preset ID. Defaults to hanmac; ignored by Fixed. * Unknown IDs are rejected in Auto rather than silently replacing colors. */ companyId?: string; + /** Auto only. undefined means all companies; [] is an explicit empty selection. */ + selectedCompanyIds?: readonly string[]; + companyTopNodes?: Readonly>; + /** Actual staff connector route, default top. Leaders always connect from above. */ + employeeConnections?: Readonly>; + zoom?: { min?: number; max?: number; step?: number }; /** No entry (or an empty URL) keeps an ordinary title. HTTP(S) and relative URLs are supported. */ organizationLinks?: OrgChartOrganizationLinks; /** Optional host-owned information UI. When supplied, it replaces the default read-only dialog. @@ -54,4 +72,11 @@ export interface OrgChartHandle { destroy(): void; subscribe(listener: (state: OrgChartState) => void): () => void; getState(): OrgChartState; + /** Auto view state is separate from the immutable source. Fixed rejects selection changes. */ + setSelectedCompanies(ids?: readonly string[]): Promise; + getSelectedCompanies(): readonly string[]; + zoomTo(scale: number): void; + panBy(x: number, y: number): void; + fitToView(): void; + getViewportState(): OrgChartViewportState | undefined; } diff --git a/packages/org-chart/src/data/types.ts b/packages/org-chart/src/data/types.ts index 933b808..7b3c79b 100644 --- a/packages/org-chart/src/data/types.ts +++ b/packages/org-chart/src/data/types.ts @@ -6,6 +6,8 @@ export type RelationType = 'cooperation' | 'support'; export interface Organization { id: ID; + /** Auto company identity. Descendants inherit it; legacy datasets may omit it. */ + companyId?: ID; parentId: ID | null; name: string; type: OrganizationType; diff --git a/packages/org-chart/src/data/validate.ts b/packages/org-chart/src/data/validate.ts index fdc4509..510315c 100644 --- a/packages/org-chart/src/data/validate.ts +++ b/packages/org-chart/src/data/validate.ts @@ -65,7 +65,8 @@ export function validateOrgChartDataset(input: unknown): ValidationResult { }; if (!object(input, '$', ['schemaVersion', 'organizations', 'employees', 'affiliations', 'relations', 'presentation', 'metadata'])) return { valid: false, issues }; if (input.schemaVersion !== SCHEMA_VERSION) issue('SCHEMA_VERSION', '$.schemaVersion', 'Unsupported or missing schema version.'); - collection(input.organizations, '$.organizations', ['id', 'parentId', 'name', 'type', 'leaderId', 'displayOrder'], (org, path) => { + collection(input.organizations, '$.organizations', ['id', 'companyId', 'parentId', 'name', 'type', 'leaderId', 'displayOrder'], (org, path) => { + if (org.companyId !== undefined) text(org.companyId, `${path}.companyId`); text(org.parentId, `${path}.parentId`, true); text(org.name, `${path}.name`); text(org.leaderId, `${path}.leaderId`, true); choice(org.type, `${path}.type`, organizationTypes, 'INVALID_TYPE'); if (!Number.isSafeInteger(org.displayOrder) || (org.displayOrder as number) < 0) issue('REQUIRED_VALUE', `${path}.displayOrder`, 'Expected a non-negative safe integer.'); diff --git a/packages/org-chart/src/index.ts b/packages/org-chart/src/index.ts index 65835a9..43179ed 100644 --- a/packages/org-chart/src/index.ts +++ b/packages/org-chart/src/index.ts @@ -2,6 +2,6 @@ export * from './data/index.js'; export { companyColorPresets, getCompanyColorPreset } from './company-color-presets.js'; export type { CompanyColorPreset, CompanyId } from './company-color-presets.js'; export { createOrgChart } from './core/index.js'; -export type { OrgChartDisplayMode, OrgChartLayoutMode, OrgChartEmployeeClickContext, OrgChartHandle, OrgChartOptions, OrgChartOrganizationLinks, OrgChartState } from './core/types.js'; +export type { OrgChartConnectionDirection, OrgChartTopNode, OrgChartViewportState, OrgChartDisplayMode, OrgChartLayoutMode, OrgChartEmployeeClickContext, OrgChartHandle, OrgChartOptions, OrgChartOrganizationLinks, OrgChartState } from './core/types.js'; export { createOrgChartTree, createPositionSummary, createAffiliationSummary, createStatusSummary } from './components.js'; export type { OrgChartSummaryComponentOptions } from './components.js'; diff --git a/packages/org-chart/src/styles/org-chart-expanded.css b/packages/org-chart/src/styles/org-chart-expanded.css index e2738ef..139c732 100644 --- a/packages/org-chart/src/styles/org-chart-expanded.css +++ b/packages/org-chart/src/styles/org-chart-expanded.css @@ -1,92 +1,195 @@ -/* P5-1A flow preview only. Every rule is under the new presenter namespace; - the fixed Workdesk composition and approved card internals stay unchanged. - Ordinary flex wrapping is not the P5-1B automatic hierarchy layout. */ +/* Auto-only layout. P5-4 retains the current card geometry by user decision; + no external SVG reconstruction is required for this presentation. */ .hm-orgchart-expanded { + --hm-orgchart-expanded-sibling-gap: var(--hm-orgchart-resolved-spacing-xxl); + --hm-orgchart-expanded-level-gap: calc(2 * var(--hm-orgchart-resolved-spacing-xxl)); + --hm-orgchart-expanded-title-gap: var(--hm-orgchart-resolved-spacing-md); + --hm-orgchart-expanded-employee-gap: var(--hm-orgchart-resolved-spacing-md); + --hm-orgchart-expanded-line-width: var(--hm-orgchart-resolved-border-width); display: grid; grid-template-columns: minmax(0, 1fr); - gap: var(--hm-orgchart-resolved-spacing-sm); + gap: var(--hm-orgchart-resolved-spacing-lg); width: 100%; min-width: 0; padding: var(--hm-orgchart-resolved-spacing-lg); background: var(--hm-orgchart-resolved-bg-content); } .hm-orgchart-expanded .hm-orgchart-heading { margin: 0; } -.hm-orgchart-expanded .hm-orgchart-expanded-stage-note { - margin: 0; - color: var(--hm-orgchart-resolved-fg-primary); - font-weight: var(--hm-orgchart-resolved-font-weight-bold); -} -.hm-orgchart-expanded .hm-orgchart-expanded-description { - margin: 0; - color: var(--hm-orgchart-resolved-fg-secondary); - overflow-wrap: anywhere; -} -.hm-orgchart-expanded .hm-orgchart-expanded-top-leaders { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: var(--hm-orgchart-resolved-spacing-lg); - padding: var(--hm-orgchart-resolved-spacing-lg) 0; -} .hm-orgchart-expanded .hm-orgchart-viewport { - padding: var(--hm-orgchart-resolved-spacing-lg); - overflow: auto; + position: relative; + height: clamp(320px, 65vh, 800px); + padding: 0; + overflow: hidden; + touch-action: none; + cursor: grab; background: var(--hm-orgchart-resolved-bg-surface); } .hm-orgchart-expanded .hm-orgchart-forest { - display: flex; - flex-wrap: wrap; + position: absolute; + top: 0; + left: 0; + width: max-content; + min-width: 0; + transform-origin: 0 0; + justify-content: flex-start; align-items: flex-start; - justify-content: center; - gap: var(--hm-orgchart-resolved-spacing-xxl); - width: 100%; - min-width: 0; + gap: var(--hm-orgchart-expanded-sibling-gap); } -.hm-orgchart-expanded .hm-orgchart-tree { flex: 0 0 auto; } -.hm-orgchart-expanded .hm-orgchart-engine.orgchart { - min-width: 0; - padding: 0; +.hm-orgchart-expanded .hm-orgchart-engine.orgchart { min-width: 0; padding: 0; } +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth]) > .nodes { + flex-direction: row; + align-items: flex-start; } .hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] { + vertical-align: top; width: max-content; - min-width: 146px; - margin: 0; + min-width: var(--hm-orgchart-resolved-card-width-leader); + margin: 0 calc(var(--hm-orgchart-expanded-sibling-gap) / 2) var(--hm-orgchart-expanded-level-gap); } -.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-organization { +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] > .hm-orgchart-organization { display: flex; flex-direction: column; align-items: center; - width: auto; + gap: var(--hm-orgchart-expanded-title-gap); + width: max-content; + min-width: var(--hm-orgchart-resolved-card-width-leader); padding: 0; border: 0; border-radius: 0; background: none; box-shadow: none; + position: relative; } -/* Same approved 1Depth title geometry; color resolves from the auto-only - company variables supplied by the shared node decorator. */ .hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-organization-name { align-self: stretch; - min-width: 146px; - height: 36px; - min-height: 36px; margin: 0; - padding: 0 12px; - background: var(--hm-orgchart-current-gradient); - color: var(--hm-orgchart-resolved-on-action-primary); - font-size: 20px; - font-weight: 700; - line-height: 36px; + height: auto; + min-height: 0; + padding: 0; + background: var(--hm-orgchart-expanded-primary); + color: var(--hm-orgchart-expanded-title-ink); + font-size: var(--hm-orgchart-resolved-font-size-base); + font-weight: var(--hm-orgchart-resolved-font-weight-bold); + line-height: 1.5; white-space: nowrap; } -.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee { margin: 0; } -.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee.hm-orgchart-leader { margin: 8px 0 0; } +.hm-orgchart-expanded .hm-orgchart-organization-link { color: inherit; } .hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employees { display: flex; flex-direction: column; - flex-wrap: nowrap; align-items: center; - gap: 8px; - margin-top: 8px; + gap: var(--hm-orgchart-expanded-employee-gap); + margin: 0; } -.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employees:empty { display: none; } +/* Shared neutral cards, isolated from Fixed asset backgrounds and geometry. */ +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-card-presentation="expanded"] { + margin: 0; + box-sizing: border-box; + border: var(--hm-orgchart-expanded-card-border-width, var(--hm-orgchart-resolved-border-width)) solid var(--hm-orgchart-resolved-stroke-default); + background: var(--hm-orgchart-resolved-bg-surface); + color: var(--hm-orgchart-resolved-fg-primary); +} +.hm-orgchart-expanded .hm-orgchart-employee[data-card-presentation="expanded"]::before { content: none; } +.hm-orgchart-expanded .hm-orgchart-employee[data-card-presentation="expanded"] .hm-orgchart-avatar { border-radius: 0; } +.hm-orgchart-expanded .hm-orgchart-person-text { display: flex; flex-direction: column; gap: 0; } +.hm-orgchart-expanded .hm-orgchart-employee::after { content: none; } +/* Keep dabeng's first/last/only-child horizontal branch geometry. Only change + connector length and offsets to agree with the Auto vertical gap. */ +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth]) > .nodes > .hierarchy::before { + top: calc(-1 * var(--hm-orgchart-expanded-level-gap) / 2); + left: 0; + width: 100%; + height: 0; + border-width: var(--hm-orgchart-expanded-line-width) 0 0; +} +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth]) > .nodes > .hierarchy:first-child::before { left: calc(50% - 1px); width: calc(50% + 1px); } +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth]) > .nodes > .hierarchy:last-child::before { width: calc(50% + 1px); } +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth]) > .nodes > .hierarchy:only-child::before { left: calc(50% - 1px); width: var(--hm-orgchart-expanded-line-width); } +.hm-orgchart-expanded .hm-orgchart-engine.orgchart > ul > li > ul li > .node::before { + top: calc(-1 * var(--hm-orgchart-expanded-level-gap) / 2); + height: calc(var(--hm-orgchart-expanded-level-gap) / 2); + width: var(--hm-orgchart-expanded-line-width); +} +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .hierarchy:not(.couple) > .node:has(+ .nodes)::after { + bottom: calc(-1 * var(--hm-orgchart-expanded-level-gap) / 2); + height: calc(100% - var(--hm-orgchart-expanded-content-height) + var(--hm-orgchart-expanded-level-gap) / 2); + width: var(--hm-orgchart-expanded-line-width); +} + +/* Standalone content variables, independent of Fixed and HM React controls. */ +.hm-orgchart-expanded { + --hm-orgchart-expanded-card-border-width: 1px; + --hm-orgchart-expanded-card-accent-width: 3px; + --hm-orgchart-expanded-card-padding: 8px; + --hm-orgchart-expanded-card-radius: var(--hm-orgchart-resolved-radius-container); + --hm-orgchart-expanded-photo-size: 36px; + --hm-orgchart-expanded-name-size: 13px; + --hm-orgchart-expanded-position-size: 10px; + --hm-orgchart-expanded-name-weight: var(--hm-orgchart-resolved-font-weight-bold); + --hm-orgchart-expanded-person-gap: 0px; + --hm-orgchart-expanded-title-padding: var(--hm-orgchart-resolved-spacing-sm) var(--hm-orgchart-resolved-spacing-md); + --hm-orgchart-expanded-title-radius: var(--hm-orgchart-resolved-radius-control-md); +} +.hm-orgchart-expanded .hm-orgchart-organization-name { display: flex; border-radius: var(--hm-orgchart-expanded-title-radius); } +.hm-orgchart-expanded-toggle { + flex: 1; border: 0; background: none; color: inherit; font: inherit; + cursor: pointer; padding: var(--hm-orgchart-expanded-title-padding); white-space: nowrap; + border-radius: inherit; +} +.hm-orgchart-expanded-toggle:not(:disabled):hover, +.hm-orgchart-expanded-link:hover { background: color-mix(in srgb, currentColor 12%, transparent); } +.hm-orgchart-expanded-toggle:disabled { cursor: default; opacity: 0.75; } +.hm-orgchart-expanded-link { display: inline-flex; align-items: center; padding-inline: var(--hm-orgchart-resolved-spacing-sm); color: inherit; } +.hm-orgchart-expanded-toggle:focus-visible, .hm-orgchart-expanded-link:focus-visible, +.hm-orgchart-expanded-toolbar button:focus-visible { + outline: 3px solid var(--hm-orgchart-resolved-action-primary); outline-offset: 3px; +} +.hm-orgchart-expanded-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--hm-orgchart-resolved-spacing-sm); } +.hm-orgchart-expanded-toolbar button { + font: inherit; padding: var(--hm-orgchart-resolved-spacing-sm) var(--hm-orgchart-resolved-spacing-md); + border: var(--hm-orgchart-resolved-border-width) solid var(--hm-orgchart-resolved-stroke-default); + border-radius: var(--hm-orgchart-resolved-radius-control-md); background: var(--hm-orgchart-resolved-bg-surface); + color: var(--hm-orgchart-resolved-fg-primary); cursor: pointer; +} +.hm-orgchart-expanded-toolbar button:disabled { opacity: 0.5; cursor: default; } +.hm-orgchart-expanded-toolbar span { color: var(--hm-orgchart-resolved-fg-secondary); font-size: var(--hm-orgchart-resolved-font-size-meta); } +.hm-orgchart-expanded-selection, .hm-orgchart-top-node-title { margin: 0; } +.hm-orgchart-expanded-empty { padding: var(--hm-orgchart-resolved-spacing-lg); } +.hm-orgchart-expanded .hm-orgchart-viewport[data-dragging] { cursor: grabbing; user-select: none; } +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] { cursor: default; } +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employees[data-connection-direction="left"], +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employees[data-connection-direction="right"] { + padding-inline: var(--hm-orgchart-resolved-spacing-xxl); +} +.hm-orgchart-expanded .hm-orgchart-employee-connectors { position: absolute; inset: 0; overflow: visible; pointer-events: none; } +.hm-orgchart-expanded .hm-orgchart-employee-connectors path { fill: none; stroke: var(--hm-orgchart-resolved-stroke-default); stroke-width: var(--hm-orgchart-expanded-line-width); } +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-card-presentation="expanded"] { + padding: var(--hm-orgchart-expanded-card-padding); + border-radius: var(--hm-orgchart-expanded-card-radius); + width: var(--hm-orgchart-expanded-card-width, var(--hm-orgchart-resolved-card-width-employee)); + min-width: 0; + min-height: var(--hm-orgchart-expanded-card-height, var(--hm-orgchart-resolved-card-height-employee)); +} +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-leader[data-card-presentation="expanded"] { + width: var(--hm-orgchart-expanded-leader-width, var(--hm-orgchart-resolved-card-width-leader)); + min-height: var(--hm-orgchart-expanded-leader-height, var(--hm-orgchart-resolved-card-height-leader)); +} +/* Inset accents preserve all outer dimensions even when accent width changes. */ +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-card-presentation="expanded"][data-connection-direction="top"] { + box-shadow: inset 0 var(--hm-orgchart-expanded-card-accent-width) 0 var(--hm-orgchart-expanded-card-primary); + border-top-color: var(--hm-orgchart-expanded-card-primary); +} +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-card-presentation="expanded"][data-connection-direction="left"] { + box-shadow: inset var(--hm-orgchart-expanded-card-accent-width) 0 0 var(--hm-orgchart-expanded-card-primary); + border-left-color: var(--hm-orgchart-expanded-card-primary); +} +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-card-presentation="expanded"][data-connection-direction="right"] { + box-shadow: inset calc(-1 * var(--hm-orgchart-expanded-card-accent-width)) 0 0 var(--hm-orgchart-expanded-card-primary); + border-right-color: var(--hm-orgchart-expanded-card-primary); +} +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee .hm-orgchart-avatar { + width: var(--hm-orgchart-expanded-photo-size); height: var(--hm-orgchart-expanded-photo-size); flex-basis: var(--hm-orgchart-expanded-photo-size); +} +.hm-orgchart-expanded .hm-orgchart-person-text { gap: var(--hm-orgchart-expanded-person-gap); } +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-person-name { font-size: var(--hm-orgchart-expanded-name-size); font-weight: var(--hm-orgchart-expanded-name-weight); } +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-person-role { font-size: var(--hm-orgchart-expanded-position-size); } diff --git a/src/org-chart/org-chart-expanded.stories.tsx b/src/org-chart/org-chart-expanded.stories.tsx index af14348..a0942f6 100644 --- a/src/org-chart/org-chart-expanded.stories.tsx +++ b/src/org-chart/org-chart-expanded.stories.tsx @@ -1,35 +1,50 @@ import { StrictMode, useId, useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@hm-design/ui'; +import { Button, Checkbox, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@hm-design/ui'; import { OrgChart } from '@hm-design/org-chart-react'; -import { companyColorPresets, getCompanyColorPreset, JsonOrgChartDataSource } from '@hm-design/org-chart'; +import { companyColorPresets, JsonOrgChartDataSource, type OrgChartConnectionDirection } 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 expandedFixture from '../../examples/org-chart/data-expanded.json'; +import small from '../../examples/org-chart/data-expanded-small.json'; +import medium from '../../examples/org-chart/data-expanded-medium.json'; +import large from '../../examples/org-chart/data-expanded-large.json'; import './org-chart-stories.css'; -const expandedSource = new JsonOrgChartDataSource(expandedFixture); +const fixtures = { small, medium, large }; +const sources = { small: new JsonOrgChartDataSource(small), medium: new JsonOrgChartDataSource(medium), large: new JsonOrgChartDataSource(large) }; const compositionStylesheets = { engine: engineUrl, core: coreUrl }; +const topNodes = { ptc: { title: '기술총괄', name: '가상책임자 5' } }; +const connections: Record = Object.fromEntries(large.organizations.map((org, index) => [org.id, (['top', 'left', 'right'] as const)[index % 3]! ])); -/** P5-1A establishes the Core rendering boundary and initial 1Depth groups. - * Automatic hierarchy placement belongs to P5-1B; this host owns no layout. */ +/** Company state is passed to the shared Core without filtering/mutating JSON. */ function ExpandedPreview() { const id = useId(); - const [companyId, setCompanyId] = useState('hanmac'); - const preset = getCompanyColorPreset(companyId); - return
-

P5-1A · 확장형 조직도 초기 화면 — 대표자와 예시 1Depth 그룹, 기존 카드 및 회사 컬러 프리셋을 표시합니다. 자동 계층 배치·재계산은 P5-1B, 접기·펼치기와 Zoom/Pan은 후속 작업입니다. 모든 직원 정보는 가상 데이터입니다.

+ const [size, setSize] = useState('small'); + const [selected, setSelected] = useState(); + const companies = fixtures[size].affiliations; + const visible = selected ?? companies.map(company => company.id); + return
+

확장형 조직도 — 회사별 독립 조직 · 타이틀 클릭으로 접기·펼치기 · 확대 및 드래그 이동. 모든 직원은 가상 데이터입니다.

- - { setSize(value as keyof typeof fixtures); setSelected(undefined); }}> + + Small · 20명Medium · 100명Large · 300명
+ +
- {preset &&

{preset.companyName} · Main {preset.primary} · 보조 컬러 {preset.secondary.length ? preset.secondary.join(', ') : '등록 없음'}

} - +
+ 표시할 회사 · 복수 선택 가능 + {companies.map(company =>
+ setSelected(checked === true ? [...visible, company.id] : visible.filter(value => value !== company.id))} /> + + {companyColorPresets[company.id as keyof typeof companyColorPresets].primary} +
)} +
+
; } diff --git a/src/org-chart/org-chart-stories.css b/src/org-chart/org-chart-stories.css index 5eefff5..596abfc 100644 --- a/src/org-chart/org-chart-stories.css +++ b/src/org-chart/org-chart-stories.css @@ -37,6 +37,10 @@ body:has(.hm-orgchart-story-full) { background: #F5F8FB; } max-width: 100%; } +.hm-orgchart-story-companies { display: flex; flex-wrap: wrap; gap: 1rem; border: 0; padding: 0; min-width: 0; } +.hm-orgchart-story-companies legend { margin-bottom: 0.5rem; } +.hm-orgchart-story-company { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; } + .hm-orgchart-story-chart { width: 100%; }