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
+ 확장형 조직도 — 회사별 독립 조직 · 타이틀 클릭으로 접기·펼치기 · 확대 및 드래그 이동. 모든 직원은 가상 데이터입니다.
-
-
+
+
- {preset && {preset.companyName} · Main {preset.primary} · 보조 컬러 {preset.secondary.length ? preset.secondary.join(', ') : '등록 없음'}
}
-
+
+
;
}
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%;
}