diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index cf27174..6f1f425 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -43,6 +43,8 @@ const preview: Preview = { ], "Patterns", ["Data", ["Data Table", "Filter Bar"], "Layout", ["GNB", "LNB", "Dashboard Layout", "Loading Screen"]], + "OrgChart", + ["Overview", "Department Colors", "Full Composition", "Expanded OrgChart"], "Center Graphic", "*", ], diff --git a/AGENTS.md b/AGENTS.md index 77c5994..ad5ad15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,13 @@ 이 파일을 먼저 읽고 필요한 항목만 탐색한다. 전체 문서·전체 소스·전체 Storybook을 반복해서 읽지 않는다. +## AI 조직도 생성 — 공통 정책 + +- OrgChart UI 수정 또는 AI 생성 작업은 [최종 디자인 승인 기록](docs/org-chart/design-approval.md)을 먼저 확인해 승인된 기본 디자인을 따른다. 기본값 변경·외부 테마 커스터마이징·데이터 생성의 구분과 접근성 미해결 상태는 해당 문서를 따른다. +- 자연어 또는 JSON·CSV·Excel에서 **새 조직도 컴포넌트 또는 전체 페이지를 생성하는 작업**은 먼저 [AI 조직도 자동 생성 정책](docs/org-chart/ai-page-generation-rules.md)을 읽고 적용한다. 생성 방식 미지정 시 코드 작성 전에 위 정책의 사전 선택 질문으로 Chart Only / Full Page를 확인하고 답변 후 구현한다. 이미 명시한 방식은 다시 묻지 않는다. 회사·조직 유형·Depth·인원수를 특정 사례의 기본값으로 고정하지 않는다. +- **Chart Only**는 기존 페이지에 삽입 가능한 독립 컴포넌트로 흰색 캔버스·조직도 노드·연결선, 최초 Fit to View·마우스 휠 확대/축소·Pan을 제공하며 외부 제목·통계·설명·조작 패널을 생성하지 않는다. **Full Page**는 기존 HM UI를 우선 재사용해 요청된 제목·정보·필터·컨트롤과 전체 배치·반응형을 구성한다. 두 방식 모두 기존 OrgChart Core·React Wrapper와 정확한 요청 구조·인원·데이터를 사용한다. Fixed·Auto/Expanded는 생성 방식 선택을 대신하지 않는다. Chart Only에는 아래 일반 업무 화면의 Navigation 의무 대신 해당 정책의 최소 화면 규칙을 적용하고, Full Page에는 기존 HM 화면 계약을 적용한다. 공개 API·Theme·React·접근성·검증 규칙은 계속 적용한다. +- 기존 Full Composition·Expanded OrgChart·Department Colors·Overview·P9.5·P-AI1에는 소급 적용하지 않는다. 일반 업무 화면과 명시적인 기존 화면 수정에는 기존 규칙 및 해당 요청 범위를 유지한다. + 1. 범위 `STORYBOOK_ONLY | PRODUCTION_UI | BOTH`, Runtime, 요구사항·필수 동작·완료 조건을 확인한다. 신규 AI 업무 화면은 실제 HM React 컴포넌트를 사용한다. 명시적 다른 Runtime adapter는 [상세 계약](docs/guidelines/runtime-portability.md)을 따른다. 2. [기능별 선택·완료 계약](docs/guidelines/ai-screen-contract.md) → [Component / Pattern 인덱스](docs/guidelines/component-pattern-reference.md) → 선택한 문서·대표 All Story·필요한 Props 순으로 읽는다. Import는 `@hm-design/ui` 공개 API가 기본이다. 의존성은 package.json의 exports/types와 실제 파일 목록을 확인하며 내부 src/dist 경로를 추측하지 않는다. verification Story는 운영 기준이 아니다. 3. 요구 기능에 맞는 기존 Pattern을 먼저 확인한 뒤 기존 Component → Variant/Prop → Composition 순으로 재사용한다. 목록 검색은 [FilterBar](docs/patterns/filter-bar.md)를 Table 유무와 관계없이 검토한다. 미사용 시 요구사항과 맞지 않는 API·구조 제약을 기록하며 강제하지 않는다. 기본 HTML·임의 CSS·`data-slot`으로 HM을 모방하지 않는다. 재사용은 import가 아닌 실제 렌더링으로 확인한다. diff --git a/CLAUDE.md b/CLAUDE.md index 0e93b39..3f16522 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,8 @@ shadcn/ui(Radix, Tailwind v4, CVA) 기반 HM 디자인 시스템 시제품. Reac 규칙의 기준은 아래 문서이며, 이 파일에는 규칙 본문을 복사하지 않는다. 작업 전에 해당 문서를 읽는다. - `AGENTS.md` — AI Implementation Defaults와 Navigation 필수 요약. 기존 Component / Storybook Reference 우선, Theme / Semantic 기본 적용, 단일 Navigation, 최소 Custom은 사용자의 별도 지시 없이 모든 신규·수정 UI에 적용한다. +- [최종 디자인 승인 기록](docs/org-chart/design-approval.md) — OrgChart UI 수정 또는 AI 생성 작업 전에 먼저 확인하고 승인된 기본 디자인을 따른다. 기본값 변경·외부 테마 커스터마이징·데이터 생성의 구분과 접근성 미해결 상태는 해당 문서를 따른다. +- [AI 조직도 자동 생성 정책](docs/org-chart/ai-page-generation-rules.md) — 자연어 또는 JSON·CSV·Excel에서 새 조직도 컴포넌트 또는 전체 페이지를 생성할 때 먼저 읽는다. 생성 방식 미지정 시 코드 작성 전에 위 정책의 사전 선택 질문으로 Chart Only / Full Page를 확인하고 답변 후 구현한다. 이미 명시한 방식은 다시 묻지 않는다. Chart Only는 흰색 캔버스·노드·연결선의 삽입용 컴포넌트, 최초 Fit·휠 Zoom·Pan과 외부 UI 제외를 따르고, Full Page는 기존 HM UI로 요청 UI·전체 배치·반응형을 구성한다. 두 방식 모두 가변 계층·정확한 인원·제공 데이터 보존·기존 Core/React 재사용을 따른다. Fixed·Auto/Expanded는 생성 방식 선택을 대신하지 않는다. Chart Only의 Navigation 예외와 Full Page의 HM 화면 계약은 `AGENTS.md`를 따른다. 기존 Full Composition·Expanded OrgChart·Department Colors·Overview·P9.5·P-AI1에는 소급 적용하지 않는다. - 일반 업무 화면은 Storybook에 등록하지 않고 별도 React 화면·독립 미리보기로 제공한다. Storybook 수정은 Component/Pattern 또는 명시적 Storybook Reference 작업에 한정한다. 상세한 Storybook / 화면 생성 분리는 `AGENTS.md`와 `docs/guidelines/shadcn-기반-구현-원칙.md`를 따른다. - `docs/guidelines/프로젝트-지침.md` — §6 컴포넌트·Storybook·CSS 작업 규칙 (모든 컴포넌트 작업 전 필독) - `docs/guidelines/shadcn-기반-구현-원칙.md` — Component / Pattern / Story의 재사용 우선순위와 Custom 구현 허용 조건 (관련 작업 전 필독) diff --git a/docs/guidelines/ai-generation-guard.md b/docs/guidelines/ai-generation-guard.md index 68b95b4..5808868 100644 --- a/docs/guidelines/ai-generation-guard.md +++ b/docs/guidelines/ai-generation-guard.md @@ -1,5 +1,13 @@ # AI Generation Guard — 경량 검증 +## OrgChart 공개 CSS 선언 수집 (P11, 2026-10-13) + +`guard-theme-analysis.mjs`는 연결된 `@hm-design/org-chart/style.css` 및 `style.css?url`의 공개 source CSS와 상대 import에서 선언명을 수집한다. OrgChart는 URL 값을 `stylesheetUrls.core`로 Shadow DOM에 넣으므로 일반 CSS entry와 구분한다. 이 수집은 **공개 변수 선언의 존재**를 확인하며, 실제 link 로딩·selector 우선순위·computed style을 증명하지 않는다. 실제 적용은 별도 브라우저 검증이 필요하다. + +임의 `?url`/`?raw`/`?inline` CSS, 연결되지 않은 OrgChart 패키지, 다른 Story의 CSS를 선언 공급자로 사용하지 않는다. `setProperty`를 무조건 선언으로 인정하거나 미선언 HM 변수를 fallback으로 PASS 처리하지 않는다. OrgChart `current-*` 회사/부서 계산 변수는 실제 사용 범위의 CSS에 명시적 기본값을 둔다. 외부 입력 변수는 조상 상속을 막지 않도록 내부 요소에 기본 선언하지 않고 기존 `resolved-*` fallback 경로를 유지한다. + +이 변경은 UI/Status CSS 매핑과 기존 검사 판정·exit 규칙을 유지한다. P11 검증은 기존 Guard 94개 + 별도 `guard-orgchart-theme.test.mjs` 5개 PASS이며 다른 UI/CENTER/Foundation/Pattern의 67개 기존 이슈가 동일함을 확인했다. 과거 검증 기록과 미검증 항목은 보존한다. [P11 결과](../org-chart/implementation-report-p11.md)에서 기존 TOKEN-001 5건의 원본 재현과 FAIL 0 결과를 구분한다. + ## 별도 요구사항 액션 검사 `node scripts/guard-requirements.mjs `는 원본 Guard의 status/issues를 보존하고 별도 판정을 출력한다. 정책 입력 예시는 `{"schemaVersion":1,"actionPolicy":{"allowedLabels":["조회","초기화"]}}`다. 실제 요구사항에서 허용한 소비 화면의 Button/button/link 이름만 작성하며, Component 기본 동작을 검사하는 경우 그 이름도 명시한다. 허용하지 않은 정적 이름(예: 미요청 CSV 다운로드)은 REQ-003 FAIL, 동적/복합 이름·spread props·오래된 source/contract는 NOT VERIFIED다. diff --git a/docs/org-chart/README.md b/docs/org-chart/README.md new file mode 100644 index 0000000..8f56b07 --- /dev/null +++ b/docs/org-chart/README.md @@ -0,0 +1,91 @@ +# HMDesign OrgChart — 개발 전 설계 기준 + +> 현재 P1 기준(2026-10-12): 공통 HM Theme와 분리된 독립 디자인을 사용한다. [개별 컴포넌트·스타일 API](standalone-components-p1.md)와 [구현·검증 결과](implementation-report-p1.md)를 따른다. 아래 초기 설계와 v0.1 보고서는 과거 기록이며 Theme 필수 연결·reserved hub/status는 최신 사용법이 아니다. + +상태: **문서 초안 / 사용자 검토 대기 / 구현 미착수**. 기준일: 2026-10-12(KST). + +확정 요구사항은 이번 사용자 요청에서 확정된 범위다. `제안`은 승인 전 설계 후보이며, `TBD`는 정책 또는 기술 검토가 필요한 항목이다. 문서 검증의 PASS는 실행 기능의 PASS를 의미하지 않는다. + +## 1. 목적과 문서 읽기 순서 + +여러 업무 화면의 콘텐츠 컨테이너에서 재사용할 Framework-independent OrgChart를 설계한다. 1차 공식 개발 대상은 **Vanilla HTML/JavaScript와 React 두 환경**이다. 표준 DOM API와 JavaScript/TypeScript 기반 Core를 우선하고 React는 별도 Wrapper로 연결한다. Core는 framework runtime과 HM React Component를 직접 import하지 않는다. 특정 Dashboard, Header 높이 또는 Navigation에 종속시키지 않는다. JSON과 향후 REST API가 같은 계약을 제공하며 각 환경의 공개 사용 방식은 유지해야 한다. + +1. 이 문서: 확정 범위와 기존 기준과의 차이. +2. [Architecture](architecture.md): 계층별 책임, 의존성, 공개 사용 방식 초안. +3. [Data Contract](data-contract.md): 정규화 계약, TypeScript·JSON Schema 초안, 검증·집계. +4. [UI Composition](ui-composition.md): 콘텐츠 구성, HM 재사용, 컨테이너·접근성. +5. [Integration](integration.md): 엔진 공식 근거, React·Theme 연동, JSON → API 교체. +6. [Implementation Plan](implementation-plan.md): 단계별 완료 조건, Storybook, TBD·리스크, 이번 문서 검증 기록. +7. [Framework Integration](framework-integration.md): Vanilla·React 사용 계약과 예시, 생명주기, CSS·Theme·배포 검증, 미지원 환경. + +## 2. 작업 환경 확인 + +읽기 전용 확인 결과, 다음 환경이 존재한다. + +- 실제 Git Root·작업 경로: `D:\디자인시스템\HMDesign-OrgChart`. +- 현재 브랜치: `feature/orgchart-package`. +- 시작 HEAD: `d18e3c33801e165e17b6d971e2f6198eed483c3c`. +- 공식 origin(fetch/push): `https://gitea.hmac.kr/b25027/HMDesign.git`. +- 운영 Worktree: `D:\디자인시스템\HMDesign`의 `main`. 기존 Worktree를 재사용하며 새 Worktree는 생성하지 않았다. +- 시작 시 Local `main`, Feature, 두 remote-tracking ref와 `git ls-remote`의 원격 두 브랜치 SHA가 모두 위 HEAD와 일치했다. `main...feature/orgchart-package`의 좌/우 독자 Commit 수는 `0 / 0`이다. +- 시작 시 개발·운영 Working Tree는 모두 깨끗하고 staged 변경도 없었다. +- 루트 [package.json](../../package.json)의 npm workspaces는 `packages/*`; 현재 패키지는 `@hm-design/ui`, `@hm-design/center-graphic`이다. OrgChart 패키지는 아직 없다. +- 루트 React/React DOM은 `19.3.0`, Storybook은 `10.6.1`, Tailwind는 `4.3.3`이다. 이는 저장소 manifest 확인이며 설치 상태·호환성 검증 결과가 아니다. +- 기존 문서는 `docs/components`, `docs/patterns`, `docs/guidelines`, `docs/reports`, `docs/center-graphic`으로 구분된다. 독립 도메인인 [Center Graphic](../center-graphic/README.md)의 선례에 맞춰 `docs/org-chart`를 신규 설계 문서 위치로 선택했다. + +이번 작업은 이 디렉터리의 Markdown 신규 작성만 허용한다. 구현, 설치, package/lockfile·Theme·Component·Pattern·Storybook 변경, Stage·Commit·Push·Merge는 수행하지 않는다. 운영 프로젝트와 `main`도 변경하지 않는다. + +## 3. 확정 요구사항 + +- 엔진은 `dabeng/OrgChart`를 사용한다. 채택할 정확한 버전·배포 모듈 방식은 기술 검증 후 확정한다. [공식 데모](https://dabeng.github.io/OrgChart/), [공식 저장소](https://github.com/dabeng/OrgChart). +- 포함 콘텐츠: 제목·설명, 최상위 리더, 중앙 허브, 부문·실·Div, 셀·팀, 직원 프로필, 조직 계층·연결선, 조직 간 추가 관계, 팀별 인원, 파견·휴직 그룹, 관계사별 인원, 전체 인원 집계. +- 제외: Header, GNB, LNB, Dashboard Header, 페이지 전체 Layout, Routing, 신규 페이지 등록, 전역 Navigation, 상위 앱 인증 처리. +- 최신 추가 요청을 우선하여 Core 노드는 Framework-independent HTML/CSS로 표현하고 HM Theme를 CSS Custom Properties로 연결한다. 공개 React Component는 React 호스트/Adapter의 적합한 영역에서 재사용을 검토하고 Core에는 직접 import하지 않는다. 기존 Color/Semantic Token·Typography·Spacing·Radius 기준을 참조하되 기존 Theme·Component·Pattern이나 공통 디자인 규칙은 변경하지 않는다. +- 조직·직원·관계사·추가 관계·표현 설정·Metadata를 ID 기반으로 분리한다. 엔진용 중첩 트리는 ViewModel에서 파생하며 원본 계약이 아니다. +- Data Source → Data Validation → Normalized Data Contract → ViewModel / Adapter → OrgChart UI를 유지한다. JSON Mock, 외부 JSON, 향후 REST API 공급 방식은 같은 계약을 제공한다. +- 조직·직원·관계·표현 설정 변경은 가능한 한 데이터만 바꿔 반영한다. 집계는 직원 원본에서 계산한다. 미확정 집계 정책을 코드 기본값으로 숨기지 않는다. +- Validation 실패는 제어된 Error 상태로 전환하고 화면 전체 비정상 종료를 방지한다. +- 검토 너비는 390/768/1280/1440px이다. 컨테이너 기준으로 검토하고 모바일 전략은 기술 검토 후 확정한다. +- Storybook은 OrgChart 콘텐츠 단독·가상 데이터 검증용으로 설계한다. Header/Dashboard 조합 Story는 제외한다. +- 실제 API 명세가 없으므로 Endpoint·인증 방식·권한·캐시·재시도 정책은 TBD다. 실제 인물·개인정보는 재사용하지 않는다. +- 1차 공식 개발 대상은 Vanilla HTML/JavaScript와 React다. Vue·Angular·기타 framework는 이번 개발 범위에서 제외하고 Core의 향후 확장 가능성만 유지한다. 공식 개발 대상 지정과 실제 지원 완료는 구별하며 현재 실행 지원은 모두 NOT VERIFIED다. +- Core는 엔진·데이터·Validation·Data Source Adapter·집계·Mount/Update/Reload/Destroy를 공통 관리한다. npm·ESM·브라우저 번들·별도 CSS·타입 선언·Framework Adapter 배포를 검증한다. Theme 미연결 정책은 TBD다. +- 두 환경은 동일 JSON 계약·Data Source Interface·Validation·집계·노드 UI·HM Theme CSS Variables·차트 인터랙션을 사용한다. React Wrapper에 별도 엔진/노드 디자인/업무 로직을 중복 구현하지 않는다. + +## 4. 제안과 자료 한계 + +- 패키지 경로 `packages/org-chart`, 이름 `@hm-design/org-chart`는 **제안 유지**다. `packages/*`와 양립 가능함을 확인했지만 이름·공개 Export·의존성 승인이나 생성은 하지 않았다. +- 최신 확정 요청의 우선 검토안은 `@hm-design/org-chart` Core + `@hm-design/org-chart-react` Wrapper 두 패키지다. 실제 분리는 기존 build 연결과 의존성 검증 후 최종 결정한다. +- 문서의 함수·Interface·Schema·폴더·Story 이름은 구현 초안이다. 현재 공개 API처럼 import하거나 사용하지 않는다. +- Workdesk 조직도 캡처를 시각 레퍼런스로 사용하는 요구는 확정이다. 그러나 이번 첨부에는 작업 지시 텍스트만 있고 캡처 파일은 없다. 콘텐츠 8영역은 사용자 설명에서 도출했다. 실제 카드 크기·색상·곡선·배치·시각적 일치는 **NOT VERIFIED**이며 추정으로 확정하지 않는다. +- 가상 직원은 `가상직원 가`처럼 예시임을 식별할 수 있는 명칭을 사용한다. 사진은 null/fallback 또는 승인된 가상 asset만 사용한다. + +## 5. 기존 지침과 적용 차이 + +기존 기준은 [AGENTS.md](../../AGENTS.md), [화면 선택·완료 계약](../guidelines/ai-screen-contract.md), [공개 인덱스](../guidelines/component-pattern-reference.md), [현재 추가 수정 기준](../guidelines/publisher-additional-review-2026-10-10.md), [Runtime Theme](../guidelines/runtime-theme.md)를 참조한다. 공통 기준 전체를 복제하지 않는다. + +- **경로·브랜치·Git 정책 충돌**: 기존 AGENTS는 `HMDesign-Gitea-Transfer/main` 수정과 검증 후 Commit/Push를 요구한다. 이번 요청은 `HMDesign-OrgChart/feature/orgchart-package`의 문서 전용, 운영 `main` 보존, Commit/Push 금지다. 이번 명시적 요청을 우선 적용하며 기존 AGENTS는 수정하지 않는다. 이 예외를 향후 구현·릴리스 권한으로 일반화하지 않는다. +- **Storybook 범위 해석**: 기존 규칙은 신규 업무 전체 화면 등록과 대표 Story 교체를 금지한다. 이번 요청은 신규 패키지의 콘텐츠 단독 Story 설계다. 이를 컴포넌트/도메인 검증으로 분리하며 전체 업무 화면을 등록하지 않는다. 미래 코드 등록 전에 분류·경로를 승인받는다. +- **Navigation·Dashboard 규칙 적용 범위**: 페이지 소비자는 기존 단일 Navigation 규칙을 따른다. 콘텐츠 패키지가 모드를 선택하거나 Dashboard 48px Header·960px 최소 폭을 내장하지 않는다. 상위 컨테이너 제약은 소비자가 제공한다. +- **Guard 범위**: 이번 산출물은 Markdown만 있고 React target/bootstrap이 없다. 화면 생성 Guard를 억지로 실행하거나 PASS를 만들지 않는다. 미래 UI 검증의 적합한 target과 범위는 [Implementation Plan](implementation-plan.md)에서 별도로 다룬다. +- **CENTER 선례의 차이**: OrgChart Core의 framework 독립성과 HM CSS Token 재사용을 함께 유지한다. CENTER의 전용 Token 정책을 복사하지 않는다. +- **Framework 요구와 기존 React 원칙 충돌**: 기존 AGENTS의 신규 React 화면·HM Component 렌더링 원칙은 최신 추가 요청의 Core 독립성·HTML/CSS 노드 조건에 맞춰 제한한다. Core 표준 DOM 표현을 이번 OrgChart 전용 예외로 기록하며 기존 지침을 자동 수정하지 않는다. React 업무 화면 소비자는 적합한 HM 공개 Component를 계속 사용한다. Vanilla 배포는 기존 Runtime Portability 계약과 함께 검증한다. + +## 6. 확정 요구사항 추적 + +- A 프로젝트 목적·패키지 제안 → 이 문서, Architecture §1. +- B 콘텐츠 범위·페이지 제외 → 이 문서 §3, UI Composition §1. +- C HM 재사용 → UI Composition §2, Integration §3. +- D 공급 계층 → Architecture §2, Integration §4. +- E 데이터 모델 → Data Contract §1–3. +- F 데이터 변경·유지보수 → Architecture §4, Integration §4. +- G Validation → Data Contract §4. +- H 인원 집계 → Data Contract §5. +- I 엔진 기술 검토 → Integration §1–2. +- J 화면 8영역·가상 데이터 → UI Composition §1, Data Contract §3. +- K 반응형·컨테이너 → UI Composition §3. +- L 콘텐츠 Storybook → Implementation Plan §2. +- M API 전환 점검 → Integration §4–5. +- 추가 Framework-independent 요구 → Architecture 전체, Framework Integration 전체, Implementation Plan §1–3. + +문서화 다음 단계는 사용자 검토·승인이다. 이 문서 작성만으로 개발, 설치 또는 Commit/Push가 승인되지는 않는다. diff --git a/docs/org-chart/ai-page-generation-rules.md b/docs/org-chart/ai-page-generation-rules.md new file mode 100644 index 0000000..14951ce --- /dev/null +++ b/docs/org-chart/ai-page-generation-rules.md @@ -0,0 +1,166 @@ +# HMDesign — AI 조직도 페이지 자동 생성 공통 정책 + +적용 대상은 Codex·Claude 등 AI 코딩 에이전트가 자연어 또는 제공 데이터에서 **새로 생성하는 삽입용 조직도 컴포넌트 또는 조직도 전체 페이지**다. 회사, 조직 유형, 계층, Depth, 인원수에 종속되지 않는다. **고정형(Fixed)과 확장형(Auto/Expanded) 모두 동일한 생성 방식 선택 원칙을 적용한다.** 기존 Full Composition·Expanded OrgChart·Department Colors·Overview의 승인된 화면은 변경하지 않으며, 기존 P9.5·P-AI1 결과에도 소급 적용하지 않는다. + +## 1. 실행 방식과 적용 우선순위 + +- 사용자가 코딩 에이전트에 요청을 전달하면 에이전트가 조직 JSON과 실제 페이지 코드를 생성한다. 웹페이지에 AI 채팅창이나 생성 입력 UI를 추가하지 않는다. 브라우저 AI Provider 연결은 이 실행 방식의 전제가 아니다. +- 사용자 요청에서 명시한 UI 요구사항 → 이 정책의 AI 조직도 전용 범위 → 기존 OrgChart Core의 디자인·동작 규칙 순으로 적용한다. 충돌하지 않는 AGENTS.md·CLAUDE.md와 HM 공개 계약·데이터 검증·접근성 규칙은 계속 준수한다. 관련 문서는 필요한 부분만 읽는다. +- 생성 방식은 아래 사전 선택 절차로 확정한다. 미지정 요청을 Chart Only 또는 Full Page로 임의 결정하지 않는다. Chart Only에는 일반 업무 화면의 GNB/LNB/DASHBOARD Navigation 의무를 가져와 메뉴나 Header를 추가하지 않는다. Full Page의 요청 UI·Navigation은 기존 HM 화면 계약을 따른다. +- 기존 Core·React Wrapper·Theme·카드·Storybook을 요청 없이 변경하지 않는다. 필요한 공통 변경은 사유와 영향 범위를 먼저 보고한다. 새 조직도 엔진이나 디자인 시스템은 만들지 않는다. + +### 1.1 생성 방식 사전 선택 + +Codex·Claude가 조직도 생성 요청을 받았을 때 **생성 방식이 명시되지 않았다면 코드 작성 전에 다음 질문을 먼저 하고, 사용자의 답변을 받은 뒤 구현한다.** 이 질문은 에이전트 대화에서 하며 웹페이지에 생성 방식 선택 UI를 추가하지 않는다. + +> 조직도만 기존 페이지에 삽입할까요, 아니면 레이아웃까지 구성한 새 페이지 전체를 생성할까요? + +**① 조직도만 생성 (Chart Only)** + +- 기존 페이지에 삽입 가능한 독립 조직도 컴포넌트를 생성한다. +- 흰색 캔버스 및 조직도 노드·연결선만 표시한다. +- 최초 Fit to View, 마우스 휠 확대·축소 및 Pan을 적용한다. +- 외부 제목·통계·설명·조작 패널을 생성하지 않는다. 상세 구성은 §6의 Chart Only 규칙을 따른다. + +**② 새 페이지 전체 생성 (Full Page)** + +- 새로운 페이지의 레이아웃까지 설계하고 화면 전체의 배치 및 반응형 구성을 구현한다. +- 기존 HMDesign UI 컴포넌트를 우선 재사용한다. +- 사용자가 요청한 제목·정보·필터·컨트롤 등을 반영한다. 선택 자체를 요청하지 않은 부가 기능의 승인으로 확대하지 않는다. +- 기존 OrgChart Core·React Wrapper로 조직도를 표시한다. 상세 구성은 §6.4를 따른다. + +사용자가 이미 생성 방식을 명시했다면 같은 선택 질문을 다시 하지 않고 해당 방식으로 바로 구현한다. 두 방식 모두 사용자 요청의 조직 구조·인원수·데이터를 정확히 반영하고 기존 OrgChart 컴포넌트를 재사용한다. Fixed·Auto/Expanded는 조직도 배치 모드이며 Chart Only·Full Page 선택을 대신하지 않는다. + +이미 Chart Only로 확정되어 진행 중인 구현에는 이 선택 절차를 다시 적용하거나 Full Page로 확대하지 않는다. 생성 방식 사전 선택 정책을 작성한 작업은 정책·참조 안내만 다뤘으며 진행 중인 구현·기존 Storybook 4개 화면은 수정하지 않았다. 별도로 승인된 2026-10-13 Feature 통합 범위와 결과는 [최종 Feature 통합 보고서](final-integration-2026-10-13.md)를 따른다. + +## 2. 요청을 가변 구조로 정리 + +화면 구현 전에 다음 조건을 내부 생성 계획에 정리한다. 계획·가정·검증 결과는 작업 보고나 별도 파일에 기록하며 조직도 화면에 자동 삽입하지 않는다. + +- 회사명·회사 수·회사 ID 및 회사별 독립 트리 +- 대표이사 유무와 소속, 조직장·직원·직위 조건 +- 부문·본부·실·부서·팀·사업부 등 요청된 조직 유형과 명칭 +- 부모·자식 관계, 조직별 서로 다른 하위 조직 수와 직원 수 +- 전체 직원 수, 조직 수, Depth 및 집계 범위 +- 사용자 지정 추가 UI와 필요한 기존 탐색 기능 + +특정 중간 단계나 공통 Depth를 강제하지 않는다. 조직 수와 직원 수는 요청에서 산출하며 전용 회사 템플릿·배열 길이·고정 분배값으로 대체하지 않는다. 삼안의 6본부·17팀·500명은 테스트 사례일 뿐 기본값이나 완료 기준이 아니다. + +다음 요청 형태를 모두 같은 해석·변환 절차로 처리한다. 실제 렌더링 가능 여부는 사용 중인 Core의 공개 계약을 확인한다. + +- 회사 → 본부 → 팀 → 직원 +- 회사 → 부문 → 본부 → 부서 → 팀 → 직원 +- 대표이사 → 실 → 부서 → 직원 +- 회사 → 사업부 → 직원 +- 여러 회사 → 회사마다 다른 조직 계층과 Depth → 직원 + +Depth에 회사나 직원 카드를 포함하는지, 조직별 인원이 직접 소속인지 하위 조직 포함인지 구분한다. 의미가 불분명해 구조나 총원이 달라지는 경우 해당 항목만 질문한다. 내부 렌더러의 0부터 시작하는 인덱스를 사용자 Depth와 혼동하지 않는다. + +## 3. 자연어에서 가상 데이터 생성 + +- 명시된 회사·조직명·관계·유형·조직 수·직원 수·직위·대표이사 유무를 정확히 반영한다. 합계가 충돌하거나 관계를 결정할 수 없으면 생성 전에 필요한 조건만 질문한다. +- 미지정 가상 명칭과 직원 분배는 요청 범위 안에서 합리적으로 결정할 수 있다. 선택한 값과 근거를 화면 밖에서 보고한다. 이전 테스트의 회사·Depth·팀 수·직원 수를 재사용하지 않는다. +- 요청하지 않은 대표이사, 중간 조직, 조직장용 추가 직원을 템플릿에서 자동 삽입하지 않는다. 대표이사가 직원 카드인지 조직 노드인지 구분하며 직원과 조직을 중복 생성하지 않는다. +- 전체 직원 수에는 별도 지시가 없는 한 요청된 대표이사·조직장을 포함한다. 직원 레코드는 한 번만 생성하고 조직장 참조를 연결한다. 조직별 직접 인원과 하위 조직 포함 인원을 분리해 집계한다. +- 직원 ID는 전체 데이터에서 고유하게 생성한다. 회사·조직 ID도 트리 간 충돌하지 않아야 한다. 입력 순서가 달라져도 같은 원본 항목의 내부 ID가 불필요하게 바뀌지 않도록 한다. +- 가상 데이터와 적용 가정을 사용자에게 명확히 보고한다. 화면 배너나 개발 안내는 기본적으로 추가하지 않고, 명시적으로 요청된 표시만 구현한다. + +## 4. JSON·CSV·Excel 제공 데이터 변환 + +원본 구조와 값을 우선한다. 원본 파일·파싱 결과와 Contract 변환 결과를 분리해 관리한다. + +- 직원·조직을 임의로 추가·삭제·합치지 않는다. 직원명·조직명·직위·원본 ID·상하위 관계를 변경하지 않는다. trim, 철자 교정, 누락 노드 합성, 임의 인원 분배를 적용하지 않는다. +- 행 수와 실제 조직·직원 수를 구분한다. 반복된 조직 정보가 같은 조직인지 원본 ID와 관계로 확인한다. 동명이인을 같은 직원으로 취급하거나 같은 이름의 조직을 자동 합치지 않는다. +- ID가 없는 항목에 변환용 내부 ID만 별도로 생성할 수 있다. 원본 필드와의 대응을 남긴다. 항목을 구별할 수 없으면 추가 식별 정보를 요청한다. +- 필요한 필드·컬럼 매핑을 확인한다. 원본에 없는 근무 상태·관계사·조직 유형·메타데이터를 실제 정보처럼 채우지 않는다. Contract 필수값이 없으면 사용자에게 확인하고, 명시적으로 허용된 변환 기본값은 원본과 구분해 기록한다. +- 중복 ID, 누락 조직·직원, 잘못된 부모·소속·조직장 참조, 순환 관계, 회사 간 계층 연결, 원본과 변환 결과의 조직 수·직원 수를 검사한다. 오류는 원본 행·셀 또는 변환 경로와 수정할 항목으로 표시한다. +- Excel의 선택 시트와 컬럼 범위를 확인한다. 여러 시트를 임의로 합치거나 제외하지 않는다. 수식·날짜·서식 있는 숫자·앞자리 0·인코딩을 보존할 수 없으면 지원 한계를 알리고 필요한 입력 형태를 요청한다. +- Contract에 없는 원본 정보는 별도 원본 자료에 보존한다. 미지원 필드를 Contract에 넣거나 원본을 조용히 버리지 않는다. 오류 데이터의 부분 렌더링은 사용자가 요청하고 안전하게 구분할 수 있을 때만 허용한다. + +## 5. 현재 Core Contract와 공개 API 확인 + +관련 안내는 [데이터 계약](data-contract.md), [Expanded 사용 안내](expanded-usage.md)와 실제 작업 기준 커밋의 패키지 exports·타입·Validator다. 문서의 초안이나 과거 검증을 현재 지원 증거로 사용하지 않는다. + +- `@hm-design/org-chart`의 Organization·Employee, Validator·DataSource와 `@hm-design/org-chart-react`의 공개 OrgChart를 우선 재사용한다. 공개 CSS export와 실제 React bootstrap을 연결한다. 내부 src/dist 경로를 추측하거나 기존 Core를 복제하지 않는다. +- 업무상 조직 유형과 Contract의 기계적 type 값을 구분한다. 사용자 조직명은 유지하고 확인된 대응을 기록한다. 새 enum을 임의로 넣거나 맞지 않는 유형으로 조용히 치환하지 않는다. +- 회사별 독립 트리와 회사 ID·컬러 매핑을 확인한다. 특정 회사나 회사 수를 기본값으로 고정하지 않는다. 미등록 회사를 기존 회사 ID에 임의 연결하지 않는다. 필요한 프리셋이나 공개 확장 경로가 없으면 제약과 필요한 결정을 보고한다. +- 대표이사는 공개 조직장·상단 직원 표현 API로 연결한다. 기술적 루트가 필요한 경우 업무 조직과의 대응 및 집계 범위를 확인한다. 실제 제공 데이터에 가짜 회사·대표이사·조직을 추가하지 않는다. +- Core의 Validator를 통과한 데이터만 DataSource와 렌더러에 연결한다. 별도 사전 검사는 가능하지만 기존 Validator를 우회하거나 실패 항목을 삭제해 통과시키지 않는다. +- 레이아웃·Depth·대표이사 표현·다중 회사·TB/LR/Hybrid·Zoom/Pan·접기·팝업은 실제 공개 API가 지원하는 범위만 사용한다. 직원 연결 방향을 전체 트리 방향 지원으로 해석하지 않는다. +- 요청 구조나 최소 화면을 공개 API로 정확히 표현할 수 없으면 지원 한계와 대안을 보고한다. 임의 구현·합성 화면을 기존 Core 결과라고 주장하지 않는다. 공통 기능 확장이 필요하면 별도 승인 범위로 분리한다. + +## 6. 선택한 생성 방식의 화면 구성 + +### 6.1 기본 출력과 제외 요소 + +Chart Only 결과물은 **White Canvas + OrgChart Nodes + Connectors**인 독립 조직도 컴포넌트다. 기존 페이지에 삽입할 수 있으며, 독립 미리보기에서는 흰색 조직도 영역을 최상위 화면으로 사용한다. + +- 회사·조직 그룹, 요청에 포함된 대표자·본부장·팀장·직원 카드, 연결선과 필요한 화살표, 회사별 컬러를 표시한다. 조직명·직위·직원 이름 등 조직도를 구성하는 필수 텍스트는 유지한다. +- 해당 모드에서 지원되는 기존 Core의 그룹 접기·펼치기, Zoom/Pan·직원 정보 보기 등 조직도 내부 탐색 기능은 유지한다. 외부 조작 UI의 기본 제외를 Core 기능 삭제로 해석하지 않는다. +- 외부 제목·통계·설명·조작 패널은 Chart Only 컴포넌트에 생성하지 않는다. 새 페이지 레이아웃과 외부 UI 생성은 Full Page 범위로 구분하며, 한 요소의 요청을 다른 부가 UI의 승인으로 확대하지 않는다. 삽입 대상 기존 페이지의 UI를 삭제하거나 변경하지 않는다. + +기본 제외 요소: + +- 페이지 제목(예: 가상 조직도), 기준일, 회사 수·전체 인원 요약 문구 +- 확대·축소·100% 복원·Fit to View 버튼 바 등 외부 컨트롤 +- Zoom 배율 안내, Drag & Drop ON/OFF 상태 문구, 조작 방법 안내 +- 설명 문단, 생성 조건 설명, 사용 설명·안내 패널 +- JSON 미리보기, 테스트 결과·디버그 패널, 별도 통계 대시보드, 개발 안내 +- 외부 회색 배경, 불필요한 Page Header·Wrapper·바깥 여백 +- AI 채팅·생성 입력 UI, Navigation·추가 메뉴 + +저장·게시·다운로드·운영 API는 요청 범위 밖에서 추가하지 않는다. 테스트 결과·가정·제약은 조직도 화면 대신 작업 완료 보고서에 기록한다. + +### 6.2 흰색 Root 캔버스와 초기 배치 + +- 캔버스는 Chart Only 컴포넌트의 Root 영역을 차지하며 배경은 흰색이다. 독립 미리보기에서는 화면의 Root로 사용하고, 기존 페이지 삽입 시에는 호스트가 제공한 영역 안에 배치한다. 호스트 페이지의 배경·레이아웃을 덮어쓰지 않는다. 외부 회색 영역이나 불필요한 바깥 여백을 생성하지 않으며 사용자가 지정한 캔버스 크기가 있으면 이를 우선한다. +- 초기 배치는 조직도 전체의 실제 Bounds와 캔버스 크기를 기준으로 가로·세로 중앙 정렬한다. 노드·그룹·연결선을 포함한 전체 범위와 위치를 확인하며 임의 고정 좌표·배율을 회사별 템플릿으로 만들지 않는다. +- 최초 Fit to View로 조직도 전체의 실제 Bounds를 캔버스에 맞춘다. 대규모 조직도에서는 초기 전체 개요와 확대 시 텍스트 읽기, 내부 이동, 외부 가로 넘침을 함께 확인한다. 공개 API의 배율 한계로 전체 Fit이 불가능하면 제약을 보고한다. +- 기존 Zoom/Pan을 재사용해 마우스 휠 확대·축소 및 Pan을 제공하고 내부 이벤트 기능을 유지한다. 초기 Fit은 배치 동작이며 Fit to View 버튼 바를 렌더링해야 한다는 뜻이 아니다. Chart Only에는 외부 컨트롤 버튼을 생성하지 않는다. +- 기존 카드·회사색·연결선·직원 팝업 디자인과 Theme를 유지한다. 캔버스 배경은 실제 선언된 공개 Theme·스타일 경로를 확인해 적용하며 없는 Token을 추측하지 않는다. +- 지원되는 공개 옵션으로 불필요한 내장 설명·요약·컨트롤을 제외한다. 옵션이 없거나 공개 API로 흰색 Root·중앙 배치를 구현할 수 없으면 제약을 보고한다. Shadow DOM 임의 조작, 공통 CSS 변경, Core·React Wrapper의 불필요한 공개 API 변경으로 해결하지 않는다. +- 최소 배율, 많은 직원, 긴 명칭, 깊은 계층, 여러 회사에서 전체 Fit과 가독성을 함께 만족할 수 없으면 잘림·읽기·탐색 한계를 보고한다. 이름·인원·Depth를 줄이거나 단순히 내용을 숨겨 잘림·레이아웃 문제를 감추지 않는다. + +기본 구성은 다음과 같다. + +- 기존 구성: Page Background → Header → Controls → Description → White Canvas → OrgChart +- Chart Only 구성: **White Canvas → OrgChart** + +### 6.3 Fixed / Auto 공통 적용과 기존 화면 보호 + +- **Fixed:** 승인된 기존 카드·연결선 디자인과 해당 모드의 기존 배치를 재사용하고, 선택한 Chart Only 또는 Full Page 범위를 적용한다. 현재 공개 Chart Only 옵션은 Auto 전용이며 Fixed와 함께 쓰면 거부된다. Fixed Chart Only가 필요하면 제약으로 보고하고 별도 승인 범위로 분리한다. +- **Auto / Expanded:** 기존 Core 자동 배치를 재사용하고, Chart Only에서는 조직도 외부의 설명·테스트·조작 패널을 제외한다. Full Page에서는 요청된 외부 UI만 구성한다. +- 두 배치 모드 모두 가변 계층·정확한 인원·제공 데이터 보존 규칙을 유지한다. 배치 모드가 다르다는 이유로 추가 UI를 허용하지 않는다. 요청 데이터가 달라지면 계층과 렌더링도 그 데이터에서 달라져야 한다. +- Chart Only는 삽입용 독립 컴포넌트로, Full Page와 독립 미리보기는 독립 React entry로 제공한다. 기존 Full Composition·Expanded OrgChart·Department Colors·Overview·Storybook 메뉴·P9.5·P-AI1 결과를 교체하지 않으며 기존 Storybook 페이지의 제목·컨트롤·설명 UI를 삭제하지 않는다. +- Guard나 문서 분석기의 제약 때문에 요청하지 않은 Navigation·통계·설명 UI를 추가하지 않는다. 정책과 검사 도구가 충돌하면 원본 검사 결과와 원인을 보고하며 도구나 공통 계약을 이번 범위 밖에서 임의 수정하지 않는다. + +### 6.4 Full Page 전체 페이지 + +- 기존 HMDesign Component·Pattern·Theme와 공개 API를 우선 재사용해 새 페이지의 전체 레이아웃을 구성한다. 기존 OrgChart Core·React Wrapper를 소비하며 새 조직도 엔진이나 카드 디자인을 만들지 않는다. +- 사용자가 요청한 제목·정보·필터·컨트롤과 필수 동작을 반영한다. 요구사항에 없는 통계·설명·다운로드·외부 API·추가 메뉴·AI 채팅·생성 입력 UI는 자동으로 추가하지 않는다. +- 조직도 캔버스와 요청 UI의 배치·크기·스크롤·반응형 동작을 함께 설계한다. Chart Only의 흰색 전체 Root·외부 UI 제외 규칙을 페이지 전체에 강제하지 않으며, 조직도 내부의 기존 디자인·탐색 기능은 유지한다. +- 일반 업무 화면의 HM 컴포넌트 선택·Navigation·Theme·React bootstrap·접근성·Guard 계약은 AGENTS.md의 필수 규칙을 따른다. Full Page 선택만으로 기존 Storybook 화면 수정이나 공통 Component·Pattern·Theme 변경을 허용하지 않는다. + +## 7. 생성 절차와 최종 통합 검증 + +> Chart Only 구현(2026-10-13): Chart Only를 선택한 Auto 조직도는 공개 옵션 `layoutMode: 'auto'` + `presentationMode: 'chart-only'`로 생성한다. 초기 Fit·가로/세로 중앙 배치(`initialView: 'fit'`)와 Ctrl/⌘ 없는 커서 기준 휠 Zoom(`wheelZoom: 'direct'`)이 기본 적용되며 React·Vanilla가 같다. 외부 제목·요약·버튼·안내를 따로 만들지 않고 Shadow DOM을 조작하지 않는다. Fixed는 이 옵션을 거부하므로(TypeError) Fixed 요청을 임의로 Auto로 바꾸지 않고 Chart Only 미지원 제약으로 보고한다. 생성 방식의 사전 선택과 Full Page 범위는 계속 §1.1을 따른다. 사용법은 [공통 사용 가이드](org-chart-usage-guide.md#chart-only-공개-api-auto)를 따른다. 아래 P10.2 문단은 구현 전 기록이다. +> +> P10.2 구현 한계 확인: 이 정책은 목표이며 현재 Core의 모든 내장 UI를 끄는 공개 옵션을 의미하지 않는다. 실제 Auto/Fixed 독립 검사는 Chart Only PARTIAL이다. Auto의 기본 초기 배치는 가로 중앙·상단 정렬이고, Vanilla의 공개 `fitToView()`는 전체 Bounds 기준 가로·세로 중앙 배치를 지원한다. React Wrapper에는 이를 호스트가 직접 호출할 공개 handle/ref가 없다. 지원 범위·후속 개선 후보는 [공통 사용 가이드](org-chart-usage-guide.md#chart-only-정책과-현재-기능의-관계)를 확인한다. 기존 화면·Core·공개 API를 변경하거나 내장 DOM을 숨겨 정책 완료로 보고하지 않는다. + +1. 코드 작성 전에 §1.1에 따라 생성 방식을 확인한다. 미지정이면 정해진 선택 질문에 대한 답변을 받은 뒤 진행하고, 이미 명시된 방식은 다시 묻지 않는다. 현재 작업 경로·Git 상태·기준 커밋·공개 API를 확인하고 기존 미커밋 변경과 병렬 작업, 서버 포트를 보호한다. +2. 회사별 계층·유형·Depth·조직 수·직원 수·조직장 집계 조건을 정리한다. 충돌하는 필수 조건만 확인한다. +3. 요청에서 가상 데이터를 생성하거나 제공 데이터를 보존해 변환한다. 계획과 변환 대응은 화면 밖에 기록한다. +4. 기존 Contract와 추가 정합성 검사를 통과시킨다. 이는 잘못된 데이터를 렌더러에 넘기지 않기 위한 필요한 사전 확인이다. +5. 선택한 방식으로 구현한다. Chart Only는 흰색 캔버스·노드·연결선의 삽입용 컴포넌트와 최초 Fit·휠 Zoom·Pan을 제공한다. Full Page는 기존 HM UI와 OrgChart Core·React Wrapper로 요청 UI·전체 배치·반응형 페이지를 구성한다. 새로운 구조를 고정된 사례로 바꾸지 않는다. +6. 구현 후 변경 범위의 코드·diff·계층·인원·중복 집계·보존·핵심 동작을 한 번에 검증한다. 필요한 Typecheck·Build·Guard를 수행하고 실제 브라우저에서 확인한다. 이미 성공한 동일 조건 검사를 반복하지 않고 수정·실패 범위만 다시 확인한다. +7. 390/768/1280/1440px와 사용자 지정 캔버스 크기에서 선택한 방식의 화면을 확인한다. Chart Only는 흰색 컴포넌트 Root·실제 Bounds 기준 최초 Fit과 중앙 배치·외부 UI 제외·호스트 삽입을, Full Page는 요청 UI·전체 배치·반응형·Navigation을 확인한다. 공통으로 배율·가독성·콘텐츠/내부 스크롤·줄바꿈·카드·연결선·회사색·Zoom/Pan 내부 이벤트·접기·팝업·키보드·포커스를 관련 사용 요소에 맞게 확인한다. 실제 실행 URL과 화면 캡처를 보고한다. +8. 선택한 생성 방식, 요청 대비 회사·조직·Depth·직원·조직장 집계, 생성 파일, 재사용 API, 가정, 제약, PASS/PARTIAL/FAIL와 NOT VERIFIED를 간결하게 보고한다. Guard 원본·정적·Runtime·STYLE 디자인 의도/최종 상태를 구분하고 미검증을 덮어쓰지 않는다. + +정책 작성·연결만 수행하는 작업에는 화면 구현·빌드·브라우저 실행·전체 회귀를 필수로 추가하지 않는다. 독립 실험 환경에서 선택적으로 화면을 검증할 수 있으나 기존 Storybook·AI Playground 테스트 페이지는 수정하지 않는다. 화면 검증을 수행하지 않았다면 **VISUAL NOT VERIFIED**로 보고한다. 데이터 생성·렌더링 기능을 실행하지 않았다면 정책 작성 완료를 범용 런타임 지원 PASS로 보고하지 않는다. + +## 8. 범위 보호와 완료 기준 + +- 기존 P9.5·P-AI1 산출물, P10 PARTIAL과 당시 Guard FAIL 10건의 검증 기록을 보존한다. 기존 P10.1 작업 폴더와 AI Playground Worktree를 수정하지 않으며 삼안 500명 테스트 페이지를 이관하지 않는다. 사전 선택 정책의 작성·연결 작업은 이 파일과 AGENTS.md·CLAUDE.md의 참조 안내만 다뤘으며 화면·데이터·Core 변경을 포함하지 않았다. 이후 2026-10-13 Feature 통합에는 Chart Only·초기 Fit·직접 Wheel Zoom, GuardFix 해결 5건, 생성 방식 사전 선택 정책만 포함한다. GuardFix는 F3–F7을 해결하며 잔여 FAIL 5건·NOT VERIFIED 9건과 원본 상태는 [최종 Feature 통합 보고서](final-integration-2026-10-13.md)에 구분해 기록한다. Cell 디자인·미리보기는 통합 대상에서 제외한다. +- 이 정책을 참조한 생성 요청만으로 Stage·Commit·Push·Merge를 승인받은 것으로 간주하지 않는다. 현재 사용자의 작업별 Git 지시를 우선한다. 사전 선택 정책의 작성 작업에서는 Stage·Commit·Push·Merge를 진행하지 않았으며, 이후 2026-10-13 통합 요청은 위 3개 작업의 변경 범위 검증 후 Feature Commit·Push를 명시적으로 승인했다. main Merge·Docker 작업·npm Registry Publish는 승인 범위에 포함되지 않는다. +- 서로 다른 회사·대표이사 유무·조직 유형·Depth·분기 수·직원 수가 같은 요청 분석 → 데이터 변환 → Contract 검증 → Core 소비 절차로 처리돼야 한다. 특정 테스트 수치나 전용 템플릿에 의존하면 완료가 아니다. +- 생성 작업의 완료는 명시 조건의 일치, 제공 데이터 보존, 기존 Core의 실제 렌더링, 요청 범위의 최소 UI, 실행 증거로 판단한다. 미지원 구조를 지원한 것처럼 보고하거나 분석·JSON만으로 화면 생성 성공을 선언하지 않는다. diff --git a/docs/org-chart/architecture.md b/docs/org-chart/architecture.md new file mode 100644 index 0000000..346e4d3 --- /dev/null +++ b/docs/org-chart/architecture.md @@ -0,0 +1,117 @@ +# OrgChart Architecture + +상태: 개발 전 설계. 확정 범위·환경·우선순위는 [README](README.md)를 따른다. 최신 추가 요청에 따라 Framework-independent Core를 기준으로 한다. + +## 1. 독립 Core와 패키지 제안 + +확정: Core는 React·Vue runtime에 의존하지 않는다. 표준 DOM API와 JavaScript/TypeScript를 사용하고 dabeng/OrgChart 엔진을 Core에서 관리한다. 데이터 계약·Validation·Data Source Adapter·집계는 공통이다. Routing·로그인·Navigation·상위 Layout은 소유하지 않는다. + +`packages/org-chart` / `@hm-design/org-chart`는 제안이다. npm workspaces `packages/*`와 양립할 수 있음을 확인했지만 생성·명칭 승인은 하지 않았다. + +우선 검토안은 Core `@hm-design/org-chart`와 React Wrapper `@hm-design/org-chart-react` 두 패키지다. 경로 후보는 `packages/org-chart`와 `packages/org-chart-react`다. workspace·build 연결과 Core 소비에 React가 따라오지 않는 dependency·bundle 경계를 입증하고 최종 분리를 결정한다. 같은 패키지의 `/react` subpath는 우선안이 불가능할 때의 비교 항목만 남긴다. Vue·Angular·기타 framework는 이번 범위에서 제외한다. 실제 분리·경로·Export는 승인 전이다. + +향후 책임별 구조 예시이며 이번에는 파일을 만들지 않는다. + +```text +packages/org-chart/ 제안: framework-independent Core + src/ + index.ts 공개 Core facade와 type + data/ 계약·Validation·집계(DOM도 비의존) + sources/ JSON Mock / 외부 JSON 공급 + view-model/ ID index·파생 트리·표시 모델 + engine/ dabeng adapter·DOM·생명주기 + renderers/ 표준 DOM 노드·콘텐츠·안전한 텍스트 + styles/ 컨테이너 scope·Theme mapping·Overlay + 브라우저 entry·빌드 설정 승인 후 결정 +packages/org-chart-react/ 우선 검토안: React 연결만 담당 +src/org-chart/ 콘텐츠 단독 Story·가상 fixture 제안 위치 +``` + +Core는 `@hm-design/ui` React API·`@hm-design/center-graphic`을 import하지 않는다. HM Theme CSS는 호스트의 별도 제공 또는 승인된 standalone 배포 경로로 연결한다. Theme 때문에 React peer를 가진 UI 전체 패키지 설치를 Vanilla의 필수 조건으로 만들지 않는다. 엔진 dependency의 버전·포함 범위는 TBD다. React Wrapper만 React/React DOM peer를 검토한다. + +루트 [build-package.mjs](../../scripts/build-package.mjs)는 현재 `ui`, `center-graphic`만 허용한다. workspace 자동 탐색만으로 신규 빌드가 완성되지 않는다. 미래 별도 build 또는 최소 기존 빌드 연결 승인이 필요하다. 배포·의존성 격리는 [Framework Integration](framework-integration.md)에서 검증한다. + +## 2. 데이터 흐름과 계층 책임 + +```text +JSON Mock / 외부 JSON / 미래 REST API + ↓ Data Source Adapter: 원본 필드 매핑 +동일 normalized 계약 후보(unknown) + ↓ Data Validation: 구조·참조·정책 검증 +검증 성공한 Normalized Data Contract(OrgChartData) + ↓ ViewModel / Engine Adapter +조직별 표시 모델·집계·engine용 파생 트리·추가 relation 모델 + ↓ Framework-independent Core +DOM 콘텐츠·engine 계층 영역·Relation Overlay 후보 + ↑ 동일 Core 생명주기 호출 +Vanilla 호스트 / React Wrapper (동일 Core 재사용) +``` + +Source 매핑은 Validation 이전에 공통 계약 후보를 만든다. 검증 후 신뢰된 계약과 구별한다. TypeScript cast만으로 신뢰하지 않고 모든 Source가 같은 runtime validator를 거친다. + +- Data Source: 데이터를 읽고 매핑한다. 네트워크·취소·향후 캐시를 담당하며 engine DOM이나 framework를 알지 못한다. +- Validation: Schema Version·필수 값·ID·참조·순환·상태·관계·입력 안전성을 검사한다. 성공 데이터 또는 위치별 issue를 반환한다. +- Normalized Contract: 조직·직원·관계사·관계·표현·Metadata를 분리한다. engine용 `children`·`relationship`·`className`은 저장하지 않는다. +- ViewModel / 집계: index·하위 조직·정렬·leader·직원 그룹·집계를 순수 변환한다. 모든 환경이 같은 함수를 사용한다. +- Engine Adapter: 복사한 파생 트리만 전달하며 DOM mount/update/dispose를 관리한다. 원본 수정·fetch·공통 UI 변경은 금지한다. +- Core facade / DOM Renderer: 요청 경쟁, Validation, 상태, 콘텐츠 DOM, 인스턴스 ID, 생명주기·Overlay를 관리한다. 텍스트는 `textContent` 등 안전한 경로로 처리한다. +- React Wrapper(Framework Adapter): React mount/effect/ref/cleanup과 Props를 Core에 연결한다. Validation·집계·engine·노드 디자인·인터랙션 재구현은 금지한다. 다른 framework Adapter는 이번에 설계 구체화/구현하지 않고 동일 facade의 향후 확장성만 보존한다. +- 상위 앱: 컨테이너·Theme bootstrap·Routing·로그인/권한·API client를 소유한다. React 호스트는 적합한 HM 공개 Component 조합을 사용할 수 있다. + +## 3. 공개 생명주기 초안 + +현재 Export가 아닌 제안이다. 데이터 type은 [Data Contract](data-contract.md)와 연결한다. `HTMLElement`는 DOM 경계에만 사용한다. + +```ts +interface OrgChartDataSource { + load(context: { signal: AbortSignal }): Promise; +} +type ValidationIssue = { code: string; path: string; message: string }; +type OrgChartIssue = ValidationIssue | { + code: "SOURCE_LOAD" | "SOURCE_PARSE" | "SOURCE_MAP"; + path: "dataSource"; + message: string; +}; +type OrgChartState = + | { status: "loading" } + | { status: "empty" } + | { status: "ready"; dataVersion: string; aggregation: AggregationResult } + | { status: "error"; issues: readonly OrgChartIssue[] }; +interface OrgChartOptions { + dataSource: OrgChartDataSource; + aggregationPolicy?: ApprovedAggregationPolicy; + interaction?: { pan: boolean; zoom: boolean }; // 공개 옵션 후보, 기본값 TBD + onStateChange?: (state: OrgChartState) => void; +} +interface OrgChartHandle { + mount(container: HTMLElement): Promise; + update(input: unknown): Promise; + reload(source?: OrgChartDataSource): Promise; + destroy(): void; +} +declare function createOrgChart(options: OrgChartOptions): OrgChartHandle; +``` + +`update`는 normalized 스냅샷 후보를 검증·반영하고 `reload`는 현재 또는 새 Source를 읽는다. 두 동작은 동일 Validation·집계·render 경로를 사용한다. 데이터 실패는 state callback으로 전달해 페이지 종료를 방지한다. 호출 순서·Promise·정리 규칙은 [Framework Integration](framework-integration.md)을 따른다. + +구조·참조·cycle·보안의 fatal Validation issue는 차트 Error다. 집계 정책 미확정은 차트 Error와 구별하여 ready의 AggregationResult `unavailable`로 전달하는 제안이다. Source 로딩·JSON 구문·매핑 실패도 제어된 Error지만 code/path로 Validation 오류와 구별하고 서버 원문을 전달하지 않는다. warning·업무 승인 필요 issue의 공개 전달 형식과 치명도는 API 승인 전 확정한다. + +API Source로 전환해도 facade·각 framework의 사용 구조는 유지하고 Source만 바꾼다. 인증은 호스트 client가 처리한다. 검색·다운로드·편집·추가 메뉴·상세 모달은 이번 범위가 아니다. + +## 4. 데이터 변경과 갱신 + +조직 추가/삭제·명칭·parent 이동, 직원 추가/삭제·조직 이동·직위/직책·사진·상태·관계사 변경, relation 변경, 기준일·제목 변경은 스냅샷 → Validation → ViewModel 재계산으로 반영한다. 전체 재계산 방식을 우선 검토하고 정렬은 Data Contract의 초안을 따른다. + +Source 전환·reload·update마다 이전 작업을 취소하고 revision을 비교해 늦은 응답을 무시한다. Metadata 버전이 같아도 새 스냅샷은 검증한다. engine에는 복사본만 전달한다. 확장 상태·focus·zoom 보존은 PoC 후 결정하며 복원 키는 안정된 도메인 ID다. + +검증·집계·render는 Core가 소유한 동일 불변 스냅샷을 사용한다. JSON Source의 캡처 시점은 생성 시점으로 제안하고 update는 호출 시 입력을 확보한다. 최종 DOM/state 반영 직전에도 revision·destroy 상태를 검사한다. consumer가 원본 객체를 나중에 바꿔 이미 반영한 화면·숫자가 암묵적으로 바뀌면 계약 실패다. + +데이터만 바꾸는 범위는 승인된 계약의 유형·상태·표현 옵션 안이다. 새 업무 정책·관계 의미·카드 기능은 계약/UI 버전 변경이 필요할 수 있다. 모든 미래 변경을 무조건 무코드로 약속하지 않는다. + +## 5. 엔진과 콘텐츠 경계 + +엔진은 조직 parent-child와 기본 계층 연결선을 담당한다. 직원은 조직 ID로 조회해 노드/그룹에 표시하고 리더를 조직으로 중복 삽입하지 않는다. + +중앙 허브가 실제 조직인지 설명 콘텐츠인지 TBD다. 임시 visual root는 내부에서만 생성하고 집계에서 제외한다. 다중 root 처리도 미확정이다. `OrganizationRelation`은 parent-child와 별도이며 engine `relationship` 플래그로 치환하지 않는다. 특수 곡선·교차 관계·고정 배치는 별도 Overlay/Layout 후보로 검토한다. 추가 relation은 집계 분모를 변경하지 않는다. + +완료 조건과 미결정 의존성은 [Implementation Plan](implementation-plan.md)을 따른다. diff --git a/docs/org-chart/browser-verification.md b/docs/org-chart/browser-verification.md new file mode 100644 index 0000000..fba2a87 --- /dev/null +++ b/docs/org-chart/browser-verification.md @@ -0,0 +1,25 @@ +# OrgChart 브라우저 검증 실행 환경 + +검증 시나리오·기대값과 과거 `test-results`는 보존한다. 과거 전체 suite는 이번 수정의 필수 재실행 대상이 아니다. + +`examples/org-chart/verify*.mjs`와 React 브라우저 runner는 공통 +`scripts/orgchart-browser-runtime.mjs`를 사용한다. + +- 기본: 프로젝트의 표준 Node 모듈 탐색으로 `playwright`를 불러오고 해당 설치의 관리 Chromium을 실행한다. +- `ORGCHART_PLAYWRIGHT_MODULE`: 선택형. 기존 외부 Playwright 설치의 entry 파일 절대경로 또는 실행 디렉터리 기준 상대경로. +- `ORGCHART_CHROME`: 선택형. 기존 Chromium/Chrome 실행 파일 절대경로 또는 실행 디렉터리 기준 상대경로. +- 지정한 모듈을 불러올 수 없거나 브라우저가 없으면 실행을 실패시키고 해결 방법 및 원래 오류를 출력한다. 자동 설치·버전 업그레이드·특정 PC fallback은 없다. + +저장소 루트에서 실행한다. 기존 프로젝트 의존성에 Playwright가 없다면 이미 설치된 외부 entry를 환경변수로 지정한다. 환경변수 없이 실행하려면 해당 프로젝트 환경에서 Playwright 및 그 관리 Chromium이 제공되어야 한다. 실제 새 PC의 의존성 설치는 별도 확인 사항이다. + +```powershell +# 기본 모듈을 사용할 수 있는 환경 +node examples/org-chart/verify-p2.mjs + +# 기존 외부 도구 설치를 재사용하는 환경(실제 경로로 지정) +$env:ORGCHART_PLAYWRIGHT_MODULE = '' +$env:ORGCHART_CHROME = '' +node examples/org-chart/verify-p2.mjs +``` + +각 suite의 준비된 example build, baseline dist, Storybook/서버 origin 등의 전제는 그대로 유지된다. 도구 경로가 이식 가능해졌다는 것이 과거 fixture/baseline을 자동 생성하거나 모든 suite가 새 PC에서 검증되었다는 뜻은 아니다. diff --git a/docs/org-chart/card-design-stage1.md b/docs/org-chart/card-design-stage1.md new file mode 100644 index 0000000..6202eef --- /dev/null +++ b/docs/org-chart/card-design-stage1.md @@ -0,0 +1,87 @@ +# HMDesign OrgChart P1 — 카드 디자인 1단계 결과 + +RESULT: **PASS — 요청한 카드 디자인 범위**. 원본 Guard는 기존 Token 추적 문제로 **FAIL**이며 이를 전체 릴리스 PASS로 치환하지 않았다. + +## 작업 정보 + +- 작업 폴더: `D:\디자인시스템\HMDesign-OrgChart` +- 브랜치: `feature/orgchart-package` +- 작업 전/후 HEAD: `d18e3c33801e165e17b6d971e2f6198eed483c3c` +- Started At: 2026-10-12 12:34:53 KST +- Completed At: 2026-10-12 12:56:20 KST +- Actual Duration: 21분 27초 +- Variance: 최초 예상 하한 30분 대비 8분 33초 단축 / 상한 45분 대비 23분 33초 단축. +- Estimated Duration: 최초 30–45분. 대표자·사업관리 추가 요청 후 40–60분으로 조정. +- 기존 package.json/package-lock.json 수정과 OrgChart 미추적 구현을 보존했다. Commit/Push/Merge/브랜치 생성/main 수정은 하지 않았다. + +## 구현 결과 + +1. **SVG 자산 방식**: 전달된 10개 SVG를 원본 그대로 복사했다. Vite `?inline`으로 ESM/IIFE에 포함해 HTML/React/file://에서 외부 경로 의존 없이 사용한다. 배경은 장식용 img(`alt=""`, `aria-hidden`, `object-fit:contain`)이며 SVG의 path·gradient를 유지한다. 기존 박스 테두리·배경·우측 가상 장식은 자산 variant에만 제거한다. +2. **조직별 매핑**: `department-card-assets.ts`에서 사업관리·경영기획·디자인기획·솔루션영업·인재성장·통합시스템 6개 부서를 매핑한다. 하위 셀/팀은 실제 parentId를 따라 해당 부서 자산을 상속한다. 사용자 승인에 따라 솔루션통합팀 SVG를 기존 solution-sales에 연결하며 조직명/JSON은 바꾸지 않았다. 알려지지 않은 조직은 기존 카드 디자인을 유지한다. +3. **대표자**: 본문으로 전달된 146×74 SVG를 `대표자.svg`로 저장했다. `representative-card-asset.ts`로 부서와 별도 관리하고 기존 topLeader 독립 슬롯에만 사용한다. Full Composition의 최상단 가상리더 00 카드 1개에 적용했다. +4. **부서 조직장/직원**: 기존 organization.leaderId 및 kind 판별을 재사용한다. 조직장 146×68, 일반 직원 108×52이며 하위 IS/디자인 팀 리더도 146×68이다. 판별 규칙이나 데이터 계약을 변경하지 않았다. 파견·휴직 보조 영역의 카드·배치는 유지했다. +5. **사업관리 신규 벡터**: PNG(`D:\디자인시스템\카드 이미지\사업관리`, 확장자 없음, 408×191)에서 외곽 1,048개 RGBA 표본과 내측 5,619개 표본을 사용했다. 외곽 색상 `#DEE6F0 / #A8BBD1 / #577BA5`, 마지막 alpha 0.6; 내측 `#FFFFFF / #E0E6F4`. 기존 같은 크기 카드의 path와 곡선을 재사용하고 PNG에서 내측 그라데이션 방향도 복원했다. 두 자산에 텍스트·로고·사진·래스터는 없다. 원본 SHA-256은 `a8353bbf3869e4935c78a6774741d304836deb427d290586b439277022b2695f`다. +6. **데이터 레이어**: 이름·직위·직책·상태·프로필·실제 회사 로고 renderer를 유지했다. 새 작은 카드에만 데이터 폰트·간격을 맞추고 로고 영역을 14px 고정 높이로 두어 숨겨진 fallback의 줄바꿈이 이름 영역과 겹치지 않게 했다. 로고의 투명 배경·정상 이미지 시 fallback 숨김은 유지했다. +7. **Storybook**: 사용자 메뉴 5개와 내부 Story 8개를 유지했다. Department Colors는 대표자 1종과 6개 부서의 2종, 총 13개 카드 specimen·실제 크기·자산 출처를 표시한다. 기존 대비 MODEL/미승인 후보 24개 출력도 유지했다. Full Composition은 같은 Core renderer를 사용한다. 새 메뉴를 만들거나 기존 Story를 대체하지 않았다. +8. **독립 패키지**: 자산·스타일은 @hm-design/org-chart 내부에 한정했다. React Wrapper/공개 exports/JSON/공통 Theme/AI Guard/허브/Summary/관계선은 변경하지 않았다. Navigation은 기존 Header 없는 Core 콘텐츠를 유지했다. 이번 작업에는 목록 검색 요구가 없어 FilterBar를 추가하지 않았다. + +## 변경 파일 — 이번 작업만 + +신규: + +- `packages/org-chart/src/assets/cards/` SVG 13개(전달 10개, 대표자 1개, 사업관리 생성 2개) 및 README.md. +- `packages/org-chart/src/core/department-card-assets.ts` +- `packages/org-chart/src/core/representative-card-asset.ts` +- `examples/org-chart/create-business-card-assets.py` +- `examples/org-chart/verify-card-design.mjs` +- `docs/org-chart/card-design-stage1.md` + +수정: + +- `packages/org-chart/src/core/employee-card.ts` — 장식 SVG 레이어/내부 자산 옵션. +- `packages/org-chart/src/core/render.ts` — 조직장·직원 부서 자산 선택. +- `packages/org-chart/src/core/org-chart-content.ts` — 대표자 전용 자산. +- `packages/org-chart/src/core/department-colors.ts` — 카드 specimen/크기/출처/대표자 표시. +- `packages/org-chart/src/core/vendor.d.ts` — svg?inline 타입 선언. +- `packages/org-chart/src/styles/employee-card.css` — 자산 variant 크기/장식 제거/데이터 레이어 조정. +- `packages/org-chart/src/styles/org-chart-content.css` — specimen caption 및 대표자 미리보기 정렬. + +생성 산출물: packages/org-chart/dist, examples/org-chart/dist, examples/org-chart-react/dist, storybook-static. 검증 근거는 기존 기록과 별도 `test-results/org-chart-card-design/`에 보존했다. + +## 최종 통합 검증 + +- Core Typecheck **PASS**: `npm.cmd run typecheck:org-chart`. +- Story/preview consumer Typecheck **PASS**: `npm.cmd exec -- tsc -p tsconfig.app.json --noEmit`. +- Core ESM/IIFE/types/CSS Build **PASS**: `npm.cmd run build:org-chart`. +- HTML prepare / React demo Build **PASS**. React Wrapper 소스 변경이 없어 기존 Wrapper 검증을 반복하지 않았다. +- Storybook production Build **PASS**. 기존 500kB chunk 경고는 남아 있으며 빌드 실패는 없다. +- 최종 Chrome **231/231 PASS**, console/runtime error **0**. +- Department Colors / Full Composition / HTML / React / file:// classic 각각 390/768/1280/1440px에서 로딩·원본 크기·배경 장식 제거·실제 DOM Range 텍스트와 프로필/로고의 카드 경계·겹침·페이지 overflow를 확인했다. +- 6개 부서 전체 매핑, 대표자 단독 top slot, 사업관리 조직장/직원 구분, 기존 status 카드 보존 **PASS**. 대표자/조직장/직원 프로필에 로컬 이미지 fixture를 넣어 경계와 로딩을 확인했으며 원본 JSON은 수정하지 않았다. +- HTML/React/file:// 카드의 computed geometry/장식/이미지 표시/위치 판정 일치 **PASS**. 전체 화면 픽셀 동일성이나 모든 사용자 데이터 조합의 적합성을 뜻하지 않는다. +- 원본 SVG 10개의 SHA-256 동일 **PASS**. Logo/JSON/Wrapper/Story 원본 보존은 preservation.json으로 확인한다. +- 사업관리 PNG와 신규 큰 SVG를 Chrome canvas로 원본 408×191 크기에 렌더링해 흰 배경 합성 RGB를 비교했다. 전체 픽셀 채널 평균 오차 **1.113/255**, 95백분위 **1.094/255**. geometry/안티앨리어싱 차이를 포함하며 임의의 지각적 동일성 인증은 아니다. 색상 유사성 검사 **PASS**, 직접 시각 확인 완료. +- 기존 Core 데이터·Summary·전체 Guard 회귀는 재실행하지 않았다. 중간 실패는 작은 카드 로고 높이와 PNG 내측 그라데이션 방향에 한정해 수정했으며, 수정된 산출물의 최종 표시를 재검증했다. + +### Guard 원본 결과 + +수정된 파일만 PARTIAL/STORYBOOK으로 실행했다. 대표자·사업관리 추가 요청으로 코드 graph가 변경된 후 해당 최종 범위를 한 번 더 실행했고, 기존 결과는 guard-five-departments.json으로 보존했다. 원본 status/staticStatus **FAIL**: TOKEN-001 8건, TOKEN-002 NOT VERIFIED 6건, SCOPE-001 NOT VERIFIED 4건. 기존 품질 보고와 같은 Shadow CSS/setProperty/선택적 입력 추적 문제이며 이 작업에서 공통 검사기를 변경하지 않았다. + +`styleEvidence`: STATIC **PASS**, RUNTIME **NOT VERIFIED**, DESIGN_INTENT **PASS**, FINAL **NOT VERIFIED**. 이 값은 Guard 증거 연결 상태다. 별도 브라우저 PASS가 raw FAIL을 덮어쓰지 않는다. 결합 styleVerification은 생성/승격하지 않았다. 카드 전용 하드코드 geometry는 공통 HM Component override가 아니라 승인된 OrgChart SVG의 원본 크기다. + +## 남은 이슈 + +- 검사한 가상 데이터의 텍스트·이미지·로고는 잘림/겹침 없이 표시된다. 매우 긴 소비자 이름·직책·임의 폰트 override는 **NOT VERIFIED**이며 고정 카드 크기에서 추가 정책이 필요할 수 있다. +- 작은 실제 로고의 판독성 한계는 남는다. 원본 로고는 crop/recolor하지 않았다. +- PNG로부터 SVG 그라데이션을 복원하므로 래스터 안티앨리어싱까지 정확히 같다는 보장은 없다. 투명도 포함 채널 비교와 원본 SHA를 보존했다. +- 허브·관계선·부서 헤더 대비의 기존 REVIEW와 Guard 추적 FAIL은 다음 별도 작업 대상이다. 전체 Workdesk 레이아웃 비교/통계 재설계/운영 API/전체 접근성 검증은 이번 범위에서 **NOT VERIFIED**. + +미리보기: `http://127.0.0.1:6028/?path=/story/orgchart--department-colors`, `http://127.0.0.1:6028/?path=/story/orgchart--full-composition`. 직접 열기 결과: `examples/org-chart/dist/p1-classic.html`. + +## 완료 기록 + +Started At: **2026-10-12 12:34:53 KST**. Completed At: **2026-10-12 12:56:20 KST**. Actual Duration: **21분 27초**. Estimated Duration: **최초 30–45분**, 추가 요청 후 **40–60분**. Variance: 최초 예상 대비 **하한 −8분 33초 / 상한 −23분 33초**. 시간은 실제 clock 도구로 확인했다. + +최종 SHA 보존 확인: baseline 111개 중 승인 범위 수정 7개, 나머지 104개 유지, 삭제 0개. 신규 소스/자산/문서 19개. root manifests/기존 Story/JSON/로고/React Wrapper/Guard/기존 보고서의 시작값은 유지했다. staged file 0개, branch/HEAD 동일. 근거: `test-results/org-chart-card-design/preservation.json`. + +Guard snapshot 후 자산 출처를 설명하는 주석과 문서만 정리했다. 실행 동작과 Guard 분석 대상의 Token/CSS 구현은 최종 검사 이후 변경하지 않았다. 정상 Typecheck/Build/browser 검사를 문서 편집 때문에 반복하지 않았다. diff --git a/docs/org-chart/card-design-stage2.md b/docs/org-chart/card-design-stage2.md new file mode 100644 index 0000000..4195fdc --- /dev/null +++ b/docs/org-chart/card-design-stage2.md @@ -0,0 +1,122 @@ +# OrgChart 카드 디자인 2단계 — Typography 및 내부 콘텐츠 + +> P3.2 후속 확정: 대표자 카드에는 모든 직책·역할·상태 뱃지와 빈 뱃지 행을 생성하지 않는다. `대표이사`·`홍 길 동`, 승인 카드 146×74 및 기존 Typography를 유지한다. `대표이사`는 표시 계층에만 적용하며 직원 데이터·팝업 원문과 다른 1Depth/2Depth 뱃지는 보존한다. [현재 표시 계약](ui-composition.md#p32-후속--대표자-뱃지-미사용-2026-10-12)이 아래 과거 기록보다 우선한다. + +> P3.2 최신 계약은 [P3.2 보고서](implementation-report-p3.2.md)를 따른다. 대표자는 그대로, 대형 이름 24px·직위 18px, 소형 이름 18px·직위 15px이다. 기존 가중치·line-height·컬러는 보존한다. readOnly 파견/휴직은 일반 소형의 108×52 카드·24×44 사진·텍스트 레이아웃을 공유하고 전용 회색 색상을 유지한다. 상태 타이틀은 일반 2Depth 기본 20px/700/19.7px, 카드 로고만 기존 실제 표시의 1.5배다. 아래 수치·검증은 역사 기록이다. + +> P3.1 최신 표시 계약은 [P3.1 보고서](implementation-report-p3.1.md)를 따른다. Noto Sans KR을 OrgChart scope에 적용하며 직위 공백은 표시에서만 제거한다. 조회 화면의 `경영진` 직책 뱃지만 제거하고 실제 직위·다른 뱃지·데이터는 보존한다. 사진 없음/실패에는 공통 사각 `person-placeholder.svg`를 표시한다. 아래 P3 및 P2의 수치와 검증은 역사 기록이다. + +> P3 최신 표시 계약: 카드/SVG 크기와 기존 Typography는 유지한다. 표시 이름만 `홍 길 동`, 직위·직책 뱃지는 원문이다. 대표자를 포함하여 직위와 뱃지는 같은 행(Gap 4px), 이름은 아래 행(Gap 0px)이다. 모든 조직 타이틀↔첫 카드 Gap은 8px이다. Workdesk 태그 Border/색상/폰트와 사각형 사진 슬롯을 적용하며, 사진 없음에는 새 프로필 이미지가 없다. 실제 결과는 [P3 보고서](implementation-report-p3.md)에 기록한다. 아래 P2 및 이전 수치·검증은 역사 기록으로 보존한다. + +RESULT: PARTIAL + +지정 Typography·Gap·말줄임·전체 이름 제공 구현과 해당 범위 검증은 PASS입니다. 제공한 Workdesk 캡처 경로의 파일과 폴더가 현재 없어 사진·텍스트·로고의 실제 운영 화면 정렬 일치는 NOT VERIFIED입니다. Guard 원본 FAIL도 유지합니다. + +## 작업 정보 + +- 작업 폴더: `D:\디자인시스템\HMDesign-OrgChart` +- 브랜치: `feature/orgchart-package` +- 작업 전/후 HEAD: `d18e3c33801e165e17b6d971e2f6198eed483c3c` (동일) +- Started At: 2026-10-12 12:58:39 KST +- Completed At: 2026-10-12 13:17:42 KST +- Actual Duration: 19분 3초 +- Estimated Duration: 30–45분 +- Variance: 최소 예상 30분보다 10분 57초 짧음 +- 기존 Working Tree 보존, Stage·Commit·Push·Merge·브랜치 생성 없음. + +## 구현 결과 + +1. 대표자: 직위 `#224387 / 20px / 400 / 22px / -0.8px`, 이름 `#071D3A / 24px / 600 / 30px / -0.8px` 적용. +2. 그룹장: 직위 `#797979 / 20px / 400 / 22px / -0.8px`, 이름 `#071D3A / 24px / 600 / 30px / -0.96px` 적용. +3. 일반 직원: 직위 `#797979 / 15px / 400 / 18px / -0.6px`, 이름 `#071D3A / 20px / 600 / 24px / -0.8px` 적용. 위 값의 순서는 color / font-size / font-weight / line-height / letter-spacing입니다. +4. 직위와 이름을 별도 세로 Flex 컨테이너에 두고 gap·margin·padding을 모두 0으로 적용했습니다. 카드 Padding과 사진·텍스트 사이 간격은 보존했습니다. +5. 사진은 왼쪽, 직위·이름은 오른쪽, 실제 회사 로고는 우측 상단에 유지했습니다. 기존 직책 배지는 사진 아래쪽 독립 영역으로 배치했습니다. SVG 카드의 상태 텍스트는 접근성 읽기에 유지하고 별도 세 번째 시각 행을 만들지 않았습니다. 파견·휴직 별도 영역의 카드 스타일은 보존했습니다. +6. 카드 크기는 대표자 146×74, 그룹장 146×68, 일반 직원 108×52입니다. 프로필 영역은 각각 36×36, 32×32, 24×24로 이전 단계와 동일합니다. 폰트·사진·카드 크기를 축소하지 않았습니다. +7. 긴 이름에만 말줄임을 적용합니다. 전체 문자열은 DOM에 유지하고, 실제 Overflow 카드만 Tab 순서에 포함합니다. Hover와 Focus로 같은 Shadow Root의 전체 이름 툴팁을 열고 `aria-describedby`로 연결합니다. 툴팁 위로 포인터를 이동할 수 있고 Escape로 닫을 수 있습니다. ResizeObserver·글꼴 로딩으로 판정을 갱신하고 기존 AbortSignal에서 관찰자·이벤트·툴팁을 정리합니다. +8. 기본 가상 이름 41개를 자연스러운 2–3음절 한국어 이름으로 바꿨습니다. 직원 ID·직위·소속·상태·프로필·인원수·JSON 계약은 그대로입니다. 실제 직원 개인정보를 복사하지 않았습니다. + +## Storybook 및 공통 적용 + +- Department Colors: 대표자와 6개 조직의 그룹장·직원 카드에 같은 Core Typography 적용. 기존 메뉴 내 “긴 이름 검증 시작/종료”로 대표자·그룹장·직원 각 1개의 긴 이름을 확인할 수 있습니다. 기본값은 짧은 이름입니다. +- Full Composition: 최상단 대표자, 6개 조직 그룹장, 일반 직원 카드의 기본 이름·Typography·로고 배치 확인. +- 사용자용 메뉴 Logo / Department Colors / Position Summary / Affiliation Summary / Full Composition 5개 유지. +- 내부 OrgChart/Content Story 8개 보존. 별도 신규 Story 메뉴 없음. +- HTML·React는 같은 Core 렌더러와 전용 CSS를 사용하며, 390/768/1280/1440px의 카드 Computed Style·크기·콘텐츠 측정값이 일치했습니다. +- 로컬 미리보기: [Department Colors](http://127.0.0.1:6030/?path=/story/orgchart--department-colors), [Full Composition](http://127.0.0.1:6030/?path=/story/orgchart--full-composition). + +## 변경 파일 + +신규 파일: + +- `packages/org-chart/src/core/person-name-overflow.ts`: Overflow 판정·Hover/Focus 툴팁·접근성·정리. +- `examples/org-chart/verify-card-content.mjs`: 이번 단계의 브라우저 검증. +- `docs/org-chart/card-design-stage2.md`: 이 보고서. + +수정 파일: + +- `packages/org-chart/src/core/employee-card.ts`: SVG 카드의 직위/이름 전용 컨테이너 및 배지 영역. +- `packages/org-chart/src/styles/employee-card.css`: 종류별 Typography·Gap·말줄임·Focus·툴팁. +- `src/org-chart/org-chart-p1.stories.tsx`: 기존 Department Colors 내 긴 이름 검증 토글. +- `examples/org-chart/create-p1-fixture.mjs`: 자연스러운 가상 이름 생성. +- `examples/org-chart/data-p1.json`: 이름 필드만 갱신. + +생성 결과: `packages/org-chart/dist/`, HTML/React demo dist, `storybook-static/`, `test-results/org-chart-card-content/`. 공통 Theme·Guard·공개 API·React Wrapper·차트 엔진·허브·Summary 구현은 변경하지 않았습니다. + +## 최종 검증 + +- 변경 패키지 Typecheck: PASS. +- Story 소비 코드 Typecheck (`tsconfig.app.json`): PASS. +- 변경 패키지 Build (ESM / browser IIFE / types / CSS): PASS. +- HTML 준비 및 React demo Build: PASS. +- Storybook production Build: PASS. 기존 큰 chunk 경고는 남아 있습니다. +- 실제 Chrome 브라우저: **368/368 PASS**, console/runtime 오류 0. +- 대표자/그룹장/직원 Computed Style, weight·line-height·letter-spacing·색상·Gap·margin·padding: PASS. +- Department Colors 및 Full Composition 렌더링: PASS. +- 짧은 이름: 줄바꿈·잘림 없이 표시, 불필요한 Tab 진입 없음. +- 긴 이름: Overflow 카드에만 ellipsis·Tab Focus·전체 이름 툴팁, Hover·툴팁 Hover·실제 키보드 Tab·Escape·Focus 유지·접근성 트리 전체 문자열: PASS. +- 긴 이름을 짧은 이름으로 복구할 때 툴팁 제거: PASS. +- 카드 크기와 프로필 크기 보존, 사진/텍스트/로고/배지 겹침, 가시 텍스트 범위, 페이지 Overflow: PASS. 실제 이미지 로딩 사례도 검증했습니다. +- HTML·React 4개 뷰포트 Computed Style/콘텐츠 일치: PASS. +- 데이터 비교: 이름 필드 외 값 동일, JSON 계약·직원 수·소속·프로필 보존 PASS. +- 시작 파일 130개 중 125개 해시 동일, 누락 없음. 변경은 위 5개 기존 파일뿐입니다. +- SVG 13개와 실제 로고 PNG 7개 해시 모두 동일. 이전 품질 보완 파일 및 1단계 보고서 보존. +- `git diff --check`: PASS. 기존 Stage 파일 없음, HEAD·브랜치 동일. +- 기존 1단계 231개 검사는 재실행하지 않았습니다. 통계·데이터 Core 전체·AI Guard 전체 회귀도 재실행하지 않았습니다. + +증거: + +- `test-results/org-chart-card-content/browser.json`: 브라우저 버전, 368개 결과, 카드별 측정값. +- `test-results/org-chart-card-content/preservation.json`: 해시 보존, Story 수, Git 상태. +- `test-results/org-chart-card-content/baseline.json`: 시작 파일 해시. +- `test-results/org-chart-card-content/data-p1-before.json`: 이름 변경 전 비교 기준. +- `test-results/org-chart-card-content/*-normal.png`, `composition-1440.png`, `representative-tooltip-visible.png`: 실제 렌더링 화면. + +### Guard 원본 상태 + +요청 파일을 대상으로 PARTIAL / STORYBOOK Guard를 1회 실행했습니다. 원본 `status=FAIL / staticStatus=FAIL`을 보존합니다. + +- TOKEN-001 FAIL 8개: 변경하지 않은 `department-colors.ts` 및 `style-presets.ts`의 OrgChart 전용 동적 CSS 변수 추적. +- 이전 1단계 FAIL 항목과 code·file·line·message가 모두 동일합니다. +- TOKEN-002 NOT VERIFIED 6개, 패키지 CSS 추적 SCOPE-001 NOT VERIFIED 1개. +- styleEvidence: STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT NOT VERIFIED / FINAL NOT VERIFIED. 기존 Story 레이아웃 CSS의 디자인 의도 항목을 포함한 원본 결과이며, 이번 브라우저 증거로 원본을 덮어쓰지 않았습니다. +- `test-results/org-chart-card-content/guard-storybook.json`에 전체 원본 저장. +- 이번 카드 동작의 PASS와 Guard 전체 상태를 구분합니다. Guard 수정은 범위에서 제외합니다. + +## 남은 차이 및 NOT VERIFIED + +- `D:\디자인시스템\HMDesign-OrgChart-References\workdesk-orgchart.png`와 상위 References 폴더가 현재 없습니다. 따라서 실제 Workdesk 사진·텍스트·로고 위치, 글꼴 및 미세 정렬 일치는 NOT VERIFIED입니다. 지정 확정 CSS 수치는 우선 적용했습니다. +- 기존 OrgChart 글꼴 설정을 유지했습니다. 실제 Workdesk의 글꼴 차이에 따른 시각적 차이는 캡처를 확인한 후 판단해야 합니다. +- 브라우저 접근성 트리와 키보드 동작은 PASS이나, 별도의 실제 스크린리더 음성 낭독은 NOT VERIFIED입니다. +- Guard의 기존 추적 FAIL/NOT VERIFIED는 미해소입니다. +- 긴 이름을 완전히 한 줄로 표시하려면 고정 카드 폭을 초과하므로, 사용자 확정대로 크기·폰트를 유지하고 말줄임과 전체 이름 툴팁을 적용했습니다. +- 전체 레이아웃·관계선·허브·통계 재설계는 다음 단계 범위이며 이번에는 변경하지 않았습니다. + +## P2 최신 확정 표시 규칙 — 2026-10-12 + +위 2단계 기록과 검증 결과는 보존한다. 조회 전용 Full Composition의 현재 적용 기준은 [P2 보고서](implementation-report-p2.md)다. + +- Depth 1 조직장만 146×68, Depth 2 이하 조직장은 108×52 소형 SVG. 독립 카드와 interactive 모드의 기존 조직장 선택은 유지한다. +- 가상 직원의 이름은 표시 계층에서 `홍 길 동`으로 통일하고 직위·직책에도 연속 한글 사이 공백을 적용한다. 원본 데이터 문자열에는 공백을 삽입하지 않는다. +- 기존 font-size, font-weight, line-height, 컬러와 내부 직위/뱃지 행 ↔ 이름 행 gap 0px는 유지한다. 글자 간격에 관한 이전 표시보다 이번 확정 지시를 우선한다. +- 외부 1Depth 헤더 ↔ 대형 카드 gap은 최종 8px이며, 이전 P2 초안의 10px는 적용하지 않는다. 아래 Depth 헤더 ↔ 소형 카드는 10px다. +- 긴 직책의 공백 표시가 회사 로고와 겹치지 않도록 조회 전용 대형 카드의 뱃지를 같은 행 안에서 아래로 정렬하고 로고 위치를 3px 올렸다. SVG·로고 원본, 글자/로고 크기 및 내부 행 높이는 변경하지 않았다. diff --git a/docs/org-chart/commit-preparation-p5-1a.txt b/docs/org-chart/commit-preparation-p5-1a.txt new file mode 100644 index 0000000..d587441 --- /dev/null +++ b/docs/org-chart/commit-preparation-p5-1a.txt @@ -0,0 +1,407 @@ +HMDesign OrgChart P5-1A Commit Preparation +Branch: feature/orgchart-package +HEAD / live remote main + feature: d18e3c33801e165e17b6d971e2f6198eed483c3c +Origin: https://gitea.hmac.kr/b25027/HMDesign.git +Stage / Commit / Push / Merge: NOT EXECUTED + +BASELINE P0-P5-0: 163 files (2 modified tracked + 161 untracked) +All stage labels are historical accumulated work, not Git commits. Individual P0/P1/P2 phase file ownership cannot be reconstructed from HEAD alone. + +docs/org-chart/README.md +docs/org-chart/architecture.md +docs/org-chart/card-design-stage1.md +docs/org-chart/card-design-stage2.md +docs/org-chart/data-contract.md +docs/org-chart/framework-integration.md +docs/org-chart/implementation-plan.md +docs/org-chart/implementation-report-p1.md +docs/org-chart/implementation-report-p2.md +docs/org-chart/implementation-report-p3.1.md +docs/org-chart/implementation-report-p3.2.md +docs/org-chart/implementation-report-p3.md +docs/org-chart/implementation-report-v0.1.md +docs/org-chart/integration.md +docs/org-chart/quality-review-p1.md +docs/org-chart/react-integration-report-v0.1.md +docs/org-chart/standalone-components-p1.md +docs/org-chart/ui-composition.md +examples/org-chart-react/README.md +examples/org-chart-react/app.tsx +examples/org-chart-react/index.css +examples/org-chart-react/index.html +examples/org-chart-react/p1.css +examples/org-chart-react/p1.html +examples/org-chart-react/p1.tsx +examples/org-chart-react/verify-boundaries.mjs +examples/org-chart-react/verify-package.mjs +examples/org-chart-react/verify.mjs +examples/org-chart-react/vite.config.ts +examples/org-chart/app.js +examples/org-chart/audit-quality-pixels.py +examples/org-chart/classic.html +examples/org-chart/create-business-card-assets.py +examples/org-chart/create-fixtures.mjs +examples/org-chart/create-p1-fixture.mjs +examples/org-chart/data-p1.json +examples/org-chart/data-v1.json +examples/org-chart/data-v2.json +examples/org-chart/demo.css +examples/org-chart/index.html +examples/org-chart/p1-app.js +examples/org-chart/p1-classic.html +examples/org-chart/p1.css +examples/org-chart/p1.html +examples/org-chart/prepare.mjs +examples/org-chart/serve.mjs +examples/org-chart/verify-card-content.mjs +examples/org-chart/verify-card-design.mjs +examples/org-chart/verify-final-card-check.mjs +examples/org-chart/verify-final-logo-check.mjs +examples/org-chart/verify-final-status-check.mjs +examples/org-chart/verify-guard-p1.mjs +examples/org-chart/verify-p1.mjs +examples/org-chart/verify-p2.mjs +examples/org-chart/verify-p3.1.mjs +examples/org-chart/verify-p3.2.mjs +examples/org-chart/verify-p3.mjs +examples/org-chart/verify-package.mjs +examples/org-chart/verify-quality-boundaries.mjs +examples/org-chart/verify-quality-p1.mjs +examples/org-chart/verify.mjs +packages/org-chart-react/README.md +packages/org-chart-react/build.mjs +packages/org-chart-react/package.json +packages/org-chart-react/src/OrgChart.tsx +packages/org-chart-react/src/index.ts +packages/org-chart-react/src/types.ts +packages/org-chart-react/tsconfig.build.json +packages/org-chart-react/tsconfig.json +packages/org-chart/README.md +packages/org-chart/build.mjs +packages/org-chart/package.json +packages/org-chart/src/assets/affiliations/README.md +packages/org-chart/src/assets/affiliations/baron.png +packages/org-chart/src/assets/affiliations/halla.png +packages/org-chart/src/assets/affiliations/hanmac.png +packages/org-chart/src/assets/affiliations/jangheon-industry.png +packages/org-chart/src/assets/affiliations/jangheon.png +packages/org-chart/src/assets/affiliations/ptc.png +packages/org-chart/src/assets/affiliations/saman.png +packages/org-chart/src/assets/cards/README.md +packages/org-chart/src/assets/cards/경영기획팀-sm.svg +packages/org-chart/src/assets/cards/경영기획팀.svg +packages/org-chart/src/assets/cards/대표자.svg +packages/org-chart/src/assets/cards/디자인기획팀-sm.svg +packages/org-chart/src/assets/cards/디자인기획팀.svg +packages/org-chart/src/assets/cards/사업관리팀-sm.svg +packages/org-chart/src/assets/cards/사업관리팀.svg +packages/org-chart/src/assets/cards/솔루션통합팀-sm.svg +packages/org-chart/src/assets/cards/솔루션통합팀.svg +packages/org-chart/src/assets/cards/인재성장팀-sm.svg +packages/org-chart/src/assets/cards/인재성장팀.svg +packages/org-chart/src/assets/cards/통합시스템 디비전-sm.svg +packages/org-chart/src/assets/cards/통합시스템 디비전.svg +packages/org-chart/src/assets/person-placeholder.svg +packages/org-chart/src/assets/workdesk/1-8-4x-1@2x.png +packages/org-chart/src/assets/workdesk/Frame2@3x.png +packages/org-chart/src/assets/workdesk/Group.svg +packages/org-chart/src/assets/workdesk/README.md +packages/org-chart/src/assets/workdesk/bg_main.png +packages/org-chart/src/assets/workdesk/bg_sky.png +packages/org-chart/src/assets/workdesk/team-arrow-blue-purple.png +packages/org-chart/src/assets/workdesk/team-arrow-blue-short.png +packages/org-chart/src/assets/workdesk/team-arrow-purple-teal.png +packages/org-chart/src/assets/workdesk/team-arrow-teal-green.png +packages/org-chart/src/company-color-presets.test.ts +packages/org-chart/src/company-color-presets.ts +packages/org-chart/src/components.ts +packages/org-chart/src/core/affiliation-bar.ts +packages/org-chart/src/core/affiliation-mark.ts +packages/org-chart/src/core/affiliation-summary.ts +packages/org-chart/src/core/contrast.test.ts +packages/org-chart/src/core/contrast.ts +packages/org-chart/src/core/department-card-assets.ts +packages/org-chart/src/core/department-colors.ts +packages/org-chart/src/core/employee-card.ts +packages/org-chart/src/core/employee-info-dialog.ts +packages/org-chart/src/core/engine.ts +packages/org-chart/src/core/index.ts +packages/org-chart/src/core/logo-legend.ts +packages/org-chart/src/core/org-chart-content.ts +packages/org-chart/src/core/org-chart-tree.ts +packages/org-chart/src/core/organization-totals.ts +packages/org-chart/src/core/person-name-overflow.ts +packages/org-chart/src/core/position-summary.ts +packages/org-chart/src/core/render.ts +packages/org-chart/src/core/representative-card-asset.ts +packages/org-chart/src/core/status-summary.ts +packages/org-chart/src/core/style-presets.ts +packages/org-chart/src/core/types.ts +packages/org-chart/src/core/vendor.d.ts +packages/org-chart/src/core/workdesk-artwork.ts +packages/org-chart/src/core/workdesk-layout.ts +packages/org-chart/src/data/content-selectors.test.ts +packages/org-chart/src/data/content-selectors.ts +packages/org-chart/src/data/data.test.ts +packages/org-chart/src/data/index.ts +packages/org-chart/src/data/normalize.ts +packages/org-chart/src/data/snapshot.ts +packages/org-chart/src/data/source.ts +packages/org-chart/src/data/summary.ts +packages/org-chart/src/data/types.ts +packages/org-chart/src/data/validate.ts +packages/org-chart/src/index.ts +packages/org-chart/src/styles/design-tokens.css +packages/org-chart/src/styles/employee-card.css +packages/org-chart/src/styles/employee-info-dialog.css +packages/org-chart/src/styles/org-chart-content.css +packages/org-chart/src/styles/org-chart-p3-summary.css +packages/org-chart/src/styles/org-chart-readonly.css +packages/org-chart/src/styles/org-chart.css +packages/org-chart/src/styles/summary-sections.css +packages/org-chart/test.mjs +packages/org-chart/tsconfig.build.json +packages/org-chart/tsconfig.json +src/org-chart/core-component-preview.tsx +src/org-chart/org-chart-cards.stories.tsx +src/org-chart/org-chart-p1.stories.tsx +src/org-chart/org-chart-stories.css +src/org-chart/org-chart-summary.stories.tsx +src/org-chart/org-chart.stories.tsx +package.json +package-lock.json + +P5-1A CHANGES TO EXISTING BASELINE: 9 + +packages/org-chart-react/README.md +packages/org-chart-react/src/OrgChart.tsx +packages/org-chart/README.md +packages/org-chart/build.mjs +packages/org-chart/src/core/index.ts +packages/org-chart/src/core/render.ts +packages/org-chart/src/core/types.ts +packages/org-chart/src/index.ts +packages/org-chart/src/styles/org-chart.css + +P5-1A NEW FILES: 9 + +examples/org-chart/data-expanded.json +examples/org-chart/verify-p5-1a.mjs +packages/org-chart/src/core/expanded-content.ts +packages/org-chart/src/core/expanded-layout.test.ts +packages/org-chart/src/core/expanded-layout.ts +packages/org-chart/src/styles/org-chart-expanded.css +src/org-chart/org-chart-expanded.stories.tsx +docs/org-chart/implementation-report-p5-1a.md +docs/org-chart/commit-preparation-p5-1a.txt + +CURRENT ENTIRE UNCOMMITTED FILE LIST: 172 + + M package-lock.json + M package.json +?? docs/org-chart/README.md +?? docs/org-chart/architecture.md +?? docs/org-chart/card-design-stage1.md +?? docs/org-chart/card-design-stage2.md +?? docs/org-chart/data-contract.md +?? docs/org-chart/framework-integration.md +?? docs/org-chart/implementation-plan.md +?? docs/org-chart/implementation-report-p1.md +?? docs/org-chart/implementation-report-p2.md +?? docs/org-chart/implementation-report-p3.1.md +?? docs/org-chart/implementation-report-p3.2.md +?? docs/org-chart/implementation-report-p3.md +?? docs/org-chart/implementation-report-v0.1.md +?? docs/org-chart/integration.md +?? docs/org-chart/quality-review-p1.md +?? docs/org-chart/react-integration-report-v0.1.md +?? docs/org-chart/standalone-components-p1.md +?? docs/org-chart/ui-composition.md +?? examples/org-chart-react/README.md +?? examples/org-chart-react/app.tsx +?? examples/org-chart-react/index.css +?? examples/org-chart-react/index.html +?? examples/org-chart-react/p1.css +?? examples/org-chart-react/p1.html +?? examples/org-chart-react/p1.tsx +?? examples/org-chart-react/verify-boundaries.mjs +?? examples/org-chart-react/verify-package.mjs +?? examples/org-chart-react/verify.mjs +?? examples/org-chart-react/vite.config.ts +?? examples/org-chart/app.js +?? examples/org-chart/audit-quality-pixels.py +?? examples/org-chart/classic.html +?? examples/org-chart/create-business-card-assets.py +?? examples/org-chart/create-fixtures.mjs +?? examples/org-chart/create-p1-fixture.mjs +?? examples/org-chart/data-expanded.json +?? examples/org-chart/data-p1.json +?? examples/org-chart/data-v1.json +?? examples/org-chart/data-v2.json +?? examples/org-chart/demo.css +?? examples/org-chart/index.html +?? examples/org-chart/p1-app.js +?? examples/org-chart/p1-classic.html +?? examples/org-chart/p1.css +?? examples/org-chart/p1.html +?? examples/org-chart/prepare.mjs +?? examples/org-chart/serve.mjs +?? examples/org-chart/verify-card-content.mjs +?? examples/org-chart/verify-card-design.mjs +?? examples/org-chart/verify-final-card-check.mjs +?? examples/org-chart/verify-final-logo-check.mjs +?? examples/org-chart/verify-final-status-check.mjs +?? examples/org-chart/verify-guard-p1.mjs +?? examples/org-chart/verify-p1.mjs +?? examples/org-chart/verify-p2.mjs +?? examples/org-chart/verify-p3.1.mjs +?? examples/org-chart/verify-p3.2.mjs +?? examples/org-chart/verify-p3.mjs +?? examples/org-chart/verify-p5-1a.mjs +?? examples/org-chart/verify-package.mjs +?? examples/org-chart/verify-quality-boundaries.mjs +?? examples/org-chart/verify-quality-p1.mjs +?? examples/org-chart/verify.mjs +?? packages/org-chart-react/README.md +?? packages/org-chart-react/build.mjs +?? packages/org-chart-react/package.json +?? packages/org-chart-react/src/OrgChart.tsx +?? packages/org-chart-react/src/index.ts +?? packages/org-chart-react/src/types.ts +?? packages/org-chart-react/tsconfig.build.json +?? packages/org-chart-react/tsconfig.json +?? packages/org-chart/README.md +?? packages/org-chart/build.mjs +?? packages/org-chart/package.json +?? packages/org-chart/src/assets/affiliations/README.md +?? packages/org-chart/src/assets/affiliations/baron.png +?? packages/org-chart/src/assets/affiliations/halla.png +?? packages/org-chart/src/assets/affiliations/hanmac.png +?? packages/org-chart/src/assets/affiliations/jangheon-industry.png +?? packages/org-chart/src/assets/affiliations/jangheon.png +?? packages/org-chart/src/assets/affiliations/ptc.png +?? packages/org-chart/src/assets/affiliations/saman.png +?? packages/org-chart/src/assets/cards/README.md +?? packages/org-chart/src/assets/cards/경영기획팀-sm.svg +?? packages/org-chart/src/assets/cards/경영기획팀.svg +?? packages/org-chart/src/assets/cards/대표자.svg +?? packages/org-chart/src/assets/cards/디자인기획팀-sm.svg +?? packages/org-chart/src/assets/cards/디자인기획팀.svg +?? packages/org-chart/src/assets/cards/사업관리팀-sm.svg +?? packages/org-chart/src/assets/cards/사업관리팀.svg +?? packages/org-chart/src/assets/cards/솔루션통합팀-sm.svg +?? packages/org-chart/src/assets/cards/솔루션통합팀.svg +?? packages/org-chart/src/assets/cards/인재성장팀-sm.svg +?? packages/org-chart/src/assets/cards/인재성장팀.svg +?? packages/org-chart/src/assets/cards/통합시스템 디비전-sm.svg +?? packages/org-chart/src/assets/cards/통합시스템 디비전.svg +?? packages/org-chart/src/assets/person-placeholder.svg +?? packages/org-chart/src/assets/workdesk/1-8-4x-1@2x.png +?? packages/org-chart/src/assets/workdesk/Frame2@3x.png +?? packages/org-chart/src/assets/workdesk/Group.svg +?? packages/org-chart/src/assets/workdesk/README.md +?? packages/org-chart/src/assets/workdesk/bg_main.png +?? packages/org-chart/src/assets/workdesk/bg_sky.png +?? packages/org-chart/src/assets/workdesk/team-arrow-blue-purple.png +?? packages/org-chart/src/assets/workdesk/team-arrow-blue-short.png +?? packages/org-chart/src/assets/workdesk/team-arrow-purple-teal.png +?? packages/org-chart/src/assets/workdesk/team-arrow-teal-green.png +?? packages/org-chart/src/company-color-presets.test.ts +?? packages/org-chart/src/company-color-presets.ts +?? packages/org-chart/src/components.ts +?? packages/org-chart/src/core/affiliation-bar.ts +?? packages/org-chart/src/core/affiliation-mark.ts +?? packages/org-chart/src/core/affiliation-summary.ts +?? packages/org-chart/src/core/contrast.test.ts +?? packages/org-chart/src/core/contrast.ts +?? packages/org-chart/src/core/department-card-assets.ts +?? packages/org-chart/src/core/department-colors.ts +?? packages/org-chart/src/core/employee-card.ts +?? packages/org-chart/src/core/employee-info-dialog.ts +?? packages/org-chart/src/core/engine.ts +?? packages/org-chart/src/core/expanded-content.ts +?? packages/org-chart/src/core/expanded-layout.test.ts +?? packages/org-chart/src/core/expanded-layout.ts +?? packages/org-chart/src/core/index.ts +?? packages/org-chart/src/core/logo-legend.ts +?? packages/org-chart/src/core/org-chart-content.ts +?? packages/org-chart/src/core/org-chart-tree.ts +?? packages/org-chart/src/core/organization-totals.ts +?? packages/org-chart/src/core/person-name-overflow.ts +?? packages/org-chart/src/core/position-summary.ts +?? packages/org-chart/src/core/render.ts +?? packages/org-chart/src/core/representative-card-asset.ts +?? packages/org-chart/src/core/status-summary.ts +?? packages/org-chart/src/core/style-presets.ts +?? packages/org-chart/src/core/types.ts +?? packages/org-chart/src/core/vendor.d.ts +?? packages/org-chart/src/core/workdesk-artwork.ts +?? packages/org-chart/src/core/workdesk-layout.ts +?? packages/org-chart/src/data/content-selectors.test.ts +?? packages/org-chart/src/data/content-selectors.ts +?? packages/org-chart/src/data/data.test.ts +?? packages/org-chart/src/data/index.ts +?? packages/org-chart/src/data/normalize.ts +?? packages/org-chart/src/data/snapshot.ts +?? packages/org-chart/src/data/source.ts +?? packages/org-chart/src/data/summary.ts +?? packages/org-chart/src/data/types.ts +?? packages/org-chart/src/data/validate.ts +?? packages/org-chart/src/index.ts +?? packages/org-chart/src/styles/design-tokens.css +?? packages/org-chart/src/styles/employee-card.css +?? packages/org-chart/src/styles/employee-info-dialog.css +?? packages/org-chart/src/styles/org-chart-content.css +?? packages/org-chart/src/styles/org-chart-expanded.css +?? packages/org-chart/src/styles/org-chart-p3-summary.css +?? packages/org-chart/src/styles/org-chart-readonly.css +?? packages/org-chart/src/styles/org-chart.css +?? packages/org-chart/src/styles/summary-sections.css +?? packages/org-chart/test.mjs +?? packages/org-chart/tsconfig.build.json +?? packages/org-chart/tsconfig.json +?? src/org-chart/core-component-preview.tsx +?? src/org-chart/org-chart-cards.stories.tsx +?? src/org-chart/org-chart-expanded.stories.tsx +?? src/org-chart/org-chart-p1.stories.tsx +?? src/org-chart/org-chart-stories.css +?? src/org-chart/org-chart-summary.stories.tsx +?? src/org-chart/org-chart.stories.tsx +?? docs/org-chart/implementation-report-p5-1a.md +?? docs/org-chart/commit-preparation-p5-1a.txt + +REQUIRED SOURCE DEPENDENCIES +- package.json + package-lock.json: workspace package registrations and orgchart6.0.0 pinned dependency tree. +- packages/org-chart/: manifest, build.mjs, tsconfigs, all runtime src/data/core/styles/assets; public exports refer to generated dist. +- packages/org-chart-react/: manifest, build.mjs, tsconfigs, src; requires Core public dist declarations first. +- src/org-chart/ and referenced examples/org-chart/data-p1.json + data-expanded.json; original CoreComponentPreview depends on Core internal baseline source. +- Vanilla examples need prepare/serve + app/HTML/CSS/JSON; React examples need TSX/HTML/CSS/vite.config. +- verify*.mjs, *.test.ts, audit/asset-generation scripts are authored verification/reproduction source, not generated caches. +- Existing HM UI exports and Storybook shared preview require their existing built public dist; no source changes or package installation in this task. + +DO NOT INCLUDE GENERATED OUTPUTS / CACHES +packages/org-chart/dist/ +packages/org-chart-react/dist/ +examples/org-chart/dist/ +examples/org-chart-react/dist/ +storybook-static/ +test-results/ (preserve local snapshots, JSON, captures; no forced add) +node_modules/ +_local/ +playwright-report/ +coverage/ +*.log +*.tsbuildinfo +*.zip +.env and private local settings + +RECOMMENDED COMMIT GROUPS AFTER USER APPROVAL +1. P0-P5-0 baseline: the 163-file starting source snapshot, manifests/lock, packages/assets/styles, examples/fixtures/Storybook/docs/authored verification. +2. P5-1A: the 9 modified baseline files + 9 new files listed above. +Several baseline files are still untracked: staging current file paths cannot separate their old/new contents. +Starting source snapshot: test-results/org-chart-p5-1a/baseline-source/ +Starting SHA256 inventory: test-results/org-chart-p5-1a/baseline-files.json +Use that captured baseline and an exact reviewed P5-1A patch if two commits are required. +If snapshot data is unavailable later, prefer one reviewed accumulated OrgChart commit over inventing historical phase boundaries. +A P5-1A-only commit directly against current HEAD omits Core/cards/JSON/React/workspace registrations and is incomplete. +Use explicit file selection and staged diff review. Do not use git add -A. Recheck live remote before later commit/push. diff --git a/docs/org-chart/consistency-review-p11-1.md b/docs/org-chart/consistency-review-p11-1.md new file mode 100644 index 0000000..a9884a2 --- /dev/null +++ b/docs/org-chart/consistency-review-p11-1.md @@ -0,0 +1,270 @@ +# HMDesign OrgChart P11.1 — 최종 정합성 검증 + +**RESULT: PASS — 요청 범위의 문서·공개 API·CSS·Storybook 정합성 및 Feature Commit 조건 충족.** 이 보고서는 Commit 전 검증 기록이며 최종 Commit/Push SHA·원격 일치·Working Tree 결과는 작업 최종 응답에 기록한다. main 병합·배포·최종 디자인 승인 완료를 뜻하지 않는다. + +## 1. 검사 기준과 범위 + +| 항목 | 확인값 | +|---|---| +| 작업 경로 | `D:\디자인시스템\HMDesign-OrgChart-Recovered` | +| 브랜치 | `feature/orgchart-package` | +| 검사 기준 HEAD / 시작 원격 Feature | `dcfa4e0895eecc97ce516cbf3d4424bbaadfddeb` | +| 시작 원격 main | `c45d63a03b84eaac43f01bd400a16488a80e4229` | +| origin | `https://gitea.hmac.kr/b25027/HMDesign.git` | +| 작성자 | `b25027 ` | +| 시작 변경 | P11 정상 산출물 26개, staged 0. [P11 보고서](implementation-report-p11.md)의 목록과 일치 | +| P11.1 추가 변경 | 사용 가이드의 문서 오류 보완 및 이 보고서 1개. 제품·공개 타입·Story 소스 변경 0 | +| 최종 Stage 후보 | P11 26개 + 이 보고서 = 27개. 임시 로그·캡처·dist·node_modules는 Git ignored이며 제외 | + +이번 작업의 명시적 경로·Feature Commit/Push 지시를 공통 지침의 공식 경로/main 지시보다 우선했다. main Checkout/Merge/Push, PR, Registry Publish, Docker, 배포, 다른 Worktree 정리, Reset/Rebase/Force Push를 수행하지 않는다. + +## 2. 정합성 결과표 + +| 검사 대상 | 문서 | 실제 코드 | Storybook | 결과 | +|---|---|---|---|---| +| Core 공개 API | 가이드 import·옵션·Handle 대조 | manifest/공개 export/구현/`.d.ts` 확인 | Full/Expanded 렌더·대표 동작 | PASS | +| React Wrapper | 기본/HM Entry·Props·Adapter 대조 | optional peer, 타입 상속·설정 전달 확인 | Full/Expanded React 19 렌더 | PASS | +| Chart Only | 기본 fit/direct·Fixed 거부·UI 제외 대조 | Core 기본 분기·viewport·wrapper 전달 확인 | 별도 React/Vanilla 화면 | PASS | +| 외부 CSS 테마 | 이름·적용 범위·우선순위 수정 | CSS 선언/사용 추출, 예제 입력 39개 연결 | React/Vanilla 테마 대표 화면 | PASS | +| HMDesign Dialog | 별도 HM Theme/Portal 범위 대조 | `/hm-dialog` 및 HMEmployeeDialog 확인 | Expanded Dialog·ESC·focus 복귀 | PASS | +| 회사별 컬러 | 7개 회사색/secondary 입력 대조 | TS 프리셋과 CSS fallback 일치 | Expanded 실제 computed 7색 | PASS | +| Fixed 디자인 | 6개 부서색·SVG/PNG 한계 대조 | 기존 자산·구성 보존 | Full/Department/Overview | PASS | +| AI 생성 정책 | 사전 선택·가변 구조·원본 보존 | enum/parentId 트리·회사 경계 대조 | 기존 Story 비소급 범위 확인 | PASS(정적 정책) | +| Guard 토큰 | P10 원본/P11 현재 결과 구분 | 실제 재검사 FAIL 0, 규칙 비활성화 없음 | 실제 theme 렌더는 별도 증거 | PASS(정적), Runtime NOT VERIFIED | + +Core 공개 export는 `createOrgChart`, DataSource/Validator/집계/이동 함수와 독립 presenter이며 새 API를 만들지 않았다. `layoutMode`는 Fixed/Auto, `autoLayout.type`은 TB/LR/Hybrid다. Auto의 Zoom/Pan/Fit·접기·편집·직원 팝업은 기존 구현을 사용하며 CSV/XLSX parser, 공개 collapse setter, React imperative ref, 서버 저장 API는 없다. JSON 최소/깊은 예제 및 필수·nullable 필드는 현재 `data/types.ts`·Validator와 일치한다. + +React 기본 Entry는 Core lifecycle·Props만 사용하고 HM UI를 import하지 않는다. `@hm-design/ui` peer는 optional, React peer는 `^19.0.0`이며 현재 19.3.0에서 렌더링했다. 선택 Entry `/hm-dialog`는 기존 HM UI Dialog를 제공하며 `employeePopup="hm-dialog"`를 명시해야 한다. `employeePopupAdapter`도 모드를 함께 지정한다. Fixed는 기존 Core 팝업/`onEmployeeClick` 경계를 유지한다. HM UI 없는 fresh install은 [기존 최소 설치 증거](minimal-install-report-2026-10-13.md)를 재사용했고 이번에 다시 설치하지 않았다. + +## 3. 공개 화면과 Chart Only + +공개 index에는 `OrgChart/Overview`, `Department Colors`, `Full Composition`, `Expanded OrgChart` 4개가 그대로 있다. ID는 각각 `orgchart--logo-personnel-summary`, `orgchart--department-colors`, `orgchart--full-composition`, `orgchart--expanded-org-chart`다. OrgChart는 최상위 그룹이며 Components/Patterns와 같은 Depth다. 신규 Story 등록·기존 ID/URL 교체 없음. + +| 화면 | 실제 확인 | +|---|---| +| Overview | 로고 legend 7개, 중복 내부 h2 없음, 동일 `data-p1.json` Core 집계/표·하단 관계사 총계 41명. 하단 집계에 반복된 회사 로고를 legend 수에 더하지 않음 | +| Department Colors | 대표자·그룹장·직원, 6개 서로 다른 기존 gradient, 모든 직원 이름 `홍 길 동`, 파견/휴직 preview. 기존 카드·Typography 캡처 확인, Cell Border Only 미적용 | +| Full Composition | 대표자·화살촉 1개·협업 PNG 화살표 5개·중앙 구성·상단 제목/인원표·하단 파견/휴직·관계사 현황·팝업 X/카드 focus 복귀 확인. 390px 반응형/내부 스크롤 판정은 [P10.10](visual-review-p10-10.md) 재사용, 새 수정 사유 없음 | +| Expanded OrgChart | 최초 7회사×20=140명, 회사 선택, 한맥기술 단독 20/100/300명 실제 카드 수, 300명 TB/LR/Hybrid 유지, 7회사 기본 computed 색, 기본 D&D OFF, ON의 직원/그룹 소속 이동(키보드 편집 경로)·300명 보존, OFF Pan, HM Dialog ESC/focus 복귀, 기존 Zoom/Fit 컨트롤 유지 | +| React/Vanilla Chart Only | 기본 흰 캔버스, 외부 heading/selection/toolbar 없음, 전체 실제 Bounds 초기 Fit/가로·세로 중앙, 직접 wheel·커서 기준 anchor, 직원 Core 팝업, 접기/재확장 확인. Fit은 빈 engine wrapper 여백 대신 실제 조직/조직 연결 path bounds로 측정 | + +Chart Only는 `layoutMode='auto'` + `presentationMode='chart-only'`, 기본 `initialView='fit'` / `wheelZoom='direct'`다. 기본 Expanded에는 이 모드를 전달하지 않아 기존 제목·요약·툴바 및 modifier wheel 정책을 유지한다. Fixed Chart Only는 Core `TypeError` 경로로 명시적 미지원이다. React와 Vanilla는 같은 Core 설정을 사용하며 호스트가 캔버스 크기를 제공한다. P11 React fixture의 host 높이 미지정 fallback 320px와 Vanilla의 850px 컨테이너를 동일 크기라고 보고하지 않는다. + +신규 대표 검사: 최초 16개 중 10 PASS/6 FAIL. 소스 대조 후 실패 6개만 보정해 6/6 PASS + 재검사 오류 0. 최초 결과를 보존한다. 원인은 logo legend와 하단 반복 로고 합산, 3종류 composition region의 6개 slot을 3개 element로 예상, Fit의 engine wrapper 여백 포함, 상태 gradient 카드에 일반 active 카드 backgroundColor 기대였다. 제품 버그나 신규 제품 변경은 없었다. + +현재 캡처를 확인한 공개 Story 4개는 렌더 PASS다. 기존 Story 전체의 변경 전후 픽셀 동일성이나 사용자의 최종 디자인 승인을 뜻하지 않는다. 순수 pointer D&D의 모든 조합은 이번에 재실행하지 않았으며 키보드 편집은 실제 ON의 공통 validation/engine drop/move 경로를 사용했다. + +## 4. CSS 변수·연결·기본값 + +실제 PostCSS AST에서 532개 변수 선언/사용 위치를 추출했다. P11 tracked CSS 변경에 나타난 66개 이름과 신규 `color-presets.css`를 합치면 116개이며, 관련 기존 공통 컬러 입력 12개를 더해 부록에 128개 이름을 기록했다. **128개가 모두 신규 공개 변수라는 뜻은 아니다.** 공개 입력, 기존 입력, 내부 resolved/current alias·geometry를 구분했다. 예제 CSS 3개의 서로 다른 공개 입력 39개는 모두 실제 `var(...)` 사용 경로가 있으며 가상의 이름은 없다. + +- 공개 입력은 상속을 위해 내부에 고정 선언하지 않는다. 기본값은 해당 scope의 소비 표현식 fallback에 있다. 내부 선언·default·selector/적용 속성은 부록에 기록한다. React/Vanilla는 같은 Core CSS를 사용하므로 모든 행의 CSS 적용 경로가 공통이다. +- 회사 ID primary 7개 기본값은 변경하지 않았다. 보조 입력은 6회사 secondary-1과 ptc secondary-2만 연결되고 baron 보조 입력은 없다. 보조 alias를 주요 카드/제목의 추가 컬러 역할로 오인하지 않는다. +- Fixed 6부서의 gradient/Border/default는 기존 TS 프리셋과 일치한다. 공통 그룹 배경은 회사/부서 제목 배경보다 우선한다. 부서별 배경·fallback 텍스트와 공통 Border·compact HTML 이름의 우선순위 차이를 문서에 보완했다. +- 파견/휴직 Fixed 요약 gradient·Border·텍스트와 Auto 상태 카드 gradient·Border·텍스트를 구분한다. Fixed `*-gradient`는 `background-image`에 연결하므로 CSS image 표현식이 필요하다. 단색 CSS color를 그 입력에 넣는다고 일반 배경값과 동일하게 적용되지 않는다. +- Core dialog 배경/글자/Border, 닫기 Hover/Focus는 Core 내부 입력이다. `/hm-dialog`는 Shadow 밖 HM Theme/Portal이며 자동 동기화하지 않는다. + +`stylesheetUrls.core`는 기본 CSS 전체를 포함한 URL 하나다. 기본 CSS `@import` 뒤에 `:host` override를 두며 engine→core 순서로 Shadow link를 로드한다. Vite는 공개 CSS/외부 CSS의 `?url`, HTTP ESM 소비자는 실제 서빙 URL을 사용한다. 변수 전용 host CSS는 document에 연결하고 class로 상속한다. 일반 host selector가 Shadow 내부를 통과하지 않는다. + +Shadow `:host`의 직접 지정값이 조상 상속보다 우선하며 소비자가 같은 입력을 inline 지정하면 해당 inline이 우선한다. P11 Core의 회사·부서 기본색 inline `setProperty`는 제거됐고 dataset에 따른 CSS 해석으로 바뀌었다. `setProperty` 자체를 새 테마 API로 문서화하거나 Guard 자동 승인에 사용하지 않았다. 호스트 class 제거의 기본값 복원과 배율 보존, React/Vanilla 외부 테마·팝업·Hover/Focus·4폭은 P11 104/104 PASS 증거를 재사용했다. 이번 대표 테마 검사는 두 runtime의 회사 제목·active 카드·연결선·상태 gradient·Core 팝업을 추가 확인했다. + +Fixed SVG/PNG 내부 색상은 CSS 변수로 바뀌지 않는다. 실제 inline SVG overlay 연결선은 CSS로 변경되지만 이미지 화살표·로고·배경·카드 SVG 불투명 영역은 그대로다. 외부 Theme 기능은 삼안/장헌산업 흰 제목 대비나 보안 Advisory를 자동 해결하지 않는다. + +## 5. 문서 보완과 예제 검증 + +| 발견된 문서 불일치 | 최소 수정 | +|---|---| +| 하단 제한에 잔여 Guard FAIL 5건이 현재 상태처럼 기재 | P10 당시 기록과 P11 실제 FAIL 0건을 분리, 원본 보고서 링크 보존 | +| 외부 CSS 테마 표준화가 현재 미포함처럼 기재 | P11 지원과 Fixed 이미지/Chart Only 미지원을 분리 | +| Fixed 카드 변수 우선순위 전체 일반화 | 부서 배경/fallback 텍스트, 공통 Border/accent, SVG/compact HTML 이름을 구분 | +| 모든 gradient 입력에 background 표현식을 허용한 설명 | Fixed `background-image`용 CSS image 표현식 필요 명시 | +| company secondary의 임의 n처럼 읽히는 표 | 실제 ID/n 조합 명시 | +| HTTP Vanilla snippet의 Element/null 컨테이너 | `getElementById` + 존재 확인으로 HTMLElement 계약 명시 | + +가이드의 TS/TSX 13개와 HTTP Vanilla JS 1개를 추출해 strict·ES2022·Bundler·react-jsx·skipLibCheck=false로 **14/14 타입 검사 PASS**. 공개 package `.d.ts`와 Vite client CSS URL 타입을 사용했다. 짧은 theme 예제의 앞 문맥 `dataSource`만 harness 선언으로 제공하고, HTTP ESM 경로는 실제 공개 Core 타입으로 해석했다. API·Props를 임의 생성하거나 잘못된 옵션을 mock으로 감추지 않았다. P11 TSX fixture도 함께 검사했다. + +전체 문서 예제의 개별 browser 실행은 NOT VERIFIED다. HMChart/CustomChart 자체의 browser 시연 대신 실제 HM Entry Story 및 Core/React fixture를 확인했다. P10.7·UsageGuide 당시 검증 원본을 지우거나 후속 결과로 덮어쓰지 않았다. + +AI 정책의 사전 질문, 이미 명시된 방식 재질문 금지, Chart Only 최소 UI, Full Page HM UI 우선 재사용, 정확한 계층/인원/제공 데이터 보존, 기존 화면 비소급은 현재 API와 일치한다. parentId 기반 가변 계층과 division/office/div/team/cell enum을 소스에서 확인했으며 유형별 새 디자인 자동 선택으로 설명하지 않는다. Cell Border Only 제외 및 Fixed Chart Only 제약을 보존했다. 새 조직도 생성 요청이 아니므로 이번 감사에 생성 방식 질문을 추가하지 않았다. + +## 6. 검증·Guard·보존 증거 + +| 확인 | 결과 / 근거 | +|---|---| +| Core/React 변경 범위 Typecheck | 신규 실행 PASS (`typecheck:org-chart`, `typecheck:org-chart-react`) | +| Core/React/소비 예제 Build | 신규 실행 PASS, CSS URL production assets 생성. 기존 host-context/minifier·큰 chunk 경고 유지 | +| 문서 예제/fixture Typecheck | 신규 실행 PASS, `test-results/org-chart-p11-1/tsconfig.json` | +| Storybook 4개 화면 렌더 | 신규 4/4 PASS, 오류 0. 공개 index/데이터/대표 동작은 신규 browser/recheck 결과 | +| React/Vanilla Chart Only·Theme 대표 | 신규 browser + 실패 한정 recheck PASS, console/page 오류 0 | +| 실제 Guard | 신규 재검사 `status PASS`, `staticStatus PASS`, issues 0 / FAIL 0 | +| Guard CSS phases | STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT PASS / FINAL NOT VERIFIED. 결합 styleVerification 미생성 유지 | +| Guard 회귀 | **기존 P11 94/94 및 CSS 회귀 5/5 PASS 재사용**. 검사기 P11.1 변경 0이라 반복하지 않음. 실제 Guard 결과와 별개 | +| 다른 HM 패키지 Guard 영향 | 기존 P11 실제 비교: 이슈 67개 동일, FAIL 0, 원본 NOT VERIFIED 보존. 변경 없는 동일 분석기/제품 소스 범위 재사용 | +| 기본 디자인 6화면 | P11 before/after PNG 12파일을 읽어 기록 SHA-256을 재확인, 6/6 동일. 신규 baseline 캡처·승인 아님 | +| Full 390px/4폭/P11 HTTP Vanilla | P10.10 Full 390px·P11 4폭 104개·순수 HTTP Core 8개 PASS 기존 유효 결과 재사용 | +| Markdown 링크/경로·diff | 변경 문서 4개 로컬 링크/앵커 236개 PASS, `git diff --check` PASS. Staged 검사는 Commit 직전 수행 | + +Guard는 다음 한 번의 실제 검사로 확인했다. 규칙 우회·예외 목록·미선언 token 자동 승인·Runtime PASS 승격 없음. + +```powershell +node scripts/ai-generation-guard.mjs --target . --scope PARTIAL --profile STORYBOOK --files packages/org-chart/src/core/department-colors.ts,packages/org-chart/src/core/style-presets.ts,packages/org-chart/src/core/expanded-content.ts --json +``` + +증거는 ignored 로컬 폴더에 보존하며 새 Clone에 포함되지 않는다. `verify-p11.mjs`의 기본값 비교는 변경 전 산출물에서 `--baseline`으로 생성한 기록을 필요로 한다. 새 Clone에서 baseline이 없는 실행을 역사적 기본 디자인 보존 검사로 해석하지 않는다: + +- [현재 검사 원본](../../test-results/org-chart-p11-1/browser-result.json), [실패 한정 재검사](../../test-results/org-chart-p11-1/recheck-result.json), [실제 Guard](../../test-results/org-chart-p11-1/guard-actual.json), [CSS 전체 추출](../../test-results/org-chart-p11-1/css-inventory.json), [예제 manifest](../../test-results/org-chart-p11-1/snippet-manifest.json). +- [Chart Only React 최초 화면](../../test-results/org-chart-p11-1/initial-react.png), [Vanilla 최초 화면](../../test-results/org-chart-p11-1/initial-vanilla.png), [HM Dialog](../../test-results/org-chart-p11-1/hm-dialog.png). +- [기본 6화면 해시](../../test-results/org-chart-p11/default-image-comparison.json), [P11 공용 비교](../../test-results/org-chart-p11/guard-other-comparison.json), [P11 구현·검증](implementation-report-p11.md). + +실행 URL: [Storybook](http://127.0.0.1:6034/), [React Chart Only](http://127.0.0.1:6026/p11.html?runtime=react&mode=auto), [Vanilla Chart Only](http://127.0.0.1:6026/p11.html?runtime=vanilla&mode=auto), [React 외부 테마](http://127.0.0.1:6026/p11.html?runtime=react&mode=auto&theme=1&statuses=1). 서버는 이 작업에서 시작했고 검토용으로 유지했다. sandbox 로컬 연결 제한을 해소하기 위해 서버/Chrome을 같은 실행 환경에서 실행했다. 연결 실패를 제품 오류로 판정하지 않았다. + +## 7. 미검증과 Commit 판정 + +**Commit 가능: PASS.** 공개 API 일치·테마/Chart Only/4 Story 유지·필수 타입/빌드·실제 Guard FAIL 0·기본 6화면 보존·정상 변경 27개·작성자 일치를 확인했다. 관련 파일만 Stage하고 Staged Diff·원격 Feature/main SHA를 재확인한 뒤 일반 Feature Fast-forward Push한다. 원격이 기준에서 바뀌면 중단하며 main은 건드리지 않는다. + +NOT VERIFIED: 모든 pointer D&D 조합·회사×100/300명×모든 layout의 전체 회귀, HM Dialog 별도 팔레트 변경 비교, HMChart/CustomChart 자체 browser 시연, 전체 Story before/after 픽셀 비교, Firefox/Safari/실제 모바일/touch/스크린리더, 전체 성능·SSR·새 fresh install·신규 npm audit·배포. 사용자 최종 디자인 승인 PENDING. Core 86개·Chart Only 51개 및 직접 영향 없는 P7~P10 전체 회귀는 반복하지 않았다. + +기존 개발 High Advisory 7건, 삼안 약 2.64:1·장헌산업 약 4.29:1 제목 대비 미해결 기록을 보존한다. P11 Guard 개선/테마 기능을 보안 해결·WCAG 자동 충족으로 표현하지 않는다. Fixed Chart Only와 Fixed 이미지 내부 테마화는 미검증 후보가 아닌 미지원이다. + +## 8. 작업 시간 + +- Started At: 2026-10-13 22:26:04 KST +- Estimated Duration: 최초 약 60분 (분석 20 / 검증 30 / 보고·Git 10) +- 검증 완료·최종 Git 완료의 Completed At / Actual Duration / Variance는 작업 최종 응답에 기록한다. 이 파일은 Commit 전 기록이므로 미래 완료 시간을 추정하지 않는다. + +## 9. P11 변수 소스 대조 부록 + +각 이름의 실제 선언 또는 첫 사용 표현식·selector/속성을 기록한다. 공개 입력은 내부 선언 없음(상속용)이며 표현식의 fallback이 기본값이다. 공개 행은 호스트 상속 또는 Shadow 외부 CSS override 가능, 내부 행은 계산 구현이며 직접 override를 권장하지 않는다. 모든 행은 같은 Core CSS를 쓰는 React/Vanilla 공통 경로다. 여러 scope에서 fallback이 다르면 해당 소스 전체가 최종 기준이며 첫 표현식만 유일한 기본값으로 해석하지 않는다. 카드 SVG/PNG 영역·HM Dialog는 앞의 지원 경계를 따른다. + + + +| 실제 이름 | 구분 | 선언/사용 위치 | scope / 적용 속성 | 선언값 또는 소비 표현식(default fallback 포함) | +|---|---|---|---|---| +| `--hm-orgchart-accent` | 내부/기존 선언 | [color-presets.css:11](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="0"]` → `--hm-orgchart-accent` | `var(--hm-orgchart-current-border)` | +| `--hm-orgchart-accent-color` | 공개 입력(상속) | [design-tokens.css:17](../../packages/org-chart/src/styles/design-tokens.css) | `.hm-orgchart-shell` → `--hm-orgchart-resolved-accent` | `var(--hm-orgchart-accent-color, #eef2fa)` | +| `--hm-orgchart-action-primary` | 공개 입력(상속) | [design-tokens.css:19](../../packages/org-chart/src/styles/design-tokens.css) | `.hm-orgchart-shell` → `--hm-orgchart-resolved-action-primary` | `var(--hm-orgchart-action-primary, #45639f)` | +| `--hm-orgchart-bg-brand` | 공개 입력(상속) | [design-tokens.css:11](../../packages/org-chart/src/styles/design-tokens.css) | `.hm-orgchart-shell` → `--hm-orgchart-resolved-bg-brand` | `var(--hm-orgchart-bg-brand, linear-gradient(180deg, #45639f 0%, #809dd7 100%))` | +| `--hm-orgchart-bg-content` | 공개 입력(상속) | [design-tokens.css:9](../../packages/org-chart/src/styles/design-tokens.css) | `.hm-orgchart-shell` → `--hm-orgchart-resolved-bg-content` | `var(--hm-orgchart-bg-content, #f4f7fb)` | +| `--hm-orgchart-bg-subtle` | 공개 입력(상속) | [design-tokens.css:10](../../packages/org-chart/src/styles/design-tokens.css) | `.hm-orgchart-shell` → `--hm-orgchart-resolved-bg-subtle` | `var(--hm-orgchart-bg-subtle, #e9edf3)` | +| `--hm-orgchart-bg-surface` | 공개 입력(상속) | [design-tokens.css:8](../../packages/org-chart/src/styles/design-tokens.css) | `.hm-orgchart-shell` → `--hm-orgchart-resolved-bg-surface` | `var(--hm-orgchart-bg-surface, #ffffff)` | +| `--hm-orgchart-border` | 공개 입력(상속) | [color-presets.css:9](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="0"]` → `--hm-orgchart-current-border` | `var(--hm-orgchart-business-management-border, var(--hm-orgchart-border, #194d82))` | +| `--hm-orgchart-business-management-border` | 공개 입력(상속) | [color-presets.css:9](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="0"]` → `--hm-orgchart-current-border` | `var(--hm-orgchart-business-management-border, var(--hm-orgchart-border, #194d82))` | +| `--hm-orgchart-business-management-card-background` | 공개 입력(상속) | [color-presets.css:6](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="0"]` → `--hm-orgchart-current-card-background` | `var(--hm-orgchart-business-management-card-background, var(--hm-orgchart-card-background, var(--hm-orgchart-resolved-bg-surface)))` | +| `--hm-orgchart-business-management-card-color` | 공개 입력(상속) | [color-presets.css:7](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="0"]` → `--hm-orgchart-current-card-color` | `var(--hm-orgchart-business-management-card-color, var(--hm-orgchart-card-color, var(--hm-orgchart-resolved-fg-primary)))` | +| `--hm-orgchart-business-management-department-color` | 공개 입력(상속) | [color-presets.css:8](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="0"]` → `--hm-orgchart-current-department-color` | `var(--hm-orgchart-business-management-department-color, var(--hm-orgchart-department-color, #ffffff))` | +| `--hm-orgchart-business-management-gradient` | 공개 입력(상속) | [color-presets.css:10](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="0"]` → `--hm-orgchart-current-gradient` | `var(--hm-orgchart-business-management-gradient, var(--hm-orgchart-gradient, linear-gradient(180deg, #194d82 0%, #1d395c 100%)))` | +| `--hm-orgchart-business-planning-border` | 공개 입력(상속) | [color-presets.css:19](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="1"]` → `--hm-orgchart-current-border` | `var(--hm-orgchart-business-planning-border, var(--hm-orgchart-border, #2164a8))` | +| `--hm-orgchart-business-planning-card-background` | 공개 입력(상속) | [color-presets.css:16](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="1"]` → `--hm-orgchart-current-card-background` | `var(--hm-orgchart-business-planning-card-background, var(--hm-orgchart-card-background, var(--hm-orgchart-resolved-bg-surface)))` | +| `--hm-orgchart-business-planning-card-color` | 공개 입력(상속) | [color-presets.css:17](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="1"]` → `--hm-orgchart-current-card-color` | `var(--hm-orgchart-business-planning-card-color, var(--hm-orgchart-card-color, var(--hm-orgchart-resolved-fg-primary)))` | +| `--hm-orgchart-business-planning-department-color` | 공개 입력(상속) | [color-presets.css:18](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="1"]` → `--hm-orgchart-current-department-color` | `var(--hm-orgchart-business-planning-department-color, var(--hm-orgchart-department-color, #ffffff))` | +| `--hm-orgchart-business-planning-gradient` | 공개 입력(상속) | [color-presets.css:20](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="1"]` → `--hm-orgchart-current-gradient` | `var(--hm-orgchart-business-planning-gradient, var(--hm-orgchart-gradient, linear-gradient(180deg, #2164a8 0%, #254975 100%)))` | +| `--hm-orgchart-card-accent` | 공개 입력(상속) | [employee-card.css:40](../../packages/org-chart/src/styles/employee-card.css) | `.hm-orgchart-employee[data-card-kind="leader"], .hm-orgchart-employee[data-card-kind="topLeader"]` → `border-top` | `var(--hm-orgchart-resolved-border-top-width) solid var(--hm-orgchart-card-accent, var(--hm-orgchart-current-border, var(--hm-orgchart-resolved-action-primary)))` | +| `--hm-orgchart-card-background` | 공개 입력(상속) | [color-presets.css:6](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="0"]` → `--hm-orgchart-current-card-background` | `var(--hm-orgchart-business-management-card-background, var(--hm-orgchart-card-background, var(--hm-orgchart-resolved-bg-surface)))` | +| `--hm-orgchart-card-border` | 공개 입력(상속) | [employee-card.css:14](../../packages/org-chart/src/styles/employee-card.css) | `.hm-orgchart-employee[data-card-kind]` → `border` | `var(--hm-orgchart-resolved-border-width) solid var(--hm-orgchart-card-border, var(--hm-orgchart-current-border, var(--hm-orgchart-resolved-stroke-default)))` | +| `--hm-orgchart-card-color` | 공개 입력(상속) | [color-presets.css:7](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="0"]` → `--hm-orgchart-current-card-color` | `var(--hm-orgchart-business-management-card-color, var(--hm-orgchart-card-color, var(--hm-orgchart-resolved-fg-primary)))` | +| `--hm-orgchart-card-focus-color` | 공개 입력(상속) | [employee-info-dialog.css:10](../../packages/org-chart/src/styles/employee-info-dialog.css) | `.hm-orgchart-organization-link:focus-visible, .hm-orgchart-employee[role="button"]:focus-visible` → `outline` | `2px solid var(--hm-orgchart-card-focus-color, var(--hm-orgchart-resolved-stroke-input-focus))` | +| `--hm-orgchart-card-hover-background` | 공개 입력(상속) | [org-chart-expanded.css:117](../../packages/org-chart/src/styles/org-chart-expanded.css) | `.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-card-presentation="expanded"]:hover` → `background` | `var(--hm-orgchart-card-hover-background, var(--hm-orgchart-resolved-status-card-background, var(--hm-orgchart-resolved-role-card-background)))` | +| `--hm-orgchart-card-position-color` | 공개 입력(상속) | [employee-card.css:242](../../packages/org-chart/src/styles/employee-card.css) | `.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-person-role` → `color` | `var(--hm-orgchart-card-position-color, var(--hm-orgchart-person-position-color))` | +| `--hm-orgchart-company-baron` | 공개 입력(상속) | [color-presets.css:103](../../packages/org-chart/src/styles/color-presets.css) | `.hm-orgchart-expanded [data-company-id="baron"]` → `--hm-orgchart-expanded-primary` | `var(--hm-orgchart-company-baron, #0C367F)` | +| `--hm-orgchart-company-halla` | 공개 입력(상속) | [color-presets.css:97](../../packages/org-chart/src/styles/color-presets.css) | `.hm-orgchart-expanded [data-company-id="halla"]` → `--hm-orgchart-expanded-primary` | `var(--hm-orgchart-company-halla, #0068B7)` | +| `--hm-orgchart-company-halla-secondary-1` | 공개 입력(상속) | [color-presets.css:99](../../packages/org-chart/src/styles/color-presets.css) | `.hm-orgchart-expanded [data-company-id="halla"]` → `--hm-orgchart-expanded-secondary-1` | `var(--hm-orgchart-company-halla-secondary-1, #231815)` | +| `--hm-orgchart-company-hanmac` | 공개 입력(상속) | [color-presets.css:66](../../packages/org-chart/src/styles/color-presets.css) | `.hm-orgchart-expanded [data-company-id="hanmac"]` → `--hm-orgchart-expanded-primary` | `var(--hm-orgchart-company-hanmac, #CB2E28)` | +| `--hm-orgchart-company-hanmac-secondary-1` | 공개 입력(상속) | [color-presets.css:68](../../packages/org-chart/src/styles/color-presets.css) | `.hm-orgchart-expanded [data-company-id="hanmac"]` → `--hm-orgchart-expanded-secondary-1` | `var(--hm-orgchart-company-hanmac-secondary-1, #1D4289)` | +| `--hm-orgchart-company-jangheon` | 공개 입력(상속) | [color-presets.css:84](../../packages/org-chart/src/styles/color-presets.css) | `.hm-orgchart-expanded [data-company-id="jangheon"]` → `--hm-orgchart-expanded-primary` | `var(--hm-orgchart-company-jangheon, #1D418D)` | +| `--hm-orgchart-company-jangheon-industry` | 공개 입력(상속) | [color-presets.css:78](../../packages/org-chart/src/styles/color-presets.css) | `.hm-orgchart-expanded [data-company-id="jangheon-industry"]` → `--hm-orgchart-expanded-primary` | `var(--hm-orgchart-company-jangheon-industry, #4580A6)` | +| `--hm-orgchart-company-jangheon-industry-secondary-1` | 공개 입력(상속) | [color-presets.css:80](../../packages/org-chart/src/styles/color-presets.css) | `.hm-orgchart-expanded [data-company-id="jangheon-industry"]` → `--hm-orgchart-expanded-secondary-1` | `var(--hm-orgchart-company-jangheon-industry-secondary-1, #92BED4)` | +| `--hm-orgchart-company-jangheon-secondary-1` | 공개 입력(상속) | [color-presets.css:86](../../packages/org-chart/src/styles/color-presets.css) | `.hm-orgchart-expanded [data-company-id="jangheon"]` → `--hm-orgchart-expanded-secondary-1` | `var(--hm-orgchart-company-jangheon-secondary-1, #AE232A)` | +| `--hm-orgchart-company-ptc` | 공개 입력(상속) | [color-presets.css:90](../../packages/org-chart/src/styles/color-presets.css) | `.hm-orgchart-expanded [data-company-id="ptc"]` → `--hm-orgchart-expanded-primary` | `var(--hm-orgchart-company-ptc, #84338E)` | +| `--hm-orgchart-company-ptc-secondary-1` | 공개 입력(상속) | [color-presets.css:92](../../packages/org-chart/src/styles/color-presets.css) | `.hm-orgchart-expanded [data-company-id="ptc"]` → `--hm-orgchart-expanded-secondary-1` | `var(--hm-orgchart-company-ptc-secondary-1, #F7AF00)` | +| `--hm-orgchart-company-ptc-secondary-2` | 공개 입력(상속) | [color-presets.css:93](../../packages/org-chart/src/styles/color-presets.css) | `.hm-orgchart-expanded [data-company-id="ptc"]` → `--hm-orgchart-expanded-secondary-2` | `var(--hm-orgchart-company-ptc-secondary-2, #0066B5)` | +| `--hm-orgchart-company-saman` | 공개 입력(상속) | [color-presets.css:72](../../packages/org-chart/src/styles/color-presets.css) | `.hm-orgchart-expanded [data-company-id="saman"]` → `--hm-orgchart-expanded-primary` | `var(--hm-orgchart-company-saman, #F08300)` | +| `--hm-orgchart-company-saman-secondary-1` | 공개 입력(상속) | [color-presets.css:74](../../packages/org-chart/src/styles/color-presets.css) | `.hm-orgchart-expanded [data-company-id="saman"]` → `--hm-orgchart-expanded-secondary-1` | `var(--hm-orgchart-company-saman-secondary-1, #6D6C6C)` | +| `--hm-orgchart-current-border` | 내부/기존 선언 | [color-presets.css:9](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="0"]` → `--hm-orgchart-current-border` | `var(--hm-orgchart-business-management-border, var(--hm-orgchart-border, #194d82))` | +| `--hm-orgchart-current-card-background` | 내부/기존 선언 | [color-presets.css:6](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="0"]` → `--hm-orgchart-current-card-background` | `var(--hm-orgchart-business-management-card-background, var(--hm-orgchart-card-background, var(--hm-orgchart-resolved-bg-surface)))` | +| `--hm-orgchart-current-card-color` | 내부/기존 선언 | [color-presets.css:7](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="0"]` → `--hm-orgchart-current-card-color` | `var(--hm-orgchart-business-management-card-color, var(--hm-orgchart-card-color, var(--hm-orgchart-resolved-fg-primary)))` | +| `--hm-orgchart-current-department-color` | 내부/기존 선언 | [color-presets.css:8](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="0"]` → `--hm-orgchart-current-department-color` | `var(--hm-orgchart-business-management-department-color, var(--hm-orgchart-department-color, #ffffff))` | +| `--hm-orgchart-current-gradient` | 내부/기존 선언 | [color-presets.css:10](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="0"]` → `--hm-orgchart-current-gradient` | `var(--hm-orgchart-business-management-gradient, var(--hm-orgchart-gradient, linear-gradient(180deg, #194d82 0%, #1d395c 100%)))` | +| `--hm-orgchart-department-color` | 공개 입력(상속) | [color-presets.css:8](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="0"]` → `--hm-orgchart-current-department-color` | `var(--hm-orgchart-business-management-department-color, var(--hm-orgchart-department-color, #ffffff))` | +| `--hm-orgchart-design-planning-border` | 공개 입력(상속) | [color-presets.css:49](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="4"]` → `--hm-orgchart-current-border` | `var(--hm-orgchart-design-planning-border, var(--hm-orgchart-border, #09a7a2))` | +| `--hm-orgchart-design-planning-card-background` | 공개 입력(상속) | [color-presets.css:46](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="4"]` → `--hm-orgchart-current-card-background` | `var(--hm-orgchart-design-planning-card-background, var(--hm-orgchart-card-background, var(--hm-orgchart-resolved-bg-surface)))` | +| `--hm-orgchart-design-planning-card-color` | 공개 입력(상속) | [color-presets.css:47](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="4"]` → `--hm-orgchart-current-card-color` | `var(--hm-orgchart-design-planning-card-color, var(--hm-orgchart-card-color, var(--hm-orgchart-resolved-fg-primary)))` | +| `--hm-orgchart-design-planning-department-color` | 공개 입력(상속) | [color-presets.css:48](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="4"]` → `--hm-orgchart-current-department-color` | `var(--hm-orgchart-design-planning-department-color, var(--hm-orgchart-department-color, #ffffff))` | +| `--hm-orgchart-design-planning-gradient` | 공개 입력(상속) | [color-presets.css:50](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="4"]` → `--hm-orgchart-current-gradient` | `var(--hm-orgchart-design-planning-gradient, var(--hm-orgchart-gradient, linear-gradient(180deg, #09a7a2 0%, #00847f 100%)))` | +| `--hm-orgchart-dialog-background` | 공개 입력(상속) | [employee-info-dialog.css:21](../../packages/org-chart/src/styles/employee-info-dialog.css) | `.hm-orgchart-employee-dialog` → `background` | `var(--hm-orgchart-dialog-background, var(--hm-orgchart-resolved-bg-surface))` | +| `--hm-orgchart-dialog-border` | 공개 입력(상속) | [employee-info-dialog.css:19](../../packages/org-chart/src/styles/employee-info-dialog.css) | `.hm-orgchart-employee-dialog` → `border` | `var(--hm-orgchart-resolved-border-width) solid var(--hm-orgchart-dialog-border, var(--hm-orgchart-resolved-stroke-default))` | +| `--hm-orgchart-dialog-close-color` | 공개 입력(상속) | [employee-info-dialog.css:38](../../packages/org-chart/src/styles/employee-info-dialog.css) | `.hm-orgchart-employee-dialog-close` → `--hm-orgchart-dialog-close-ink` | `var(--hm-orgchart-dialog-close-color, var(--foreground, var(--hm-sem-fg-primary, #0a221d)))` | +| `--hm-orgchart-dialog-close-hover` | 내부/기존 선언 | [employee-info-dialog.css:39](../../packages/org-chart/src/styles/employee-info-dialog.css) | `.hm-orgchart-employee-dialog-close` → `--hm-orgchart-dialog-close-hover` | `var(--hm-orgchart-dialog-close-hover-background, var(--muted, var(--hm-sem-bg-subtle, #f4f1ea)))` | +| `--hm-orgchart-dialog-close-hover-background` | 공개 입력(상속) | [employee-info-dialog.css:39](../../packages/org-chart/src/styles/employee-info-dialog.css) | `.hm-orgchart-employee-dialog-close` → `--hm-orgchart-dialog-close-hover` | `var(--hm-orgchart-dialog-close-hover-background, var(--muted, var(--hm-sem-bg-subtle, #f4f1ea)))` | +| `--hm-orgchart-dialog-close-ink` | 내부/기존 선언 | [employee-info-dialog.css:38](../../packages/org-chart/src/styles/employee-info-dialog.css) | `.hm-orgchart-employee-dialog-close` → `--hm-orgchart-dialog-close-ink` | `var(--hm-orgchart-dialog-close-color, var(--foreground, var(--hm-sem-fg-primary, #0a221d)))` | +| `--hm-orgchart-dialog-close-ring` | 내부/기존 선언 | [employee-info-dialog.css:40](../../packages/org-chart/src/styles/employee-info-dialog.css) | `.hm-orgchart-employee-dialog-close` → `--hm-orgchart-dialog-close-ring` | `var(--hm-orgchart-dialog-focus-color, var(--ring, var(--hm-sem-stroke-input-focus, #266651)))` | +| `--hm-orgchart-dialog-color` | 공개 입력(상속) | [employee-info-dialog.css:22](../../packages/org-chart/src/styles/employee-info-dialog.css) | `.hm-orgchart-employee-dialog` → `color` | `var(--hm-orgchart-dialog-color, var(--hm-orgchart-resolved-fg-primary))` | +| `--hm-orgchart-dialog-focus-color` | 공개 입력(상속) | [employee-info-dialog.css:40](../../packages/org-chart/src/styles/employee-info-dialog.css) | `.hm-orgchart-employee-dialog-close` → `--hm-orgchart-dialog-close-ring` | `var(--hm-orgchart-dialog-focus-color, var(--ring, var(--hm-sem-stroke-input-focus, #266651)))` | +| `--hm-orgchart-dispatched-card-border` | 공개 입력(상속) | [org-chart-expanded.css:107](../../packages/org-chart/src/styles/org-chart-expanded.css) | `.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-work-status="dispatched"]` → `border-color` | `var(--hm-orgchart-dispatched-card-border, var(--hm-orgchart-card-border, var(--hm-orgchart-resolved-stroke-default)))` | +| `--hm-orgchart-dispatched-card-color` | 공개 입력(상속) | [org-chart-expanded.css:108](../../packages/org-chart/src/styles/org-chart-expanded.css) | `.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-work-status="dispatched"]` → `color` | `var(--hm-orgchart-dispatched-card-color, var(--hm-orgchart-card-color, var(--hm-orgchart-resolved-fg-primary)))` | +| `--hm-orgchart-dispatched-card-gradient` | 공개 입력(상속) | [org-chart-expanded.css:105](../../packages/org-chart/src/styles/org-chart-expanded.css) | `.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-work-status="dispatched"]` → `--hm-orgchart-resolved-status-card-background` | `var(--hm-orgchart-dispatched-card-gradient, var(--hm-orgchart-resolved-role-card-background))` | +| `--hm-orgchart-dispatched-title-background` | 공개 입력(상속) | [summary-sections.css:142](../../packages/org-chart/src/styles/summary-sections.css) | `.hm-orgchart-status-group[data-work-status="dispatched"]` → `--hm-orgchart-resolved-status-title-background` | `var(--hm-orgchart-dispatched-title-background, var(--hm-orgchart-status-title-background, linear-gradient(180deg, #87909c 0%, #68727e 100%)))` | +| `--hm-orgchart-expanded-card-accent-width` | 내부/기존 선언 | [org-chart-expanded.css:154](../../packages/org-chart/src/styles/org-chart-expanded.css) | `.hm-orgchart-expanded` → `--hm-orgchart-expanded-card-accent-width` | `3px` | +| `--hm-orgchart-expanded-card-border-width` | 내부/기존 선언 | [org-chart-expanded.css:153](../../packages/org-chart/src/styles/org-chart-expanded.css) | `.hm-orgchart-expanded` → `--hm-orgchart-expanded-card-border-width` | `1px` | +| `--hm-orgchart-expanded-card-primary` | 내부/기존 선언 | [color-presets.css:67](../../packages/org-chart/src/styles/color-presets.css) | `.hm-orgchart-expanded [data-company-id="hanmac"]` → `--hm-orgchart-expanded-card-primary` | `var(--hm-orgchart-company-hanmac, #CB2E28)` | +| `--hm-orgchart-expanded-line-color` | 공개 입력(상속) | [org-chart-expanded.css:10](../../packages/org-chart/src/styles/org-chart-expanded.css) | `.hm-orgchart-expanded` → `--hm-orgchart-resolved-expanded-line-color` | `var(--hm-orgchart-expanded-line-color, #738399)` | +| `--hm-orgchart-expanded-line-width` | 내부/기존 선언 | [org-chart-expanded.css:8](../../packages/org-chart/src/styles/org-chart-expanded.css) | `.hm-orgchart-expanded` → `--hm-orgchart-expanded-line-width` | `var(--hm-orgchart-resolved-border-width)` | +| `--hm-orgchart-expanded-primary` | 내부/기존 선언 | [color-presets.css:66](../../packages/org-chart/src/styles/color-presets.css) | `.hm-orgchart-expanded [data-company-id="hanmac"]` → `--hm-orgchart-expanded-primary` | `var(--hm-orgchart-company-hanmac, #CB2E28)` | +| `--hm-orgchart-expanded-secondary-1` | 내부/기존 선언 | [color-presets.css:68](../../packages/org-chart/src/styles/color-presets.css) | `.hm-orgchart-expanded [data-company-id="hanmac"]` → `--hm-orgchart-expanded-secondary-1` | `var(--hm-orgchart-company-hanmac-secondary-1, #1D4289)` | +| `--hm-orgchart-expanded-secondary-2` | 내부/기존 선언 | [color-presets.css:93](../../packages/org-chart/src/styles/color-presets.css) | `.hm-orgchart-expanded [data-company-id="ptc"]` → `--hm-orgchart-expanded-secondary-2` | `var(--hm-orgchart-company-ptc-secondary-2, #0066B5)` | +| `--hm-orgchart-fg-primary` | 공개 입력(상속) | [design-tokens.css:12](../../packages/org-chart/src/styles/design-tokens.css) | `.hm-orgchart-shell` → `--hm-orgchart-resolved-fg-primary` | `var(--hm-orgchart-fg-primary, #10243b)` | +| `--hm-orgchart-fg-secondary` | 공개 입력(상속) | [design-tokens.css:13](../../packages/org-chart/src/styles/design-tokens.css) | `.hm-orgchart-shell` → `--hm-orgchart-resolved-fg-secondary` | `var(--hm-orgchart-fg-secondary, #596575)` | +| `--hm-orgchart-fixed-line-color` | 공개 입력(상속) | [org-chart-readonly.css:204](../../packages/org-chart/src/styles/org-chart-readonly.css) | `.hm-orgchart-workdesk-overlay path[data-connector]` → `stroke` | `var(--hm-orgchart-fixed-line-color, #45639F)` | +| `--hm-orgchart-gradient` | 공개 입력(상속) | [color-presets.css:10](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="0"]` → `--hm-orgchart-current-gradient` | `var(--hm-orgchart-business-management-gradient, var(--hm-orgchart-gradient, linear-gradient(180deg, #194d82 0%, #1d395c 100%)))` | +| `--hm-orgchart-group-background` | 내부/기존 선언 | [color-presets.css:12](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="0"]` → `--hm-orgchart-group-background` | `var(--hm-orgchart-current-gradient)` | +| `--hm-orgchart-group-border` | 공개 입력(상속) | [org-chart-content.css:73](../../packages/org-chart/src/styles/org-chart-content.css) | `.hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-organization` → `border-top` | `var(--hm-orgchart-resolved-border-top-width) solid var(--hm-orgchart-group-border, var(--hm-orgchart-current-border))` | +| `--hm-orgchart-group-title-background` | 공개 입력(상속) | [org-chart-content.css:100](../../packages/org-chart/src/styles/org-chart-content.css) | `.hm-orgchart-engine.orgchart .node[data-header-role="organization"] .hm-orgchart-organization-name` → `background` | `var(--hm-orgchart-group-title-background, var(--hm-orgchart-group-background))` | +| `--hm-orgchart-group-title-color` | 공개 입력(상속) | [org-chart-content.css:95](../../packages/org-chart/src/styles/org-chart-content.css) | `.hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-organization-name` → `color` | `var(--hm-orgchart-group-title-color, var(--hm-orgchart-current-department-color))` | +| `--hm-orgchart-integrated-system-border` | 공개 입력(상속) | [color-presets.css:39](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="3"]` → `--hm-orgchart-current-border` | `var(--hm-orgchart-integrated-system-border, var(--hm-orgchart-border, #8c67b6))` | +| `--hm-orgchart-integrated-system-card-background` | 공개 입력(상속) | [color-presets.css:36](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="3"]` → `--hm-orgchart-current-card-background` | `var(--hm-orgchart-integrated-system-card-background, var(--hm-orgchart-card-background, var(--hm-orgchart-resolved-bg-surface)))` | +| `--hm-orgchart-integrated-system-card-color` | 공개 입력(상속) | [color-presets.css:37](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="3"]` → `--hm-orgchart-current-card-color` | `var(--hm-orgchart-integrated-system-card-color, var(--hm-orgchart-card-color, var(--hm-orgchart-resolved-fg-primary)))` | +| `--hm-orgchart-integrated-system-department-color` | 공개 입력(상속) | [color-presets.css:38](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="3"]` → `--hm-orgchart-current-department-color` | `var(--hm-orgchart-integrated-system-department-color, var(--hm-orgchart-department-color, #ffffff))` | +| `--hm-orgchart-integrated-system-gradient` | 공개 입력(상속) | [color-presets.css:40](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="3"]` → `--hm-orgchart-current-gradient` | `var(--hm-orgchart-integrated-system-gradient, var(--hm-orgchart-gradient, linear-gradient(180deg, #8c67b6 0%, #5d3494 100%)))` | +| `--hm-orgchart-leader-card-background` | 공개 입력(상속) | [org-chart-expanded.css:99](../../packages/org-chart/src/styles/org-chart-expanded.css) | `.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-card-kind="leader"]` → `background` | `var(--hm-orgchart-leader-card-background, var(--hm-orgchart-card-background, var(--hm-orgchart-resolved-bg-surface)))` | +| `--hm-orgchart-on-accent` | 공개 입력(상속) | [design-tokens.css:18](../../packages/org-chart/src/styles/design-tokens.css) | `.hm-orgchart-shell` → `--hm-orgchart-resolved-on-accent` | `var(--hm-orgchart-on-accent, #224387)` | +| `--hm-orgchart-on-action-primary` | 공개 입력(상속) | [design-tokens.css:20](../../packages/org-chart/src/styles/design-tokens.css) | `.hm-orgchart-shell` → `--hm-orgchart-resolved-on-action-primary` | `var(--hm-orgchart-on-action-primary, #ffffff)` | +| `--hm-orgchart-on-leave-card-border` | 공개 입력(상속) | [org-chart-expanded.css:113](../../packages/org-chart/src/styles/org-chart-expanded.css) | `.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-work-status="onLeave"]` → `border-color` | `var(--hm-orgchart-on-leave-card-border, var(--hm-orgchart-card-border, var(--hm-orgchart-resolved-stroke-default)))` | +| `--hm-orgchart-on-leave-card-color` | 공개 입력(상속) | [org-chart-expanded.css:114](../../packages/org-chart/src/styles/org-chart-expanded.css) | `.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-work-status="onLeave"]` → `color` | `var(--hm-orgchart-on-leave-card-color, var(--hm-orgchart-card-color, var(--hm-orgchart-resolved-fg-primary)))` | +| `--hm-orgchart-on-leave-card-gradient` | 공개 입력(상속) | [org-chart-expanded.css:111](../../packages/org-chart/src/styles/org-chart-expanded.css) | `.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-work-status="onLeave"]` → `--hm-orgchart-resolved-status-card-background` | `var(--hm-orgchart-on-leave-card-gradient, var(--hm-orgchart-resolved-role-card-background))` | +| `--hm-orgchart-on-leave-title-background` | 공개 입력(상속) | [summary-sections.css:148](../../packages/org-chart/src/styles/summary-sections.css) | `.hm-orgchart-status-group[data-work-status="onLeave"]` → `--hm-orgchart-resolved-status-title-background` | `var(--hm-orgchart-on-leave-title-background, var(--hm-orgchart-status-title-background, linear-gradient(180deg, #87909c 0%, #68727e 100%)))` | +| `--hm-orgchart-person-position-color` | 내부/기존 선언 | [employee-card.css:206](../../packages/org-chart/src/styles/employee-card.css) | `.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])` → `--hm-orgchart-person-position-color` | `#797979` | +| `--hm-orgchart-resolved-action-primary` | 내부/기존 선언 | [design-tokens.css:19](../../packages/org-chart/src/styles/design-tokens.css) | `.hm-orgchart-shell` → `--hm-orgchart-resolved-action-primary` | `var(--hm-orgchart-action-primary, #45639f)` | +| `--hm-orgchart-resolved-bg-surface` | 내부/기존 선언 | [design-tokens.css:8](../../packages/org-chart/src/styles/design-tokens.css) | `.hm-orgchart-shell` → `--hm-orgchart-resolved-bg-surface` | `var(--hm-orgchart-bg-surface, #ffffff)` | +| `--hm-orgchart-resolved-border-top-width` | 내부/기존 선언 | [design-tokens.css:35](../../packages/org-chart/src/styles/design-tokens.css) | `.hm-orgchart-shell` → `--hm-orgchart-resolved-border-top-width` | `var(--hm-orgchart-border-top-width, 3px)` | +| `--hm-orgchart-resolved-border-width` | 내부/기존 선언 | [design-tokens.css:34](../../packages/org-chart/src/styles/design-tokens.css) | `.hm-orgchart-shell` → `--hm-orgchart-resolved-border-width` | `var(--hm-orgchart-border-width, 1px)` | +| `--hm-orgchart-resolved-expanded-line-color` | 내부/기존 선언 | [org-chart-expanded.css:10](../../packages/org-chart/src/styles/org-chart-expanded.css) | `.hm-orgchart-expanded` → `--hm-orgchart-resolved-expanded-line-color` | `var(--hm-orgchart-expanded-line-color, #738399)` | +| `--hm-orgchart-resolved-fg-primary` | 내부/기존 선언 | [design-tokens.css:12](../../packages/org-chart/src/styles/design-tokens.css) | `.hm-orgchart-shell` → `--hm-orgchart-resolved-fg-primary` | `var(--hm-orgchart-fg-primary, #10243b)` | +| `--hm-orgchart-resolved-on-action-primary` | 내부/기존 선언 | [design-tokens.css:20](../../packages/org-chart/src/styles/design-tokens.css) | `.hm-orgchart-shell` → `--hm-orgchart-resolved-on-action-primary` | `var(--hm-orgchart-on-action-primary, #ffffff)` | +| `--hm-orgchart-resolved-role-card-background` | 내부/기존 선언 | [org-chart-expanded.css:119](../../packages/org-chart/src/styles/org-chart-expanded.css) | `.hm-orgchart-expanded .hm-orgchart-employee[data-card-kind="employee"]` → `--hm-orgchart-resolved-role-card-background` | `var(--hm-orgchart-card-background, var(--hm-orgchart-resolved-bg-surface))` | +| `--hm-orgchart-resolved-status-card-background` | 내부/기존 선언 | [org-chart-expanded.css:105](../../packages/org-chart/src/styles/org-chart-expanded.css) | `.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-work-status="dispatched"]` → `--hm-orgchart-resolved-status-card-background` | `var(--hm-orgchart-dispatched-card-gradient, var(--hm-orgchart-resolved-role-card-background))` | +| `--hm-orgchart-resolved-status-card-border` | 내부/기존 선언 | [summary-sections.css:144](../../packages/org-chart/src/styles/summary-sections.css) | `.hm-orgchart-status-group[data-work-status="dispatched"]` → `--hm-orgchart-resolved-status-card-border` | `var(--hm-orgchart-dispatched-card-border, var(--hm-orgchart-status-card-border, rgba(120, 130, 142, 0.35)))` | +| `--hm-orgchart-resolved-status-card-color` | 내부/기존 선언 | [summary-sections.css:145](../../packages/org-chart/src/styles/summary-sections.css) | `.hm-orgchart-status-group[data-work-status="dispatched"]` → `--hm-orgchart-resolved-status-card-color` | `var(--hm-orgchart-dispatched-card-color, var(--hm-orgchart-status-card-color, #3e4650))` | +| `--hm-orgchart-resolved-status-card-gradient` | 내부/기존 선언 | [summary-sections.css:143](../../packages/org-chart/src/styles/summary-sections.css) | `.hm-orgchart-status-group[data-work-status="dispatched"]` → `--hm-orgchart-resolved-status-card-gradient` | `var(--hm-orgchart-dispatched-card-gradient, var(--hm-orgchart-status-card-gradient, linear-gradient(180deg, #f4f5f6 0%, #dfe3e7 100%)))` | +| `--hm-orgchart-resolved-status-title-background` | 내부/기존 선언 | [summary-sections.css:142](../../packages/org-chart/src/styles/summary-sections.css) | `.hm-orgchart-status-group[data-work-status="dispatched"]` → `--hm-orgchart-resolved-status-title-background` | `var(--hm-orgchart-dispatched-title-background, var(--hm-orgchart-status-title-background, linear-gradient(180deg, #87909c 0%, #68727e 100%)))` | +| `--hm-orgchart-resolved-stroke-default` | 내부/기존 선언 | [design-tokens.css:14](../../packages/org-chart/src/styles/design-tokens.css) | `.hm-orgchart-shell` → `--hm-orgchart-resolved-stroke-default` | `var(--hm-orgchart-stroke-default, #bac7d7)` | +| `--hm-orgchart-resolved-stroke-input-focus` | 내부/기존 선언 | [design-tokens.css:16](../../packages/org-chart/src/styles/design-tokens.css) | `.hm-orgchart-shell` → `--hm-orgchart-resolved-stroke-input-focus` | `var(--hm-orgchart-stroke-input-focus, #45639f)` | +| `--hm-orgchart-solution-sales-border` | 공개 입력(상속) | [color-presets.css:59](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="5"]` → `--hm-orgchart-current-border` | `var(--hm-orgchart-solution-sales-border, var(--hm-orgchart-border, #5a963c))` | +| `--hm-orgchart-solution-sales-card-background` | 공개 입력(상속) | [color-presets.css:56](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="5"]` → `--hm-orgchart-current-card-background` | `var(--hm-orgchart-solution-sales-card-background, var(--hm-orgchart-card-background, var(--hm-orgchart-resolved-bg-surface)))` | +| `--hm-orgchart-solution-sales-card-color` | 공개 입력(상속) | [color-presets.css:57](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="5"]` → `--hm-orgchart-current-card-color` | `var(--hm-orgchart-solution-sales-card-color, var(--hm-orgchart-card-color, var(--hm-orgchart-resolved-fg-primary)))` | +| `--hm-orgchart-solution-sales-department-color` | 공개 입력(상속) | [color-presets.css:58](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="5"]` → `--hm-orgchart-current-department-color` | `var(--hm-orgchart-solution-sales-department-color, var(--hm-orgchart-department-color, #ffffff))` | +| `--hm-orgchart-solution-sales-gradient` | 공개 입력(상속) | [color-presets.css:60](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="5"]` → `--hm-orgchart-current-gradient` | `var(--hm-orgchart-solution-sales-gradient, var(--hm-orgchart-gradient, linear-gradient(180deg, #5a963c 0%, #3f7629 100%)))` | +| `--hm-orgchart-status-card-border` | 공개 입력(상속) | [summary-sections.css:144](../../packages/org-chart/src/styles/summary-sections.css) | `.hm-orgchart-status-group[data-work-status="dispatched"]` → `--hm-orgchart-resolved-status-card-border` | `var(--hm-orgchart-dispatched-card-border, var(--hm-orgchart-status-card-border, rgba(120, 130, 142, 0.35)))` | +| `--hm-orgchart-status-card-color` | 공개 입력(상속) | [summary-sections.css:145](../../packages/org-chart/src/styles/summary-sections.css) | `.hm-orgchart-status-group[data-work-status="dispatched"]` → `--hm-orgchart-resolved-status-card-color` | `var(--hm-orgchart-dispatched-card-color, var(--hm-orgchart-status-card-color, #3e4650))` | +| `--hm-orgchart-status-card-gradient` | 공개 입력(상속) | [summary-sections.css:143](../../packages/org-chart/src/styles/summary-sections.css) | `.hm-orgchart-status-group[data-work-status="dispatched"]` → `--hm-orgchart-resolved-status-card-gradient` | `var(--hm-orgchart-dispatched-card-gradient, var(--hm-orgchart-status-card-gradient, linear-gradient(180deg, #f4f5f6 0%, #dfe3e7 100%)))` | +| `--hm-orgchart-status-position-color` | 공개 입력(상속) | [summary-sections.css:139](../../packages/org-chart/src/styles/summary-sections.css) | `.hm-orgchart-status-group .hm-orgchart-employee[data-card-kind] .hm-orgchart-person-role` → `color` | `var(--hm-orgchart-status-position-color, #555f6a)` | +| `--hm-orgchart-status-title-background` | 공개 입력(상속) | [summary-sections.css:142](../../packages/org-chart/src/styles/summary-sections.css) | `.hm-orgchart-status-group[data-work-status="dispatched"]` → `--hm-orgchart-resolved-status-title-background` | `var(--hm-orgchart-dispatched-title-background, var(--hm-orgchart-status-title-background, linear-gradient(180deg, #87909c 0%, #68727e 100%)))` | +| `--hm-orgchart-status-title-border` | 공개 입력(상속) | [summary-sections.css:129](../../packages/org-chart/src/styles/summary-sections.css) | `.hm-orgchart-status-group > .hm-orgchart-summary-title` → `border` | `1px solid var(--hm-orgchart-status-title-border, rgba(92, 101, 112, 0.52))` | +| `--hm-orgchart-status-title-color` | 공개 입력(상속) | [org-chart-readonly.css:275](../../packages/org-chart/src/styles/org-chart-readonly.css) | `.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-status-group > .hm-orgchart-summary-title` → `color` | `var(--hm-orgchart-status-title-color, var(--hm-orgchart-resolved-on-action-primary))` | +| `--hm-orgchart-stroke-default` | 공개 입력(상속) | [design-tokens.css:14](../../packages/org-chart/src/styles/design-tokens.css) | `.hm-orgchart-shell` → `--hm-orgchart-resolved-stroke-default` | `var(--hm-orgchart-stroke-default, #bac7d7)` | +| `--hm-orgchart-stroke-input-default` | 공개 입력(상속) | [design-tokens.css:15](../../packages/org-chart/src/styles/design-tokens.css) | `.hm-orgchart-shell` → `--hm-orgchart-resolved-stroke-input-default` | `var(--hm-orgchart-stroke-input-default, #d3dce8)` | +| `--hm-orgchart-stroke-input-focus` | 공개 입력(상속) | [design-tokens.css:16](../../packages/org-chart/src/styles/design-tokens.css) | `.hm-orgchart-shell` → `--hm-orgchart-resolved-stroke-input-focus` | `var(--hm-orgchart-stroke-input-focus, #45639f)` | +| `--hm-orgchart-talent-growth-border` | 공개 입력(상속) | [color-presets.css:29](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="2"]` → `--hm-orgchart-current-border` | `var(--hm-orgchart-talent-growth-border, var(--hm-orgchart-border, #0f6ace))` | +| `--hm-orgchart-talent-growth-card-background` | 공개 입력(상속) | [color-presets.css:26](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="2"]` → `--hm-orgchart-current-card-background` | `var(--hm-orgchart-talent-growth-card-background, var(--hm-orgchart-card-background, var(--hm-orgchart-resolved-bg-surface)))` | +| `--hm-orgchart-talent-growth-card-color` | 공개 입력(상속) | [color-presets.css:27](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="2"]` → `--hm-orgchart-current-card-color` | `var(--hm-orgchart-talent-growth-card-color, var(--hm-orgchart-card-color, var(--hm-orgchart-resolved-fg-primary)))` | +| `--hm-orgchart-talent-growth-department-color` | 공개 입력(상속) | [color-presets.css:28](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="2"]` → `--hm-orgchart-current-department-color` | `var(--hm-orgchart-talent-growth-department-color, var(--hm-orgchart-department-color, #ffffff))` | +| `--hm-orgchart-talent-growth-gradient` | 공개 입력(상속) | [color-presets.css:30](../../packages/org-chart/src/styles/color-presets.css) | `[data-group-variant="2"]` → `--hm-orgchart-current-gradient` | `var(--hm-orgchart-talent-growth-gradient, var(--hm-orgchart-gradient, linear-gradient(180deg, #0f6ace 0%, #044a94 100%)))` | +| `--hm-orgchart-top-leader-card-background` | 공개 입력(상속) | [org-chart-expanded.css:102](../../packages/org-chart/src/styles/org-chart-expanded.css) | `.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-card-kind="topLeader"]` → `background` | `var(--hm-orgchart-top-leader-card-background, var(--hm-orgchart-card-background, var(--hm-orgchart-resolved-bg-surface)))` | diff --git a/docs/org-chart/consistency-review-p11-6.md b/docs/org-chart/consistency-review-p11-6.md new file mode 100644 index 0000000..12c25d2 --- /dev/null +++ b/docs/org-chart/consistency-review-p11-6.md @@ -0,0 +1,109 @@ +# HMDesign OrgChart P11.6 — 최종 정합성·병합 준비 검증 + +RESULT: **PARTIAL — 검증한 기능·정합성 PASS, 배포 플랫폼 일부 NOT VERIFIED.** +기술 판정: **CONDITIONALLY READY.** 아래 Feature Git 완료 확인을 전제로 하며 실제 main Merge·Push·배포 승인을 뜻하지 않는다. + +## 1. 기준과 범위 + +- 작업 경로: `D:\디자인시스템\HMDesign-OrgChart`. +- 작업 시작: 2026-10-14 08:45:22 KST. 증거 생성·검토: 같은 날 08:45–08:51 KST. +- Feature: `feature/orgchart-package`, 검사 전 HEAD `cd7a0db916c3eed75e5ebf4155828bf3727d21d5`. +- 공식 origin: `https://gitea.hmac.kr/b25027/HMDesign.git`. +- 읽기 전용 원격 조회로 Feature HEAD 일치 및 main `c45d63a03b84eaac43f01bd400a16488a80e4229` 확인. +- 초기 Working Tree: P11.5 관련 5개 수정, Untracked 0. 기존 변경을 모두 보존했다. +- 초기 Git user.name은 `김수현`, email은 `shkim4@samaneng.com`. 이번 Commit에만 요청된 `b25027` / `shkim4@samaneng.com` 작성자를 지정한다. +- P11.4 `deployment-rollback-review-p11-4.md`는 없음. 재작성·Stage하지 않는다. 외부 보관 위치 NOT VERIFIED, 기존 롤백 PARTIAL 유지. + +P11.6은 정합성 검사 및 같은 작업 범위의 Feature Commit·일반 Push 준비다. 신규 화면·Story·Props·기본 회사색·승인 디자인·Workflow·Docker 정의를 수정하지 않았다. + +## 2. 변경 파일 + +이번 Commit 대상은 다음 6개로 한정한다. + +1. `packages/org-chart/src/styles/org-chart-content.css` — Fixed interactive composition/stage 전용 입력, 투명 fallback. +2. `packages/org-chart/src/styles/org-chart-readonly.css` — Fixed readOnly composition/stage 전용 입력 및 기존 bg-content fallback. +3. `packages/org-chart/src/styles/org-chart-expanded.css` — Auto 내부 viewport 전용 입력 및 기존 bg-surface fallback. +4. `docs/org-chart/org-chart-usage-guide.md` — P11.5 배경 설정과 P11.6 한정 검증 근거. +5. `docs/org-chart/design-approval.md` — 사용자 확정 Expanded 내부 흰색 변경 기록. +6. `docs/org-chart/consistency-review-p11-6.md` — 이 보고서. + +P11.6에서 추가 제품 오류를 발견하지 않아 제품 코드 추가 수정은 없다. `test-results`, `_local`, node_modules/dist, 캡처·로그·개인 설정·Cell 실험은 제외한다. + +## 3. Core·React·데이터·사용 가이드 정합성 + +**PASS — 실제 소스·공개 exports·타입과 가이드의 관련 예제 대조.** + +- Core의 주 Entry·CSS 2개·IIFE browser Entry 및 DataSource/Validator/집계/이동/모듈 export가 manifest와 일치한다. React/Vanilla 모두 같은 Core를 사용한다. +- Fixed 기본/interactive/readOnly, Auto TB/LR/Hybrid, 독립 interaction flags, Fit/Zoom/Pan, 그룹 접기, 직원 팝업, 기본 OFF Drag & Drop, 회사 선택과 데이터 갱신의 지원 범위·제약이 타입·구현과 일치한다. +- Chart Only는 Auto 전용 `presentationMode: 'chart-only'`, 초기 Fit·direct wheel 기본값. Fixed 조합은 TypeError로 거부하며 신규 Fixed Chart Only를 만들지 않았다. +- 데이터의 schemaVersion, Organization/Employee/affiliation 및 metadata, nullable 필드, 계층·조직장 참조 제약을 실제 타입/Validator와 대조했다. 문서 JSON 2개를 현재 빌드의 Validator로 실행해 통과했다. +- React 기본 Entry는 `OrgChart`와 UI-independent 타입을 제공한다. `/hm-dialog`만 HMEmployeeDialog를 연결하며 employeePopup의 명시적 선택이 필요하다. 과거 lazy HM UI MISSING_EXPORT 문제를 현재 미해결 상태로 해석하지 않는다. +- Core 옵션 전달 및 CSS URL 연결은 기존 Props 그대로다. 배경 입력은 class/style/외부 CSS 상속 경로이며 배경용 Props를 추가하지 않았다. +- optionKey 기반 값 비교와 stylesheet URL primitive 의존성, 데이터 identity reload, Auto setter를 실제 Wrapper에서 확인했다. 동일 값의 옵션 객체만 바뀌면 create/destroy를 반복하지 않는다. 이 생명주기 전체를 새 브라우저 검사했다고 확대하지 않는다. +- React peer `^19.0.0`, 실제 설치 React 19.3.0. 현재 소스·전체 타입/빌드와 기존 P11.5 React 브라우저 증거를 확인했다. 다른 React major/SSR 지원은 NOT VERIFIED. + +가이드의 TS/TSX 블록 **14개**를 공개 빌드 `.d.ts` 기준 strict·skipLibCheck=false로 통과시켰다. 문서에서 앞 절의 문맥을 요구하는 블록 2개에만 Import/데이터 문맥을 보충했다. JS 부분 예제는 실제 API와 수동 대조했으며 모든 예제를 별도로 브라우저 실행하지 않았다. + +## 4. 배경·외부 테마·Shadow DOM + +**PASS — CSS 대조, P11.5 증거 재사용 및 추가 Computed Style 검사.** + +- `--hm-orgchart-fixed-canvas-background`는 Fixed composition/stage에 적용한다. readOnly는 기존 `--hm-orgchart-bg-content` → `#F5F8FB`, interactive는 기존 transparent를 유지한다. +- `--hm-orgchart-expanded-canvas-background`는 Auto 내부 viewport에 적용한다. 기존 `--hm-orgchart-resolved-bg-surface` 경로를 보존하고 기본은 `#FFFFFF`다. 외부 카드/프레임/Storybook 페이지와 직원 카드에는 전용 입력을 전파하지 않는다. +- 두 입력은 var 참조로 소비하며 내부 요소에 기본 custom property를 선언해 호스트 상속을 차단하지 않는다. bg-surface/bg-content 기존 외부 테마, host 상속·Shadow :host·소비자 inline의 우선순위를 유지한다. +- React/Vanilla 각각 기본/Fixed만 custom/Expanded만 custom/서로 다른 custom/제거 후 기본값 복구를 추가 검사했다. 같은 페이지의 Chart Only sibling과 다른 runtime에 영향이 없었다. +- Chart Only의 전용 Expanded custom 배경 및 제거 후 흰색 복구를 실제 viewport computed style로 확인했다. +- P11.5의 진짜 외부 CSS `@import + :host`, 반대 유형 입력 무영향, 같은 composition/stage 입력 결과를 재사용했다. +- 깨끗한 Core CSS와 P11.5 산출물은 줄바꿈 정규화 후 동일하다. Core/React 기본 JS 산출물은 byte 동일하다. 원본의 정상 dist를 수정하지 않았다. + +## 5. Storybook·승인 디자인·Chart Only + +**PASS — P11.5 한정 회귀 증거 재사용, 새 빌드의 4개 화면 렌더 확인.** + +- Overview: 회사 로고·현황·카드·텍스트·외부 Container의 P11.5 주요 computed style/geometry 동일성 재사용. +- Department Colors: 6종 Gradient·그룹/직원·파견/휴직·`홍 길 동` 및 기존 프레임의 P11.5 동일성 재사용. Expanded 7개 회사색과 분리 유지. +- Full Composition: Fixed 기존 BG·배경 이미지/자산·카드·연결선/화살표·통계·중앙/반응형 구조에 추가 변경 없음. P11.5 before/after Full Composition PNG byte 동일성과 주요 스타일 동일성 재사용. +- Expanded OrgChart: 내부 viewport `rgb(255, 255, 255)`를 새 Storybook 빌드에서도 측정했다. P11.5 외부 배경·geometry·PNG 동일성 재사용. 회사별 20/100/300 선택, TB/LR/Hybrid, HM popup·Drag & Drop 컨트롤의 소스·구성은 변경 없음. 모든 조합을 새로 실행했다고 주장하지 않는다. +- 승인 Story ID 유지: `orgchart--logo-personnel-summary`, `orgchart--department-colors`, `orgchart--full-composition`, `orgchart--expanded-org-chart`. 새 Story 추가 없음. +- Chart Only: P11.5 React/Vanilla 초기 Fit·wheel Zoom·Pan·popup/focus 복귀·stable-ID 접기/펼치기 PASS 재사용. 외부 제목/요약/툴바 미생성·Fixed 미지원은 현재 코드 확인. 이번에는 custom BG와 제거·인스턴스 격리를 추가 실행했다. +- 승인 문서의 5개 대상 및 P11.5 변경 한정 범위가 구현과 일치한다. 7개 회사색 `#CB2E28`, `#F08300`, `#4580A6`, `#1D418D`, `#84338E`, `#0068B7`, `#0C367F` 및 흰색 그룹 제목은 그대로다. +- AGENTS.md·CLAUDE.md·AI 생성 정책의 Chart Only/Full Page 구분, 기본 승인 디자인·외부 CSS custom·정확한 사용자 데이터·Fixed Chart Only 미지원 정책에 충돌 없음. + +P11.5 최초 검사 **33 PASS/선택자 harness FAIL 2** 및 실패 한정 보완 **15/15 PASS** 원본을 보존한다. 전체 48개 제품 PASS로 단순 합산하지 않는다. 별도 역사적 승인 PNG 비교와 모든 viewport/동작 조합의 신규 회귀는 NOT VERIFIED다. + +## 6. 통합 검증과 증거 + +로컬 ignored 증거는 `test-results/org-chart-p11-6/`에 보존하며 Commit에서 제외한다. P11.5 증거는 `test-results/org-chart-p11-5/final-result.json`, `browser-result.json`, `recheck-result.json`, `baseline.json`, `after.json`, before/after PNG를 검토·재사용했다. + +- `npm.cmd ci --offline --no-audit --no-fund`: 격리 복사본에서 PASS, 552 packages 설치. 첫 sandbox EPERM은 권한 문제이며 정상 허용 실행에서 해결했다. 신규 audit는 수행하지 않았다. +- main Docker 순서 그대로 `npm.cmd run build` → `npm.cmd run build-storybook`: 모두 PASS. prebuild의 패키지 빌드 반복도 Docker 실제 명령대로 수행했다. 로그 `build.log`, `build-storybook.log`. +- `npm.cmd run typecheck --ignore-scripts`: PASS, 전체 workspace. 이미 완료한 prebuild를 다시 실행하지 않았다. 로그 `typecheck.log`. +- `node test-results/org-chart-p11-6/audit.mjs` 및 격리 TypeScript의 `-p .../test-results/snippets`: PASS, 공개 선언 예제 14개·JSON 2개·JS/CSS/lockfile 정합성. `audit.json`, `snippet-typecheck.log`. +- `node test-results/org-chart-p11-6/browser.mjs`: 추가 **20/20 PASS**, 브라우저 오류 0. `browser.json`, `browser.log`. 실제 URL은 `http://127.0.0.1:6046/storybook-static/iframe.html?id=orgchart--<기존ID>&viewMode=story`, fixture는 `/_local/p11-6/fixture.html`이다. +- Guard: `node scripts/ai-generation-guard.mjs --target . --profile STORYBOOK --scope FULL_SCREEN --json`. `guard-full.json`: **FAIL 0, NOT VERIFIED 98**, status/staticStatus/runtimeStatus **NOT VERIFIED**. CSS phases **STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT NOT VERIFIED / FINAL NOT VERIFIED**, 결합 styleVerification 미생성. 전체 Storybook import graph 범위이며 모든 package source CSS의 실행 검증을 뜻하지 않는다. 변경 CSS의 P11.5 scoped Guard FAIL 0도 재사용했다. +- 기존 `bg-[var(...)]` CSS 최적화, `host-context` minifier, 큰 bundle 경고는 보존했다. Build exit 0이며 이번 신규 제품 오류와 구분한다. 검사 규칙/예외를 변경하지 않았다. + +전체 Core/browser 회귀·접근성·성능·SSR/hydration·다른 브라우저·전 화면 디바이스 재검증은 수행하지 않았다. 기존 정상 검사를 무조건 반복하지 않는다. + +## 7. main 병합·배포 빌드 호환성 + +- Merge Base: `d18e3c33801e165e17b6d971e2f6198eed483c3c`. +- 검사 전 main-only 3 / Feature-only 16 Commit. **Fast-forward 불가, Merge Commit 필요**. +- main-only 변경은 `.dockerignore`, `.gitea/workflows/deploy-storybook.yml`, `.gitignore`, `Dockerfile`, `README.md`, `compose.yaml` 6개다. Feature 변경 경로와 겹치지 않는다. +- 읽기 전용 `git merge-tree origin/main HEAD` 결과에 changed-in-both/충돌 marker가 없다. 원본 `merge-tree.txt` 보존. 새 P11.6 6개 경로도 main 변경과 겹치지 않는다. 검토한 SHA 기준 **예상 텍스트 충돌 없음**. +- `.storybook/main.ts` 변경 없음, preview는 기존 승인 OrgChart 4개 순서 추가. main 쪽 설정 경쟁 변경 없음. +- Feature의 manifest/lockfile은 OrgChart workspace·엔진을 포함한다. **Core → UI → Center → React** 빌드 순서라 UI 타입 생성 전에 React가 빌드되는 과거 문제가 해결되어 있다. +- 격리 디렉터리는 `git archive HEAD`에 P11.5 현재 5개 파일과 main 전용 6개 파일을 복사한 예상 병합 source다. 입력 경로가 서로 겹치지 않음을 먼저 확인했고 Git 브랜치를 실제 병합하지 않았다. +- 설치 전에 4개 패키지에 dist/node_modules가 없음을 확인했다. 격리 `npm ci`로 별도 node_modules를 설치했다. root lockfile SHA-256 `fae6ace49f0cfb2c9fe606e2fdd2f1fdf9ef9f19139f9a8628a7719f0a75c9b9` 동일 유지. 원본 node_modules·dist 보존. +- main Docker 정의는 node:24-alpine에서 npm ci → npm run build → npm run build-storybook, nginx:1.29-alpine으로 Storybook 산출물 제공이다. main Workflow는 main Push/수동 실행 시 immutable source archive 전송 후 compose build/up 한다. +- **동일 명령의 clean source 빌드 호환성 PASS**. 실행 환경은 Windows Node 22.23.2/npm 10.9.8이다. Docker 실행 파일이 없으므로 Linux/Node 24 Alpine 컨테이너 build, nginx serving, 실제 Gitea runner/서버/SSH/복구는 **NOT VERIFIED**. 이를 Docker 배포 Runtime PASS로 올리지 않는다. + +## 8. Git 완료 조건과 최종 판정 + +기능·문서·승인 디자인의 변경 범위 PASS, 추가 브라우저 20/20, clean build/typecheck PASS, 실행한 Guard FAIL 0, 예상 충돌 없음으로 Feature Commit·일반 Push 조건을 충족했다. 이번 Commit 메시지는 `fix(org-chart): separate fixed and expanded canvas backgrounds`다. Commit 직전 원격 main/Feature가 검사 기준에서 바뀌지 않았는지 다시 확인하며 위 6개 파일만 Stage·staged diff 검토한다. + +이 문서를 포함하는 최종 Commit SHA·원격 일치·Working Tree·main 유지 여부는 작업 완료 응답 및 로컬 `test-results/org-chart-p11-6/git-final.json`에 기록한다. Git 실행 실패나 원격 변경이 있으면 완료로 간주하지 않는다. main 실제 Merge·Push·자동 배포는 이번 범위가 아니다. + +**CONDITIONALLY READY**: 확인한 소스의 배포 명령 빌드와 정합성은 PASS다. Docker/운영 플랫폼 검증 제한을 남기고 별도의 사용자 병합·배포 지시를 기다린다. 실제 병합 직전에 최신 원격 SHA를 다시 조회하여 새 변경·충돌 여부를 확인해야 한다. 이번 검증으로 운영 위험 수용이 승인된 것은 아니다. + +기존 위험은 유지한다: 자동 롤백 없음, P11.4 롤백 검토 PARTIAL, 서버 복구 Runtime NOT VERIFIED, 개발 의존성 High Advisory 7건 처리 방침 PENDING(이번 신규 audit 없음), 삼안 약 2.64:1·장헌산업 약 4.29:1 흰색 제목 대비 미달, Guard Runtime NOT VERIFIED. 디자인 승인은 WCAG AA 준수/조직 위험 수용 승인이 아니며 롤백 검증을 병합 필수 조건으로 두지 않는다는 방향도 복구 성공 보장이나 포괄 위험 수용을 의미하지 않는다. diff --git a/docs/org-chart/data-contract.md b/docs/org-chart/data-contract.md new file mode 100644 index 0000000..91bebed --- /dev/null +++ b/docs/org-chart/data-contract.md @@ -0,0 +1,262 @@ +# OrgChart Data Contract + +상태: **JSON Schema / TypeScript 초안, 승인 전**. ID 기반 분리·동일 계약 사용·Validation·원본 집계는 확정 요구다. 아래 필드명·enum·null·버전 값은 제안이다. 모든 환경이 이 계약과 같은 검증·집계를 공유한다. [Architecture](architecture.md), [API 전환](integration.md)을 함께 읽는다. + +## 1. 정규화 모델과 TypeScript 초안 + +ID는 엔티티별 배열 안에서 유일한 불투명 문자열이다. 이름·배열 위치로 연결하지 않는다. 조직은 `parentId`, 직원은 `organizationId`·`affiliationId`, 조직장은 `leaderId`, 추가 관계는 두 조직 ID를 참조한다. 서로 다른 엔티티의 같은 ID 문자열은 허용할지 TBD이며, DOM에는 엔티티·인스턴스 prefix를 별도로 붙인다. + +```ts +type ID = string; +type OrganizationType = "root" | "division" | "office" | "div" | "cell" | "team"; +type WorkStatus = "active" | "dispatched" | "onLeave"; +type RelationType = "cooperation" | "support"; + +interface Organization { + id: ID; + companyId?: ID; // P5-2/P5-3 Auto 회사 Root 식별. 하위 조직은 상속, 기존 JSON 생략 허용. + parentId: ID | null; + name: string; + type: OrganizationType; + leaderId: ID | null; + displayOrder: number; +} +interface Employee { + id: ID; + organizationId: ID; + affiliationId: ID; + name: string; + position: string; // 직위. jobTitle(직책)과 구별 + jobTitle: string | null; + profileImage: string | null; + workStatus: WorkStatus; +} +interface Affiliation { id: ID; name: string } +interface OrganizationRelation { + id: ID; + sourceOrganizationId: ID; + targetOrganizationId: ID; + type: RelationType; +} +interface PresentationConfig { + title: string; + description: string | null; + hubText: string | null; + groups: { + showEmployees: boolean; + showSummary: boolean; + showDispatched: boolean; + showOnLeave: boolean; + showAffiliations: boolean; + }; + organizationOrder: ID[]; +} +interface Metadata { + schemaVersion: "0.1-draft"; + asOfDate: string; // YYYY-MM-DD, 유효한 실제 날짜 + dataVersion: string; + updatedAt: string | null; // timezone을 포함한 RFC 3339 갱신 시각 + updateNote: string | null; +} +interface OrgChartData { + organizations: Organization[]; + employees: Employee[]; + affiliations: Affiliation[]; + relations: OrganizationRelation[]; + presentation: PresentationConfig; + metadata: Metadata; +} +``` + +제안된 `root/division/office/div/cell/team`은 최상위/부문/실/Div/셀/팀 표현이다. 실제 조직 유형·업무 상태·관계 유형 vocabulary, 직위 필수 여부, 무소속·관계사 미지정 허용은 **TBD**다. 새로운 enum은 schema/API 버전과 함께 확장하고 알 수 없는 값을 임의 재직 상태로 치환하지 않는다. + +`schemaVersion`은 계약 호환성, `dataVersion`은 스냅샷 식별이다. 서로 대신 쓰지 않는다. 부모 null은 root 후보, leader null은 공석 후보, 이미지 null은 가상 fallback을 뜻하는 초안이다. API에서 필드 생략이 가능한지와 null 허용은 승인 후 확정한다. + +표현 설정은 표시만 바꾸고 원본 소속·집계 분모를 변경하지 않는다. 순서는 제안상 `organizationOrder`에 있는 ID 우선 → 나머지 형제 `displayOrder` → 동률 ID 순서로 결정한다. 전역 목록이 계층 parent를 바꾸지는 않는다. 실제 정렬 우선순위는 TBD다. PresentationConfig에 CSS·HTML·DOM 좌표·API URL을 받지 않는다. + +## 2. JSON Schema 초안 + +JSON Schema Draft 2020-12 제안이다. `format`을 실제 검사하는 validator 설정이 필요하다. 아래 구조는 JSON 구문 검증 대상이며 runtime validator 구현 또는 Schema 승인 결과가 아니다. 중복 ID·참조·순환·업무 규칙은 §4의 별도 검증이 필요하다. 모든 필드를 required로 하고 nullable을 명시하는 엄격한 초안이며 API 매핑에서 같은 형태로 변환한다. + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "HMDesign OrgChart draft", + "type": "object", + "additionalProperties": false, + "required": ["organizations", "employees", "affiliations", "relations", "presentation", "metadata"], + "properties": { + "organizations": {"type": "array", "items": {"$ref": "#/$defs/Organization"}}, + "employees": {"type": "array", "items": {"$ref": "#/$defs/Employee"}}, + "affiliations": {"type": "array", "items": {"$ref": "#/$defs/Affiliation"}}, + "relations": {"type": "array", "items": {"$ref": "#/$defs/OrganizationRelation"}}, + "presentation": {"$ref": "#/$defs/PresentationConfig"}, + "metadata": {"$ref": "#/$defs/Metadata"} + }, + "$defs": { + "ID": {"type": "string", "minLength": 1}, + "Text": {"type": "string", "minLength": 1}, + "NullableText": {"anyOf": [{"$ref": "#/$defs/Text"}, {"type": "null"}]}, + "NullableID": {"anyOf": [{"$ref": "#/$defs/ID"}, {"type": "null"}]}, + "Organization": { + "type": "object", "additionalProperties": false, + "required": ["id", "parentId", "name", "type", "leaderId", "displayOrder"], + "properties": { + "id": {"$ref": "#/$defs/ID"}, + "parentId": {"$ref": "#/$defs/NullableID"}, + "name": {"$ref": "#/$defs/Text"}, + "type": {"enum": ["root", "division", "office", "div", "cell", "team"]}, + "leaderId": {"$ref": "#/$defs/NullableID"}, + "displayOrder": {"type": "integer", "minimum": 0} + } + }, + "Employee": { + "type": "object", "additionalProperties": false, + "required": ["id", "organizationId", "affiliationId", "name", "position", "jobTitle", "profileImage", "workStatus"], + "properties": { + "id": {"$ref": "#/$defs/ID"}, + "organizationId": {"$ref": "#/$defs/ID"}, + "affiliationId": {"$ref": "#/$defs/ID"}, + "name": {"$ref": "#/$defs/Text"}, + "position": {"$ref": "#/$defs/Text"}, + "jobTitle": {"$ref": "#/$defs/NullableText"}, + "profileImage": {"anyOf": [{"type": "string", "format": "uri-reference", "minLength": 1}, {"type": "null"}]}, + "workStatus": {"enum": ["active", "dispatched", "onLeave"]} + } + }, + "Affiliation": { + "type": "object", "additionalProperties": false, + "required": ["id", "name"], + "properties": {"id": {"$ref": "#/$defs/ID"}, "name": {"$ref": "#/$defs/Text"}} + }, + "OrganizationRelation": { + "type": "object", "additionalProperties": false, + "required": ["id", "sourceOrganizationId", "targetOrganizationId", "type"], + "properties": { + "id": {"$ref": "#/$defs/ID"}, + "sourceOrganizationId": {"$ref": "#/$defs/ID"}, + "targetOrganizationId": {"$ref": "#/$defs/ID"}, + "type": {"enum": ["cooperation", "support"]} + } + }, + "PresentationConfig": { + "type": "object", "additionalProperties": false, + "required": ["title", "description", "hubText", "groups", "organizationOrder"], + "properties": { + "title": {"$ref": "#/$defs/Text"}, + "description": {"$ref": "#/$defs/NullableText"}, + "hubText": {"$ref": "#/$defs/NullableText"}, + "organizationOrder": {"type": "array", "uniqueItems": true, "items": {"$ref": "#/$defs/ID"}}, + "groups": { + "type": "object", "additionalProperties": false, + "required": ["showEmployees", "showSummary", "showDispatched", "showOnLeave", "showAffiliations"], + "properties": { + "showEmployees": {"type": "boolean"}, "showSummary": {"type": "boolean"}, + "showDispatched": {"type": "boolean"}, "showOnLeave": {"type": "boolean"}, + "showAffiliations": {"type": "boolean"} + } + } + } + }, + "Metadata": { + "type": "object", "additionalProperties": false, + "required": ["schemaVersion", "asOfDate", "dataVersion", "updatedAt", "updateNote"], + "properties": { + "schemaVersion": {"const": "0.1-draft"}, + "asOfDate": {"type": "string", "format": "date"}, + "dataVersion": {"$ref": "#/$defs/Text"}, + "updatedAt": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}]}, + "updateNote": {"$ref": "#/$defs/NullableText"} + } + } + } +} +``` + +빈 문자열·공백만 있는 필수 값은 runtime Validation에서 거부하도록 제안한다. `minLength`만으로 공백 검사가 끝나지 않는다. 입력 길이·배열 크기·깊이 상한은 실제 데이터 규모 검토 후 TBD로 확정한다. + +## 3. 가상 JSON 스냅샷 예시 + +실제 인사 정보와 사진이 없는 예시다. Schema 값은 초안이며 아래 데이터가 운영 계약으로 승인된 것은 아니다. + +```json +{ + "organizations": [ + {"id": "org-root", "parentId": null, "name": "가상 사업본부", "type": "root", "leaderId": "emp-a", "displayOrder": 0}, + {"id": "team-a", "parentId": "org-root", "name": "가상 기획팀", "type": "team", "leaderId": "emp-b", "displayOrder": 1}, + {"id": "team-b", "parentId": "org-root", "name": "가상 운영팀", "type": "team", "leaderId": null, "displayOrder": 2} + ], + "employees": [ + {"id": "emp-a", "organizationId": "org-root", "affiliationId": "aff-a", "name": "가상직원 가", "position": "대표", "jobTitle": "본부장", "profileImage": null, "workStatus": "active"}, + {"id": "emp-b", "organizationId": "team-a", "affiliationId": "aff-a", "name": "가상직원 나", "position": "책임", "jobTitle": "팀장", "profileImage": null, "workStatus": "active"}, + {"id": "emp-c", "organizationId": "team-a", "affiliationId": "aff-b", "name": "가상직원 다", "position": "선임", "jobTitle": null, "profileImage": null, "workStatus": "dispatched"}, + {"id": "emp-d", "organizationId": "team-b", "affiliationId": "aff-b", "name": "가상직원 라", "position": "선임", "jobTitle": null, "profileImage": null, "workStatus": "onLeave"} + ], + "affiliations": [{"id": "aff-a", "name": "가상 관계사 A"}, {"id": "aff-b", "name": "가상 관계사 B"}], + "relations": [{"id": "rel-a", "sourceOrganizationId": "team-a", "targetOrganizationId": "team-b", "type": "cooperation"}], + "presentation": { + "title": "가상 조직도", "description": "설계 검토용 가상 데이터", "hubText": "협업 중심", + "groups": {"showEmployees": true, "showSummary": true, "showDispatched": true, "showOnLeave": true, "showAffiliations": true}, + "organizationOrder": ["org-root", "team-a", "team-b"] + }, + "metadata": {"schemaVersion": "0.1-draft", "asOfDate": "2026-10-12", "dataVersion": "mock-1", "updatedAt": null, "updateNote": "초안 예시"} +} +``` + +직원 고유 ID 수 4, 상태별 raw 수 2/1/1은 fixture 산술 기준이다. 업무상 재직/전체 인원과 파견·휴직 포함 여부는 §5의 정책 승인이 필요하다. 조직장 카드와 상태 그룹에 같은 직원이 보이더라도 raw ID 수가 늘어나지 않아야 한다. + +## 4. Validation과 오류 상태 + +제안 순서: 구조·버전 → 각 collection ID index → parent/직원/관계사/leader 참조 → cycle/root → 업무 규칙·입력 안전성. invalid 데이터를 engine로 전달하지 않는다. + +- `SCHEMA_VERSION`: 승인된 버전과 불일치. 자동 추정·silent migration 금지. +- `DUPLICATE_ID`: 같은 collection 내 ID 중복. 임의 last-write-wins 금지. +- `REQUIRED_VALUE`: 누락/null 위반/공백뿐인 필수 값/잘못된 타입. +- `ORGANIZATION_REFERENCE`: 직원 소속이 존재하지 않음. 삭제 시 parent/직원 이동을 일관된 스냅샷으로 제공해야 한다. +- `PARENT_REFERENCE` / `HIERARCHY_CYCLE`: 없는 parent, 자기 parent, 다단계 순환. 정렬로 cycle을 은폐하지 않는다. +- `LEADER_REFERENCE`: leader 직원이 없음. leader가 동일 조직/하위 조직/타 조직 소속이어도 되는지는 TBD이며 정책 없는 불일치는 승인 요구 issue로 보고한다. +- `AFFILIATION_REFERENCE`: 직원 관계사 없음. 미지정 소속 허용 정책은 TBD. +- `INVALID_STATUS` / `INVALID_TYPE`: 허용 vocabulary 외 값. 제안 enum이 실제 승인 목록을 대신하지 않는다. +- `RELATION_REFERENCE` / `INVALID_RELATION`: 없는 양 끝 조직, 자기 관계, 중복 양 끝+유형, 허용되지 않은 유형을 검사한다. 관계 방향·대칭·역방향 중복 허용은 TBD. 일반 관계의 cycle은 조직 parent cycle과 구별한다. +- `PRESENTATION_REFERENCE`: organizationOrder의 없는 ID·중복·정렬 정책 위반. +- `UNSAFE_INPUT`: 실행 가능한 HTML/스크립트 payload·위험한 이미지 URL·제어문자/식별 교란 입력을 검사한다. 표시 문자열은 plain text, external `className/style/nodeTemplate`는 받지 않는다. image scheme/origin·상대 경로 정책·길이 상한은 승인 후 확정한다. JSON Schema `uri-reference`만으로 URL 안전성을 보장하지 않는다. + +입력 검사와 출력 안전성은 함께 적용한다. 정상 문자열을 무조건 삭제하지 않고 거부/경고 기준을 문서화한다. DOM 텍스트는 `textContent`로 출력하고 engine 템플릿에는 외부 문자열을 직접 삽입하지 않는다. engine ID는 내부 안전 ID로 변환한다. HTML escaping만으로 이미지 URL 검사를 대체하지 않는다. + +구조·참조·cycle·보안 실패는 Error 상태로 전환하고 engine mount/update를 중단한다. 사용자 메시지는 해당 데이터가 표시 불가능함을 설명하되 원본 개인정보를 노출하지 않는다. partial 데이터 자동 제거는 별도 승인 전 하지 않는다. 직원 0명과 조직 0개의 Empty 정의, 갱신 실패 시 직전 유효 화면 유지 여부는 TBD다. 미확정 집계 정책은 차트 유효성과 분리하여 집계를 `unavailable`로 표시하는 방식을 제안한다. + +fatal Validation issue는 차트 Error로 처리한다. 집계 정책 미확정은 fatal data issue가 아니며 유효한 차트의 ready와 별도 AggregationResult `unavailable`로 공존하는 안을 제안한다. Source의 HTTP/JSON/매핑 실패는 dataSource 위치의 별도 code로 구별한다. warning·업무 승인 필요 issue의 치명도와 공개 반환 형식은 승인 전 TBD다. 어떤 경로도 원본 서버 오류·개인정보를 callback에 그대로 전달하지 않는다. + +Validation·집계·render는 같은 불변 스냅샷을 사용하며 engine는 별도 복사본만 받는다. Source가 여러 API/페이지를 조합할 때의 시간 일관성은 ID 참조 검사와 별도로 보장해야 한다. Source 캡처·revision 반영 계약은 Architecture/Framework Integration을 따른다. + +## 5. 인원 집계와 미확정 정책 + +원본 `employees`에서 계산하며 DOM 카드·engine 노드 수를 세지 않는다. Validation 통과 직원 ID 집합을 사용하고 parent 트리의 하위 조직을 통해 집계한다. 추가 관계·허브·접힌/숨겨진 카드·표시 설정은 조직 소속을 변경하지 않는다. + +- 조직 직접 인원: 해당 organizationId의 직원 집합. +- 하위 포함 인원: 자기 조직 + 승인된 parent 트리 하위 조직의 직원 ID 합집합. +- 직위별 인원: 정해진 대상 집합을 `position`으로 그룹화. 직책과 혼합하지 않는다. +- 재직/파견/휴직: 승인된 상태 매핑과 포함 정책을 적용한다. raw 상태 카운트와 재직 정의는 구별한다. +- 관계사별 인원: `affiliationId`로 그룹화하고 동일한 승인 정책을 적용한다. +- 전체 인원: 승인된 전체 대상의 직원 ID 집합. 하위 포함 조직별 숫자를 더하면 중복되므로 그 합을 총원으로 쓰지 않는다. + +집계 정책은 PresentationConfig와 별도로 제공하는 제안이다. 아래 type의 값이나 default는 아직 승인되지 않았다. + +```ts +interface ApprovedAggregationPolicy { + policyVersion: string; + includedStatuses: WorkStatus[]; + activeStatuses: WorkStatus[]; + includeLeaders: boolean; + membershipMode: "primaryOnly" | "uniqueEmployee"; +} +type AggregationResult = + | { status: "unavailable"; reason: "policyRequired" } + | { status: "ready"; policyVersion: string; total: number }; +``` + +TBD: 파견·휴직을 재직/전체/팀/관계사에 포함하는지, 조직장 포함·겸직 조직장 배분, 조직 간 중복 소속, 파견 원소속/파견지 기준, 퇴사·예정 상태 필요 여부, 관계사 합과 총원 관계. 초안의 단일 `organizationId`는 복수 소속을 표현하지 못한다. 복수 소속이 필요하면 별도 Membership 계약과 schema revision을 승인하고 집계 mode를 확정한다. `uniqueEmployee` 후보만으로 현재 복수 소속이 지원된다고 주장하지 않는다. + +정책 없이 임의 총원을 표시하지 않는다. 향후 테스트에는 leader 중복 표시, 상태 변경, 조직 이동, 하위 포함 중복 제거, 관계사 이동, 표시 숨김, 동일 JSON의 Vanilla/React 집계 일치를 포함한다. 같은 조건에 같은 결과가 나와야 한다. + +집계 정책 자체도 policyVersion·지원 membershipMode·허용 status·중복 status·업무상 상호 관계를 검증한다. `activeStatuses`와 `includedStatuses`의 관계, leader 제외가 어느 조직의 어떤 직원에 적용되는지는 승인 전 TBD다. 필드 값만 받아 임의 해석하지 않는다. 환경별 정렬 일치를 위해 ID 비교 기준·locale 의존 정렬 여부도 확정한다. diff --git a/docs/org-chart/design-approval.md b/docs/org-chart/design-approval.md new file mode 100644 index 0000000..5362f5a --- /dev/null +++ b/docs/org-chart/design-approval.md @@ -0,0 +1,148 @@ +# HMDesign OrgChart P11.2 — 최종 디자인 승인 기록 + +**디자인 승인 상태: APPROVED.** 이 문서는 사용자가 승인한 HMDesign OrgChart 기본 디자인을 향후 개발과 AI 생성 작업의 기준으로 기록한다. 디자인 승인은 접근성 준수 판정, 접근성 위험 수용, 보안 위험 수용, main 병합 또는 운영 배포 승인이 아니다. + +## 1. 승인 정보와 근거 + +| 항목 | 확정 내용 | +|---|---| +| 승인일 | 2026-10-13 | +| 승인 주체 | 사용자 디자인 승인 | +| 기준 Commit | `346c648c093ae3b0dedc1fc754d8ffa52f00ddd0` | +| 적용 대상 | HMDesign OrgChart 기본 디자인 | +| 승인 상태 | APPROVED | + +실제 담당자의 법적 이름이나 직책은 확인되지 않았으므로 기록하지 않는다. 승인은 이번 사용자 결정과 기존 보고서의 검증 범위에 근거한다. [P10.10 브라우저 시각 검증](visual-review-p10-10.md), [P11 외부 CSS 테마 구현·검증](implementation-report-p11.md), [P11.1 최종 정합성 검증](consistency-review-p11-1.md)을 참조한다. 과거 보고서의 승인 대기·실패·미검증 기록은 당시 기록으로 보존한다. + +| 승인 화면 | 디자인 승인 상태 | +|---|---| +| Overview | APPROVED | +| Department Colors | APPROVED | +| Full Composition | APPROVED | +| Expanded OrgChart | APPROVED | +| Chart Only — React / Vanilla (Auto/Expanded) | APPROVED | + +Storybook 4개 화면과 Chart Only를 포함한다. 기존 NOT VERIFIED인 브라우저·접근성·성능 항목을 승인 완료나 제품 전체 회귀 PASS로 변경하지 않는다. + +## 2. 화면별 승인 기준 + +### Overview + +- 관계사 회사 로고 7개와 중복 제목 제거. +- 조직·직위별 인원 현황 및 관계사별 인원 현황. +- 기존 Storybook Container 스타일, 카드 배경·Border·Radius·간격. + +기존 이미지 및 인원 데이터는 수정하지 않는다. + +### Department Colors + +- 대표자 카드, 조직별 Gradient 6종, 그룹 타이틀. +- 그룹장·직원 카드, 파견자·휴직자 미리보기, 직원 이름 `홍 길 동`. +- 기존 카드 규격·Typography·Border와 Storybook 미리보기 Container. + +**6개 조직 Gradient는 Expanded의 7개 회사별 컬러와 별개다.** 기존 디자인값을 임의로 통합하거나 변경하지 않는다. + +### Full Composition + +- 대표자 카드와 대표자 아래 화살촉 1개, 협업 화살표 5개. +- 조직 카드·연결선과 중앙 조직도 배치. +- 상단 제목·인원 현황, 파견·휴직자 위치와 간격, 하단 관계사별 인원 현황. +- 세로로 긴 화면의 상단·중앙·하단 배치, 비례 축소 및 반응형. +- Core 직원 정보 팝업의 X 닫기 버튼. + +[P10.10](visual-review-p10-10.md)에서 Full Composition 390px 내부 스크롤·잘림 오류는 재현되지 않았다. 과거 실패는 현재 비례 축소 구성과 맞지 않는 구형 내부 스크롤 기대값과 구분하며, 원본 실패 기록을 보존한다. 이 결과를 새로운 전체 디바이스 검증 PASS로 확대하지 않는다. 기존 Fixed Full Composition 디자인 승인은 유지한다. + +### Expanded OrgChart + +- 회사별 20 / 100 / 300명, 전체·개별·복수 회사 선택. +- TB / LR / Hybrid, 회사별 그룹 타이틀 컬러, 직원 카드·연결선. +- Drag & Drop ON/OFF와 기본 OFF, Zoom/Pan/Fit. +- 그룹 접기·펼치기, 직원 정보 팝업, 기존 HMDesign Dialog 연결. + +기존 데이터 Contract와 회사별 컬러값을 유지한다. 승인된 기능 구성의 기록을 모든 데이터·동작 조합의 신규 실행 검증으로 해석하지 않는다. + +**2026-10-14 P11.5 사용자 확정 변경:** Expanded 조직도 내부 viewport / Pan·Zoom 캔버스의 기본 배경은 순수 흰색 `#FFFFFF`다. Fixed 기존 BG·디자인, Storybook 페이지·외부 프레임, 카드·회사색·그룹 제목·연결선·레이아웃 승인은 유지한다. 캔버스 전용 외부 CSS 입력과 기존 테마 fallback은 [사용 가이드의 P11.5 절](org-chart-usage-guide.md#p115-fixedexpanded-캔버스-배경-독립-설정-2026-10-14)을 따른다. 이 한정된 사용자 변경 요청으로 다른 승인 상태·접근성 대비 미달 2건·Guard Runtime NOT VERIFIED를 변경하지 않는다. + +### Chart Only — React / Vanilla + +Auto/Expanded Chart Only의 승인 기준은 다음과 같다. + +- 흰색 캔버스, 외부 제목·설명·요약·툴바 없음. +- 최초 Fit to View, 전체 조직도 가로·세로 중앙 정렬. +- Ctrl/⌘ 없는 마우스 휠 Zoom과 커서 중심 확대·축소. +- 빈 공간 Pan, 직원 팝업, 그룹 접기·펼치기. +- React·Vanilla 공통 동작. + +**Fixed Chart Only는 현재 미지원이다.** 이를 승인된 기능으로 기록하지 않는다. 기존 Fixed Full Composition 승인과 구분한다. 공개 옵션과 생성 방식 선택은 [사용 가이드](org-chart-usage-guide.md) 및 [AI 조직도 자동 생성 정책](ai-page-generation-rules.md)을 따른다. + +## 3. 기존 7개 회사 기본 컬러 + +| 회사 | 승인된 기본 메인 컬러 | +|---|---| +| 한맥기술 | `#CB2E28` | +| 삼안 | `#F08300` | +| 장헌산업 | `#4580A6` | +| 장헌 | `#1D418D` | +| 피티씨 | `#84338E` | +| 한라산업개발 | `#0068B7` | +| 바론컨설턴트 | `#0C367F` | + +[회사 컬러 프리셋](../../packages/org-chart/src/company-color-presets.ts)의 기존 값과 일치하며 그룹 제목은 기존 정책대로 **흰색 텍스트**를 유지한다. + +P11의 외부 CSS 테마 변경 기능은 다른 프로젝트가 커스텀 컬러를 의도적으로 지정하기 위한 것이다. 외부 테마로 색상을 변경할 수 있다는 사실은 HMDesign 기본 컬러의 변경을 의미하지 않는다. **외부 테마를 적용하지 않은 기본 조직도는 위 7개 회사 컬러를 유지한다.** Department Colors의 6개 조직 Gradient를 이 팔레트로 대체하지 않는다. + +## 4. 접근성 미해결 사항 + +| 회사 | 배경색 | 흰색 제목 대비 | 일반 텍스트 WCAG AA | 현재 상태 | +|---|---|---|---|---| +| 삼안 | `#F08300` | 약 2.64:1 | 4.5:1 미달 | 미해결 | +| 장헌산업 | `#4580A6` | 약 4.29:1 | 4.5:1 미달 | 미해결 | + +비율은 기존 선언색 계산 기록이며 신규 브라우저 픽셀 실측이 아니다. 해당 회사색을 쓰는 Expanded/Auto Chart Only 그룹 제목에 적용된다. 회사 배경색과 흰색 그룹 제목은 유지하고 디자인은 승인하되, **접근성 미준수 사실은 유지한다. 디자인 승인을 WCAG AA 준수 승인 또는 접근성 위험 수용 승인으로 처리하지 않는다.** + +| 미확정 항목 | 상태 | +|---|---| +| 조직 접근성 정책상 예외 수용 여부 | PENDING (정책 확인 NOT VERIFIED) | +| 접근성 위험 승인 주체 | PENDING | +| 향후 개선 시점 | PENDING | +| WCAG 준수 최종 판정 | NOT VERIFIED | + +기존 미검증 접근성 항목과 두 대비 미달을 숨기거나 PASS 처리하지 않는다. + +## 5. Cell 디자인 처리 + +- Cell 전용 Border Only 스타일: **적용하지 않음**. +- 연한 배경형 B안: **적용하지 않음**. +- 기존 조직 그룹 Header 디자인, 직원 카드·연결선 유지. + +Cell Border Only 디자인 실험은 사용자 결정으로 정식 적용에서 제외됐다. 향후 Cell 전용 디자인을 다시 적용하려면 별도 사용자 승인을 받아야 한다. + +## 6. AI 개발·생성 작업 적용 + +[AGENTS.md](../../AGENTS.md)와 [CLAUDE.md](../../CLAUDE.md)에서 OrgChart UI 수정 또는 AI 생성 작업 전에 이 문서를 우선 확인한다. 생성 방식과 데이터 처리는 기존 [AI 생성 정책](ai-page-generation-rules.md)을 그대로 따른다. + +1. 승인된 기존 디자인을 기본 기준으로 사용하고 카드·색상·Typography·간격을 임의 변경하지 않는다. +2. 사용자 요청에 따른 조직 계층·직원 데이터 생성은 계속 허용하며 정확한 요청 구조·인원·원본 데이터를 보존한다. +3. 외부 CSS 테마를 통한 의도적인 커스터마이징은 허용한다. 기본값 자체 변경과 구분한다. +4. 승인된 기본값 자체를 변경할 때는 사전 사용자 확인을 받는다. 이미 해당 변경을 명시적으로 승인받은 요청은 같은 확인을 반복하지 않는다. +5. 접근성 미해결 사항을 숨기거나 PASS로 변경하지 않는다. + +이 지침은 변경 방지 기준을 제공하며 실제 코드 수정을 기술적으로 차단하는 보안 장치가 아니다. 생성 방식 사전 선택·Navigation·공개 API·Theme·접근성 계약을 대체하지 않는다. + +## 7. 디자인 승인과 별도로 유지할 상태 + +| 항목 | 현재 기록 | +|---|---| +| 실제 Guard 정적 결과 | P11.1 `status PASS`, `staticStatus PASS`, issues 0 / FAIL 0 | +| Guard Runtime | `runtimeStatus NOT VERIFIED` | +| Guard CSS phases | STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT PASS / FINAL NOT VERIFIED | +| 결합 styleVerification | 미생성 유지 | +| 접근성 위험 수용 | PENDING | +| npm 개발 의존성 High Advisory 위험 수용 | PENDING | +| 신규 npm audit·현재 전체 의존 그래프 평가 | NOT VERIFIED | +| 운영 배포 승인 | PENDING | +| main 병합 승인 | PENDING | + +Guard는 [P11.1 기록](consistency-review-p11-1.md)과 기존 로컬 `test-results/org-chart-p11-1/guard-actual.json`을 확인한 결과이며 이번 문서 작업에서 재실행하지 않는다. P10 당시 실제 FAIL 5건 등 과거 원본은 보존한다. 정적 PASS·디자인 승인·별도 브라우저 증거로 원본 Runtime NOT VERIFIED를 덮어쓰지 않는다. + +남은 승인과 운영 확인은 [정식 통합 승인 준비 문서](release-readiness-p10-7.md)를 따른다. 이번 P11.2는 문서화 단계이며 제품 코드·디자인 변경, 제품 전체 회귀, Commit·Push·main Merge·배포 실행을 포함하지 않는다. diff --git a/docs/org-chart/expanded-usage.md b/docs/org-chart/expanded-usage.md new file mode 100644 index 0000000..cee1cbc --- /dev/null +++ b/docs/org-chart/expanded-usage.md @@ -0,0 +1,302 @@ +# Expanded OrgChart 사용 및 스타일 — P7 + +2026-10-12 사용자 변경 요구사항으로 **현재 확장형 카드 외형과 크기를 유지**한다. +`Group 3391.svg`, `Group 1171288761.svg` 검색·재현·용도 배정은 이번 완료 조건에서 제외한다. +이 결정은 이전 P5-1B/P5-2/P5-3 검증 보고의 당시 미검증 기록을 변경하지 않는다. +Fixed의 승인 SVG·1920×914.5 구성·컬러·팝업은 기존 정책을 유지한다. + +## Vanilla JS와 JSON Source + +아래는 Vite를 사용하는 호스트의 공개 API 예시다. 순수 HTML 배포에서는 공개 CSS·ESM +산출물을 호스트가 제공하고 URL을 지정한다. 실행 가능한 HTML은 +`examples/org-chart/expanded.html`과 `expanded-app.js`를 참고한다. + +```js +import { createOrgChart, JsonOrgChartDataSource, getCompanyColorPreset } from '@hm-design/org-chart'; +import engineCssUrl from '@hm-design/org-chart/engine.css?url'; +import coreCssUrl from '@hm-design/org-chart/style.css?url'; +import dataset from './data-expanded-small.json'; // 기존 20명 가상 JSON을 호스트에 복사 + +const chart = createOrgChart({ + container: document.getElementById('chart'), + dataSource: new JsonOrgChartDataSource(dataset), + stylesheetUrls: { engine: engineCssUrl, core: coreCssUrl }, + layoutMode: 'auto', // 'fixed' 또는 생략은 기존 Workdesk + displayMode: 'readOnly', + selectedCompanyIds: ['hanmac'], // 단일 회사 + companyTopNodes: { hanmac: { title: '회장', display: 'card' } }, + employeeConnections: { 'hanmac-office-0': 'left' }, + zoom: { min: 0.05, max: 3, step: 1.2 }, +}); +await chart.mount(); +await chart.setSelectedCompanies(['hanmac', 'saman']); // 복수 회사 +await chart.setSelectedCompanies(); // 전체 +await chart.setSelectedCompanies([]); // 명시적 빈 선택 +chart.zoomTo(1); // 배율만 변경; 100% 도구 버튼은 이동 위치도 복원 +chart.panBy(40, 0); +chart.fitToView(); +console.log(chart.getViewportState(), getCompanyColorPreset('hanmac')?.primary); +// 호스트가 더 이상 컴포넌트를 사용하지 않을 때 chart.destroy() +``` + +`JsonOrgChartDataSource`는 인라인 JSON 또는 호스트가 제공하는 HTTP(S)/상대 URL을 받는다. +URL을 사용할 경우 `new JsonOrgChartDataSource('./data-expanded-large.json')`처럼 지정한다. +file:// Fetch는 지원하지 않는다. 이번 작업에 외부 API·서버·DB 구현은 추가하지 않았다. +기존 JSON 예시는 Small 20명/3회사, Medium 100명/5회사, Large 300명/7회사다. +P7의 7회사 합산·회사별 선택은 Large를 기반으로 아래 공개 helper를 사용한다. + +## React + +```tsx +import { useState } from 'react'; +import { OrgChart } from '@hm-design/org-chart-react'; +import { JsonOrgChartDataSource } from '@hm-design/org-chart'; +import engineUrl from '@hm-design/org-chart/engine.css?url'; +import coreUrl from '@hm-design/org-chart/style.css?url'; +import dataset from './data-expanded-small.json'; + +const source = new JsonOrgChartDataSource(dataset); +const stylesheets = { engine: engineUrl, core: coreUrl }; +const topNodes = { hanmac: { title: '회장', display: 'card' as const } }; +const connections = { 'hanmac-office-0': 'left' as const }; + +export function ExpandedExample() { + const [selected, setSelected] = useState(['hanmac', 'saman']); + return <> + + + ; +} +``` + +위 native 버튼은 React 계약만 설명하는 최소 예시다. HM 화면에서는 실제 기존 +`Button`, `Checkbox`, `Label`, `Select`를 조합한다. Expanded Story가 그 사용 예다. +Source·옵션 객체를 안정적으로 유지한다. Core 생성 옵션(layoutMode·TopNode·연결 방향·Zoom 범위)을 +변경하면 React는 handle을 다시 생성한다. Vanilla는 새 handle을 생성한다. +`autoLayout`·`interactions`·회사 선택·팝업 선택은 기존 인스턴스에서 갱신한다. +회사 선택은 기존 setter/React prop으로 변경하며 원본 JSON과 parentId를 수정하지 않는다. +React에는 별도 레이아웃 알고리즘이나 imperative ref API가 없다. + +## 테마·최상위 노드·연결 방향 + +회사 Root의 `Organization.companyId`가 자손에게 상속된다. 회사별 독립 Root와 기존 +Employee 관계를 유지한다. `companyId` 생성 옵션은 기존 JSON의 회사 판별 fallback이며, +다중 회사 전체를 한 색으로 바꾸는 설정이 아니다. + +| ID | 회사 | 메인 컬러 | +|---|---|---| +| hanmac | 한맥기술 | #CB2E28 | +| saman | 삼안 | #F08300 | +| jangheon-industry | 장헌산업 | #4580A6 | +| jangheon | 장헌 | #1D418D | +| ptc | 피티씨 | #84338E | +| halla | 한라산업개발 | #0068B7 | +| baron | 바론컨설턴트 | #0C367F | + +표의 값은 기존 `getCompanyColorPreset(companyId)`에서 조회한다. 카드에 중복 하드코딩하지 않는다. +타이틀은 조직 회사, 카드 강조색은 알려진 직원 affiliation(미등록 시 조직 회사)의 프리셋을 쓴다. +현재 사용자 확정에 따라 모든 회사 타이틀은 normal/hover/focus/접힘/펼침에서 흰색을 유지한다. +이는 P7 시작 전 기존 미커밋 변경을 보존한 결과다. 삼안·장헌산업 흰 글자의 대비 진단은 +접근성 승인으로 재분류하지 않으며, 이번 기능 확장으로 메인 컬러를 바꾸지 않는다. + +`companyTopNodes`는 `employeeId`, `title`, `name`, `profileImage`, `organizationName`, +`display: 'card' | 'titleOnly' | 'hidden'`을 지원한다. 생략 시 Root의 leaderId를 사용하며, +employeeId는 그 Root에 소속된 기존 직원이어야 한다. null은 최상위 카드 배정을 제거한다. +표시 override는 원본 identity·팝업 데이터·callback·관계를 바꾸지 않는다. + +`employeeConnections`의 지원 값은 `top | left | right`다. Depth를 기준으로 정하지 않는다. +직원 연결선의 실제 도착점과 같은 면에만 회사 Border를 표시하며, 조직장은 타이틀 아래 +TB/Hybrid에서는 상단 연결을 사용한다. LR은 조직장·일반 직원 모두 왼쪽에서 연결되며 +직원별 실제 도착 면과 왼쪽 Border가 일치한다. 다른 면은 중립, 강조는 inset이라 외형 크기가 변하지 않는다. +연결 없는 카드에 임의로 accent를 붙이는 별도 `none` API는 없다. 현재 Auto presenter의 +렌더링된 카드는 모두 타이틀 또는 이전 카드/분기 rail과 연결된다. + +## 접기·펼치기와 직원 팝업 + +그룹 타이틀 전체 native button은 click·Enter·Space로 접기/펼치기하며 `aria-expanded`와 +표시 기호를 갱신한다. 조직장은 유지하고 일반 직원·자손을 숨기며 형제 상태는 독립이다. +부모 재확장 시 유효한 하위 상태를 유지한다. 하위 직원/조직 없는 타이틀은 disabled다. +그룹 링크는 별도 아이콘으로 페이지 이동과 토글 동작을 분리한다. +공개 프로그래밍 방식 collapse setter는 제공하지 않는다. + +Vanilla 직원 카드는 click·Enter·Space로 공통 직원 정보 dialog를 연다. dialog는 변환 forest 밖의 +native top layer에 표시되므로 Zoom과 Stage overflow의 영향을 받지 않는다. +ESC/닫기 버튼과 focus trap·카드 focus 복귀를 제공한다. +`onEmployeeClick(employee, context)` 지정 시 기본 팝업 대신 callback을 호출하며 +호스트가 dialog 접근성·닫기·focus 복귀를 책임진다. + +P5-4는 Tab으로 화면 밖 카드·타이틀에 도달하면 **배율을 유지하고 Pan으로 표시**한다. +드래그 직후 포인터 click만 억제하고 이후 Enter/Space 활성화는 허용한다. +실제 스크린리더 음성·실제 모바일 터치는 별도 검증 대상이다. + +## 카드 CSS 관리 + +현재 기본 카드 크기는 일반 직원 width 114px/min-height 54px, 조직장·최상위 +width 154px/min-height 72px이며 사진은 36×36px +비원형 슬롯이다. 직위/이름 10/13px, Noto Sans KR, padding 8px, 기본 border 1px, +inset accent 3px를 유지한다. 3글자 한글 이름은 `홍 길 동`처럼 표시하며 직위 공백은 +표시 계층에서만 제거한다. 긴 가상 이름이 두 줄이면 일반 직원의 실측 높이는 67px로 +자연 확장된다(기존 동작). 실제 profileImage를 우선하고 없음/실패는 기존 공통 사람 SVG다. + +Card geometry는 `packages/org-chart/src/styles/org-chart-expanded.css`에 Auto 전용으로 관리한다. +원본 Fixed CSS·SVG·Theme는 수정하지 않는다. `layoutConfig`·카드 교체 API는 없다. +Auto 부모·자식 관계선과 직원 연결선은 전용 `--hm-orgchart-expanded-line-color` +변수(기본 `#738399`)를 공유한다. 카드의 중립 Border·회사별 Border 강조와 Fixed +연결선은 이 변수를 사용하지 않는다. 배경 또는 이 색상을 재정의할 때는 실제 +합성 배경과 computed 표시색으로 비텍스트 대비 3:1 이상을 별도로 확인한다. +Core는 Shadow DOM을 사용하므로 일반 호스트 CSS selector는 내부 카드에 닿지 않는다. + +향후 별도 디자인 변경이 승인된다면 호스트가 공개 Core CSS 뒤에 다음 override를 결합한 +CSS 자산을 만들고 그 URL을 `stylesheetUrls.core`로 제공할 수 있다(이번에는 적용하지 않음). +`--hm-orgchart-expanded-*` 중 내부 선언이 있는 변수는 호스트 div에 설정하면 덮어써질 수 +있으므로 **Shadow DOM에 로드되는 CSS의 `.hm-orgchart-expanded`에서 재정의**한다. + +```css +/* 공개 Core CSS 다음에 결합하는 Auto 전용 예시. 임의 신규 API가 아니다. */ +.hm-orgchart-expanded { + --hm-orgchart-expanded-card-width: 114px; + --hm-orgchart-expanded-card-height: 54px; + --hm-orgchart-expanded-leader-width: 154px; + --hm-orgchart-expanded-leader-height: 72px; + --hm-orgchart-expanded-card-accent-width: 3px; + --hm-orgchart-expanded-photo-size: 36px; +} +``` + +크기 변경 시 기존 ResizeObserver가 article의 실측 높이와 실제 endpoint를 재계산한다. +향후 변경에는 Fit·형제/하위 트리 겹침·텍스트 overflow를 다시 확인해야 한다. +P7도 기본 카드 geometry를 바꾸지 않는다. LR/Hybrid 300명과 2,100명의 실측은 +과거 P5/P6 성능과 분리하여 P7 보고서에 기록한다. + +과거 검증은 [P5-4 보고서](implementation-report-p5-4.md), P7 변경과 실측은 +[P7 보고서](implementation-report-p7.md)를 참고한다. + +## P7 데이터·레이아웃·독립 옵션 + +```js +import { createOrgChartExampleDataset } from '@hm-design/org-chart'; +import template from './data-expanded-large.json'; + +const dataset = createOrgChartExampleDataset(template, { + scope: 'per-company', // 'total'이면 전체 7회사 합산 employeeCount + employeeCount: 300, // 20 | 100 | 300 + companyIds: ['hanmac', 'saman'], // 생략하면 7회사, 이 예시는 600명 +}); +const chart = createOrgChart({ + ...configuredBaseOptions, + dataSource: new JsonOrgChartDataSource(dataset), + layoutMode: 'auto', + autoLayout: { type: 'lr', initialVisibleDepth: 'all' }, + interactions: { collapse: false, zoom: true, pan: false }, +}); +await chart.mount(); +await chart.setAutoLayout({ type: 'hybrid', hybridStartDepth: 3 }); +await chart.setInteractions({ collapse: true }); // 다른 설정은 그대로 +``` + +총량 모드는 회사별 가중 분배 후 선택한 회사만 필터링하며 재분배하지 않는다. +회사별 모드는 선택 회사마다 정확히 N명, 전체 7회사 × 300명 = 2,100명이다. +ID는 deterministic 가상 namespace로 생성하며 기존 직원 기록·조직 ID·부모 관계를 재사용한다. +원본은 변경하지 않고 반환 snapshot은 deep freeze한다. helper의 빈 회사 선택은 유효한 +조직 데이터가 될 수 없어 거절하며, 화면의 빈 선택은 `setSelectedCompanies([])`로 표시한다. + +| 옵션 | TB | LR | Hybrid | 제약 및 전환 | +|---|---|---|---|---| +| 레이아웃 `type` | 지원 | 지원 | 지원 | 다른 기능을 켤 필요 없음 | +| `hybridStartDepth: 2/3/4` | 적용 안 함 | 적용 안 함 | 지원 | Root=1Depth, 기본 3 | +| `hybridOrganizationIds` | 지정 그룹 자손 세로 배치 | 조합 거절 | 조합 거절 | LR/Hybrid 전환 전 목록 해제; Story는 자동 해제·disabled | +| `initialVisibleDepth: 1/2/3/'all'` | 지원 | 지원 | 지원 | 기본 all; 값을 바꾸면 접힘 상태를 초기 정책으로 재설정 | +| `interactions.collapse/zoom/pan` | 각각 지원 | 각각 지원 | 각각 지원 | 각각 기본 true; false도 다른 기능 사용을 막지 않음 | +| 회사 선택·직원 팝업 | 지원 | 지원 | 지원 | 특정 레이아웃·Zoom·Pan·접기 사용을 요구하지 않음 | +| Compact | 미지원 | 미지원 | 미지원 | 엔진 compact DOM/140×50 grid가 기존 HM 카드·접기 계약과 충돌 | + +TB는 기존 경로를 유지한다. LR은 실제 dabeng `direction: 'l2r'`와 재귀 hierarchy를 사용한다. +원본 `.title/.content` 전용 회전 CSS는 HM 복합 article에 맞지 않아 Auto 범위에서 +회전을 해제하고 부모 좌측·자식 우측·형제 세로 배치를 구성한다. Hybrid는 실제 +`verticalLevel`/그룹 `hybrid`를 사용한다. 측정한 부모·자식 좌표로 연결선을 갱신하며 +Fixed의 CSS·엔진 옵션·기존 native 팝업은 보존한다. + +접기·레이아웃 전환은 유효한 조직 ID의 접힘 상태를 유지한다. 데이터·회사·레이아웃 +전환은 표시된 카드·타이틀·연결선 전체 경계를 다시 측정하여 가로 중앙을 갱신한다. +자동 배치는 현재 배율을 유지하며 큰 조직도를 임의로 축소하지 않는다. 사용자가 Pan·Zoom· +100% 복원 또는 화면 밖 focus 노출로 이동한 뒤에는 데이터·회사·레이아웃 전환과 Resize에서도 +배율·이동 위치를 보존한다. 명시적 Fit to View는 전체 표시 영역을 축소해 담고 가로·세로 중앙에 +배치하며 자동 중앙 배치를 다시 허용한다. 개별 interaction/popup 변경은 기존 viewport와 +접힘 상태를 보존한다. Pan을 꺼도 키보드로 화면 밖 카드에 도달하면 배율 유지·focus +노출을 제공한다. Fit/100% 복원은 Zoom/Pan 사용 여부와 독립적으로 가능하다. +2,100명 전체 Fit은 개요용으로 작게 축소되므로 카드 읽기에는 Zoom 또는 초기 Depth를 사용한다. + +React에서 명시적인 `@hm-design/org-chart-react/hm-dialog` Entry와 +`employeePopup="hm-dialog"`는 실제 공개 HM Dialog·Avatar·Button을 사용한다. +`'core'`가 기본, `'none'`은 카드의 클릭·button 의미를 제거하되 키보드 이동용 초점은 유지한다. `onEmployeeClick`을 +지정하면 선택 모드와 관계없이 callback이 우선한다. HM 모드는 +`@hm-design/ui@0.1.0` optional peer와 호스트 `index.css → hm-theme.css` 연결이 필요하다. +기본 Entry `@hm-design/org-chart-react`는 HM runtime/type Import 없이 Core 팝업을 +사용한다. UI 설치만으로 전환하지 않는다. 다른 디자인 시스템은 기본 Entry에서 +`employeePopup="adapter"`와 `employeePopupAdapter`를 명시하여 연결한다. +Core/Vanilla와 기존 데이터에는 React/HM 의존성이나 변경을 추가하지 않았다. +기존 HM 소비자는 Import만 `/hm-dialog`로 변경하고 기존 Props를 유지한다. +모든 bundler 조합은 별도 미검증이다. + +```tsx +import { OrgChart } from '@hm-design/org-chart-react/hm-dialog'; + + +``` + +## P9.3 브라우저 소속 변경 + +P9.4의 공통 Core/React 옵션 `dragDropEnabled?: boolean`은 기본 OFF다. 아래 소속 변경은 +ON에서 허용한다. React는 `dragDropEnabled={true}`, Vanilla는 생성 옵션 또는 +`handle.setDragDropEnabled(true)`로 활성화한다. `handle.getDragDropEnabled()`로 +현재 설정을 조회한다. OFF의 `handle.move()`는 `EDITING_DISABLED`를 반환하며 포인터와 +Alt+M도 시작하지 않는다. 전환 시 진행 중 이동·Pointer capture·Drop 강조를 취소하고 +이미 완료한 이동을 보존한다. 설정만 바꿀 때 데이터 재요청·재생성·레이아웃 갱신은 없다. +직원 정보, 그룹 접기·펼치기, Zoom/Pan/Fit은 기존 독립 설정을 따른다. Fixed는 변경하지 않는다. + +Expanded Story의 데이터 범위 UI는 제거했고 예시 직원 수20/100/300명은 회사별 기준만 +표시한다. 전체 합산 `createOrgChartExampleDataset` 공개 helper 계약은 유지한다. +회사 선택은 즉시 표시를 갱신하고 숨김·복귀에도 완료한 이동을 유지한다. 직원 수는 기존 +**데이터 적용**으로 새 예시를 생성하며 임시 이동과 새 Fixture를 섞지 않는다. 초기 설정은 +7개 회사×20명=140명, 선택2개×100명=200명, 선택7개×300명=2,100명이다. + +Expanded Story의 외곽 미리보기는 실제 Center Storybook 공통 카드 CSS의 +`--sb-center-card-background` / `--sb-center-card-border` / `--sb-center-card-radius`를 +재사용한다. 전용 `--hm-orgchart-expanded-preview-background`, `-border`, `-radius` +입력은 `.hm-orgchart-expanded` 외곽에만 적용한다. 직원 카드·회사 타이틀·Viewport의 +색상 입력은 그대로이며, 입력을 생략한 Vanilla의 기존 기본 외곽 스타일은 유지한다. + +Auto의 빈 공간 드래그는 기존 Pan이며 직원 카드·그룹 타이틀 드래그는 회사 내부 또는 +회사 간 소속 변경이다. 직원 이동은 `organizationId`, 조직 그룹 이동은 `parentId`를 +갱신한다. 같은 회사 내부에서는 파견 직원을 포함한 기존 `affiliationId`를 보존한다. +회사 간 직원 이동은 `affiliationId`도 대상 회사로 변경하고, 회사 간 그룹 이동은 이동 +그룹과 하위 조직 전체의 `companyId`, 소속 직원 전체의 `affiliationId`도 대상 회사로 +변경한 새 snapshot을 현재 Core 인스턴스에 적용한다. ID·하위 계층· +이름·직위·사진·조직장·정렬은 보존하며 회사 색상과 인원 집계는 대상 회사에 맞춰 갱신한다. +원본 JSON·회사 프리셋·서버 데이터는 변경하지 않는다. +그룹 클릭의 접기·펼치기와 직원 클릭의 정보 팝업은 유지하며 실제 드래그 뒤의 포인터 +click은 억제한다. 자기 자신·자손을 부모로 지정하는 이동, 회사 Root 이동, +조직장 참조를 손상시키는 직원 이동은 거절하며 이유를 안내한다. + +직원 카드 또는 그룹 타이틀에 초점을 둔 뒤 `Alt+M`으로 이동을 시작할 수 있다. +방향키로 대상을 선택하고 `Enter`로 확인하거나 `ESC`로 취소한다. 팝업을 꺼도 직원 +카드는 이동 조작을 위한 초점을 제공한다. Fixed에는 소속 변경을 추가하지 않는다. + +Core와 React의 `onMove(result: OrgChartMoveResult)`는 이동 성공·실패를 알린다. +`result.moved`로 성공을 구분하며, React는 성공한 이동 후 열린 HM 직원 Dialog를 닫는다. +React callback 변경은 최신 committed callback만 갱신하고 Core를 재생성하거나 reload하지 않는다. + +Expanded Story는 모든 회사의 원본 예시를 하나의 세션 source로 유지하고 회사 선택은 +`selectedCompanyIds`로 처리한다. 회사 숨김·다시 표시·빈 선택은 성공한 소속 변경을 +보존한다. **데이터 적용**은 현재 draft 조건으로 새 예시 source를 만들고 이동 내용을 +초기화한다. **초기화**는 최초 설정과 최초 소속으로 돌아가며 설정값이 이미 최초값이어도 +이동 내용은 되돌린다. 파일을 다시 작성하거나 React key로 강제 remount하지 않는다. +Story의 직원 합계와 그룹 선택은 성공한 `onMove`의 `result.data`를 별도 read model로 +읽으며, source identity는 유지한다. 표시 직원 수는 최초 예시 인원이 아닌 현재 조직의 +회사 소속을 기준으로 계산하므로 회사 간 이동 직후와 회사 숨김·다시 표시에도 일치한다. +미리보기에는 선택 회사별 현재 직원 수도 표시하며 데이터 설정의 회사당 인원은 예시 생성 기준이다. diff --git a/docs/org-chart/final-integration-2026-10-13.md b/docs/org-chart/final-integration-2026-10-13.md new file mode 100644 index 0000000..bcf671b --- /dev/null +++ b/docs/org-chart/final-integration-2026-10-13.md @@ -0,0 +1,60 @@ +# OrgChart 최종 Feature 통합 — 2026-10-13 + +통합 범위는 **Chart Only + 초기 Fit + 직접 Wheel Zoom**, **GuardFix 해결 5건**, **AI 생성 방식 사전 선택 정책**이다. Cell 디자인 A/B안은 제외했다. 범위 검증·테스트는 PASS이며 전체 Guard는 잔여 FAIL 5건을 유지한다. Feature Commit·Push 완료 SHA와 최종 Working Tree는 이 작업의 최종 응답에서 보고한다. + +## 범위와 원본 보호 + +- 대상: `D:\디자인시스템\HMDesign-OrgChart`, `feature/orgchart-package`, 기준 HEAD `0a8daf7ee7ac026952a2e1d3d5f25a922dab07cf`. +- Chart Only 원본 Worktree(24개 승인 파일)와 GuardFix 원본(2개 파일)은 같은 기준 HEAD다. 원본 파일을 해시 snapshot으로 복사했고, 통합 뒤 원본 SHA-256이 모두 같음을 확인했다. 원본 Worktree에 수정·Stage·Commit하지 않았다. +- 기존 미커밋 `AGENTS.md`, `CLAUDE.md`, `ai-page-generation-rules.md`의 사전 선택 변경을 보존했다. 겹치는 정책 문서는 전체 교체하지 않고 Auto 공개 API·Fixed 제약·현재 통합 범위만 합쳤다. +- Cell 실험은 제품 Core에 적용된 적이 없다. 별도 `examples/org-chart-cell-preview`만 경로 검증 후 ignored `test-results/org-chart-cell-withdrawn-2026-10-13/preview`로 옮겼다. Cell 전용 스타일·미리보기·이미지는 Commit 대상에 포함하지 않는다. 기존 그룹 타이틀·직원 카드·연결선 디자인을 유지한다. +- 회사 프리셋, 직원 카드, Fixed 승인 아트워크, 기존 Storybook 메뉴/Story, Theme/Token, Guard 분석기, JSON Contract, lockfile을 수정하지 않았다. `git reset --hard`, `git clean`, Force Push는 사용하지 않았다. +- main Merge·Docker·npm Registry Publish는 실행하지 않는다. + +## 통합한 동작 + +- Auto Core/React 공통 `presentationMode: 'chart-only'`는 흰색 캔버스·조직 노드·연결선만 표시한다. 내장 제목·기준일·선택 회사/인원 요약·Toolbar를 생성하지 않고 기존 접근성 live feedback는 시각적으로 숨긴다. Shadow DOM 소비자 강제 조작은 없다. +- Chart Only의 기본은 `initialView: 'fit'`, `wheelZoom: 'direct'`다. 실제 Bounds를 최초 Fit·가로/세로 중앙 배치하며, 직접 휠 Zoom은 캔버스 안에서 포인터를 기준으로 적용한다. 바깥 휠은 페이지 스크롤을 유지한다. +- 옵션을 생략한 기존 Expanded는 제목·요약·Toolbar·Modifier 휠·기본 중앙/상단 배치를 유지한다. Fixed Chart Only는 승인 디자인 보호를 위해 TypeError로 거부하며 임의로 Auto로 바꾸지 않는다. +- React의 같은 값 inline 옵션 리렌더링에서 불필요한 재마운트/재Fit을 피하고, 데이터/회사/layout 교체에서는 새 Bounds로 Fit한다. +- 통합 검토에서 발견한 2개 뷰 보존 문제를 같은 범위에서 수정했다. controller 재생성의 `initialPlacement` default가 명시적 undefined를 center로 바꾸던 문제를 제거했고, 내부 `fitScale()` getter로 기존 Fit floor를 전달한다. 전체 데이터 교체 시 이전 floor를 버리고 새 Fit을 계산한다. 계획된 공개 Props 3개 외 공개 API 확장은 없다. +- AI 요청의 생성 방식이 미지정이면 코드 작성 전 Chart Only / Full Page를 질문하고 답변 후 구현한다. 이미 선택된 방식은 재질문하지 않는다. Fixed/Expanded 배치 선택은 생성 방식 선택을 대신하지 않는다. + +## GuardFix 5건과 남은 결과 + +`department-colors.ts`의 미선언 contrast 후보 참조 3곳을 원래 fallback과 같은 `#10243b`/`#ffffff`로 교체했다. `expanded-content.ts`의 미소비 `--hm-orgchart-expanded-title-ink` setter와 그 안의 두 토큰 참조를 제거했다. 회사 primary·기존 흰색 제목 텍스트를 바꾸지 않는다. GuardFix 원본의 변경 전후 computed/geometry·캡처 hash 동일 증거도 확인했다. + +현재 동일 범위 Guard는 **F3–F7 해결, 기존 FAIL 10→5, 새 FAIL 0, NOT VERIFIED 9**다. 남은 F1/F2/F8/F9/F10은 다음 5개 TOKEN-001 위치다. + +- `department-colors.ts:47`: current-gradient / current-card-background. +- `style-presets.ts:9`: resolved-bg-surface. +- `style-presets.ts:74–75`: current-border / current-gradient. + +원본 `status/staticStatus = FAIL`, `runtimeStatus = NOT VERIFIED`를 보존한다. 원본 `styleEvidence`는 STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT PASS / FINAL NOT VERIFIED이며 이 값으로 전체 Guard를 PASS로 바꾸지 않는다. 결합 styleVerification은 생성하지 않았다. 기존 P10/P10.3의 FAIL 10건 기록은 수정하지 않는다. `org-chart-content.css`의 추가 미선언 후보 참조까지 해결한 전체 패키지 검사는 이번 GuardFix 5건 범위가 아니다. + +실행: `node scripts/ai-generation-guard.mjs --target . --scope PARTIAL --profile STORYBOOK --files packages/org-chart/src/core/department-colors.ts,packages/org-chart/src/core/expanded-content.ts,packages/org-chart/src/core/style-presets.ts --json`. 결과는 ignored `_local/final-integration/guard-final.json`에 보존한다. + +## 최종 통합 검증 + +- **PASS:** 루트 `tsc -b`, Core/React/독립 소비자 TypeScript 검사, 패키지 빌드, 독립 Chart Only production build, Storybook production build, Theme defaults 92 colors/94 defaults, `git diff --check`. +- **PASS:** Core 86/86, Guard 회귀 94/94. viewport 수정 후 Core/관련 타입·빌드만 재검사했고 변경하지 않은 Guard 회귀는 반복하지 않았다. +- **PASS:** 실제 통합 production 빌드의 React/Vanilla Chart Only **51/51**, 콘솔 오류 0. 20/100/500명·다중 회사·TB/LR/Hybrid·390px/1440px, 초기 Fit/중앙, 직접 휠의 포인터 고정·범위, Pan, 캔버스 밖 페이지 스크롤, 데이터 교체, popup Core/HM Dialog, 접기·직원 보존, 모바일 CDP touch를 확인했다. +- **PASS:** 추가 5개 회귀는 미조작 초기 Fit 직후 접기/펼치기, Fit보다 높은 `zoom.min=0.8`로 재생성 후 축소 floor, Vanilla `setInteractions` 재생성 후 뷰 보존을 검사한다. 별도 `verify-min` query는 예제 검증용이며 제품 API/기본값 변경이 아니다. +- **PASS:** 새 Storybook의 공개 메뉴 4개·Fixed 구성·Expanded 내장 UI·Fit 버튼 4개 검사는 첫 실행에서 통과했다. Modifier 휠 항목은 페이지 smooth scroll의 좌표 이동 때문에 실패했으므로 actual wheel 이벤트/배율로 진단하고 instant scroll로 테스트 좌표를 고정해 해당 항목만 재검사했다(1/1 PASS, 일반 휠 scale=1, Ctrl 휠=1.2214). 최초 결과 4/5와 재검사 결과를 별도 보존했다. 제품 휠 코드는 이 진단으로 변경하지 않았다. +- 원본 Chart Only 작업의 기존 baseline DOM/픽셀 비교 9/9는 원본 작업 당시 증거다. 현재 통합의 5개 Storybook 동작 검사와 구분하며, 통합 후 전체 Storybook 픽셀 baseline 비교 완료를 주장하지 않는다. + +실제 증거: `test-results/org-chart-chart-only/results.json`, `storybook/results.json`, `storybook/results-wheel.json`, 캡처 PNG 및 `_local/final-integration/` 로그. 서버 네트워크 범위를 맞추기 전 connection timeout은 검사 실행 전의 도구 오류였으며, 실제 브라우저 실행 결과와 구분한다. + +Build는 성공했지만 기존 `:host-context(.dark)` 처리·bundle size 경고와 Storybook 기존 경고를 보존한다. 경고 해결을 위해 범위를 넓히지 않았다. + +**NOT VERIFIED / 기존 제약:** Fixed Chart Only, Safari/Firefox, 실제 모바일 기기·스크린리더, 2,100명 전체 회귀, 모든 회사·폭의 수동 시각 검토, 신규 npm tarball 외부 fresh install, 전체 baseline 픽셀 회귀. 기존 Fixed 390px 내부 스크롤 검사 실패는 원본 작업에서 기준 HEAD에도 재현된 기존 기록이며 이번 통합에서 해당 검사를 재실행하거나 수정하지 않았다. + +## 미리보기와 기록 + +- React: `http://127.0.0.1:6045/react.html?data=100` +- Vanilla: `http://127.0.0.1:6045/vanilla.html?data=100` +- 통합 Storybook: `http://127.0.0.1:6046` + +Feature Push 대상은 공식 `https://gitea.hmac.kr/b25027/HMDesign.git`의 `feature/orgchart-package`다. 시작 시 원격 Feature는 기준 HEAD와 같고, 원격 main은 `c45d63a03b84eaac43f01bd400a16488a80e4229`였다. Commit/Push 직전에 다시 확인하며 원격 변경 시 덮어쓰지 않는다. + +작업 시작: 2026-10-13 18:18:29 KST. 최초 Estimated Duration: 20–30분. Commit·Push를 포함한 완료 시각/Actual Duration/Variance는 최종 응답에서 기록한다. diff --git a/docs/org-chart/framework-integration.md b/docs/org-chart/framework-integration.md new file mode 100644 index 0000000..9ccf1f3 --- /dev/null +++ b/docs/org-chart/framework-integration.md @@ -0,0 +1,179 @@ +# OrgChart Framework Integration + +상태: 설계 계약·사용 예시 제안. **현재 package/Wrapper/HTML bundle은 존재하지 않으며 예시 API·경로도 승인 전이다.** 이 문서는 코드를 생성하거나 지원 완료를 주장하지 않는다. [Architecture](architecture.md)의 facade와 [Data Contract](data-contract.md)를 공통 기준으로 한다. + +## 1. 지원 범위와 책임 + +1차 공식 개발 대상은 **Vanilla HTML/JavaScript와 React만**이다. 현재 두 환경 모두 구현 미착수·실행 **NOT VERIFIED**다. Vue·Angular·기타 framework는 이번 개발 범위에서 제외하며 Adapter 구현·배포·지원 검증을 계획에 포함하지 않는다. 최초 추가 요청의 Vue 설계 항목은 최신 지원 범위 확정으로 대체했다. 향후 Core facade를 연결할 수 있도록 framework 독립성만 유지한다. + +우선 검토 구성은 `@hm-design/org-chart`(Core), `@hm-design/org-chart-react`(React Wrapper)다. 실제 경로·두 패키지 분리는 workspace/build/dependency 검토 후 최종 승인한다. React Wrapper는 Engine Adapter와 다르다: Wrapper는 React와 Core 사이, Engine Adapter는 Core와 dabeng 사이에 있다. Data Source Adapter는 외부 형식과 normalized 계약 사이에 있다. + +- Core: 표준 DOM, 공통 계약·Validation·ViewModel·집계·engine·노드 UI·Theme CSS·인터랙션·요청 취소·생명주기. +- Vanilla 호스트: container 제공, Source·options 전달, Theme/CSS/script 로딩, 페이지 제거 시 Destroy. +- React Wrapper: ref/effect로 Core mount, Props 변경을 update/reload 또는 설정 재생성에 연결, 언마운트 시 Destroy. 공통 업무 로직·렌더링 엔진·노드 디자인을 재구현하지 않는다. +- 상위 앱: Layout·Navigation·Routing·API 인증·권한·Theme 공급. Core에 React context나 인증 hook을 요구하지 않는다. + +두 환경은 동일 JSON 입력 → 동일 Validation → 동일 계층·집계 → 동일 Core 노드·Theme·인터랙션을 사용한다. React 외부 HM Component는 페이지 조합이며 Core 내부 노드의 다른 디자인을 만드는 근거가 아니다. + +## 2. 생명주기 계약 초안 + +메서드 이름은 확정 생명주기 요구를 구체화한 API 제안이다. engine 자체의 destroy 지원과 혼동하지 않는다. + +1. `createOrgChart(options)`: DOM 생성·요청 없이 Handle을 만든다. framework import와 전역 window 접근 부작용을 ESM import 시 피하는 안을 검토한다. +2. `mount(container)`: 연결된 HTMLElement의 전용 자식 root를 소유하고 첫 Source를 읽는다. 호스트의 sibling/기존 콘텐츠를 지우지 않는다. 한 Handle에 한 container이며 중복 mount는 명시적 호출 오류로 거부한다. +3. `update(input)`: mounted 상태에서 normalized 스냅샷 후보를 검증·집계·렌더한다. Source fetch 없이 스냅샷만 바꾼다. 현재 Source 객체는 유지하므로 이후 reload는 그 Source를 다시 읽는다. +4. `reload(source?)`: mounted 상태에서 현재/교체 Source를 읽고 같은 반영 경로를 사용한다. JSON → Mock API/실제 API 전환의 공개 지점이다. +5. `destroy()`: 동기·반복 안전한 최종 정리. 진행 요청 abort·revision 무효화·콜백 중단, Core/engine 이벤트·Observer·frame/timer·DOM·인스턴스 참조를 정리한다. destroyed Handle은 재사용하지 않고 새 Handle을 만든다. + +mount/update/reload는 Promise 제안이다. 데이터·Source 실패는 Error state로 알리고 Promise를 정상 완료하는 방식을 우선 검토한다. 잘못된 호출 순서·사용법은 명시적 오류로 reject한다. abort/새 revision에 의해 폐기된 작업은 unhandled rejection 없이 종료하고 DOM/상태를 바꾸지 않는다. mount 진행 중에도 이미 확보한 container의 update/reload는 이전 작업을 무효화한다. mount 전·destroy 후 update/reload는 오류다. Promise 완료는 해당 revision의 반영 또는 controlled error 전환까지이며 이미지/폰트의 모든 로드 완료를 뜻하지 않는다. + +상태 전이는 loading → ready/empty/error다. update는 동기 validation 중에도 같은 state 의미를 유지한다. Empty 정의와 갱신 중/실패 시 직전 화면 유지 정책은 TBD다. 같은 Metadata 버전이라도 새 입력을 무시하지 않는다. reload/update 경쟁은 마지막 시작 revision이 우선하도록 제안한다. + +엔진 정리 가능성은 최초 PoC에서 입증해야 한다. document 이벤트·관찰자를 해제할 경로를 찾지 못하면 Destroy 완료를 PASS로 표시하지 않는다. core가 소유하지 않은 engine 자원을 `innerHTML = ""`만으로 정리했다고 판단하지 않는다. 제한/통합 대안을 승인받기 전 기능 개발을 확대하지 않는다. + +engine 재생성 전 이전 chart가 살아 있는 동안 기존 pan/zoom·minimap 등의 해제 가능성을 확인한다. update/reload 반복 후에도 이벤트/Observer가 증가하지 않는지 검사한다. Core와 데이터 계층의 DOM 없는 import는 upstream 직접 import와 구별하며 지연 engine 로딩/변환 entry를 검증한다. + +## 3. 데이터 Source 사용 예시 + +아래 `createJSONSource`·`createExternalJSONSource`도 제안 API다. 모든 반환값은 같은 validator를 통과한다. 가상 데이터 `data`는 Data Contract §3 스냅샷을 뜻한다. + +```ts +// 제안 API; 아직 설치 가능한 공개 함수가 아니다. +const jsonSource = createJSONSource(data); +const externalSource = createExternalJSONSource({ url: configuredJsonUrl }); +// URL은 호스트 설정. Core에 회사 서버 주소를 하드코딩하지 않는다. +``` + +JSON Mock은 객체 스냅샷을 공급하고 consumer 입력을 수정하지 않는다. External JSON은 AbortSignal·HTTP 오류·JSON 구문 오류를 처리한다. source가 JSON을 반환했다는 이유만으로 Validation을 생략하지 않는다. + +JSON Source는 생성 시점 입력을 캡처하는 안, update는 호출 시점 입력을 확보하는 안을 제안한다. 같은 객체의 외부 mutation을 자동 변경 알림으로 취급하지 않는다. 검증·집계·DOM 반영은 Core 소유 불변 스냅샷에서 수행하고 최종 state/DOM commit 직전 revision·destroy 상태를 다시 확인한다. + +API가 마련됐을 때의 공통 계약 예시: + +```ts +// OrgChartDataSource, OrgChartData는 문서의 초안 type이다. +function createApiSource( + request: (signal: AbortSignal) => Promise, + mapResponse: (raw: unknown) => OrgChartData +): OrgChartDataSource { + return { + async load({ signal }) { + return mapResponse(await request(signal)); + } + }; +} +const apiSource = createApiSource(hostApiRequest, mapApiResponse); +await handle.reload(apiSource); // Core/노드 UI/집계/React Wrapper는 유지 +``` + +실제 request·mapping은 명세 확정 후 호스트에서 작성한다. mapResponse type 선언은 보안 경계가 아니며 Core가 결과를 재검증한다. Endpoint·인증·재시도·캐시를 임의로 예시에 넣지 않는다. Mock API는 같은 fixture를 Promise로 반환하고 지연/오류를 주입하여 실제 서버 없이 전환을 검증한다. + +## 4. Vanilla HTML 사용 예시 + +아래 asset 이름·`window.HMOrgChart` namespace는 미래 browser bundle **제안**이며 파일을 제작한 것이 아니다. React 설치 없이 classic script + 별도 CSS + inline JSON으로 `file://` 직접 실행을 검증할 예시다. 포장된 Theme CSS·font·Core CSS·번들이 모두 상대 경로로 제공돼야 한다. + +```html + + + + + + 가상 조직도 검증 + + + + +
+ + + + + +``` + +위 예시는 실행 검증 전 설계용이다. inline JSON은 가상 데이터만 사용한다. pagehide 이후 bfcache 복귀가 필요한 앱은 새 instance의 재mount를 호스트에서 계획한다. SPA 콘텐츠 제거에서는 pagehide에만 의존하지 않고 해당 제거 시점에 Destroy한다. + +ESM 비교 예시(HTTP(S) static server에서 우선 검증): + +```js +import { createOrgChart, createJSONSource } from "./assets/orgchart.esm.js"; +const handle = createOrgChart({ dataSource: createJSONSource(data) }); +await handle.mount(document.getElementById("orgchart-host")); +``` + +bare npm specifier는 일반 HTML에서 자동 해석되지 않는다. file://의 module/fetch 제한과 서버 기반 ESM 실행을 구별한다. `file://`에서는 inline JSON + classic bundle 경로를 검증하고 외부 JSON fetch가 가능하다고 보장하지 않는다. External JSON/API는 HTTP(S)·CORS·호스트 권한 조건에서 별도로 검증한다. + +## 5. React 사용 예시와 Wrapper 계약 + +아래 package names·Component/Prop은 우선 분리안의 예시이며 실제 Export가 아니다. 동일 Core CSS·Theme를 사용한다. React 호스트의 기존 CSS entry 순서를 지키고 HM 공개 Component를 사용하는 주변 영역은 해당 HM Tailwind source/Theme 규칙을 유지한다. + +```tsx +import { useMemo } from "react"; +import { createJSONSource } from "@hm-design/org-chart"; +import { OrgChart } from "@hm-design/org-chart-react"; +import "@hm-design/org-chart/style.css"; +// 기존 앱 Theme entry에서 @hm-design/ui/theme.css를 연결한다. + +function OrgChartContent({ data }: { data: OrgChartData }) { + const dataSource = useMemo(() => createJSONSource(data), [data]); + return ; +} +``` + +Props 후보는 dataSource·aggregationPolicy·interaction·onStateChange다. title/description/group/order는 공통 데이터의 PresentationConfig를 사용한다. React 전용 표현·집계 옵션을 따로 만들지 않는다. 직접 스냅샷 Prop 추가는 public API 승인 후 검토한다. + +Wrapper 동작: 최초 ref container 확보 → Core 생성·mount; dataSource 교체 → 기존 Handle.reload(newSource); 동일 Source의 내부 값 변경은 명시적 reload 또는 새 immutable Source로 전달한다. 새 객체를 매 렌더 생성해 불필요한 reload를 유발하지 않는다. options 중 집계·인터랙션 변경은 Core의 갱신 API 확장 또는 cleanup 후 instance 재생성 중 PoC로 결정한다. 미확정 옵션을 조용히 무시하지 않는다. + +언마운트는 destroy; effect cleanup→setup이 반복되는 React StrictMode에서도 새 instance만 mount한다. promise 경쟁·늦은 callback·변경되지 않은 props에 의한 중복 engine 생성·callback identity로 인한 무한 reload를 검사한다. Core가 DOM을 소유하는 전용 container 자식에 React가 동시에 노드를 render하지 않는다. Framework wrapper는 engine/node/Validation/집계 CSS를 복제하지 않는다. + +SSR/Server Components의 DOM 렌더링은 이번 지원 완료 범위가 아니다. import 시 브라우저 global 부작용·React client boundary·hydrate 안전성은 검토하고 미확인 기능은 NOT VERIFIED로 남긴다. + +## 6. CSS·Theme·배포 방식 + +같은 Core CSS와 HM Variables mapping을 모든 배포가 사용한다. 호스트는 Theme를 로드하고 Core는 컨테이너 내부 전용 selector만 사용한다. Theme 정책과 실제 선언 근거는 [Integration §3](integration.md#3-hm-theme-연결)을 따른다. + +- npm: Core ESM + type + CSS, Wrapper ESM + type 후보. exports/types/files/sideEffects를 설계하고 package tarball 안에서 실제 import·CSS·font/asset 존재를 검증한다. 실제 publish는 별도 승인이다. +- ESM: npm bundler import와 브라우저 상대 URL import를 구별한다. Core에 React/React DOM 또는 unresolved bare dependency가 섞이지 않도록 검증한다. +- browser bundle: IIFE/UMD 중 직접 script 사용 형식을 선택한다. `window.HMOrgChart` 후보, 엔진 포함 여부·외부 dependency·license·크기·CSP를 검토한다. inline page 예시는 CSP 승인 완료를 뜻하지 않는다. +- CSS: Core 스타일을 별도 `style.css` export/상대 asset로 제공하고 Theme를 중복 주입하지 않는다. upstream CSS의 container 밖 누출도 포함해 scope를 검증한다. +- TypeScript: Core 선언에는 React type이 없어야 하고 Wrapper만 React Props를 가진다. JS consumer에 TS transpilation을 요구하지 않는다. +- Theme 미연결: 승인된 scope 기본 스타일 제공 vs Theme 필수 연결 중 **TBD**다. 어느 정책이든 HTML/React가 같아야 한다. approved 기본값 없이 fallback 팔레트를 새로 만들지 않는다. + +Root의 기존 build script는 두 패키지만 처리하므로 새 Core·Wrapper build 연결 범위는 승인 후 확정한다. 엔진 CommonJS/UMD를 ESM·browser bundle로 변환할 수 있는지, export 기능 dependencies가 필수로 포함되는지, DOM-only engine와 adapter의 tree-shaking 가능성은 **NOT VERIFIED**다. 단순히 빌드 설정을 제안했다는 이유로 배포 가능 PASS를 내지 않는다. + +Vanilla Theme를 위해 Core dependency/peer에 React peer를 가진 `@hm-design/ui` 전체 package를 필수로 추가하지 않는다. 승인된 호스트 asset/별도 Theme 산출 경로에서 React 미설치 consumer 동작을 확인한다. Core/engine CSS 누출, 호스트 Theme의 전역 영향, 전달된 font의 상대 URL은 서로 다른 검증 대상이다. + +Theme owner는 호스트 하나이며 Core는 scope를 상속한다. 별도 Scoped Theme는 기존 `[data-hm-theme]` mapping 재계산·Scoped partial Default 의미를 따르고 Core가 자동 주입/reset하지 않는다. Global A/Scoped B의 두 instance가 서로 영향을 주지 않는지 확인한다. + +## 7. 최소 통합 검증 계획 + +동일 가상 fixture·공통 설정·Theme로 두 환경을 비교한다. 테스트가 확인해야 할 차이를 명시하고 같은 검사 반복을 피한다. + +1. React 설치 없는 HTML 단독 페이지에서 표시. `file://` classic bundle과 HTTP(S) ESM의 asset/console 결과를 각각 기록. +2. React에서 같은 Core로 동일 계층·노드 UI·인터랙션 표시. 새 렌더링 엔진이 없는지 dependency/code 검토. +3. 같은 JSON의 조직 ID·parent·순서·leader·직원 그룹과 공통 집계 결과 일치. +4. 조직 추가/삭제/이동·직원 이동/상태/관계사/사진·title/기준일 변경 후 재검증과 갱신. consumer 원본 객체를 바꿔도 이미 캡처/반영한 스냅샷과 집계가 암묵적으로 바뀌지 않음. +5. JSON → Mock API Source → JSON 전환, API 매핑 계약·loading/error·응답 경쟁·abort 확인. +6. HM Theme 연결 및 승인된 Theme 미연결 정책. computed Variables·node CSS·font·scope가 두 환경에서 일치. Global A + Scoped B + 두 instance의 독립성·Theme owner와 cleanup도 확인. +7. mount/update/reload/destroy·연속 remount·StrictMode·다중 instance에서 이벤트/Observer/timer/DOM 잔존과 늦은 callback 확인. +8. CSS 전역 충돌과 390/768/1280/1440px Overflow·키보드·focus·읽기 가능성 검증. +9. Core build·npm/ESM/browser bundle·CSS/type/license와 React dependency 미포함 검증. +10. 공식 운영 main·기존 UI/Theme/Storybook과 무관 파일 보존 증거. 코드 중복·공통 Core 재사용 검토. + +Vue/Angular 실행 검증은 여기 포함하지 않는다. 기술 제약·승인 게이트는 [Implementation Plan](implementation-plan.md)을 따른다. diff --git a/docs/org-chart/guard-review-p10-3.md b/docs/org-chart/guard-review-p10-3.md new file mode 100644 index 0000000..e868a2e --- /dev/null +++ b/docs/org-chart/guard-review-p10-3.md @@ -0,0 +1,106 @@ +# OrgChart P10.3 — Guard FAIL 10건 원인 분석 및 배포 차단 판정 + +RESULT: **PARTIAL** — FAIL 10건 모두 원인·영향·수정 방법 확인, 10/10 재현. 기술적 BLOCKER 0건. 단 Guard 원본 status는 FAIL 그대로이고, CI·조직 배포 정책에서 Guard가 필수인지는 근거가 없어 NOT VERIFIED다. 제품 코드·Guard 수정 없음. + +## 1. 실행 기준 + +| 항목 | 값 | +|---|---| +| Worktree / 브랜치 | `D:\디자인시스템\HMDesign-OrgChart-GuardReview` / `feature/orgchart-guard-review` (신규, 커밋 없음) | +| 기준 HEAD | `5acaf8a29596317133e54e8ad437ee00beba1cf0` (P10.2 미커밋 변경 미포함) | +| Guard | `scripts/ai-generation-guard.mjs` (schemaVersion 2) + `scripts/guard-theme-analysis.mjs`. P10.2 작업본에서도 수정되지 않은 파일 | +| 원본 결과 | P10 작업본 `test-results/org-chart-p10/guard.json` (sha256 앞부분 `a83d058656480fbf`): `status FAIL / staticStatus FAIL / runtimeStatus NOT VERIFIED`, STORYBOOK / PARTIAL, FAIL 10 / NOT VERIFIED 19 | +| 재현 | Node v22.23.2. `node scripts/ai-generation-guard.mjs --target --scope PARTIAL --profile STORYBOOK --files department-colors.ts,expanded-content.ts,style-presets.ts --json` → [guard-repro.json](../../test-results/org-chart-p10-3/guard-repro.json) | +| 재현 결과 | exit 1, FAIL **10**, P10과 code/file/line/message **10/10 동일**, 새 FAIL 0 | + +P10 원래 실행의 정확한 15개 경로 `--files` 명령은 보고서·로그에 기록이 없다. 그래서 FAIL이 나온 3개 파일만 선택해 재현했다. 출력 파일은 Worktree의 gitignore 대상 경로와 scratchpad에만 썼다. Guard는 stdout 외에 파일을 쓰지 않는다. + +## 2. Guard의 판정 방식 (근거) + +`guard-theme-analysis.mjs` 기준: + +- **정의(definitions) 수집 출처**는 세 가지뿐이다. ① graph에 포함된 소비 CSS 파일의 선언, ② 공개 CSS 매핑(`@hm-design/ui/theme.css`, `@hm-design/ui/dashboard-status.css`)과 그 상대 `@import`(L85-89), ③ JS object literal의 `'--x'` key(L102). +- `element.style.setProperty('--hm-…', …)`는 **정의로 수집하지 않는다**. +- `@hm-design/org-chart/style.css`는 공개 CSS 매핑에 없다. Storybook에서는 이 CSS를 `?url`로 import해 Shadow DOM에 주입하므로 graph에서도 보이지 않는다(L83의 `r.css`가 아님). +- `--hm-` 참조가 미정의면 fallback이 있어도 **FAIL**이다(L128, [Guard 정책 TOKEN-001](../guidelines/ai-generation-guard.md): "fallback 색이 있어도 없는 HM token은 FAIL"). +- 같은 동작은 P6 최소 재현 스크립트 `examples/org-chart/verify-guard-p1.mjs`에도 assertion으로 고정되어 있다. Shadow CSS 제외 → FAIL, setProperty 정의 미수집 → FAIL. + +## 3. 필수 요약표 + +| ID | 검사명 | 실제 원인 | 재현 | 영향 | 차단 등급 | 수정 방안 | 예상 시간 | +|---|---|---|---|---|---|---|---| +| F1 | TOKEN-001 `department-colors.ts:47` `--hm-orgchart-current-gradient` | **B** 런타임 선언(`applyOrganizationStyle(row)` setProperty, L28)을 Guard가 수집하지 못함 | 재현 | 없음. Storybook Department Colors 전용, dist 미포함 | LOW | Guard에 literal setProperty 정의 수집 추가(승인 필요) 또는 예외 승인 | F1·F2·F8~F10 공통 60~120분 | +| F2 | TOKEN-001 `department-colors.ts:47` `--hm-orgchart-current-card-background` | **B** F1과 동일 | 재현 | 없음 | LOW | F1과 동일 | (F1에 포함) | +| F3 | TOKEN-001 `department-colors.ts:75` `--hm-orgchart-contrast-candidate-color` | **D** 저장소 어디에도 선언 없는 HM 토큰. 미승인 대비 후보 미리보기(`UNAPPROVED_NOT_APPLIED`)의 fallback `#10243b`로만 렌더 | 재현 | 외부 패키지 영향 없음(dist 미포함, `createDepartmentColors` 0회). **Guard 규칙상 실제 위반** | MEDIUM | 같은 값의 literal로 교체(색 변화 없음). 또는 미리보기 CSS에 선언(단 `?url` CSS는 Guard graph 밖이라 해소 안 됨) | F3~F5 합계 15~25분 | +| F4 | TOKEN-001 `department-colors.ts:78` `--hm-orgchart-contrast-candidate-color` | **D** F3과 동일 (chip label) | 재현 | F3과 동일 | MEDIUM | F3과 동일 | (F3에 포함) | +| F5 | TOKEN-001 `department-colors.ts:78` `--hm-orgchart-contrast-candidate-chip-background` | **D** 선언 없음, fallback `#ffffff` | 재현 | F3과 동일 | MEDIUM | F3과 동일 | (F3에 포함) | +| F6 | TOKEN-001 `expanded-content.ts:30` `--hm-orgchart-resolved-fg-primary` | **B+A** 변수는 `styles/design-tokens.css:12` → `dist/style.css`에 선언됨(Guard graph 밖). 그리고 이 줄의 `--hm-orgchart-expanded-title-ink`는 **소비처가 없는 dead code**(d0828a7에서 CSS `color: var(--…title-ink)` 삭제, 제목은 `#ffffff` 고정 L75) | 재현 | 렌더 영향 없음. 삼안·장헌산업만 어두운 글자를 쓰는 것처럼 오해할 수 있는 유지보수 위험 | LOW | dead `setProperty` 1줄 삭제(시각 변화 없음) | 5~10분 | +| F7 | TOKEN-001 `expanded-content.ts:30` `--hm-orgchart-resolved-on-action-primary` | **B+A** F6과 동일(`design-tokens.css:20`) | 재현 | F6과 동일 | LOW | F6과 동일 | (F6에 포함) | +| F8 | TOKEN-001 `style-presets.ts:9` `--hm-orgchart-resolved-bg-surface` | **B** `design-tokens.css:8` → `dist/style.css`에 선언. 공개 CSS 매핑에 OrgChart CSS 없음 / `?url` Shadow 주입 | 재현 | 없음(Fixed/Expanded 실행 PASS 이력) | LOW | Guard 공개 CSS 매핑에 `@hm-design/org-chart/style.css` 추가(승인 필요) 또는 예외 승인 | (F1에 포함) | +| F9 | TOKEN-001 `style-presets.ts:74` `--hm-orgchart-current-border` | **B** 같은 함수 L69에서 같은 element에 setProperty로 선언 | 재현 | 없음. 배포 JS 경로(render.ts L84) | LOW | F1과 동일 | (F1에 포함) | +| F10 | TOKEN-001 `style-presets.ts:75` `--hm-orgchart-current-gradient` | **B** 같은 함수 L71에서 setProperty로 선언 | 재현 | 없음 | LOW | F1과 동일 | (F1에 포함) | + +분류: A 0(단독) · **B 7** (F1, F2, F6~F10. F6·F7은 A 성격의 dead code 동반) · C 0 · **D 3** (F3~F5) · E 0 · UNKNOWN 0. + +## 4. 개별 근거 (12개 필수 항목 요약) + +공통 사항: 10건 모두 **기존 문제**다(P9.4와 동일, P10 신규 0). 모두 **OrgChart 도입으로 발생**했다(`61c6f8d`/`a92913d`/`064517e`, 2026-10-12. main에는 OrgChart가 없음. C 레거시 아님). 모두 재현됐다. + +- **F1·F2** — 조건: Storybook graph에 직접 import된 `department-colors.ts`가 선택 파일일 때. swatch는 `applyOrganizationStyle(row, index)`가 실행된 `row`의 자손이므로 실제 cascade로 값이 정의된다. 외부 사용 영향 없음. 배포 영향 없음. +- **F3~F5** — 조건: 같은 파일. 세 변수 모두 저장소 전체에서 선언 0건이다(rg 확인). override hook을 소비하는 곳도 없다. 렌더 결과는 fallback과 같다. Guard 정책 문구상 정당한 FAIL이며 오탐이 아니다. 다만 실행 영향은 없고 Storybook 전용이다. 접근성 대비 후보의 승인 여부와는 별개다. +- **F6·F7** — 조건: Expanded Storybook graph. 참조 변수 자체는 공개 패키지 CSS에 선언되어 있다(dist 확인). 남은 문제는 dead setProperty다. 제거하면 두 FAIL이 사라지고 화면은 바뀌지 않는다. +- **F8~F10** — 조건: `style-presets.ts`가 선택 파일일 때. F8은 패키지 CSS에 선언되어 있지만 graph에서 보이지 않는다. F9·F10은 같은 함수 안의 setProperty 직후 참조라서 런타임에서 항상 정의된다. + +## 5. 배포 차단 판정 + +| 구분 | 판정 | 근거 | +|---|---|---| +| 기술적 배포 차단 | **없음 (BLOCKER 0)** | 빌드 실패·공개 API 파손·데이터 손상 없음. F1·F2·F6~F10은 실제 선언이 존재한다(design-tokens.css / dist/style.css / setProperty). F3~F5는 fallback으로 렌더되며 배포 산출물에 포함되지 않는다 | +| CI상 차단 | **CI 근거 없음** | 저장소에 `.github`·`.gitea`·`.husky`·CI yaml이 없다. `package.json`에 Guard를 실행하는 build/test 훅도 없다(`test:guard`는 Guard 자체 단위 테스트). 원격 Gitea 서버 측 설정·브랜치 보호는 **NOT VERIFIED** | +| 조직 정책상 차단 | **UNKNOWN — 사용자 결정 필요** | Guard 정책상 exit 1=FAIL이다. P10 보고서는 "Guard 잔여 수용 여부를 정리한 뒤 명시 승인"을 Commit/Push 조건으로 두었다. 이 근거만으로 배포 가능하다고 판정하지 않는다 | + +건수: **BLOCKER 0 / HIGH 0 / MEDIUM 3 (F3~F5) / LOW 7 / UNKNOWN 0**. + +## 6. 수정 우선순위 (실행하지 않음) + +| 순서 | 구분 | 대상 | 최소 수정 | 예상 | 제품 영향 | 병렬 | 수정 후 검증 | +|---|---|---|---|---|---|---|---| +| 1 | 배포 전 권장 | `packages/org-chart/src/core/expanded-content.ts:30` | dead `--hm-orgchart-expanded-title-ink` setProperty 삭제 → F6·F7 해소 | 5~10분 | 없음(제목 `#ffffff` 유지, 회사색 불변) | 가능(P10.2 미수정 파일) | Core typecheck/test, Guard PARTIAL 해당 파일, Expanded 제목 computed color 1회 | +| 2 | 배포 전 권장 | `packages/org-chart/src/core/department-colors.ts:75,78` | 미선언 HM var를 동일 값 literal로 교체 → F3~F5 해소. 후보 색은 미승인·미적용 상태 유지 | 15~25분 | 없음(Storybook 미리보기, 동일 색) | 가능 | Guard PARTIAL, Department Colors Story 1회(다른 Guard 규칙이 hex literal을 지적하는지 확인 필요) | +| 3 | 별도 의사결정 | `scripts/guard-theme-analysis.mjs` + `ai-generation-guard.test.mjs` | ⓐ `@hm-design/org-chart/style.css`(`?url` 포함)를 공개 CSS로 인식 ⓑ literal `style.setProperty('--hm-…')`를 정의로 수집 → F1·F2·F8~F10 해소. 규칙 완화가 아니라 탐지 정확도 개선이지만 Guard 계약 변경이라 승인 필요 | 60~120분 | 제품 영향 없음. 다른 소비 화면 판정이 바뀔 수 있어 회귀 필요 | 가능 | `node --test scripts/ai-generation-guard.test.mjs` 전체, 기존 Baseline 수량 대조, OrgChart PARTIAL 재실행 | +| 3' | 대안(승인 필요) | 문서 | 3을 하지 않으면 F1·F2·F8~F10을 "Guard 탐지 범위 밖 · 런타임 선언 확인됨" 예외로 기록 | 15분 | — | — | 원본 FAIL 보존 | + +- **즉시 수정 대상(BLOCKER):** 없음. +- **최소 수정 대상 파일:** `expanded-content.ts`, `department-colors.ts`(+ 선택 시 Guard 2개 파일). +- **예상 총 수정 시간:** 코드 1·2: 20~35분 + 검증 20~30분. Guard 확장 3 포함 시 **약 1.5~3시간**. +- 1·2만 적용하면 FAIL은 10 → 5건이 되고 Guard status는 여전히 FAIL이다. Guard PASS에는 3 또는 3'의 결정이 필요하다. +- OrgChart를 검사 대상에서 빼거나 규칙을 완화하는 방안은 제시하지 않는다. + +## 7. 디자인·접근성 구분 (삼안·장헌산업) + +1. 흰 제목(`#ffffff`) on 회사 primary의 일반 텍스트 WCAG 미달 진단은 **미해결로 유지**한다(이번에 재측정하지 않음 → NOT VERIFIED). +2. **Guard FAIL 10건에 포함되지 않는다.** F6·F7은 이 회사들의 제목 색을 바꾸던 이전 코드의 잔재일 뿐이고 현재 렌더에 관여하지 않는다. +3. 사용자 승인 디자인 예외다. 접근성 PASS가 아니다. +4. 배포 정책상 WCAG 준수가 필수인지는 근거 문서가 없다 → 별도 승인 필요. +5. 색 변경 없는 대안(텍스트 그림자, 굵기 증가, 제목 영역 내부 흰 label 등)은 P11 또는 별도 디자인 결정 사항이며 이번에 적용하지 않는다. 회사색·카드 디자인은 변경하지 않았다. + +## 8. 외부 패키지 구조와의 관계 + +FAIL 10건은 모두 `@hm-design/org-chart` Core 소스의 CSS 변수 참조에서 나온다. React Wrapper, `/hm-dialog` Entry, `employeePopupAdapter`, Optional Peer, HMDesign UI 정적 import, exports, 타입과는 **관련이 없다**. 기준 Commit에는 P10.2 Wrapper 개선이 없으며, 이번 결과를 개선 코드의 실패로 보지 않는다. Package Graph와 외부 소비자 기준 Guard는 별도 실행하지 않았다(NOT VERIFIED). + +## 9. P10.2와의 충돌 가능성 + +P10.2 미커밋 변경 파일(AGENTS/CLAUDE, docs, `packages/org-chart-react/*`, `src/org-chart/org-chart-expanded.stories.tsx`)과 수정 후보 파일은 **겹치지 않는다**. 다만 P10.2가 바꾸는 Expanded Story가 Guard graph에 포함되므로, 통합 후 PARTIAL Guard를 1회 다시 실행해야 한다. Guard 확장(3)은 AGENTS·Guard 정책 문서 갱신과 겹칠 수 있다. + +## 10. NOT VERIFIED + +- P10 원래 15개 경로 Guard 명령(기록 없음. 3개 파일 subset으로 동일 FAIL 재현) +- dist 선언 확인은 P10 작업본의 기존 `packages/org-chart/dist`를 읽은 것이다. 이 dist가 5acaf8a에서 빌드되었는지는 확인하지 않았다(Core 소스는 P10.2 미수정) +- 원격 Gitea 서버 CI·브랜치 보호, 조직 배포 정책상 Guard 필수 여부 +- Runtime / Design Intent / 소비자(React·Vanilla) 기준 Guard graph +- 삼안·장헌산업 대비 재측정 +- 수정안 적용 시 다른 Guard 규칙의 반응 + +## 11. 작업 제한 준수 + +제품 코드·Core·React Wrapper·Storybook·Guard 규칙·회사색 수정 없음. P10.2 작업본 파일 수정 없음(읽기만 함). 새 Worktree·브랜치를 만들고 이 보고서와 gitignore 대상 재현 JSON만 추가했다. Commit·Push·Merge·Rebase·Cherry-pick·PR·main Checkout은 진행하지 않았다. diff --git a/docs/org-chart/implementation-plan.md b/docs/org-chart/implementation-plan.md new file mode 100644 index 0000000..1b66208 --- /dev/null +++ b/docs/org-chart/implementation-plan.md @@ -0,0 +1,110 @@ +# OrgChart Implementation Plan + +상태: 설계 검토 후 수행할 개발 계획. **이번 작업은 Markdown만 작성하며 구현을 시작하지 않는다.** 1차 공식 개발 대상은 Vanilla HTML/JavaScript와 React다. Vue·Angular·기타 framework는 제외한다. [README](README.md)의 요구사항 추적과 [Framework Integration](framework-integration.md)의 통합 검증을 따른다. + +## 1. 단계별 계획과 완료 조건 + +### 1단계 — 설계 승인·핵심 기술 PoC + +사용자가 이 문서를 검토한 뒤 후속 개발 권한을 확인한다. Core/React Wrapper 두 패키지 분리안과 실제 workspace/build 연결 범위를 결정한다. engine 배포 버전을 고정하고 native DOM·module import·custom node·완전 cleanup·CSS scope를 소규모 PoC로 확인한다. 캡처가 제공되면 콘텐츠 8영역·허브·곡선·카드 배치를 대조한다. + +완료 조건: 정확한 engine version/tag/배포 artifact·license와 dependency 범위가 기록됨; React 없는 HTML 구동 가능성, Destroy·다중 instance·custom node 가능성 근거 확보; node Theme mapping·미연결 정책·모바일 선택의 승인 필요 범위가 구분됨. 완전 정리가 불가능하면 기능 구현을 확대하지 않고 차단 사항·대안을 보고한다. 이 기술 PoC는 뒤 단계의 전제라 필요한 최소 중간 검증에 해당한다. + +### 2단계 — 공통 데이터 계약·Validation·집계 + +Schema 초안의 필드/enum/null/version/root/leader·복수 소속과 집계 정책을 승인하고 순수 JS/TS 데이터 계층을 구현한다. JSON Mock/외부 JSON Source와 공통 validator를 만든다. API 명세 없이 Mock API Source로 교체 계약을 확인한다. + +완료 조건: 원본 ID 모델·동일 Source Interface; 중복 ID/참조/cycle/상태/관계/안전 입력 실패가 controlled issue로 변환됨; 승인된 집계 정책으로 단일 ID·하위 포함·상태/관계사 이동이 결정적 결과를 생성함. 미승인 정책은 code default로 숨기지 않음. UI·framework 의존 없이 사용할 수 있음. + +### 3단계 — Vanilla Core와 콘텐츠 + +승인한 패키지/build 구조에서 engine adapter, DOM 노드 UI, 제목·허브·summary·상태/관계사 영역을 구현한다. mount/update/reload/destroy, Source 경쟁·취소·instance scope·Core CSS·Theme 연결을 구현한다. Overlay/Layout은 PoC와 승인한 디자인만 구현한다. + +완료 조건: Core에 React import 없음; API endpoint/Navigation/로그인 의존 없음; 데이터만 변경해 조직·직원·relation·제목 갱신; 승인 정책의 집계; 요청된 인터랙션·사용자 상태·정리 보장; HTML 단독 배포 예시의 runtime 목표 명확. 임의 추가 검색/Export/메뉴/drag 편집은 없음. + +### 4단계 — React Wrapper와 콘텐츠 Storybook + +Wrapper는 Core 생성·ref·props 전달·Source 변경·언마운트 cleanup만 연결한다. 동일 Core UI와 CSS를 사용하고 Storybook은 콘텐츠 단독으로 구성한다. 기존 Component/Pattern/Theme/대표 Story는 보존한다. 현 [Storybook main](../../.storybook/main.ts)의 glob은 `src/**/*.stories.ts(x)`이며 패키지 내부 Story는 자동 포함되지 않는다. `src/org-chart/*.stories.tsx`는 제안 위치다. + +완료 조건: engine/Validation/집계/노드 UI 중복 없음; 데이터 변경·StrictMode·unmount 시 동일 Core 계약 준수; 모든 Story는 가상 데이터만 사용; Header/Dashboard/신규 페이지 조합 없음. 지원 Props와 stale response 동작을 명시함. + +### 5단계 — 배포·최종 통합 검증 + +npm/ESM/browser bundle·CSS·`.d.ts`와 license·asset·dependency 포함 범위를 실제 build와 consumer에서 확인한다. §3의 최종 검증을 변경 최종 상태 기준으로 한 번 종합 실행한다. 실패를 수정한 경우 영향받은 검사만 다시 수행한다. 같은 조건으로 이미 성공한 검사를 형식적으로 반복하지 않는다. + +완료 조건: HTML과 React의 공통 기능/디자인/데이터/집계/인터랙션 일치; 네 컨테이너 너비의 실행 증거; JSON → Mock API 교체; lifecycle/CSS/접근성·asset 결과; build와 browser 결과 별도 보고; NOT VERIFIED·TBD를 감추지 않음; 운영 main 및 요청 밖 파일 보존. 실제 API 지원 완료와 npm publish는 이 단계에서 자동 주장하거나 수행하지 않는다. + +## 2. Storybook 구성 제안 + +최상위 `OrgChart` 콘텐츠 그룹 후보다. 이름·기본 sidebar 대표 Story와 세부 검증 Story 배치는 승인 후 확정한다. 기존 All Story나 navigation Pattern 대표 Story를 교체하지 않는다. + +- Basic OrgChart: 작은 가상 ID 계층, 기본 노드. +- Leader Hierarchy: 최상위/하위 leader, 공석, 허브 처리. +- Organization / Team / Cell: 부문·실·Div·셀·팀, 정렬·다단계 계층. +- Employee Cards: 이름·직위/직책·이미지 실패/fallback·긴 텍스트. +- Summary Table: 직접/하위 포함·직위/상태 집계, 정책 필요 상태. +- Multiple Affiliations: 같은 집계 정책의 관계사별 숫자와 총원. +- Dispatched / On Leave: 상태 변경, 원소속 표시 정책, 중복 카드와 ID 집계 구별. +- Full OrgChart Content: 8개 콘텐츠 영역과 추가 관계. Header·GNB/LNB·Dashboard 전체 조합 제외. +- JSON Data Update: 조직/직원 이동·추가/삭제·상태/사진·명칭·기준일 갱신. +- Mock API Data Source: 같은 fixture의 지연/실패/응답 경쟁, JSON Source 전환. +- Loading / Empty / Error: schema/참조/cycle/정책 오류·제어된 상태. +- Responsive Container: 390/768/1280/1440px와 부모 크기 변경, 다중 instance. + +Storybook은 React Wrapper의 실제 Core를 검증한다. Vanilla 단독 consumer 검증을 Storybook 성공으로 대체하지 않는다. 지원하지 않는 API·인증·직원 개인정보나 verification Story의 부가 기능을 신규 요구로 복사하지 않는다. + +## 3. 최종 통합 검증 + +[Framework Integration §7](framework-integration.md#7-최소-통합-검증-계획)의 10항목을 최종 증거의 공통 기준으로 사용한다. 다음을 관련 항목에 연결한다. + +- 데이터: 모든 Validation 실패 유형·leader/하위 포함/상태/관계사 집계·immutable 원본·같은 입력의 결정적 결과·JSON/Mock API parity. +- HTML/React: 동일 조직 ID/parent/순서/leader/직원 그룹·계산 숫자·노드 UI·Theme·키보드/zoom/확장 상태. Wrapper의 다른 engine 구현 부재와 Core 코드 재사용을 검토. +- lifecycle: data update/reload 경쟁·abort·StrictMode·반복 mount/unmount·destroy 두 번·destroy 후 callback 없음·두 instance의 DOM ID/이벤트/scroll/focus 격리. 이벤트/Observer/DOM 수와 제거 후 동작 증거를 남김. +- lifecycle 재생성: 이전 chart 참조를 잃기 전 handler/Observer 정리, 반복 init/update/reload의 누적 이벤트 검사. facade import와 engine의 DOM global 부작용을 구별하고 지연 로딩 entry를 확인. +- 컨테이너: 네 너비의 캡처·computed style·document/내부 scrollWidth/clientWidth·scrollHeight/clientHeight, 긴 명칭·ID 한 줄 식별·이미지/폰트 로드·absolute 알림 기준·Overlay 좌표. +- 접근성: 이름/label 연결, Tab/Enter/Space/Escape 등 해당 기능 키보드·focus 복귀, disabled/readOnly, screen reader 상태 설명. 실행하지 않은 범위는 NOT VERIFIED. +- 배포: package tarball imports·browser-ready ESM/classic bundle·별도 CSS/타입/상대 asset·React 미설치 HTML·Theme 미연결 승인 정책·CSP/CORS 범위. typecheck/build/browser를 구분하고 통과한 조건만 PASS. +- Theme 배포·소유권: React peer 없는 Vanilla Theme 공급 경로, Core/engine CSS와 호스트 Theme global 영향 구별, 상대 font URL, Global A/Scoped B 두 instance의 computed·Default/partial·cleanup. Wrapper의 별도 summary/state/node 구현 부재 확인. +- 영향: 최종 Git diff·요구사항·관련 docs/Storybook 정합성·불필요한 변경·운영 main/기존 Component/Pattern/Theme 보존. + +관련 TypeScript·build·Storybook 명령은 미래 승인된 package scripts로 결정한다. 현재 package의 `prebuild/pretypecheck`가 생성물을 만들 수 있어 문서 전용 작업에서 실행하지 않는다. + +Guard는 React consumer target이 실제 존재할 때 [Guard 정책](../guidelines/ai-generation-guard.md)의 적합한 범위·files/result로 1회 적용한다. PARTIAL 콘텐츠 검사가 전체 페이지 계약을 대체하지 않는다. Vanilla Core는 React bootstrap 전제와 충돌할 수 있으므로 기존 [Runtime Portability](../guidelines/runtime-portability.md)를 바탕으로 검사 적용 범위를 별도 합의하고 자동 PASS를 만들지 않는다. Guard 원본 status/staticStatus와 Runtime 증거를 구별하고 STYLE의 STATIC/RUNTIME/DESIGN_INTENT/FINAL을 함께 보고한다. 현재 지침을 이번 문서 작업에서 고치지 않는다. + +## 4. TBD 결정 목록과 리스크 + +- **패키지/배포**: 두 package 최종 분리·경로·exports·type·dependency/peer·build 연결·Core npm 이름·browser namespace/format·engine 고정 버전. vanilla용 bare-import 없는 bundle과 React dependency 격리가 필요하다. +- **생명주기**: upstream 완전 disposal·Observer·document 이벤트 해제, 갱신 시 상태/focus/zoom 보존, 옵션 갱신 방식·대용량 전체 rebuild 성능. +- **데이터**: schemaVersion·enum/null/필수 값·ID 범위·root 수·무소속·leader 소속/겸직·관계 방향/중복·복수 소속 계약·정렬 우선순위·안전 입력 상한. +- **집계**: 파견/휴직 포함·재직 정의·조직장 포함·원소속/파견지·직원 ID 중복·관계사/총원 분모. 승인 없이는 숫자를 업무 기준으로 확정하지 않는다. +- **디자인/모바일**: 캡처 자료 미제공, 허브 실체·곡선 Overlay·카드 anchor·노드/그룹 배치·읽기 가능한 최소 폭/배율·zoom limit·모바일 방식·Theme 미연결 정책. computed 일치와 디자인 승인도 구별한다. +- **API/보안**: 실제 endpoint/auth/권한·캐시/재시도·갱신·버전·이미지 권한·개인정보·외부 입력/URL 정책. 명세 없는 부분은 상상해 구현하지 않는다. +- **지침 정합성**: 기존 React/HM 재사용 원칙과 framework-independent 노드의 예외, 공식 일반 수정 경로와 Feature 문서 경로, Guard runtime 전제. README의 차이를 보존하고 작업별 사용자 지시를 우선한다. + +Vue·Angular·기타 framework Adapter는 TBD 개발 범위가 아니라 **이번 범위 제외**다. 미래 별도 요청이 있어야 개발 대상으로 편입한다. + +## 5. 이번 문서 작업의 검증 기록 + +기준 HEAD: `d18e3c33801e165e17b6d971e2f6198eed483c3c`, 경로 `HMDesign-OrgChart`, 브랜치 `feature/orgchart-package`. 최초/최신 추가 요청의 확정 요구를 README에서 추적한다. + +- PASS: 작업 전 Git root/branch/HEAD/origin/운영 Worktree·원격 main/Feature SHA·깨끗한 Working Tree 확인. +- PASS: 현재 workspace·package export·Storybook glob·CSS entry·선택 Component/Pattern·Token 실제 파일 확인. 이는 읽기 전용 구조 확인이며 신규 API/배포 검증은 아니다. +- PASS: 문서 7개에 콘텐츠 독립성·Core/React Wrapper·같은 계약/집계/노드/Theme/인터랙션·Source 교체·페이지 제외·TBD·개발 게이트를 반영. +- PASS: 기존 `node scripts/check-doc-links.mjs` 실행에서 Markdown 118개·내부 링크 620개·problems 0. 신규 OrgChart 문서의 내부 링크는 53개다. 외부 URL 도달성 전체 검사는 포함하지 않는다. +- PASS: JSON code/inline 블록 3개 구문, JavaScript/HTML inline script 2개 구문, 가상 fixture의 required/type/enum/null·ID 참조·parent cycle·raw 수 2/1/1, Schema와 TypeScript 초안의 필드 일치를 읽기 전용으로 확인했다. Schema에 사용한 일부 keyword의 구조 검사이며 완전 Draft 2020-12 validator나 TypeScript typecheck가 아니다. +- PASS: 최종 확인 시 개발 Worktree는 `docs/org-chart/*.md` 7개만 untracked, 기존 tracked/staged diff 0. 운영 Worktree는 깨끗하고 Local/원격 main·Feature SHA는 시작 HEAD 그대로였다. package/lockfile·기존 UI/Theme/Storybook/AGENTS 수정, 설치·새 Worktree·Stage·Commit·Push 없음. +- NOT VERIFIED: 캡처 시각 대조, engine 고정 tag/npm 배포물 동일성, 실행/빌드/HTML bundle/React Wrapper/Theme computed/접근성·반응형·생명주기·실제 API. 구현 전 문서 작업이라 실행 검사 PASS를 내지 않는다. +- NOT VERIFIED: 완전한 JSON Schema validation과 TypeScript compiler/typecheck. 현재 Worktree의 관련 dependency가 없어 설치하지 않았고 필드/구문 확인으로 지원 완료를 주장하지 않는다. +- 적용 대상 없음: 이번 화면 생성 Guard(typecheck/build 포함). React bootstrap·UI 변경 없이 Markdown만 작성하므로 실행하지 않는다. + +이 단계의 최종 산출물은 사용자 검토 대상 설계 문서다. 다음 기능 개발·설치·Stage·Commit·Push는 시작하지 않고 사용자 검토 및 승인을 기다린다. + +## 6. 병렬 읽기 전용 검토 통합 + +사용자 요청으로 분석 작업자 3개를 동시에 사용했다. 각 작업자는 읽기 전용으로 검토했고 문서 작성·수정은 메인 작업자만 수행했다. 기존 링크/fixture 검사는 작업자마다 중복 실행하지 않았다. + +- **엔진 HTML·React 호환성**: 이전 chart를 잃기 전 update/reload cleanup, 문자열 template + createNode 안전 DOM 우선안, DOM global import 부작용·지연 로딩, 우선 두 패키지 표현 통일을 Integration/Architecture/Framework Integration에 통합했다. +- **HM Theme·Token·UI**: React peer 없는 Vanilla Theme 공급, host Theme와 Core/engine CSS 영향 구별, 상대 font, Scoped mapping/Default/owner, Wrapper의 summary/state/node 중복 금지를 Architecture/Integration/UI Composition/Framework Integration에 통합했다. +- **데이터·Validation·API 확장**: fatal data issue와 집계 unavailable/Source 실패의 경계, immutable 스냅샷·최종 revision 확인, 여러 API/페이지의 완전한 동일 기준 스냅샷 조립, 집계 정책 자체 검사·결정적 정렬 조건을 Data Contract/Architecture/Integration/Framework Integration에 통합했다. + +각 검토는 설계·공식 소스의 정적 근거다. 실제 이벤트 누수·빌드·computed·API 일관성이 검증됐다는 뜻이 아니며 해당 NOT VERIFIED를 유지한다. 중복 문서·구현 코드·별도 리뷰 파일은 만들지 않았다. diff --git a/docs/org-chart/implementation-report-chart-only.md b/docs/org-chart/implementation-report-chart-only.md new file mode 100644 index 0000000..061d0e9 --- /dev/null +++ b/docs/org-chart/implementation-report-chart-only.md @@ -0,0 +1,73 @@ +# HMDesign OrgChart — Chart Only 구현 보고서 (2026-10-13) + +RESULT: **PASS** (Auto/Expanded · React · Vanilla). Fixed Chart Only는 지원하지 않으며 제약으로 구분한다. Commit·Push·Merge는 하지 않았다. + +## 1. 작업 위치 + +- Worktree: `D:\디자인시스템\HMDesign-OrgChart-ChartOnly-Impl`, 브랜치 `feature/orgchart-chart-only-implementation`, 기준 HEAD `0a8daf7ee7ac026952a2e1d3d5f25a922dab07cf`(변경은 미커밋). +- 의존성은 이 Worktree에서 `npm ci --ignore-scripts`(lockfile 그대로)로 설치했다. 버전 변경 없음. + +## 2. UI 소유 위치 (STEP 1) + +| UI | 소유 | Chart Only 처리 | +|---|---|---| +| 제목·기준일(`createHeading`) | Core `expanded-content.ts` | 생성하지 않음 | +| 선택 회사 수·인원 요약 | Core `index.ts` | 생성하지 않음 | +| 확대/축소/100%/Fit 버튼·배율·조작 안내 | Core `expanded-viewport.ts` | toolbar 없이 같은 viewport 컨트롤러 사용 | +| Drag & Drop 상태 문구 | Core `index.ts` | 요소는 유지(aria-live·aria-describedby), 화면에서는 시각적으로 숨김 | +| 회색 패딩·테두리 외곽 | Core CSS `.hm-orgchart-expanded` | chart-only에서 흰색·패딩 0·테두리 0 | +| Storybook 필터바·페이지 제목·설명 | Storybook Preview | 변경 없음(사용하지 않음) | +| React Wrapper | 컨테이너 div와 선택형 Popup Adapter만 | 변경 없음 | + +기존 API만으로는 Vanilla `fitToView()` 호출까지 가능했고, 내장 UI 제거·React 초기 Fit·직접 휠 Zoom은 불가능했다. + +## 3. 추가한 공개 API (Core 옵션, React Props로 그대로 전달) + +- `presentationMode?: 'default' | 'chart-only'` — 기본 `'default'`. Auto 전용. Fixed와 함께 쓰면 `TypeError`. +- `initialView?: 'center' | 'fit'` — chart-only 기본 `'fit'`, 그 외 `'center'`(기존 동작). +- `wheelZoom?: 'modifier' | 'direct'` — chart-only 기본 `'direct'`, 그 외 `'modifier'`(기존 Ctrl/⌘ + 휠). +- 타입 export: `OrgChartPresentationMode`, `OrgChartInitialView`, `OrgChartWheelZoom`. +- React Wrapper: 세 옵션 전달, 객체 옵션(`autoLayout`·`interactions`·`selectedCompanyIds`·`zoom`·`companyTopNodes`·`employeeConnections`·`organizationLinks`·`aggregationPolicy`)을 값으로 비교해 같은 값의 inline 객체 리렌더링이 재마운트·재Fit을 일으키지 않게 했다. 마운트 직후 중복 `setSelectedCompanies` 호출도 사라졌다. +- JSON Contract, `/hm-dialog`, `employeePopupAdapter`, 기본 Entry 의존성, 카드 SVG, 회사 컬러는 변경하지 않았다. + +## 4. 동작 + +- 초기 Fit: 실제 Forest Bounds(조직·연결선 path 포함) 기준, 16px 여백, 최대 100%, 가로·세로 중앙. 측정 전에는 forest를 `visibility: hidden`으로 두어 첫 프레임이 튀지 않는다. 캔버스 크기 0이면 실행하지 않고 ResizeObserver/레이아웃 완료 경로에서 실행한다. +- 재Fit: 최초 렌더링, `update`/`reload`/React `dataSource` 교체, 회사 선택·layout 변경. Zoom·Pan·팝업·그룹 접기·Drag & Drop 전환·단순 리렌더링에서는 재Fit하지 않는다. +- 휠: 캔버스 안에서만 `preventDefault` 후 포인터 기준 Zoom. delta 정규화(line/page)와 이벤트당 ±120 제한. 캔버스 밖은 페이지 스크롤. `interactions.zoom=false`이면 휠을 가로채지 않는다. +- Fit 배율이 `zoom.min`보다 작으면 휠 축소는 Fit 배율에서 멈춘다(`initialView: 'fit'`일 때만). + +## 5. 브라우저 검증 (실제 Chrome 155, Playwright 1.64) + +[`examples/org-chart-chart-only/verify.mjs`](../../examples/org-chart-chart-only/verify.mjs) **46/46 PASS, 콘솔 오류 0**. 증거: `test-results/org-chart-chart-only/`(ignored). + +| 데이터 (1440×900) | 배율 | 중앙 오차 X/Y | 상하좌우 여백 | +|---|---|---|---| +| 20명·3개 회사 | 1.0000 | 0 / 0 px | 174 / 29.75 px | +| 100명·5개 회사 | 0.5135 | 0 / 0 px | 16 / 89.66 px | +| 500명·삼안 6본부 17팀 | 0.3254 | 0 / 0 px | 231.58 / 16 px | +| 100명 LR / Hybrid | 0.1819 / 0.5309 | 0 / 0 px | — | +| 모바일 390×844 (20/100/500) | 0.3278 / 0.1306 / 0.1193 | 0 / 0 px | 페이지 가로 넘침 없음 | + +- React와 Vanilla의 배율·여백·휠·Pan 수치가 동일하다. +- 일반 휠 위/아래 Zoom(0.3254→0.3974→0.3254), 포인터 아래 지점 화면 이동 0.14px, 최대 3·최소 0.05, Zoom 후 Pan 정확히 (120, −80)px, 600ms 뒤 재Fit 없음, Ctrl+휠도 캔버스 안에서만 동작(페이지 확대 없음). +- 임베드 페이지: 캔버스 밖 휠은 페이지 스크롤(180px), 안쪽 휠은 스크롤 없이 Zoom. +- 직원 팝업(Core·`/hm-dialog` HM Dialog) 열기/닫기, 그룹 접기·펼치기(500→472→500명) 후 배율·위치 유지. Drag & Drop 기본 OFF, 카드 드래그 후 소속 불변. +- 데이터 전체 교체(500→100명) 후 다시 Fit·중앙(오차 0px). React 단순 리렌더링 후 사용자 뷰 유지. +- 모바일 터치 Pan(CDP touch) 동작. 모바일에서 휠은 검증 대상이 아니다. + +## 6. 기존 화면 보호 + +[`verify-storybook.mjs`](../../examples/org-chart-chart-only/verify-storybook.mjs) **9/9 PASS, 콘솔 오류 0**. 이 Worktree의 Storybook(6044)을 기존 Feature Storybook(6024, 같은 HEAD)과 비교했다. + +- 공개 OrgChart 메뉴 4개 유지(Overview, Department Colors, Full Composition, Expanded OrgChart). +- 4개 페이지 모두 DOM 동일, 스크린샷 픽셀 동일. Full Composition은 한 실행에서 5픽셀 ±1 래스터 오차가 있었고 재실행에서 픽셀 동일이었다. +- Expanded: 제목·기준일·요약·툴바 4버튼·배율·`드래그·방향키 이동 · Ctrl/⌘ + 휠 확대` 안내·Drag & Drop 문구 유지. 일반 휠은 Zoom하지 않고(페이지 스크롤), Ctrl+휠은 Zoom, Fit to View 버튼 동작. +- 그 외: Core 단위 테스트 86/86, Guard 테스트 94/94(기준 HEAD와 동일), 루트 `tsc -b`, Core·React typecheck/build, Theme defaults check 통과. +- 기존 `examples/org-chart-react/verify-boundaries.mjs`: 8 PASS / 1 FAIL이며 기준 HEAD 임시 Worktree에서도 같은 항목이 같은 결과로 실패한다(Fixed 390px 내부 가로 스크롤 검사, 기존 실패). + +## 7. 제약 · NOT VERIFIED + +- **Fixed Chart Only 미지원**: Workdesk 구성(제목·요약·아트워크·비례 축소)이 서로 묶여 있어 일부만 제거하면 승인 디자인이 바뀐다. 옵션을 함께 쓰면 오류로 알린다. Fixed에는 Auto의 휠 Zoom/Pan이 없다. +- NOT VERIFIED: Safari/Firefox, macOS 트랙패드 핀치, 실제 모바일 기기, 스크린리더 낭독, 2,100명 전체 회귀(요청대로 반복하지 않음), `examples/org-chart-react/verify.mjs`(storybook-static 빌드 필요)·기존 P9.x 개별 스크립트. +- React imperative ref는 추가하지 않았다(초기 Fit을 옵션으로 해결). diff --git a/docs/org-chart/implementation-report-p1.md b/docs/org-chart/implementation-report-p1.md new file mode 100644 index 0000000..26ca4ae --- /dev/null +++ b/docs/org-chart/implementation-report-p1.md @@ -0,0 +1,95 @@ +# HMDesign OrgChart P1 결과 — 2026-10-12 + +RESULT: **PARTIAL**. 최종 요청의 독립 디자인·개별 공개 API·5개 Story 메뉴·HTML/React 기능 검증은 완료했다. 원본 Guard의 정적 토큰 추적 FAIL, 실측 흰색 부서명 컬러의 일부 명도 대비 부족, Workdesk 픽셀 일치 미검증은 아래에 보존한다. 전체 디자인 승인 또는 배포 승인으로 해석하지 않는다. + +## 개발 환경과 보존 + +- 실제 수정 경로: `D:\디자인시스템\HMDesign-OrgChart`. +- 기존 브랜치: `feature/orgchart-package`; 새 브랜치를 생성하지 않았다. +- 작업 전/후 HEAD: `d18e3c33801e165e17b6d971e2f6198eed483c3c`. Local `main` 및 기존 `origin/main` ref도 동일 SHA다. Git 쓰기 작업 없이 확인했다. 원격 HEAD는 이번 P1에서 새로 조회하지 않았다. +- 공통 UI/Theme/Pattern, 기존 Wrapper 구현, 원본 JSON v1/v2, 기존 v0.1 구현·React 검증 보고서를 보존했다. 시작 시 기존 package.json/lock 수정 및 untracked 패키지 작업을 유지했다. +- 시작 snapshot 671개 파일과 비교한 요청 범위 밖 변경은 0건이다(`test-results/org-chart-p1/preservation.json`). 기존 main checkout은 접근 제한으로 직접 Working Tree 검증하지 못했다. 그 경로에 수정 명령을 실행하지 않았다. +- 별도 브랜치·Stage·Commit·Push·Merge를 수행하지 않았다. 작업은 미커밋 상태다. `git diff --check` 통과. + +## 병렬 작업과 통합 + +사용자의 최초 P1 병렬 구현 요청에 따라 실제 두 Track을 병렬 실행했다. Track A는 공통 EmployeeCard, 조직별 presentation preset, 정식 로고 mapping, 독립 디자인 변수에 집중했다. Track B는 순수 summary selector, Position/Affiliation/Status DOM 모듈, 독립 Story 및 변경 범위 browser verifier를 담당했다. Main은 공통 controller/Tree/Content 통합, 공개 factory, 실제 Workdesk/Figma 근거 확인, 배포 예제와 canonical Story, 빌드·최종 증거 연결을 담당했다. 책임 파일을 분리했고 공유 파일 충돌은 없었다. 정상 테스트를 두 Track에서 반복하지 않았다. + +## 디자인·콘텐츠 구현 + +1. `OrgChartContent`가 독립 Tree와 Summary를 조합한다. 리더·허브는 조직 트리의 가짜 parent를 만들지 않는다. 기존 organization ID/parentId를 유지한다. +2. 최상위 리더, 조직장, 일반 직원은 같은 Core `createEmployeeCard`를 사용한다. 직위·직책·소속 로고·근무 상태를 표시하며 null/실패 프로필 이미지는 이니셜로 대체한다. React 카드 UI는 추가하지 않았다. +3. 중앙 협업·소통 허브는 `presentation.hubText`로 표시한다. 추가 관계선은 구현하지 않았다. +4. 사업관리·경영기획·인재성장·통합시스템 Div·디자인기획·솔루션영업과 확인된 셀/팀 명칭을 fixture에 사용한다. 6개 branch는 organization ID preset에 연결하고 하위 셀/팀이 variant를 상속한다. fixture membership/leader/직위는 가상 설정이다. +5. 우측 PositionSummary는 organization × position 교차표, 직접/하위 포함 인원, 상태별 Raw Count를 같은 summary/selector에서 계산한다. 좁은 container에서는 표와 트리 각각 내부 스크롤을 제공한다. +6. 하단 AffiliationSummary는 7개 관계사 명칭과 실제 로고, 가상 Raw Count를 표시한다. 0명 관계사 3곳도 표시한다. +7. 파견·휴직은 독립 StatusSummary 안에서 원본 직원 참조로 표시한다. 전체 인원에 다시 더하지 않는다. 각 그룹 flag를 독립 적용한다. +8. 카드·Typography·Spacing·Border·Radius·Gradient는 OrgChart 내부 CSS와 presentation preset으로 관리한다. 공통 HM Token을 필수로 사용하지 않고 Theme가 없는 호스트에서도 표시된다. 전용 public 변수만 외부 override에 사용한다. CSS는 Shadow DOM/내부 shell에 격리된다. +9. Full Composition에 Header·GNB·LNB·페이지 Routing을 추가하지 않았다. Storybook 외부 검증 controls만 기존 공개 HM Button을 사용하며 Core 자체의 Theme 의존성과 구분한다. +10. 같은 fake JSON, validation/normalize/summary/lifecycle, Core DOM/CSS를 HTML과 React가 공유한다. 기존 React Wrapper 구현과 Props API는 변경하지 않았다. + +## 컬러와 로고 근거 + +Workdesk `https://workdesk.hmac.kr/`, 표시 버전 `v1.2610.9c5e`, 2026-10-12 관찰. 6개 visible 조직 heading 부모의 computed background-image와 heading white text를 읽었다. + +- 사업관리 `.h33`: `#194d82 → #1d395c`. +- 경영기획 `.h33.org-team-link`: `#2164a8 → #254975`. +- 인재성장 `.h38.org-team-link`: `#0f6ace → #044a94`. +- 통합시스템 Div `.h310.org-team-link`: `#8c67b6 → #5d3494`. +- 디자인기획 `.h312.org-team-link`: `#09a7a2 → #00847f`. +- 솔루션영업 `.h36.org-team-link`: `#5a963c → #3f7629`. + +부서명 텍스트 실측값은 모두 `#ffffff`. 이는 헤더 측정이며 직원 카드 전체 원본 스타일을 검증한 값이 아니다. 카드 배경, 기본 Typography/Spacing/Radius 및 헤더 시작색을 재사용한 카드 테두리는 DESIGN_ADAPTATION으로 명시했다. 임의 기본값을 공식 Workdesk 값으로 확정하지 않았다. 기존 공통 Theme 값에 억지로 맞추지 않았다. + +정식 로고 7종은 접근 가능한 Figma 파일 `Plr6RRLi5XnLIn60x9aG1s`의 footer `22:179`, logo nodes `22:181/187/193/199/205/211/217`에서 받은 PNG다. `(주)한맥기술`, `(주)삼안`, `(주)장헌`, `장헌산업`, `PTC`, `한라산업개발`, `바론컨설턴트`. affiliation ID로 Core map에 연결하며 같은 자산을 카드·하단 summary·Logo에서 사용한다. 원본 PNG/크기/SHA256/출처는 `packages/org-chart/src/assets/affiliations/README.md`에 기록한다. 임시 Figma URL은 배포에 필요 없고 bundle에 inline된다. 실제 프로필 사진·개인정보는 가져오지 않았다. 로고 미확보 항목은 이 7종 범위에서 없다. 미매핑 ID/파일 오류는 명칭 텍스트 fallback으로 처리한다. + +## 공개 API·파일 변경 + +패키지 root에 `createOrgChartTree`, `createPositionSummary`, `createAffiliationSummary`, `createStatusSummary`, 타입 `OrgChartSummaryComponentOptions`를 additive export했다. 기존 API와 `0.1-draft` schema는 유지한다. Summary는 `stylesheetUrl` 하나만 필요하며 Tree는 기존 engine/core URL pair를 사용한다. 모든 factory는 기존 OrgChartHandle을 반환한다. 다른 업무 페이지의 container에 각각 mount/update/reload/destroy할 수 있으며 전체 OrgChart나 Header를 요구하지 않는다. [사용법과 override 예시](standalone-components-p1.md). + +- 주요 Core 변경: `core/index.ts`, `render.ts`, `employee-card.ts`, `affiliation-mark.ts`, `style-presets.ts`, `src/index.ts`, `build.mjs`, `vendor.d.ts`. +- 독립 모듈: `org-chart-content.ts`, `org-chart-tree.ts`, `position-summary.ts`, `affiliation-summary.ts`, `status-summary.ts`, `logo-legend.ts`, `department-colors.ts`, `components.ts`. +- 데이터: `content-selectors.ts`와 신규 7개 selector 테스트. 기존 validator/schema/source/aggregation 계약을 바꾸지 않았다. +- 스타일: `design-tokens.css`, `employee-card.css`, `summary-sections.css`, `org-chart-content.css`, `org-chart.css`. 빌드가 모든 내부 CSS를 `dist/style.css` 하나로 inline한다. +- 예제: 가상 `data-p1.json`과 생성기, Theme 없는 `p1.html/p1-classic.html/p1-app.js/p1.css`, React `p1.html/p1.tsx/p1.css`; 기존 examples entry 유지. React Vite input에 P1 entry만 추가했다. +- Storybook: Core component preview, card/internal summary Story, canonical P1 Story. 기존 8개 Story에는 `internal` tag만 추가하여 메뉴에서 숨기고 테스트/내용은 보존했다. 신규 card/status verification Story도 숨겼다. +- 사용자용 OrgChart 메뉴는 **Logo / Department Colors / Position Summary / Affiliation Summary / Full Composition** 5개뿐이다. Department Colors는 실제 swatch/HEX/CSS variable, 카드 배경·직원 텍스트·부서명 텍스트·border/Gradient, OrgChart 전용 여부와 실측/기본값 근거를 표시한다. +- 추가 npm dependency: 없음. 현재 React 19/Storybook 10.6.1/engine 6.0.0을 유지한다. 별도 Summary npm 패키지를 만들지 않았다. + +## 검증 결과와 범위 + +- Core typecheck: 초기 점검 후 Department Colors의 literal union Set 타입 오류를 수정했다. 최종 Core 빌드에 포함된 TS declaration compiler 통과. +- Story/source graph typecheck: `npm.cmd exec -- tsc -p tsconfig.app.json --noEmit` 최종 PASS. +- Core ESM/IIFE/declaration/CSS build PASS. 공개 root import는 DOM 없는 Node에서 정상이고 기존 createOrgChart와 4개 신규 factory 모두 function으로 확인했다. +- 기존 Wrapper typecheck/build PASS. 그 이후 Wrapper 구현은 변경하지 않았다. +- HTML prepare, 별도 React consumer production build, Storybook production build PASS. 500kB chunk와 Storybook plugin timing 경고는 남았으며 오류는 아니다. +- 새 selector 7개 테스트 **7/7 PASS**. 원본 data 테스트/기존 69·79 browser suite를 재실행하지 않았다. +- 최초 신규 browser 검증 **197 checks: 196 PASS / 1 FAIL**. 유일 실패는 개별 meta의 storySort global-only 콘솔 오류였다. 해당 옵션을 제거하고 Storybook만 재빌드했다. +- 오류 및 실제 메뉴 선택 재검증 **4/4 PASS**: 수정된 canonical 5개 iframe 콘솔 오류 0, 실제 manager/sidebar 5개 항목 정확 노출, 내부 검증 그룹 기본 숨김, manager 오류 0. 정상 196개 검사는 반복하지 않았다. 최초 report의 FAIL은 삭제하지 않고 후속 해소 증거로 연결한다. +- Browser Chrome `155.0.8059.39`. HTML/React 가상41명, 18조직, 상태·소속·이동 JSON, 원본 집계/정책 미확정 메시지, group별 표시, keyboard collapse/expand/focus, 긴 이름/직위/직책, null/broken profile, 실로고 로딩·텍스트 fallback, destroy 검증 PASS. +- `390/768/1280/1440px`: HTML/React/Full Composition 문서 overflow·글자 bounds·Navigation 없음 PASS. 트리/교차표 내부 overflow는 의도한 스크롤이다. 전체 6개 branch가 작은 폭에서 한 화면에 모두 보인다는 의미는 아니다. +- Theme 미연결 HTML/React 실제 CSS PASS; host 공통 HM 변수 override에 영향 없음, public OrgChart override 적용 및 다른 instance/page에 영향 없음 PASS. 6개 조직별 Shadow DOM Gradient와 Department Colors legend/실제 카드색 일치 PASS. +- 독립 Summary 3종 및 Tree의 공개 dist API mount/update/destroy PASS. Summary에 engine stylesheet 없고 Tree root leader가 누락되지 않음 확인. +- 로컬 첫 브라우저 접근은 sandbox `ERR_NETWORK_ACCESS_DENIED`로 검사 전 중단됐다. 승인된 로컬 서버 환경에서 신규 검증을 실행했으며 운영 사이트를 수정하지 않았다. + +증거 파일: `test-results/org-chart-p1/browser.json`, `storybook-console-recheck.json`, `preservation.json`, `contrast.json`, `storybook-build.log`; HTML/React/Full Composition 각 4개 폭 PNG와 `storybook-sidebar.png`. 원본 v0.1/React 보고서·검증 artifacts는 유지한다. + +## Guard·명도 대비·남은 한계 + +Storybook PARTIAL Guard 원본: **status/staticStatus FAIL**, TOKEN-001 3개(`resolved-bg-surface`, `current-border`, `current-gradient`) 및 TOKEN-002 동적 변수 미검증. 최신 menu 파일 재검사도 같은 static FAIL을 보존한다. Guard는 `?url`로 가져온 private CSS를 일반 consumer CSS 적용으로 간주하지 않아 Shadow DOM 주입과 `style.setProperty` 선언을 static graph에서 연결하지 못한다. `resolved-bg-surface`는 `design-tokens.css`, current 변수는 `applyOrganizationStyle`에서 선언하며 실제 렌더의 6개 swatch/card/Gradient와 override 검증은 PASS다. 공통 Theme/Guard를 변경해 이 결과를 숨기지 않았다. 이 정적 FAIL을 Runtime PASS로 덮거나 전체 Guard PASS로 보고하지 않는다. + +Storybook Style: **STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT NOT VERIFIED / FINAL NOT VERIFIED**(Guard 자체 기준). React consumer Guard: **status NOT VERIFIED / static NOT APPLICABLE**, RENDER-002 실제 bootstrap 브라우저 확인 요구; Style **STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT PASS / FINAL NOT VERIFIED**. 관련 브라우저 증거는 위의 별도 결과다. 원본 Guard JSON을 유지한다. 일반 HM React Component style override를 추가하지 않았다. OrgChart 독립 디자인 의도는 사용자의 2026-10-12 최종 지시를 근거로 적용했다. + +흰색 텍스트와 측정 Gradient stop의 WCAG 대비를 계산했다. 일반 텍스트 4.5:1 기준: 통합시스템 시작색 4.44:1, 디자인기획 시작색 2.97:1, 솔루션영업 시작색 3.58:1은 미달한다. 다른 stop은 4.5:1 이상이다. 디자인기획 시작색은 큰 텍스트 3:1 기준도 약간 미달한다. 실측 디자인을 임의로 공식 보정하지 않고 원래 값을 유지했으며 접근성 개선 시 OrgChart 전용 department-color/gradient를 교체할 수 있다. 대비 검증을 전체 AA PASS로 표시하지 않는다. 기본 직원 foreground/흰 배경은 충분한 대비를 가진다. + +Workdesk 최신 화면의 큰 배치와 실제 heading 컬러를 반영했으나 픽셀 일치, 카드 모든 spacing/radius/typography의 Figma 상세 정밀 측정은 NOT VERIFIED다. 실제 조직 parent/leader/membership 및 운영 인원은 Mock의 검증 대상이 아니다. Screen reader speech, 물리 touch, SSR/hydration, heap/GC 완전 회복은 NOT VERIFIED다. 원본이 비활성화한 pan/zoom/drag/export는 추가하지 않았다. 복잡한 협업 관계선·직원 상세·정책 승인 및 실제 HR/API 전환은 P2 범위다. + +## 시간 기록 (KST) + +- Started At: 2026-10-12 10:49:28 KST +- Completed At: 2026-10-12 11:32:48 KST +- Actual Duration: 0시간 43분 20초 +- Estimated Duration: 최초 2–3시간 +- Variance: 예상보다 76분 40초–136분 40초 단축 + +Commit·Push·Merge: **진행하지 않음**. diff --git a/docs/org-chart/implementation-report-p10.md b/docs/org-chart/implementation-report-p10.md new file mode 100644 index 0000000..447596f --- /dev/null +++ b/docs/org-chart/implementation-report-p10.md @@ -0,0 +1,196 @@ +# HMDesign OrgChart P10 — main 비교 및 최종 품질 검증 + +RESULT: **PARTIAL — 확인한 기능은 PASS, 신규 제품 회귀 0. 기존 Guard FAIL·접근성 잔여·디자인 승인 및 배포 미검증은 남아 있다.** + +이번 작업은 비교·검증·보고만 수행했다. 기존 구현과 승인 카드/회사색을 수정하지 않았다. main 변경 적용, Stage·Commit·Push·PR은 진행하지 않았다. 정식 main 통합을 무조건 PASS로 승인하는 보고서는 아니다. + +## 1. 기준과 증거 재사용 + +- 작업 폴더: `D:\디자인시스템\HMDesign-OrgChart` +- 브랜치: `feature/orgchart-package` +- Local HEAD 및 live remote Feature: `7614507583927a25a270963a74ff044be0557b9f` +- origin: `https://gitea.hmac.kr/b25027/HMDesign.git` +- Fetch 후 origin/main: `c45d63a03b84eaac43f01bd400a16488a80e4229` +- 공통 조상: `d18e3c33801e165e17b6d971e2f6198eed483c3c` +- 시작 시 같은 폴더의 P9.5 세션은 idle였으며, P10 동안 기존 authored 파일 변경은 없었다. 다른 세션에 메시지·중단 요청을 보내지 않았다. +- 기존 main Worktree와 AI Playground는 그대로 보존했다. Playground 페이지·JSON·변환 도구·실험 캡처를 이관하지 않았다. + +AGENTS.md, CLAUDE.md, AI 생성 정책, P9.4 및 P9.5 중앙 배치/Full Composition/X 버튼/소비 화면 팝업 검증 문서를 확인했다. 최신 P9.5 popup-parity 시작 snapshot **839개 기존 파일의 SHA-256이 현재와 모두 동일**하며, Core와 Vanilla 예제의 `index.js`, `style.css`도 마지막 검증 artifact와 동일하다. 따라서 기존 정상 suite와 패키지·Storybook 빌드를 반복하지 않았다. P9.4 이후 중앙/Fit 변경은 P9.5 geometry 증거, 팝업 변경은 최종 popup-parity 증거로 연결했다. + +근거: [증거 유효성](../../test-results/org-chart-p10/evidence-validity.json), [P9.5 소비 화면 검증](verification-p9-5-popup-parity.md). 보고서들의 PASS 수는 서로 합산하지 않는다. + +## 2. origin/main 양방향 비교 — PASS + +`git fetch origin main`을 실행했다. main에만 3개 커밋, Feature에만 7개 커밋이 있다. 공통 조상 이후 main 변경은 **6개 파일**, Feature 변경은 **192개 파일**이다. 양쪽 커밋의 공통 변경 파일 **0**, main 변경과 현재 미커밋 파일의 겹침 **0**이다. 이는 파일 변경 집합 비교이며 실제 통합 실행의 성공 증거는 아니다. + +main 전용 커밋: + +- `4d597a16506809a97086f7e65cfeb7fbfc486561`: Docker/nginx 정적 Storybook과 Gitea 배포 설정 추가. +- `9118c2c602791f9b90417af14f029ba9069ac265`: 실행 커밋의 immutable source archive를 SSH로 전달하고 별도 release directory에서 빌드하도록 보완. +- `c45d63a03b84eaac43f01bd400a16488a80e4229`: 위 변경의 main merge commit. 별도 UI 변경은 없다. + +Feature 전용 7개 커밋의 전체 SHA·목적과 양쪽 파일 목록은 [main-comparison.json](../../test-results/org-chart-p10/main-comparison.json)에 기록했다. HEAD끼리의 큰 diff를 main이 OrgChart를 삭제한 변경으로 해석하지 않았다. + +### 분류 및 적용 권고 + +**A. 반영 필요: 0건.** main에서 UI 컴포넌트, Dialog/Switch/Checkbox, 토큰/Typography/공통 CSS, Storybook 소스 설정, exports, React/TypeScript/build scripts, package manifest/lockfile 또는 OrgChart 인터페이스가 변경되지 않았다. 현재 OrgChart 동작을 복구하기 위한 필수 반영은 없다. + +**B. 반영 권장: 6개 파일.** 아래는 동일한 배포 변경 묶음이며, 승인 후 함께 통합하는 편이 낫다. + +1. `Dockerfile` — `4d597a16506809a97086f7e65cfeb7fbfc486561`. Node 24 Alpine에서 npm ci·앱·Storybook build 후 nginx 1.29 Alpine으로 제공. 현재 브라우저 동작 영향 없음, 배포 빌드에는 새 OrgChart workspace 전체가 포함돼야 한다. 승인 후 main의 최종 파일을 통합. 파일 충돌 위험 낮음; Linux/컨테이너 빌드 호환성은 NOT VERIFIED. 파일 적용 약 2~3분. +2. `compose.yaml` — 위 커밋과 `9118c2c602791f9b90417af14f029ba9069ac265`. `name: hmdesign`, 기본 6024:80 포트, restart 정책. 현재 로컬 6064 미리보기와 독립적이다. Dockerfile과 같이 통합. 파일 충돌 위험 낮음; 실제 서버 포트·Compose 상태 확인 필요. 약 1~2분. +3. `.gitea/workflows/deploy-storybook.yml` — 위 두 커밋. main push/수동 실행 시 SHA archive 전송·release directory Docker build·서비스 교체. Feature commit만으로 배포되지 않으나 추후 main push의 외부 영향이 생긴다. **초기 커밋만 cherry-pick하지 말고 archive 보완까지 포함**한다. 파일 충돌 위험 낮음; Actions 변수/Secret/SSH/서버 권한/실제 배포는 NOT VERIFIED. 파일 검토·적용 약 3~5분, 배포 검증 시간 별도. +4. `.dockerignore` — `4d597a16506809a97086f7e65cfeb7fbfc486561`. Git·node_modules·dist·검증 생성물·로컬 작업본·env 제외. OrgChart authored 소스는 포함되고 dist는 컨테이너 안에서 생성된다. Dockerfile과 같이 통합. 파일 충돌 위험 낮음. 약 1분. +5. `.gitignore` — `9118c2c602791f9b90417af14f029ba9069ac265`. `.storybook-deploy/` 제외만 추가. 현재 authored 작업과 겹침 없다. 기존 ignore 규칙을 유지하며 이 한 줄 또는 최종 main patch를 반영. 충돌 위험 낮음. 약 1분. +6. `README.md` — 위 두 커밋. Docker/PowerShell 실행·archive 기반 Gitea 배포 안내 추가. OrgChart 문서 수정과 파일 겹침 없다. 현재 OrgChart 설명을 보존하여 main의 배포 섹션만 통합. 충돌 위험 낮음. 약 2~3분. + +전체 검토·통합 예상 **10~20분**, 실제 Linux Docker/서버 배포 검증 별도. 위 시간은 예상이며 실행하지 않았다. + +**C. 반영 불필요: 별도 변경 0건.** 모든 신규 파일은 같은 배포 목적이다. 현재 로컬 OrgChart 기능 확인을 위해 이 묶음을 적용할 필요는 없다. + +**D. 현재 미커밋/승인 디자인을 덮어쓰는 파일 충돌: 0건.** Docker/Actions를 적용하거나 실제 merge하여 검증하지 않았으므로 통합·배포 위험이 없는 것으로 단정하지 않는다. main 변경을 실제 반영하려면 별도 승인이 필요하다. + +## 3. P9.5 최종 완료 여부 — 기능 PASS / 디자인 승인 NOT VERIFIED + +**대표자 화살촉:** 마지막 `arrowhead-touch/after.json` 25/25 PASS를 재사용했다. 원본 상향 Chevron 모양, 카드 하단 중앙, 카드와 간격 약 0px(최대 0.0001 디자인px), 확대/축소/세로 이동 보존. 대표자 화살촉 1개는 협업 화살표 5개와 별개다. 이전 8.7px 간격 기록은 당시 결과로 보존하며 최종값으로 사용하지 않는다. + +**협업 화살표:** PNG 원본 4종을 사용하는 5개 SVG image를 유지한다. Full의 69/69 배치 검사 및 화살표 픽셀/원본 hash 검증을 재사용했고 현재 Full 캡처에서도 5개가 표시된다. 새 화살표나 카드/연결선 디자인 변경 없음. + +**파견·휴직:** 기존보다 12 디자인px 위, bottom anchor 82. 관계사 바와 기준 간격 14.609375 디자인px. 축소 화면의 실제 간격은 배율에 따라 줄어든다. 제목·직원 카드 스타일 변경 없음. + +**Full 적응형 배치:** [최종 Full 보고서](implementation-report-p9-5-full-composition.md)의 1920×1080, 1920×1440, 1440×900, 1280×1024, 390×844 측정을 재사용했다. 상단/하단은 콘텐츠 내부 앵커이고 중앙은 확보된 여유 높이에 배치한다. 동일 mount에서 높이 +360px이면 하단 +360px, 중앙 +180px, 상단 유지. 카드 실제 교차·표시 잘림·실사용 스크롤 0, 가로 중심 최대 오차 약 0.000061px. 높이가 부족한 1440×900은 기존 비례 축소/상대 배치를 유지하므로 세로 중심 오차 −24.756px를 숨기지 않는다. transform 전 raw scroll 치수와 실제 표시 overflow를 구분했다. + +**Core X 버튼:** [X 버튼 보고서](implementation-report-p9-5-core-dialog-close.md) 52 PASS와 [실제 소비 경로](verification-p9-5-popup-parity.md) 118 PASS를 재사용했다. 버튼 28×28, 아이콘 16×16, stroke 2, padding 0, CSS top/right 8px, HM 색/Hover/Focus 일치. Core 외부 클릭 유지, React HM Dialog 외부 클릭 닫힘 유지. 상세 버튼 검사는 반복하지 않았다. + +## 4. Fixed / Expanded / Storybook — PASS (확인 범위) + +- **Fixed:** 현재 React Full Composition 로딩·화면 확인 PASS. P9.5 Full 배치·화살촉·Core 팝업 근거를 재사용. 원본 SVG·회사색·카드 크기·집계 유지. Fixed는 기존 `createOrgChartContent` 경로를 사용한다. +- **Expanded:** P9.4 회사별 20/100/300명, 회사 dropdown·scope UI 제거·기본 Drag & Drop OFF·OFF 이동 차단/독립 Pan·ON 직원/그룹 이동·소속/집계·접기·컬러·TB/LR/Hybrid 검증을 재사용. P9.5 중앙/Fit 124/124, 변환/실제 pointer 62/62 PASS가 최근 viewport 변경을 검증한다. 자동 배치는 현재 100%를 유지하며 명시적 Fit이 전체 bounds에 맞춰 축소한다. 최초 100%에서 모든 회사가 동시에 보인다고 주장하지 않는다. +- **Overview:** 현재 캡처에서 회사 로고·조직/직위 표·관계사 인원 표시 확인. 승인된 요약 스타일은 보존. P9 집계/반응형 결과 재사용. +- **Department Colors:** 현재 캡처에서 대표자와 배지 없음, `홍 길 동` 표시 13개, 6개 조직 컬러·146×68 그룹장/108×52 직원 카드 및 Center preview 외곽을 확인. 장문 설명·불필요 안내 없음. 기존 7/7 이름·자산 보존 결과 재사용. +- **Full Composition:** 현재 1440px 캡처와 5개 지정 화면의 기존 측정 근거를 연결. 중앙/상하단·5개 협업 화살표·붙은 대표자 화살촉·하단 현황·Core 팝업 정상. +- **Expanded OrgChart:** 현재 7회사×20명=140명, 회사 dropdown, 직원 수20/100/300, Drag & Drop OFF, HM Dialog 선택·TB/LR/Hybrid 설정·기존 탐색 도구 확인. 상세 편집/데이터 suite는 반복하지 않음. +- **메뉴:** 실제 manager DOM에서 Components → Patterns → 최상위 OrgChart, 공개 페이지 Overview → Department Colors → Full Composition → Expanded OrgChart 확인. 내부 23개 Story는 기존 tags 기본 제외 정책을 유지하며 직접 URL/정적 index에는 존재할 수 있다. +- **공통 Container:** 네 페이지 `.sb-page`, `.sb-page-inner`의 computed background/border/radius 동일. [frame.json](../../test-results/org-chart-p10/frame.json). 공통 페이지 프레임과 조직도 내부 preview를 구분한다. Fixed Workdesk의 기존 `#F5F8FB` 면과 Department/Expanded Center의 `rgba(217,217,217,.48)`·border·8px radius는 승인된 서로 다른 specimen 디자인이며 일괄 변경하지 않았다. Department/Expanded의 Center surface 동등성은 P9.4 7/7 증거를 재사용. + +## 5. Core / React / Vanilla / 데이터 — PASS (확인 범위) + +Core manifest의 직접 의존성은 dabeng `orgchart@6.0.0`이며 Core source에 React/HM UI import가 없다. React Wrapper는 공개 Core `createOrgChart`를 소비하고 HM Dialog는 adapter의 lazy module이다. Core에 새 React 의존성을 추가하지 않았다. 전체 transitive package graph 인증은 NOT VERIFIED다. + +Vanilla 실제 3개 예제의 최신 팝업/실행 경로는 P9.5 118 PASS로 확인돼 있다. 현재 Core JS/CSS와 Vanilla dist JS/CSS의 SHA도 동일하므로 빌드/상세 버튼 검사를 재실행하지 않았다. + +이번 P10에서 **독립 production React 예제**를 기존 `npm.cmd run build:org-chart-react-example`로 빌드했다. public package entry·StrictMode의 index/Full readOnly를 1440×900·390×900에서 실행하여 렌더링·lifecycle 오류 없음·Core popup open·ESC close·카드 focus 복귀 **12 PASS**를 추가 확보했다. Storybook의 HM Dialog 및 Core 선택 경로는 기존 118 PASS로 연결했다. 예제에 신규 화면/기능은 추가하지 않았다. + +데이터 1회사×20=20, 2회사×100=200, 7회사×300=2,100과 회사 Root 집계·고유 Employee ID는 P9.4 78/78 증거를 재사용했다. 이동 후 회사색·소속·집계는 P9.3/P9.4 Core/React 이동과 P9.5 실제 pointer 결과를 재사용했다. P7~P9.4 성능 suite는 반복하지 않았다. 2,100명 mount/Fit 관측은 이전 P7 기록이며 P9.5에서 전체 Fit/중앙 정렬도 확인했다. 최신 대용량 전체 interaction latency, 장시간 반복·누수·대용량 Drag 성능은 NOT VERIFIED다. + +## 6. AI 생성 정책 — 문서 PASS / 범용 생성 실행 NOT VERIFIED + +현재 `AGENTS.md`와 `CLAUDE.md`가 실제 `docs/org-chart/ai-page-generation-rules.md`를 참조하며 관련 문서 링크가 존재한다. + +10개 문서 조건 모두 확인했다: + +1. Codex의 AGENTS 참조. +2. Claude의 CLAUDE 참조. +3. Fixed/Auto 동일 최소 화면 생성 원칙. +4. 회사별 서로 다른 계층·분기·Depth 지원 요구. +5. 특정 회사/조직명·Depth·팀 수·인원 템플릿을 기본값으로 고정하지 않는 규칙. +6. 기존 Core/React public API·Validator 재사용 및 지원 한계 보고. +7. 별도 요청이 없으면 조직도·기존 탐색 기능만 표시. +8. 설명/안내/테스트/JSON/AI 입력/추가 통계/Navigation 기본 제외. +9. JSON/CSV/Excel 원본·필드 대응·ID·관계·인원 보존. +10. 기존 Full/Expanded/P9.5/P-AI1 비소급·변경 금지. + +경로·참조·정책 내용 PASS다. 이 정책이 임의의 모든 구조를 현재 Core가 렌더링할 수 있음을 뜻하지 않는다. P10에서는 데이터 생성/CSV·XLSX 변환/새 AI 페이지 실험을 실행하지 않았으므로 **실제 범용 생성 테스트 NOT VERIFIED**. Playground 산출물을 이관하지 않았다. + +## 7. Build / 품질 / Guard + +**PASS — 새로 실행:** 전체 root `node node_modules/typescript/bin/tsc -b`, 독립 React example production build, `git diff --check`, 현재 compiled Storybook 4개 페이지/manager 로딩, 독립 React 12개 검사, 공통 frame 비교. [root-typecheck.log](../../test-results/org-chart-p10/root-typecheck.log), [react-example-build.log](../../test-results/org-chart-p10/react-example-build.log). + +**PASS — 유효한 기존 결과 재사용:** Core 최신 Typecheck/Build·Storybook 최종 Build(P9.5 Core X), React 패키지 Typecheck/Build(P9.4 완료 기록; 그 뒤 Wrapper 소스 변경 없음), Vanilla prepare·최신 dist 동등성(P9.5 popup-parity). 로그와 실행 보고서를 확인했다. Core/Storybook 정상 build를 이유 없이 재실행하지 않았다. 독립 React build의 기존 500kB chunk 경고는 남으며 오류는 아니다. + +현재 브라우저 확인 결과는 [browser-final.json](../../test-results/org-chart-p10/browser-final.json)의 관련 **19 PASS / FAIL0**, 신규 console/pageerror0이다. 공통 frame 1 PASS는 별도 실행으로 유지한다. 상세 기존 suite와 합산하지 않는다. Chromium `153.0.8010.12`, Windows headless 한 환경이다. + +### Guard: 기존 FAIL / 신규 회귀 / 미검증 구분 + +누적 Story/직접 영향 Core·CSS 15개 경로의 STORYBOOK/PARTIAL Guard를 1회 실행했다. [원본 guard.json](../../test-results/org-chart-p10/guard.json), [비교 guard-comparison.json](../../test-results/org-chart-p10/guard-comparison.json). + +- 원본 `status FAIL / staticStatus FAIL / runtimeStatus NOT VERIFIED`. +- **기존 FAIL10:** P9.4와 code/file/line/message가 모두 동일한 TOKEN-00110건. department-colors 5, expanded-content 2, style-presets 3. 사유는 connected consumer/public Theme CSS에서 런타임 토큰 선언을 입증하지 못하는 진단이다. 원본 FAIL을 통과로 덮어쓰지 않았다. +- **신규 FAIL0:** P10 구현 변경이 없고 이번 검사에도 새 FAIL 없음. 이는 선택 scope에 대한 결론이며 repository 전체 검사 PASS를 뜻하지 않는다. +- **NOT VERIFIED19:** SCOPE-0018(선택된 package 구현/CSS 및 `?url` Department CSS가 proven graph 밖), 동적 TOKEN-00210, 기존 Radix available-height TOKEN-0011. 이전 NOT VERIFIED 수와의 차이는 누적 선택 범위 차이이며 신규 기능 실패 수가 아니다. +- `styleEvidence`: **STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT NOT VERIFIED / FINAL NOT VERIFIED**. 결합 `styleVerification`은 생성하지 않았으며 NOT VERIFIED. 제한된 computed 비교·기능 PASS를 전체 CSS 디자인 승인으로 확대하지 않는다. +- **사용자 승인된 디자인 예외:** 삼안·장헌산업 흰 그룹 제목/기존 배경색 유지. 일반 텍스트 대비 미해결 진단을 보존하며 접근성 PASS로 처리하지 않는다. 이 대비 이슈는 위 Guard TOKEN FAIL10과 별개다. 배경색·회사색을 변경하지 않았다. + +### 테스트 환경·검사 코드 진단 + +샌드박스 Fetch의 공유 Git 메타데이터 write 거부, 로컬 HTTP `EACCES`, sandbox DNS 차단은 허용된 동일 작업 실행으로 해소했다. 초기 로그를 보존하며 제품 회귀로 분류하지 않는다. CIM process 접근은 제한됐으므로 OS 전체 개발 프로세스를 완전히 확인했다고 주장하지 않는다; 앱 세션 상태, 실제 HTTP·브라우저 응답, authored 해시 보존으로 관측 범위를 확인했다. + +보조 browser probe의 첫 readiness selector가 Overview의 다른 summary 구조를 포함하지 않아 timeout이었다. 수정 후 로딩은 성공했다. 정적 index의 internal `OrgChart/Content`까지 공개 메뉴로 집계한 1FAIL도 있었다. 같은 실행에서 캡처한 manager에는 정확히 공개4개만 표시됐다. [raw browser.json](../../test-results/org-chart-p10/browser.json) 26PASS/1FAIL을 보존하고, 그 manager DOM으로 메뉴 assertion만 정정하며 내부8개 로딩 검사를 보고 범위에서 제외한 **19PASS/0FAIL** 파생 결과를 작성했다. 성공한 브라우저/React 구간을 재실행하지 않았다. 재실행용 probe는 internal/verification tags를 제외하도록 바로잡았다. 제품 소스 수정 없음. + +### 남은 NOT VERIFIED + +최종 사용자 디자인 승인, 전체 package/CSS graph, 결합 CSS 디자인 의도·최종 상태, 다른 브라우저/OS/Theme, 실제 스크린리더·터치 기기, 모든 props/Depth/Hybrid 옵션 조합, 장시간 2,100명 성능·대용량 Drag, Classic 패키징, callback/팝업 none의 전 조합, SSR/hydration·서버 저장·운영 배포, main 통합 후 Linux Docker/npm ci/Actions 실행은 NOT VERIFIED다. Compact는 기존 NOT SUPPORTED이며 새 기능을 추가하지 않았다. + +## 8. 영역별 최종 판정 + +- **main 비교: PASS.** Fetch/live SHA·양방향 커밋/파일·미커밋 겹침 확인. 적용은 미진행. +- **Fixed: PASS.** P9.5 배치·화살표·팝업 근거 재사용 및 현재 화면 확인. +- **Expanded: PASS.** 데이터/편집 기본값·TB/LR/Hybrid·탐색 기존 증거, 최신 중앙/실제 pointer 근거 연결. +- **Storybook: PASS.** 공개4페이지·실제 메뉴 순서·공통 frame 확인. +- **Vanilla: PASS.** 최신 Core 사용·동작 기존 증거·dist 동일 hash. +- **React: PASS.** Wrapper·HM Dialog 기존 증거와 독립 production consumer 추가 실행. +- **AI 정책: 문서 PASS / 범용 생성 런타임 NOT VERIFIED.** +- **데이터: PASS.** 회사당20/100/300, 20/200/2,100·중복 및 이동 집계 기존 증거. 성능 전체는 NOT VERIFIED. +- **Build: PASS.** 정상 패키지/Storybook/Vanilla 결과 재사용, root 타입/독립 React build 추가. +- **Guard: FAIL.** 기존10·신규0·NOT VERIFIED19; 대비 예외는 미해결로 보존. +- **Git 보존: PASS.** 기존 파일841개 byte 동일, HEAD/branch/staged 유지. 신규 보고서만 authored 추가. + +## 9. 수정 파일 / Git 준비 상태 + +P10에서 기존 authored 파일을 수정하지 않았다. **새 authored 파일은 이 `implementation-report-p10.md` 1개**다. `test-results/org-chart-p10/*`는 비교 JSON·검증 probe·로그·캡처 생성물이며, `examples/org-chart-react/dist`와 root tsbuildinfo는 빌드 생성물이다. 생성물을 자동 Stage하지 않는다. + +시작 상태는 tracked 수정33개·untracked authored46개였다. P10 보고서 추가 후 tracked33·untracked47개이며, staged0이다. P7 이후 구현/검증 스크립트·보고서·AI 정책은 기존 미커밋 작업으로 보존했다. 파일별 목록은 [commit-inventory.json](../../test-results/org-chart-p10/commit-inventory.json), byte 보존은 [final-state.json](../../test-results/org-chart-p10/final-state.json)에 기록한다. + +**Feature Commit·Push:** 확인한 기능과 빌드 기준으로 검토 준비는 되어 있으나, 이번에는 승인되지 않았고 실행하지 않았다. 전체 품질 gate PASS는 아니다. Guard 잔여·디자인/접근성 예외의 수용 여부를 정리한 뒤 명시 승인을 받아야 한다. + +승인 후 권장 commit 구분: + +1. P7~P9.5 공유 Core/React·데이터 helper·이동/레이아웃 정책·React optional HM peer 및 lockfile·관련 API/사용 문서. 새 `expanded-editing`, `auto-layout-policy`, `examples`, `moves`, HMEmployeeDialog 등을 누락하지 않는다. +2. Storybook 메뉴/Overview/Department/Full/Expanded composition·helper·preview CSS. 1번의 공개 계약을 전제로 묶는다. +3. authored 검증 스크립트·P7~P10 보고서. 실행 probe/실패 로그/캡처/dist 등 generated 출력은 제외한다. 기존 examples의 authored `verify-p*.mjs`는 cache로 오인해 제외하지 않는다. +4. AI 정책 문서와 AGENTS/CLAUDE 참조 변경을 별도 문서 commit으로 검토. + +이는 인벤토리 기반 제안이다. pathname만으로 stage하거나 `git add -A`하지 않고 정확한 patch와 의존성을 검토한다. `package-lock.json` 변경은 Wrapper의 optional HM UI peer 변경과 함께 검토하며 기존 unrelated 변경 포함 여부도 staged diff에서 확인한다. + +**main 병합 전 추가 조치:** main 배포6파일 통합 승인 → 기존 미커밋 patch의 검토/보존과 commit 경계 확정 → 승인된 feature 정리 → 통합 상태에서 필요한 빌드/회귀·Linux Docker 검증 → 실제 push 직전 live main 재확인. main push가 Gitea 배포를 trigger할 수 있으므로 운영 변수/권한과 배포 결과도 별도 확인한다. 이번 비교에서는 파일 충돌 후보0이지만 실제 merge/rebase/cherry-pick·main checkout·pull을 수행하지 않았다. + +사용자 검토 필요 항목은 **배포 변경 적용 여부, 최종 디자인 승인, Guard/대비 잔여 수용·추가 대응 범위, Feature Stage/Commit/Push 허용 여부**다. 승인된 SVG·회사색 변경이나 새 AI 실험은 이번 후속 제안에 포함하지 않는다. + +## 10. 주요 화면과 원본 증거 + +이번 현재 빌드 캡처: + +- [Overview1440](../../test-results/org-chart-p10/orgchart--logo-personnel-summary-1440.png) +- [Department1440](../../test-results/org-chart-p10/orgchart--department-colors-1440.png) +- [Full1440](../../test-results/org-chart-p10/orgchart--full-composition-1440.png) +- [Expanded1440](../../test-results/org-chart-p10/orgchart--expanded-org-chart-1440.png) +- [독립 React Full1440 팝업](../../test-results/org-chart-p10/react-full-1440.png), [390 팝업](../../test-results/org-chart-p10/react-full-390.png) + +재사용/실제 시각 확인: + +- [Full1920×1080](../../test-results/org-chart-p9-5-full/after-1920x1080.png), [1920×1440](../../test-results/org-chart-p9-5-full/after-1920x1440.png), [1440×900](../../test-results/org-chart-p9-5-full/after-1440x900.png), [1280×1024](../../test-results/org-chart-p9-5-full/after-1280x1024.png), [390×844](../../test-results/org-chart-p9-5-full/after-390x844.png). 이 5개 캡처는 화살촉 후속 붙임 전 기록이며 최종 화살촉 근거와 구분한다. +- [최종 붙은 화살촉 상세](../../test-results/org-chart-p9-5-arrowhead-touch/after-1920x1080-detail.png), [25/25 측정](../../test-results/org-chart-p9-5-arrowhead-touch/after.json). +- [Expanded 중앙/Fit124](../../test-results/org-chart-p9-5/after.json), [실제 pointer/geometry62](../../test-results/org-chart-p9-5/geometry.json). +- [P9.4 데이터/편집78](../../test-results/org-chart-p9-4/browser.json), [Vanilla API21](../../test-results/org-chart-p9-4/core-api-cancel-default-fixed.json), [소비 화면 팝업118](../../test-results/org-chart-p9-5-popup-parity/result.json). + +검토 중인 실제 정적 Storybook은 `http://127.0.0.1:6064/`이며, 기존 서버·포트를 재시작하거나 교체하지 않았다. 별도 React probe 서버는 임시 loopback port를 사용하고 종료했다. + +## 11. 시간 + +- Started At: 2026-10-13 14:15:23 KST +- Estimated Duration: 최초 약35분 (main8분·증거/정책7분·선별 검증15분·보고5분). +- Completed At: 2026-10-13 14:32:33 KST +- Actual Duration: 17분10초 +- Variance: 예상보다17분50초 짧음. 최신 증거의 해시 일치를 확인하여 기존 정상 suite/build를 재사용했다. + +제출 직전 live main/Feature SHA는 위 기준과 동일했다. 보고서 링크33개 모두 실제 대상이 존재한다. 기존841개 파일 byte 동일·branch/HEAD/staged 유지, authored 추가는 P10 보고서1개이며 최종 Git 감사에 기록했다. diff --git a/docs/org-chart/implementation-report-p11.md b/docs/org-chart/implementation-report-p11.md new file mode 100644 index 0000000..018bde7 --- /dev/null +++ b/docs/org-chart/implementation-report-p11.md @@ -0,0 +1,139 @@ +# OrgChart P11 — 외부 CSS 테마 및 Guard 결과 + +RESULT: **PASS** (아래 명시된 구현·검증 범위) + +| 구분 | 결과 | +|---|---| +| 외부 CSS 테마 실제 적용 | **PASS** — React / Vanilla / 순수 HTTP Core 소비자 | +| 기존 TOKEN-001 5건 Guard 실제 재검사 | **PASS** — 원본 `status PASS`, `staticStatus PASS`, FAIL **5 → 0**, issues 0 | +| 다른 공용 Guard 검사 결과 보존 | **PASS** — UI/CENTER/Foundation/Pattern의 기존 67개 이슈 동일, FAIL 0 유지 | +| 기본 디자인 보존 | **PASS** — 6개 기본 화면의 변경 전후 PNG SHA-256 동일 | + +Guard의 자체 `runtimeStatus NOT VERIFIED`는 그대로 보존했다. 정적 Guard PASS를 브라우저 결과로 덮어쓰지 않았다. 외부 테마 브라우저 검증과 원본 Guard 판정은 각각 보고한다. 전체 제품·전체 접근성·모든 브라우저의 PASS를 뜻하지 않는다. + +## 작업 기준 및 Git + +- 폴더: `D:\디자인시스템\HMDesign-OrgChart-Recovered` +- 브랜치: `feature/orgchart-package` +- 시작/완료 HEAD: `dcfa4e0895eecc97ce516cbf3d4424bbaadfddeb` +- origin: `https://gitea.hmac.kr/b25027/HMDesign.git` +- Git 작성자 설정: `b25027 ` +- 시작 Working Tree: clean. 완료 Working Tree: 아래 P11 변경만 unstaged. Staged 파일 0. +- 이번 첨부 요청의 경로·Feature 브랜치·Commit/Push 보류 지시를 적용했다. Stage·Commit·Push·Merge·운영 서버·Docker·Registry Publish 미진행. +- 범위: 기존 OrgChart Core의 컬러 구현 및 소비 검증. 공개 API·데이터 계약·엔진·회사 프리셋·기존 4개 공개 Story 소스·HM UI Theme를 변경하지 않았다. 신규 업무 화면/Navigation/FilterBar를 추가하는 작업이 아니다. + +## 구현과 CSS 변수 + +회사색과 Fixed 6개 부서색의 JS inline 주입을 제거하고 `data-company-id`, `data-group-variant`에 따른 CSS 계산으로 옮겼다. `color-presets.css`에 실제 사용 범위의 `current-card-background`, `current-card-color`, `current-department-color`, `current-border`, `current-gradient`와 compatibility alias 기본 선언을 추가했다. Auto의 `expanded-primary`, `expanded-card-primary`, 기존 secondary alias도 CSS로 계산한다. + +외부 공개 입력은 조상 CSS가 Shadow DOM을 통과해 상속되도록 내부에 고정 기본 선언하지 않는다. 내부 `resolved-*`/`current-*`가 해당 공개 입력과 기존 색상 fallback을 해석한다. 외부 테마를 JS inline 기본색으로 막지 않는다. + +주요 추가/연결 입력은 다음과 같다. 전체 이름·기본 정책·회사/부서 ID 목록은 [사용 가이드](org-chart-usage-guide.md#p11-외부-css-테마-2026-10-13)에 있다. 접두사는 모두 `--hm-orgchart-`다. + +| 영역 | 입력 | +|---|---| +| 회사 | 기존 `company-{id}` 경로를 공개 입력으로 연결, 7개 기본색 유지 | +| 그룹 | `group-title-background`, `group-title-color`, `group-border` | +| 카드 | `card-background`, `card-color`, `card-position-color`, `card-border`, `card-accent` | +| 역할 | `leader-card-background`, `top-leader-card-background` (Auto) | +| 상태/Focus | `card-hover-background`, `card-focus-color` | +| 연결선 | 기존 `expanded-line-color`를 상속 입력으로 전환, `fixed-line-color` 추가 | +| 파견/휴직 | `status-*`, `dispatched-*`, `on-leave-*`의 제목·카드 gradient/Border/글자 입력 | +| Core 팝업 | `dialog-background`, `dialog-color`, `dialog-border`, `dialog-close-color`, `dialog-close-hover-background`, `dialog-focus-color` | +| 기존 공통 | `bg-surface`, `bg-content`, `fg-primary`, `stroke-default` 등 재사용 | +| Fixed | 기존 `{department}-gradient/border/department-color/card-background/card-color` 입력 유지 | + +상태 요약 카드의 고정 gradient/Border를 변수로 바꾸고 기존 `!important`를 제거했다. 미지정 Hover는 원래 카드/상태 gradient를 유지한다. SVG/PNG 자산을 수정하거나 Cell Border Only를 재적용하지 않았다. + +## 외부 파일 연결 및 실제 결과 + +- [external-theme.css](../../examples/org-chart-react/external-theme.css): 기본 공개 CSS를 먼저 import하고 `:host`의 컬러 입력을 뒤에 선언한다. Vite에서는 `?url`로 import해 기존 `stylesheetUrls.core`에 전달한다. +- 일반 HTTP Vanilla에서는 기본 `style.css`를 상대/절대 HTTP 경로로 import하는 같은 파일을 사용한다. 검증 fixture는 공개 Core ESM만 로드했으며 React 또는 node_modules 요청 0건이었다. +- [host-theme.css](../../examples/org-chart-react/host-theme.css): 호스트 HTML에 연결하는 변수 전용 예제. 호스트 class 적용/제거만으로 회사색·배경이 변경/복원됐다. +- [external-group-theme.css](../../examples/org-chart-react/external-group-theme.css): 회사색과 별도로 공통 제목 배경을 바꾸는 CSS 파일. 실제 `rgb(97, 62, 139)` 적용 확인. +- Shadow 파일의 `:host` 선언이 조상 상속보다 우선한다. 호스트 selector는 Shadow 내부 카드에 접근하지 못하므로 내부 selector 수정은 `stylesheetUrls.core`에 포함한다. 기본 CSS 전체를 포함하지 않은 변수 전용 파일로 core URL을 대체하면 안 된다. + +| 확인 대상 | 실제 적용 결과 | +|---|---| +| 한맥 / 삼안 | 제목·소속 카드 강조선이 `#2457a7` / `#385ca8`로 변경 | +| 나머지 5개 회사 | 각각 개별 CSS 색상 변경. 7개 승인 기본색도 브라우저 확인 | +| 그룹 | 제목 글자 `#fff4d6`, 공통 배경 별도 파일 override 확인 | +| 직원·대표자·그룹장 | Auto 배경 `#f4f8ff` / `#d2e3ff` / `#e3eeff`, Border `#527aa9` | +| Hover / Focus | 실제 pointer Hover `#c1d8f8`, 키보드 Focus `#7650a5` | +| Auto 연결선 | 직원 SVG 경로 및 조직 pseudo 연결선 `#249a79` | +| Fixed 연결선 | 실제 overlay path `#297ab0`. 이미지 화살표 내부 색은 그대로 | +| 조직도 배경 | Auto canvas `#edf4ff`, Fixed readOnly 내용 `#dce8f8` | +| 파견·휴직 | 서로 다른 녹색/보라색 gradient·Border, Fixed 및 Auto 상태 카드 확인 | +| Core 팝업 | 배경 `#eef2ff`, 글자 `#283568`, Border `#6879ba`, 실제 닫기 Hover/Focus 확인 | +| 런타임 복원 | mount 상태에서 호스트 class 제거 시 기본 색상·크기 즉시 복원 | + +React HM Dialog는 Shadow 밖의 별도 HM Theme/Portal 계약을 계속 따른다. `dialog-*`는 Core 팝업에만 적용된다. `@hm-design/ui/theme` 연계 및 Portal 주의사항을 사용 가이드에 추가했으며 HM Dialog 구현 자체는 바꾸지 않았다. + +## 기본 디자인 및 Fixed 한계 + +React/Vanilla 각각 Auto Chart Only, Fixed readOnly Full Composition, Fixed Interactive를 변경 전 Core/Wrapper dist로 캡처했다. 변경 후 빌드에서 모든 카드·제목·캔버스·팝업의 주요 computed 색상/Border/크기를 비교했다. 화면 6개의 PNG SHA-256이 정확히 동일했다([이미지 비교 결과](../../test-results/org-chart-p11/default-image-comparison.json)). + +기존 공개 Story 4개(Full Composition, Expanded OrgChart, Department Colors, Overview)를 실제 브라우저에서 열었다. 4/4 렌더 확인, 오류 0, Department Colors의 서로 다른 기본 gradient 6개 유지. Story 소스·ID·등록은 변경하지 않았다. Story 화면은 현재 렌더 확인이며 변경 전 Story 전체의 픽셀 비교를 수행한 것은 아니다. + +Fixed의 CSS 제목·상태 카드·Focus·배경·실제 SVG overlay path는 변경할 수 있다. `cards/*.svg` 카드 이미지, 회사 로고 PNG, Workdesk `Group.svg`, `bg_sky.png`, `bg_main.png`, `Frame2@3x.png`, `1-8-4x-1@2x.png`, `team-arrow-*.png` 내부 색상은 CSS 변수로 바꿀 수 없다. 대표자/부서 SVG의 불투명 영역은 카드 CSS 배경을 바꿔도 유지된다. 자산 전면 재작성·필터 착색 없음. + +## Guard 5건 해결과 공용 영향 + +기존 분석기에서 다음 명령으로 TOKEN-001 5건을 먼저 재현했다. [원본 재현 JSON](../../test-results/org-chart-p11/guard-before.json)은 보존한다. + +```powershell +node scripts/ai-generation-guard.mjs --target . --scope PARTIAL --profile STORYBOOK --files packages/org-chart/src/core/department-colors.ts,packages/org-chart/src/core/style-presets.ts,packages/org-chart/src/core/expanded-content.ts --json +``` + +| 기존 FAIL | 해결 | +|---|---| +| Department Colors의 `current-gradient`, `current-card-background` (2건) | 실제 `data-group-variant` 범위의 CSS 기본 선언 및 공개 CSS 연결 | +| Style presets의 `resolved-bg-surface` (1건) | 기존 공개 design-tokens.css 선언 정상 수집 | +| Style presets의 `current-border`, `current-gradient` (2건) | 실제 범위 기본 선언. JS 색상/alias 주입 제거 | + +CSS 선언만으로는 `?url`로 연결된 공개 CSS를 기존 분석기가 볼 수 없어 5건을 해결할 수 없었다. 필요한 분석기 변경은 `guard-theme-analysis.mjs`의 **공개 OrgChart CSS 두 import 형태와 상대 CSS import 선언 수집**으로 한정했다. 임의 CSS URL 전체를 적용 스타일로 인정하지 않고, setProperty 정의 자동 승인·예외 목록·규칙 비활성화를 하지 않았다. 미선언 HM 토큰은 fallback이 있어도 FAIL이다. + +동일 명령의 [최종 JSON](../../test-results/org-chart-p11/guard-final.json): `status PASS`, `staticStatus PASS`, `issues []`, FAIL 0. 원본 CSS phases는 STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT PASS / FINAL NOT VERIFIED다. 실제 테마 적용은 별도 브라우저 결과로 확인했다. Guard에 그 Runtime 증거를 결합하여 원본 결과를 변경하지 않았다. + +기존 Guard 94/94 및 신규 공개 CSS 회귀 5/5 PASS. 이전 분석기를 임시 복사해 UI/CENTER/Foundation/Pattern의 실제 현재 소스를 검사한 뒤 수정 분석기와 비교했다. 기존 67개 이슈·status·staticStatus가 동일했다([비교 JSON](../../test-results/org-chart-p11/guard-other-comparison.json)). 해당 다른 범위의 원본 status는 NOT VERIFIED, FAIL 0이며 이를 PASS로 올리지 않았다. 비교용 임시 분석기 파일은 제거했다. + +P11 React 소비 fixture는 `--result p11.html`도 별도 실행했다([소비 Guard](../../test-results/org-chart-p11/guard-consumer.json)). 기존 공용 Guard는 OrgChart를 HM UI 화면 재사용 계약 대상으로 모델링하지 않아 `staticStatus NOT APPLICABLE`, 원본 NOT VERIFIED다. 이를 기존 5건의 PASS와 혼동하지 않는다. 패키지 CSS 구현 자체는 해당 소비 graph 밖의 범위이며 빌드·타입·실제 브라우저로 검증했다. + +## 기술 검증 + +| 검사 | 결과 / 증거 | +|---|---| +| 최종 빌드 React/Vanilla 테마·기본값·동작·4개 폭 | **104/104 PASS**, 콘솔/page 오류 0 — [browser-result.json](../../test-results/org-chart-p11/browser-result.json) | +| 순수 HTTP Vanilla Core | **8/8 PASS**, 오류 0. 외부 CSS @import, 팝업, 공개 zoomTo/panBy/fitToView/destroy — [standalone 결과](../../test-results/org-chart-p11/standalone-vanilla-result.json) | +| 기존 공개 Story | **4/4 PASS**, 오류 0 — [Storybook 결과](../../test-results/org-chart-p11/storybook-result.json) | +| 회사 프리셋·Expanded company/layout 관련 테스트 | **17/17 PASS** (bundled Node 24.19.0 사용) | +| 공용 Guard 회귀 | **94/94 PASS** — [로그](../../test-results/org-chart-p11/guard-tests.txt) | +| 신규 Guard/CSS 기본값 회귀 | **5/5 PASS** — [로그](../../test-results/org-chart-p11/guard-orgchart-tests.txt) | +| Core / React Typecheck | PASS. P11 TSX fixture 별도 Typecheck도 PASS | +| Core / React Wrapper / 소비 예제 Build | PASS. 외부 CSS URL이 실제 배포 assets에 산출됨 | +| 390/768/1280/1440px | Auto canvas 폭 일치, 문서 가로 overflow 없음. Fixed 문서 overflow 없음 | +| 상호작용 | 직접 Wheel, Pan, 접기/펼치기, Enter 팝업, Escape 닫기/Focus 복귀, Hover/Focus, Drag & Drop ON/OFF 속성 확인 | +| git diff --check | PASS, Staged 파일 0, HEAD 유지 | + +빌드의 기존 `:host-context(.dark)` minifier 경고와 큰 bundle 경고는 남아 있다. 최초 시스템 Node 20에서 기존 unit runner의 `node:fs globSync` import가 실패해, 해당 runner는 제공된 Node 24로 실행하여 관련 17개를 통과했다. 의존성/패키지 버전 업그레이드 없음. + +NOT VERIFIED: Firefox/Safari/Edge, screen reader 음성, 물리 터치, 실제 Drag & Drop 데이터 이동 전체 회귀, `/hm-dialog` 별도 Theme를 변경한 새 브라우저 비교, 기존 Story 4개 전체의 변경 전 픽셀 비교, 전체 제품 테스트·배포. Fixed 이미지 내부 색상 테마화는 이번 범위에서 지원하지 않는다. 기본 렌더·팝업의 픽셀/computed 비교와 실제 테마 결과의 검사 범위는 위 증거로 한정한다. + +## 변경 파일 + +- Core: `packages/org-chart/src/core/style-presets.ts`, `expanded-content.ts`, `department-colors.ts` +- CSS: `packages/org-chart/src/styles/color-presets.css`(신규), `org-chart.css`, `employee-card.css`, `employee-info-dialog.css`, `org-chart-content.css`, `org-chart-expanded.css`, `org-chart-readonly.css`, `summary-sections.css` +- CSS packaging: `packages/org-chart/build.mjs` +- Guard: `scripts/guard-theme-analysis.mjs`, `scripts/guard-orgchart-theme.test.mjs`(신규) +- 소비/검증: `examples/org-chart-react/p11.html`, `p11.tsx`, `external-theme.css`, `external-group-theme.css`, `host-theme.css`, `verify-p11.mjs`, `verify-p11-stories.mjs`(모두 신규), `vite.config.ts` +- 순수 Vanilla 검증: `examples/org-chart/verify-p11-standalone.mjs`(신규) +- 문서: `docs/org-chart/org-chart-usage-guide.md`, `docs/guidelines/ai-generation-guard.md`, 이 보고서 +- ignored 산출물: Core/Wrapper/소비 dist, `test-results/org-chart-p11/*`. Stage하지 않았다. + +## 시간 (Asia/Seoul) + +- Started At: 2026-10-13 21:43:34 KST +- Completed At: 2026-10-13 22:20:11 KST +- Actual Duration: 36분 37초 +- Estimated Duration: 최초 약 60분 (분석 10 / 구현·가이드 25 / 검증 25) +- Variance: 예상보다 23분 23초 단축 diff --git a/docs/org-chart/implementation-report-p2.md b/docs/org-chart/implementation-report-p2.md new file mode 100644 index 0000000..a1ee550 --- /dev/null +++ b/docs/org-chart/implementation-report-p2.md @@ -0,0 +1,109 @@ +# OrgChart P2 — Depth별 카드와 Workdesk Full Composition + +RESULT: **PASS — 현재 P1 fixture의 조회 전용 Full Composition 수정 범위**. + +실제 조직 계층에 따라 1Depth 조직장만 대형 카드를 사용하고, 2Depth 조직장은 소형 카드로 표시한다. Workdesk 원본 연결선·협업 화살표·장식을 공통 Core에 적용했다. 기존 Guard 미검증과 이전 검사의 FAIL은 이 결과와 별도로 보존한다. Workdesk 전체 픽셀 일치나 모든 데이터 조합의 지원을 뜻하지 않는다. + +## 작업 범위와 보존 + +- 수정 경로: `D:\디자인시스템\HMDesign-OrgChart`, 브랜치 `feature/orgchart-package`. +- Workdesk 원본 `D:\디자인시스템\gpd-workdesk\sample\조직도_v1.3`는 읽기 전용으로 참조했다. 운영 main에는 수정하지 않았다. +- 기존 미커밋 변경은 시작 파일 해시와 비교해 보존했다. 원본 카드 SVG·회사 로고·공통 Theme·Component·Pattern·Storybook 메뉴·독립 컴포넌트 API를 유지했다. +- 직원 41명의 원본 fixture는 변경하지 않았다. 직원 ID·이름·리더 정보·직위·소속·상태·조직 관계·집계 계약도 그대로다. +- Stage·Commit·Push·Merge·Reset·Rebase를 수행하지 않았다. + +## 카드·이름·간격 + +- 변경 전: 하위 조직장도 모두 대형 `leader` 자산을 사용했다. 조회 전용 외부 margin이 후속 직원 margin 규칙에 덮여 실제 헤더↔카드 간격은 0px이었다. +- 변경 후 대표자: 전용 SVG **146×74px** 유지. +- 변경 후 1Depth: 주요 조직장 6명 모두 기존 대형 SVG **146×68px** 사용. +- 변경 후 2Depth: fixture의 기존 조직장 3명 모두 소형 SVG **108×52px** 사용. 다른 일반 직원도 소형 카드를 유지한다. +- 대형 여부는 직책 문자열이나 리더 플래그 단독이 아니라 원본 `parentId` 조상 관계로 계산한다. semantic leader 구분과 직책 뱃지는 보존하며 독립·interactive 화면의 카드 정책은 유지한다. +- 최신 사용자 수정 기준으로 1Depth 외부 간격은 **8px**, 2Depth 이하 헤더↔첫 카드 간격은 **10px**다. 화면의 17개 조직 노드에서 Depth별 간격을 확인했다. +- Storybook 1920px 측정값을 Stage 좌표로 환산하면 1Depth 헤더 Y **254.1875px**, 높이 **36px**, 대형 카드 상단 **298.1875px**다. 6개 외부 간격은 약 **8.000002px**, 하위 조직 간격은 **9.99998–10.00001px**였다. +- Full Composition의 대표자·조직장·직원·파견·휴직을 포함한 렌더 카드 43개의 이름은 모두 **홍 길 동**으로 표시한다. 접근성 이름은 **홍길동**이다. 직위·직책도 요청한 한글 자간 표시를 적용하며 원본 데이터는 변경하지 않는다. +- 확정 Typography·카드 크기·프로필 크기와 직위↔이름 내부 gap **0px**를 유지했다. 외부 헤더 간격과 내부 행 간격을 분리했다. + +## Workdesk 원본 디자인 적용 + +- 활성 compact `screenD` 곡선 3개, 대표자↔허브 중앙선, 실제 헤더 상단 중앙을 따라가는 사업관리 곡선의 **SVG Path 5개**를 적용했다. 사업관리 곡선은 원본 renderer의 계산식을 사용한다. +- Stroke `#45639F`, width `0.893023`, dasharray `1.79 1.79`, fill `none`을 사용한다. 기존 상단 pseudo 연결선은 해당 원본 artwork preset에서 숨겨 중복을 제거했다. +- 원본에서 하위 조직 SVG 블록은 비활성 상태이며 현재 사용 가능한 별도 원본 Path가 없다. 하위 셀·팀은 기존 엔진의 실제 계층 경로를 보존하고 원본 점선 스타일을 적용했다. 원본에 없는 관계를 추가하지 않았다. +- 협업 화살표는 원본 PNG 4종으로 **5개**를 배치했다. 원본 SVG image 방식과 `preserveAspectRatio="none"`을 유지한다. +- `Group.svg`, `bg_sky.png`, `bg_main.png`, `Frame2@3x.png`, `1-8-4x-1@2x.png`와 화살표 4종, 총 **9개 자산**을 복사했다. SHA-256이 Workdesk 원본과 모두 일치한다. +- `?inline` import로 Core ESM/IIFE에 자산을 포함해 외부 Workdesk 서버 의존성을 제거했다. HTML·React·직접 열기 classic에서 동일한 자산을 사용한다. +- 장식 0, SVG 선·화살표 4, 조직 7, 허브 12, 통계 18, 파견·휴직 32, 대표자 35의 레이어를 구성했다. 전체 artwork는 카드와 같은 Stage 안에서 함께 축소된다. +- 우측 조직별 총인원, 좌하단 파견·휴직의 가로 2개 그룹, 하단 관계사 한 줄 현황을 유지한다. 독립 PositionSummary의 상세 교차표 기능은 보존했다. + +## HTML·React·Storybook과 반응형 + +- HTML `p1.html?mode=readOnly`, 직접 열기 `p1-classic.html?mode=readOnly`, React `p1.html?mode=readOnly`, 기존 Full Composition Story가 같은 Core DOM/CSS를 사용한다. +- React 예제에 HTML과 같은 `mode` query 처리를 연결했다. 별도 React 노드 UI를 만들지 않았다. +- 기준 Stage **1920×914.5px**와 기존 폭·높이 기반 단일 비례축소를 유지한다. 작은 화면에서 카드 종류를 바꾸거나 조직을 재배열하지 않는다. +- 1920×1080: scale **0.983333**. +- 1440×900: scale **0.733333**. +- 1280×800: scale **0.650000**. +- 768×1024: scale **0.383333**. +- 390×844: scale **0.186458**. +- 네 진입 경로의 조직·카드·간격·통계·자산·축소 측정값이 일치했다. Storybook의 베이지 호스트 배경과 HTML의 흰 배경 때문에 바깥 배경 톤은 다르다. +- 390px에서도 구조 전체가 함께 축소되지만 글자는 매우 작다. 모바일에서 원본 크기 가독성을 제공한다는 의미는 아니다. + +## 검증과 근거 + +- OrgChart Core·React Wrapper Typecheck: **PASS**. +- Core ESM/IIFE·타입·CSS 및 Wrapper Build: **PASS**. +- React 소비 예제 production Build: **PASS**. 약 2.53MB chunk 경고는 원본 이미지 inline 포함에 따른 것으로 남겨 두었다. +- 현재 Storybook dev **6041**의 Full Composition: **PASS**. 전체 Storybook production Build는 이번에 재실행하지 않았다. +- Chrome `155.0.8059.39`, 네 진입 경로·다섯 뷰포트의 P2 브라우저 검사: **484/484 PASS**, 새 console/runtime 오류 **0**. +- 대표자·Depth별 카드 크기, 실제 조상 Depth, 17개 조직의 외부 간격, 이름·직책 뱃지·원본 fixture 보존, Stage geometry·overflow, 카드·헤더·화살표·통계의 사각형 겹침을 확인했다. +- Tooltip 검증은 실제 표시된 홍 길 동 이름 1개의 폭을 일시적으로 **8px**로 제한해 overflow를 유도했다. 실제 hover·tooltip hover·키보드 Tab·Escape·focus 유지·숨김 scroll extent·복구를 확인하고 폭을 원복했다. fixture나 표시 이름을 검증용 긴 이름으로 바꾸지 않았다. +- 1920/1440/390px HTML·Storybook PNG를 육안 확인했다. 실제 텍스트 glyph 충돌과 화살표의 카드 침범은 발견하지 않았다. 확정 내부 gap 0에서 Noto 글꼴의 DOM Range ascent/descent envelope가 교차할 수 있으므로 해당 원시 측정값은 실제 glyph 충돌과 구분해 보존했다. +- 곡선 Path의 bounding box만으로 실제 곡선 겹침을 판정하지 않았다. 라우팅·레이어는 저장된 전체 화면 캡처로 확인했고 자동 곡선 충돌 검증은 **NOT VERIFIED**다. + +증거 파일: + +- [P2 browser 결과·측정값](../../test-results/org-chart-p2/browser.json). +- [기존 소스 변경 범위](../../test-results/org-chart-p2/source-changes.json), [시작 해시](../../test-results/org-chart-p2/source-baseline.json), [원본 자산 SHA 비교](../../test-results/org-chart-p2/asset-preservation.json). +- [Storybook 1920](../../test-results/org-chart-p2/storybook-1920x1080.png), [1440](../../test-results/org-chart-p2/storybook-1440x900.png), [390](../../test-results/org-chart-p2/storybook-390x844.png). +- [HTML 1920](../../test-results/org-chart-p2/html-1920x1080.png), [1440](../../test-results/org-chart-p2/html-1440x900.png), [390](../../test-results/org-chart-p2/html-390x844.png). +- [Storybook Full Composition](http://127.0.0.1:6041/?path=/story/orgchart--full-composition), [독립 iframe](http://127.0.0.1:6041/iframe.html?id=orgchart--full-composition&viewMode=story). + +## Guard·기존 FAIL·미검증 + +- React 소비 예제에 PARTIAL Guard를 실행했다. 원본 `staticStatus=NOT APPLICABLE`, `status=NOT VERIFIED`, `RENDER-002`를 보존한다. +- `styleEvidence`: STATIC **PASS**, RUNTIME **NOT VERIFIED**, DESIGN_INTENT **PASS**, FINAL **NOT VERIFIED**. Guard CSS checks는 `[]`이며 Core Shadow CSS를 검사 범위에 포함하지 못했다. 이를 Core 스타일 전체 정적 PASS로 확대하지 않는다. +- [Guard 원본](../../test-results/org-chart-p2/guard-react.json)과 실제 P2 브라우저 증거는 별도 파일로 보존하며 Runtime PASS로 원본 status를 덮어쓰지 않았다. +- 기존 `verify-p1.mjs` [baseline](../../test-results/org-chart-full-composition/verify-p1-baseline.json)·[after](../../test-results/org-chart-full-composition/verify-p1-after.json)의 **197건 중 145 PASS / 동일 52 FAIL**을 보존하고 재실행하지 않았다. 이전 fixture·root·교차표·내부 스크롤·Typography 기대값 관련 실패와 P2 검사를 구분한다. +- 사용자가 언급한 v0 `verify.mjs` FAIL **9건**은 이번에 독립 확인하지 못했다. 현재 저장된 [v0 report](../../test-results/org-chart-browser/report.json)는 **6건 중 2 FAIL**로, 이 기록만으로 9건을 확정하지 않는다. 검사 삭제나 무조건적인 기대값 PASS 변경은 없다. +- **NOT VERIFIED**: 실제 운영 Workdesk 최신 화면과의 완전한 픽셀 일치, 전체 스크린리더, 자동 곡선/글꼴 ink 충돌 판정, 모든 미매핑 조직·혼합 root 데이터 조합, 전체 Storybook production Build. + +## 변경 파일 + +기존 소스 6개: + +- [render.ts](../../packages/org-chart/src/core/render.ts): 실제 조상 Depth에 따른 readOnly 카드 자산·헤더 역할. +- [employee-card.ts](../../packages/org-chart/src/core/employee-card.ts): readOnly 표시 이름·한글 간격·접근성 이름. +- [status-summary.ts](../../packages/org-chart/src/core/status-summary.ts): 상태 카드에도 동일 표시 옵션 전달. +- [org-chart-content.ts](../../packages/org-chart/src/core/org-chart-content.ts): 대표자 표시와 공통 artwork 조합. +- [org-chart-readonly.css](../../packages/org-chart/src/styles/org-chart-readonly.css): 헤더·카드 간격, 원본 장식·레이어·계층선, 파견/휴직 가로 그룹. +- [React p1.tsx](../../examples/org-chart-react/p1.tsx): HTML과 같은 readOnly query 진입. + +추가 소스·자산·문서: + +- [workdesk-artwork.ts](../../packages/org-chart/src/core/workdesk-artwork.ts): 원본 Path·5개 image 화살표·장식·동적 사업관리 연결선. +- [Workdesk 자산 README](../../packages/org-chart/src/assets/workdesk/README.md) 및 해당 디렉터리의 원본 자산 9개. +- [verify-p2.mjs](../../examples/org-chart/verify-p2.mjs): 기존 검사와 분리된 P2 브라우저 검증. +- 이 보고서와 현재 계약을 반영한 [UI Composition](ui-composition.md), [카드 디자인 2단계](card-design-stage2.md). +- [React 예제 README](../../examples/org-chart-react/README.md): 조회 전용 query와 P2 검증 실행 안내. + +패키지·HTML/React build 산출물과 `test-results/org-chart-p2/` 증거를 생성했다. 이전 보고서·검증 기록을 보존했으며 Commit·Push·Merge는 미진행이다. + +별도 [런타임 증거](../../test-results/org-chart-p2/runtime-evidence.json)에 원본 Guard 해시와 브라우저·시각 검토 상태를 연결했다. 현재 source/contract fingerprint가 원본 Guard와 일치함을 확인했다. 원본 status는 변경하지 않았다. 카드 41개의 직위/뱃지 행 하단과 이름 행 상단 간격도 실측 0px(부동소수점 오차 내)이다. + +## 시간 기록 + +- Started At: 2026-10-12 14:52:51 KST +- Completed At: 2026-10-12 15:15:24 KST +- Actual Duration: 22분 33초 +- Estimated Duration: 약 45~60분 +- Variance: 예상보다 22분 27초~37분 27초 단축 diff --git a/docs/org-chart/implementation-report-p3.1.md b/docs/org-chart/implementation-report-p3.1.md new file mode 100644 index 0000000..811da9f --- /dev/null +++ b/docs/org-chart/implementation-report-p3.1.md @@ -0,0 +1,43 @@ +# HMDesign OrgChart P3.1 — 잔여 디자인 정밀 보완 + +RESULT: **PASS — 요청한 P3.1 변경 범위**. + +집중 브라우저 검사 **486/486 PASS**, 신규 콘솔·런타임 오류 **0건**. 기존 [P3의 732/732 기록](implementation-report-p3.md)은 역사 근거로 보존했고 전체 suite를 재실행하지 않았다. Guard 원본 및 결합 전체 상태는 **NOT VERIFIED**로 유지한다. + +1. **Noto Sans KR**: OrgChart 전용 기본 token에 적용했다. 기존 `public/fonts/NotoSansKR-variable.ttf`를 HTML의 기존 복사 경로와 React bundler 경로에서 재사용하고 Storybook의 기존 HM font-face를 사용한다. HM Theme는 변경하지 않았다. 카드·직위·타이틀·허브·표·관계사·파견/휴직·팝업의 computed family를 확인했으며 네 실행 경로의 대표 9개 텍스트에서 Chrome CDP로 실제 custom-font 글리프 사용을 확인했다. 파일 내부 family 이름은 `Noto Sans KR Thin`, PostScript 이름은 `NotoSansKR`와 가중치별 suffix다. 이는 fallback 글꼴이 아니다. 한국어 Core shell의 `lang="ko"`로 `en` Storybook host와 `ko` HTML의 가운데점 글리프 폭 차이도 제거했다. +2. **1Depth·2Depth**: 1Depth를 20px/700/36px·실제 헤더 36px로 맞췄다. 경영기획 2Depth는 원본 `.hw`의 18px/600/16.9px를 적용했다. 인재성장은 같은 기존 값, 나머지는 20px/700/19.7px를 유지한다. 2Depth 114×30.4px·기존 Gradient·Radius 7.1px·Shadow·정렬을 유지했다. 모든 타이틀↔첫 카드 Gap 8px를 실제 측정했다. +3. **경영진 뱃지**: Full Composition의 `workdeskPresentation`에서 `jobTitle.trim() === '경영진'`인 뱃지 DOM만 생성하지 않는다. 빈 attribute container도 생성하지 않는다. 실제 직위·직책 데이터·팝업·통계는 그대로다. 현재 fixture의 대표자 `position="경영진"`, `jobTitle="실장"`은 유지한다. 따라서 대표자 직위 글자와 표의 `경영진` 열은 정상적으로 남는다. +4. **기타 뱃지·이름**: 실장·팀장·디비전장 등의 원본 뱃지는 유지한다. 이름은 기존 `홍 길 동`, 직위는 표시 계층에서만 공백을 제거한다. JSON을 수정하지 않는다. 기존 fixture에 경영진 직책 뱃지가 없어 대표자/1Depth/2Depth/일반/파견/휴직의 메모리 전용 변형을 별도로 검사했다. 여섯 역할에서 뱃지 미표시·직위 유지, 팝업의 원본 직책 보존, interactive 공통 렌더러의 별도 표시 정책을 확인했다. +5. **허브**: 원본 21px/750/24px·아이콘 20px·Gap 8px·좌우 Padding 22/24px·높이 42px를 유지했다. 기존 상속 `max-width:100%`가 198px 축 폭으로 텍스트를 제한하던 부분을 해제했다. 중심을 X 935에 명시하여 실제 폭 약 219.016px에서도 연결선 축을 유지한다. 기존 말풍선 SVG·배경·곡선은 변경하지 않았다. +6. **사진 없음**: 신규 공통 `person-placeholder.svg` 한 종을 모든 카드에서 같은 inline 자산으로 사용한다. 연한 사각 배경·중립 사람 실루엣이며 실사/원형 아바타/장식 없음. 실제 이미지 우선, 이미지 decode 실패 시 공통 SVG fallback을 확인했다. 승인 카드의 예약 슬롯 36×62/32×56/24×44 및 P3 파견/휴직 28×28을 유지한다. 원본 슬롯 55×76/54×70/38×52는 승인 카드보다 커서 복사하지 않았다. +7. **인원 현황 표**: 기존 표 폭 330px·위치·14px/400/1.15·좌우 조직명 padding 7px·행 19px/합계 20px·정렬·Border를 유지했다. 조직명 열 112px, 경영진 열 46px, 나머지는 잔여 폭을 배분한다. `nowrap`을 적용했으며 모든 셀의 실제 텍스트 Range가 셀 내부 한 줄로 들어가는지 확인했다. 숨김·말줄임·글자 축소 없음. 표 높이는 약 200.375px에서 172px로 줄었다. 가상 41명·조직별/직위별 계산·관계사 바는 보존했다. +8. **변경 파일**: 구현·예제·문서 14개. 재생성된 dist와 전체 해시 차이는 [파일 기록](../../test-results/org-chart-p3.1/source-changes.json)에 있다. + + - `packages/org-chart/src/core/employee-card.ts`, `src/core/index.ts` + - `packages/org-chart/src/styles/design-tokens.css`, `employee-card.css`, `org-chart-readonly.css`, `org-chart-p3-summary.css` + - 신규 `packages/org-chart/src/assets/person-placeholder.svg` + - `examples/org-chart/p1.css`, `examples/org-chart-react/p1.css`, 신규 `examples/org-chart/verify-p3.1.mjs` + - `packages/org-chart/README.md`, `docs/org-chart/ui-composition.md`, `card-design-stage2.md`, 이 보고서 + +9. **화면별 결과**: HTML HTTP·직접 `file://` classic·React production HTTP·실제 Storybook 6041 모두 1920×1080, 1440×900, 1280×800, 768×1024, 390×844에서 PASS. 문서 scroll/client 폭·높이가 같고 모든 카드가 Stage 내부에 들어간다. 모바일은 기존 전체 비례 축소라 글자가 작지만 팝업은 축소하지 않는다. [1920 HTML](../../test-results/org-chart-p3.1/html-1920x1080.png), [1920 Storybook](../../test-results/org-chart-p3.1/storybook-1920x1080.png), [390 Storybook](../../test-results/org-chart-p3.1/storybook-390x844.png)을 육안 검토했다. +10. **HTML·React 동일성**: 네 경로의 카드 내용/좌표/크기·허브 위치/폭·표 내용/폭을 화면별로 비교하여 일치했다. 같은 Core와 SVG를 사용하며 HTML 모방 화면을 만들지 않았다. React Wrapper와 Storybook 소스·사용자 메뉴 세 개는 변경하지 않았다. +11. **회귀·검증**: Core/Wrapper Typecheck, Core ESM/IIFE/타입/CSS build, Wrapper build, HTML prepare, React 소비 production build PASS. 여섯 카드 역할의 데스크톱/모바일 팝업·focus trap·ESC 복귀, 대표자 Enter/Space/닫기, HTML/React의 실제 키보드 그룹 링크 이동을 확인했다. 146×74/146×68/108×52 카드 및 P3 상태 카드 114×54 보존. 상태 카드 114×54와 슬롯 28×28은 이번 도입값이 아니라 기존 P3 측정값이다. 기존 큰 이미지 inline chunk 약 2.54MB 경고는 유지한다. 마지막 들여쓰기 정리 뒤 재빌드한 runtime 파일 73개는 검증 시점과 byte 동일하여 브라우저를 반복하지 않았다. +12. **남은 Workdesk 차이**: 원본 Pretendard 대신 이번 확정 Noto Sans KR을 적용한다. 승인 카드 때문에 원본보다 작은 사진 슬롯을 유지한다. 표 열/행과 인원은 현재 가상 fixture를 따른다. 원본 운영 화면과 완전한 픽셀 일치 및 곡선/글리프 ink 자동 교차 검사는 NOT VERIFIED다. 확장형 Auto Layout/layoutConfig API 없음. +13. **Guard·NOT VERIFIED**: 최종 유효 PARTIAL Guard는 React bootstrap `p1.html`에 실행했다. 원본 staticStatus `NOT APPLICABLE`, status `NOT VERIFIED`, `RENDER-002`를 보존한다. 원본 및 결합 CSS 상태는 STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT PASS / FINAL NOT VERIFIED다. 별도 Runtime은 PASS이며 이름 있는 computed 측정은 PASS이나 Core Shadow CSS 전체의 정적/의도 인증으로 확대하지 않는다. 최초 Guard는 target 기준 result 경로를 중복 지정해 ENTRY/SCOPE 미검증이 발생했다. 해당 실행은 [잘못된 인자 진단](../../test-results/org-chart-p3.1/guard-initial-invalid-result-path.json)으로 보존하고 인자만 바로잡았다. 실제 운영 Workdesk 픽셀 일치, 모든 임의 데이터/테마/브라우저, 스크린리더 발화, 실물 touch, 전체 Storybook production build는 NOT VERIFIED다. +14. **보존·Git**: 작업 경로 `D:\디자인시스템\HMDesign-OrgChart`, 브랜치 `feature/orgchart-package`, HEAD `d18e3c33801e165e17b6d971e2f6198eed483c3c` 유지. Workdesk 원본·운영 main 미수정. JSON·승인 SVG·로고·Workdesk 장식·HM Theme·Guard·과거 검사/보고서·package manifests 등 보존 대상 216개 해시 동일. 시작 시 기존 미커밋 package.json/package-lock.json 및 OrgChart 작업을 보존했다. Stage·Commit·Push·Merge·Reset·Rebase·Force Push 미진행. + +초기 집중 검사 102 FAIL도 [초기 기록](../../test-results/org-chart-p3.1/browser-initial.json)에 남긴다. 다섯 화면의 실행 경로 동일성 실패는 host 언어에 따른 허브 폭 차이였고 `lang="ko"`로 수정했다. 나머지 97건은 기존 상태 카드 크기/slot, `.hw` ID, variable font 내부 이름, fixture에 없는 뱃지에 대한 검사 기준을 정확히 잡지 못한 진단 오류였다. 원본 P3의 기대값이나 FAIL 기록은 수정하지 않았다. + +검증 근거: [최종 browser JSON](../../test-results/org-chart-p3.1/browser.json), [원본 Guard](../../test-results/org-chart-p3.1/guard-react.json), [Runtime](../../test-results/org-chart-p3.1/runtime-evidence.json), [결합 증거](../../test-results/org-chart-p3.1/linked-evidence.json), [runtime byte 동등성](../../test-results/org-chart-p3.1/artifact-format-equivalence.json). + +Workdesk 읽기 전용 근거: `index.css`의 `.hw` 2858–2865, 그룹 헤더 3703–3751, 2Depth 표면 3776–3786, 폭 3884–3899, 허브 2472–2542, 표 1432–1673; `index.html` 255–282 및 `org-dashboard-renderer.js` 1638–1663. P3가 남긴 원본 수치 근거도 보존한다. + +미리보기: [Storybook Full Composition](http://127.0.0.1:6041/?path=/story/orgchart--full-composition). + +작업 시간: + +- Started At: 2026-10-12 16:01:00 KST +- Completed At: 2026-10-12 16:30:44 KST +- Actual Duration: 29분 44초 +- Estimated Duration: 35–50분 +- Variance: 예상보다 5분 16초–20분 16초 단축 diff --git a/docs/org-chart/implementation-report-p3.2.md b/docs/org-chart/implementation-report-p3.2.md new file mode 100644 index 0000000..839410e --- /dev/null +++ b/docs/org-chart/implementation-report-p3.2.md @@ -0,0 +1,73 @@ +# HMDesign OrgChart P3.2 — 카드 Typography·파견/휴직·로고 크기 보정 + +RESULT: **PASS — 요청한 P3.2 변경 범위**. 집중 브라우저 **431/431 PASS**, 정책 측정 보완 **13/13 PASS**, 신규 콘솔·런타임 오류 **0건**. Typecheck·관련 Build PASS. P3.1의 486건 전체 suite를 반복하지 않았으며 기존 보고서·검증 증거 115개 파일을 보존했다. Guard 원본과 결합 전체 상태는 **NOT VERIFIED**로 유지한다. + +## 변경 전후 px + +아래는 실제 Chrome computed style과 Stage 축소 배율을 제거한 크기다. 측정 원본의 소수점 오차는 약 0.0001px 이내이며 아래 정수는 반올림한 값이다. [43개 카드 전체 측정](../../test-results/org-chart-p3.2/before-after-px.json)에 원시 값·컬러·로고 alpha 영역을 남겼다. + +- **1Depth 대형 카드**: 이름 **24→24px**, weight 600·line-height 30px 유지. 직위 **20→18px**, weight 400·line-height 22px 유지. 기존 이름 #071D3A·직위 #797979·letter-spacing 유지. 카드 **146×68**, 사진 **32×56**, 사진↔텍스트 **10px** 유지. +- **2Depth/일반 소형 카드**: 이름 **20→18px**, weight 600·line-height 24px 유지. 직위 **15→15px**, weight 400·line-height 18px 유지. 기존 이름 #323232·직위 #797979·letter-spacing 유지. 카드 **108×52**, 사진 **24×44**, 사진↔텍스트 **4px** 유지. 2Depth의 semantic leader도 동일한 소형 규격이다. +- **대표자**: 이름 **24px/600/30px**, 직위 **20px/400/22px**, 기존 #071D3A/#224387·letter-spacing 유지. 카드 **146×74**, 사진 **36×62** 유지. +- **파견·휴직 타이틀**: **14px/700/20px → 20px/700/19.7px**. 일반 2Depth의 기본 Typography와 같은 CSS 변수 세 개를 공유한다. 전용 회색 Gradient, Border·Shadow·108×24 타이틀 표면은 유지한다. 경영기획·인재성장의 기존 **18px/600/16.9px** 예외는 변경하지 않았다. +- **파견·휴직 직원**: 이름 **13px/700/18px → 18px/600/24px**, 직위 **10px/400/13px → 15px/400/18px**. 일반 소형 카드와 같은 콘텐츠 DOM·Typography 변수·사진 영역·텍스트 좌표·간격을 재사용한다. 이름 #3E4650·직위 #555F6A, 기존 회색 Gradient·Border·Radius·로고 grayscale(1)/opacity 0.72 유지. 이름 아래 별도 상태 문구 없음. +- **파견·휴직 크기 차이**: P3.1 실제값은 카드 **114×54**, 사진 **28×28**이었다. 이번 명시된 최신 요구값인 카드 **108×52**, 사진 **24×44**로 보정했다. 이를 기존 108×52를 그대로 보존한 것으로 표현하지 않는다. 1px Border를 유지하면서 padding을 3px로 조정하여 일반 소형의 4px 사진 원점·32px 텍스트 원점과 일치한다. +- **타이틀↔첫 카드 Gap**: 일반 조직 및 파견·휴직 모두 **8→8px**. 직위↔이름 행 Gap **0px**. +- **Font/name/badge**: Noto Sans KR·기존 호스트 font-face/폰트 파일 유지. 이름은 **홍 길 동**, 직위·뱃지의 글자 사이 공백은 표시에서만 제거한다. 직원 JSON·팝업의 원문 데이터는 변경하지 않는다. + +## 카드 로고 적용 범위 + +렌더링된 43개 직원 카드(대표자 1, 대형 6, 소형 34, 파견·휴직 2) 모두 기존 회사/관계사 PNG를 실제로 표시한다. 원본 SVG·PNG를 수정하지 않고 직원 카드 selector에만 확대를 적용했다. + +- **소형**: 기존 실제 contain 영역 **12×12→18×18px**, 정확히 **1.5배**. 컨테이너는 투명한 1px inset을 포함해 14×14→20×20이다. 카드 기준 image origin **(88, 3)px**. +- **대형·대표자**: 실제 contain 영역 **14×14→21×21px**, 정확히 **1.5배**. 컨테이너 16×16→23×23, image origin **(124, 1)px**. top/right 0으로 옮겨 뱃지와의 여유를 확보했다. +- **파견·휴직**: 실제 contain 영역 **14×14→21×21px**, 정확히 **1.5배**. image origin **(85, 1)px**. 파견의 이전 CSS image box는 14×24였지만 object-fit:contain으로 원본 정사각 로고가 실제 표시된 영역은 14×14였다. 원시 box 높이를 단순 1.5배하는 방식으로 확대하지 않았다. +- 모든 원본은 transparent padding을 포함하므로 이미지 box와 실제 유색 로고 영역은 다르다. 예를 들어 한맥 21px box의 유색 범위는 약 **15.5835×9.5011px**다. **contain 영역과 PNG alpha 영역의 폭·높이 모두 1.5배**를 비교했다. +- 원본 비율 유지, 추가 배경·테두리·그림자 없음. 승인 카드 전체 크기 변경 없음. 로고 alpha 영역과 이름·직위·뱃지·프로필 영역 충돌 **0건**, 카드 밖 잘림 **0건**. 이번 fixture에서 1.5배를 모두 수용하므로 별도 최대 크기 제약은 없다. +- 하단 관계사별 인원 현황의 로고 box·바 전체 좌표·집계 문자열은 이전 측정과 동일하다. 확대 selector는 footer에 적용되지 않는다. + +## 수정 파일 + +기존 파일 **6개**를 변경하고 **2개**를 추가했다. 기존 미커밋 package.json/package-lock.json 및 OrgChart 작업은 보존했다. + +1. [employee-card.ts](../../packages/org-chart/src/core/employee-card.ts): readOnly 상태 카드의 공통 compact 콘텐츠 DOM, 표시 뱃지 공백 제거. 공개 API 변경 없음. +2. [employee-card.css](../../packages/org-chart/src/styles/employee-card.css): 요청 Typography, 일반/상태 공통 스타일, 카드 한정 로고 확대·위치. +3. [org-chart-readonly.css](../../packages/org-chart/src/styles/org-chart-readonly.css): 일반 2Depth/상태 타이틀 Typography 변수 공유, 대형 로고 top 조정. +4. [ui-composition.md](ui-composition.md): P3.2 최신 계약 추가. 과거 절 보존. +5. [card-design-stage2.md](card-design-stage2.md): P3.2 우선 안내 추가. 과거 절 보존. +6. [Core README](../../packages/org-chart/README.md): P3.2 현재 규격·적용 범위 추가. +7. 신규 [verify-p3.2.mjs](../../examples/org-chart/verify-p3.2.mjs): 변경 전 capture, 변경 항목 집중 검사, 정책 측정 보완 모드. +8. 신규 이 보고서. + +관련 Core ESM/IIFE/타입/CSS, Wrapper, HTML example dist, React production dist만 재생성했다. 기존 package manifests/lock·JSON·승인 카드 SVG·로고 PNG·공통 사람 아이콘·Storybook 소스·Theme는 보존한다. 기존 파일 해시와 6개 변경 파일 diff는 [source-changes](../../test-results/org-chart-p3.2/source-changes.json) 및 같은 디렉터리의 diff/에 기록했다. + +## 검증 결과 + +- **PASS**: [집중 브라우저 431/431](../../test-results/org-chart-p3.2/browser.json). HTML HTTP, 직접 file:// classic, React production HTTP, 실제 Storybook의 **1920×1080 / 1440×900 / 1280×800 / 768×1024 / 390×844**, 총 20 captures. 요청 Typography·상태 규격·로고 1.5·카드 크기·Gap·사진 아이콘·집계·하단 로고·경영진 뱃지 미표시를 확인했다. +- **PASS**: 모든 화면의 document scrollWidth=clientWidth, scrollHeight=clientHeight. 모든 카드와 콘텐츠는 Stage 안에 들어간다. 기존 전체 자동 축소 정책을 유지한다. 390px는 기존처럼 전체 비례 축소되어 글자가 작다. +- **PASS**: 대표자·1Depth·소형·파견·휴직 팝업을 네 실행 경로의 데스크톱/390에서 확인했다. 원문 정보·축소하지 않는 팝업·Tab focus trap·ESC 카드 focus 복귀 유지. HTML/React의 1Depth 링크 설정과 실제 Enter navigation 확인. +- **PASS**: 같은 Core의 카드 Typography·위치·크기가 네 실행 경로에서 동일하다. Storybook 사용자 메뉴는 기존 세 개다. Core/Wrapper/Storybook 공개 계약·메뉴를 추가하지 않았다. +- **PASS**: [정책 보완 13/13](../../test-results/org-chart-p3.2/policy-browser.json). 초기 검사 도구의 배경/화살표 selector는 빈 shell/잘못된 attribute를 측정했다. 구현은 그대로 두고 selector만 바로잡아 readOnly·collapse 0·#F5F8FB·연결 path 5·협업 arrow 5를 네 경로의 1920에서 별도 확인했다. 통과한 431건은 다시 실행하지 않았다. +- **PASS**: Core/Wrapper Typecheck, Core ESM/IIFE/타입/CSS Build, Wrapper Build, HTML prepare, React 소비 production Build. 로그는 test-results/org-chart-p3.2/의 typecheck-*.log/build-*.log/prepare-html.log. 기존 inline image chunk 약 2.54MB의 Vite 경고는 유지된다. 신규 브라우저 console/runtime 오류 **0건**. +- **PASS**: 구현 세 파일의 before 대비 코드 검토에서 추가 수정 필요 사항 없음. 실제 [1920 Storybook](../../test-results/org-chart-p3.2/storybook-1920x1080.png), [1920 React](../../test-results/org-chart-p3.2/react-1920x1080.png), [390 Storybook](../../test-results/org-chart-p3.2/storybook-390x844.png) 육안 확인. +- **PASS**: 보존 스냅샷의 기존 **266개 중 260개 동일**, 예정한 **6개만 변경**. Workdesk **45개 모두 동일**, 기존 P3.1 증거 **115개 모두 동일**. JSON·직원 41명·원본 자산 보존. [변경 전 스냅샷](../../test-results/org-chart-p3.2/baseline-preservation.json), [최종 보존 결과](../../test-results/org-chart-p3.2/source-changes.json). +- **PARTIAL / NOT VERIFIED**: [PARTIAL Guard 원본](../../test-results/org-chart-p3.2/guard-react.json)은 staticStatus **NOT APPLICABLE**, status **NOT VERIFIED**, RENDER-002를 유지한다. p1 React bootstrap 소비 graph 검사이며 내부 Core Shadow CSS의 전체 정적 인증을 의미하지 않는다. 원본/결합 CSS는 **STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT PASS / FINAL NOT VERIFIED**. [별도 Runtime](../../test-results/org-chart-p3.2/runtime-evidence.json) 및 [결합 증거](../../test-results/org-chart-p3.2/linked-evidence.json)의 runtimeStatus는 **PASS**, 이름 있는 computed CSS 측정은 PASS다. 원본 미검증을 덮어쓰지 않았다. +- **FAIL**: 최종 요청 범위에서 **0건**. 최초 baseline의 Storybook 연결 실패는 sandbox/server 실행 환경 차이였다. 서버 실행 환경을 맞춘 뒤 baseline 20개 capture를 완료했다. [초기 진단](../../test-results/org-chart-p3.2/baseline-browser-initial-server-isolation.json)은 보존했다. + +## 미해결·검증 한계 + +요청한 fixture의 1.5배 크기 제한이나 구현 미해결 사항은 없다. 다만 임의의 새 데이터/외부 Theme/다른 브라우저, 운영 Workdesk 전체 픽셀 일치, 전체 Storybook production Build, 스크린리더 발화·실물 touch는 **NOT VERIFIED**다. 기존 P3.1 전체 486건을 현재 결과로 재인증하지 않았다. + +PNG alpha bounding box와 텍스트 Range/프로필의 교차는 검사했다. Noto Sans KR의 인접 행 Range 외곽은 line-height보다 커 겹칠 수 있으므로 이를 곧바로 글자 충돌로 판정하지 않았다. 실제 글리프 ink 간 교차를 자동 인증한 것은 아니며 이 항목은 **NOT VERIFIED**다. 대표 화면은 육안으로 읽힘·로고/글자 분리를 확인했다. + +## 작업 시간·Git + +- Started At: 2026-10-12 16:34:08 KST +- Completed At: 2026-10-12 16:51:16 KST +- Actual Duration: 17분 8초 +- Estimated Duration: 25분 +- Variance: 7분 52초 단축 +- 작업 폴더: D:\디자인시스템\HMDesign-OrgChart +- 브랜치: feature/orgchart-package +- 시작/완료 HEAD: d18e3c33801e165e17b6d971e2f6198eed483c3c (동일) +- 원본 Workdesk·운영 HMDesign main 미수정. Stage·Commit·Push·Merge·Reset·Rebase 없음. diff --git a/docs/org-chart/implementation-report-p3.md b/docs/org-chart/implementation-report-p3.md new file mode 100644 index 0000000..b33e392 --- /dev/null +++ b/docs/org-chart/implementation-report-p3.md @@ -0,0 +1,54 @@ +# HMDesign OrgChart P3 — 디자인 및 조회 기능 + +RESULT: **PASS — 보존된 41명 fixture와 P3 변경 범위**. + +HTML·직접 열기 classic·React·실제 Storybook에서 P3 검사 **732/732 PASS**, 신규 브라우저 오류 **0**. 기존 P2·P1 검사를 재실행하거나 기대값을 바꾸지 않았다. Guard의 원본 미검증은 별도로 보존한다. + +1. **P2 대비 수정**: 조회 배경, 하위 연결선, 이름/직위 표시, 직책 뱃지, 타이틀 간격, 하위 조직 제목, 허브, 사진 슬롯, 상태 카드, 조직별 교차표, 관계사 바, 링크, 정보 팝업, 컨테이너 높이 맞춤 및 Storybook 메뉴를 수정했다. 조직 관계·순서·41명 데이터와 승인 자산은 유지했다. +2. **Workdesk 반영**: 읽기 전용 원본 `D:\디자인시스템\gpd-workdesk\sample\조직도_v1.3`의 실제 HTML/CSS/SVG를 확인했다. 배경 `#F5F8FB`, 하위 타이틀의 크기·그라데이션·그림자·글꼴 수치, 직책 뱃지의 팀별 색, 허브 SVG/텍스트, 통계 표 및 관계사 바를 반영했다. [원본 수치·경로 증거](../../test-results/org-chart-p3/workdesk-summary-source-evidence.json)를 보존했다. +3. **카드·타이틀·뱃지·허브**: 대표자 146×74, 1Depth 조직장 146×68, 2Depth 조직장/일반 직원 108×52px 유지. 조회 이름은 `홍 길 동`, 직위와 직책은 원본 문자열이다. 직위와 직책 뱃지를 한 줄에 gap 4px로 놓고 이름과의 gap 0px 유지. 모든 타이틀↔첫 카드 gap 8px. 하위 제목 표면은 114×30.4px이며 부모 팀 색을 상속한다. 허브는 원본 말풍선 SVG, 아이콘 20px/gap 8px, 텍스트 21px/750/24px. 대표자 및 긴 디비전장 뱃지는 동일한 22px 행 내부 하단 정렬로 승인 SVG 우상단 로고와의 충돌을 피했다. +4. **연결선**: 조회 전용에서 엔진의 하위 pseudo 연결선을 숨겼다. 기존 대표자↔허브↔주요 조직 연결 SVG Path 5개, 협업 화살표 5개 및 장식은 유지했다. interactive 연결선·접기 동작은 유지했다. 선을 지우거나 조직 관계를 바꾸지 않았다. +5. **사진/상태**: 사진 없음은 비원형의 빈 직사각형 슬롯으로 표시한다. 실제 직원 사진이나 임의 인물 이미지를 사용하지 않았다. 기존 승인 카드의 예약 너비 36/32/24px를 유지하고 높이를 62/56/44px로 조정했다. 조회 compact 파견·휴직 카드의 이름 하단 상태 문구만 숨기고 그룹 제목과 원본 상태 데이터는 유지한다. +6. **조직별 표 데이터**: 원본 employees에서 `조직 × 직위`를 계산한다. 최상위 직접 소속 1명과 주요 6개 조직의 하위 포함 집계를 분리해 중복 없이 41명. 행 소계는 총괄기획실 1, 사업관리 2, 경영기획 7, 인재성장 7, 통합시스템 10, 디자인기획 10, 솔루션영업 4. 직위 합계는 경영진 1/수석 6/책임 12/선임 11/연구 11. 독립 PositionSummary 공개 API는 유지한다. +7. **관계사 집계**: 기존 회사 로고를 사용한 제목 없는 한 줄 바. 한맥 11, 삼안 11, 장헌 0, 장헌산업 0, 피티씨 9, 한라 0, 바론 10, 총 41명. 바 높이 약 55.4px, 로고 39.3px, 글꼴 19.6px 및 원본 간격·그림자 반영. DOM의 43개 카드는 상태 요약 2개 중복 표시를 포함하며 직원 수는 41명이다. +8. **1Depth 링크**: 공개 `organizationLinks?: Readonly>` 옵션을 Core와 React에 추가했다. 원본 조상 관계가 1Depth이며 URL이 지정된 제목만 링크다. 미지정 제목은 일반 제목. HTTP(S)/상대 URL을 지원하며 다른 scheme·credentials는 거부한다. 기본 실제 URL은 넣지 않았다. 검증에서만 로컬 증명 경로를 사용해 실제 이동·키보드·focus 및 카드 이벤트 분리를 확인했다. +9. **직원 팝업**: 대표자/1Depth/2Depth/일반/파견/휴직 카드 click·Enter·Space로 조회한다. 기존 데이터의 이름·직위·소속·직책·관계사·상태만 표시한다. 닫기/ESC/focus trap/원래 카드 focus 복귀 확인. 팝업은 Stage 밖 native dialog top layer로 표시되며 transform 없음, 데스크톱 폭 420px/390px 화면 폭 358px. 공개 `onEmployeeClick(employee, context)` 지정 시 기본 팝업 대신 호출한다. React callback의 최신 값과 동시 이벤트를 검증했다. 기존 tooltip은 팝업 직전 정리한다. +10. **Scale/Overflow**: 기준 디자인 1920×914.5px. 실제 host·조상 높이·padding·max-height와 viewport를 사용하며 임의 Header 48px 차감 없음. 네 경로 모두 같은 측정값이며 문서 client/scroll 폭·높이 동일, 카드/주요 영역 사각형 충돌 및 Stage 밖 콘텐츠 0건. + + - 1920×1080: scale 0.9833333333, Stage 1888×899.2584px, 문서 1920×1080. + - 1440×900: scale 0.7333333333, Stage 1408×670.6334px, 문서 1440×900. + - 1280×800: scale 0.6500000000, Stage 1248×594.4250px, 문서 1280×800. + - 768×1024: scale 0.3833333333, Stage 736×350.5583px, 문서 768×1024. + - 390×844: scale 0.1864583333, Stage 358×170.5161px, 문서 390×844. + - 추가 높이 조건: 1920×650 scale 0.6754098256, Stage 높이 617.6623px. 고정/max-height 400px host scale 0.4371584574, Stage 높이 399.7814px. 재부착된 400px host의 외부 20px/내부 12px padding에서 가용 높이 336px, scale 0.3672130903, Stage 높이 335.8164px. + + Stage의 layout scroll extent는 transform 전 1920×915px로 남는다. 실제 변환된 모든 콘텐츠 경계가 가용 영역 안에 들어오는 것을 별도로 확인했으므로 `overflow:hidden`만으로 잘림을 숨긴 결과가 아니다. offsetHeight 915는 CSS 높이 914.5의 정수 반올림이며 높이 계산은 소폭 안전 여유를 둔다. + +11. **Storybook**: 사용자 메뉴는 `Logo · Personnel Summary` / `Department Colors` / `Full Composition` 3개. 통합 페이지에서 기존 Logo·PositionSummary·AffiliationSummary Core를 각각 렌더링한다. 기존 독립 검증 Story는 internal로 보존했다. 실제 6041 index·manager·페이지를 확인했으며 메뉴 통합과 Full Composition 배치는 분리했다. +12. **수정 파일**: 기존 소스/문서 27개와 신규 구현·검사·보고 파일은 아래 목록. 재생성된 dist 및 정확한 시작 해시 차이는 [전체 파일 기록](../../test-results/org-chart-p3/source-changes.json)에 있다. 기존 package.json/package-lock.json의 미커밋 변경은 추가로 수정하지 않았다. + + - Core: `packages/org-chart/src/core/{employee-card,status-summary,render,types,index,org-chart-content,organization-totals,affiliation-bar}.ts`, `src/index.ts`, `src/styles/{employee-card,org-chart-readonly,org-chart}.css`, `build.mjs`, `README.md`. + - 신규 Core: `packages/org-chart/src/core/employee-info-dialog.ts`, `src/styles/employee-info-dialog.css`, `src/styles/org-chart-p3-summary.css`. + - React: `packages/org-chart-react/src/{OrgChart.tsx,types.ts}`, `README.md`. + - 예제: `examples/org-chart/{p1-app.js,p1.css}`, 신규 `verify-p3.mjs`; `examples/org-chart-react/{p1.tsx,p1.css,README.md}`. + - Story: `src/org-chart/{org-chart-p1.stories.tsx,org-chart-stories.css}`. + - 문서: `docs/org-chart/{integration,ui-composition,card-design-stage2}.md` 및 이 P3 보고서. P1/P2 보고서는 수정하지 않았다. + - 산출물: Core/Wrapper/HTML/React dist, `test-results/org-chart-p3`의 브라우저 JSON·원본 Guard·별도 runtime/linked evidence·진단 이력·캡처·시작/변경 해시. `assemble-evidence.mjs`는 저장된 증거만 연결하며 테스트를 재실행하지 않는다. + +13. **HTML·React 검증**: Core/Wrapper typecheck, Core ESM/IIFE/타입/CSS 및 Wrapper build, HTML prepare, React 소비 예제 production build PASS. 공통 Core의 geometry·표·카드가 HTML HTTP/file classic/React HTTP/Storybook에서 일치했다. Chrome 155.0.8059.39의 5개 화면, 링크/callback/키보드, 6종 카드×2화면×4경로 팝업 48개 측정, 높이 제한 조건을 포함해 **732/732 PASS**. 원본 이미지 inline 때문에 React 약 2.54MB chunk 경고는 유지한다. 실제 테스트는 live Storybook 6041이며 오래된 `storybook-static` 결과를 최종 근거로 사용하지 않는다. +14. **기존 PASS/신규 FAIL**: 승인 SVG·로고·장식·41명 fixture·P1/P2 보고서·기존 검사 등 보존 대상 38개 해시 동일. 관련 interactive/독립 API/공통 Core 회귀는 P3 검사에서 PASS. P2 484건과 오래된 verify/verify-p1 suite 전체는 재실행하지 않았으므로 전체 기존 PASS를 재인증하지 않는다. 알려진 과거 FAIL 기록·기대값을 그대로 보존했다. 초기 P3 522건/63 FAIL과 중간 554건/10 FAIL JSON도 보존했으며 최종 신규 FAIL 0. 제품의 focus 복귀/뱃지 겹침/재부착 host padding 오류 및 검사 환경의 오래된 Storybook 경로·선택자·Chrome 0px letter-spacing 직렬화 근거를 수정했다. 실패를 필터링하지 않았다. +15. **남은 시각적 차이**: 원본 Pretendard Variable 대신 기존 승인 글꼴을 유지한다. 원본 사진 55×76/54×70/38×52는 승인 카드보다 커서 그대로 복사하지 않고 슬롯 크기를 맞췄다. 긴 뱃지 행 정렬은 로고 충돌 방지를 위한 조정이다. 표의 실제 열/행은 현재 fixture를 기반으로 하며 원본 API의 직위 목록은 제공되지 않았다. 긴 `경영진`/`통합시스템 Div` 줄바꿈으로 표 높이는 약 200.375px. 바 폭은 기준 좌표에 맞춰 1806px(원본 약 1805.7px). 최종 Storybook 1920/390 및 HTML 1920 캡처를 육안 확인했다. 모바일은 전체 화면 비례축소로 글자가 작지만 정보 팝업은 축소하지 않는다. +16. **Guard/NOT VERIFIED**: PARTIAL Guard를 최종 React bootstrap에 1회 실행. 원본 staticStatus `NOT APPLICABLE`, status `NOT VERIFIED`, `RENDER-002` 보존. 원본 CSS STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT PASS / FINAL NOT VERIFIED, checks `[]`. [별도 runtime](../../test-results/org-chart-p3/runtime-evidence.json)은 동일 소스·계약 fingerprint와 브라우저 증거 SHA로 연결했다. [결합 증거](../../test-results/org-chart-p3/linked-evidence.json)는 Runtime PASS·명시한 computed CSS PASS이나 Guard의 정적 적용 범위/스타일 미검증 때문에 전체 status NOT VERIFIED를 유지한다. Core Shadow CSS 전체 Guard 인증, 실제 운영 Workdesk 픽셀 일치, 자동 glyph/곡선 교차 검사, 모든 임의 데이터/테마/브라우저, screen reader 발화·실물 touch, 전체 Storybook production build는 NOT VERIFIED. +17. **Git/원본 보존**: `D:\디자인시스템\HMDesign-OrgChart`, `feature/orgchart-package`, HEAD `d18e3c33801e165e17b6d971e2f6198eed483c3c`. Workdesk 원본과 HMDesign main을 수정하지 않았다. 기존 미커밋 작업을 보존하고 Stage·Commit·Push·Merge·Reset·Rebase를 수행하지 않았다. + +검증 근거: [최종 browser JSON](../../test-results/org-chart-p3/browser.json), [1920 캡처](../../test-results/org-chart-p3/storybook-1920x1080.png), [390 캡처](../../test-results/org-chart-p3/storybook-390x844.png), [원본 Guard](../../test-results/org-chart-p3/guard-react.json), [초기 진단](../../test-results/org-chart-p3/browser-initial.json), [중간 진단](../../test-results/org-chart-p3/browser-after-card-fixes.json). + +미리보기: [Storybook Full Composition](http://127.0.0.1:6041/?path=/story/orgchart--full-composition). + +작업 시간: + +- Started At: 2026-10-12 15:19:27 KST +- Completed At: 2026-10-12 15:56:54 KST +- Actual Duration: 37분 27초 +- Estimated Duration: 45~60분 +- Variance: 예상보다 7분 33초~22분 33초 단축 diff --git a/docs/org-chart/implementation-report-p5-1a.md b/docs/org-chart/implementation-report-p5-1a.md new file mode 100644 index 0000000..10b4664 --- /dev/null +++ b/docs/org-chart/implementation-report-p5-1a.md @@ -0,0 +1,74 @@ +# HMDesign OrgChart P5-1A 구현 및 커밋 준비 결과 + +RESULT: **PASS — P5-1A 요청 범위**. Fixed/Auto 렌더링 경계를 분리하고 확장형 초기 Storybook 화면을 추가했다. 자동 재귀 배치, 접기·펼치기, Zoom/Pan은 구현하지 않았다. + +## 작업 환경과 시간 + +- 경로: `D:\디자인시스템\HMDesign-OrgChart` +- 범위: Core·React 패키지와 요청된 Storybook 초기 화면. 운영 main 수정 없음. +- 브랜치: `feature/orgchart-package` +- HEAD: `d18e3c33801e165e17b6d971e2f6198eed483c3c` +- Origin: `https://gitea.hmac.kr/b25027/HMDesign.git` +- 실제 원격 `main`·`feature/orgchart-package` SHA 조회 결과: 위 HEAD와 동일. +- Started At: 2026-10-12 17:23:00 KST +- Completed At: 2026-10-12 17:42:41 KST +- Actual Duration: 19분 41초 +- Estimated Duration: 약 45분 (분석 10분, 구현 20분, 검증·커밋 준비 15분) +- Variance: 예상보다 25분 19초 짧음 + +## 구현 결과 + +공개 `OrgChartOptions.layoutMode?: 'fixed' | 'auto'`를 추가했다. 생략 시 `fixed`이며 기존 `displayMode`와 독립적이다. Fixed는 기존 `createOrgChartContent`, Workdesk 좌표·아트워크·크기 맞춤 경로를 그대로 사용한다. 기존 세 사용자 Story 파일도 시작 스냅샷과 바이트 단위로 동일하다. + +Auto는 `expandedPreviewRoots` → 공유 `engineTree`/dabeng engine → `createExpandedOrgChartContent` 경계로 초기 그룹을 표시한다. 정규화된 실제 hierarchy의 직계 그룹과 원본 `parentId`를 보존한다. 하위 데이터는 삭제하지 않고 이번 표시만 보류하며 집계에는 남긴다. 새로운 presenter는 `.hm-orgchart-expanded` 아래에서 일반 흐름·줄바꿈·스크롤을 사용한다. Fixed Stage, scale observer, 고정 좌표는 사용하지 않는다. + +직원·대표자 카드는 기존 `createEmployeeCard`, 승인 SVG 대형/소형/대표자 자산, 사진 없음 아이콘, Noto Sans KR 및 Typography를 재사용한다. 초기 fixture는 대표자와 기존 부서 ID의 1Depth 그룹 3개, 조직장 3명·직원 6명을 표시한다. 대표자 146×74, `대표이사` / `홍 길 동`, 뱃지·상태·속성 wrapper 0개를 확인했다. 대형 카드 146×68, 소형 카드 108×52도 확인했다. 소형 카드 재사용은 2Depth 자동 계층 배치 완료를 뜻하지 않는다. + +회사 설정 `companyId`는 Auto에서만 `getCompanyColorPreset`을 조회한다. 기본값 `hanmac`; 미등록 ID는 TypeError로 거부한다. Fixed는 회사 옵션을 무시하고 조직별 Gradient를 유지한다. 실제 ID와 Main은 `hanmac` #CB2E28, `saman` #F08300, `jangheon-industry` #4580A6, `jangheon` #1D418D, `ptc` #84338E, `halla` #0068B7, `baron` #0C367F다. 보조 컬러 전체는 기존 프리셋에서 조회하며 노드의 indexed secondary 변수로 연결한다. 기존 프리셋 파일은 변경하지 않았다. + +React는 `layoutMode`·`companyId`를 동일 Core에 전달하고 설정 변경 시 기존 lifecycle 계약으로 인스턴스를 재생성한다. 데이터 검증·JSON Source·엔진·카드·팝업·update/reload/destroy는 Vanilla와 공유한다. React나 Story에 레이아웃 알고리즘을 구현하지 않았다. Auto 초기 preview의 카드 표시 정책과 후속 기능 보류는 명시적이며 `displayMode` 공개 값은 변경하지 않는다. + +## Storybook과 재사용 판단 + +- [Expanded OrgChart](http://127.0.0.1:6052/?path=/story/orgchart--expanded-org-chart) +- [직접 화면](http://127.0.0.1:6052/iframe.html?id=orgchart--expanded-org-chart&viewMode=story) +- 기존 `Logo · Personnel Summary`, `Department Colors`, `Full Composition`을 보존하고 네 번째 공개 화면을 추가했다. 내부 Story ID·태그는 유지했다. +- 회사 선택에는 실제 공개 HM `Label`, `Select`, `SelectTrigger`, `SelectValue`, `SelectContent`, `SelectItem`을 렌더링한다. Label ID와 control의 접근성 이름을 연결했다. +- FilterBar 미사용: 목록 검색·조회 조건 요구가 없고 단일 preset 선택만 필요하므로 기존 Select composition을 사용했다. +- 페이지 Navigation 미적용: 요청된 Component Storybook preview이며 신규 업무 페이지·GNB/LNB/Dashboard를 만들지 않았다. +- 팝업은 기존 Core native dialog를 재사용하며 Enter·Tab·Escape 및 카드 focus 복귀를 확인했다. + +## 변경 범위 통합 검증 + +- Core Typecheck·Build: PASS (`npm.cmd run typecheck:org-chart`, `npm.cmd run build:org-chart`) +- React Typecheck·Build: PASS (`npm.cmd run typecheck:org-chart-react`, `npm.cmd run build:org-chart-react`) +- Storybook 포함 앱 Typecheck: PASS (`node node_modules/typescript/bin/tsc -p tsconfig.app.json --incremental`) +- 집중 단위 테스트: **4/4 PASS**, hierarchy 보존·독립 root·기본 호환성·옵션 오류 경계. +- 집중 브라우저 검증: **89/89 PASS**, 콘솔 오류·경고 0. [원본 JSON](../../test-results/org-chart-p5-1a/browser.json), [검증 소스](../../examples/org-chart/verify-p5-1a.mjs). +- 기존 빌드를 `baseline-dist`에 보존해 Fixed 기본값 및 명시적 Fixed의 카드 좌표·자산·내용·Typography·헤더 Gradient·집계와 팝업을 비교했다. 이번 최소 회귀 범위에서 변화 없음. +- 실제 React/Vanilla의 카드·자산·폰트가 일치했다. 7개 회사 선택 후 Main·보조 컬러 연결을 확인했다. Auto update/reload/destroy의 stale DOM·중복 없음, 후속 3Depth 데이터는 보존되며 아직 배치하지 않음을 확인했다. +- 실제 신규 Storybook을 390/768/1280/1440px에서 검사했다. 페이지 가로 넘침·카드 겹침 없음; 작은 폭에서 그룹이 일반 흐름으로 줄바꿈된다. 문서·Core viewport의 client/scroll 크기를 원본 JSON에 기록했다. +- `git diff --check`: PASS. P5-1A 대부분이 untracked이므로 시작 스냅샷 대비 별도 변경 목록과 공백 검사를 함께 기록한다. +- 최종 소스 리뷰: Fixed 분기·lifecycle·CSS 격리·요구 범위에서 수정이 필요한 버그 없음. + +Guard는 최초 통합 Story 검사에서 기존 private Core 참조의 동적 토큰을 해석하지 못해 TOKEN-001 FAIL을 기록했다. 진단을 [보존](../../test-results/org-chart-p5-1a/guard-initial-truncated.txt)했고 기존 코드를 임의 수정하지 않았다. 신규 화면을 독립 public-API Story로 분리한 후 해당 변경 파일만 PARTIAL 검사했다. [최종 Guard](../../test-results/org-chart-p5-1a/guard-expanded.json)는 원본 status **PASS**, staticStatus **PASS**, runtimeStatus **NOT VERIFIED**다. + +CSS styleEvidence: STATIC **PASS**, RUNTIME **NOT VERIFIED**, DESIGN_INTENT **NOT VERIFIED**, FINAL **NOT VERIFIED**. [결합 증거](../../test-results/org-chart-p5-1a/guard-combined.json)는 해시 연결 `linked=true`, 정적 PASS이지만 Storybook profile의 Production `--result` bootstrap 증명 및 위치별 CSS 승인 증거가 없어 **NOT VERIFIED**다. styleVerification도 STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT NOT VERIFIED / FINAL NOT VERIFIED다. 실제 브라우저 89 PASS로 원본 Guard나 디자인 의도 승인을 덮어쓰지 않는다. 초기 디자인의 사용자 최종 승인은 미검증이다. + +## 변경 파일과 커밋 준비 + +시작 기존 P0~P5-0 변경은 **163개**(tracked 수정 2개 + untracked 161개)였다. 현재 전체 미커밋 파일은 **172개**다. P5-1A는 기존 파일 수정 9개와 신규 9개이며 삭제된 baseline 파일은 없다. [전체·단계별 파일 목록, 의존성, 제외 대상과 추천 커밋 분리](commit-preparation-p5-1a.txt)를 제공한다. + +기존 파일 수정: Core `README.md`, `build.mjs`, `src/index.ts`, `src/core/index.ts`, `src/core/render.ts`, `src/core/types.ts`, `src/styles/org-chart.css`; React `README.md`, `src/OrgChart.tsx`. + +신규: Core `src/core/expanded-content.ts`, `src/core/expanded-layout.ts`, `src/core/expanded-layout.test.ts`, `src/styles/org-chart-expanded.css`; `src/org-chart/org-chart-expanded.stories.tsx`; `examples/org-chart/data-expanded.json`, `examples/org-chart/verify-p5-1a.mjs`; 이 보고서와 `commit-preparation-p5-1a.txt`. + +HEAD에는 두 OrgChart 패키지 자체가 없으므로 P5-1A 파일만 단독 커밋하면 카드·프리셋·Core·wrapper·workspace 의존성이 누락된다. 권장 방식은 저장한 **163개 시작 소스 스냅샷으로 P0~P5-0 baseline 커밋**을 구성하고, 이후 **P5-1A 변경 커밋**을 분리하는 것이다. 현재 untracked 파일을 단순히 경로별 Stage하면 과거 내용과 P5-1A를 분리할 수 없다. 시작 스냅샷·SHA-256 목록은 `test-results/org-chart-p5-1a/baseline-source/`, `baseline-files.json`에 보존했다. 이후 승인 단계에서 정확한 patch·staged diff를 검토해야 한다. 과거 P0/P1별 소유 파일은 Git만으로 확정할 수 없다. + +`dist`, `storybook-static`, `test-results`, `node_modules`, `_local`, log/cache/tsbuildinfo는 커밋에서 제외한다. authored test·verify·asset 생성 소스는 임시 출력과 구분한다. 기존 미커밋 작업 및 과거 증거는 보존했다. **Stage·Commit·Push·Merge는 실행하지 않았다.** + +## 남은 항목과 NOT VERIFIED + +P5-1B: 동일 Depth 가로 배치, 부모 아래 하위 그룹 배치, 재귀 3Depth 이상, 조직 증가 시 재계산, 카드 겹침 방지 알고리즘. 접기·펼치기·Zoom/Pan은 후속 범위다. P5-1A의 일반 그룹 표시를 자동 배치 완성으로 판정하지 않았다. + +전체 과거 P2/P3 suite, Storybook 전체 production build, 임의 조직 데이터·모든 테마·다른 브라우저, 실기기 touch·스크린리더 발화·SSR/운영 연동, 최종 디자인 승인·CSS site별 결합 검증은 NOT VERIFIED다. P5-1B를 자동으로 시작하지 않았으며 사용자 검토를 기다린다. diff --git a/docs/org-chart/implementation-report-p5-23.md b/docs/org-chart/implementation-report-p5-23.md new file mode 100644 index 0000000..cf17a27 --- /dev/null +++ b/docs/org-chart/implementation-report-p5-23.md @@ -0,0 +1,103 @@ +# HMDesign OrgChart P5-2 + P5-3 구현 보고 + +RESULT: **PARTIAL** — 요청된 기능 구현·변경 범위 검증은 PASS. 참고 SVG 두 개의 원본 일치와 최종 카드 디자인 승인, 물리적 터치·스크린리더 검증은 NOT VERIFIED. + +## 작업 환경 및 시간 + +- 실제 폴더: `D:\디자인시스템\HMDesign-orgchart-package` +- 브랜치: `feature/orgchart-package` +- HEAD: `a92913d6bcc950dacc6fdf21cf3b0843ee79416d` (시작/완료 동일) +- origin: `https://gitea.hmac.kr/b25027/HMDesign.git` +- P5-1B 채팅 완료 상태와 미커밋 변경을 확인한 뒤 확장. 시작 시 변경 12파일·신규 검증 스크립트 1개를 보존. 시작 diff는 `test-results/org-chart-p5-23/p5-1b-before.patch`, 비교 dist는 같은 폴더의 `baseline-dist`. +- 작업 범위: BOTH — 공통 Core·HTML·React와 요청된 기존 Expanded Story. 운영 프로젝트·main 수정 없음. 신규 페이지 Navigation 없음(임베드 조직도 컴포넌트). +- Started At: 2026-10-12 19:27:14 KST +- Completed At: 2026-10-12 19:52:30 KST +- Actual Duration: 25분 16초 +- Estimated Duration: 60–90분 (확인 10분 / 구현 35–55분 / 검증 15–25분) +- Variance: 최초 예상 하한보다 34분 44초, 상한보다 64분 44초 빠르게 완료. + +## 기능 결과 + +| 항목 | 결과·근거 | +|---|---| +| 회사 전체·단일·복수·해제 | PASS. 독립 Root Forest를 가로 배치. undefined=전체, []=빈 상태. 원본 직원 수 불변, 선택 인원 별도 표시. | +| 회사 ID·계층 | PASS. 선택적 `Organization.companyId`, 부모 상속, 다른 회사 부모·자식 연결 오류. 기존 JSON은 알려진 Root leader affiliation 또는 기존 companyId fallback. | +| 회사별 최상위 | PASS. 기존 Root·Employee 그대로 사용. 직위·이름·조직명 override, card/titleOnly/hidden, employeeId/null. 예시는 회장·사업총괄·본부장 등과 최상위 직원 없는 바론. 이미지 URL 검증과 표시 경로 지원; 별도 프로필 이미지 변경의 시각 검증은 NOT VERIFIED. | +| 그룹 타이틀 | PASS. 전체 타이틀 native button, Enter/Space, aria-expanded, 빈 그룹 disabled. 조직장 유지·직원/하위 조직 숨김. 부모 재확장·회사 재선택 시 유효한 자식 상태 보존. 별도 링크 아이콘으로 이동. | +| 재귀 자동 배치 | PASS. dabeng 6.0.0 Forest 유지. 형제 가로 배치, 자신의 부모 아래 자식, 동일 Depth 높이 예약. Small 0–2, Medium 0–3, Large 0–4 Depth. 카드 겹침 0, 실제 DOM parentId 일치. | +| Zoom/Pan/Fit | PASS. 기본 5–300%, 설정 min/max/step. 확대/축소/100%/Fit, 현재 배율. Ctrl/Command+휠, 드래그, 방향키. 일반 휠에는 preventDefault하지 않음. 직원 카드 드래그 팝업 억제. 합성 touch PointerEvent 경로 PASS; 물리적 터치는 NOT VERIFIED. | +| 회사 테마 | PASS. 기존 7개 ID·메인 색상 모두 정확. P5-0 파일 변경 없음. 보조색 변수 보존. 삼안·장헌산업 제목은 대비를 위해 중립 짙은 텍스트. | +| 직원 Border | PASS. 직원 회사 affiliation의 기존 preset(미등록 affiliation은 조직 회사)을 CSS 변수로 참조. 실제 SVG endpoint와 top/left/right 면 일치, 나머지 중립. 3방향 실제 예시 모두 확인. inset accent 두께 변수로 외형 크기 보존. | +| 확장형 카드 | PASS(기능). 공통 비원형 사람 아이콘, 실제 이름·직위, Noto Sans KR, 홍길동→홍 길 동. Auto 전용 Width/Height/Padding/Border/Radius/사진/Font/간격 변수. 승인 SVG 재현은 NOT VERIFIED. | +| 직원 정보 팝업 | PASS. 공통 Core dialog, 변환 Forest 밖, focus·Escape 복귀. 표시 override에도 원본 직원 identity·callback 데이터 유지. | +| Fixture | PASS. Small 20명·3회사, Medium 100명·5회사, Large 300명·7회사. 고유 Employee ID, 유효 parentId·leader membership, 중복 없는 조직장 집계. 고정 JSON·결정적 생성 스크립트. 실제 개인정보 없음. | +| HTML·React | PASS. 실제 HTML 예시와 StrictMode React Story의 ID·직위·이름·크기·회사·색·방향 일치. 필터 로직은 Story가 아닌 Core. DataSource 계약 유지, API/DB 구현 없음. | +| Fixed | PASS. P5-1B 시작 dist와 현재 dist의 카드 좌표·크기·SVG·이름·직위·색 정확 비교 및 공통 팝업 최소 회귀. Fixed CSS·SVG·Full Composition Story 변경 없음. | +| Storybook | PASS. 사용자 메뉴 4개 유지. Expanded 하나에 20/100/300명·회사 체크박스·전체/해제 및 공통 확대 도구. | + +## 실제 사용 요소·선택 근거 + +React Story는 공개 `@hm-design/ui`의 Button, Checkbox, Label, Select/Trigger/Value/Content/Item을 실제 렌더링한다. Label은 실제 control ID와 연결한다. FilterBar를 검토했으며 이번 도구는 검색·조회 조건이 아니라 Fixture 전환과 즉시 회사 표시 상태이므로 기존 Pattern의 목록 검색 구조를 강제하지 않았다. 체크박스 복수 선택과 Select를 직접 조합한다. + +공통 Core는 React 의존성 없는 명시적 HTML Runtime adapter다. 그룹·Zoom native button과 HTML 예시의 native select/checkbox/label은 이 계약의 접근성 요소이며 HM React UI 모방이 아니다. Core·Wrapper·DataSource 역할을 유지한다. + +## 성능 측정 + +Windows 로컬 Headless Chrome, 1440×1000, 실제 300명 JSON. 아래는 측정치이며 임의 성능 기준에 따른 PASS 판정이 아니다. + +| 처리 | 실측 | +|---|---:| +| Small 최초 mount (모듈·CSS + 2 animation frames) | 93.3ms | +| Medium 최초 mount (동일 방식) | 93.9ms | +| Large 최초 mount (동일 방식) | 195.6ms | +| 300명 그룹 접기 (native click + 2 frames, 두 측정) | 222.7 / 281.9ms | +| 자동화 Enter/Space 접기·펼치기 (조작·focus·프레임 대기 포함) | 317.9–656.8ms | +| 300명→단일 회사 선택 + 재배치 2 frames | 42.0ms | +| Zoom + 60회 panBy + 1 frame (두 측정) | 20.5 / 0.9ms | + +대용량 전체 DOM 누락·중복·카드 겹침 없음. 위 pan 일괄 호출은 실기기 프레임율/FPS를 뜻하지 않는다. 최초 mount 수치에는 초기 스크립트 경로 비용이 포함되며 다운로드 성능 인증이 아니다. 별도 가상화·엔진 교체·신규 라이브러리 설치 없음. + +## 변경 범위 검증 + +- Core typecheck PASS; Core ESM/IIFE 및 선언 build PASS. +- React Wrapper typecheck·선언/ESM build PASS; 앱/Story TypeScript 검사 PASS. +- Expanded Story만 소비하는 독립 React bootstrap의 Vite production build PASS. 500kB 초과 chunk 경고 있음(기존 Core 이미지 자산 포함); 빌드 오류 아님. 결과: `test-results/org-chart-p5-23/react-build/dist/index.html`. +- 영향 범위 단위 테스트 **7/7 PASS**. Node 20의 globSync 부재로 최초 실행 불가였으며, 제공된 Node 런타임으로 정상 실행. 기존 전체 테스트를 반복하지 않음. +- 브라우저 **96/96 PASS**, 신규 console/runtime error 0. 실행기 선택자 오류를 수정하고 미완료 범위만 이어서 확인. 390/768/1280/1440px 가로 넘침·핵심 도구 확인. +- `git diff --check` PASS. 원래 CRLF 전환 안내는 공백 오류와 구분. +- 원본 Guard: PARTIAL/STORYBOOK profile, staticStatus **PASS**, 원본 status **PASS**, issues 0. Runtime은 자체 수행하지 않으므로 **NOT VERIFIED**. +- CSS 원본 styleEvidence: STATIC **PASS**, RUNTIME **NOT VERIFIED**, DESIGN_INTENT **NOT VERIFIED**, FINAL **NOT VERIFIED**. +- 별도 browser/runtime 증거 연결: fingerprint 일치 linked=true. 결합 status와 styleVerification은 **NOT VERIFIED** 유지. STORYBOOK profile에는 Production `--result` bootstrap 판정이 없으며 최종 디자인 승인·개별 CSS 적용 전 상태 검증도 미완료. 직접 실행된 React 브라우저 결과 PASS와 혼동하지 않음. +- Guard는 Story host 소비 범위이며 Core 내부의 모든 CSS를 승인하는 검사가 아니다. Core 연결 방향·색상·좌표는 별도 브라우저 근거에 기록. +- P5-1B 879/879 PASS 기록 보존. 과거 전체 회귀·기존 미검증 결과를 새 결과로 덮어쓰지 않음. + +자동 승인 검토는 `0.0.0.0` Storybook 실행을 LAN 노출 위험으로 거부했다. `127.0.0.1`로만 바인딩한 안전한 실행은 승인·검증됐으며, 초기 sandbox 서버는 종료했다. 기능·작업에 남은 승인 차단 없음. + +## 실행 URL·캡처·원시 근거 + +- [Expanded OrgChart](http://127.0.0.1:6054/?path=/story/orgchart--expanded-org-chart) +- [HTML 예시](http://127.0.0.1:4189/expanded.html) +- `test-results/org-chart-p5-23/browser.json`, `guard.json`, `runtime.json`, `combined.json` +- `storybook-large-1440.png`: 7회사·300명 Fit 전체 화면. +- `storybook-small-hanmac-100.png`: 한맥기술·100%·상단/좌측/우측 Border와 연결선. +- `storybook-{390,768,1280,1440}.png`: 반응형 실제 React 화면. +- `html-{small,medium,large}-fit.png`, `fixed-1440.png`: HTML Fixture 및 최소 Fixed 회귀. + +## 이번 추가·수정 파일 + +기존 P5-1B 변경 전체를 이번 변경으로 주장하지 않는다. P5-1B의 `data-expanded.json`, `expanded-layout.test.ts`, `verify-p5-1b.mjs`는 그대로 보존했다. + +- Core: `packages/org-chart/src/core/{index,types,render,employee-card,expanded-content,expanded-layout}.ts`; 신규 `expanded-company.ts`, `expanded-company.test.ts`, `expanded-viewport.ts`. +- 데이터 계약: `packages/org-chart/src/data/{types,validate}.ts`, `packages/org-chart/src/index.ts`, `docs/org-chart/data-contract.md`. +- 스타일: `packages/org-chart/src/styles/org-chart-expanded.css`. +- React: `packages/org-chart-react/src/OrgChart.tsx`. +- Story: `src/org-chart/org-chart-expanded.stories.tsx`, `org-chart-stories.css`. +- HTML/Fixture: `examples/org-chart/prepare.mjs`; 신규 `expanded.html`, `expanded-app.js`, `expanded.css`, `create-expanded-fixtures.mjs`, `data-expanded-small.json`, `data-expanded-medium.json`, `data-expanded-large.json`, `verify-p5-23.mjs`. +- 문서: `packages/org-chart/README.md`, `packages/org-chart-react/README.md`, 이 보고서. +- generated dist·검증 JSON·스크린샷·비교본은 기존 gitignore의 test-results/dist 경로. 소스 Stage 없음. + +## 남은 미검증·Git 상태 + +참고 `Group 3391.svg`, `Group 1171288761.svg`의 원본 구조·최종 디자인 일치, 물리적 터치, 스크린리더 음성, 다른 브라우저·운영 소비자, 전체 heap/GC 회수, 전체 과거 회귀는 NOT VERIFIED. 최상위 profileImage 변경의 별도 시각 검증도 NOT VERIFIED. 기존 미검증을 기능 PASS로 제거하지 않았다. + +**Commit·Push·Merge·Stage 미진행. Reset/Rebase/Force Push/Clean 미진행. main·운영 프로젝트·Fixed SVG/CSS 미수정. P5-4 미시작.** diff --git a/docs/org-chart/implementation-report-p5-4.md b/docs/org-chart/implementation-report-p5-4.md new file mode 100644 index 0000000..bbf2e51 --- /dev/null +++ b/docs/org-chart/implementation-report-p5-4.md @@ -0,0 +1,123 @@ +# HMDesign OrgChart P5-4 완료 보고 + +RESULT: **PASS — 변경된 P5-4 요구사항의 구현·변경 영향 범위.** +전체 접근성 인증·실기기·모든 CSS 의도 검증은 **PARTIAL / NOT VERIFIED**이며 아래에 구분한다. + +## 확정 범위와 환경 + +- 실제 작업 폴더: `D:\디자인시스템\HMDesign-orgchart-package` +- 브랜치: `feature/orgchart-package` +- 시작/완료 HEAD: `a92913d6bcc950dacc6fdf21cf3b0843ee79416d` +- origin: `https://gitea.hmac.kr/b25027/HMDesign.git` +- 범위: BOTH — 기존 Vanilla/React 공통 Core와 Expanded Story의 안내·문서 보완. +- 사용자 후속 확정: 현재 Expanded 카드 외형·크기·사진·직위·이름 구조를 유지한다. + `Group 3391.svg`, `Group 1171288761.svg`는 검색·적용·용도 배정 대상에서 제외하며, + SVG 미확보는 완료 차단 사유가 아니다. SVG 역할을 추정하지 않았다. +- 기존 P5-1B 879/879 및 P5-2·3 96/96·단위 7/7 기록은 과거 근거로 보존한다. + 이번 결과는 그 전체 검사를 다시 실행했다는 의미가 아니다. + +## 구현과 보존 결과 + +| 항목 | P5-4 결과 | +|---|---| +| 카드 외형·크기 | PASS. 시작 dist와 Small 20명의 카드 size/photo/font/color/border 정확 비교 일치. 기본 일반 직원 width 114/min-height 54px, 긴 가상 이름 실측 114×67px. 조직장·최상위 width 154/min-height 72px. 변경 전후 동일. | +| 사진·직위·이름 | PASS. 비원형 36×36 슬롯·공통 사람 SVG·Noto Sans KR computed family·직위 10px/이름 13px 유지. 표시용 직위 공백만 제거, raw 데이터 불변·원문 aria-label 제공. 가상 SVG profile URL override와 error-event placeholder 복귀 확인. 실제 사람 사진은 미사용. | +| 그룹 타이틀 | PASS. 기존 전체 버튼·상태 기호·aria-expanded·키보드 Focus·별도 링크 유지. 활성 타이틀 및 링크에 Hover 배경 피드백 추가. Enter 접힘·Space 펼침·rerender 후 focus 복귀 확인. | +| 회사 컬러·Border | PASS. 기존 7개 preset·회사 ID 불변. top/left/right 실제 endpoint 오차 1px 미만, 강조 한 면·나머지 중립·외형 크기 유지. 연결 없는 카드용 `none`은 지원 API가 아니며 별도 PASS 처리하지 않음. | +| 자동 배치·연결선 | PASS. 기존 dabeng 재귀 엔진·실측 ResizeObserver 유지. Medium/Large 직원 중복·누락·카드 겹침 없음, DOM 부모 관계와 부모 아래 자식 확인. 엔진 재작성·간격/색/크기 재작업 없음. | +| 다중 회사 | PASS. Large에서 두 회사 독립 Root 및 단일 선택 확인. 원본 JSON 및 source 인원 불변. 전체/해제·자식 collapse state 보존 등 변경 없는 상세 동작은 P5-2·3 근거 재사용. | +| Zoom/Pan/Fit | PASS. 기존 도구와 배율 범위 유지. 새 Tab focus가 화면 밖 카드/타이틀에 도달하면 배율 유지·Pan으로 노출, hidden scrollLeft/Top 0 확인. 실제 mouse Pan 및 Medium/Large Fit에 전체 카드 포함 확인. | +| 드래그·키보드 | PASS. 드래그의 pointer click 억제가 다음 Enter/Space 활성화를 막지 않도록 수정. 이후 Space 직원 팝업 활성화 확인. 기존 Pan 알고리즘 재구현 없음. | +| 직원 팝업 | PASS. 직원 이름·직위의 의미 있는 카드 이름, dialog 제목·닫기 이름, forest 밖·transform 없음, focus 내부·ESC 복귀 확인. 기존 공통 native dialog 재사용. | +| 20/100/300명 | PASS. 기존 JSON 그대로 사용. Medium/Large HTML의 고유 인원·전체 Fit, React Small 및 Large 300명 선택·겹침 없음 확인. 새 fixture·실제 개인정보 없음. | +| HTML/React | PASS. Small의 카드 identity·size·photo·표시 텍스트·font-size·중립 border 동일. 실제 Story와 production React bootstrap 모두 같은 Core 실행. 별도 React 엔진 없음. | +| Fixed | PASS(최소 회귀). 시작 dist와 카드 좌표·size·SVG asset·직위·이름·컬러·사진 정확 일치. Auto 토글 0, 공통 팝업 최소 회귀 확인. Fixed CSS·SVG·Full Composition Story 미수정. | +| Storybook | PASS. 사용자용 4개 메뉴 유지. 기존 internal Story는 사용자 메뉴 집계에서 제외. 안내의 SVG 미확보 문구 제거·P5-4 접근성 이름 갱신. 신규 Story 없음. | + +## 접근성·검증 범위 + +- 실제 키보드 자동화: 그룹 Focus outline·Enter/Space·직원 Space·Tab으로 화면 밖 요소 표시·ESC/Focus 복귀 **PASS**. +- DOM/ARIA: 타이틀 이름·펼침 상태·카드 직원 정보 이름·dialog 제목·닫기 버튼·React 회사 Checkbox 및 데이터 Select 이름 **PASS**. +- 터치: Chrome mobile/hasTouch 에뮬레이션의 title tap, CDP touch Pan, card tap 팝업·닫기 **PASS**. 실제 모바일 기기는 **NOT VERIFIED**. +- 실제 스크린리더 발화, 다른 브라우저 엔진·운영 소비자, 전체 과거 회귀, 종합 자동 접근성 audit는 **NOT VERIFIED**. +- Noto는 computed family 및 기존 host font-face 로딩 확인이다. 실제 font glyph의 CDP 재측정은 하지 않았으며 P3.1 검증 기록을 보존한다. +- 390/768/1280/1440px: 실제 React 문서 가로 overflow 없음·카드 20명 유지·hidden viewport scroll 0 **PASS**. + 문서 및 viewport scrollHeight/clientHeight 원시 값은 `browser.json`에 보존한다. +- 100%에서 큰 조직도가 viewport 밖으로 이어지는 것은 기존 Pan 탐색 동작이며, 전체 표시 여부는 Fit 상태에서 판정했다. +- 기존 조직 ID는 dataset·ARIA 동작 대상에 유지된다. 이번 화면은 개발용 조직 ID 텍스트를 표시하지 않으므로 ID 한 줄 표시 검사는 NOT APPLICABLE. +- 빈 그룹 disabled는 기존 구현을 유지했다. disabled/readOnly의 모든 입력 조합 재검사는 NOT VERIFIED. + +기존 실제 React UI는 `@hm-design/ui` Button/Checkbox/Label/Select를 사용한다. +이 작업은 fixture/즉시 회사 표시 도구이며 FilterBar 목록 검색 구조를 추가할 필요가 없어 +P5-2·3의 미사용 근거를 유지한다. 페이지 Navigation 없는 임베드 컴포넌트이며 신규 Navigation 없음. +Core native button/dialog는 명시적 Vanilla adapter 계약이다. + +## 최종 통합 검증 + +- Core Typecheck 및 ESM/IIFE·선언 Build **PASS**. +- React Wrapper Typecheck·선언/ESM Build 및 앱/Story TypeScript **PASS**. +- 기존 Expanded Story를 소비하는 별도 production React Build **PASS**. + 기존 이미지 자산에 의한 500kB 초과 chunk 경고는 유지하며 빌드 오류와 구분한다. +- 변경 영향 브라우저 **46/46 PASS**, 마지막 세 실행 경로 console/runtime errors **0**. + 초기 검사 runner 오류(부모 중심 범위의 잘못된 제한·검증 URL data scheme·internal Story 집계·자산 라우팅·favicon 404)를 수정했다. + 초기 실패 JSON을 보존하고 성공 25개→45개를 재사용하여 실패·미완료만 확인했다. + 제품 코드 FAIL을 숨기거나 과거 Guard 결과를 바꾸지 않았다. +- 기존 단위 7/7 및 과거 전체 브라우저 suite는 **재실행하지 않음**. DOM 동작 변경은 위 통합 브라우저로 검증. +- `git diff --check` **PASS**. CRLF 전환 안내는 공백 오류가 아니다. +- Guard는 변경한 Expanded 소비 Story에 PARTIAL/STORYBOOK profile로 **1회** 실행. + 원본 `status=PASS`, `staticStatus=PASS`, issues=0, `runtimeStatus=NOT VERIFIED`. +- 원본 styleEvidence: STATIC **PASS** / RUNTIME **NOT VERIFIED** / DESIGN_INTENT **NOT VERIFIED** / FINAL **NOT VERIFIED**. +- 별도 Runtime 증거 fingerprint 연결 `linked=true`; 결합 status **NOT VERIFIED**. + 결합 styleVerification도 STATIC **PASS**, 나머지 세 항목 **NOT VERIFIED** 유지. + Storybook profile에는 Production `--result` 증명이 없고 전체 host CSS 의도 검증을 수행하지 않는다. + 실제 production React 빌드·브라우저 PASS와 Guard 자동 판정을 구분한다. +- 현재 카드 유지의 사용자 확정은 모든 소비 CSS·외부 입력 상태의 포괄적 승인으로 확대하지 않는다. +- 카드 geometry·엔진이 동일하므로 성능을 재측정하지 않았다. 이전 195.6ms/42ms는 다른 작업의 조건부 기록으로 보존한다. + +## 실행 화면·캡처·문서 + +- [Expanded OrgChart](http://127.0.0.1:6054/?path=/story/orgchart--expanded-org-chart) +- [Vanilla HTML 예시](http://127.0.0.1:4189/expanded.html) +- production React: `test-results/org-chart-p5-4/react-build/dist/index.html` (브라우저 검증 중 로컬 서버로 실행, file:// 직접 실행용 아님). +- `storybook-large.png`: 기존 7회사·300명 전체 Expanded 화면. +- `cards-100-percent.png`: 카드·상단/좌측 강조 연결선 100% 탐색 화면. +- `group-collapsed.png` / `group-expanded.png`: 동일 그룹 접힘·펼침 비교. +- `two-companies.png`: 2회사 독립 Root. +- `html-large-fit.png` / `html-medium-fit.png`: 300/100명 HTML Fit. +- `storybook-{390,768,1280,1440}.png`: 4개 폭 실제 React 화면. +- `touch-emulation.png`, `fixed-1440.png`: 터치 에뮬레이션·Fixed 최소 회귀. +- 캡처와 원시 근거는 모두 `test-results/org-chart-p5-4/`. 실제 직원 개인정보 없음. +- [사용 예제 및 스타일 관리](expanded-usage.md): Fixed/Auto, 컬러, 단일/복수 회사, + collapse, Zoom/Pan, JSON Source, popup, Shadow DOM CSS 변수 및 연결 방향을 실제 공개 API로 설명. + 없는 collapse setter·layoutConfig·React imperative ref를 지원한다고 문서화하지 않음. + +## 이번 실제 변경 파일 + +시작 시 미커밋 32개 파일을 snapshot/hash로 기록했다. 아래 기존 파일 7개만 이번에 추가 보완했으며, +나머지 25개는 시작 hash와 정확히 일치한다. 기존 미커밋 변경을 이번 신규 구현으로 주장하지 않는다. + +1. `packages/org-chart/src/core/employee-card.ts` — Auto 직위 표시·원문 접근성 이름. +2. `packages/org-chart/src/core/expanded-viewport.ts` — 화면 밖 focus 표시·drag 이후 keyboard click·frame cleanup. +3. `packages/org-chart/src/core/expanded-content.ts` — current presentation 상태. +4. `packages/org-chart/src/styles/org-chart-expanded.css` — Auto title/link Hover·radius 상속, 기준 주석. +5. `src/org-chart/org-chart-expanded.stories.tsx` — P5-4 이름·SVG 안내 제거. +6. `packages/org-chart/README.md` — 현재 카드 기준·접근성·문서 링크. +7. `packages/org-chart-react/README.md` — 동일 기준·공통 Core 보완 설명. +8. 신규 `docs/org-chart/expanded-usage.md` — 사용·스타일·지원 경계. +9. 신규 `examples/org-chart/verify-p5-4.mjs` — 변경 영향 검증 runner. +10. 신규 이 보고서. + +과거 보고·fixture·공개 타입/API·회사 preset·자동 레이아웃 엔진·Fixed 자산은 이번에 변경하지 않았다. +검증 산출물·시작 patch/dist/hash는 기존 ignored `test-results`에 저장했다. + +**Stage·Commit·Push·Merge·PR 미진행.** 시작/완료 HEAD 동일, staged diff 없음. +Reset·Rebase·Force Push·Clean 없음. 운영 main·다른 프로젝트 수정 없음. + +## 작업 시간 (Asia/Seoul) + +- Started At: 2026-10-12 21:23:35 KST +- Completed At: 2026-10-12 21:41:32 KST +- Actual Duration: 17분 57초 +- Estimated Duration: 최초 45–60분 (자료 확인 10분 / 구현 20–30분 / 검증·보고 15–20분) +- Variance: 예상 하한보다 27분 03초, 상한보다 42분 03초 짧음. +- 시간 차이는 사용자 확정으로 SVG 재작업이 제외되고 기존 PASS를 재사용한 결과다. diff --git a/docs/org-chart/implementation-report-p6.1.md b/docs/org-chart/implementation-report-p6.1.md new file mode 100644 index 0000000..fa6ced9 --- /dev/null +++ b/docs/org-chart/implementation-report-p6.1.md @@ -0,0 +1,101 @@ +# HMDesign OrgChart P6.1 — 연결선 접근성 및 배포 품질 보완 + +RESULT: **PASS** (요청한 P1 1건·P2 2건의 변경 범위) + +Auto 관계선 대비, 과거 브라우저 runner의 도구 경로, 두 배포 README의 링크를 수정했다. Fixed CSS·Core/React API·레이아웃 알고리즘·회사 프리셋·package.json·lockfile은 변경하지 않았다. 전체 운영 배포 인증 또는 기존 미검증 영역 승인으로 확대하지 않는다. + +## 작업 환경과 Git + +- 저장소: `D:\디자인시스템\HMDesign-orgchart-package` +- 범위: BOTH의 기존 공통 Core Auto CSS 및 검증 도구·패키지 문서. 신규 UI/Story 없음. +- 브랜치 및 시작/종료 HEAD: `feature/orgchart-package`, `d2b76e766466e1f156f6a0f8f63710e023af900e` +- origin: `https://gitea.hmac.kr/b25027/HMDesign.git`. 읽기 전용 `git ls-remote`의 feature SHA도 일치했다. 최초 sandbox 네트워크 오류 후 허용된 실행 환경에서 확인했다. +- Git 작성자 유지: `b25027` / `shkim4@samaneng.com` +- 기존 미커밋 파일: untracked `docs/org-chart/quality-diagnosis-p6.md` 1개. 수정하지 않았다. 최종 SHA-256: `224aa06880b9cad222f510b6e633d5b0faccece742f720584fd2b42912cdb10f`. +- 최종 Working Tree: 기존 추적 파일 23개 수정, 이번 신규 문서 2개·실행 도우미 1개, 기존 untracked P6 보고서 1개. 실행기·로그·캡처·Tarball은 ignored `test-results/org-chart-p6-1/`에 보존했다. +- Staged diff 없음. **Stage·Commit·Push·Merge·PR·main Checkout·Reset·Rebase·Clean·Force Push 미진행.** + +## 수정 결과 + +| 항목 | 수정 및 검증 결과 | +|---|---| +| P1 Auto 대비 | Auto 전용 `--hm-orgchart-expanded-line-color: #738399`. 부모/자식 pseudo 관계선과 직원 SVG path만 참조한다. 이전 computed `rgb(186, 199, 215)` → `rgb(115, 131, 153)`. 흰 배경 **1.7159036307:1 → 3.8635120805:1**. | +| 배경·상태 | browser computed 배경/색상·opacity·background image/filter/blend를 기록하고 투명 배경을 합성했다. 기본 실제 관계선 배경은 흰색이다. 기존 light surface override `rgb(240, 241, 242)`도 **3.4163257790:1**. Zoom/Pan 좌표 probe가 반환한 content 배경 `rgb(244,247,251)`의 보수적 비율은 3.5956293943:1이며, 이 probe에는 viewport 밖의 clipped 좌표도 포함될 수 있다. 최소 확인 비율은 3.4163:1. 임의 외부 배경 재정의는 별도 검증이 필요하다. | +| 기준 | 내용을 전달하는 그래픽의 인접 색상 대비 **3:1 이상 충족**. [W3C SC 1.4.11](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html). Anti-aliasing/전체 Fit의 축소는 기존 상태이며 굵기를 변경하지 않았다. | +| 기존 스타일 | 같은 페이지에서 수정 전 공개 CSS → 수정 후 CSS를 교체하여 Fixed 전체 DOM의 관련 computed 스타일 및 `::before/::after`를 비교했다. Fixed 연결선·협업 화살표·카드 스타일 불변. Auto 직원 path 좌표·굵기, 카드 offset/크기·Border·회사 accent 불변. | +| P2 실행 경로 | Core 17개·React 2개 runner가 공통 `scripts/orgchart-browser-runtime.mjs`를 사용한다. 기본은 표준 프로젝트 `playwright` 모듈 및 관리 Chromium. 기존 외부 모듈·브라우저는 `ORGCHART_PLAYWRIGHT_MODULE` / `ORGCHART_CHROME`으로 선택형 지정. 하드코딩된 개인 경로·고정 Chrome 경로 제거. 원래 모듈/실행 오류를 cause와 함께 안내한다. | +| 새 PC | 경로 이식성·선택형 환경변수·오류 메시지 검증 PASS. 이 PC의 프로젝트에는 Playwright가 없어 이미 설치된 외부 모듈과 Chrome을 환경변수로 지정하여 실제 실행했다. 신규 설치/업그레이드 없음. **실제 다른 PC의 fresh install과 실행은 NOT VERIFIED**. 기존 fixture·baseline·서버 origin 전제는 유지한다. [실행 안내](browser-verification.md). | +| P2 README | Core 4개, React 3개 링크(타 패키지 README 상대링크 포함), 총 7개를 기존 문서가 존재하는 공식 Gitea **고정 Commit URL**로 교체했다. 문서 복사·배포 파일 구조 변경 없음. | +| Tarball | Build 후 `npm pack --ignore-scripts`로 실제 2개 tgz 생성·추출. README의 깨진 상대 링크 0개, 외부 URL 형식/고정 Commit의 Git blob 존재 7/7. 공개 export 대상 파일과 README named import/type export 일치. Gitea URL의 HTTP 접근 권한은 별도 NOT VERIFIED(저장소 권한 필요 가능). | + +## 검증 결과와 재사용 + +| 검증 | 결과·범위 | +|---|---| +| Core Typecheck / Build | PASS / PASS, 기존 workspace 명령 사용 | +| React Typecheck / Build | PASS / PASS, 공통 Core 공개 선언 기반 | +| 변경 범위 브라우저 | **17/17 PASS**, 실제 Chrome. 300명·7회사, 부모/자식 3종 및 직원 관계선 computed 대비, 제목 Hover, Enter 접기/Space 펼치기, 회사 단일/복수 선택, UI Zoom·실제 pointer Pan·Fit, 기존 light background override 포함. 콘솔/page/request 오류 0. | +| 실행기·패키지 감사 | **87/87 PASS**. 19개 runner 및 공통 helper syntax, 도구 연결 변경을 제외한 원래 시나리오/Assert/기대값의 텍스트 동일성, PC 경로 제거, 기본/선택형 도구 설정과 명확한 실패, Tarball 링크·공개 export/import/type, `git diff --check`. 실제 과거 전체 suite 실행 수가 아니다. | +| 기존 P6 브라우저 | 59/59 PASS 재사용. 전체 재실행 없음. | +| 기존 패키지·CSS | Export/Tarball/타입 16/16, CSS 격리 9/9, Noto Sans KR PASS 재사용. 이번 pack 링크 검사는 변경된 README에 대한 보완이며 과거 증거를 덮어쓰지 않았다. | +| 기존 Guard | Static 및 연결 Runtime PASS 재사용. 제품 bootstrap/import/Navigation 계약 변경 없이 패키지 내부 CSS·도구·README만 수정했다. 기존 consumer Guard 전체 반복 없음. | +| Guard CSS 범위 | 기존 **STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT NOT VERIFIED / FINAL NOT VERIFIED**를 유지한다. 이번 연결선 computed 및 Fixed 영향 검증 PASS를 Core 전체 CSS/동적 입력/디자인 의도 승인으로 확대하지 않는다. 원본 RENDER-002 NOT VERIFIED 유지. | + +초기 browser 실행의 `ERR_NETWORK_ACCESS_DENIED`는 sandbox localhost 제한이었다. 초기 로그/JSON을 별도 보존하고 동일 실행기를 localhost 접근 가능한 권한으로 실행했다. 성공한 과거 suite를 반복하거나 기대값을 수정하지 않았다. 신규 제품 FAIL 0건. + +근거: [Build](../../test-results/org-chart-p6-1/build-results.json), [브라우저 raw](../../test-results/org-chart-p6-1/browser.json), [대비 raw](../../test-results/org-chart-p6-1/contrast.json), [감사](../../test-results/org-chart-p6-1/audit.json), [Pack 목록](../../test-results/org-chart-p6-1/pack.json), [캡처](../../test-results/org-chart-p6-1/auto-after.png), [Git 상태](../../test-results/org-chart-p6-1/final-state.json). 이 파일들의 기존 test-results 정책은 유지하며 Git Stage하지 않았다. + +## 변경 파일 + +제품·패키지·문서: + +- `packages/org-chart/src/styles/org-chart-expanded.css` +- `packages/org-chart/README.md` +- `packages/org-chart-react/README.md` +- `docs/org-chart/expanded-usage.md` +- `scripts/orgchart-browser-runtime.mjs` (신규) +- `docs/org-chart/browser-verification.md` (신규) +- `docs/org-chart/implementation-report-p6.1.md` (신규) + +도구 경로만 변경한 Core runner 17개: + +- `examples/org-chart/verify.mjs` +- `examples/org-chart/verify-p1.mjs` +- `examples/org-chart/verify-p2.mjs` +- `examples/org-chart/verify-p3.mjs` +- `examples/org-chart/verify-p3.1.mjs` +- `examples/org-chart/verify-p3.2.mjs` +- `examples/org-chart/verify-p5-1a.mjs` +- `examples/org-chart/verify-p5-1b.mjs` +- `examples/org-chart/verify-p5-23.mjs` +- `examples/org-chart/verify-p5-4.mjs` +- `examples/org-chart/verify-card-content.mjs` +- `examples/org-chart/verify-card-design.mjs` +- `examples/org-chart/verify-final-card-check.mjs` +- `examples/org-chart/verify-final-logo-check.mjs` +- `examples/org-chart/verify-final-status-check.mjs` +- `examples/org-chart/verify-quality-boundaries.mjs` +- `examples/org-chart/verify-quality-p1.mjs` + +React runner 2개: + +- `examples/org-chart-react/verify.mjs` +- `examples/org-chart-react/verify-boundaries.mjs` + +## 남은 결함과 미검증 + +이번 요청의 배포 전 결함은 P1 1건·P2 2건 모두 해결했다. **이번 확인 범위의 남은 P0/P1/P2: 0/0/0**, 신규 회귀 결함 0. 과거 Fixed 대비 REVIEW와 오래된 테스트 FAIL을 새 PASS로 재분류하지 않았다. + +계속 NOT VERIFIED: Guard 전체 CSS Runtime/디자인 의도 및 임의 동적 입력, 실제 화면 리더·물리 터치·타 브라우저, 전체 역사 회귀/과거 FAIL 전체 재분류, 모든 Fixed 폭·전체 픽셀 일치, 임의 데이터/override/접힘 전 조합, 실제 새 PC fresh install, 실제 운영 base/font/CSP/origin·배포·SSR/hydration·실제 API. 390/768/1280/1440 기존 P6 결과를 재사용했으며 이번에는 1440px의 변경 범위 시나리오를 확인했다. + +Stage·Commit·Push는 사용자 검토 후 별도 요청으로 진행한다. + +## 작업 시간 + +- Started At: 2026-10-12 22:33:57 KST +- Completed At: 2026-10-12 22:43:52 KST +- Actual Duration: 9분 55초 +- Estimated Duration: 최초 35~45분 +- Variance: 예상 하한보다 25분 5초, 상한보다 35분 5초 짧음 + +준비된 기존 의존성/브라우저, 공통 실행 도우미, P6 정상 기록 재사용과 변경 범위 검사로 예상보다 빠르게 완료했다. diff --git a/docs/org-chart/implementation-report-p7.md b/docs/org-chart/implementation-report-p7.md new file mode 100644 index 0000000..bd62d6b --- /dev/null +++ b/docs/org-chart/implementation-report-p7.md @@ -0,0 +1,188 @@ +# HMDesign OrgChart P7 구현·검증 보고 + +RESULT: **PASS — P7 변경 범위.** 전체 접근성·CSS 디자인 의도·운영 배포 승인으로 확대하지 않는다. +TB/LR/Hybrid 독립 모드, 데이터 선택, 선택형 HM Dialog를 구현했다. 최신 dabeng LR 예제 비교에서 추가 기능 수정이 필요한 결함은 발견하지 않았다. +Compact는 **현재 Auto adapter에서 NOT SUPPORTED**이며 요구사항의 허용된 제외 사유를 아래에 명시한다. + +## 요청 항목별 결과 + +| 번호 | 항목 | 결과와 근거 | +|---|---|---| +| 1 | 폴더·브랜치·HEAD | `D:\디자인시스템\HMDesign-OrgChart` / `feature/orgchart-package` / `7614507583927a25a270963a74ff044be0557b9f`. origin `https://gitea.hmac.kr/b25027/HMDesign.git`. 시작·완료 HEAD 동일. | +| 2 | 작업 시간 | 아래 KST 시간 기록 참조. 최초 예상 60–90분. | +| 3 | 전체 합산 20/100/300 | **PASS**. 모든 7회사, 고유 Employee ID 기준 정확히 20/100/300. 가중 분배 후 선택 회사만 필터링하며 재분배하지 않음. | +| 4 | 회사별 20/100/300 | **PASS**. 각 회사 정확히 N명. helper 단위 검사는 모든 회사·규모 조합, 실제 화면은 단일 회사 20/100/300 확인. | +| 5 | 다중 회사 | **PASS**. 한맥기술+삼안 ×100=200명, 전체 7×300=2,100명. 빈 선택은 Core empty view. 전환 전 예상 규모 표시·데이터 적용 버튼 제공. | +| 6 | TB 유지 | **PASS**. 기존 Small fixture의 직원/그룹 위치·치수를 시작 전 dist와 직접 비교해 동일. 일반 TB 관계선 경로 보존. | +| 7 | LR | **PASS**. 실제 dabeng `direction:'l2r'`, 부모 좌측·자식 우측·형제 세로 정렬. 회사마다 독립 tree. 아래 공식 예제 비교 참조. | +| 8 | Hybrid | **PASS**. 실제 `verticalLevel` + 명시적 engine level. Root=1Depth, 시작 Depth 2/3/4. 부모별 지역 세로 배치·재귀 구조 검증. | +| 9 | 초기 Depth | **PASS**. 1/2/3/all, 모든 레이아웃에서 독립. 변경하면 초기 접힘 정책으로 재설정; 이후 사용자 접힘 상태는 유효한 조직 ID에 대해 유지. | +| 10 | 그룹 Hybrid·Compact | 그룹 Hybrid **PASS**, TB의 지정 그룹 자손에만 실제 적용. LR/전체 Hybrid와 조합 거절·Story disabled/자동 해제. Compact **NOT SUPPORTED**, 설명 참조. | +| 11 | 접기·펼치기 | **PASS**. click/Enter/Space, 유효한 하위 상태·형제 상태 유지. 독립 `collapse` 설정 제공. LR 접기·재확장 후 실제 양끝 연결 좌표·재배치 확인. | +| 12 | Zoom/Pan/Fit | **PASS**. 각 독립 설정·실제 pointer Pan·Ctrl-wheel gate·도구 버튼. Fit/100% 복원 및 키보드 focus 노출은 기능 스위치와 독립. | +| 13 | 연결선·Border | **PASS**. 실제 parentId·제목 좌표 및 직원 카드 도착점에 SVG 배치. LR 모든 직원 left 연결/left accent, TB/Hybrid 기존 top/left/right. 다른 면 중립·카드 치수 보존. | +| 14 | 컬러·흰 타이틀 | **PASS**. 7회사 프리셋 파일/배경 동일, 시작 전 `#ffffff` 변경 보존. normal/hover/focus/collapsed/expanded 흰 글자 확인. 삼안·장헌산업 대비 진단은 유지. | +| 15 | HM Dialog | **PASS**. 실제 공개 HM Dialog/Portal·Avatar·Button. 직원 identity, 필드, 단일 팝업, Enter/Space, ESC/닫기·focus trap/복귀, source 변경 시 stale 팝업 정리. 390px 필드/팝업 가로 overflow 없음. | +| 16 | Vanilla 호환 | **PASS**. 기존 Core native dialog 및 callback 계약 유지. React/HM은 Core 의존성이 아님. 동일 공개 helper/DataSource/Core로 직원 기록 공유. | +| 17 | 300/2,100 성능 | **PASS — 이 PC의 측정 시나리오**. 신규 LR/Hybrid 별도 실측·DOM/JS heap 기록. 아래 표 참조. 장시간·타 기기·peak memory는 NOT VERIFIED. | +| 18 | Fixed 영향 | **PASS — 최소 회귀**. 시작 전 dist와 geometry/SVG/text/gradient/border 동일, 새 Auto control 없음, 기존 팝업 open/ESC. 보호 자산 33개·고정형 관련 파일/프리셋/fixture 해시 동일. 전체 Fixed 픽셀 인증은 별도 미검증. | +| 19 | Storybook URL | [Expanded OrgChart](http://127.0.0.1:6056/?path=/story/orgchart--expanded-org-chart). 실제 React Story, 기존 사용자용 4개 메뉴 유지. internal/verification Story는 기존 제외 정책 적용. | +| 20 | 캡처 | 아래 현재 화면·LR·Hybrid·2,100명·Dialog·390px 링크 참조. | +| 21 | 수정 파일 | 아래 27개 authored 파일 목록. 기존 흰색 타이틀 변경과 새 P7 CSS는 같은 파일에 공존. | +| 22 | 변경 범위 검사 | focused unit **25/25**, Core/React typecheck/build, scoped Story typecheck, 실제 React consumer production build, 변경 범위 브라우저, `git diff --check` **PASS**. 초기 실패·수정 검사 provenance는 아래 기록. | +| 23 | 기존 FAIL/미검증 | 과거 기록을 변경하지 않음. Guard 정적 PASS / runtime·CSS intent/final NOT VERIFIED, 기존 제목 대비 진단, 실기기·스크린리더·전체 역사 회귀 등을 아래에 분리. | +| 24 | Git 미진행 | **Stage·Commit·Push·Merge·checkout·pull·reset·rebase·PR 모두 미진행.** staged 파일 0, branch/HEAD 동일. | + +## 독립 옵션과 호환 범위 + +공개 사용 예와 세부 계약은 [expanded-usage.md](expanded-usage.md)에 있다. +`autoLayout.type`은 TB/LR/Hybrid 중 하나이며 다른 기능을 켤 필요가 없다. + +| 옵션 | TB | LR | Hybrid | 제한 | +|---|---|---|---|---| +| 접기·펼치기 / Zoom / Pan | 각각 선택 | 각각 선택 | 각각 선택 | 각각 기본 true, 다른 스위치의 필수 사용 없음 | +| 초기 Depth / 회사 선택 | 독립 | 독립 | 독립 | 초기 Depth 변경은 접힘 상태를 초기 정책으로 재설정 | +| React popup core/hm-dialog/none | 독립 | 독립 | 독립 | HM 모드만 HM UI optional peer·호스트 theme 필요; callback은 기본 팝업보다 우선 | +| Hybrid 시작 Depth 2/3/4 | 적용 안 함 | 적용 안 함 | 선택 | 값은 보관하되 TB/LR에서는 적용하지 않음 | +| 지정 그룹 Hybrid | 지원 | 조합 거절 | 조합 거절 | Story는 비호환 모드로 전환할 때 목록을 해제 | +| Compact | 미지원 | 미지원 | 미지원 | 엔진 기능 자체의 미지원이라는 뜻은 아님 | + +dabeng 6 Compact는 자식 `.node`를 부모 `.node` 내부에 넣고 140×50 grid 및 별도 compact/loose 토글을 사용한다. +현재 HM article·카드 크기·측정 연결선·그룹 접기 계약과 충돌한다. 기존 카드 디자인을 유지하면서 +안전하게 연결할 수 없어 옵션을 제공하지 않았다. 새 엔진이나 레이아웃 라이브러리는 추가하지 않았다. +`visibleLevel` 요구는 Core 초기 접힘 projection으로 제공한다. upstream hidden 자손을 그대로 둘 경우 +직원 카드·연결선 측정이 충돌하므로 engine에는 `visibleLevel:999`를 전달하고 실제 보이는 트리를 넘긴다. + +## dabeng LR 공식 예제 비교 + +[사용자 제공 예제](https://dabeng.github.io/OrgChart/left2right.html) 및 +[공식 원본](https://github.com/dabeng/OrgChart/blob/master/demo/left2right.html)을 현재 소스와 비교했다. +원본은 `direction:'l2r'`와 `.title/.content`를 사용한다. 설치된 dabeng 6도 동일 옵션이며 +차트 회전·50×140 node·title/content 반회전 CSS를 사용한다. +현재 구현은 해당 방향 API·재귀 DOM을 재사용하고, 기존 HM 복합 article의 정방향·치수 유지에 필요한 +Auto 전용 flex/측정 SVG adapter를 적용한다. 원본 화면과 픽셀 동일성을 요구하거나 주장하지 않는다. + +| 사용자 확인 항목 | 판정 | 증거 | +|---|---|---| +| 1 실제 `l2r` 우선 재사용 | PASS | engine options 단위 검사, 실제 7개 `.orgchart.l2r`, 공식 원본/설치 소스 비교 | +| 2 최상위 왼쪽·하위 오른쪽 | PASS | 모든 parentId 대상 child-right 실측 | +| 3 형제 세로 정렬 | PASS | 모든 같은-parent 형제의 y 범위 검사 | +| 4 3Depth 이상 관계선 | PASS | 5Depth fixture, parentId 및 실제 heading endpoint 검사 | +| 5 LR 카드 Border 일치 | PASS | 모든 카드 left 방향·실제 도착점 <1px·한 면 accent 검사 | +| 6 접기 후 갱신 | PASS | 닫기/재확장 시 자손 수·겹침·부모/자식 선 양끝 재측정 | +| 7 전환 잔상 | PASS | TB→Hybrid→LR에서 tree 7 / l2r 7 / SVG 7 / 관계선 70 / 부모 있는 node 70 / vertical list 0 | +| 8 독립 회사 Root·겹침 | PASS | 7회사 300/2,100명 전체, 카드/그룹 겹침 없음, 회사 선택 독립 | +| 9 사진·직위·이름 정방향 | PASS | 모든 관련 element transform=none, 사진 36×36, 100% 캡처 확인 | +| 10 Zoom/Pan/Fit·팝업 | PASS | 기존 LR 통과 증거 재사용 + 독립 스위치/실제 HM Dialog 검증 | + +이번 예제 비교 이후 제품 코드에 LR 추가 수정은 하지 않았다. 미확인한 정방향·접기 양끝·잔상만 추가 검사했다. + +## 실행 검증과 초기 실패 처리 + +기존 P5/P6 전체 suite는 재실행하지 않았다. 새로운 helper/policy와 직접 영향받는 경로만 확인했다. +단위 검사 25개는 data example 11 + auto policy 7 + 기존 expanded layout 4 + company 3이며, +중간 수정 이후에는 영향받은 policy 검사만 갱신하고 나머지 유효한 결과를 재사용했다. + +| 검사 | 결과 | 근거 | +|---|---|---| +| Core/React typecheck·build | PASS | 공개 패키지별 명령 실행. 마지막 Core build는 변경된 선언 주석까지 dist에 반영. | +| scoped Story typecheck | PASS | P7 Story만 포함한 tsconfig, 최종 그룹 이름 변경 후 확인. | +| 실제 React production consumer | PASS | 공개 패키지·HM theme/CSS·StrictMode bootstrap, production build 및 실제 브라우저. 기존 asset 크기로 chunk size 경고 1종, build 오류 없음. | +| Core 최초 변경 검사 | 84 PASS / 10 FAIL → 수정 확인 PASS | [초기 raw](../../test-results/org-chart-p7/browser-core-initial.json). update 후 Fit, LR 형제 CSS 우선순위, Hybrid engine level/부모별 list를 최소 수정. 실패 경로 및 연결선/2,100명 관련 [31/31 repair](../../test-results/org-chart-p7/browser-repair.json). | +| Story·production React | 50 PASS / timing 검사 2 FAIL → PASS | [초기 raw](../../test-results/org-chart-p7/browser-story-react.json). Radix 종료 animation/presence 전 검사였음. 종료 hidden을 기다리는 [3/3 targeted 확인](../../test-results/org-chart-p7/browser-react-repair.json). 제품 결함으로 재분류하지 않음. | +| 독립 interaction·최종 popup | 79 PASS 후 runner 중단 | [raw](../../test-results/org-chart-p7/browser-options-react-repair.json). 실제 그룹 옵션의 중복 회사 접두사 정리, 남은 옵션/폭/색상 [12 PASS](../../test-results/org-chart-p7/browser-options-tail.json). 최초 메뉴 집계는 internal 3개까지 포함해 실패; 기존 제외 정책 적용한 [2/2 메뉴 검사](../../test-results/org-chart-p7/browser-story-menu.json). | +| LR reference 추가 영향 | 4 PASS 후 잔상 집계 1 FAIL → PASS | [raw](../../test-results/org-chart-p7/browser-lr-reference.json). 빈 parentId Root까지 관계선 수에 포함한 테스트 오류; Root 제외 후 해당 검사만 [2/2 PASS](../../test-results/org-chart-p7/browser-lr-residue.json). 제품 코드 변경 없음. | +| 신규 성능·390px Dialog | 6/6 PASS | [raw](../../test-results/org-chart-p7/browser-performance-dialog-narrow.json). 측정 시간을 새로 확보한 검사이며 전체 suite 반복 아님. | +| 390/768/1280/1440 | PASS | 최종 설정 UI 줄바꿈, document 가로 overflow 없음, stage scrollLeft/Top=0, scrollWidth/clientWidth·scrollHeight/clientHeight 일치, Fit 전체 bounds. 문서 세로 스크롤은 정상. | +| 최소 Fixed 회귀 | PASS | 최초 Core raw의 Fixed geometry/SVG/text/gradient/border, Auto control 부재·native 팝업 open/ESC 및 보호 hash. | +| Guard 1회 | static PASS, issues 0 | [원본](../../test-results/org-chart-p7/guard.json), STORYBOOK/PARTIAL. raw status PASS, runtimeStatus NOT VERIFIED. 아래 CSS 범위 구분 유지. | +| 최종 diff·보호 파일 | PASS | `git diff --check`, [baseline](../../test-results/org-chart-p7/baseline.json), [최종 audit](../../test-results/org-chart-p7/final-state.json). | + +Chrome `155.0.8059.39`, 기존 설치 Playwright/Chrome를 사용했으며 패키지 설치/업그레이드 없음. +localhost sandbox `ERR_NETWORK_ACCESS_DENIED` 초기 로그를 보존하고 허용된 로컬 브라우저 실행으로 확인했다. +서로 다른 실행의 PASS 수를 중복 합산해 총 검사 수라고 주장하지 않는다. 모든 성공 실행의 신규 console/page 오류는 0이다. + +## 신규 모드 성능 + +Windows 이 PC, Chrome headless, 1440×1100, full depth·7회사·가상 fixture, 각 1회 관측이다. +Core mount는 `performance.now()`의 `handle.mount()` 시간, ready/interaction은 Playwright 동작과 +font 준비·5 animation frame 대기를 포함한 경과 시간이다. 네트워크/브라우저 warm 상태와 GC 영향을 받으며 서로 다른 지표를 동일 benchmark로 비교하지 않는다. + +| 데이터·모드 | Core mount | 화면 준비 | 접기 | Zoom | Fit | used JS heap | 내부 DOM | +|---|---:|---:|---:|---:|---:|---:|---:| +| 300 LR | 31.6ms | 442ms | 195ms | 116ms | 116ms | 9,839,068B | 3,306 | +| 300 Hybrid Depth2 | 19.2ms | 300ms | 182ms | 131ms | 118ms | 12,887,564B | 3,152 | +| 300 Hybrid Depth3 | 18.2ms | 325ms | 182ms | 133ms | 117ms | 17,325,639B | 3,194 | +| 300 Hybrid Depth4 | 17.1ms | 321ms | 183ms | 116ms | 118ms | 15,098,350B | 3,243 | +| 2,100 LR | 160.5ms | 미측정 | 미측정 | 미측정 | 전체 Fit PASS | 15,640,127B | 17,706 | +| 2,100 Hybrid Depth3 | LR→Hybrid 전환+준비 917ms | 앞 열 지표 | 미측정 | 미측정 | 전체 Fit PASS | 28,410,298B | 17,594 | + +2,100명의 실제 카드 수·고유 ID·겹침 없음·전체 bounds를 따로 확인했다. 전체 Fit은 가독성이 작은 개요 화면이다. +카드 읽기에는 Zoom 또는 초기 표시 Depth가 필요하다. Story에서 2,100명 전환 규모를 적용 전에 표시하고 +초기 Depth2로 전환하는 실제 UI도 확인했다. `performance.memory.usedJSHeapSize`는 한 시점의 JS heap이며 +DOM native memory·프로세스 전체 메모리·peak·누수/장시간 반복을 증명하지 않는다. 2,100명 interaction latency 전체는 NOT VERIFIED. + +## 미검증·기존 진단 보존 + +- Guard `styleEvidence`: **STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT NOT VERIFIED / FINAL NOT VERIFIED**. + 이 실행에 결합 `styleVerification`은 생성하지 않았으며 NOT VERIFIED로 취급한다. Story 정적 graph와 + 별도 실제 React build/browser 증거를 전체 Core CSS·임의 외부 props/style의 인증으로 합치지 않는다. +- 기존 삼안·장헌산업 흰 타이틀의 일반 텍스트 대비 미달 진단을 유지한다. 요청한 흰색·회사 색은 변경하지 않았다. + P6.1의 Auto 관계선 대비 수정과 과거 FAIL/미검증 기록은 그대로 보존한다. +- 실제 스크린리더 발화·물리 터치·다른 브라우저, 전체 Fixed 픽셀/모든 폭·전체 과거 회귀, + 모든 임의 데이터/override/옵션 상태 조합, HM peer 없는 fresh install/bundler, 운영 배포·SSR/hydration은 **NOT VERIFIED**. + 실제 API/서버/DB·실제 개인정보는 요청 범위 밖이며 추가하지 않았다. + +## 주요 화면 + +- [최종 설정 화면 1440px](../../test-results/org-chart-p7/story-1440.png), [390px](../../test-results/org-chart-p7/story-390.png) +- [LR 300명·7회사](../../test-results/org-chart-p7/corrected-lr-group.png), [LR 카드 100% 정방향](../../test-results/org-chart-p7/lr-card-upright.png) +- [Hybrid Depth2](../../test-results/org-chart-p7/corrected-hybrid-2.png), [Depth3](../../test-results/org-chart-p7/corrected-hybrid-3.png), [Depth4](../../test-results/org-chart-p7/corrected-hybrid-4.png) +- [HM Dialog LR](../../test-results/org-chart-p7/Final-lr-dialog.png), [HM Dialog 390px](../../test-results/org-chart-p7/hm-dialog-390.png) +- [2,100명 LR](../../test-results/org-chart-p7/2100-lr.png), [2,100명 Hybrid](../../test-results/org-chart-p7/2100-hybrid.png), [Story 초기 Depth2](../../test-results/org-chart-p7/story-2100-depth2.png) +- [보존된 Fixed](../../test-results/org-chart-p7/fixed-1440.png) + +Popup full-page 캡처는 고정 overlay가 현재 viewport만 덮는 특성상 문서 바깥 영역과 차이가 보일 수 있다. +실제 popup bounds·overflow·focus는 DOM 실측으로 별도 확인했다. + +## Authored 변경 파일 + +1. `package-lock.json` — optional HM UI peer metadata만 반영, 라이브러리 버전/설치 변경 없음. +2. `packages/org-chart/src/data/examples.ts` — 공통 deterministic 가상 데이터 helper. +3. `packages/org-chart/src/data/examples.test.ts` — 11개 데이터 계약 검사. +4. `packages/org-chart/src/data/index.ts` — 공개 export. +5. `packages/org-chart/src/core/auto-layout-policy.ts` — 모드/Depth/독립 interaction 계약. +6. `packages/org-chart/src/core/auto-layout-policy.test.ts` — 7개 policy 검사. +7. `packages/org-chart/src/core/types.ts` — additive 공개 옵션/setter types. +8. `packages/org-chart/src/core/engine.ts` — 기존 dabeng options 전달. +9. `packages/org-chart/src/core/vendor.d.ts` — 설치 엔진 option typing. +10. `packages/org-chart/src/core/render.ts` — 명시적 level/그룹 hybrid projection. +11. `packages/org-chart/src/core/index.ts` — Auto 상태·setter·전환 Fit/engine lifecycle. +12. `packages/org-chart/src/core/expanded-content.ts` — Auto mode/interaction/방향 표시. +13. `packages/org-chart/src/core/expanded-layout.ts` — Auto 크기 측정·조직 관계선. +14. `packages/org-chart/src/core/expanded-viewport.ts` — 독립 Zoom/Pan gate 및 focus reveal. +15. `packages/org-chart/src/index.ts` — 공개 types export. +16. `packages/org-chart/src/styles/org-chart-expanded.css` — Auto 전용 LR/Hybrid CSS, 기존 흰 타이틀 보존. +17. `packages/org-chart/README.md` — data/API/연결 방향 문서. +18. `packages/org-chart-react/package.json` — optional `@hm-design/ui` peer. +19. `packages/org-chart-react/src/types.ts` — React 옵션 및 popup contract. +20. `packages/org-chart-react/src/OrgChart.tsx` — 기존 Core setter·popup state adapter. +21. `packages/org-chart-react/src/HMEmployeeDialog.tsx` — 실제 HM Dialog와 focus 복귀. +22. `packages/org-chart-react/README.md` — 선택형 HM peer·옵션 사용 안내. +23. `src/org-chart/org-chart-expanded.stories.tsx` — 기존 Expanded Story에 실제 선택 UI. +24. `src/org-chart/org-chart-expanded-story.css` — Expanded 한정 콘텐츠 layout. +25. `examples/org-chart/verify-p7.mjs` — 변경 범위 integration runner. +26. `docs/org-chart/expanded-usage.md` — P7 공개 API·호환 범위. +27. `docs/org-chart/implementation-report-p7.md` — 이 보고서. + +`packages/*/dist`, `test-results/org-chart-p7`의 baseline/React consumer/JSON/캡처/tsconfig/audit 등은 +기존 ignore 정책의 로컬 산출물이며 Stage하지 않았다. 별도 역사 보고서·승인 SVG·fixture·Theme·UI Component·기타 Story는 수정하지 않았다. +필수 소스·metadata는 함께 검토할 수 있도록 목록에 포함했다. 이번에는 Git 반영 계획이나 자동 Stage를 실행하지 않는다. + +## 시간 기록 — Asia/Seoul + +- Started At: 2026-10-13 09:07:58 KST +- Completed At: 2026-10-13 09:40:32 KST +- Actual Duration: 32분 34초 +- Estimated Duration: 60–90분 (분석 10–15분 / 구현 30–45분 / 통합 검증 20–30분) +- Variance: 예상보다 27분 26초–57분 26초 빨리 완료 diff --git a/docs/org-chart/implementation-report-p8.md b/docs/org-chart/implementation-report-p8.md new file mode 100644 index 0000000..407fed3 --- /dev/null +++ b/docs/org-chart/implementation-report-p8.md @@ -0,0 +1,132 @@ +# HMDesign OrgChart P8 — Storybook 메뉴·페이지 통일 + +RESULT: **PASS — P8 요청의 메뉴·페이지 및 최소 기능 검증 범위.** +기존 Core 토큰 관련 Guard FAIL과 CSS 디자인 의도 미검증은 별도로 보존한다. +P9·Stage·Commit·Push·Merge·PR은 진행하지 않았다. + +## 작업 기준 + +- 폴더: `D:\디자인시스템\HMDesign-OrgChart` +- 브랜치: `feature/orgchart-package` +- 시작·완료 HEAD: `7614507583927a25a270963a74ff044be0557b9f` +- origin: `https://gitea.hmac.kr/b25027/HMDesign.git` +- 범위: `STORYBOOK_ONLY`, 실제 React Storybook. 시작 시 P7 미커밋 변경 27개를 보존했다. +- Started At: `2026-10-13 09:43:40 KST` +- Completed At: `2026-10-13 10:09:15 KST` +- Actual Duration: `25분 35초` +- Estimated Duration: `30분` — 분석 7분 / 구현 12분 / 검증 11분 +- Variance: `예상보다 4분 25초 짧음` + +## 최종 메뉴와 페이지 + +```text +Components +Patterns +OrgChart + ├─ Overview + ├─ Department Colors + ├─ Full Composition + └─ Expanded OrgChart +``` + +기존 Foundation·Center Graphic 등 다른 카테고리는 유지한다. OrgChart는 Patterns 바로 다음의 독립 최상위 root이며, 공개 페이지는 정확히 4개다. 다른 292개 Story의 상대 순서는 유지한다. + +슬래시 없는 `title: OrgChart`는 Storybook이 root 없는 component로 분류해 정렬 설정과 관계없이 최상단에 표시했다. 각 페이지를 `OrgChart/페이지명`의 단일 Story CSF로 나누어 표준 root/단일 Story 표시 정책을 사용했다. 기존 Story URL 27개는 리터럴 `parameters.__id`와 기존 export를 통해 모두 유지한다. 내부 23개 Story는 기본 제외 정책으로 숨기며 삭제하지 않았다. + +| 페이지 | 적용 결과 | +|---|---| +| Overview | 기존 통합 페이지의 로고·조직/직위별 현황·관계사별 현황을 3개 공통 섹션으로 구성. 동일 Core 독립 컴포넌트와 가상 fixture 유지. | +| Department Colors | 공통 제목·설명, 표시 옵션, 기존 고정형 컬러/카드 미리보기. 긴 이름 버튼 유지. Auto 회사색은 혼합하지 않음. | +| Full Composition | 공통 제목·설명·섹션만 추가. 실제 가용 폭·높이를 기존 Core가 측정하며 비례 축소. 내부 카드·허브·화살표·현황·파견/휴직·링크·팝업·Workdesk 디자인 유지. | +| Expanded OrgChart | 데이터 / 레이아웃 / 인터랙션·직원 정보 / 미리보기의 4개 섹션. 기존 FilterBar·Label·Select·Checkbox·Button·모든 이벤트와 옵션 유지. 기존 Zoom/Pan/Fit 도구는 트리 안에 유지. | + +## 재사용한 공통 스타일 + +실제 대표 페이지 3개인 Filter Bar, Page Header, Loading Screen을 비교했다. +`src/components/story-page.tsx`의 `StoryPage / StoryPageHeader / StorySection`과 기존 CSS를 그대로 재사용한다. + +- 제목 28px / 700 / 36px, 설명 14px / 20px +- 콘텐츠 envelope 최대 1280px(양측 gutter 포함), gutter 16–40px +- 섹션 간격 32px, 섹션 header 하단 12px / 본문 간격 16px +- 기존 semantic 테두리·Typography·배경, fullscreen bootstrap +- 별도 페이지 디자인 시스템이나 공통 Theme 변경 없음 + +추가 CSS는 Story 전용 최대 폭·미리보기 host, 접근성 legend, 팝업 설정 폭에 한정한다. +기존 `body:has(.hm-orgchart-story-full)` 배경 적용을 제거하고 고정형 배경은 해당 견본 안에 유지한다. +공통 CSS가 Shadow DOM의 폰트·카드·색·Border·치수를 변경하지 않는 것을 실제 computed 값으로 확인했다. + +## 변경 범위 검증 + +| 검사 | 결과·근거 | +|---|---| +| 메뉴 위치·Depth·4개 순서·중복·내부 기본 숨김 | **PASS**, [최종 manager 8/8](../../test-results/org-chart-p8/browser-manager.json) | +| 기존 URL·최종 CSF 연결 | **PASS**, [최종 route 5/5](../../test-results/org-chart-p8/browser-route-final.json). 4개 기존 공개 URL의 실제 제목·Core 견본 및 오류 없음 확인. | +| 4페이지와 반응형 | **PASS**, 390/768/1280/1440px × 4페이지. document 가로 overflow 없음, 제목/컨트롤 줄바꿈 정상. | +| 기존 Pattern과 스타일 비교 | **PASS**, 실제 공통 frame·Typography·spacing·border·gutter 비교. | +| Fixed 표시·비례 축소·잘림·내부 스크롤 | **PASS**, 4폭에서 전체 stage/card/summary bounds와 휠 후 offset 확인. [Full 표적 6/6](../../test-results/org-chart-p8/browser-full-repair.json) | +| Expanded 최소 기능 | **PASS**, 20→100명 draft/apply, TB/LR/Hybrid, 초기 Depth, Enter/Space 접기/펼치기, 독립 Zoom/Pan, 실제 pointer Pan, Fit. | +| HMDesign 직원 팝업 | **PASS**, 실제 공개 HM Dialog/Portal·Avatar·Button. 직원 identity, 단일 팝업, Tab focus trap, ESC/닫기, 카드 focus 복귀, 390px 표시 확인. Vanilla Core 책임 분리 유지. | +| P7 소스·디자인 보존 | **PASS**, 기준 803개 파일 중 798개 동일. 변경 5개는 Storybook 설정/Story/CSS. Core·React 패키지·카드 SVG·회사색·직원 JSON·흰 타이틀·Theme 모두 동일. Expanded 상태/데이터 로직 및 컨트롤 속성 21개 보존. | +| Typecheck | **PASS**, 변경 Story·설정·새 CSF와 의존 helper를 포함한 [범위 config](../../test-results/org-chart-p8/story-tsconfig.json), 최종 구조 기준. | +| Storybook production build | **PASS**, [최종 build log](../../test-results/org-chart-p8/build.log). 기존 대형 asset의 chunk-size 경고는 유지. | +| diff·Git 상태 | **PASS**, `git diff --check`, staged 0, branch/HEAD 동일. | +| 신규 브라우저 오류 | **0**, 실제 검증의 console/page error. | + +P7의 단위 25/25·Core/React build 및 기존 P5/P6/P7 전체 회귀는 재실행하지 않았다. +20/100/300명·2,100명 데이터 정책과 배치 알고리즘은 소스 동일 및 기존 P7 결과로 보존 확인하며, 이번에 성능을 새로 측정한 것으로 보고하지 않는다. + +### 초기 결과와 해결 근거 + +[초기 runtime](../../test-results/org-chart-p8/browser-initial-runtime.json)은 83 PASS / 6 FAIL이었다. +실제 메뉴 위치 문제 1건은 표준 CSF root 분리로 수정하고 manager 범위만 다시 검사했다. +나머지 5건은 runner가 auto margin의 CSSOM 표현값 및 CSS transform의 숨긴 원본 scroll extent를 화면 결함으로 집계한 경우였다. Full의 실제 양측 gutter, 표시 bounds, 휠·scroll offset을 표적 확인해 해결했으며 제품 CSS를 변경하지 않았다. +서로 다른 실행의 PASS 수를 단순 합산하지 않는다. [최종 증거 연결](../../test-results/org-chart-p8/browser-final-summary.json)에 초기·표적 raw와 해결 사유를 남겼다. + +초기 6057 sandbox 서버 접속 실패와 6058 사용 중 오류는 환경 문제였다. 화면 검사 전 network raw를 보존하고, 접근 가능한 6061 서버에서 실제 검증했다. + +## Guard 원본·기존 FAIL·NOT VERIFIED + +최종 구조 변경 후 PARTIAL/STORYBOOK Guard로 영향 범위만 확인했다. +[원본 Guard](../../test-results/org-chart-p8/guard.json)의 **status/staticStatus는 FAIL**, runtimeStatus는 **NOT VERIFIED**다. 원본을 PASS로 덮어쓰지 않았다. + +- 변경 P8 파일의 issue: **0** +- 변경 전 Core의 TOKEN-001 **10 FAIL**, 동적 Token/공통 helper class의 TOKEN-002 **10 NOT VERIFIED** +- 해당 Core·공통 helper 소스는 시작 hash와 동일. Story용 Core 내부 import와 Shadow stylesheet/동적 선언의 정적 분석 경계에 대한 기존 진단을 보존한다. +- styleEvidence: **STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT NOT VERIFIED / FINAL NOT VERIFIED** +- 실제 페이지 스타일 동등성과 Shadow 격리의 브라우저 PASS는 별도 근거다. 디자인 의도 승인 또는 전체 Guard runtime 승인으로 확대하지 않는다. + +[범위 분류](../../test-results/org-chart-p8/guard-scope.json), [소스 보존·Git·ID audit](../../test-results/org-chart-p8/final-state.json)을 연결했다. +기존 삼안·장헌산업 제목 대비 진단, Compact **NOT SUPPORTED**, 전체 Fixed 픽셀/역사 회귀, 모든 옵션 조합, 물리 기기·스크린리더·다른 브라우저, 2,100명 장시간/peak memory, SSR/hydration·운영 배포는 기존 미검증 상태를 유지한다. + +## 실제 화면 + +- [좌측 메뉴 전체](../../test-results/org-chart-p8/manager-full-sidebar.png), [좌측 메뉴](../../test-results/org-chart-p8/manager-sidebar.png) +- [Overview](../../test-results/org-chart-p8/overview-1440.png), [390px](../../test-results/org-chart-p8/overview-390.png) +- [Department Colors](../../test-results/org-chart-p8/department-colors-1440.png), [390px](../../test-results/org-chart-p8/department-colors-390.png) +- [Full Composition](../../test-results/org-chart-p8/full-composition-1440.png), [390px](../../test-results/org-chart-p8/full-composition-390.png) +- [Expanded OrgChart](../../test-results/org-chart-p8/expanded-org-chart-1440.png), [390px](../../test-results/org-chart-p8/expanded-org-chart-390.png) +- [실제 HM Dialog](../../test-results/org-chart-p8/expanded-hm-dialog.png), [390px Dialog](../../test-results/org-chart-p8/expanded-hm-dialog-390.png) + +현재 미리보기: [Overview](http://127.0.0.1:6061/?path=/story/orgchart--logo-personnel-summary), [Department Colors](http://127.0.0.1:6061/?path=/story/orgchart--department-colors), [Full Composition](http://127.0.0.1:6061/?path=/story/orgchart--full-composition), [Expanded OrgChart](http://127.0.0.1:6061/?path=/story/orgchart--expanded-org-chart). +Overview는 기존 `orgchart--logo-personnel-summary` URL을 유지한다. + +## P8 변경 파일 + +기존 미커밋 P7 변경과 아래 P8 delta를 구분한다. + +| 파일 | P8 변경 | +|---|---| +| `.storybook/preview.tsx` | OrgChart root 및 4개 페이지 경로 정렬, 기존 leaf 정렬 정책 유지 | +| `src/org-chart/org-chart-p1.stories.tsx` | Overview 단일 CSF, 기존 ID 보존 | +| `src/org-chart/org-chart-expanded.stories.tsx` | 공통 섹션·root title·기존 ID | +| `src/org-chart/org-chart-stories.css` | 최소 host 규칙, body 배경 범위 축소 | +| `src/org-chart/org-chart-expanded-story.css` | 기존 P7 CSS 보존 + legend/설정 host | +| `src/org-chart/org-chart-page-previews.tsx` | 기존 페이지 함수를 공통 재사용 helper로 이동 | +| `src/org-chart/org-chart-department-colors.stories.tsx` | 단일 페이지 CSF, 기존 ID | +| `src/org-chart/org-chart-full-composition.stories.tsx` | 단일 페이지 CSF, 기존 ID | +| `src/org-chart/org-chart-overview-internal.stories.tsx` | 기존 내부 3개 CSF·ID·기본 숨김 유지 | +| `examples/org-chart/verify-p8.mjs` | 요청한 화면/최소 기능 검증, 실패 범위 선택 실행 | +| `docs/org-chart/implementation-report-p8.md` | 결과·증거·시간 기록 | + +`test-results/org-chart-p8`의 baseline·raw JSON·캡처·static build·logs는 생성된 검증 산출물이다. +**Stage·Commit·Push·Merge·main checkout·Pull·Reset·Rebase·Force Push·PR은 미진행. P8에서 종료한다.** diff --git a/docs/org-chart/implementation-report-p9-1.md b/docs/org-chart/implementation-report-p9-1.md new file mode 100644 index 0000000..6ea7760 --- /dev/null +++ b/docs/org-chart/implementation-report-p9-1.md @@ -0,0 +1,83 @@ +# HMDesign OrgChart P9.1 — Department Colors 간소화 · Full Composition 중앙 정렬 + +RESULT: **PASS — P9.1 변경 범위.** + +두 페이지의 Storybook 표시만 수정했다. 기존 Core/React 카드·연결선·통계·Expanded 소스와 P7~P9 결과를 보존했다. Expanded 추가 디자인·기능 작업은 시작하지 않았다. + +## 기준 및 시간 + +- 범위: `STORYBOOK_ONLY`, 실제 React Storybook. +- 경로: `D:\디자인시스템\HMDesign-OrgChart` +- 브랜치: `feature/orgchart-package` +- 시작/완료 HEAD 및 기존 remote-tracking feature HEAD: `7614507583927a25a270963a74ff044be0557b9f` +- origin: `https://gitea.hmac.kr/b25027/HMDesign.git`. 이번 원격 조회/Fetch 없음. +- Started At: `2026-10-13 10:35:50 KST` +- Completed At: `2026-10-13 10:48:05 KST` +- Actual Duration: `12분 15초` +- Estimated Duration: `25분` — 확인 5분 / 수정 10분 / 검증 10분 +- Variance: `예상보다 12분 45초 짧음` + +## Department Colors + +- 기존 조직 그룹 타이틀, 1Depth 그룹장 대형 카드 146×68, 2Depth 소형 직원 카드 108×52만 남겼다. +- 제거: 장문 페이지/섹션 설명, 표시 옵션/긴 이름 검증 버튼, Core 예제 안내 2개, 별도 대표자 견본, 출처·버전·selector, CSS 변수/HEX/Gradient legend와 구현 설명, 카드 자산/크기 caption, 대비 MODEL 비교·진단/보조 안내. +- Story 전용 `presentation="design"`에서 생성된 예제 DOM만 정리한다. 카드 하위 DOM·SVG/로고·Typography·6개 조직 색/Gradient와 Core generator는 그대로다. 삭제된 대비 요소를 참조하는 updater는 이 옵션에서 호출하지 않는다. +- 2행 subgrid로 같은 행 제목/카드를 정렬한다. 기존 카드 grid margin-top 12px와 공통 StoryPage 제목·여백/섹션 스타일을 유지한다. 390px 단일 열에서 카드 잘림/가로 overflow 없음. + +## Full Composition 실측 + +측정 기준: 실제 6개 tree의 조직 헤더와 직원 카드 `getBoundingClientRect()` 합집합. 폭 1920인 빈 forest/Stage 박스 중심을 본문 중심으로 사용하지 않았다. + +원본 Stage 내부 본문 bounds는 X=32~1835, 중심 X=933.5이다. Stage 중심 X=960과 차이가 있으며, 높이 제한으로 축소된 Stage가 좌측에 붙는 경우 추가 여백도 발생했다. + +| 브라우저 크기 | 실제 가용 영역 X/폭 | 가용 중심 | 수정 전 본문 중심 | 수정 전 오차 | 수정 후 본문 중심 | 수정 후 오차 | +|---|---:|---:|---:|---:|---:|---:| +| 1920×1080 | 360 / 1200 | 960 | 943.4375 | -16.5625 | 960.00003 | +0.00003px | +| 1440×900 | 120 / 1200 | 720 | 701.52458 | -18.47542 | 719.99991 | -0.00009px | +| 390×844 | 16 / 358 | 195 | 190.05885 | -4.94115 | 195.00026 | +0.00026px | + +Story 전용 helper가 본문 중심을 실측하고 전체 Stage에 독립 CSS scale/translate를 적용한다. 기존 Core의 가로/세로 fit transform은 유지하며, 본문 중심에서 Stage 좌·우 끝까지의 최대 거리에 맞춰 동일 비율을 추가 보정한다. Stage 전체/통계까지 영역 안에 들어오도록 계산하며 overflow 규칙을 추가·변경하지 않았다. + +- 원본 1920×914.5 유지. DOM offsetHeight의 정수 반올림 915와 computed height 914.5를 각각 확인. +- 카드/조직별 좌표 수정 없음. 대표자·허브·조직·연결선·협업 화살표·장식·우측 통계·좌측 하단 파견/휴직·하단 관계사 집계를 전체 Stage와 함께 이동. +- Stage 내부 DOM SHA-256, 자산 hash와 152개 요소의 정규화 상대 좌표/크기·computed style 보존. 3개 폭에서 부적절한 겹침 0. +- 비례 축소와 전체 실측 bounds 포함, 문서 및 사용자 조작 가능한 내부 가로/세로 스크롤 없음. 원래 fitted wrapper의 숨김 overflow에서 보고되는 변환 전 layout scrollWidth는 실제 스크롤로 오인하지 않고, 원본 동일 규칙/화면 전체 포함/휠 offset 0을 함께 확인. +- 같은 화면을 remount 없이 1920×1080→390×844→1440×640→1440×900으로 변경해 중앙 정렬/균일 축소/스크롤 없음 확인. 10프레임 동안 Stage 스타일 mutation 0, observer 안정성 확인. + +## 변경 파일 및 보존 + +| 파일 | 이번 변경 | +|---|---| +| `src/org-chart/org-chart-page-previews.tsx` | Department 설명/옵션 제거 및 디자인 모드 선택, Full Composition 정렬 helper 연결. Overview JSX 보존 | +| `src/org-chart/core-component-preview.tsx` | Department에만 적용되는 선택형 디자인 예제 간소화. 기존 workdesk/standalone 경로 보존 | +| `src/org-chart/center-composition-preview.ts` | 신규 Story-only 실측/Stage 정렬 helper와 observer cleanup | +| `examples/org-chart/verify-p9-1.mjs` | 신규 범위 검증/수정 전후 캡처/동일 mount 리사이즈 runner | +| `docs/org-chart/implementation-report-p9-1.md` | 이 보고서 | + +시작 시점 authored 파일 811개 중 기존 파일 2개만 변경, 809개 SHA-256 동일. 신규 authored 파일 3개. Core/React 패키지, 카드·색상·공통 CSS, Expanded Story/CSS/기능, 메뉴/ID 설정과 P7~P9 보고서/runner는 변경하지 않았다. [시작 snapshot](../../test-results/org-chart-p9-1/baseline.json), [최종 audit](../../test-results/org-chart-p9-1/final-state.json). + +## 변경 범위 검증 + +| 검증 | 결과 | +|---|---| +| 두 페이지 실제 Chromium | **44/44 PASS**, 신규 console/pageerror 0. [원본](../../test-results/org-chart-p9-1/browser.json) | +| 동일 mount 리사이즈/스타일 안정성 | **17/17 PASS**, 신규 console/pageerror 0. [원본](../../test-results/org-chart-p9-1/browser-resize.json) | +| 변경 Story/helper Typecheck | **PASS**: `node node_modules/typescript/bin/tsc -p test-results/org-chart-p9-1/story-tsconfig.json` | +| Storybook production Build | **PASS**: `npm.cmd exec -- storybook build -o storybook-static --disable-telemetry`. [log](../../test-results/org-chart-p9-1/build.log), 기존 500kB chunk 경고/플러그인 timing 진단 유지, build 오류 없음 | +| Storybook 공개 메뉴 | **PASS**, 기존 Overview / Department Colors / Full Composition / Expanded OrgChart 4개 ID/index 유지. manager 설정 동일 hash와 P8 메뉴 배치 PASS 재사용 | +| Git diff/공백 | **PASS**, `git diff --check` 및 신규 authored 파일 공백 확인 | +| Git 보호 | **Stage·Commit·Push·Merge·main 수정/checkout·PR·Reset·Rebase·Force Push 모두 미진행**, staged 파일 0, branch/HEAD 동일 | + +P7~P9 정상 결과는 동일 소스 근거와 함께 재사용했다. 전체 Guard·과거 브라우저 suite·Expanded 상호작용·2,100명 성능 검사는 반복하지 않았다. + +## NOT VERIFIED / 검토 대기 + +- P9 Guard 원본의 FAIL / RUNTIME·DESIGN_INTENT·FINAL NOT VERIFIED 및 기존 접근성 대비 잔여 사항을 그대로 보존한다. 이번 브라우저 PASS로 원본 상태를 덮어쓰지 않았다. +- 최종 사용자 디자인 승인, 다른 브라우저/실기기/스크린리더/SSR·배포는 **NOT VERIFIED**. +- Expanded 기능은 소스 hash 보존 및 P7~P9 PASS 재사용 근거다. 이번 기능 재검증/추가 디자인 수정은 진행하지 않았다. 사용자 검토를 기다린다. + +## Storybook 및 화면 + +- [Department Colors](http://127.0.0.1:6061/?path=/story/orgchart--department-colors): [1920px](../../test-results/org-chart-p9-1/after-department-1920.png), [1440px](../../test-results/org-chart-p9-1/after-department-1440.png), [390px](../../test-results/org-chart-p9-1/after-department-390.png) +- [Full Composition](http://127.0.0.1:6061/?path=/story/orgchart--full-composition): [1920px](../../test-results/org-chart-p9-1/after-fixed-1920.png), [1440px](../../test-results/org-chart-p9-1/after-fixed-1440.png), [390px](../../test-results/org-chart-p9-1/after-fixed-390.png) +- 수정 전 캡처는 같은 결과 폴더의 `before-*.png`로 보존. diff --git a/docs/org-chart/implementation-report-p9-2.md b/docs/org-chart/implementation-report-p9-2.md new file mode 100644 index 0000000..4fc6dec --- /dev/null +++ b/docs/org-chart/implementation-report-p9-2.md @@ -0,0 +1,112 @@ +# HMDesign OrgChart P9.2 — Full Composition 중앙 정렬 · 회사 선택 통합 + +RESULT: **PASS — 요청한 P9.2 변경 범위.** 기존 Guard FAIL/NOT VERIFIED 및 사용자 디자인 승인 상태는 별도로 유지한다. + +## 작업 기준·시간 + +- 범위: `STORYBOOK_ONLY`, 기존 실제 React Storybook 수정. +- 작업 폴더: `D:\디자인시스템\HMDesign-OrgChart` +- 브랜치: `feature/orgchart-package` +- 시작/완료 HEAD: `7614507583927a25a270963a74ff044be0557b9f` +- origin: `https://gitea.hmac.kr/b25027/HMDesign.git`. 원격 조회·Fetch·Pull 없음. +- Started At: 2026-10-13 10:53:44 KST +- Completed At: 2026-10-13 11:06:16 KST +- Actual Duration: 12분 32초 +- Estimated Duration: 최초 35~50분 — 구조 확인 8분 / 수정 15~20분 / 검증·캡처·보고 12~22분. +- Variance: 예상보다 22분 28초~37분 28초 짧음. + +## Full Composition 원인·수정 + +P9.1은 6개 조직 카드의 시각적 합집합 중심을 전체 Stage 중심으로 취급했다. 가용 폭도 부모의 border-box 폭으로 구해 Border/Padding이 있을 때 실제 콘텐츠 영역과 달라졌다. 따라서 1920·1440px에서 Stage가 약 16.12px 오른쪽으로 이동했고, 비대칭 여백에서 약 51.44px 오차와 실제 콘텐츠 밖 배치가 발생했다. + +실제 사용자에게 보이는 회색 배경 `section.hm-orgchart-story-full[aria-label="Full Composition"]`을 Content Wrapper로 사용한다. 경로는 StoryPage → StorySection의 `.sb-page-section-body` → 이 section → React OrgChart host/Shadow DOM → Core Scale Wrapper `.hm-orgchart-composition` → `.hm-orgchart-stage[data-workdesk-artwork]`다. + +Wrapper의 BoundingRect에서 왼쪽 Border·Padding을 더하고 오른쪽 Border·Padding을 빼 콘텐츠 가용 영역을 계산한다. 그 중심과 변환된 전체 Stage BoundingRect 중심의 차이를 실측해 전체 Stage 하나에 CSS translate를 적용한다. 임의 고정 이동값·조직별 좌표 수정이 없으며 Core transform/width-height fit과 원본 1920×914.5px을 유지한다. P9.1의 카드 중심에 맞춘 추가 scale 보정은 제거했다. + +| 화면 폭 | Wrapper Left | Wrapper Right | Wrapper Center X | Stage Left | Stage Right | Stage Center X | 수정 전 오차 | 수정 후 오차 | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| 1920px | 360.000 | 1560.000 | 960.000 | 360.000000 | 1560.000000 | 960.000000 | +16.117645 | 0.000000 | +| 1440px | 120.000 | 1320.000 | 720.000 | 121.967201 | 1318.032753 | 719.999977 | +16.117462 | -0.000023 | +| 390px | 16.000 | 374.000 | 195.000 | 16.000000 | 374.000000 | 195.000000 | +4.808695 | 0.000000 | + +canonical 화면에서 Wrapper 중심이 Viewport 중심과 우연히 일치한다. 기준이 Viewport가 아님은 비대칭 조건에서 확인했다: 1440px Viewport 중심720, 실제 콘텐츠 중심680, Stage 중심680. Padding만 재분배하면 콘텐츠/Stage 중심714로 같이 이동한다. 1010/390px 동일 mount resize에서도 오차 최대0.000016px이다. + +- Full Composition 브라우저 **43/43 PASS**, canonical3개+비대칭 Border/Padding+동일 폭 Padding 재분배+1010/390 resize7조건. +- 152개 내부 요소의 DOM·정규화 상대 좌표·computed style 및 이미지139개의 SHA-256을 수정 전과 비교해 일치. +- 카드43개·조직6개·연결경로5개·대표자·허브·협업 화살표·우측통계·좌측파견/휴직·하단관계사·배경/장식 유지. +- 실제 rendered Stage와 모든 측정 콘텐츠가 부모 콘텐츠 영역에 포함됨. 잘림·카드/통계의 부적절한 충돌0건, 문서/사용 가능한 내부 스크롤바0건. +- 기존 Core `overflow:hidden`은 변경하지 않았다. transform 전 자연1920×915 layout 때문에 Scale Wrapper raw scrollWidth/scrollHeight가 client 크기보다 큰 기존 값은 보존된다. 이를 raw overflow0으로 보고하지 않으며, 실제 변환 후 Stage/콘텐츠 포함 검사로 잘림이 없음을 별도로 증명했다. +- 정렬 후12프레임 Stage style mutation0, observer 반복 루프 없음. +- [수정 전 측정](../../test-results/org-chart-p9-2/before-composition.json), [수정 후 원본](../../test-results/org-chart-p9-2/browser-composition.json). + +## Expanded 회사 선택 + +외부에 펼쳐진 회사 체크박스7개와 전체 회사/선택 해제 버튼을 제거하고, 기존 FilterBar 데이터 범위·직원 수 사이에 회사 선택 드롭다운1개를 배치했다. 공개 MultiSelect export가 없고 Select는 단일값 API이므로 실제 HM `Popover + Checkbox + Label + Button + Separator`를 렌더링한다. 기존 library 의존성을 사용하며 추가 설치 없음. + +실제 P5-0 preset ID/이름 `hanmac/한맥기술, saman/삼안, jangheon-industry/장헌산업, jangheon/장헌, ptc/피티씨, halla/한라산업개발, baron/바론컨설턴트`를 그대로 사용한다. + +| 선택 상태 | 닫힘 요약 | 전체 선택 상태 | 실제 결과 | +|---|---|---|---| +| 7개 | 전체 회사 (7개) | checked | 전체 회사 Root/기존 합산20명 | +| 1개 | 한맥기술 또는 실제 선택회사명 | mixed | 해당 회사만 표시 | +| 2개 | 2개 회사 선택 | mixed | 한맥기술+삼안 반영 | +| 0개 | 회사를 선택하세요 | unchecked | 빈 forest·0명·오류 없음 | + +- 전체 선택·해제·단일·복수·개별 제외·부분 선택에서 전체 선택을 확인했다. 닫고 다시 열어도 상태 유지. +- 회사 선택은 draft/applied의 companyIds에 즉시 반영한다. 데이터 범위·직원 수의 기존 draft→데이터 적용 정책은 보존한다. 미적용 범위/인원수를 바꾼 뒤 회사만 체크해도 적용된 범위/인원수는 그대로다. +- 회사별100명: 한맥기술100명 → 삼안 추가 즉시200명. 회사별20/300명도 단일회사 실제 확인. +- 전체 합산20/100/300명: 기존 P7 weighted partition 후 선택회사만 필터하는 계약 유지, 선택회사에 재분배하지 않음. 데이터 helper/직원JSON/Core 계약 source hash 동일. +- TB/LR/Hybrid 대표모드에서 선택된6개 회사/직원 및 제외된회사 반영 확인. 흰 제목·연결 방향별 Border, 그룹 접기/Space 펼치기, HM 직원 Dialog identity/ESC/카드focus복귀, 실제 Zoom·pointer Pan·Fit PASS. +- Enter로 열기, Tab으로 전체/회사checkbox 접근, Space 조작, ESC/trigger 클릭/외부 클릭 닫기, ESC trigger focus복귀 PASS. 접근성 이름 및 aria-checked true/mixed/false 제공, 기존 HM focus 스타일 사용. +- 실제 public Popover Portal 사용. 1920/1440/768/390px에서 표시 및 요청한 닫힘/열림/전체/부분선택 캡처. 1920/768/390px 모든8개checkbox의 panel 포함·viewport 포함·가로overflow없음 확인. +- Expanded 최초 원본44PASS/1FAIL 중 FAIL은 검증 코드가 존재하지 않는 `.hm-orgchart-empty` 안내요소를 찾은 문제였다. 기존 Core empty 계약은 자식이 없는 forest이므로 probe만 수정하고 실패한 항목만 재실행2/2PASS. 앱 코드는 변경하지 않았으며 성공한 테스트를 재실행하지 않았다. **최종45개 항목PASS**. 원본 실패기록은 보존하고 [종합/provenance](../../test-results/org-chart-p9-2/verification-summary.json)에 해소 근거를 연결했다. +- [최초 원본](../../test-results/org-chart-p9-2/browser-expanded.json), [empty항목 보정](../../test-results/org-chart-p9-2/browser-expanded-empty.json). + +## 기술 검증·보존 + +| 항목 | 결과 | +|---|---| +| 변경 Story/helper Typecheck | PASS — `node node_modules/typescript/bin/tsc -p test-results/org-chart-p9-2/story-tsconfig.json` | +| Storybook production build | PASS — `npm.cmd exec -- storybook build -o storybook-static --disable-telemetry`. 기존500kB chunk 경고/plugin timing 진단 유지, 오류없음 | +| 신규 console/pageerror | 두 변경 및 보정/변수근거 실행 모두0건 | +| 기존 공개메뉴·StoryID | Overview / Department Colors / Full Composition / Expanded OrgChart4개 동일. OrgChart root/Patterns 다음 메뉴설정 hash 동일, 기존P8 manager증거 재사용 | +| Fixed/Auto CSS 격리 | Core/React 패키지와 공통CSS hash 동일. 신규StoryCSS는 설정컨트롤/Portal만 대상으로 하며 Shadow 내부 Fixed/Auto 카드·레이아웃을 변경하지 않음 | +| Overview·Department Colors | JSX/스타일/helper/CSF hash 동일. P9.1 간소화 결과 보존 | +| 코드리뷰·변경범위 | 독립 read-only 리뷰 finding0. 소스3개만 수정, baseline 나머지 파일 SHA-256 동일 | +| diff/공백 | `git diff --check` PASS 및 신규 authored파일 whitespace 확인 | + +Core/React 데이터 생성·레이아웃 알고리즘·Theme·카드SVG·회사색은 수정하지 않았다. 기존 P7~P9.1 PASS를 동일 소스 근거로 재사용하며 전체2100명 성능·과거전체suite·전체Guard는 반복하지 않았다. + +## Guard·미검증 + +이번 PARTIAL/STORYBOOK Guard 원본은 **status/staticStatus/runtimeStatus NOT VERIFIED**, 신규FAIL0. 원본 issue2개: + +- `TOKEN-001 NOT VERIFIED`: StoryCSS의 `--radix-popover-content-available-height`. HM 공개 Popover/Radix가 런타임에 선언하는 위치변수이므로 연결consumerTheme 정적 선언만으로 확인되지 않는다. 실제 변수값과 maxHeight 일치를 [추가 런타임 근거](../../test-results/org-chart-p9-2/browser-expanded-token.json)로 확인했다. 원본 미검증을 덮어쓰지 않았다. +- `TOKEN-002 NOT VERIFIED`: 기존 StoryPage 동적class site. 소스불변. + +styleEvidence는 **STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT NOT VERIFIED / FINAL NOT VERIFIED**, 결합styleVerification은 생성하지 않았으며 NOT VERIFIED다. 실제 computed/상호작용PASS는 사용자 디자인의도 승인으로 확대하지 않는다. + +기존 P9 Guard의 status/staticStatus **FAIL**, runtime **NOT VERIFIED**, TOKEN-00110FAIL/TOKEN-00210NOT VERIFIED 및 패키지CSS SCOPE-0012NOT VERIFIED 원본/보고서/hash를 그대로 보존한다. 이번 PARTIAL 검사가 이전 전체범위 결과를 대체하지 않는다. 삼안·장헌산업 흰 제목 대비 잔여진단, Compact NOT SUPPORTED도 유지한다. + +사용자 최종 디자인 승인·다른브라우저/실기기/스크린리더·모든옵션조합·SSR/배포는 NOT VERIFIED. 요청범위 완료 후 사용자 검토 대기하며 P10은 시작하지 않았다. + +## 변경파일 + +| 파일 | P9.2 변경 | +|---|---| +| src/org-chart/center-composition-preview.ts | 실제부모 content-box 기준 전체Stage 중앙정렬 | +| src/org-chart/org-chart-expanded.stories.tsx | HM 회사선택Popover·전체/부분/미선택·즉시회사반영 | +| src/org-chart/org-chart-expanded-story.css | trigger/요약/Portal 콘텐츠레이아웃 | +| examples/org-chart/verify-p9-2-composition.mjs | 중앙정렬 수정전후·포함·내부보존·scroll/resize검증 | +| examples/org-chart/verify-p9-2.mjs | 회사선택·데이터·키보드·레이아웃·Portal 검증 | +| docs/org-chart/implementation-report-p9-2.md | 이 보고서 | + +test-results/org-chart-p9-2 및 storybook-static은 생성검증산출물이다. [시작baseline](../../test-results/org-chart-p9-2/baseline.json), [최종audit](../../test-results/org-chart-p9-2/final-state.json), [typecheck log](../../test-results/org-chart-p9-2/typecheck.log), [build log](../../test-results/org-chart-p9-2/build.log), [Guard원본](../../test-results/org-chart-p9-2/guard.json). + +**Stage·Commit·Push·Merge·main checkout·PR·Pull·Reset·Rebase·Force Push 모두 미진행.** 시작과완료branch/HEAD 동일, staged파일0. 기존미커밋P7~P9.1변경 보존. + +## 실제 Storybook·캡처 + +- [Full Composition](http://127.0.0.1:6062/?path=/story/orgchart--full-composition): [1920px](../../test-results/org-chart-p9-2/after-full-composition-1920.png), [1440px](../../test-results/org-chart-p9-2/after-full-composition-1440.png), [390px](../../test-results/org-chart-p9-2/after-full-composition-390.png). +- [Expanded OrgChart](http://127.0.0.1:6062/?path=/story/orgchart--expanded-org-chart): [닫힘](../../test-results/org-chart-p9-2/expanded-closed.png), [열림](../../test-results/org-chart-p9-2/expanded-open-all.png), [전체선택](../../test-results/org-chart-p9-2/expanded-all-selected.png), [부분선택](../../test-results/org-chart-p9-2/expanded-open-partial.png), [2회사100명씩](../../test-results/org-chart-p9-2/expanded-closed-partial.png), [390px열림](../../test-results/org-chart-p9-2/expanded-open-390.png). +- [수정전회사UI](../../test-results/org-chart-p9-2/before-expanded-1440.png). 수정전FullComposition은 같은폴더before-full-composition-1920/1440/390.png에 보존. diff --git a/docs/org-chart/implementation-report-p9-3.md b/docs/org-chart/implementation-report-p9-3.md new file mode 100644 index 0000000..96a765b --- /dev/null +++ b/docs/org-chart/implementation-report-p9-3.md @@ -0,0 +1,148 @@ +# HMDesign OrgChart P9.3 — Pan 유지 · 회사 안팎 소속 변경 Drag & Drop + +RESULT: **PASS — P9.3에서 요청한 기능 변경 범위.** 회사 간 이동 금지 조건은 2026-10-13 사용자의 최신 요청으로 취소되었다. Guard의 NOT VERIFIED와 디자인 승인 상태는 기능 PASS와 별도로 보존한다. + +## 작업 기준·시간 + +- 범위: 기존 Expanded React Storybook과 공유 Vanilla Core의 Auto 동작. 신규 업무 화면·Story·Navigation·서버 API 없음. +- 작업 폴더: `D:\디자인시스템\HMDesign-OrgChart` +- 브랜치: `feature/orgchart-package` +- 시작/완료 HEAD: `7614507583927a25a270963a74ff044be0557b9f` +- origin: `https://gitea.hmac.kr/b25027/HMDesign.git`. 원격 조회·Fetch·Pull 없음. +- Started At: 2026-10-13 11:11:19 KST +- Completed At: 2026-10-13 11:50:58 KST +- Actual Duration: 39분 39초 +- Estimated Duration: 최초 50~70분 — 분석10분 / 구현25~35분 / 통합 검증15~25분. 회사 간 허용 정책 변경 시 추가15~25분 예상 안내. +- Variance: 최초 예상보다 10분21초~30분21초 짧음. + +## 엔진·입력 처리 + +기존 `orgchart@6.0.0`의 `filterAllowedDropNodes`, `dropHandler`, `dragendHandler` 및 `nodedrop.orgchart`/`otherdropped.orgchart` 이벤트를 재사용한다. 이벤트의 기본 DOM 변경은 `preventDefault()`로 취소하고 검증한 불변 데이터로 Core를 갱신한다. 여러 회사는 독립 엔진 인스턴스이므로 모든 표시 트리에 Drop 후보를 계산하고 다른 회사 트리의 대상도 최종 검증한다. 새로운 DnD 의존성을 추가하지 않았다. + +Core의 Pointer 처리에서 화면 좌표6px 임계값, Pointer capture, 실제 Shadow DOM hit-test를 담당한다. 빈 공간에서 시작하면 기존 Pan, 직원 카드에서 시작하면 직원 이동, 그룹 타이틀에서 시작하면 그룹 이동이다. 임계값 미만의 클릭은 기존 직원 팝업·그룹 접기를 실행한다. Drag 후 발생하는 포인터 click은 차단하며 키보드 click은 유지한다. ESC·Pointer cancel·포커스 상실은 이동을 취소한다. Viewport 밖 Drop은 데이터 변경 없이 거부한다. + +브라우저 touch 에뮬레이션에서 카드 Drag와 빈 공간 Pan 모두 PASS. 자식의 implicit touch capture 변경을 상위 Viewport 취소로 오인하지 않도록 대상 요소를 구분한다. 실기기 터치는 NOT VERIFIED다. + +## 소속 변경 계약·결과 + +기존 `organization.companyId`는 조직 트리의 회사, `employee.affiliationId`는 직원의 관계사 소속이다. 조직의 회사 값은 기존 상위 조직 상속 계약으로 판정하며, 같은 회사 이동에서 파견 등 기존 affiliation을 덮어쓰지 않는다. 기존 필드만 사용하고 데이터 스키마를 확장하지 않았다. + +| 이동 | 실제 데이터 변경 | 보존 | +|---|---|---| +| 같은 회사 직원 | 대상 `organizationId` | `affiliationId`, 직원 ID·개인정보·전체 인원 | +| 회사 간 직원 | 대상 `organizationId`와 대상 회사 `affiliationId` | 직원 ID·개인정보·전체 인원 | +| 같은 회사 그룹 | 그룹 `parentId` | 하위 관계·회사·직원 소속·조직장·ID | +| 회사 간 그룹 | 그룹 `parentId`, 전체 하위 조직 `companyId`, 전체 하위 직원 `affiliationId` | 하위 조직 연결·조직장·ID·직원 총원 | + +회사 간 예시에서 `virtual-006`을 `saman-division-1`로 옮기면 실제 `organizationId=saman-division-1`, `affiliationId=saman`이 된다. Story의 회사별20명·한맥기술/삼안 표시 조건에서 한맥의10명 하위 그룹을 삼안으로 옮기면 양쪽 집계는 **한맥20→10명, 삼안20→30명, 선택 합계40명 유지**다. 숨긴5개 회사를 포함하는 세션 전체140명도 유지된다. 조직·직원 ID 중복은 없다. + +다음 이동은 이유를 표시하고 데이터 변경 없이 거부한다: 조직장 직원, 회사 Root, 자기 자신, 자손 조직, 기존 부모/소속, 없는 source/target, 잘못된 데이터 및 회사 참조. 회사 간 이동 대상의 affiliation 항목이 없으면 임의 소속을 만들지 않고 거부한다. 회사 Root는 이동 대상이 될 수 있지만 Root 자체를 옮길 수 없다. + +성공 시 검증된 새 데이터는 깊이 동결된 브라우저 상태가 된다. `OrgChartHandle.move()`, `getData()`, `onMove`를 통해 Vanilla/React에서 같은 정책을 사용한다. 원본 DataSource·JSON·서버·DB·localStorage에 기록하지 않는다. 전체 페이지 재로딩은 원래 데이터로 돌아간다. + +## 재배치·색상·집계·팝업 + +현재 데이터를 기준으로 기존 자동 레이아웃, 부모 연결선, 직접/하위 포함 인원, 회사별 인원, affiliation 현황과 직원 팝업 context를 다시 만든다. 성공한 이동 시 열린 React HM Dialog를 닫아 이전 소속 정보가 남지 않으며, 다시 클릭하면 현재 조직·회사 정보를 표시한다. 기존 회사 palette에서 대상 회사 main color를 그룹 제목과 직원의 연결 방향 강조 Border에 적용한다. palette·카드 SVG·카드 크기 변경 없음. + +이동 시 Zoom/Pan 값과 기존 조직 ID 기준 접기 상태를 보존한다. 숨긴 조직의 Drop 후보는 현재 표시 범위에서 제외한다. 이동 후 Fit은 다시 계산한 forest 전체를 포함한다. 회사 선택은 세션의 표시 필터로 처리하므로 숨겼다 다시 표시해도 소속 변경이 유지된다. `데이터 적용`은 지정 조건의 샘플을 다시 만들며 `초기화`는 최초 설정/소속/이동 안내로 돌아간다. + +| 표시 유형 | 같은 회사 직원/그룹 | 회사 간 직원 | 회사 간 하위 그룹 | 연결선·Border·Fit | +|---|---|---|---|---| +| TB | PASS | PASS | PASS | PASS | +| LR | PASS | PASS | PASS | PASS | +| Hybrid | PASS | PASS | PASS | PASS | + +Hybrid의 회사 간 직원 이동은 별도 실제 포인터 시나리오에서 회사·조직·색상·양쪽 집계·HM Dialog 갱신을 확인했다. Hybrid 시작 Depth 전체 조합, 특정 그룹 세로 배치 전체 조합은 NOT VERIFIED다. Compact 지원 범위는 기존과 동일하다. + +TB에서 Zoom100%·65%·140%, Fit 이후 Pan한 상태의 Drop을 확인했다. LR/Hybrid 대표 실제 그룹 Drag와 회사 간 이동 후 세 유형의 Fit 포함 검사를 확인했다. 모든 Zoom×모드 조합을 재검증하지 않았다. + +## 접근성·화면 + +직원 카드/그룹 타이틀에 초점을 둔 뒤 **Alt+M → 방향키 또는 Home/End로 대상 선택 → Enter/Space로 이동**, ESC/Tab으로 취소할 수 있다. 이동 가능·거부 후보는 선 모양과 텍스트로도 구분하고 live 안내를 제공한다. 성공 후 이동된 카드 또는 대상 제목으로 초점이 이동하며, 거부·취소 시 source 초점을 복구한다. Collapse가 꺼져 있어도 제목을 이동 source로 사용할 수 있다. 직원 팝업이 꺼져 있어도 키보드 이동을 위한 카드 초점을 제공한다. + +Story는 기존 HM FilterBar·Button·Checkbox·Popover·Label·Select·Separator와 실제 HM Dialog를 유지한다. 데이터 조건은 기존 FilterBar API와 맞으므로 그대로 재사용한다. 기존 네 메뉴만 유지하며 신규 Navigation은 없다. 최종 회사별 인원/이동 안내를 390·768·1280·1440px에서 확인해 문서와 텍스트 overflow가 없었다. 실제 스크린리더·운영체제 보조기술 검증은 NOT VERIFIED다. + +## 통합 검증 + +| 검사 | 결과 | 근거 | +|---|---|---| +| 새 이동 정책 단위 테스트 | 11/11 PASS | `node packages/org-chart/test.mjs src/data/moves.test.ts`, 실행 출력은 도구 기록에 보존 | +| Vanilla Core 변경 검사 | 51개 고유 assertion PASS / FAIL0 | [통합 결과](../../test-results/org-chart-p9-3/verification-summary.json), `core-browser*.json` | +| 실제 React Storybook 이동/입력 검사 | 117개 고유 assertion PASS / FAIL0 | 같은 통합 결과, `browser-editing*.json` | +| Fixed·기존 메뉴·최초 반응형 최소 회귀 | 9/9 PASS | [원본](../../test-results/org-chart-p9-3/preservation.json) | +| 최신 안내·회사별 집계 반응형 | 5/5 PASS | [원본](../../test-results/org-chart-p9-3/responsive-current.json) | +| Core Typecheck·Build | PASS | `typecheck-core.log`, `build-core.log` | +| React Typecheck·Build | PASS | `typecheck-react.log`, `build-react.log`; 최종 React 소스 기준 빌드 유지 | +| 변경 Story Typecheck·Storybook production build | PASS | `story-tsconfig.json`, `typecheck-story.log`, `build-storybook.log` | +| 신규 console/page 오류 | 0 | 각 브라우저 실행의 오류 결과 | +| JSON·palette·원본 artwork 불변 / 기존 작업 보존 | PASS | [SHA-256·Git 감사](../../test-results/org-chart-p9-3/final-state.json) | +| `git diff --check` | PASS | 최종 변경 상태 검사 | + +고유 assertion 합계는 원본 실행 횟수의 합이 아니다. 같은 이름은 최종 결과로 교체했다. Reset의 잘못된 root selector 검사는 명시적 이름 대응표로 수정된 실제 Root/원래 소속 검사의 PASS를 연결했다. 최신 정책으로 취소된 회사 간 거부 검사6건은 최종 합계에서 제외하고 원본을 보존한다. 단위 테스트와 브라우저 assertion 수를 하나의 테스트 수로 합하지 않는다. + +최초 실제 Drag hit-test가 detached fragment를 참조한 문제와 touch capture 문제는 소스 수정 후 실패 범위만 재검증했다. LR 검증에서 Pan 첫 좌표가 Viewport 밖으로 나간 문제와 Reset 검증의 존재하지 않는 root selector는 검증 코드만 수정했다. 초기 FAIL/진단 JSON을 삭제하거나 PASS로 덮어쓰지 않았다. 현재 통합 기능 결과에 미해결 FAIL 없음. 기존 P7~P9.2 전체 브라우저 suite·20/100/300명 전체 조합을 반복하지 않았다. + +### Guard·미검증 + +최종 `STORYBOOK / PARTIAL` Guard 대상은 변경된 Expanded Story와 Auto editing CSS다. [Guard 원본](../../test-results/org-chart-p9-3/guard.json)의 **FAIL0 / status NOT VERIFIED / staticStatus NOT VERIFIED / runtimeStatus NOT VERIFIED**를 유지한다. + +- `SCOPE-001`: package CSS가 증명된 Story graph 밖이다. 별도 Core build와 실제 Shadow DOM 브라우저 검사 결과를 제공한다. +- 기존 `TOKEN-001`: Radix의 `--radix-popover-content-available-height` 선언 범위. +- 기존 `TOKEN-002`: 공통 StoryPage의 동적 className. +- `styleEvidence`: **STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT NOT VERIFIED / FINAL NOT VERIFIED**. 결합 `styleVerification`도 NOT VERIFIED다. + +브라우저에서 회사 색상과 Border 및 editing token을 확인한 것은 해당 기능의 런타임 근거다. 전체 HM CSS 결합 디자인 승인으로 해석하지 않는다. 기존 P9~P9.2 전체 Guard FAIL/NOT VERIFIED와 보고서는 변경하지 않았다. 사용자 디자인 승인, 실제 터치 기기, 실제 스크린리더, Chrome 외 브라우저, 전체 레이아웃/옵션 조합, 300명 이상 Drag 성능, 서버 저장·SSR은 NOT VERIFIED다. + +## Fixed 보호·변경 파일 + +Fixed Full Composition의 DOM hash, 이미지139개 SHA-256, 요소152개 computed style/정규화 좌표가 P9.2 최종 스냅샷과 일치했다. 중앙 정렬·직원 카드43개·연결경로5개·충돌0 유지. Fixed에 편집 상태·Drop 후보·안내를 추가하지 않았다. 이 검사 이후 회사 간 정책 변경은 Auto 데이터/bridge/Story에 한정되므로 정상 Fixed 검사를 반복하지 않았다. + +시작 시 미커밋 authored 파일47개를 SHA-256과 패치로 보존했다. 그중 관련13개에 P9.3을 추가하고 나머지34개는 byte 단위로 동일하다. Full Composition Story·P9.2 보고서·기존 카드·palette·fixture·lockfile·package dependency는 변경하지 않았다. Git 전체 diff에는 누적 변경이 포함되므로 이번 범위를 Git 전체 경로 목록으로 간주하지 않는다. + +관련 기존 파일13개: + +```text +docs/org-chart/expanded-usage.md +packages/org-chart-react/README.md +packages/org-chart-react/src/OrgChart.tsx +packages/org-chart/README.md +packages/org-chart/src/core/engine.ts +packages/org-chart/src/core/expanded-content.ts +packages/org-chart/src/core/expanded-viewport.ts +packages/org-chart/src/core/index.ts +packages/org-chart/src/core/types.ts +packages/org-chart/src/core/vendor.d.ts +packages/org-chart/src/data/index.ts +packages/org-chart/src/styles/org-chart-expanded.css +src/org-chart/org-chart-expanded.stories.tsx +``` + +새 authored 파일7개: + +```text +docs/org-chart/implementation-report-p9-3.md +examples/org-chart/verify-p9-3-core.mjs +examples/org-chart/verify-p9-3-preservation.mjs +examples/org-chart/verify-p9-3.mjs +packages/org-chart/src/core/expanded-editing.ts +packages/org-chart/src/data/moves.test.ts +packages/org-chart/src/data/moves.ts +``` + +`packages/*/dist`, `storybook-static`, `test-results`, `_local`, logs 등은 검증 생성물이며 authored 변경 목록에서 제외한다. 기존 baseline 기록은 `_local/p9-3/baseline-files.json`과 `baseline.patch`에 남겼다. + +**Stage·Commit·Push·Merge·main checkout·PR·Reset·Clean·Rebase·Force Push 미진행.** Staged0, 시작/완료 branch·HEAD 동일. P9.3에서 종료하며 P10을 시작하지 않았다. + +## 실제 Storybook·캡처 + +- [Expanded OrgChart](http://127.0.0.1:6064/?path=/story/orgchart--expanded-org-chart): 실제 React production Storybook. +- [TB 회사 간 그룹 이동 후 Fit](../../test-results/org-chart-p9-3/tb-cross-company-fit.png) +- [LR 회사 간 그룹 이동 후 Fit](../../test-results/org-chart-p9-3/lr-cross-company-fit.png) +- [Hybrid 회사 간 그룹 이동 후 Fit](../../test-results/org-chart-p9-3/hybrid-cross-company-fit.png) +- [직원 이동 후보](../../test-results/org-chart-p9-3/tb-staff-drop-hover.png) / [직원 이동 완료](../../test-results/org-chart-p9-3/tb-staff-moved.png) +- [키보드 이동 완료](../../test-results/org-chart-p9-3/keyboard-move-complete.png) +- [최신390px](../../test-results/org-chart-p9-3/expanded-current-390.png) / [768px](../../test-results/org-chart-p9-3/expanded-current-768.png) / [1280px](../../test-results/org-chart-p9-3/expanded-current-1280.png) / [1440px](../../test-results/org-chart-p9-3/expanded-current-1440.png) +- [Fixed 보존1440px](../../test-results/org-chart-p9-3/fixed-preserved-1440.png) +- [공유 Vanilla Core](../../test-results/org-chart-p9-3/vanilla-cross-company.png) + +미리보기 서버는 `_local/p9-3/serve.mjs`로 실행한 로컬6064 세션이다. 정적 Storybook 배포나 외부 공개는 수행하지 않았다. diff --git a/docs/org-chart/implementation-report-p9-4.md b/docs/org-chart/implementation-report-p9-4.md new file mode 100644 index 0000000..8f27dbc --- /dev/null +++ b/docs/org-chart/implementation-report-p9-4.md @@ -0,0 +1,125 @@ +# HMDesign OrgChart P9.4 — Department Colors · 회사별 데이터 · Drag & Drop ON/OFF + +RESULT: **PASS — 요청한 P9.4 기능과 두 추가 수정 범위.** 기존 Guard FAIL과 미검증은 별도로 유지한다. P10과 Git 작업은 시작하지 않았다. + +## 작업 기준과 시간 + +- 폴더: `D:\디자인시스템\HMDesign-OrgChart` +- 브랜치: `feature/orgchart-package` +- 시작/완료 HEAD: `7614507583927a25a270963a74ff044be0557b9f` +- origin: `https://gitea.hmac.kr/b25027/HMDesign.git`. 원격 조회·Fetch·Pull 없음; 현재 원격 SHA는 NOT VERIFIED. +- 범위: 기존 React Storybook 두 페이지와 공유 Vanilla Core/React Auto 편집 설정. 신규 화면·메뉴·API·의존성 없음. +- Started At: 2026-10-13 12:19:16 KST +- Completed At: 2026-10-13 12:43:09 KST +- Actual Duration: 23분 53초 +- Estimated Duration: 최초 45~60분. 이후 Department 이름 및 Expanded 외곽 스타일 추가 요청도 같은 작업에 반영. +- Variance: 예상보다 21분 7초~36분 7초 짧음 + +## Department Colors + +간소화 코드가 기존 Core에서 만든 대표자 DOM을 제외하고 있었다. 그 DOM을 보존하여 대표자 → 조직 타이틀 → 대형 그룹장 → 소형 직원 순으로 표시한다. 대표자 한 개, 146×74px, 대표이사, Noto Sans KR, 대표자 뱃지 0개이며 Full Composition과 SVG·사진·Typography가 같다. 원본 카드 Presenter/SVG를 수정하지 않았다. + +실제 `src/center/center-stories.css`와 Tunnel의 `sample / stage / variant-grid` 클래스를 Department 전용 Shadow DOM에 연결했다. 공통 배경 `rgba(217,217,217,.48)`, Border `1px solid rgba(64,5,0,.14)`, Radius 8px, Shadow 없음, 내부 Padding 24px와 Gap 14px를 재사용한다. 조직별 Gradient·흰색 타이틀·직원 카드 모양·146×68 / 108×52 크기는 보존한다. 2열→모바일 1열이며 설명·Legend·대비 진단·카드별 Caption을 다시 추가하지 않았다. + +추가 이름 요청은 **Department 디자인 미리보기에서만** 처리한다. 표본 이름 `홍길동`을 `홍 길 동`으로 형식화한 표시 데이터 사본으로 Core를 호출하며, 이름의 접근성 표기는 `홍길동`이다. 표시 중인 13개 카드와 Tooltip이 같은 이름을 사용한다. 모든 직원 레코드에 이름만 적용하므로 그룹장/일반 직원에도 동일 정책이다. 원본 JSON·Employee ID·직위·직책·뱃지·조직·사진은 그대로다. 공통 EmployeeCard renderer와 Full Composition/Expanded 데이터는 변경하지 않았다. + +## Expanded 데이터 선택 + +데이터 범위 UI와 전체 합산 설명을 제거했다. 기존 HM `FilterBar / Popover / Checkbox / Label / Button / Separator / Select`를 실제 렌더링한다. 회사 선택·전체 선택/해제·복수 선택·선택 요약·ESC/외부 클릭·키보드 조작을 유지한다. 공개 helper의 `scope: 'total'` 기능과 Fixture는 보존했다. + +직원 수는 항상 회사별20/100/300명이다. 최초7개×20명=140명, 1개×20/100/300명, 2개×100명=200명, 7개×300명=2,100명을 실제 DOM Employee ID와 회사 Root 집계로 확인했다. ID 중복과 조직장 중복 집계가 없고 0개 회사는 명시적 빈 상태다. + +회사 선택은 즉시 표시를 갱신하며 숨김·복귀 시 완료한 이동을 보존한다. 직원 수는 기존 **데이터 적용** 정책을 유지한다. 적용/초기화 때 새 예시 Source를 만들고 세션 이동을 초기화하므로 새 Fixture와 섞이지 않는다. 전체7개 회사의 세션 Source를 유지하고 선택된 회사의 현재 소속 기준 인원 합계를 별도로 표시한다. 숨긴 회사를 포함한 `전체` 세션 인원과 선택 합계를 구분한다. + +## 공유 Drag & Drop 정책 + +Core 옵션과 React prop에 `dragDropEnabled?: boolean`을 추가했으며 기본값은 **false**다. Vanilla는 `setDragDropEnabled(boolean)` / `getDragDropEnabled()`를 제공한다. React는 독립 layout effect로 같은 handle의 setter를 호출한다. 옵션 변경으로 재생성·Source 재요청·레이아웃 재계산을 하지 않는다. + +실제 HM Switch의 명칭은 **Drag & Drop**, 상태는 ON/OFF다. OFF는 pointer·Alt+M·engine drop 판정·공개 `handle.move()`에서 차단하며 공개 이동은 `EDITING_DISABLED`를 반환한다. 순수 데이터 이동 helper는 기존 계약을 유지한다. Highlight·이동 커서/단축키 안내도 상태에 맞춘다. + +ON은 기존 P9.3 엔진/검증/불변 이동을 재사용한다. 직원·그룹의 회사 안팎 이동, 하위 조직·직원 소속, 대상 회사색·인원 집계·Border·연결선·배치를 유지한다. Root·자기 자신·자손/순환·잘못된 대상 등 기존 보호 정책을 교체하지 않았다. + +OFF 전환은 진행 중 pointer/keyboard 이동을 취소하고 capture·Drop 강조·pending 상태를 해제한다. Drag 후 잘못된 pointer click을 차단하며 이미 완료한 이동을 보존한다. Source load는1회, 데이터·DOM forest·Viewport가 옵션 변경 후 동일함을 Vanilla에서 확인했다. Pan·Zoom·Fit·접기/펼치기·직원 Dialog는 독립 기능이다. Fixed는 옵션을 무시하고 Auto setter/move를 거부하며 ON/OFF UI를 추가하지 않는다. + +## Expanded 외곽 스타일 추가 요청 + +「확장형 조직도 · 가상 데이터」의 외곽만 실제 Center Storybook 공통 카드의 배경·Border·Radius로 통일했다. 기존 Wrapper에 `sb-center`를 연결하고 선언된 `--sb-center-card-*` 값을 전용 `--hm-orgchart-expanded-preview-*` 입력으로 전달한다. Wrapper의 기존 간격을 보존하고 `.hm-orgchart-expanded` 외곽에서만 세 입력을 소비한다. + +내부 Viewport·직원 카드 배경·회사별 그룹 타이틀/흰 글자·컨트롤·Padding·레이아웃을 유지한다. Vanilla에서 입력을 생략하면 기존 외곽 기본값을 사용한다. 새 색상 복사·Theme/공통 Center CSS 수정·직원 카드 override는 없다. + +## 통합 검증과 증거 + +- [기본 변경 범위 브라우저](../../test-results/org-chart-p9-4/browser.json): **78/78 PASS**, console/pageerror0. Department 공통 카드 비교, 회사별 인원/선택, OFF/ON 이동·Pan·Dialog·접기·취소, TB/LR/Hybrid 대표 동작,390/768/1280/1440px, 공개4개 Story ID. +- [공유 Vanilla API](../../test-results/org-chart-p9-4/core-api-cancel-default-fixed.json): **21/21 PASS**, 기본 OFF·4종 이동 차단/허용·Source load1·capture 해제·키보드 취소·total helper 호환·Fixed 보호. +- [Fixed 최소 회귀](../../test-results/org-chart-p9-4/fixed-preservation.json): **4/4 PASS**. P9.2 DOM hash·139개 자산 hash·152개 스타일/정규화 좌표 동일, 중앙 오차 약0px, 카드43개·연결선5개·충돌0, 편집 UI 없음. +- [Department 추가 이름](../../test-results/org-chart-p9-4/browser-department-names.json): **7/7 PASS**, 13개 이름과 ID·직위·뱃지·SVG·사진·Typography·Gradient·크기 보존. +- [외곽 스타일와 이름 모바일](../../test-results/org-chart-p9-4/browser-expanded-frame-names-mobile.json): **7/7 PASS**, 실제 공통 카드와3개 computed 값 동일, 내부 카드/타이틀색 보존, OFF/ON·390px, console/pageerror0. 회사 이동은 최종 스타일 캡처를 위한 예시이며 기존 이동 PASS를 반복 집계하지 않는다. +- Core/React 패키지 Typecheck·Build PASS. 변경 Story/helper Typecheck PASS. 추가 이름과 외곽 CSS를 반영한 최종 [Storybook build](../../test-results/org-chart-p9-4/storybook-build-frame.log) PASS. 기존500kB chunk 경고와 plugin timing 진단은 유지한다. +- `git diff --check` PASS. 독립 읽기 전용 Core/React/Department 리뷰에 새 구체적 버그 없음. + +Vanilla 최초 실행은 테스트 HTML의 `id="chart"`가 `window.chart`와 충돌했고 Fixed probe가 기존 thrown Error를 반환값으로 오인했다. 앱 변경 없이 테스트만 수정했다. [원본 실패](../../test-results/org-chart-p9-4/core-initial.json)를 보존하고 실패한 구간만 재검사했다. 위 실행들의 중복 공통 오류 검사를 합산하여 고유 테스트 수로 주장하지 않는다. 추가 요청 후 기존 정상 기능 suite를 재실행하지 않았다. + +## Guard와 NOT VERIFIED + +[초기 PARTIAL Guard](../../test-results/org-chart-p9-4/guard.json): **status/staticStatus FAIL, runtimeStatus NOT VERIFIED**, 기존TOKEN-00110FAIL / NOT VERIFIED13. [P9/P9.3 비교](../../test-results/org-chart-p9-4/guard-comparison.json)에서 FAIL10 모두 기존과 같고 신규FAIL0이다. 새 Department CSS의 `?url` 로딩은 정적 graph에 증명되지 않아 SCOPE-001 NOT VERIFIED이며 실제 Shadow DOM computed 비교를 별도로 제공한다. + +추가 요청 이후에는 수정 범위만 다시 검사했다. + +- [이름 변경 Guard](../../test-results/org-chart-p9-4/guard-department-names.json): 기존FAIL10 / NOT VERIFIED10, status/staticStatusFAIL / runtimeNOT VERIFIED. styleEvidence STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT PASS(해당 정적 scope) / FINAL NOT VERIFIED. +- [Expanded 외곽 Guard](../../test-results/org-chart-p9-4/guard-expanded-frame.json): **FAIL0 / NOT VERIFIED3**, status/staticStatus/runtimeStatusNOT VERIFIED. package CSS scope, 기존Radix 위치 변수, 기존StoryPage 동적class 미검증. styleEvidence STATIC PASS / RUNTIME·DESIGN_INTENT·FINAL NOT VERIFIED. + +초기 Guard 원본은 추가 요청 이전 소스 fingerprint의 기록이며, 뒤의 수정 범위 결과와 각각 보존한다. 결합 styleVerification은 생성하지 않았고 NOT VERIFIED다. 실제 기능/computed PASS를 전체 CSS 디자인 승인으로 확대하지 않는다. 기존 전체 Guard FAIL·삼안/장헌산업 흰 제목 대비 잔여 진단·Compact NOT SUPPORTED를 보존한다. + +사용자 최종 디자인 승인·실제 터치 기기·스크린리더·Chrome 외 브라우저·모든 옵션 조합·2,100명 전체 성능/Drag 성능·서버 저장·SSR/배포는 NOT VERIFIED다. 기존 P7~P9.3의 승인 카드·배치·성능/메뉴 증거를 필요한 범위에서 재사용했다. + +## 변경 파일과 작업 보존 + +시작 baseline697개(일반684 + 승인 카드 SVG byte 사본13)를 비교했다. 이번 요청 관련 기존 파일12개 변경,685개 동일, 누락0. [Source 감사](../../test-results/org-chart-p9-4/source-preservation.json)에 파일별 SHA와 보호 범위를 기록했다. Fixture7·승인 SVG13·카드 Presenter·palette·Full Composition·Overview·메뉴·의존성·HM UI/Pattern/Theme·Center 공통 CSS와 기존 설정/Popover CSS를 보존했다. baseline에서 빠진 일부 기존 한글 경로는 별도로 기록했으며 모든 저장소 파일의 byte 보존을 주장하지 않는다. + +수정한 기존 authored12개: + +```text +docs/org-chart/expanded-usage.md +packages/org-chart/README.md +packages/org-chart/src/core/types.ts +packages/org-chart/src/core/index.ts +packages/org-chart/src/core/expanded-editing.ts +packages/org-chart/src/data/moves.ts +packages/org-chart/src/styles/org-chart-expanded.css +packages/org-chart-react/README.md +packages/org-chart-react/src/OrgChart.tsx +src/org-chart/core-component-preview.tsx +src/org-chart/org-chart-expanded.stories.tsx +src/org-chart/org-chart-expanded-story.css +``` + +새 authored5개: + +```text +src/org-chart/department-colors-preview.css +examples/org-chart/verify-p9-4.mjs +examples/org-chart/verify-p9-4-core.mjs +examples/org-chart/verify-p9-4-preservation.mjs +docs/org-chart/implementation-report-p9-4.md +``` + +`packages/*/dist`, `storybook-static`, `test-results`와 로그는 검증 생성물이다. 현재 Git 설정은 `김수현 `이며 사용자 문서의 이름과 다르지만 설정을 변경하지 않았다. [최종 Git 상태](../../test-results/org-chart-p9-4/final-state.json)를 별도로 기록했다. + +**Stage·Commit·Push·Merge·main checkout·PR·Pull·Reset·Rebase·Force Push 미진행.** 시작/완료 branch·HEAD 동일, staged0. 기존 P7~P9.3 미커밋 변경을 보존했으며 P9.4에서 사용자 검토를 기다린다. + +## 실제 Storybook과 최종 캡처 + +- [Department Colors](http://127.0.0.1:6064/?path=/story/orgchart--department-colors) +- [Expanded OrgChart](http://127.0.0.1:6064/?path=/story/orgchart--expanded-org-chart) + +요청된7개 캡처는 추가 수정까지 반영한 현재 Storybook에서 새로 촬영했다. + +1. [Department 대표자](../../test-results/org-chart-p9-4/department-representative-current.png) +2. [Department 전체 간소화/이름 통일](../../test-results/org-chart-p9-4/department-full-current.png) +3. [Expanded 데이터 범위 제거/최종 외곽](../../test-results/org-chart-p9-4/expanded-frame-scope-removed.png) +4. [회사 선택 드롭다운](../../test-results/org-chart-p9-4/expanded-frame-company-dropdown.png) +5. [Drag & Drop OFF](../../test-results/org-chart-p9-4/expanded-frame-off.png) +6. [Drag & Drop ON](../../test-results/org-chart-p9-4/expanded-frame-on.png) +7. [회사 간 이동 후 최종 화면](../../test-results/org-chart-p9-4/expanded-frame-company-moved.png) + +추가: [Department390px](../../test-results/org-chart-p9-4/department-names-390.png), [Expanded390px](../../test-results/org-chart-p9-4/expanded-frame-390.png), [Fixed1440px](../../test-results/org-chart-p9-4/fixed-preserved-1440.png). 이름/외곽 추가 이전 캡처도 원본 기록으로 보존하고 최종 캡처와 구분한다. diff --git a/docs/org-chart/implementation-report-p9-5-core-dialog-close.md b/docs/org-chart/implementation-report-p9-5-core-dialog-close.md new file mode 100644 index 0000000..d12abd8 --- /dev/null +++ b/docs/org-chart/implementation-report-p9-5-core-dialog-close.md @@ -0,0 +1,49 @@ +# P9.5 추가 — Core 직원 팝업 X 닫기 버튼 + +RESULT: PASS (요청한 닫기 버튼 및 팝업 동작 범위) + +## 변경 + +- 실제 HM Dialog의 `Button variant="ghost" size="icon-sm"`과 Lucide `XIcon`을 확인했다. HM Dialog/Button 자체는 수정하지 않았다. +- Core에서 같은 두 SVG 경로와 viewBox, Stroke 2, Round cap/join을 native DOM으로 생성한다. React 의존성은 추가하지 않았다. +- 버튼 28×28px, 아이콘 16×16px, Padding 0, 투명 Border 1px, 기본 Radius 3.2px, 상단/우측 CSS 8px를 적용했다. BoundingRect 기준 외곽에서 9px이며 팝업 Border 1px를 포함한 값이다. +- HM 선언값/상속 변수를 사용한다. 기본 색 #0a221d, Hover #f4f1ea, Focus Border #266651 + 같은 색 50%의 3px Ring, 150ms transition, 눌림 Y +1px. Vanilla fallback은 실제 HM 선언된 기본값이다. +- 우상단 absolute 버튼으로 바꾸면서 기존 텍스트 버튼의 Header 높이를 유지해 Title/본문/팝업 크기를 보존했다. 직원 정보 데이터와 닫기 이벤트 로직은 수정하지 않았다. + +## 검증 + +- React Full Composition의 Core 팝업과 React 없는 public Vanilla Core에서 각각 1440×900 / 390×900 검증: 48 PASS, 0 FAIL. +- 실제 HM Dialog의 Normal/Hover/Focus와 버튼 및 아이콘의 CSS/BoundingRect/경로를 비교했다. X 클릭, ESC, Tab/Shift+Tab trap, 직원 카드 Focus 복귀를 확인했다. +- 기존 외부 클릭 정책 유지: Backdrop 클릭은 닫히지 않는다. +- Dark Hover 및 눌림 실제 이동량만 추가 비교: React/Vanilla 4 PASS. HM은 CSS translate, Core는 transform을 사용하므로 초기 속성 이름 비교 실패를 실제 이동량 비교로 바로잡았다. 최초 결과는 states-initial.json에 보존했다. 제품 코드 추가 수정은 없었다. +- 1440px React/Vanilla 팝업: 420×276.5px → 420×276.5px. 390px React/Vanilla 팝업: 358×276.5px → 358×276.5px. 모두 Title/본문 좌표 변화 0px; 본문 시작 y=386.25px 유지. 배경/Border/Typography/데이터도 동일하다. +- 기존 닫기 버튼: 약 41.922×29.5px → 28×28px, 외곽 상단/우측 inset 25px → 9px. +- 최초 실제 1440px 기준 측정을 저장했고, 나머지 크기의 전후 비교는 저장한 기존 CSS와 기존 닫기 markup을 동일 팝업에 잠시 적용해 측정했다. +- Core Typecheck / Core Build / Storybook Build / git diff --check PASS. 신규 Console/Page 오류 0. P9.4/P9.5 전체 회귀는 반복하지 않았다. + +Guard 1회: 원본 status/staticStatus NOT VERIFIED (SCOPE-001: package 구현이 proven Storybook graph 밖). styleEvidence STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT PASS (검사 없음) / FINAL NOT VERIFIED. Guard 원본을 보존했고 위 실제 브라우저 증거를 별도로 기록했다. 디자인 최종 승인과 다른 브라우저/OS는 NOT VERIFIED. + +## 파일과 증거 + +수정 구현: `packages/org-chart/src/core/employee-info-dialog.ts`, `packages/org-chart/src/styles/employee-info-dialog.css`. + +추가 검증/보고: `examples/org-chart/verify-p9-5-core-dialog-close.mjs`, 본 문서. 생성물과 로그는 `test-results/org-chart-p9-5-core-dialog-close/`에 보관한다. + +- 측정: `result.json`, `states.json`, `baseline-popup.json`, `reference.json`. +- 캡처: `react-1440-popup.png`, `react-390-popup.png`, `vanilla-1440-popup.png`, `vanilla-390-popup.png`, `hm-reference.png`. +- 검토 URL: http://127.0.0.1:6064/?path=/story/orgchart--full-composition +- 기존 6064 서버 종료를 확인해 동일 URL의 loopback static Storybook/Core 서버를 복구했다. 사용자 기존 탭은 오류 화면의 data URL 정책으로 제어되지 않아 새 검토 탭에 실제 팝업을 열어 확인했다. + +## 보존 및 Git + +- 시작 시점 authored 파일 SHA256을 캡처했다. 기존 P9.5 레이아웃/화살촉, Expanded, HM UI, 기존 미커밋 파일은 이 작업에서 수정하지 않았다. +- 작업 중 `docs/org-chart/ai-page-generation-rules.md`의 외부 변경을 감지했다. 이 작업에서 편집/복구/덮어쓰기하지 않았다. +- `feature/orgchart-package`, HEAD `7614507583927a25a270963a74ff044be0557b9f` 유지, Staged 파일 0. Stage/Commit/Push/Merge/PR/Reset/Rebase 미진행. + +Started At: 2026-10-13 13:51:35 KST +Completed At: 2026-10-13 14:04:24 KST +Actual Duration: 12분 49초 +Estimated Duration: 15분 +Variance: -131초 + +사용자 시각 검토 대기. diff --git a/docs/org-chart/implementation-report-p9-5-full-composition.md b/docs/org-chart/implementation-report-p9-5-full-composition.md new file mode 100644 index 0000000..6709de7 --- /dev/null +++ b/docs/org-chart/implementation-report-p9-5-full-composition.md @@ -0,0 +1,118 @@ +# HMDesign OrgChart P9.5 — Full Composition 적응형 배치 + +RESULT: **PASS — 요청한 Full Composition의 배치·디자인 및 직접 영향 범위.** 기존 Expanded P9.5 보고서와 P7~P9.4 기록을 보존했다. 사용자 디자인 승인은 별도 검토를 기다린다. + +## 상단·중앙·하단 분리 + +기존 DOM에 내부 `data-composition-region`을 부여했으며 전체 DOM을 다시 만들지 않았다. 상단은 제목·기준일과 우측 인원 표, 중앙은 대표자·허브·6개 조직/직원·연결선·화살표·장식, 하단은 파견/휴직과 관계사 바로 구분한다. 상단은 콘텐츠 컨테이너의 앵커이며 sticky/스크롤 추적이 아니다. 하단 두 모듈은 각각 bottom 앵커를 유지한다. + +원본1920×914.5는 디자인 기준으로 보존한다. 하나의 배율 `min(1, 실제 가용 폭/1920, 실제 가용 높이/914.5)`로 표시한다. 세로 여유가 있으면 Stage 사용 높이를 `가용 높이/배율`로 확장하되 카드·장식·Typography를 확대하지 않는다. 실제 상단 Rect의 가장 아래 지점과 하단 Rect의 가장 위 지점 사이에서 중앙 콘텐츠 합집합 Rect의 중심을 계산한다. 기준보다 새로 확보된 높이 범위 안에서만 수직 오프셋을 적용한다. + +대표자·허브·조직 viewport·원본914.5px SVG overlay·배경 장식은 같은 중앙 오프셋으로 이동한다. meeting/grid의 bottom 좌표는 원본 기준 top 좌표로 환산했고 sky 높이도914.5로 유지한다. management 곡선은 Stage 대신 overlay 원점에서 측정하여 이동량이 두 번 적용되지 않도록 했다. 장식과 다른 요소의 전후 좌표 차이는 브라우저 서브픽셀 정밀도 안이며 grid의 bottom→top 변환에서 약0.016디자인px 반올림 차이가 있다. + +높이가 부족하면 Stage914.5와 중앙 오프셋0을 유지하여 전체 비례 축소를 재사용한다. 기준 배치가 원래 사용하는 일부 세로 범위는 가로 위치가 다른 상단/하단 모듈과 공유한다. BoundingRect 합집합만으로 충돌을 판단하지 않고 실제 카드·타이틀·고정 모듈의 x/y 교차를 검사했으며 실제 겹침은0건이다. 모든 실제 콘텐츠가 가용 영역 안에 들어옴을 검사하여 기존 `overflow:hidden`만으로 잘림을 감추지 않았다. + +P9.2 Content Wrapper 기준 가로 중앙 계산식과 전체 Stage 이동 단위는 그대로다. 1440×900의 새 소수점 배율에서 기존4자리 반올림이 `translate:1.6401↔1.6402px`로 왕복하는 현상이 확인되어0.001px 미만이면 보정 쓰기를 멈추는 조건만 추가했다. 계산 기준·공식·Stage X좌표계는 변경하지 않았다. 20프레임 style mutation은20→0건으로 안정화됐다. + +부모 컨테이너의 높이만 바뀌는 경우도 같은 ResizeObserver 경로를 사용한다. 배율이 동일해도 중앙 위치가 바뀌면 기존 `hm-orgchart-scale` 내부 위치 변경 알림을 재사용하여 이름 툴팁을 이전 좌표에 남기지 않는다. 새 팝업·새 이벤트 API·새 라이브러리는 추가하지 않았다. + +## 협업 화살표·하단 간격 + +수정 전 실제 화면에서 **5개 모두 정상 표시**되어 복원할 누락 자산은 없었다. `team-arrow-blue-short.png`2회와 blue-purple/purple-teal/teal-green각1회, 총4개 원본PNG·5개 SVG image를 보존했다. 중복 경로나 화살표를 추가하지 않았다. 로딩·SHA-256·카드/타이틀 비충돌·실제 캡처에서 화살표를 숨기기 전후의 픽셀 기여까지 확인했다. 원본 overlay z4와 조직 viewport z7은 유지하며, 수직 transform으로 생기는 axis stacking context는 z12를 명시하여 허브를 곡선 위에 유지한다. + +파견·휴직 CSS 하단 앵커70→82디자인px로 **12px 위로** 옮겼다. 실제 기준 Rect는 `(x32,y748.5,w240,h84) → (x32,y736.5,w240,h84)`다. 관계사 바는 `(x57,y835.109375,w1806,h55.390625)`와 로고·문자·높이를 유지한다. 그룹/바 간격은 **2.609375→14.609375디자인px**다. CSS Grid의12px Stage padding 때문에 실제 관계사 바의 하단 inset은24px이며 이 값도 유지한다. + +실제1920×914.5 콘텐츠 벤치마크에서 배율1, Stage 높이914.5, 중앙 오프셋0, 카드43개, 가로 오차0이며 위 간격은 화면상 **14.609375px**다. 축소 화면의 가시 간격은 같은 배율로 축소되며 모든 화면에서12~16화면px라고 주장하지 않는다. + +## 화면별 실제 측정 + +모든 수치는 CSS px다. 중앙 이동은 기존 기준 좌표에서의 화면상 추가 이동이며, 가로 오차는 Viewport가 아닌 실제 `.hm-orgchart-story-full`의 Border/Padding을 제외한 content-box 중심과 전체 Stage 중심의 차이다. + +- **1920×1080**: 배율62.50%, 중앙+114.043px, 가로 오차0. 상단Y297.50~420.752, 중앙Y433.604~915.898, 파견/휴직Y928.75~981.25, 관계사Y990.381~1025.00, 하단 모듈 간격9.131px. +- **1920×1440**: 배율62.50%, 중앙+294.043px, 가로 오차0. 상단Y297.50~420.752, 중앙Y613.604~1095.898, 파견/휴직Y1288.75~1341.25, 관계사Y1350.381~1385.00, 간격9.131px. +- **1440×900**: 배율62.329%, 중앙 이동0, 가로 오차+0.000061. 상단Y297.479~420.395, 중앙Y319.480~800.456, 파견/휴직Y749.054~801.411, 관계사Y810.517~845.041, 간격9.106px. 부족한 높이에서 기존 상대 배치를 유지하므로 중앙 합집합의 가용 영역 세로 중심 오차−24.756px를 숨기지 않는다. +- **1280×1024**: 배율62.656%, 중앙+86.994px, 가로 오차−0.000050. 상단Y295.909~419.469, 중앙Y405.019~888.520, 파견/휴직Y874.070~926.701, 관계사Y935.855~970.561, 간격9.154px. 일부 세로 범위 공유가 있지만 실제 직원/고정 하단 요소 교차0건이다. +- **390×844**: 배율18.646%, 중앙+183.147px, 가로 오차0. 상단Y308.237~345.008, 중앙Y497.966~641.851, 파견/휴직Y794.809~810.472, 관계사Y813.196~823.524, 간격2.724px. + +1920×1080→1920×1440 동일 mount 높이 변경에서 상단은 그대로, 하단은360px 아래로, 중앙은180px 아래로 이동한다. 추가 세로 여백을 중앙 위아래로 분배하며 실제 가용 세로 중앙 오차는 충분한 화면에서 약0.000046px 이하이다. 1920/1440/390의 기존1px 가로 허용 범위를 충분히 만족한다. + +문서 가로·세로 스크롤0, 실제 사용 가능한 내부 스크롤바0, 카드/타이틀/통계/화살표/하단 바 잘림0이다. transform 전의 자연 layout scrollWidth/scrollHeight가 더 큰 기존 raw 값은 [원본 JSON](../../test-results/org-chart-p9-5-full/after.json)에 함께 기록했다. raw overflow와 실제 표시 영역 포함 여부를 혼동하지 않는다. + +- [수정 전 원본 Rect](../../test-results/org-chart-p9-5-full/before.json) +- [수정 후 원본 Rect](../../test-results/org-chart-p9-5-full/after.json) +- [요약 Rect·간격·배율](../../test-results/org-chart-p9-5-full/layout-summary.json) +- [동일 mount·정렬 안정성·실제 기준 크기](../../test-results/org-chart-p9-5-full/stability-recheck.json) + +## 직접 영향 검증과 보호 + +- 5개 화면의 배치·상대 좌표·크기/타이포·집계/로고·5화살표·장식·잘림·충돌·스크롤: **69/69 PASS**. +- 실제 화살표 픽셀·PNG SHA, 4개 동일 mount Resize의 상대 좌표/5경로, management 끝점, native 직원 팝업·ESC·초점 복귀, 공개 그룹 링크: **기존 성공43개 보존**. 최초 정렬 안정성1FAIL은 원본에 보존하고 [영향 구간9/9 PASS](../../test-results/org-chart-p9-5-full/stability-recheck.json)로 연결했다. 동일 성공 검사와 공통 오류 검사를 하나의 총 테스트 수로 중복 합산하지 않는다. +- 현재 Full Story는 organizationLinks를 지정하지 않아 그룹 anchor0개다. 이를 원래와 동일하게 보존했으며 공개 Core의 `organizationLinks`는 임시 readOnly mount에서 실제 href·keyboard focus·Enter activation까지 확인했다. 외부 페이지 이동은 차단했으므로 목적지 서버/네트워크 동작은 NOT VERIFIED다. +- [부모 높이만 변경](../../test-results/org-chart-p9-5-full/anchor-notification.json): **5/5 PASS**, 오류0. viewport1920×1080을 유지하고 parent914.5→1014.5px로 변경했을 때 배율1, 중앙 오프셋0→89.7188px, 대표자·허브·조직의 이동89.718811px가 일치하며 기존 위치 변경 알림은1회 발생했다. 실제 paint 포함·문서/내부 스크롤 없음도 확인했다. 성공한69/43/9개 구간은 재실행하지 않았다. +- Core typecheck/build, 수정된 Story helper 단독 Typecheck, 변경 반영 최종 Storybook build PASS. Storybook의 기존 큰 chunk/plugin timing 안내는 유지한다. +- `git diff --check` PASS. 기존 CRLF 안내만 남는다. 독립 읽기 전용 구조/코드 검토에서 현재 Full 기준의 새 구체적 결함 없음. +- Fixed SVG·Gradient·Noto Sans KR·사진·뱃지·Typography·원본 직원JSON·통계·로고·팝업/링크 구현은 수정하지 않았다. Expanded의 이전 P9.5 중앙 정렬 코드/Story/CSS·Overview·Department Colors·HM Component/Pattern/Theme·의존성도 시작 SHA-256을 유지한다. 기존 P7~P9.4 전체 suite는 반복하지 않았다. + +[최초 interaction 원본](../../test-results/org-chart-p9-5-full/interactions.json) 및 [수정 전 정렬 루프](../../test-results/org-chart-p9-5-full/stability-before-fix.json)를 보존한다. 첫 Core Typecheck의 DOM 타입 선택 문제는 HTMLElement generic으로 수정했고 원본 로그를 보존했다. helper Typecheck의 최초 CLI는 TypeScript7의 `--ignoreConfig` 요구로 중단됐으며 해당 옵션으로 파일 범위를 검사했다. 이를 앱 오류로 보고하지 않는다. + +## Guard·NOT VERIFIED + +Core/CSS [초기 PARTIAL Guard](../../test-results/org-chart-p9-5-full/guard.json)는 FAIL0 / NOT VERIFIED3(SCOPE-001), 원본 status/staticStatus/runtimeStatus NOT VERIFIED다. 마지막 [위치 알림 수정1파일](../../test-results/org-chart-p9-5-full/guard-position-notify.json)도 FAIL0 / NOT VERIFIED1(SCOPE-001)이며 같은 package graph 한계다. 실제 build/browser 근거를 별도로 제공한다. + +[가로 helper PARTIAL Guard](../../test-results/org-chart-p9-5-full/guard-alignment.json)는 issue0, status/staticStatus NOT APPLICABLE, runtime NOT VERIFIED다. 처음 잘못 지정한 file target의 Storybook graph 범위 진단은 별도 원본을 보존하고 프로젝트 root의 수정 파일 범위로 바로잡았다. 세 결과의 styleEvidence는 STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT PASS(검사 항목 없음) / FINAL NOT VERIFIED이며 결합 styleVerification과 전체 CSS 디자인 승인은 NOT VERIFIED다. 기존 P9.4 Guard FAIL 기록을 덮어쓰지 않았다. + +최종 사용자 디자인 승인, Chrome 외 브라우저·실기기·스크린리더·모든 숨김 모듈/동적 데이터 조합·외부 링크 목적지·SSR/배포는 NOT VERIFIED다. 이번 결과는 현재 Workdesk Full Composition과 직접 영향 경로다. + +## 수정 파일과 Git + +기존 authored 파일5개: + +1. `packages/org-chart/src/core/org-chart-content.ts` — 논리 영역·가용 높이/중앙 Rect 배치·기존 위치 알림. +2. `packages/org-chart/src/core/workdesk-artwork.ts` — overlay 기준 곡선 원점. +3. `packages/org-chart/src/styles/org-chart-readonly.css` — 하단 간격·중앙 레이어/장식 앵커. +4. `src/org-chart/center-composition-preview.ts` — 계산식 유지,0.001px 미만 왕복 보정 억제. +5. `docs/org-chart/ui-composition.md` — 현재 Full 계약과 이전 기록의 구분. + +새 authored 파일은 `verify-p9-5-full.mjs`, `verify-p9-5-full-interactions.mjs`, 이 보고서다. `test-results/org-chart-p9-5-full`, Core dist, storybook-static과 로그는 검증 생성물이다. + +[시작 파일 SHA-256 감사](../../test-results/org-chart-p9-5-full/source-preservation.json):832개 중 위5개 변경,827개 동일,누락0. 브랜치`feature/orgchart-package`, 시작/완료 HEAD`7614507583927a25a270963a74ff044be0557b9f`, origin`https://gitea.hmac.kr/b25027/HMDesign.git`, staged0. 기준 원격 SHA는 사용자 제공값과 로컬 HEAD가 일치하며 현재 live remote SHA는 조회하지 않았다. + +**Stage·Commit·Push·Merge·main Checkout·PR·Reset·Rebase·Force Push·Clean·Fetch·Pull 미진행.** Expanded 등 기존 미커밋 작업을 보존했다. 요청 범위 완료 후 사용자 검토를 기다린다. + +## 실제 Storybook과 캡처 + +[Full Composition](http://127.0.0.1:6064/?path=/story/orgchart--full-composition) + +- [1920×1080](../../test-results/org-chart-p9-5-full/after-1920x1080.png) +- [1920×1440](../../test-results/org-chart-p9-5-full/after-1920x1440.png) +- [1440×900](../../test-results/org-chart-p9-5-full/after-1440x900.png) +- [1280×1024](../../test-results/org-chart-p9-5-full/after-1280x1024.png) +- [390×844](../../test-results/org-chart-p9-5-full/after-390x844.png) +- [화살표 실제 표시](../../test-results/org-chart-p9-5-full/interaction-artwork-1920x1080.png) +- [직원 팝업](../../test-results/org-chart-p9-5-full/interaction-employee-popup.png) + +## 시간 + +- Started At:2026-10-13 13:05:08 KST +- Estimated Duration:약35분 (탐색10분·보완15분·직접 검증10분) +- Completed At:2026-10-13 13:33:06 KST +- Actual Duration:27분 58초 +- Variance:예상보다 7분 2초 짧음 + +## 대표자 화살촉 후속 정정 + +사용자 첨부에서 표시한 대표자 카드 아래의 상향 화살촉은 조직 간 PNG 화살표5개와 별개였다. 앞선5개 화살표 검증에는 이 누락 요소가 포함되지 않았다. Workdesk 원본 `org-arrow-jang-head`의 solid Chevron path와 색을 복원하고, Core의 Stage padding을 포함한 실제 카드 하단에 원본8.7디자인px 간격으로 배치했다. 점선 상단만 Chevron 아래로 이어지도록 보완했고 허브 방향 끝점과5개 기존 연결선·팀 화살표는 보존한다. + +[후속 측정](../../test-results/org-chart-p9-5-arrowhead/after.json):1920×1080·1920×1440·390×844에서25/25 PASS, 화살촉0→1개, 실제 픽셀 표시·축 정렬·카드 비충돌·세로 이동·축소 확인. 이전 수치와 PASS는 당시 검사 범위의 기록이며 이 후속 보완과 구분한다. [복원 후 확대 캡처](../../test-results/org-chart-p9-5-arrowhead/after-1920x1080-detail.png). + +최초3개 원본 모양 비교는 테스트 정규식이 `d` 대신 `kind`를 추출하여 FAIL이었다. [원본](../../test-results/org-chart-p9-5-arrowhead/after-initial.json)을 보존하고 실제 캡처된 DOM의3개 비교만 다시 평가했다. 성공한22개 브라우저 검사를 반복하지 않았다. Core Typecheck/Build·최종 Storybook Build·diff check PASS, console/pageerror0. [화살촉 변경1파일 Guard](../../test-results/org-chart-p9-5-arrowhead/guard.json)는 FAIL0/SCOPE-001 NOT VERIFIED1, 원본 status/staticStatus/runtimeStatus 및 전체 CSS 승인은 NOT VERIFIED다. + +시작835개 파일 중 artwork와 이 후속 보고서2개만 변경,833개 동일,누락0. Expanded·카드·Theme·팀 PNG4종·이전 중앙/하단 배치 코드와 미커밋 작업을 보존했다. 열려 있던 Full Composition 미리보기 탭을 새 빌드로 갱신하고 실제 화살촉 DOM 표시를 확인했다. Stage·Commit·Push·Merge 미진행. + +- Started At:2026-10-13 13:37:01 KST +- Completed At:2026-10-13 13:46:55 KST +- Actual Duration:9분 54초 +- Estimated Duration:약10분 +- Variance:예상보다 0분 6초 짧음 + +사용자 후속 요청에 따라 화살촉 위쪽 끝을 실제 대표자 카드 하단에 직접 붙였다. 이전8.7디자인px 간격을0으로 바꾸고 연결선 시작점도 함께 조정했다. [붙임 검증](../../test-results/org-chart-p9-5-arrowhead-touch/after.json):3개 화면25/25 PASS, 측정 간격 약0px(최대0.0001디자인px), 기존5개 협업 화살표·카드·허브·세로 배치 보존. [최종 확대 캡처](../../test-results/org-chart-p9-5-arrowhead-touch/after-1920x1080-detail.png). 기존 간격 기록은 당시 상태로 보존한다. diff --git a/docs/org-chart/implementation-report-p9-5.md b/docs/org-chart/implementation-report-p9-5.md new file mode 100644 index 0000000..46bfcda --- /dev/null +++ b/docs/org-chart/implementation-report-p9-5.md @@ -0,0 +1,84 @@ +# HMDesign OrgChart P9.5 — Expanded 중앙 정렬 + +Expanded의 실제 표시 영역에 중앙 오차가 있어 공유 Core의 배치만 보완했다. 기존 P9.4 PASS suite는 재실행하지 않았다. + +## 실제 측정 기준과 수정 전후 + +실제 React Storybook의 Shadow DOM에서 다음 경로를 측정했다. + +`.hm-orgchart-content → .hm-orgchart-expanded → .hm-orgchart-viewport → .hm-orgchart-forest → .hm-orgchart-tree` + +중앙 기준은 외곽 카드 전체가 아닌 Pan/Zoom 콘텐츠 캔버스 `.hm-orgchart-viewport`의 client 영역이다. `getBoundingClientRect().left + clientLeft + clientWidth / 2`를 사용하여 Border를 제외한다. 전체 표시 경계는 모든 회사의 조직 article(타이틀·직원 카드)과 조직 연결선 path의 합집합이다. Forest와 개별 Tree Wrapper의 BoundingRect도 별도로 기록했다. 연결선은 Forest 전체와 같은 transform을 사용한다. + +1440×1100px 브라우저에서 외곽 Expanded Wrapper는 `left=120, width=1200`, Viewport는 `left=137, width=1166`, client 콘텐츠는 `left=138, width=1164`, 가로 중앙은 `720px`였다. + +Forest/Tree Wrapper 자체의 수정 전 가로 오차는 약 0px였지만, LR의 뒤쪽 margin과 Hybrid의 앞쪽 padding 때문에 실제 카드·타이틀의 중앙은 달랐다. 단일 회사·20명 기준: + +- TB 가로: `0 → 0px`. +- LR 가로: `-14.697845 → 0px` (음수는 왼쪽). +- Hybrid 가로: `+3.631989 → 0px`. +- TB Fit 세로: `-10.751465 → 0px`. + +1/2/7개 회사 × 회사당 20/100/300명 × TB/LR/Hybrid의 27개 조합에서 수정 전 최대 절대 가로 오차는 `14.697845px`, 수정 후 자동 정렬·Fit 최대 절대 가로 오차는 `0.00006103515625px`다. Fit 최대 세로 오차는 `10.751465 → 0px`다. 수치는 CSS px이며 브라우저 부동소수점 측정 정밀도를 포함한다. 자동 배치는 가로 중앙과 상단 여백만 맞추므로 세로 중앙 오차를 완료 조건으로 주장하지 않는다. + +기존 자동 Fit과 수정 후 자동 중앙 배치는 배율 정책이 다르므로 각각 배율을 함께 기록했다. 단일 회사·20명은 TB `44.34% → 100%`, LR `61.24% → 100%`, Hybrid `30.27% → 100%`; 최초 7회사·20명은 `22.07% → 100%`다. 실제 카드/엔진 크기는 변경하지 않았다. 명시적 Fit에서는 TB 약45.78%, LR 약62.59%, Hybrid 약30.27%로 전체 표시 영역을 담는다. + +LR/Hybrid에서는 여백을 포함한 Wrapper 박스 중심과 표시 영역 중심이 의도적으로 다르다. 예를 들어 LR 100%에서 Forest/Tree 박스는 콘텐츠 중앙보다24px 오른쪽에 있으며, 실제 표시 영역 중심은 중앙에 일치한다. Wrapper와 표시 영역을 혼동하여 박스 중앙만 PASS로 판단하지 않는다. + +- [수정 전 1/2회사 측정](../../test-results/org-chart-p9-5/before.json) +- [수정 전 7회사 측정](../../test-results/org-chart-p9-5/before-seven.json) +- [수정 후 전체 측정](../../test-results/org-chart-p9-5/after.json) +- [동일 조건 비교](../../test-results/org-chart-p9-5/centering-comparison.json) + +## 최소 변경 + +- `expanded-viewport.ts`: 렌더링된 콘텐츠의 경계를 Forest 로컬 좌표로 측정한다. 자동 배치는 현재 배율을 유지하고, 명시적 Fit만 전체 표시 영역에 맞춰 축소하여 가로·세로 중앙에 배치한다. Center/Fit/수동 이동을 구분하므로 observer가 Fit 직후 상단 배치로 되돌리지 않는다. +- `core/index.ts`: 재생성 전에 수동 이동 여부를 보관하여 새 Expanded controller에 전달한다. Pan·Zoom·100% 복원·화면 밖 focus 노출 이후에는 회사/데이터/레이아웃/Resize에 의해 배율과 이동 위치를 임의로 바꾸지 않는다. 명시적 Fit은 수동 보존을 해제한다. +- `expanded-usage.md`: 자동 배치와 명시적 Fit, 수동 위치 보존 정책을 현재 동작에 맞췄다. + +공개 API/React props/CSS/카드/엔진/연결선 renderer/Drag & Drop hit testing은 변경하지 않았다. Fixed는 기존 `createOrgChartContent` 경로를 유지하고 Expanded controller를 사용하지 않는다. + +## 이번 레이아웃 검증 + +- 실제 Chromium `153.0.8010.12`, React Storybook: 중앙·Fit·탐색 **124/124 PASS**, console/pageerror0. +- 자동 중앙27개: 가로 중앙 일치, 배율100% 유지. +- Fit27개: 가로·세로 중앙 일치, 전체 표시 경계가 콘텐츠 안의16px 여백에 들어옴. 최대7개×300명=2,100명 Forest 포함. +- 최초 렌더링과 회사 선택·20/100/300명 적용·TB/LR/Hybrid 변경에 새 경계 반영. +- 수동 Pan 이후 Resize·회사 선택·직원 수·TB→LR에서 scale/x/y 동일. 이후 명시적 Fit으로 중앙 복귀. +- 390/768/1280/1440px: 단일 회사의 자동 가로 중앙 및 Fit 가로·세로 중앙. +- [변환 좌표 및 실제 포인터](../../test-results/org-chart-p9-5/geometry.json): **62/62 PASS**, console/pageerror0. 카드·연결선의 Forest 로컬 좌표는 Pan/Zoom/Fit 전후 최대 `0.000370833px` 차이로 동일하다. 각 상태·방향에서 직원 연결선20개, LR/Hybrid에서 조직 SVG route10개를 확인했다. 끝점 오차는 최대 `0.500552px`이며 정수 offset 치수와 소수점 렌더링 차이를 고려한0.75로컬px 허용치 안이다. TB의 기존 조직 pseudo-element 연결선 renderer/소스는 보존하고 공통 transform을 확인했으며 pseudo-route 내부 전체 재검증은 반복하지 않았다. +- 중앙 배치→Fit→수동 Pan 상태에서 TB `virtual-022 → hanmac-cell-0`, LR `virtual-032 → hanmac-office-1`, Hybrid `virtual-027 → hanmac-team-sibling` 실제 pointer 이동 성공. 화면상의 source/target hit·allowed highlight·release 후 소속이 의도한 조직과 일치한다. [Hybrid 이동 캡처](../../test-results/org-chart-p9-5/geometry-pointer-hybrid.png). +- Core typecheck/build와 수정 코드가 포함된 Storybook build PASS. 빌드는 실행 결과를 확인했으며 기존 P9.4 기능 suite는 반복하지 않았다. +- `git diff --check` PASS. 기존 CRLF 안내 경고만 존재. +- 독립 읽기 전용 검토에서 새 구체적 결함 없음. + +수정 전 첫 측정의7회사 구간은 테스트의 회사명 매칭 오류로 중단됐다. 정상 완료한1/2회사 수치37개를 보존하고, 전체 선택을 사용하는7회사 구간19개만 보완 실행했다. 최초 측정1개가 중복이며 비교 JSON은 이름으로 합쳤다. localhost/브라우저 실행 제한은 도구 환경에서 해결했으며 앱 변경으로 해결했다고 주장하지 않는다. + +좌표 probe의 [원본33PASS/18FAIL](../../test-results/org-chart-p9-5/geometry-initial.json)도 보존했다. 끝점12개 판단은 이미 캡처된 실제0.5px 차이를0.75로컬px 기준으로 해석했으며 브라우저를 다시 실행하지 않았다. 포인터 source/target을 조직도 캔버스와 브라우저 viewport 양쪽 안으로 제한하고 focus 후 client 좌표를 다시 측정하여 실패한 pointer 구간만 재실행했다. 최종62개는 보존/재해석한41개와 새 pointer20개 및 최종 오류 검사1개이며 원본 수와 중복 합산하지 않았다. 앱 소스를 변경하여 테스트 실패를 감추지 않았다. + +## Guard 및 미검증 + +[변경 Core 2파일 PARTIAL Guard](../../test-results/org-chart-p9-5/guard.json)는 FAIL0 / NOT VERIFIED2다. 원본 status/staticStatus/runtimeStatus는 **NOT VERIFIED**이며 패키지 구현2파일이 proven Storybook graph 밖이라는 SCOPE-001이다. 실제 패키지 build와 브라우저 증거를 별도로 기록했다. styleEvidence는 STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT PASS(검사 항목 없음) / FINAL NOT VERIFIED다. 결합 styleVerification은 생성하지 않았으며 전체 CSS 디자인 승인을 주장하지 않는다. 기존 P9.4 Guard FAIL과 미검증 기록은 보존한다. + +실제 터치 기기·스크린리더·Chrome 외 브라우저·모든 Depth/개별 Hybrid override 조합·2,100명 성능·사용자 디자인 승인·서버/SSR/배포는 NOT VERIFIED다. 이번 검증은 중앙 배치와 그 변환 좌표의 영향 범위에 한정한다. + +## 기존 작업 보존과 Git + +[시작 파일 감사](../../test-results/org-chart-p9-5/source-preservation.json):829개 중 기존3개만 변경,826개 SHA-256 동일,누락0. Fixed Full Composition·카드·연결선·Drag & Drop·React·P9.4 Story/CSS/데이터·Theme/Component/Pattern의 시작 파일을 보존했다. Fixed의 기존 브라우저 PASS는 재실행하지 않고 소스 보존과 호출 경로 분리로 영향 범위를 확인했다. + +- 폴더:`D:\디자인시스템\HMDesign-OrgChart` +- 브랜치:`feature/orgchart-package` +- 시작/완료 HEAD:`7614507583927a25a270963a74ff044be0557b9f` +- staged0. Stage·Commit·Push·Merge·Checkout·Reset·Clean·Fetch·Pull·Rebase·PR 미진행. 현재 원격 SHA는 NOT VERIFIED. + +새 authored 파일은 `examples/org-chart/verify-p9-5.mjs`, `examples/org-chart/verify-p9-5-geometry.mjs`, 이 보고서3개다. `test-results`, `packages/org-chart/dist`, `storybook-static`는 검증 생성물이다. 기존 미커밋 변경을 커밋 대상으로 묶지 않았다. + +[실제 Expanded Storybook](http://127.0.0.1:6064/?path=/story/orgchart--expanded-org-chart), [최초100% 캡처](../../test-results/org-chart-p9-5/after-initial.png), [단일 회사 Fit 캡처](../../test-results/org-chart-p9-5/after-single-fit.png). + +## 시간 + +- Started At:2026-10-13 12:45:15 KST +- Estimated Duration:약25분 (탐색7분·보완8분·레이아웃 검증10분) +- Completed At:2026-10-13 13:04:27 KST +- Actual Duration:19분 12초 +- Variance:예상보다 5분 48초 짧음 diff --git a/docs/org-chart/implementation-report-p9.md b/docs/org-chart/implementation-report-p9.md new file mode 100644 index 0000000..9124cd4 --- /dev/null +++ b/docs/org-chart/implementation-report-p9.md @@ -0,0 +1,89 @@ +# HMDesign OrgChart P9 — Storybook 디자인 보완 보고 + +RESULT: **PASS — P9 변경 범위.** +Overview와 Fixed의 표·관계사 내부 디자인을 공유하고 Department Colors 카드 미리보기 행을 정렬했다. +기존 Guard FAIL·미검증과 접근성 잔여 이슈를 별도로 보존한다. P10은 시작하지 않았다. + +## 작업 기준과 시간 + +- 폴더: `D:\디자인시스템\HMDesign-OrgChart` +- 브랜치: `feature/orgchart-package` +- 시작·완료 HEAD: `7614507583927a25a270963a74ff044be0557b9f` +- origin: `https://gitea.hmac.kr/b25027/HMDesign.git` +- 범위: `STORYBOOK_ONLY`, 실제 React Storybook, 기존 P7·P8 미커밋 변경 보존. +- Started At: `2026-10-13 10:13:21 KST` +- Completed At: `2026-10-13 10:28:23 KST` +- Actual Duration: `15분 2초` +- Estimated Duration: `35–50분` — 분석 10분 / 최소 수정 15–20분 / 통합 검증·보고 10–20분 +- Variance: `예상보다 19분 58초–34분 58초 짧음` + +## 요청 항목별 결과 + +| 항목 | 결과와 근거 | +|---|---| +| Overview 제목 3개 | **관계사·회사 로고 / 조직·직위별 인원 현황 / 관계사별 인원 현황**. 기존 외부 제목 두 곳을 요청한 정확한 명칭으로 보완. | +| 조직·직위별 인원 표 | **PASS**. Fixed와 동일한 제목·7개 조직 행·5개 직위 열·소계·합계 41명. 각 행·직위 합계 검산, 실제 DOM 및 computed Typography·배경·Border·Height·Padding·정렬·줄바꿈·Radius·Shadow·폰트 동일. | +| Fixed Summary 재사용 | 기존 `createOrganizationTotals`와 `createAffiliationBar`를 Story 전용 선택형 `presentation="workdesk"`로 호출. 동일 `org-chart-p3-summary.css` selector를 공유. 표 디자인 중복 CSS 및 Fixed 절대 좌표 복사 없음. 기본 standalone Presenter와 공개 API는 유지. | +| 관계사별 인원 현황 | **PASS**. 동일 로고·회사명·인원수·총계 41명·내부 Typography·Border·배경·Radius·Shadow 재사용. Overview 제목 유지. 페이지 폭에 따른 wrapping과 높이만 독립 조정하며 모바일 줄 구분선은 숨김. | +| 로고 영역 | **PASS**. 기존 승인 로고 7개 모두 로드, 원본 자산·색·비율 및 `object-fit:contain` 보존. 로고 자체에 새 배경·Border·Shadow 추가 없음. 기존 외부 견본 컨테이너 유지. | +| Department Colors | **PASS**. 기존 Fixed 조직색 6개·HEX·SVG 카드·폰트 보존. CSS subgrid로 같은 행의 제목·설명·컬러 legend 블록·카드·대비 안내 시작점을 정렬. 1920/1440에서 같은 행 카드 preview Y가 정확히 일치. 390에서는 단일 열·잘림 없음. 카드 종류별 146×68/108×52 및 대표자 146×74 유지. | +| Full Composition | **PASS**. 수정 전후 원본 1920×915, Stage DOM SHA-256 및 검사한 모든 표·관계사·카드 computed 값 동일. 확정 배경·카드·허브·화살표·장식·파견/휴직·통계·하단 바 보존. 3개 화면에서 전체 bounds·비례 축소·휠 후 내부 offset 0, 문서 가로/세로 스크롤 없음. Fixed 레이아웃/JS 수정 없음. | +| Expanded OrgChart | **PASS**. TB/LR/Hybrid를 1920×1080·1440×900·390×844에서 확인. 기존 20명 fixture·Fit·연결선·방향별 Border·7회사 색·흰 제목 유지. 대표 그룹 Hover·Focus·Collapsed·Expanded 흰색 확인. 카드 원래 외형과 Placeholder 유지. | +| HMDesign UI Dialog | **PASS**. 실제 공개 Export `Dialog`와 `DialogContent` 등 사용. 직원 카드 클릭→단일 HM Portal·identity·focus, Core native popup 중복 없음, Tab 유지·ESC·닫기 버튼·카드 focus 복귀, 390px 표시 확인. React/Fixed/Vanilla 연결 소스 변경 없음. Fixed/Vanilla native 경로는 기존 P7·P8 PASS와 동일 소스 근거 재사용. | +| 공통 Storybook 스타일 | **PASS**. 기존 `StoryPage / StoryPageHeader / StorySection` 및 공통 CSS 유지. 4페이지 실제 제목·설명·섹션 Typography/Border 동일. 페이지 envelope/gutter 설정 소스 보존. Core Shadow DOM 독립 Scope 유지. 코드 예제 형식 변경 없음. | +| 메뉴·ID | **PASS**. 공개 4페이지·기존 ID/URL 유지. 독립 OrgChart root·Patterns 다음 순서·내부 숨김 정책은 변경하지 않은 P8 설정/CSF 및 기존 manager PASS 재사용. 내부 Story 삭제 없음. | +| 변경 범위 검사 | 본 브라우저 **85/85 PASS**, 누락된 Expanded 1920px만 추가 **10/10 PASS**. 서로 다른 실행의 중복 오류 체크를 합산한 전체 고유 검사 수는 주장하지 않음. Core/Story 타입 검사·Core/Storybook production build·diff 검사 PASS. | +| 신규 콘솔 오류 | 두 실행 모두 **0**, pageerror/console error 없음. | +| 기존 검증 재사용 | P7 데이터 정책·2,100명·Zoom/Pan·독립 옵션·회사색/상태, P8 메뉴 위치/공통 frame/최소 기능 PASS를 관련 소스 동일 근거와 함께 재사용. 전체 과거 suite·2,100명 성능 검사 재실행 없음. | +| Git 보호 | **Stage·Commit·Push·Merge·main checkout·Pull·Reset·Rebase·Force Push·PR 모두 미진행.** staged 파일 0, branch/HEAD 동일. 기존 809개 authored 파일 중 P9 대상 4개만 변경, 805개 hash 동일. 신규 authored 파일은 runner와 이 보고서 2개. | + +## 수정 범위와 검증 + +| P9 파일 | 변경 | +|---|---| +| `src/org-chart/org-chart-page-previews.tsx` | Overview 정확한 제목, Fixed Summary Presenter 선택, 설명 정합화 | +| `src/org-chart/core-component-preview.tsx` | Story-only 선택형 Workdesk Presenter, 기본 독립 Summary 유지 | +| `packages/org-chart/src/styles/org-chart-p3-summary.css` | 기존 Fixed 스타일 selector 공유, Overview 흐름·wrapping만 보완 | +| `packages/org-chart/src/styles/org-chart-content.css` | Department Colors 5개 콘텐츠 블록 행 정렬 | +| `examples/org-chart/verify-p9.mjs` | P9 표·집계·스타일 동등성/반응형/보존/Dialog/메뉴 검증 | +| `docs/org-chart/implementation-report-p9.md` | 이 보고서 | + +집계 selector·fixture·회사색·승인 이미지·독립 Summary API·Fixed 좌표/레이아웃·Auto 알고리즘·React Dialog·공통 Theme·StoryPage·메뉴 설정은 변경하지 않았다. +P8 helper의 다른 페이지 구현은 유지하고 Overview 부분만 수정했다. +`packages/*/dist` 및 `test-results/org-chart-p9`는 기존 ignore 정책의 생성 산출물이며 Stage하지 않았다. + +| 실행 | 결과 | +|---|---| +| `npm.cmd run typecheck:org-chart` | PASS, JS/API 변경 없이 공유 Core CSS의 패키지 계약 확인 | +| `npm.cmd run build:org-chart` | PASS, 최종 CSS specificity 보완 반영 | +| `node node_modules/typescript/bin/tsc -p test-results/org-chart-p9/story-tsconfig.json` | PASS, 수정 Story/helper 및 전체 관련 CSF 포함 | +| Storybook 최종 production build | PASS, [최종 log](../../test-results/org-chart-p9/build-final.log). 기존 500kB chunk-size 경고와 bundler plugin timing 진단, build 오류 없음. | +| P9 실제 브라우저 | [85/85 raw](../../test-results/org-chart-p9/browser.json), [1920 Expanded 추가 10/10 raw](../../test-results/org-chart-p9/browser-expanded-1920.json). Chrome 155.0.8059.39, 기존 Playwright 사용, 패키지 설치/업데이트 없음. | +| 독립 Summary 최소 회귀 | 원래 internal Story의 상세 직접/하위 포함/합계 및 Affiliation 카드 layout 유지 확인. | +| 소스 보존·Git | [baseline](../../test-results/org-chart-p9/baseline.json), [최종 audit](../../test-results/org-chart-p9/final-state.json), `git diff --check` PASS. | + +동일 조건의 성공 검사를 반복하지 않았다. 최초 캡처에서 Overview bar wrapping의 CSS specificity 충돌을 발견해 preview-only selector를 보완했다. 해당 CSS 보완으로 기존 build가 무효화됐으므로 영향을 받는 Core/Storybook build만 갱신했다. 소스 변경이 없는 타입 검사는 반복하지 않았다. +브라우저 85개 검사 이후 누락된 1920px Expanded 9개 모드/스타일 검사와 오류 검사를 추가했으며 기존 폭 검사는 반복하지 않았다. + +## Guard·NOT VERIFIED·P10 잔여 + +[Guard 원본](../../test-results/org-chart-p9/guard.json)의 **status/staticStatus FAIL / runtimeStatus NOT VERIFIED**를 보존한다. +[범위 분류](../../test-results/org-chart-p9/guard-scope.json)에서 P8 기존 **TOKEN-001 10 FAIL / TOKEN-002 10 NOT VERIFIED**가 모두 동일함을 확인했다. **신규 FAIL 0**이다. +선택한 Core CSS 두 파일에는 **SCOPE-001 2 NOT VERIFIED**가 추가된다. 패키지 stylesheet는 Storybook 정적 import graph 밖이므로 Core build와 실제 브라우저 동등성/보존 결과를 별도 증거로 연결한다. 이를 Guard PASS로 덮어쓰지 않았다. + +- Guard styleEvidence: **STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT NOT VERIFIED / FINAL NOT VERIFIED**. +- 결합 `styleVerification`은 생성하지 않았으며 **NOT VERIFIED**. 기록한 실제 computed PASS는 전체 CSS/임의 외부 props의 인증 또는 사용자 디자인 의도 승인이 아니다. +- 기존 삼안·장헌산업 흰색 제목의 일반 텍스트 대비 미달 진단 유지. 확정 회사색·흰색을 임의 변경하지 않았다. +- Compact **NOT SUPPORTED**, 전체 옵션 조합·스크린리더·다른 브라우저·실기기·SSR/hydration·API/배포·장시간 2,100명은 기존 상태 유지/이번 **NOT VERIFIED**. +- P10에서 사용자 최종 디자인 검토, 접근성 대비 해결 정책과 Guard 패키지 CSS 근거 범위를 우선 확정할 수 있다. 새 기능/Compact/API/Docker/알고리즘 수정은 별도 범위 확정이 필요하다. +- **P10 및 Git 작업은 사용자 검토를 기다린다.** + +## 실제 화면 + +- [Overview 1920px](../../test-results/org-chart-p9/overview-1920.png), [1440px](../../test-results/org-chart-p9/overview-1440.png), [390px](../../test-results/org-chart-p9/overview-390.png) +- [Department Colors 1440px](../../test-results/org-chart-p9/department-colors-1440.png), [390px](../../test-results/org-chart-p9/department-colors-390.png), [모바일 상세](../../test-results/org-chart-p9/department-first-390.png) +- [Full Composition 1920px](../../test-results/org-chart-p9/full-composition-1920.png), [1440px](../../test-results/org-chart-p9/full-composition-1440.png), [390px](../../test-results/org-chart-p9/full-composition-390.png) +- [Expanded TB 1920px](../../test-results/org-chart-p9/expanded-tb-1920.png), [LR 1920px](../../test-results/org-chart-p9/expanded-lr-1920.png), [Hybrid 1920px](../../test-results/org-chart-p9/expanded-hybrid-1920.png), [LR 390px](../../test-results/org-chart-p9/expanded-lr-390.png), [원래 크기 카드](../../test-results/org-chart-p9/expanded-card-100-percent.png) +- [실제 HM Dialog 1440px](../../test-results/org-chart-p9/hm-dialog-1440.png), [390px](../../test-results/org-chart-p9/hm-dialog-390.png) + +현재 미리보기: [Overview](http://127.0.0.1:6061/?path=/story/orgchart--logo-personnel-summary), [Department Colors](http://127.0.0.1:6061/?path=/story/orgchart--department-colors), [Full Composition](http://127.0.0.1:6061/?path=/story/orgchart--full-composition), [Expanded OrgChart](http://127.0.0.1:6061/?path=/story/orgchart--expanded-org-chart). diff --git a/docs/org-chart/implementation-report-v0.1.md b/docs/org-chart/implementation-report-v0.1.md new file mode 100644 index 0000000..8f0192e --- /dev/null +++ b/docs/org-chart/implementation-report-v0.1.md @@ -0,0 +1,72 @@ +# HMDesign OrgChart v0.1 구현·최종 검증 보고 + +RESULT: **PASS** — 이번 요청의 Core/JSON/Vanilla 최소 완료 조건 12개 충족. React Wrapper·Storybook 및 미검증 후속 항목을 포함한 전체 MVP 완료 판정은 아닙니다. + +## 개발 환경과 보존 + +- 작업 경로: `D:\디자인시스템\HMDesign-OrgChart` +- 브랜치: `feature/orgchart-package` +- 작업 전/후 HEAD: `d18e3c33801e165e17b6d971e2f6198eed483c3c` (동일) +- origin(fetch/push): `https://gitea.hmac.kr/b25027/HMDesign.git` +- Local main, 공식 원격 main/Feature도 위 SHA 그대로. 운영 `D:\디자인시스템\HMDesign` Working Tree 깨끗함을 작업 전/후 읽기 전용으로 확인. +- 시작 시 기존 미추적 파일: `docs/org-chart/*.md` 7개. 모두 SHA-256 일치 확인(7/7); 삭제·초기화·전면 수정 없음. +- 기존 `packages/ui`, `packages/center-graphic`, Theme/Token/Storybook/AGENTS 및 Spike 수정 없음. 최종 tracked diff는 루트 package.json·package-lock.json뿐. +- Stage/Commit/Push/Merge 없음. Staged diff 비어 있음. main/운영 프로젝트 수정 없음. +- 수행 방식: Track A(Data), Track B(Core/CSS) 병렬 구현 + 읽기 전용 검토 작업자. Main만 package/export/build/examples와 최종 통합을 수정·관리함. + +## 구현 결과 + +1. 신규 패키지: `packages/org-chart`, `@hm-design/org-chart@0.1.0`. React/React DOM peer·런타임 의존성 없음. +2. 직접 의존성: `orgchart@6.0.0` 고정. upstream 전이 의존성 `html2canvas@1.4.1`, `json-digger@2.0.2`, `jspdf@4.2.1` 및 그 의존성이 lockfile에 추가됨. OrgChart 외 무관한 직접 의존성 추가 없음. export UI는 비활성화하며 export 라이브러리는 브라우저 번들에 포함되지 않음. upstream MIT 고지를 배포 산출물에 포함. +3. Data Contract: 조직/직원/관계사를 ID로 분리하고 문서의 `displayOrder/jobTitle/profileImage/workStatus` 유지. 버전은 문서 초안의 `0.1-draft`. 최신 요청에 따른 최상위 schemaVersion 필수 추가; metadata.schemaVersion은 선택 호환 mirror로 일치 검사. relations/presentation 및 일부 metadata 확장 필드는 선택. 조직·직원 ID namespace는 독립. +4. JSON Source: 객체 또는 주입 URL, 선택 fetch를 지원. 객체는 생성 시 캡처하고 load마다 독립 불변 snapshot 반환. HTTP/JSON/취소 오류를 구별하고 서버 원문을 노출하지 않음. 기본 native fetch를 globalThis에 바인딩. 외부 JSON은 HTTP에서 검증; file:// 예제는 inline JSON 사용. +5. Validation: 필수 값·타입·버전·날짜·중복 ID·참조·parent cycle·leader/관계사·상태·관계·presentation·위험한 HTML/이미지 URL 검증. 구조화된 code/path/message 반환. 외부 문자열은 textContent로 렌더링하고 engine ID는 내부 prefix+숫자로 생성. +6. Summary: 고유 직원 ID 기준 raw total, 조직 direct/inclusive, 직위·관계사·상태 집계. 조직 이동/추가/삭제와 표시 옵션 독립성 테스트. 업무상 총원 포함 정책을 기본값으로 숨기지 않으며 미주입은 policyRequired, 미지원 정책은 unsupportedPolicy. 지원 정책 범위는 명시적 primaryOnly·leader 포함 및 유효 상태 목록. raw 숫자는 승인된 재직 총원이 아님. +7. Core: createOrgChart, mount(container?), update, reload(source?), destroy, subscribe, getState. 마지막 시작 revision 우선·AbortSignal·immutable 입력·최종 commit 검사·재진입 callback 방어. mount 중 CSS load/error를 확인. 요청 및 data 실패는 error state, 잘못된 호출 순서는 사용 오류. destroy는 동기·반복 안전·terminal. +8. Node UI: 조직·부서, 조직장·직원, 이름·직위·직책·상태, 사진/fallback, 원본 조직별 인원. 계층 연결선과 native 버튼 키보드 확장/축소. 여러 root는 독립 엔진으로 실제 조직 관계를 보존. 갱신 시 확장 상태는 초기화; 제거된 focus는 viewport로 복귀. +9. Theme: 호스트의 기존 HM Theme 변수/폰트를 상속. node와 연결선은 기존 Semantic 변수, radius/font 사용. Shadow DOM 안에 upstream engine.css와 전용 style.css를 로드하여 전역 selector 누출 방지. 예제 Theme 산출물은 실제 공개 CSS Entry에서 생성하고 기존 폰트/OFL을 포함; 독립 유지되는 Theme 복제본 없음. +10. Export: ESM index.js + index.d.ts, style.css, engine.css, classic browser subpath. IIFE는 window.HMOrgChart. 브라우저 번들에 unresolved bare import 없음. stylesheets의 실제 URL을 호스트가 stylesheetUrls로 주입해야 함. +11. 예제: `examples/org-chart/dist/index.html`(HTTP ESM/외부 JSON), `classic.html`(직접 열기/Classic inline JSON). build outputs/public Entry만 소비하며 Spike 코드 참조 없음. reload·이동/추가 update·destroy·새 instance 생성 controls. 가상 조직·직원만 사용. + +## 최종 검증 + +- **PASS** TypeScript: `npm run typecheck --workspaces --if-present` — OrgChart/UI/Center Graphic 모두 통과. root 앱 production build를 실행한 결과로 해석하지 않음. +- **PASS** Package Build: ESM 123.50 kB(gzip 29.36), IIFE 126.34 kB(gzip 29.55), 선언/CSS/license 생성. +- **PASS** 데이터 테스트 22/22: 계약/참조/순환/URL/정책/추가·삭제·이동/중복 방지/불변성/취소·늦은 응답/native fetch receiver 회귀. +- **PASS** Chrome 155.0.8059.39 실제 브라우저 검사 69/69: HTTP ESM + file:// Classic 각각 mount/custom node/summary/reload/update; 네 너비 document overflow·내부 scroll geometry·HM color/font/radius·1줄 ID·Theme 연결선 정렬; keyboard Enter/Space·focus 복귀; depth-3 확장 state; 두 instance/Scoped Theme; source/validation error·latest revision·reentrant destroy·late callback 차단; 5회 mount/reload/destroy 자원 안정성. Console/runtime errors 0. +- **PASS** Destroy 관찰 범위: 전용 DOM 제거·호스트 sibling과 다른 instance 보존·chart observer 0·전역 listener 수 안정·요청 abort·destroy 이후 callback 0. Playwright 자체 observer/listener를 chart 자원과 구분함. 전체 heap 회수를 판정한 결과가 아님. +- **PASS** npm tarball consumer: 실제 tarball 추출, DOM 없는 public ESM import, public CSS/browser export, 엄격한 NodeNext 타입 소비(React types 없음), fixture validation/summary. React가 미설치된 사용자 npm 환경에서 새로 dependency install하는 테스트까지 수행한 것은 아님; HTML은 React를 로드하지 않음. +- **PASS** 기존 main/설계 문서/패키지 소스 보존, Git diff --check, 문서 링크 검사(119 files/620 links/problems 0). +- **NOT VERIFIED** AI Guard 원본 status/staticStatus/runtimeStatus. 1회 PARTIAL 실행에서 ENTRY-001/SCOPE-001, checkedFiles=[]: Vanilla package가 React consumer graph 전제에 포함되지 않음. Guard/Theme/Component 규칙 수정 없음. 원본 styleEvidence는 STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT PASS / FINAL NOT VERIFIED, checks=[]이며 비어 있는 검사 PASS를 디자인 승인으로 사용하지 않음. +- 별도 CSS 증거: 실제 선언 존재 확인 PASS, 위 제한된 browser computed·overflow/interaction PASS. **DESIGN_INTENT와 FINAL은 NOT VERIFIED**: reference 디자인 승인·전체 시각 parity를 주장하지 않음. + +검증 증거: `test-results/org-chart-browser/report.json` 및 HTTP/file:// 네 너비 PNG, `test-results/org-chart-consumer/report.json`, `test-results/org-chart-preservation.json`, `test-results/org-chart-pack.json`. 이 생성물은 Git ignored이며 source test harness는 보존함. + +## 변경 내역 + +- 수정: `package.json`(6개 OrgChart 실행 scripts), `package-lock.json`(workspace link와 upstream dependency graph). 이미 등록된 packages/* workspace는 변경하지 않음. 기존 @babel/runtime의 dev 표시 제거는 새 production 전이 dependency 영향이며 버전 교체가 아님. +- 신규 패키지: package.json, README.md, tsconfig.json, tsconfig.build.json, build.mjs, test.mjs, src/index.ts; data/{types,index,validate,normalize,summary,snapshot,source,data.test}.ts; core/{types,index,engine,render}.ts, core/vendor.d.ts; styles/org-chart.css. +- 신규 예제: index.html, classic.html, app.js, demo.css, data-v1.json, data-v2.json, prepare.mjs, serve.mjs, verify.mjs, verify-package.mjs, create-fixtures.mjs. +- 신규 보고서: 이 파일. 기존 7개 설계 문서는 수정하지 않음. +- dependency install은 초기 sandbox DNS 실패 후 승인된 실행으로 완료. npm cleanup에서 ignored node_modules 일부 EPERM 경고가 있었으나 설치 exit 0이며 후속 typecheck/build/runtime 모두 통과. +- 공식 tag 웹 조회는 실패했으므로 npm 6.0.0 배포물과 특정 upstream git tag의 byte 동일성은 **NOT VERIFIED**. 설치 manifest와 lock integrity를 기준으로 버전을 고정함. + +## 설계 정합성과 후속 작업 + +- Architecture/Framework Integration의 공통 데이터→검증→ViewModel→engine→DOM 및 terminal lifecycle 유지. 공개 target snippet에 필요한 CSS URL 계약 추가와 schemaVersion 위치 변화는 위에 명시. 기존 문서의 구현 전/미착수 상태는 과거 기록으로 보존; 신규 package README/이 보고서가 현재 실행 계약. +- 다중 root는 임의 parent 없이 forest 렌더링. leader cross-organization은 정책 미정으로 LEADER_APPROVAL_REQUIRED. 조직 0개는 오류, 직원 0명은 valid ready. +- hubText/showDispatched/showOnLeave는 reserved 필드이며 별도 허브/상태 영역 UI는 이번 최소 범위에서 구현하지 않음. showEmployees/showSummary/showAffiliations는 실제 지원. 추가 관계는 검증하고 계층선 미포함 안내만 표시; 복잡한 relation overlay 제외. +- pan/zoom/minimap/drag/export 비활성화. 390px은 읽을 수 있는 원래 크기와 내부 가로 스크롤로 표시; 고급 반응형 처리·확장/scroll state 보존 제외. +- React Wrapper는 ref/effect·Props/Source→동일 Core·unmount cleanup만 연결. Validation/집계/engine/node/style 중복 구현 금지. Storybook은 이후 가상 fixture·error/loading·policy·Theme scope·lifecycle 콘텐츠 Story로 구성. +- 기술적 리스크: 6.0.0 내부 init observer hook에 의존하는 managed subclass. 버전 upgrade 전 재검토 필요. upstream anonymous node/transition 이벤트는 제거된 DOM과 함께 GC 가능한 참조가 되며 완전 GC·heap 회수는 **NOT VERIFIED**. 매우 큰 데이터의 성능/깊이 상한·보안 CSP/origin 정책·실제 API/auth·screen reader announcements·reference 시각 일치·Theme 미연결 동작·React runtime은 **NOT VERIFIED**. +- Commit/Push/Merge는 요청대로 보류. 이후 사용자 승인 없이 수행하지 않음. + +## 시간 기록 (Asia/Seoul) + +- Started At: 2026-10-12 10:00:28 KST +- Completed At: 2026-10-12 10:19:19 KST +- Actual Duration: 18분 51초 +- Estimated Duration: 최초 약 2~3시간 +- Variance: 예상 하한 2시간 대비 101분 9초 단축; 예상 상한 3시간 대비 161분 9초 단축 + +예상은 참고값입니다. 기존 PoC/설치 환경 재사용과 병렬 구현으로 실제 시간이 줄었으며 미검증 항목을 PASS로 대체하지 않았습니다. diff --git a/docs/org-chart/integration-report-p10-2.md b/docs/org-chart/integration-report-p10-2.md new file mode 100644 index 0000000..04fe0a4 --- /dev/null +++ b/docs/org-chart/integration-report-p10-2.md @@ -0,0 +1,103 @@ +# HMDesign OrgChart P10.2 — 병렬 결과 통합 및 Chart Only 검증 + +RESULT: **PARTIAL**. 문서 통합·React Entry 정합성은 PASS다. 실제 공개 API의 Chart Only 출력은 PARTIAL이며, 기존 P10 PARTIAL 및 Guard FAIL 10건을 유지한다. 신규 제품 코드·디자인·공개 API를 변경하지 않았다. + +## 1. 작업 상태와 보호 + +- 작업 폴더: `D:\디자인시스템\HMDesign-OrgChart`. +- 브랜치: `feature/orgchart-package`. +- 통합 전·후 HEAD: `5acaf8a29596317133e54e8ad437ee00beba1cf0`. +- 공식 origin: `https://gitea.hmac.kr/b25027/HMDesign.git`. 읽기 전용 `ls-remote`로 Feature HEAD 일치 확인. +- 시작 Working Tree: tracked 수정 9개 + untracked 4개 = 기존 React Wrapper 개선 13개. Stage 0. +- 기존 13개 파일을 [사본·SHA-256](../../test-results/org-chart-p10-2/baseline.json)과 binary diff로 보존했다. 통합 후에도 13개 모두 byte 동일하다. +- ChartOnly: `feature/orgchart-ai-chart-only-policy`, HEAD `5acaf8a…`, AGENTS/CLAUDE/정책 3개 수정. UsageGuide: `feature/orgchart-usage-guide`, HEAD `5acaf8a…`, 가이드 1개 신규. 공통 수정 파일 0. 각 Worktree 원본을 변경하지 않았다. +- AI Playground·삼안 500명 데이터/페이지는 읽기·복사·수정·통합하지 않았다. 서버 존재만 보호 목적으로 확인했다. + +기존 서버는 Storybook 6024, Vanilla 예제 4178, 기존 검증 6064/6074, AI Playground preview 6067, 외부 소비자 preview 4187이었다. 모두 재시작·종료·수정하지 않았다. 이번 브라우저 검사는 별도 임시 포트에서 수행한 뒤 해당 검사 서버만 종료했다. + +## 2. 통합 결과 + +- **PASS — React Wrapper 보존:** 기본 Entry는 HM UI runtime/type 의존성을 요구하지 않는다. 선택 `/hm-dialog` Entry만 HM UI를 사용한다. React 19 peer·Core 독립성과 기존 Props를 유지한다. +- **PASS — 기존 소비자 Import:** Expanded Story는 이미 `/hm-dialog`로 전환되어 있으며 `employeePopup="hm-dialog"`를 유지한다. Full Composition은 기본 Entry/Core popup을 유지한다. 예제의 기본 Entry는 Core popup이므로 변경이 필요 없다. +- **PASS — Popup Adapter:** 기본 Entry의 `employeePopup="adapter"` + `employeePopupAdapter` 계약을 문서화했다. Adapter 전달이나 HM UI 설치만으로 전환하지 않는다. `onEmployeeClick`이 우선하며 Fixed는 기존 Core/호스트 callback 경로를 유지한다. +- **PASS — Chart Only 정책:** ChartOnly diff를 검토하고 관련 추가 사항만 통합했다. 목표는 White Canvas + OrgChart Nodes + Connectors다. 회사·Depth·인원·특정 사례를 기본 템플릿으로 고정하지 않는다. 실제 API 한계 문단을 추가해 정책 목표와 지원 기능을 구분했다. +- **PASS — AGENTS/CLAUDE:** 기존 다른 지침을 보존하고 같은 정책을 참조한다. 기존 Full Composition·Expanded·Department Colors·Overview에 소급 적용하지 않는다. +- **PASS — 공통 가이드:** [사용 가이드](org-chart-usage-guide.md)를 통합하고 최소 tarball 설치·기본 Entry·`/hm-dialog`·`HMEmployeeDialog` Export·선택 popup·커스텀 Adapter 예제·React/Vanilla 차이·실제 API 범위를 보완했다. 과거 lazy chunk 실패 제약은 현재 Entry 분리 결과로 갱신했다. 원본 예제/데이터 검증 기록은 과거 범위로 보존했다. + +## 3. 최소 설치·Entry·문서 검증 + +기존 [최소 설치 보고서](minimal-install-report-2026-10-13.md)의 실제 npm pack A/B/C 설치·strict 타입·Production Build·브라우저 결과를 재사용했다. 성공한 동일 조건 검사를 반복하지 않았다. 현재 Core/Wrapper/UI의 모든 최상위 배포 JS·`.d.ts`·CSS를 실제 tarball로 설치했던 각 소비자의 파일과 byte 비교했고 동일했다. [재사용 근거](../../test-results/org-chart-p10-2/reused-pack-evidence.json). + +- **A Core + Vanilla: PASS 재사용.** React/HM UI 없는 설치·타입·Build·Fixed/Auto·Core popup. +- **B Core + React Wrapper: PASS 재사용.** HM UI/Theme/Tailwind 없는 기본 Entry·React 19.3.0·타입·Build·Core popup·커스텀 Adapter. +- **C Core + React Wrapper + HM UI: PASS 재사용.** 명시 `/hm-dialog` 선택·HM Dialog·닫기/Escape/focus 복귀·Core 전환. +- 기본 Entry 전이 그래프에서 HM UI 참조 없음 PASS 재사용. exports와 실제 설치 JS/타입 경로 일치 확인. +- App/Storybook Typecheck와 Storybook Production Build는 기존 최소 설치 작업의 PASS를 재사용했다. 제품 코드가 이번 통합에서 변경되지 않아 반복하지 않았다. +- **새 가이드 TS/TSX 10개 strict 타입 검사: PASS.** 현재 public package declarations, `skipLibCheck=false`. 이전 원본 가이드 8개 검사와 구분한다. [예제 목록](../../test-results/org-chart-p10-2/guide-examples.json). +- **새 독립 packed React/Vanilla Production Build: PASS.** 이미 설치된 실제 packed dependency를 사용했으며 신규 패키지 설치·lockfile 변경·alias를 제품에 추가하지 않았다. 테스트 전용 resolve 설정만 사용했다. +- 변경 문서 5개 내부 Markdown 링크 85개 경로/anchor 및 `git diff --check`: PASS. [최종 감사](../../test-results/org-chart-p10-2/final-audit.json). + +기존 빌드 경고인 `:host-context`, `use client` bundling, 큰 chunk 경고는 유지된다. 새 테스트 첫 브라우저 접속은 sandbox localhost 차단으로 중단되어 승인된 실행에서 완료했다. 연결선(TB CSS / LR·Hybrid SVG)과 Storybook 전용 Preview DOM을 실제 소스에 맞춰 검사했다. 테스트 harness 문제를 제품 FAIL로 계산하지 않는다. + +## 4. 실제 Chart Only — PARTIAL + +테스트 데이터는 가상 한맥기술 1회사·2본부·본부별 2팀·7조직·20명이다. 대표 1 + 본부장 2 + 팀장 4 + 직원 13을 고유 ID로 한 번씩 집계했다. 정책의 고정 기본값이 아니다. + +[브라우저 측정](../../test-results/org-chart-p10-2/browser.json), [React 1440px 캡처](../../test-results/org-chart-p10-2/react-1440.png), [Vanilla 390px 캡처](../../test-results/org-chart-p10-2/vanilla-390.png), [Fixed 캡처](../../test-results/org-chart-p10-2/fixed-1440.png). + +- **PASS:** 실제 React/Vanilla, 390/768/1280/1440px. 20개 고유 직원 카드·7개 조직·TB 조직 연결선 6개 및 직원 연결선 표시. 한맥 기존 컬러 `#CB2E28`, 기존 카드/엔진/CSS 재사용. 외부 페이지·설명·JSON·테스트 패널·Navigation을 생성하지 않았다. +- **PASS:** document/body와 내부 viewport는 흰색. document 가로 overflow 없음. 기본 키보드 Zoom/Pan. Console Error·pageerror·HTTP 실패 0. +- **제한 확인:** Core가 내부 제목·기준일·회사/인원 요약·Zoom/Fit toolbar·배율/조작 안내·Drag & Drop OFF 안내를 생성한다. 조직도 바깥 Auto 영역은 `rgb(244, 247, 251)`이다. 공개 API로 모두 제거하는 옵션이 없다. 따라서 최상위 흰색 캔버스만 있는 목표는 만족하지 못한다. +- **초기 중앙 배치 PARTIAL:** 실제 전체 Bounds의 가로 중앙 오차 0px, 세로 오차 +182.25px. 기본 배치가 상단 16px 정렬이고 전체 Fit/세로 중앙 정렬이 아니다. 모든 콘텐츠를 축소 없이 한 번에 볼 수 있다는 뜻이 아니다. +- **Vanilla 공개 Fit PASS:** `fitToView()` 호출 뒤 가로·세로 중앙 오차 절댓값 0.001px 미만. 실제 Bounds를 사용하고 콘텐츠를 맞춘다. +- **React 한계:** 외부 handle/ref·초기 Fit prop이 없어 호스트가 버튼 바 없이 초기 Fit을 직접 요청할 경로가 없다. 테스트의 React 캡처는 기존 내장 Fit 버튼을 사용한 상태이며 초기 기본 출력 PASS로 해석하지 않는다. +- **Fixed PARTIAL:** 동일 20명 readOnly 렌더링에서도 제목·기준일·기존 요약과 회색 영역이 남는다. 승인된 Workdesk 표시 경로는 원본 직원 이름 대신 `홍 길 동`을 표시한다(사용 가이드 §6의 기존 제약, 데이터 원본/ID는 유지). 제공 인명을 그대로 표시하는 일반 Chart Only 결과로 사용할 수 있다고 주장하지 않는다. Fixed 전체 화면의 Zoom/Pan·Fit은 Auto API와 같지 않다. + +`display:none`, DOM 후처리 삭제, 전역 `!important`, Core 내부 수정 없이 관찰했다. 공개 옵션으로 조직 데이터·기본 white viewport·탐색은 가능하지만, 완전한 부가 UI 제거와 Root 캔버스-only 출력은 불가능하다. + +**후속 개선 후보(미구현):** 기본값을 보존하는 선택형 `presentationMode` 또는 heading/summary/toolbar/feedback 표시 계약, white root/full-canvas 레이아웃, React/Vanilla 공통 초기 Fit 정책. 표시 상태와 Zoom/Pan 이벤트를 분리하고 접근성 live status는 화면 문구와 구분해야 한다. 영향 후보는 Core options/types, render heading/composition, expanded-content/viewport/CSS와 Wrapper Props 전달, 사용 가이드 및 소비자 검증이다. 타입/API 이름은 제안이며 확정하지 않았다. 기존 Fixed/Expanded 기본값·디자인 보존 검사가 필요한 별도 작업이며 이번 범위에서 자동 시작하지 않는다. + +## 5. 기존 Storybook·Guard·미검증 + +- **PASS:** 기존 4개 공개 메뉴 유지 — Overview(`orgchart--logo-personnel-summary`), Department Colors, Full Composition, Expanded OrgChart. 현재 Storybook index를 확인하고 Department Colors/Overview 실제 렌더링·콘솔 오류 0을 확인했다. [Storybook 증거](../../test-results/org-chart-p10-2/storybook.json). +- **PASS 재사용:** Full Composition Core popup 및 Expanded HM Dialog/Escape/focus 복귀. 관련 원본/수정 13개 파일의 byte 동일성을 확인했다. 신규 제품 회귀는 이번 검사 범위에서 발견되지 않았다. 전체 회귀 PASS를 선언하지 않는다. +- Core/UI/Theme·Fixed SVG/PNG·회사 컬러·Department Colors/Overview·기존 JSON Contract·Drag & Drop·Guard 설정을 변경하지 않았다. 외부 CSS 테마 표준화(P11)는 진행하지 않았다. +- **기존 Guard FAIL 10건 유지:** P10.1 원본 결과를 보존했다. Wrapper 기존 PARTIAL Guard의 FAIL 0 / NOT VERIFIED 5도 보존했다. +- **새 임시 화면 PARTIAL Guard 1회:** FAIL 0, RENDER-002 NOT VERIFIED 1. staticStatus NOT APPLICABLE, 원본 status/runtimeStatus NOT VERIFIED. [원본 검사](../../test-results/org-chart-p10-2/guard-fixture.json)는 덮어쓰지 않고 별도 브라우저 증거를 연결한다. styleEvidence STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT PASS(적용 검사 0) / FINAL NOT VERIFIED다. 디자인 승인으로 해석하지 않는다. +- **NOT VERIFIED:** 2,100명 성능·전체 P10 회귀, SSR/hydration·다른 bundler/React major/OS·실제 touch·전체 스크린리더·시각 디자인 승인, Fixed의 가변 데이터 모든 배치/반응형, 가이드 HMChart/CustomChart 예제 자체의 브라우저 시연. 기존 검사 결과나 타입 PASS를 이 항목의 Runtime PASS로 확대하지 않는다. + +## 6. 변경 파일·Git·시간 + +이번 P10.2 변경은 아래 5개 문서다. + +- `AGENTS.md` +- `CLAUDE.md` +- `docs/org-chart/ai-page-generation-rules.md` +- `docs/org-chart/org-chart-usage-guide.md` (신규, 원본 가이드 통합·보완) +- `docs/org-chart/integration-report-p10-2.md` (신규) + +기존 React Wrapper 13개 변경은 그대로 유지한다. + +- `docs/org-chart/expanded-usage.md` +- `packages/org-chart-react/README.md` +- `packages/org-chart-react/build.mjs` +- `packages/org-chart-react/package.json` +- `packages/org-chart-react/src/HMEmployeeDialog.tsx` +- `packages/org-chart-react/src/OrgChart.tsx` +- `packages/org-chart-react/src/index.ts` +- `packages/org-chart-react/src/types.ts` +- `src/org-chart/org-chart-expanded.stories.tsx` +- `docs/org-chart/minimal-install-report-2026-10-13.md` (신규) +- `packages/org-chart-react/src/hm-dialog.tsx` (신규) +- `scripts/verify-orgchart-hm-story.mjs` (신규) +- `scripts/verify-orgchart-minimal-install.mjs` (신규) + +최종 Working Tree: tracked 수정 12개 + untracked 6개 = 18개. Stage 0. 테스트 fixture·빌드·스크립트·캡처·증거는 Git ignored `test-results/org-chart-p10-2`에만 있으며 패키지/Feature Commit에 포함하지 않았다. + +**Stage·Commit·Push·Merge·main Checkout·Rebase·Cherry-pick·Pull·Reset·Clean·Stash pop·Force Push 모두 미진행.** 기존 Feature HEAD와 다른 Worktree를 보존했다. + +- Started At: 2026-10-13 15:37:03 KST +- Completed At: 2026-10-13 15:55:33 KST +- Actual Duration: 18분 30초 +- Estimated Duration: 약 35–50분 +- Variance: 예상보다 16분 30초–31분 30초 짧음 diff --git a/docs/org-chart/integration.md b/docs/org-chart/integration.md new file mode 100644 index 0000000..7bc0188 --- /dev/null +++ b/docs/org-chart/integration.md @@ -0,0 +1,91 @@ +# OrgChart Integration + +상태: 공식 문서·소스 읽기 기반 기술 검토. 설치·실행·번들·브라우저 PoC는 수행하지 않았다. 모든 지원 완료 판정은 **NOT VERIFIED**다. 최신 범위는 Vanilla HTML/JavaScript + React이며 [Framework Integration](framework-integration.md)에서 같은 Core를 연결한다. + +## 1. 엔진 버전·라이선스·근거 + +확정 엔진은 [dabeng/OrgChart](https://github.com/dabeng/OrgChart)다. 별도 프로젝트 `react-orgchart`를 채택한 것으로 해석하지 않는다. 확인일은 2026-10-12(KST)이며 웹 자료는 이후 변경될 수 있다. + +- [공식 릴리스 목록](https://github.com/dabeng/OrgChart/releases)은 6.0.0을 최신으로 표시하고 v6부터 native JavaScript로 전환하여 jQuery 의존성을 제거했다고 설명한다. **6.0.0을 우선 검증 후보로 제안**하되 지원/고정 버전 확정은 아니다. 구버전 jQuery 연동 예제를 그대로 사용하지 않는다. +- 확인한 [master package.json](https://raw.githubusercontent.com/dabeng/OrgChart/master/package.json)의 version은 6.0.0, main은 `dist/js/orgchart.min.js`, dependencies는 html2canvas/json-digger/jspdf다. 이 의존성을 Core의 필수 기능 또는 배포물에서 자동 제외 가능하다고 단정하지 않는다. Export/download는 요청되지 않았으므로 UI에는 추가하지 않으며 transitive bundle 영향은 조사한다. +- [공식 LICENSE](https://raw.githubusercontent.com/dabeng/OrgChart/master/LICENSE)와 manifest는 MIT를 표시한다. 엔진 재배포 시 고지·라이선스 포함을 계획하고 실제 배포 artifact의 라이선스와 transitive 의존성은 별도로 확인한다. +- [README](https://github.com/dabeng/OrgChart#readme), [JS 소스](https://raw.githubusercontent.com/dabeng/OrgChart/master/src/js/orgchart.js), [CSS 소스](https://raw.githubusercontent.com/dabeng/OrgChart/master/src/css/orgchart.css), [데모 index](https://dabeng.github.io/OrgChart/)를 읽었다. 데모를 브라우저에서 실행하거나 화면을 대조하지 않았다. +- v6.0.0 tag의 raw 파일 조회와 release API 조회는 도구에서 실패했다. 따라서 **master 소스와 고정 tag/npm 배포물의 동일성은 NOT VERIFIED**다. 구현 전에 배포된 버전·tag·소스 hash·라이선스를 다시 대조하고 설치 후보를 고정한다. npm registry 배포/ESM entry/type 선언 존재도 아직 확인하지 않았다. + +## 2. 기능·구조 검토와 책임 분리 + +아래는 upstream 문서/소스에 기능 경로가 있음을 확인한 결과이며 HMDesign에서의 동작 PASS가 아니다. + +- **데이터·계층**: README는 JSON/UL 데이터와 `children` 기반 계층을 설명한다. Core의 flat ID 계약을 Engine Adapter가 파생 트리로 변환한다. engine에 UI 데이터를 원본 그대로 넘기지 않는다. +- **커스텀 노드**: `nodeTemplate(data)`와 `createNode(node, data)` 경로가 있다. 읽은 master는 template 반환값을 문자열 결합하므로 Element 반환 지원을 가정하지 않는다. **정적 skeleton 문자열 + createNode callback의 DOM/textContent 구성**을 우선 검토한다. engine edge/계층을 지우지 않고 콘텐츠 전용 자식만 관리한다. 고정 버전의 callback 순서·크기 측정·확장/축소는 PoC로 확인하며 React root를 노드 안에 만들지 않는다. +- **확장·축소**: show/hide parent/children/siblings 계열 API가 문서화돼 있다. 깊은 조직·custom 노드·focus·키보드와 함께 검증한다. +- **pan·zoom**: pan/zoom과 한계 옵션이 있다. CSS transform·touch·wheel·호스트 스크롤·Overlay 좌표 결합은 미검증이다. 기능 노출과 설정 기본값은 PoC 후 확정한다. +- **가로·세로 배치**: direction/verticalLevel/hybrid/compact 관련 문서가 있다. 일부 조합 제약이 안내되므로 모든 조합 지원으로 쓰지 않는다. 가로/세로 스크롤은 콘텐츠 컨테이너 책임과 함께 검사한다. +- **데이터 갱신**: `init`/`setOptions`의 데이터 교체 경로가 있다. 일관성·성능·확장/zoom 상태 유지까지 보장된 것은 아니다. + +JS 소스의 init은 기존 chart DOM을 제거·재생성하고 node 생성은 입력에 parentId를 추가하는 경로가 있다. 템플릿 문자열은 `insertAdjacentHTML` 경로로 삽입되므로 외부 텍스트 직접 주입은 금지한다. Core가 복사한 engine 데이터와 안전한 텍스트/ID를 제공해야 한다. [소스 근거](https://raw.githubusercontent.com/dabeng/OrgChart/master/src/js/orgchart.js). + +같은 소스에 이벤트 해제 함수와 Observer 사용은 있으나 공개 완결 `destroy()`는 읽은 소스/README에서 확인하지 못했다. Core의 Destroy는 **engine API가 아니라 우리가 보장해야 할 facade 계약**이다. 인스턴스 제거만으로 document 이벤트·Observer가 정리됐다고 주장하지 않는다. 소스 검토에서 관찰된 정리 한계는 최초 PoC의 차단 조건이며 필요하면 engine 버전/기능 제한/명시적 disposal 지원 또는 승인된 별도 통합 대안을 검토한다. 이번에는 upstream/기존 코드를 수정하지 않는다. [JS 검토 근거](https://raw.githubusercontent.com/dabeng/OrgChart/master/src/js/orgchart.js). + +여러 인스턴스는 chartClass 관련 문서만으로 격리를 보장할 수 없다. 컨테이너 Element 전달·인스턴스별 DOM ID·container 내부 query·이벤트·focus·zoom을 실제 검증한다. `.orgchart .node .title/.content` 등 upstream CSS의 고정 크기·색상·generic descendant selector가 custom 콘텐츠와 충돌할 수 있다. engine CSS와 Core CSS를 컨테이너 내부로 제한하고 다른 HM 요소를 비교한다. [CSS 근거](https://raw.githubusercontent.com/dabeng/OrgChart/master/src/css/orgchart.css). + +특수 곡선 연결선·중앙 허브·그룹별 자유 카드 배치·교차 조직 relation을 기본 기능만으로 표현할 수 있다는 근거는 확보하지 못했다. **NOT VERIFIED / 별도 PoC**다. 계층 연결선은 engine, 추가 관계는 Relation Overlay, 허브/summary/상태 그룹은 콘텐츠 Layout의 책임으로 나눠 검토한다. fixed 좌표·engine hierarchy 왜곡으로 reference를 맞추지 않는다. + +update/reload도 cleanup 대상이다. master의 pan/zoom 해제 경로는 현재 chart에 저장된 handler를 조회하므로 init 후 이전 handler를 회수하지 못할 위험이 있다. **이전 chart 참조를 잃기 전에** pan/zoom·minimap·관련 observer와 이벤트를 정리할 수 있는지 PoC로 확인한다. 반복 재생성 후 container/document 이벤트 수·응답 횟수 증가를 검사한다. 소스상 위험이며 실제 누수 재현은 NOT VERIFIED다. + +master는 모듈 평가 시 window/document를 참조하는 경로도 있어 DOM 없는 import 안전성을 주장할 수 없다. Core facade·데이터 계층 import를 engine 로딩에서 분리하고 지연 로딩 또는 승인된 변환 entry를 검증한다. native ESM export가 이미 존재한다고 가정하지 않는다. + +## 3. HM Theme 연결 + +Core 노드는 framework와 무관한 동일 HTML/CSS다. React Wrapper가 다른 노드 디자인을 만들지 않는다. Token 정책은 [Runtime Theme](../guidelines/runtime-theme.md), [Token 가이드](../guidelines/테마-토큰-가이드.md), [Runtime Portability](../guidelines/runtime-portability.md)를 따른다. + +확인한 선언은 [default.css](../../packages/ui/src/theme/default.css)의 `--hm-sem-bg-content`, `--hm-sem-fg-primary`, `--hm-sem-fg-secondary`, `--hm-sem-stroke-default`, `--hm-radius-container` 등이다. 노드 surface/상태/연결선의 **최종 매핑은 디자인 승인 전**이며 content 배경 변수를 Card White 면으로 자동 적용하지 않는다. Typography와 spacing은 모든 것이 공개 runtime 입력은 아니다. [card.css](../../packages/ui/src/theme/card.css), [avatar.css](../../packages/ui/src/theme/avatar.css), [table.css](../../packages/ui/src/theme/table.css), [tailwind.css](../../packages/ui/src/tailwind.css)의 실제 규칙을 참고하되 없는 `--hm-spacing-*` 같은 Token을 추측하지 않는다. + +React 호스트는 기존 CSS entry `index.css → hm-theme.css`와 공개 HM CSS 순서를 유지한다. Core CSS는 별도로 연결한다. Vanilla는 browser-ready Theme CSS와 상대 fonts/assets가 필요하며 Tailwind 원본 CSS/bare npm import를 그대로 ``에 넣지 않는다. [Theme-only 배포](../guidelines/theme-delivery.md)의 현재 `theme-dist/themes`는 JSON 산출물이므로 standalone CSS가 이미 제공된다고 오인하지 않는다. + +Core scope는 전용 컨테이너 class 내부로 제한하고 HM 전역 변수 재선언·global reset·HM Component selector override를 금지한다. OrgChart 전용 alias가 필요하면 기존 의미의 변수를 참조하고 scope에서만 선언한다. Theme 없는 환경의 기본 스타일 제공/필수 연결/오류 정책은 TBD다. 임의 fallback 색으로 미정의 Token을 감추지 않는다. 정확한 standalone Theme artifact 경로·포함 범위·CSS import flatten·font 경로는 배포 PoC에서 확정한다. + +Vanilla의 dependency/peer에 `@hm-design/ui` 전체 패키지를 추가해 Theme를 해결하면 React/React DOM/Tailwind peer가 설치 조건에 영향을 줄 수 있다. 승인된 호스트 CSS asset 또는 React peer 없는 Theme 산출 경로를 비교하고 React 미설치 consumer에서 실제 설치·동작을 검증한다. 현재 설치 동작은 NOT VERIFIED다. + +현재 `packages/ui/src/theme.css` 원본은 전체 Component/Pattern CSS import와 절대 `/fonts/` 경로를 포함한다. Core prefix만으로 호스트 Theme까지 scope된다고 주장하지 않는다. Vanilla Theme는 전체 CSS와 필요한 변수·font 제공 범위를 구별하고 상대 font 경로를 확인한다. Core/engine CSS 누출과 호스트 Theme 자체의 전역 영향을 별도로 기록한다. + +Theme 적용/해제의 owner는 호스트다. Core는 제공된 scope를 상속하고 Theme를 자동 주입/reset하지 않는다. 별도 Scoped Theme는 기존 `[data-hm-theme]`에서 mapping을 재계산하는 계약과 Scoped partial의 미지정 값이 HM Default를 쓰는 의미를 따른다. 단순 후손 override로 Global 값을 재계산한 것처럼 간주하지 않는다. Global A + Scoped B + 두 instance의 computed 값·독립성·cleanup을 검증한다. + +## 4. JSON에서 REST API로 전환 + +공통 `OrgChartDataSource.load({signal}) → Promise`은 normalized 계약 후보를 반환한다. JSON Mock·외부 JSON·API adapter는 모두 같은 validator·ViewModel·집계·Core renderer를 사용한다. [사용 예시](framework-integration.md#3-데이터-source-사용-예시)를 참고한다. + +바뀌는 영역: 호스트의 Source 생성/선택, 실제 API client 설정, 원본 응답 매핑, 취소·권한·이미지 접근·향후 캐시/재시도 정책, Source 계약 테스트. 고정 계약과 다른 응답은 API adapter에서 변환한다. + +유지되는 영역: normalized 필드/ID 계약(승인 버전 기준), runtime Validation, 집계 정책과 공통 함수, engine 변환, Core DOM·노드 디자인·Theme CSS, Core facade, React Wrapper의 Props 구조, 콘텐츠 단독 Story. Core나 Wrapper가 서버 URL/원본 필드에 직접 의존하면 완료 조건 실패다. + +실제 API가 생겨 schema를 확장해야 하면 Source 전환만으로 끝나는 것처럼 표현하지 않고 contract migration을 별도 승인한다. Mock API Story는 지연/오류/응답 경쟁을 흉내내는 가상 Source이며 실제 서버·인증 검증으로 보고하지 않는다. + +여러 endpoint/pagination으로 수집하면 Source Adapter가 완전한 collection과 같은 기준의 스냅샷을 조립해 한 번에 반환한다. 부분 페이지·서로 다른 버전의 조직/직원/관계를 Core에 순차 반영하지 않는다. ID 참조 검사를 통과해도 시간 일관성을 입증한 것은 아니다. 서버 일관성·버전 확인·재시도 방법은 실제 API 명세 전 TBD다. + +## 5. API 전환 전 결정 항목 + +실제 명세가 없어 다음은 모두 TBD다: Endpoint·메서드·응답 schema·인증/인가·데이터 권한, 갱신 주기·캐시/만료·재시도/오류 처리, 프로필 이미지 권한/origin/만료, 개인정보 노출/로그/보존 정책, dataVersion·기준일·updatedAt의 의미와 충돌 처리. 임의 회사 주소·토큰·인증 규칙은 예시에 넣지 않는다. + +HTML 환경도 상위 앱 API client가 인증을 담당한다. React 전용 hook/context를 API adapter의 필수 전제로 삼지 않는다. 공유 스냅샷이 같으면 두 환경의 조직 구조·집계·Theme·인터랙션 결과가 같아야 한다. + +## 6. P3 조회 API 추가 — 2026-10-12 + +위 초기 기술 검토 기록은 보존한다. 현재 패키지의 추가 API는 [Core README](../../packages/org-chart/README.md#organization-links-and-employee-information-p3)와 [React README](../../packages/org-chart-react/README.md#public-api)에 기술한다. 런타임 검증 결과는 해당 구현 보고서에서 별도로 확인한다. + +- `organizationLinks`는 기존 조직 ID별 외부 설정 URL map이다. 원본 계층의 1Depth 제목에만 적용하며 누락·빈 URL은 일반 제목으로 남는다. 실제 URL을 패키지에서 만들지 않는다. HTTP(S)·상대 URL을 허용하고 다른 scheme·잘못된 URL·credentials는 생성 시 `TypeError`로 거부한다. 링크는 native 키보드 이동·Focus 표시를 지원하며 카드 클릭과 분리된다. +- 대표자·조직장·직원·파견·휴직 카드의 클릭·Enter·Space는 기본 조회 dialog를 연다. `readOnly`에서도 조회하며 수정 UI는 없다. 이름·직위·조직·직책·관계사·근무 상태 중 기존 값만 표시하고 없는 선택 필드는 생략한다. readOnly 가상 표시 이름은 카드의 `홍 길 동`과 일치하며 데이터는 변경하지 않는다. +- 기본 native modal은 축소 Stage 밖의 같은 Core shell에 위치한다. 닫기 버튼·ESC·Focus trap·카드로 Focus 복귀를 처리하고 정보 팝업을 열면 이름 tooltip을 닫는다. HTML과 React는 같은 구현을 사용한다. +- 선택적 `onEmployeeClick(employee, context)`를 제공하면 기본 팝업 대신 호스트 callback을 실행한다. context는 기존 `organization`·`affiliation`, Focus 가능한 `trigger` 카드, `originalEvent`를 담는다. 이 경우 호스트가 팝업 접근성과 정리를 담당한다. 기존 API를 제거하지 않는 추가 옵션이다. +- Vanilla는 생성 옵션으로 전달하고 설정을 변경할 때 새 handle을 만든다. React는 같은 이름의 Props로 전달한다. 새 URL map 또는 callback 유무 변경은 재생성하며, callback 함수 identity만 바뀌면 최신 committed callback을 사용한다. 공개 Core `setOptions`는 추가하지 않았다. + +```js +const chart = createOrgChart({ + ...configuredBaseOptions, + organizationLinks: configuredOrganizationLinks, + // 생략하면 Core 기본 조회 팝업 사용 + onEmployeeClick(employee, context) { + openEmployeeInformation(employee, context); + }, +}); +``` diff --git a/docs/org-chart/minimal-install-report-2026-10-13.md b/docs/org-chart/minimal-install-report-2026-10-13.md new file mode 100644 index 0000000..540f7a5 --- /dev/null +++ b/docs/org-chart/minimal-install-report-2026-10-13.md @@ -0,0 +1,125 @@ +# OrgChart 선택형 UI 의존성 및 최소 설치 검증 + +RESULT: **PASS — 이번 최소 설치·선택형 Adapter 개선 범위.** 기존 P10 전체 판정 PARTIAL과 기존 Guard FAIL 10건은 유지한다. 전체 회귀·디자인 승인 결과로 확장하지 않는다. + +## 1. 실제 실패 원인 + +원본 외부 로그: `D:\디자인시스템\조직도 테스트\evidence\consumer-build-without-ui.log`. +기준 Feature/HEAD: `feature/orgchart-package`, `5acaf8a29596317133e54e8ad437ee00beba1cf0`. + +기본 React Entry → `OrgChart.tsx` → `lazy(() => import('./HMEmployeeDialog.js'))` → Dialog 청크 → `@hm-design/ui` 순으로 연결됐다. UI가 없는 소비자도 Vite 8.3.0이 dynamic import 청크를 Production Build에서 해석했다. optional peer stub에는 named export가 없어 다음 오류를 포함한 **12건의 MISSING_EXPORT**로 실패했다. + +```text +[MISSING_EXPORT] "Avatar" is not exported by "__vite-optional-peer-dep:@hm-design/ui:@hm-design/org-chart-react". +node_modules/@hm-design/org-chart-react/dist/HMEmployeeDialog-gdk9Uddu.js:3:10 +``` + +React.lazy는 실행 시 로딩을 늦추지만 소비자의 빌드 의존성 그래프에서 해당 Import를 제거하지 않는다. 원인을 확인한 후에만 Entry를 분리했다. + +## 2. 의존성·Import·공개 API + +- Core dependencies는 기존 `orgchart: 6.0.0` 그대로다. React/HM UI dependency, peer, Import를 추가하지 않았다. +- Wrapper dependencies는 `@hm-design/org-chart: 0.1.0` 그대로다. `react: ^19.0.0` peer 및 `@hm-design/ui: 0.1.0` optional peer 선언도 그대로다. 신규 런타임 라이브러리·lockfile 변경이 없다. +- 기본 `@hm-design/org-chart-react`는 UI-independent 공유 Wrapper만 Import한다. runtime JS와 `.d.ts`의 전이 그래프에 UI Import/참조가 없다. +- 신규 공개 `@hm-design/org-chart-react/hm-dialog`는 같은 Props의 `OrgChart`와 `HMEmployeeDialog`를 제공한다. 이 Entry만 HM UI 공개 API를 Import한다. package exports의 JS/타입 경로와 실제 tarball 산출물이 일치한다. +- 기존 `OrgChart`, 기존 Props, Core type-only re-export, `'core' | 'hm-dialog' | 'none'` 값을 유지했다. `'adapter'`, `employeePopupAdapter`, `OrgChartEmployeeSelection`, `OrgChartEmployeePopupProps`는 추가 API다. +- 기본값은 항상 Core다. HM UI 설치, HM Entry Import 또는 Adapter 전달만으로 팝업을 전환하지 않는다. `employeePopup` 설정으로 사용자가 명시적으로 선택한다. +- 기본 Entry에서 `'hm-dialog'`/`'adapter'`를 선택하고 Adapter를 제공하지 않으면 Core 팝업으로 유지하며 `onLifecycleError`에 구성 오류를 전달한다. `onEmployeeClick`은 기존대로 우선한다. +- HM Adapter의 직원 필드·Avatar·Dialog·닫기·Escape·focus 복귀 구현은 유지했다. 타입 계약만 공용 UI-independent 타입으로 이동했다. +- Auto에서 팝업 소유권 전환은 기존 `setEmployeeClickHandler`를 사용한다. Core 인스턴스와 데이터는 재생성·변경하지 않는다. Fixed의 기존 Core 팝업 정책은 유지하며, 다른 UI는 기존 `onEmployeeClick`으로 연결할 수 있다. +- Department Colors와 Overview의 별도 페이지 Export를 추가하지 않았다. AI 실험 페이지를 이관하지 않았다. + +**기존 HM 소비자의 설치/API 전환은 Import 한 줄이 필요하다.** 기존 기본 Entry와 `employeePopup="hm-dialog"`만 사용한 코드는 다음 Entry를 선택하거나 `HMEmployeeDialog`를 명시적으로 주입해야 한다. 자동 HM 로딩은 더 이상 제공하지 않는다. + +```tsx +import { OrgChart } from '@hm-design/org-chart-react/hm-dialog'; +; +``` + +다른 디자인 시스템은 기본 Entry에서 `employeePopup="adapter" employeePopupAdapter={MyPopup}`를 사용한다. Adapter는 `{ selection, onClose }`를 받으며 직원/context 원본을 수정하지 않고 접근성·닫기·focus 복귀를 소유한다. Core와 조직도 JSON을 수정하지 않는다. + +## 3. CSS·자산·설치 경계 + +Core의 공개 `engine.css`와 `style.css` 실제 배포 URL을 기존 `stylesheetUrls`로 Shadow DOM에 연결했다. Core CSS는 기존 빌드가 source-relative CSS를 inline한 자체 산출물이며 HM Theme/CSS를 Import하지 않는다. React Wrapper에는 자동 CSS Import나 새로운 자산 참조가 없다. HM Entry도 Theme를 자동 Import하지 않고 호스트의 실제 HM Theme/Tailwind 연결을 사용한다. + +A/B에서는 HM Theme나 Tailwind를 설치하지 않았다. A에는 React도 없다. C는 선택 HM UI와 해당 peer/Theme를 소비 프로젝트에서 제공했다. 일반 콘텐츠 CSS나 Fixed/Expanded 디자인을 수정하지 않았다. + +## 4. 실제 외부 프로젝트 A/B/C + +세 프로젝트는 저장소 및 상위 `node_modules` 밖의 새 임시 폴더에 만들었다. 소비자 소스에는 저장소 내부 Import/alias/symlink가 없다. `npm pack` 실제 `.tgz`를 dependency로 설치했고 `npm ls --all --json`으로 설치 그래프를 기록했다. 프로젝트 절대경로는 [projects.json](../../test-results/org-chart-minimal-install/projects.json)에 보존했다. + +- **A Core + Vanilla: PASS.** React·Wrapper·HM UI가 없는 실제 설치. strict Typecheck, Production Build, Fixed/Auto 렌더링 및 native Core 팝업·Enter·Escape·닫기·focus 복귀 PASS. +- **B Core + React Wrapper: PASS.** React/React DOM 19.3.0, HM UI·HM Theme·Tailwind 없음. 기본 선언 strict Typecheck, Production Build, Fixed/Auto 렌더링·Core 팝업 PASS. 다른 디자인 시스템의 팝업 Adapter 주입·callback 우선권·Adapter 미제공 시 Core 유지도 PASS. +- **C Core + React Wrapper + HM UI: PASS.** React 19.3.0, HM UI optional peer·Theme 연결. strict Typecheck 및 Production Build PASS. 기본 Entry와 HM Entry 모두 명시적 HM 선택 이전에는 Core를 유지한다. 기존 HM Dialog의 직원 정보·Enter·Escape·닫기·focus 복귀, HM→Core 전환 시 동일 Core 인스턴스 유지 PASS. + +[외부 브라우저 검사](../../test-results/org-chart-minimal-install/browser.json): **17개 그룹 PASS**, 브라우저 Chromium 155.0.8059.39, 콘솔 오류·브라우저 예외·HTTP 실패 0. StrictMode의 최종 host 1개와 입력 데이터 byte 보존도 검사했다. 이것은 전체 회귀 테스트 건수가 아니다. + +[기본 Entry 전이 그래프](../../test-results/org-chart-minimal-install/default-entry-graph.json): `index.js`, 공유 Wrapper JS, `index.d.ts`, `OrgChart.d.ts`, `types.d.ts`에 HM UI 참조가 없음 PASS. B의 `skipLibCheck: false` 타입 검사가 HM UI 없이 통과했다. + +실제 로그: [A 빌드](../../test-results/org-chart-minimal-install/build-A.log), [B 빌드](../../test-results/org-chart-minimal-install/build-B.log), [C 빌드](../../test-results/org-chart-minimal-install/build-C.log), [최종 외부 실행](../../test-results/org-chart-minimal-install/verification-online.log). + +첫 offline 설치 시도는 샌드박스 npm 캐시에 TypeScript가 없어 ENOTCACHED로 중단됐다. 네트워크 설치로 해결했고 패키지 FAIL로 계산하지 않았다. 반복 검증은 실패한 환경·미완료 검사만 보완했다. + +## 5. 기존 Storybook·최소 통합 검증 + +- `npm pack`: Core/UI/Wrapper의 prepack Build와 선언 생성 PASS. 공개 Entry가 실제 tarball에 포함됨 PASS. +- `tsc -p tsconfig.app.json --noEmit`: 기존 앱·Storybook TypeScript graph PASS. +- Storybook 10.6.1 Production Build: PASS. 기존 전체 회귀 suite는 실행하지 않았다. +- [Storybook 브라우저](../../test-results/org-chart-minimal-install/storybook-browser.json): Expanded의 기본 HM 팝업, Escape/focus 복귀, 기존 Select를 통한 Core 팝업 전환 및 Core 모달 focus PASS. Full Composition Fixed의 렌더링·Core 팝업·Escape PASS. 콘솔 오류·브라우저 예외 0. +- 외부 HM Dialog 캡처와 Storybook Expanded 캡처를 직접 확인했다. 레이아웃·직원 정보·닫기 버튼이 정상 표시됐다. 디자인 최종 승인을 의미하지 않는다. +- [Git/산출물 보존](../../test-results/org-chart-minimal-install/preservation.json): Core/UI/Center Graphic/Theme/기존 데이터/정책/P10 보호 파일의 tracked 수정 0. Expanded Story는 Import 한 줄만 변경했다. 최종 Wrapper tarball은 README의 잔여 lazy 설명만 보완하여 `--ignore-scripts`로 다시 pack했고, 모든 JS·`.d.ts`가 실제 설치·검증된 B/C와 byte 동일함을 확인했다. 정상 Build를 반복하지 않았다. + +빌드 경고는 기존 `:host-context` CSS minify 경고, vendor `use client` bundling 경고 및 큰 chunk 경고다. 빌드 실패는 없다. 이 작업에서는 관련 디자인·엔진 최적화 범위를 확대하지 않았다. + +## 6. Guard와 NOT VERIFIED + +기존 [P10.1 Guard](../../test-results/org-chart-p10-1/guard.json)의 FAIL 10건은 Core Token 참조에 대한 기존 기록이며 해당 파일을 수정하지 않았다. 원본 보고서를 보존했다. 전체 Guard를 반복하거나 전체 판정을 PASS로 바꾸지 않았다. + +이번 변경만 한 번 검사한 [PARTIAL Guard](../../test-results/org-chart-minimal-install/guard-partial.json)는 **FAIL 0 / NOT VERIFIED 5**다. 원본 `status`와 `staticStatus`는 NOT VERIFIED, `runtimeStatus`도 NOT VERIFIED로 그대로 둔다. + +- SCOPE-001 3건: 패키지 구현이 Guard의 증명된 Storybook graph 범위 밖임. 별도 npm pack·외부 build·브라우저 증거를 연결하며 원본 상태를 덮어쓰지 않는다. +- TOKEN-001: 기존 Expanded CSS의 Radix runtime 제공 변수. +- TOKEN-002: 기존 StoryPage의 동적 className. +- CSS styleEvidence: STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT NOT VERIFIED / FINAL NOT VERIFIED. 기존 CSS를 수정하지 않았으며 기술적 팝업 성공을 CSS 디자인 승인으로 사용하지 않았다. + +SSR/hydration, 다른 React major·bundler·OS, 전체 스크린리더, 실제 touch, 장기 메모리/성능, 390/768/1280px의 재검증, 전체 회귀 및 디자인·배포 승인은 **NOT VERIFIED / 이번 범위에서 미실행**이다. 이번 브라우저 검증은 1440px 핵심 팝업 동작에 한정했다. 기존 유효한 검사 결과는 과거 증거로 보존한다. + +## 7. 변경 파일과 사용 가이드 반영 + +기존 수정 파일: + +- `packages/org-chart-react/package.json`: 선택 HM Entry Export 추가. dependency 선언 유지. +- `packages/org-chart-react/build.mjs`: UI-independent/HM Entry를 함께 빌드. +- `packages/org-chart-react/src/OrgChart.tsx`: UI Import 제거, 명시 Adapter 주입·Core fallback. +- `packages/org-chart-react/src/types.ts`: UI-independent popup Adapter 계약 및 추가 Props. +- `packages/org-chart-react/src/index.ts`: 공용 Adapter 타입 Export. +- `packages/org-chart-react/src/HMEmployeeDialog.tsx`: 팝업 타입을 공용 계약으로 변경. +- `src/org-chart/org-chart-expanded.stories.tsx`: HM 선택 Entry로 Import 한 줄 변경. +- `packages/org-chart-react/README.md`: 최소 설치·HM Import 전환·커스텀 Adapter·빌드 경계 안내. +- `docs/org-chart/expanded-usage.md`: HM 선택 Entry·Adapter 사용법 보완. + +신규 파일: + +- `packages/org-chart-react/src/hm-dialog.tsx`: 선택 HM Adapter Entry. +- `scripts/verify-orgchart-minimal-install.mjs`: 외부 실제 설치·타입·빌드·팝업 검증. +- `scripts/verify-orgchart-hm-story.mjs`: 기존 변경 Story/Fixed 핵심 팝업 검증. +- 이 보고서. + +병렬 사용 가이드 담당자가 반영할 내용: 최소 설치에서 UI/Theme 제외, 기존 HM 소비자의 `/hm-dialog` Import 전환, `employeePopupAdapter` 및 `'adapter'` 계약, package 설치만으로 전환하지 않는 정책, Fixed의 기존 Core/`onEmployeeClick` 정책. Chart Only·UsageGuide Worktree 파일은 수정하지 않았다. 같은 파일의 동시 변경을 발견하지 않아 현재 Feature에서 최소 수정했다. + +## 8. Git 및 시간 + +공식 origin: `https://gitea.hmac.kr/b25027/HMDesign.git`. +Local/Remote Feature HEAD: `5acaf8a29596317133e54e8ad437ee00beba1cf0`. +읽기 전용 확인한 Remote main: `c45d63a03b84eaac43f01bd400a16488a80e4229`. main을 checkout·수정·Merge하지 않았다. + +Stage 0. **Commit·Push·main Merge 미진행.** 이 작업의 수정은 현재 Feature Working Tree에 남긴다. 기존 P10.1과 병렬 정책·사용 가이드 작업을 보존한다. + +- Started At: 2026-10-13 15:17:35 KST +- Completed At: 2026-10-13 15:32:28 KST +- Actual Duration: 14분 53초 +- Estimated Duration: 약 35–50분 +- Variance: 예상보다 20분 7초–35분 7초 짧음 + +검증 재현: 저장소의 일반 개발 의존성이 준비된 상태에서 `ORGCHART_PLAYWRIGHT_MODULE`을 기존 Playwright entry로 설정하고 `node scripts/verify-orgchart-minimal-install.mjs`를 실행한다. 기존 Chrome의 다른 설치 경로는 `ORGCHART_CHROME`으로 지정한다. 각 실행은 새 외부 임시 소비자를 만들고 기존 폴더를 삭제하지 않는다. Storybook 빌드 후 `node scripts/verify-orgchart-hm-story.mjs`로 선택된 기존 Story만 검사한다. 생성물은 `test-results/org-chart-minimal-install`에 보존하며 Stage하지 않았다. diff --git a/docs/org-chart/org-chart-usage-guide.md b/docs/org-chart/org-chart-usage-guide.md new file mode 100644 index 0000000..21e6daa --- /dev/null +++ b/docs/org-chart/org-chart-usage-guide.md @@ -0,0 +1,903 @@ +# HMDesign OrgChart 공통 사용 가이드 + +개발자·Codex·Claude가 공개 API로 조직 데이터를 연결하기 위한 가이드다. **2026-10-13 P10.7의 검증 기준 Commit은 `d56d587fd20316f19554105f33c200060394962b`**(`feature/orgchart-package`)이다. 이 SHA는 당시 검증한 버전이며 향후 최신 버전을 뜻하지 않는다. 실제 받은 버전은 `git rev-parse HEAD`로 확인한다. 기능 검증 근거는 [최종 Feature 통합 보고서](final-integration-2026-10-13.md), 승인 대기 항목은 [P10.7 정식 통합 승인 준비](release-readiness-p10-7.md)를 따른다. 과거 P-AI1/P10.2의 제한을 현재 지원 여부로 사용하지 않는다. + +빠른 시작은 아래 데이터 파일과 React 또는 Vanilla 진입점을 함께 복사한다. 예제는 Vite의 TypeScript 환경을 전제로 한다. 기본 예제는 제목·요약·툴바가 있는 Auto 화면이다. **Auto/Expanded Chart Only는 구현 및 기존 브라우저 검증이 완료됐으며**, 외부 UI 없이 삽입할 때는 [Chart Only 공개 API](#chart-only-공개-api-auto)를 사용한다. **Fixed Full Composition은 지원하지만 Fixed Chart Only는 미지원**이다. + +## P11 외부 CSS 테마 (2026-10-13) + +Core와 React는 같은 Shadow DOM CSS 정책을 사용한다. 공개 JS API·데이터 계약은 바뀌지 않는다. 외부 입력 `--hm-orgchart-*`는 호스트에서 상속되며, 내부 `resolved-*`/`current-*`는 실제 사용 요소에서 기본값을 계산한다. 회사색과 Fixed 부서색을 JS inline으로 덮어쓰지 않는다. 기본 파일만 연결하면 기존 회사색·카드·디자인을 유지한다. + +### Shadow DOM 안에 외부 파일 연결 + +`stylesheetUrls.core`는 **기본 스타일 전체를 포함하는 한 개의 CSS URL**이다. 테마 변수만 있는 파일로 교체하면 기본 레이아웃이 누락된다. `engine`에는 기존 엔진 CSS URL을 유지한다. 일반 HTTP 서버에서는 공개 `style.css`를 복사한 경로로 import한다. + +```css +/* /themes/external-theme.css — 기본 CSS를 먼저, 컬러 override를 뒤에 */ +@import url('/assets/org-chart/style.css'); +:host { + --hm-orgchart-bg-surface: #edf4ff; + --hm-orgchart-bg-content: #dce8f8; + --hm-orgchart-company-hanmac: #2457a7; + --hm-orgchart-company-saman: #385ca8; + --hm-orgchart-group-title-color: #fff4d6; + --hm-orgchart-card-background: #f4f8ff; + --hm-orgchart-card-border: #527aa9; + --hm-orgchart-expanded-line-color: #249a79; +} +``` + +Vite에서는 `@import '@hm-design/org-chart/style.css';`를 사용하고 CSS URL로 import한다. 실행 가능한 전체 예제는 [external-theme.css](../../examples/org-chart-react/external-theme.css), [React·Vanilla 소비 fixture](../../examples/org-chart-react/p11.tsx)다. `!important`가 필요하지 않다. `:host`에 선언한 값은 내부 기본값의 fallback보다 우선한다. 동일 속성의 CSS 규칙을 직접 재작성할 때는 보통의 specificity·source order가 적용된다. + +React: + +```tsx +import { OrgChart } from '@hm-design/org-chart-react'; +import engineUrl from '@hm-design/org-chart/engine.css?url'; +import externalThemeUrl from './external-theme.css?url'; + + +``` + +Vanilla (공개 ESM을 서빙하는 환경): + +```js +import { createOrgChart } from '/assets/org-chart/index.js'; +const container = document.getElementById('chart'); +if (!container) throw new Error('chart 요소가 없습니다.'); +const chart = createOrgChart({ + container, dataSource, + layoutMode: 'auto', + stylesheetUrls: { + engine: '/assets/org-chart/engine.css', + core: '/themes/external-theme.css', + }, +}); +await chart.mount(); +``` + +### 호스트 HTML CSS 상속 방식 + +호스트에서 변수만 설정할 때는 기본 `stylesheetUrls.core`를 유지한다. 다음 파일을 호스트 HTML의 ``로 연결하고, React의 `className="orgchart-custom-theme"` 또는 Vanilla 컨테이너의 같은 class를 사용한다. + +```css +.orgchart-custom-theme { + --hm-orgchart-company-hanmac: #2457a7; + --hm-orgchart-company-saman: #385ca8; + --hm-orgchart-expanded-line-color: #249a79; +} +``` + +이 방식은 **Custom Property 상속만** Shadow DOM을 통과한다. 호스트의 `.hm-orgchart-employee { ... }` 같은 selector는 Shadow 내부 카드에 적용되지 않는다. 내부 selector를 수정해야 하면 `stylesheetUrls.core` 파일 안에 작성한다. 호스트 class를 제거하면 기본값이 즉시 복원된다. 외부 테마를 동시에 쓸 경우 Shadow 파일의 `:host`에 같은 입력을 선언하면 조상에서 상속된 값보다 우선한다. 소비자가 직접 inline으로 입력 변수를 설정하면 소비자 inline이 우선하므로 CSS 파일 운영 시 이를 제거한다. + +### P11.5 Fixed·Expanded 캔버스 배경 독립 설정 (2026-10-14) + +캔버스 전용 입력은 `--hm-orgchart-fixed-canvas-background`와 `--hm-orgchart-expanded-canvas-background`다. 새 JS 옵션이나 React Props 없이 같은 Core CSS로 React·Vanilla에 적용된다. + +- Fixed readOnly / Full Composition: 미지정 시 기존 `--hm-orgchart-bg-content` → `#F5F8FB` 유지. composition과 내부 stage에 함께 적용된다. Fixed interactive는 미지정 시 기존 투명 배경을 유지한다. +- Expanded / Auto: 내부 viewport에만 적용. 미지정 시 기존 `--hm-orgchart-bg-surface` → `#FFFFFF`를 유지하여 기존 외부 테마 override가 무효화되지 않는다. 카드 배경·바깥 프레임의 `--hm-orgchart-expanded-preview-background`는 바뀌지 않는다. +- Chart Only: 기본 흰 캔버스와 기존 Fit·Wheel Zoom·Pan 유지. Auto 내부 viewport이므로 Expanded 캔버스 입력을 지정하면 같은 방식으로 커스터마이징할 수 있다. Fixed Chart Only는 계속 미지원이다. +- 각 전용 입력이 기존 공통 배경 입력보다 우선한다. 한쪽 전용 입력은 다른 유형에 영향을 주지 않는다. Storybook 페이지·Header·외부 Container는 적용 대상이 아니다. + +호스트 HTML에 연결하는 CSS 예시: + +```css +.orgchart-canvas-theme { + --hm-orgchart-fixed-canvas-background: #F5F8FB; + --hm-orgchart-expanded-canvas-background: #FFFFFF; +} +``` + +React (앞 절의 공개 CSS URL import와 `dataSource` 사용): + +```tsx +import coreUrl from '@hm-design/org-chart/style.css?url'; + + + +``` + +Vanilla (앞 절의 공개 ESM import와 CSS 서빙 경로 사용): + +```js +fixedContainer.classList.add('orgchart-canvas-theme'); +expandedContainer.classList.add('orgchart-canvas-theme'); +const stylesheetUrls = { engine: '/assets/org-chart/engine.css', core: '/assets/org-chart/style.css' }; +const fixed = createOrgChart({ container: fixedContainer, dataSource, + displayMode: 'readOnly', stylesheetUrls }); +const expanded = createOrgChart({ container: expandedContainer, dataSource: autoDataSource, + layoutMode: 'auto', stylesheetUrls }); +await fixed.mount(); +await expanded.mount(); +``` + +Shadow DOM 내부의 외부 CSS 파일을 사용할 때는 기존 기본 CSS import 뒤의 `:host`에 위 두 입력을 선언한다. 호스트 상속·`:host`·소비자 inline의 기존 우선순위는 그대로다. 같은 페이지에서 서로 다른 class 또는 각 컨테이너의 CSS 입력으로 인스턴스별 배경을 지정할 수 있다. 회사색·카드·연결선·팝업은 이 입력의 대상이 아니다. + +### 공식 컬러 변수 + +아래 이름은 모두 `--hm-orgchart-` 접두사를 사용한다. `resolved-*`, `current-*`, `expanded-primary`, `expanded-card-primary`는 내부 계산 결과이므로 테마는 아래 공개 입력을 사용한다. 표에 없는 geometry API와 기존 table 변수는 그대로 유지한다. + +| 입력 이름 (접두사 생략) | 적용 범위 / 기본값 정책 | +|---|---| +| `fixed-canvas-background`, `expanded-canvas-background` | Fixed composition/stage 및 Auto 내부 viewport의 독립 배경. 위 P11.5 절의 기존 입력 fallback 유지 | +| `bg-surface`, `bg-content`, `bg-subtle`, `bg-brand` | 캔버스·내용·보조 배경·브랜드 배경. Fixed readOnly의 `bg-content` 미지정 기본값은 기존 `#F5F8FB` | +| `fg-primary`, `fg-secondary` | 일반 텍스트 / 보조 텍스트 | +| `stroke-default`, `stroke-input-default`, `stroke-input-focus` | 일반 Border / 입력 Border / Focus | +| `accent-color`, `on-accent`, `action-primary`, `on-action-primary` | 기존 공통 강조·액션 색 | +| `company-{id}` | Auto 회사 제목과 해당 직원의 소속 회사 강조선. 소속이 부모와 다르면 직원 소속색을 사용 | +| `company-{id}-secondary-{n}` | 기존 보조색 alias 입력. `{n}=1`은 hanmac/saman/jangheon-industry/jangheon/ptc/halla, `{n}=2`는 ptc만 있다. baron에는 보조색 입력이 없다. 현재 주요 제목·카드에는 primary만 사용 | +| `group-title-background`, `group-title-color` | Fixed·Auto 그룹 제목 배경(단색/gradient), 글자. 배경 미지정 시 부서/회사색 | +| `group-border` | Fixed 일반 조직 카드의 상단 Border. Auto에는 기본 그룹 외곽 Border가 없음 | +| `card-background`, `card-color`, `card-position-color`, `card-border`, `card-accent` | CSS 카드 배경·이름/텍스트·Fixed 직위·Border·강조선. Fixed 부서별 배경과 일반 fallback 카드의 텍스트 입력은 공통 값보다 우선한다. SVG/compact 카드의 HTML 이름은 공통 `card-color`를 사용한다. 직원 카드 Border는 공통 `card-border`가 부서 Border보다 우선하며 상단 강조선은 `card-accent`가 우선한다. SVG 이미지 내부 색은 바뀌지 않는다 | +| `leader-card-background`, `top-leader-card-background` | Auto 그룹장 / 대표자 카드의 별도 배경. 미지정 시 공통 카드 배경 | +| `card-hover-background`, `card-focus-color` | Auto 카드 Hover 배경 / Core 직원 버튼 Focus 색(Fixed에도 Focus 적용) | +| `expanded-line-color` | Auto CSS pseudo선과 SVG 조직·직원 연결선. 기존 기본 `#738399` | +| `fixed-line-color` | Fixed 엔진 연결선, Workdesk overlay의 실제 SVG path/arrowhead. PNG 화살표 제외 | +| `gradient`, `border`, `department-color` | 기존 Fixed 공통 부서 gradient·Border·제목 글자색 | +| `{department}-gradient`, `{department}-border`, `{department}-department-color`, `{department}-card-background`, `{department}-card-color` | Fixed 6개 부서별 override. 기존 측정 gradient·기본값 유지 | +| `status-title-background`, `status-title-color`, `status-title-border` | 파견·휴직 요약 제목의 공통 색. 기존 회색 gradient, readOnly 흰 글자 유지 | +| `status-card-gradient`, `status-card-border`, `status-card-color`, `status-position-color` | Fixed 파견·휴직 요약 카드의 공통 gradient·Border·이름·직위. 기존 회색 기본값 | +| `dispatched-title-background`, `on-leave-title-background` | 파견 / 휴직 제목의 독립 gradient, 공통보다 우선 | +| `dispatched-card-gradient`, `dispatched-card-border`, `dispatched-card-color` | 파견 카드 독립 입력. Fixed 요약·Auto 상태 직원 카드에 적용 | +| `on-leave-card-gradient`, `on-leave-card-border`, `on-leave-card-color` | 휴직 카드 독립 입력. Fixed 요약·Auto 상태 직원 카드에 적용 | +| `dialog-background`, `dialog-color`, `dialog-border` | Core native 팝업 배경·글자·Border | +| `dialog-close-color`, `dialog-close-hover-background`, `dialog-focus-color` | Core 닫기 버튼 기본·Hover·Focus. 미지정 시 기존 HM 호스트 값/기본 fallback | + +회사 `{id}`와 기본색은 `hanmac #CB2E28`, `saman #F08300`, `jangheon-industry #4580A6`, `jangheon #1D418D`, `ptc #84338E`, `halla #0068B7`, `baron #0C367F`다. 등록된 ID와 기존 프리셋은 그대로다. Fixed `{department}`는 `business-management`, `business-planning`, `talent-growth`, `integrated-system`, `design-planning`, `solution-sales`다. 회사색 override로 Fixed 부서색을 바꾸지 않는다. + +```css +:host { + --hm-orgchart-company-saman: #385ca8; /* Auto 회사 제목·소속 카드 강조선 */ + --hm-orgchart-business-planning-gradient: linear-gradient(180deg, #385ca8, #23417f); + --hm-orgchart-business-planning-border: #385ca8; /* Fixed 부서: 별도 설정 */ + --hm-orgchart-dispatched-card-gradient: linear-gradient(180deg, #e9f9f0, #a9d8bd); + --hm-orgchart-dispatched-card-border: #278857; + --hm-orgchart-on-leave-card-gradient: linear-gradient(180deg, #f7efff, #d6bbeb); + --hm-orgchart-dialog-background: #eef2ff; + --hm-orgchart-dialog-focus-color: #8958ba; +} +``` + +색상 입력에는 유효한 CSS color를 사용한다. `*-background`는 적용되는 CSS 속성에 맞는 배경 표현식을, `*-gradient`는 `linear-gradient(...)` 같은 CSS image 표현식을 사용한다(Fixed 요약 카드는 `background-image`에 적용). CSS 파일만으로 동작하며 소스를 수정하거나 JS 색상 설정을 추가할 필요가 없다. 그룹 공통 배경을 설정하면 회사별 제목 배경보다 우선하므로 회사별 배경을 유지하려면 공통 배경을 생략한다. 상태 카드도 Auto의 회사 강조선을 유지한다. + +### 런타임 변경과 기본 테마 복원 + +호스트 변수 class/값을 바꾸면 이미 mount된 차트에 반영되며 데이터·Zoom·Pan을 재생성하지 않는다. 서버의 CSS 파일 값을 바꾼 뒤 기존 link는 브라우저 캐시에 따라 다시 요청되지 않을 수 있으므로 CSS link를 갱신하거나 새 URL로 mount한다. React는 `stylesheetUrls.core` URL 변경 시 기존 lifecycle로 remount한다. Vanilla는 기존 handle을 `destroy()`한 뒤 새 CSS URL로 `createOrgChart()`·`mount()`한다. 새 테마 변경 API는 추가하지 않았다. + +복원은 호스트 테마 class/link/소비자 inline 입력을 제거하고 Core URL을 기본 `@hm-design/org-chart/style.css?url` 또는 `/assets/org-chart/style.css`로 되돌린다. 일부 값만 복원하려면 외부 파일의 해당 선언을 삭제한다. `resolved-*`에 `unset`을 주는 방식은 사용하지 않는다. + +### HM Dialog와 Fixed 이미지 한계 + +React `/hm-dialog`의 `employeePopup="hm-dialog"`는 Shadow 밖의 HM React Dialog다. 위 `dialog-*` 변수는 Core 팝업에만 적용된다. HM Dialog에는 기존 `@hm-design/ui/theme.css`와 `@hm-design/ui/theme`의 `applyRuntimeTheme`/`resetRuntimeTheme`를 사용한다([Runtime Theme 가이드](../guidelines/runtime-theme.md)). Portal은 DOM 경계를 따르므로 차트 컨테이너에만 적용한 Scoped Theme는 body Portal로 전달되지 않는다. 같은 팔레트를 원하면 HM Global Runtime Theme 입력과 OrgChart CSS 입력에 각각 연결한다. 자동 동기화나 HM Dialog 디자인 변경은 하지 않는다. + +Fixed에서 회사 로고 PNG, `cards/*.svg`의 대표자·6개 부서 직원/그룹장 카드 이미지, `workdesk/Group.svg`, `bg_sky.png`, `bg_main.png`, `Frame2@3x.png`, `1-8-4x-1@2x.png`, `team-arrow-*.png`는 이미지 내부 픽셀 색상을 CSS 변수로 바꿀 수 없다. 카드 바깥의 Focus, CSS 제목·파견/휴직·배경과 실제 inline SVG 연결 path는 변경할 수 있다. 카드 CSS 배경을 바꿔도 SVG 이미지의 불투명 영역은 그대로다. 자산 재작성·필터 착색·이미지 교체는 하지 않았다. + +검증 범위·Guard 원본 상태는 [P11 결과 보고서](implementation-report-p11.md)를 참고한다. + +## 1. 설치·패키지·실행 환경 + +- `@hm-design/org-chart@0.1.0`: 데이터 타입·Validator·JSON DataSource·집계·이동 함수·DOM 렌더러. React 없이 사용한다. 내부 엔진 의존성은 `orgchart@6.0.0`이며 소비자가 엔진을 직접 조작하지 않는다. +- `@hm-design/org-chart-react@0.1.0`: 위 Core에 의존하는 React lifecycle adapter. 조직 배치·카드·데이터 검증을 다시 구현하지 않는다. React peer는 `^19.0.0`, HM Dialog 선택에 사용하는 `@hm-design/ui@0.1.0` peer는 optional이다. +- 저장소는 npm workspaces다. 공유 방식은 공식 Gitea 소스 연결이며 Git Clone 후 의존성 설치·빌드를 별도로 수행한다. npm Registry Publish와 ZIP 배포는 현재 공유 방식에 포함하지 않는다. +- Core 렌더링은 DOM·Shadow DOM·ResizeObserver·native dialog를 사용하는 브라우저 작업이다. SSR 중에는 mount하지 않는다. React 호스트도 client 경계에서 렌더링한다. + +### Feature 브랜치 Git Clone + +Git, Node.js와 npm, 공식 Gitea 및 npm 레지스트리 접근이 필요하다. Node.js 조건은 `^20.19.0 || >=22.12.0`이며 Node 21은 제외한다. P10.3 실행 환경은 Node 22.23.2 / npm 10.9.8이다. 기존 폴더를 덮어쓰지 않고 새 경로에서 실행한다. ZIP·별도 설치 파일·다른 작업 폴더의 node_modules/dist 복사는 필요하지 않다. + +```powershell +git clone --branch feature/orgchart-package --single-branch https://gitea.hmac.kr/b25027/HMDesign.git HMDesign-OrgChart +cd HMDesign-OrgChart +git branch --show-current +git rev-parse HEAD +# 검증 기준 고정: 기존 변경이 없는 새 Clone에서 실행한다. +git checkout --detach d56d587fd20316f19554105f33c200060394962b +git rev-parse HEAD +node --version +npm.cmd --version +npm.cmd ci +npm.cmd run build:packages +npm.cmd run typecheck --ignore-scripts +npm.cmd run test:org-chart +npm.cmd run build --ignore-scripts +``` + +`--ignore-scripts`는 위에서 완료한 package build를 pretypecheck/prebuild에서 반복하지 않기 위한 옵션이다. 이 위치에서 Typecheck/Build 본문은 실제로 실행된다. 루트 설치는 Storybook/HM UI를 포함하는 개발 workspace 설치이며 Core 소비자의 최소 의존성을 의미하지 않는다. + +Git으로 받은 소스만으로 의존성 설치나 빌드가 완료되는 것은 아니다. `npm.cmd ci`는 해당 Commit의 lockfile을 사용하며 `npm.cmd run build:packages`가 공식 workspace 패키지 빌드 명령이다. 위 타입·테스트·앱 빌드 명령은 소비 환경 검증용 안내이며 P10.7 문서 작업에서 실행한 결과가 아니다. + +### 버전 고정과 업데이트 + +- 브랜치 Clone은 이동하는 브랜치의 HEAD를 받는다. 재현 가능한 소비 버전은 전체 Commit SHA로 고정하고 `git rev-parse HEAD` 결과를 소비 프로젝트 기록에 남긴다. 위 detached checkout은 브랜치 포인터를 변경하지 않는다. +- 업데이트 시 기존 로컬 변경을 먼저 확인·보존하고 `git fetch origin feature/orgchart-package`로 후보를 가져온다. 새 승인/검증 SHA를 확인한 뒤 `git checkout --detach <새로 확인한 전체 SHA>`로 바꾼다. 예시 자리표시자를 그대로 실행하지 않는다. +- Commit을 바꾼 뒤 `npm.cmd ci`와 `npm.cmd run build:packages`를 수행하고 호스트 타입·빌드·핵심 동작을 확인한다. 같은 `0.1.0` package version만으로 두 소스 버전이 동일하다고 판단하지 않는다. +- 기존 프로젝트에는 필요에 따라 Git Submodule 또는 Subtree로 소스를 연결할 수 있다. Submodule은 부모 저장소의 gitlink Commit, Subtree는 가져온 upstream Commit을 기록한다. 두 방식도 의존성 설치·빌드·호스트 연결은 별도다. 기존 UI Submodule 사용 사례를 **OrgChart Submodule/Subtree 통합 검증 완료**로 표현하지 않는다. OrgChart의 두 통합 방식은 **NOT VERIFIED**다. + +Vanilla 예제는 아래 build 명령 후 `dev:org-chart-example`로 실행한다. React 예제는 package build 후 `build:org-chart-react-example`, `dev:org-chart-react-example`을 사용한다. HM Dialog Reference는 `npm.cmd run storybook`의 Expanded OrgChart에서 확인한다. 포트가 사용 중이면 기존 서버를 재사용한 결과를 독립 Clone 성공으로 보고하지 않는다. + +저장소의 설치 절차를 완료한 환경에서 사용하는 명령이다. Windows에서는 `npm.cmd`를 사용한다. + +```powershell +npm.cmd run build:org-chart +npm.cmd run build:org-chart-react +npm.cmd run build:org-chart-example +npm.cmd run dev:org-chart-example +# 또는 React 예제 서버 +npm.cmd run dev:org-chart-react-example +``` + +위 명령을 실행하면 기존 예제/산출물을 갱신할 수 있다. 병렬 문서 작업의 검증 명령으로 실행하지 않는다. 실행 위치·포트는 [Vanilla 예제 서버](../../examples/org-chart/serve.mjs), [React Vite 설정](../../examples/org-chart-react/vite.config.ts)을 확인하고 기존 서버와 충돌시키지 않는다. + +### 외부 프로젝트 최소 설치 + +외부 호스트의 최소 의존성을 검증할 때는 **Clone한 소스에서 직접 생성한** `npm pack` 산출물을 로컬 설치한다. tarball을 별도 전달하거나 Registry publish를 요구하지 않는다. 위 package build가 완료된 Clone 루트에서 다음을 실행한 뒤 아래 설치 경로를 생성된 파일 위치로 바꾼다. + +```powershell +New-Item -ItemType Directory -Path test-results/local-pack -Force +npm.cmd pack -w @hm-design/org-chart --ignore-scripts --pack-destination test-results/local-pack +npm.cmd pack -w @hm-design/org-chart-react --ignore-scripts --pack-destination test-results/local-pack +npm.cmd pack -w @hm-design/ui --ignore-scripts --pack-destination test-results/local-pack +``` + +생성물은 Git ignored 로컬 검증 자료이며 Git으로 받은 소스에서 소비자가 만드는 설치 artifact다. tarball/ZIP을 별도 배포하거나 Registry에 게시하는 절차가 아니다. A/B/C는 별도 호스트 폴더에서 아래처럼 설치한다. + +```powershell +# A: Core + Vanilla. React/HMDesign UI 없이 설치한다. +npm.cmd install ./hm-design-org-chart-0.1.0.tgz +# B: Core + React Wrapper. HMDesign UI/Theme/Tailwind 없이 설치한다. +npm.cmd install ./hm-design-org-chart-0.1.0.tgz ./hm-design-org-chart-react-0.1.0.tgz react@19.3.0 react-dom@19.3.0 +# C: HM Dialog를 명시적으로 사용할 때만 HMDesign UI와 해당 peer를 추가한다. +npm.cmd install ./hm-design-ui-0.1.0.tgz +``` + +Core와 Wrapper의 `package.json`에서 실제 dependency/peer를 확인한다. C의 HM Theme/Tailwind 연결은 호스트가 제공하며 `/hm-dialog`가 CSS를 자동 import하지 않는다. 기본 Entry는 HM UI runtime/type 의존 그래프를 포함하지 않는다. 설치 여부로 popup을 자동 전환하지 않는다. + +### 공개 Export와 CSS + +Core package의 exports는 `.`, `./style.css`, `./engine.css`, `./browser`다. + +- 주 진입점: `createOrgChart`, `JsonOrgChartDataSource`, `OrgChartSourceError`, `validateOrgChartDataset`, `isSafeImageUrl`, `normalizeOrgChartDataset`, `calculateSummary`, `snapshotOrgChartDataset`. +- 색상: `companyColorPresets`, `getCompanyColorPreset`; 타입: `CompanyId`, `CompanyColorPreset`. +- 가상 예제: `createOrgChartExampleDataset` 및 관련 타입. +- 이동: `createOrgChartMoveValidator`, `validateOrgChartMove`, `applyOrgChartMove` 및 관련 타입. +- 독립 모듈: `createOrgChartTree`, `createPositionSummary`, `createAffiliationSummary`, `createStatusSummary`. 이들은 Full Composition의 Auto 기능 전체를 제공하는 별도 React 컴포넌트가 아니다. +- 데이터·옵션·Handle 타입은 같은 주 진입점에서 가져온다. 내부 `src`/`dist` 경로를 소비 코드에서 import하지 않는다. + +React 기본 `@hm-design/org-chart-react`의 runtime export는 **`OrgChart` 하나**이며 HMDesign UI가 필요 없다. 선택 Entry `@hm-design/org-chart-react/hm-dialog`는 `OrgChart`와 `HMEmployeeDialog`를 export한다. 두 Entry 모두 `OrgChartProps`, `OrgChartEmployeeSelection`, `OrgChartEmployeePopupProps`와 Core 공개 타입을 type-only로 export한다. `JsonOrgChartDataSource` 같은 runtime helper는 Core에서 import한다. 내부 `src`/`dist` Import는 사용하지 않는다. + +`stylesheetUrls: { engine, core }`는 필수다. Core가 자기 Shadow DOM에 engine CSS → core CSS 순으로 link를 넣는다. document에 CSS를 import하는 것만으로 내부 카드가 스타일링되지는 않는다. 아래 `?url`은 Vite 전용이며 다른 bundler에서는 공개 CSS를 실제 배포 URL로 해석해 전달한다. + +React 없는 일반 HTML도 공개 browser bundle을 HTTP 서버로 배포해 사용할 수 있다. `./browser`는 ESM named import용이 아니라 build가 생성하는 IIFE `HMOrgChart` 전역 bundle이다. 이 가이드의 Vanilla TypeScript 예제는 주 ESM 진입점을 사용한다. URL JSON은 HTTP(S)/상대 경로를 사용하며 `file://` fetch는 지원하지 않는다. + +근거: [Core manifest](../../packages/org-chart/package.json), [Core exports](../../packages/org-chart/src/index.ts), [Data exports](../../packages/org-chart/src/data/index.ts), [React manifest](../../packages/org-chart-react/package.json), [React exports](../../packages/org-chart-react/src/index.ts), [Core build](../../packages/org-chart/build.mjs). + +## 2. 빠른 시작용 데이터 — 예제 A / B / E + +다음은 **가상 데이터 예제**다. `data.ts`로 저장한다. 예제의 회사·분기 수를 제품 기본값으로 사용하지 않는다. A는 1회사·2본부·본부별 2팀·총 7명(대표 1명 + 본부장 2명 + 팀 직원 4명)이다. B는 회사별 독립 Root 두 개와 회사별 컬러를 보여준다. E는 동일한 `singleCompany`를 React와 Vanilla에서 사용한다. + +업무 명칭인 본부를 이 예제에서는 `office`로 대응한다. Contract에 `headquarters` enum을 새로 추가하지 않는다. + +```ts +// data.ts +import { + getCompanyColorPreset, validateOrgChartDataset, + type CompanyId, type Employee, type Organization, type OrgChartDataset, +} from '@hm-design/org-chart'; + +export function makeCompanyDataset( + companyId: CompanyId, teamCounts: readonly number[], +): OrgChartDataset { + if (teamCounts.some(count => !Number.isSafeInteger(count) || count < 0)) { + throw new Error('팀 수는 0 이상의 정수여야 합니다.'); + } + const name = getCompanyColorPreset(companyId).companyName; + const rootId = companyId + '-root'; + const organizations: Organization[] = []; + const employees: Employee[] = []; + const addEmployee = (id: string, organizationId: string, position: string) => { + employees.push({ + id, organizationId, affiliationId: companyId, name: '가상 ' + id, + position, jobTitle: null, profileImage: null, workStatus: 'active', + }); + }; + addEmployee(companyId + '-ceo', rootId, '대표이사'); + organizations.push({ + id: rootId, companyId, parentId: null, name, type: 'root', + leaderId: companyId + '-ceo', displayOrder: 0, + }); + teamCounts.forEach((count, index) => { + const officeId = companyId + '-office-' + (index + 1); + const leaderId = officeId + '-leader'; + addEmployee(leaderId, officeId, '본부장'); + organizations.push({ + id: officeId, parentId: rootId, name: (index + 1) + '본부', + type: 'office', leaderId, displayOrder: index, + }); + for (let team = 0; team < count; team++) { + const teamId = officeId + '-team-' + (team + 1); + organizations.push({ + id: teamId, parentId: officeId, name: (team + 1) + '팀', + type: 'team', leaderId: null, displayOrder: team, + }); + addEmployee(teamId + '-employee', teamId, '사원'); + } + }); + const data: OrgChartDataset = { + schemaVersion: '0.1-draft', organizations, employees, + affiliations: [{ id: companyId, name }], + metadata: { asOfDate: '2026-10-13', dataVersion: 'example-1' }, + }; + const checked = validateOrgChartDataset(data); + if (!checked.valid) throw new Error(JSON.stringify(checked.issues)); + return checked.data; +} + +export const singleCompany = makeCompanyDataset('hanmac', [2, 2]); +const secondCompany = makeCompanyDataset('saman', [1]); +export const multiCompany: OrgChartDataset = { + ...singleCompany, + organizations: [...singleCompany.organizations, ...secondCompany.organizations], + employees: [...singleCompany.employees, ...secondCompany.employees], + affiliations: [...singleCompany.affiliations, ...secondCompany.affiliations], + metadata: { ...singleCompany.metadata, dataVersion: 'example-multi-1' }, +}; +const checkedMulti = validateOrgChartDataset(multiCompany); +if (!checkedMulti.valid) throw new Error(JSON.stringify(checkedMulti.issues)); +``` + +Root의 `companyId`는 하위 조직이 상속한다.복수 회사는 `parentId: null`인 별도 Root를 만든다. 회사들을 잇는 가짜 상위 조직을 추가하지 않는다. B는 위 `multiCompany`를 같은 렌더러에 전달하면 된다. + +## 3. React 최소 사용 — 예제 D / E + +Vite React 프로젝트에서 아래 `App.tsx`, `main.tsx`, `index.html`과 앞의 `data.ts`를 사용한다. 기본 조회용 설정은 `dragDropEnabled={false}`다. Zoom/Pan/팝업은 별도 설정으로 계속 사용한다. + +```tsx +// App.tsx +import { OrgChart } from '@hm-design/org-chart-react'; +import { JsonOrgChartDataSource } from '@hm-design/org-chart'; +import engineUrl from '@hm-design/org-chart/engine.css?url'; +import coreUrl from '@hm-design/org-chart/style.css?url'; +import { singleCompany } from './data'; + +const source = new JsonOrgChartDataSource(singleCompany); +const stylesheetUrls = { engine: engineUrl, core: coreUrl }; +const autoLayout = { type: 'tb' as const, initialVisibleDepth: 'all' as const }; + +export default function App() { + return { + if (state.status === 'error') console.error(state.issues); + }} + onLifecycleError={error => console.error(error)} + />; +} +``` + +```tsx +// main.tsx +import { createRoot } from 'react-dom/client'; +import App from './App'; + +const container = document.getElementById('root'); +if (!container) throw new Error('root 요소가 없습니다.'); +const root = createRoot(container); +root.render(); +window.addEventListener('pagehide', () => root.unmount(), { once: true }); +``` + +```html + + + + OrgChart + +
+ + + +``` + +React가 전용 div와 Core handle의 mount·subscribe·destroy를 소유한다. 소비자가 같은 div에 다른 자식이나 별도 Core 인스턴스를 넣지 않는다. StrictMode도 wrapper가 lifecycle을 관리한다. 공개 `ref`/imperative handle prop이나 `data`, `title`, `children` prop은 없다. + +기본 Entry의 `core` 모드는 Core CSS로 동작하며 HMDesign UI/Theme/Tailwind를 설치하지 않아도 된다. 실제 tarball을 설치한 React 19.3.0/Vite 8.3.0 환경의 타입·Production Build 검증은 [최소 설치 보고서](minimal-install-report-2026-10-13.md)를 참조한다. 다른 bundler·React major의 지원을 이 결과로 확대하지 않는다. + +### HMDesign Dialog 선택형 연동 + +기존 HM Dialog 소비자는 Import를 `/hm-dialog`로 변경하고 `employeePopup="hm-dialog"`를 유지한다. 기본 Entry에 HM UI가 설치되어 있어도 자동 전환하지 않으며, HM Entry만 Import해도 기본 popup은 Core다. 실제 `@hm-design/ui`와 호스트의 `index.css → hm-theme.css`/Tailwind 연결이 필요하다. + +```tsx +// HMChart.tsx +import { OrgChart } from '@hm-design/org-chart-react/hm-dialog'; +import { JsonOrgChartDataSource } from '@hm-design/org-chart'; +import engineUrl from '@hm-design/org-chart/engine.css?url'; +import coreUrl from '@hm-design/org-chart/style.css?url'; +import { singleCompany } from './data'; + +const source = new JsonOrgChartDataSource(singleCompany); +export function HMChart() { + return ; +} +``` + +### 외부 Popup Adapter + +기본 Entry에 `employeePopup="adapter"`와 `employeePopupAdapter`를 함께 전달한다. Adapter는 `{ selection, onClose }`를 받고 표시·닫기·접근성·focus 복귀를 소유한다. selection에는 직원 원본과 context가 있다. 아래 native dialog는 호스트가 소유하는 예시이며 조직도 카드·Core·데이터를 수정하지 않는다. + +```tsx +// CustomChart.tsx +import { useEffect, useRef } from 'react'; +import { OrgChart, type OrgChartEmployeePopupProps } from '@hm-design/org-chart-react'; +import { JsonOrgChartDataSource } from '@hm-design/org-chart'; +import engineUrl from '@hm-design/org-chart/engine.css?url'; +import coreUrl from '@hm-design/org-chart/style.css?url'; +import { singleCompany } from './data'; + +function EmployeePopup({ selection, onClose }: OrgChartEmployeePopupProps) { + const dialog = useRef(null); + useEffect(() => { + if (!selection) return; + const element = dialog.current!; + element.showModal(); + return () => { + element.close(); + if (selection.context.trigger.isConnected) selection.context.trigger.focus(); + }; + }, [selection]); + return selection ? { event.preventDefault(); onClose(); }}> +

{selection.employee.name} · {selection.employee.position}

+ +
: null; +} +const source = new JsonOrgChartDataSource(singleCompany); +export function CustomChart() { + return ; +} +``` + +Adapter 전달만으로는 활성화되지 않는다. 기본 Entry에서 `hm-dialog`/`adapter`를 선택하고 Adapter를 제공하지 않으면 Core popup을 유지하고 `onLifecycleError`로 구성 오류를 알린다. `onEmployeeClick`이 지정되면 이 선택보다 우선한다. Fixed는 기존 Core popup을 유지하고 외부 popup은 `onEmployeeClick`으로 연결한다. + +근거: [Props](../../packages/org-chart-react/src/types.ts), [Wrapper lifecycle](../../packages/org-chart-react/src/OrgChart.tsx), [HM Dialog](../../packages/org-chart-react/src/HMEmployeeDialog.tsx). + +## 4. Vanilla TypeScript 최소 사용 — 예제 D / E + +React가 없는 Vite TypeScript 프로젝트에서 `data.ts`, 아래 `main.ts`과 `index.html`을 사용한다. 반환 cleanup은 페이지/호스트 lifecycle 종료 시 호출한다. + +```ts +// main.ts +import { createOrgChart, JsonOrgChartDataSource } from '@hm-design/org-chart'; +import engineUrl from '@hm-design/org-chart/engine.css?url'; +import coreUrl from '@hm-design/org-chart/style.css?url'; +import { singleCompany } from './data'; + +export async function mountChart(container: HTMLElement): Promise<() => void> { + const chart = createOrgChart({ + container, dataSource: new JsonOrgChartDataSource(singleCompany), + stylesheetUrls: { engine: engineUrl, core: coreUrl }, + layoutMode: 'auto', displayMode: 'readOnly', dragDropEnabled: false, + autoLayout: { type: 'tb', initialVisibleDepth: 'all' }, + onStateChange: state => { + if (state.status === 'error') console.error(state.issues); + }, + }); + try { + await chart.mount(); + const state = chart.getState(); + if (state.status !== 'ready') throw new Error('조직도를 표시할 수 없습니다.'); + // Auto에서 필요할 때 호출: 현재 전체 표시 영역을 맞춘다. + chart.fitToView(); + return () => chart.destroy(); + } catch (error) { + chart.destroy(); + throw error; + } +} + +const container = document.getElementById('chart'); +if (!container) throw new Error('chart 요소가 없습니다.'); +const dispose = await mountChart(container); +window.addEventListener('pagehide', dispose, { once: true }); +``` + +```html + + + + OrgChart + +
+ + + +``` + +`createOrgChart()` 자체는 DOM/요청 부작용 없이 handle을 만든다. `mount(container?)`는 연결된 HTMLElement 하나를 소유하고 첫 load까지 수행한다. 다른 handle이 소유한 컨테이너, 중복 mount, destroy 후 재사용은 거절한다. `destroy()`는 여러 번 호출해도 안전한 최종 종료이며 다시 사용할 때는 새 handle을 만든다. + +Core의 subscribe(listener)는 unsubscribe 함수를 반환하며 이후 상태 변경을 구독한다. getState()는 현재 상태를 읽는다. Auto의 getData()는 숨긴 회사와 로컬 이동을 포함한 현재 immutable 데이터, getSelectedCompanies()/getAutoLayout()/getInteractions()/getDragDropEnabled()는 현재 옵션을 조회한다. getData()는 Auto 데이터가 준비되지 않으면 undefined다. + +mount/update/reload의 Promise 완료만으로 성공을 판정하지 않는다. Contract·소스·CSS 오류는 reject 대신 `getState().status === 'error'`/`onStateChange`로 전달될 수 있다. + +## 5. JSON 데이터 계약 + +현재 구현 타입 이름은 **`OrgChartDataset`**다. 과거 [데이터 계약 문서](data-contract.md)의 초안 이름·선택 필드는 현재 타입보다 우선하지 않는다. JSON 문자열을 직접 `JsonOrgChartDataSource`에 전달하면 URL로 해석된다. 문자열 JSON은 먼저 `JSON.parse(text)`하고 Validator를 통과시킨다. + +### 필수·선택 필드 + +- Dataset 필수: `schemaVersion: '0.1-draft'`, `organizations: Organization[]`, `employees: Employee[]`, `affiliations: Affiliation[]`, `metadata: Metadata`. +- Dataset 선택: `relations?: OrganizationRelation[]`, `presentation?: PresentationConfig`. +- Organization 필수: `id: string`, `parentId: string | null`, `name: string`, `type: 'root' | 'division' | 'office' | 'div' | 'cell' | 'team'`, `leaderId: string | null`, `displayOrder: number`(0 이상 안전한 정수). 선택: `companyId?: string`. +- Employee는 모든 필드 필수: `id`, `organizationId`, `affiliationId`, `name`, `position`은 string; `jobTitle`, `profileImage`는 string 또는 null; `workStatus`는 `active | dispatched | onLeave`. +- Affiliation 필수: `id: string`, `name: string`. 별도 `Company[]`는 없다. Auto 회사 식별은 Organization.companyId와 등록된 회사 프리셋을 사용하며 직원의 관계사/법인 소속은 Employee.affiliationId로 구분한다. +- Metadata 필수: 실제 달력 날짜인 `asOfDate: 'YYYY-MM-DD'`, 비어 있지 않은 `dataVersion: string`. 선택: `schemaVersion?: '0.1-draft'`(최상위와 같아야 함), `updatedAt?: string | null`(timezone 포함 RFC3339), `updateNote?: string | null`. +- Relation은 `id`, `sourceOrganizationId`, `targetOrganizationId`, `type: 'cooperation' | 'support'`가 필수다. 추가 관계는 검증/보존되지만 기본 계층 연결선으로 렌더링되지 않는다. +- Presentation을 넣으면 `title: string`, `description: string | null`, `hubText: string | null`, `organizationOrder: string[]`, `groups`의 `showEmployees/showSummary/showDispatched/showOnLeave/showAffiliations: boolean` 모두를 넣는다. Presentation 생략은 현재 heading 제거 옵션이 아니다. + +nullable 필드는 생략하거나 빈 문자열로 대신하지 않는다. 필수 string은 공백뿐인 값이 거절된다. Validator는 trim으로 원본을 고치지 않는다. Contract에 없는 키도 `UNKNOWN_FIELD`다. 원본에 추가 정보가 있다면 별도 자료에 보존하고 Contract용 변환본을 만든다. + +### 최소 JSON: 회사 → 본부 → 팀 → 직원 + +```json +{ + "schemaVersion": "0.1-draft", + "organizations": [ + { "id": "company-root", "companyId": "saman", "parentId": null, "name": "삼안", "type": "root", "leaderId": null, "displayOrder": 0 }, + { "id": "hq-1", "parentId": "company-root", "name": "1본부", "type": "office", "leaderId": null, "displayOrder": 0 }, + { "id": "team-1", "parentId": "hq-1", "name": "1팀", "type": "team", "leaderId": "employee-1", "displayOrder": 0 } + ], + "employees": [ + { "id": "employee-1", "organizationId": "team-1", "affiliationId": "saman", "name": "가상직원", "position": "차장", "jobTitle": "팀장", "profileImage": null, "workStatus": "active" } + ], + "affiliations": [{ "id": "saman", "name": "삼안" }], + "metadata": { "asOfDate": "2026-10-13", "dataVersion": "minimal-1" } +} +``` + +`leaderId`는 해당 조직에 소속된 Employee.id를 참조한다. 위 팀장도 employees에 한 번만 있다. 조직장 별도 레코드를 추가해 총원을 두 번 세지 않는다. 직원은 한 `organizationId`로 직접 소속되며 이름으로 관계를 연결하지 않는다. ID 중복 검사는 collection 내부 기준이고 서로 다른 collection의 같은 문자열은 현재 Validator가 거절하지 않지만, 호스트 변환 단계에서는 엔티티 prefix로 구분하기를 권장한다. + +`profileImage`는 HTTP(S) 또는 안전한 상대 URL이다. null 또는 이미지 load 실패 시 기존 사람 SVG Placeholder로 전환한다. credentials·data URL·javascript·protocol-relative URL을 이미지 API로 우회하지 않는다. 직위(position)와 직책(jobTitle)은 별개이며 연락처 같은 미지원 정보를 합성하지 않는다. + +### 깊은 JSON: 회사 → 부문 → 본부 → 부서 → 팀 → 직원 + +현재 Auto의 트리는 enum 순서에 종속되지 않고 실제 parentId 관계를 따른다. 다음은 5개 조직 계층 + 직원 카드 예제다. 본부=`office`, 부서=`div` 대응은 이 예제의 변환 선택이며 일반 기업 의미를 강제하지 않는다. + +```json +{ + "schemaVersion": "0.1-draft", + "organizations": [ + { "id": "deep-root", "companyId": "hanmac", "parentId": null, "name": "한맥기술", "type": "root", "leaderId": null, "displayOrder": 0 }, + { "id": "division-1", "parentId": "deep-root", "name": "기술부문", "type": "division", "leaderId": null, "displayOrder": 0 }, + { "id": "hq-1", "parentId": "division-1", "name": "기술본부", "type": "office", "leaderId": null, "displayOrder": 0 }, + { "id": "department-1", "parentId": "hq-1", "name": "설계부서", "type": "div", "leaderId": null, "displayOrder": 0 }, + { "id": "team-1", "parentId": "department-1", "name": "설계팀", "type": "team", "leaderId": null, "displayOrder": 0 } + ], + "employees": [ + { "id": "deep-employee-1", "organizationId": "team-1", "affiliationId": "hanmac", "name": "가상직원", "position": "사원", "jobTitle": null, "profileImage": null, "workStatus": "active" } + ], + "affiliations": [{ "id": "hanmac", "name": "한맥기술" }], + "metadata": { "asOfDate": "2026-10-13", "dataVersion": "deep-1" } +} +``` + +임의의 최대 Depth를 보장하는 API는 없다. 소스는 가변 계층을 처리하지만 극단적으로 깊거나 큰 입력의 렌더링·성능은 실제 브라우저에서 별도로 확인한다. + +근거: [실제 타입](../../packages/org-chart/src/data/types.ts), [Validator](../../packages/org-chart/src/data/validate.ts), [DataSource](../../packages/org-chart/src/data/source.ts), [정규화](../../packages/org-chart/src/data/normalize.ts), [집계](../../packages/org-chart/src/data/summary.ts). + +## 6. Fixed / Auto 선택과 레이아웃 + +### Fixed — 기존 Workdesk 디자인 구성 + +`layoutMode: 'fixed'`가 생략 시 기본이다. `displayMode: 'interactive' | 'readOnly'`와 별도 설정이다. + +- Fixed readOnly는 기존 승인 SVG 카드·대표자·허브·요약과 Workdesk 구성/비례 축소를 사용한다. 기준 캔버스는 1920×914.5, 축 X=935다. +- 측정된 배치는 `business-management/business-planning/talent-growth/integrated-system/design-planning/solution-sales` ID의 기존 열 구성에 맞는다. 상위 열이 매핑되지 않으면 일반 중앙 flow로 돌아가며 임의 데이터에 승인된 좌표 배치를 보장하지 않는다. 데이터의 ID/parentId를 디자인 매핑에 맞추려고 바꾸지 않는다. +- readOnly는 전체 펼침이며 Fixed interactive는 기존 접기 동작을 제공한다. Auto용 TB/LR/Hybrid·회사 선택·Drag & Drop·Zoom/Pan Handle은 Fixed의 새로운 기능이 아니다. +- **표시 데이터 제약:** `employee-card.ts`는 Workdesk 표현 카드의 이름을 `홍 길 동`으로 표시하고 Fixed 대표자 카드는 `대표이사` 직위를 표시하는 경로가 있다. 원본 Employee는 유지되지만 화면 표시가 원본 인명과 일치하지 않을 수 있다. 실제 사용자 데이터 표시에는 이를 보고하고 Auto를 검토한다. 이를 이번 문서 작업에서 수정하지 않는다. +- readOnly가 제목·기준일·요약까지 제거하는 Chart Only prop은 아니다. Chart Only는 Auto 전용 `presentationMode: 'chart-only'`이며 Fixed에서는 거부된다([Chart Only 공개 API](#chart-only-공개-api-auto)). + +### Auto / Expanded — 데이터 기반 가변 조직도 + +`layoutMode: 'auto'`는 회사별 독립 Root와 전체 관계를 사용한다. Auto의 회사별 컬러·접기·팝업·Zoom/Pan·이동은 Core 구현을 공유한다. + +- `autoLayout.type`: `tb`(기본, 위→아래), `lr`(왼쪽→오른쪽), `hybrid`(지정 Depth부터 세로 목록). 현재 기준 소스는 세 유형을 구현한다. +- `hybridStartDepth?: 2 | 3 | 4`: 기본 3, global Hybrid용. Root 조직=Depth 1이며 직원 카드를 조직 Depth로 세지 않는다. +- `initialVisibleDepth?: 1 | 2 | 3 | 'all'`: 기본 all. 해당 Depth부터 그룹을 접는 초기 정책이고 조직장은 남는다. 값을 변경하면 접힘 상태를 초기 정책으로 재설정한다. 다른 레이아웃 변경은 유효한 ID의 접힘 상태를 유지한다. +- `hybridOrganizationIds?: readonly string[]`: TB에서 지정 조직의 자손만 세로 배치. LR/global Hybrid와 동시 사용은 거절한다. 전환 전에 빈 배열로 해제한다. +- `interactions: { collapse?, zoom?, pan? }`: 각각 기본 true, 서로 독립이다. Compact 레이아웃, 임의 `layoutConfig`, 엔진 방향 옵션 전달, 공개 collapse setter는 없다. +- `presentationMode: 'default'`에서 `initialView`를 생략하면 초기 배율 1·Bounds의 가로 중앙·상단 여유 배치다. `initialView: 'fit'` 또는 Chart Only 기본값은 전체 Bounds를 맞추고 가로·세로 중앙에 배치한다. 사용자가 Pan/Zoom한 뒤에는 viewport를 보존한다. +- 명시적 Fit은 표시된 Bounds를 맞추고 가로·세로 중앙에 둔다. 큰 입력은 읽기 어려울 만큼 축소될 수 있어 initial Depth 또는 확대를 함께 검증한다. Fit은 수동 zoom.min보다 작아질 수 있다. +- Auto unknown 회사 프리셋은 거절한다. 회사 선택 `undefined`=전체, `[]`=명시적 빈 표시. 회사 숨김/복귀는 원본 데이터나 완료된 이동을 삭제하지 않는다. +- `companyTopNodes`는 회사 ID별 `employeeId/title/name/profileImage/organizationName/display` 표시 옵션이다. 기본은 Root.leaderId이며 직원은 해당 회사 Root 소속이어야 한다. `employeeId: null`은 상단 카드 배정을 제거한다. `display`는 `card | titleOnly | hidden`. 표시 override는 원본 직원/팝업 identity를 바꾸지 않는다. + +Vanilla Auto에서 설정 변경은 기존 handle로 한다. + +```ts +import type { OrgChartHandle } from '@hm-design/org-chart'; + +export async function configureLayout(chart: OrgChartHandle) { + await chart.setAutoLayout({ hybridOrganizationIds: [] }); + await chart.setAutoLayout({ type: 'lr', initialVisibleDepth: 2 }); + await chart.setInteractions({ collapse: true, zoom: true, pan: true }); + await chart.setSelectedCompanies(['hanmac', 'saman']); + // chart.setSelectedCompanies()는 전체, []는 빈 선택. +} +``` + +setter는 mount 후 Auto에서 호출한다. Vanilla의 생성 옵션인 layoutMode/displayMode/companyTopNodes/employeeConnections/zoom/organizationLinks/presentationMode/initialView/wheelZoom을 바꾸려면 destroy 후 새 handle을 만든다. + +React에서는 대응 prop으로 변경한다. dataSource 변경은 reload, autoLayout/interactions/dragDropEnabled/selectedCompanyIds와 Auto 팝업 변경은 기존 handle을 갱신한다. aggregationPolicy·CSS URL·layoutMode·displayMode·companyId·companyTopNodes·employeeConnections·zoom·organizationLinks·presentationMode·initialView·wheelZoom 값 변경은 handle을 재생성한다. 객체 옵션은 값으로 비교하므로 같은 값의 inline 객체 자체가 재마운트를 일으키지는 않는다. `setAutoLayout`는 patch merge이며 React에서 새 autoLayout 객체에 넣지 않은 필드가 매번 Core 기본으로 초기화된다고 가정하지 않는다. + +근거: [Core Handle/옵션](../../packages/org-chart/src/core/types.ts), [배치 정책](../../packages/org-chart/src/core/auto-layout-policy.ts), [회사 검증](../../packages/org-chart/src/core/expanded-company.ts), [Workdesk 좌표](../../packages/org-chart/src/core/workdesk-layout.ts), [Fixed 구성](../../packages/org-chart/src/core/org-chart-content.ts), [Viewport](../../packages/org-chart/src/core/expanded-viewport.ts). + +## 7. 디자인·회사별 컬러·카드 재사용 + +현재 Auto의 `getCompanyColorPreset(id).primary` 값: + +- `hanmac` 한맥기술: `#CB2E28` +- `saman` 삼안: `#F08300` +- `jangheon-industry` 장헌산업: `#4580A6` +- `jangheon` 장헌: `#1D418D` +- `ptc` 피티씨: `#84338E` +- `halla` 한라산업개발: `#0068B7` +- `baron` 바론컨설턴트: `#0C367F` + +색상은 조회 helper와 Core에 맡기고 새 카드 CSS에 복사하지 않는다. Root.companyId → 자손 상속 → legacy Root leader의 등록 affiliation → 생성 옵션 companyId(기본 hanmac)의 순으로 회사가 결정된다. 새 데이터는 Root.companyId를 명시한다. 조직 회사와 직원 affiliation은 다른 값일 수 있다. 카드 강조는 등록된 affiliation 프리셋을 우선하고 미등록 affiliation은 조직 회사 색을 사용한다. 자손의 companyId가 부모 회사와 다르면 Auto에서 `COMPANY_HIERARCHY`다. + +Fixed는 별도의 기존 부서 ID별 스타일·SVG를 사용한다. 회사 컬러 옵션을 바꿔 Fixed 전체를 재색칠하는 API는 없다. + +### 카드와 글자 + +- Fixed의 기존 대표자 SVG는 146×74, 1Depth 조직장 SVG는 146×68, 더 깊은 조직장/일반 직원의 small SVG는 108×52다. 등록 부서 자산이 없는 조직은 기존 fallback 카드다. 모든 Fixed 데이터에 SVG 매핑이 있는 것은 아니다. +- Fixed SVG 직위/이름 기본 크기는 small 15/18px, 대표자 20/24px, 부서장 SVG 직위 18px·이름 24px다. 이름 weight 600, 직위 400이며 전역 서체는 Noto Sans KR 등 기존 fallback을 따른다. 재설계 규격으로 사용하지 않는다. +- Auto는 SVG 배경 대신 기존 중립 카드와 회사 강조 Border를 쓴다. 일반 직원 폭 114px·최소 높이 54px, 조직장/상단 카드 폭 154px·최소 높이 72px, 사진 36×36, padding 8px, Border 1px, inset 강조 3px, 이름/직위 13/10px다. 긴 텍스트는 실측 높이가 늘 수 있다. +- Auto의 Root 대표자와 하위 조직장 카드는 같은 leader geometry를 사용한다. 1Depth/2Depth별 새 디자인 prop은 없다. 3글자 한글 이름은 표시 계층에서 띄어 쓰고 직위 공백을 표시상 제거하지만 원본과 callback 데이터는 유지한다. +- Auto 일반 카드에서 jobTitle·근무 상태를 별도 행으로 출력하지 않는 경로가 있다. 팝업은 원본 jobTitle/workStatus를 보여준다. Fixed의 파견/휴직 보조 카드·요약과 Auto의 직원 카드를 혼동하지 않는다. +- Fixed SVG의 외형 Border는 자산에 포함되고 Auto Border는 CSS/실제 연결면에 따라 강조한다. 회사 타이틀 텍스트는 현재 CSS에서 흰색이며 특정 색 조합의 대비가 승인됐다는 뜻은 아니다. + +### 연결선·스타일 적용 + +Auto 전체 트리 방향은 autoLayout.type, 직원 연결 방향은 조직 ID별 `employeeConnections: { [id]: 'top' | 'left' | 'right' }`다. LR/세로 경로는 실제 endpoint를 측정해 연결면을 정하고 LR 직원 카드의 연결/강조면은 왼쪽이다. TB/Hybrid 조직장은 상단 연결을 사용한다. 직원 방향 옵션을 전체 트리 방향 API로 해석하지 않는다. + +Auto 관계선/직원선은 기존 `--hm-orgchart-expanded-line-color`(기본 `#738399`)를 공유한다. Fixed 선·회사 Border와 별개다. 기본 선과 기존 SVG/회사 프리셋을 변경하지 않는다. + +Core는 Shadow DOM을 사용하므로 호스트의 일반 selector로 내부 카드가 바뀌지 않는다. 기존 공개 CSS 두 개와 Core를 재사용한다. 별도 승인된 스타일 변경이 있을 때만 [Expanded 스타일 안내](expanded-usage.md)의 실제 변수·스타일 로딩 경로를 확인한다. 변수 이름을 추측하거나 카드 교체 API를 만들지 않는다. + +근거: [회사 프리셋](../../packages/org-chart/src/company-color-presets.ts), [Fixed 부서 자산](../../packages/org-chart/src/core/department-card-assets.ts), [대표자 자산](../../packages/org-chart/src/core/representative-card-asset.ts), [직원 카드](../../packages/org-chart/src/core/employee-card.ts), [공통 카드 CSS](../../packages/org-chart/src/styles/employee-card.css), [Auto 카드 CSS](../../packages/org-chart/src/styles/org-chart-expanded.css), [Auto 연결선](../../packages/org-chart/src/core/expanded-layout.ts). + +## 8. 인터랙션·팝업·Drag & Drop + +### 탐색 + +- Auto toolbar의 확대/축소는 현재 배율×step 또는 ÷step, 기본 step=1.2, min=0.05, max=3이다. 공개 handle에는 zoomIn/zoomOut이 없으며 `zoomTo(scale)`를 사용한다. +- 기본 화면은 Ctrl/⌘ + 휠, Chart Only는 modifier 없이 직접 휠 Zoom을 사용한다(`wheelZoom`으로 선택 가능). 휠은 커서 중심 확대·축소이며 viewport 초점에서 +/−, 빈 공간 드래그/방향키 Pan을 지원한다. `panBy(x,y)`, `getViewportState(): {scale,x,y} | undefined`를 제공한다. +- toolbar의 100% 복원은 배율 1과 위치를 함께 복원한다. `zoomTo(1)`는 배율 변경이므로 동일한 동작이 아니다. 공개 resetViewport 메서드는 없다. +- `fitToView()`는 Auto에서 전체 표시 범위를 맞춘다. Fixed의 위 handle viewport 호출은 Auto 동작을 제공하지 않는다. +- 그룹 타이틀은 click/Enter/Space로 접기·펼치고 조직장은 남긴다. 부모 재확장 시 유효한 자손 접힘 상태를 유지한다. `interactions.collapse=false`는 토글만 끈다. 하위 내용 없는 제목은 aria-disabled로 접기 불가이나 이동/키보드 대상일 수 있다. +- Zoom/Pan을 끄면 해당 조작/API를 막지만 Fit은 독립적으로 가능하다. manual Pan이 OFF여도 키보드로 화면 밖 카드에 도달하면 배율을 유지하고 초점을 화면에 드러낸다. + +### 팝업 + +Core/Vanilla 기본 직원 카드 click/Enter/Space는 native dialog를 연다. X·ESC로 닫고 Tab 초점을 가두며 원래 카드로 복귀한다. 카드가 사라졌거나 인스턴스가 정리되면 shell/연결 상태에 따라 복귀한다. **Core dialog에는 외부 클릭으로 닫는 별도 핸들러가 없다.** + +React Auto의 `employeePopup`은 `core`(기본), `hm-dialog`, `adapter`, `none`이다. HM 모드는 `/hm-dialog` Entry 또는 명시적으로 주입한 `HMEmployeeDialog`를 사용한다. HM Dialog·Avatar·Button으로 X·닫기 버튼·ESC·외부 클릭으로 닫으며 onCloseAutoFocus에서 원래/재생성된 카드 또는 shell에 복귀한다. `adapter`는 호스트의 `employeePopupAdapter`를 명시적으로 선택한다. none은 기본 팝업/button 의미를 제거하지만 키보드 탐색용 초점은 남긴다. Fixed에서는 employeePopup 선택이 적용되지 않고 기존 Core 팝업을 유지한다. + +`onEmployeeClick(employee, context)`를 지정하면 기본 팝업보다 우선한다. context는 `organization`, `affiliation`, `trigger: HTMLElement`, `originalEvent: MouseEvent | KeyboardEvent`다. 임의 popup API를 추가하지 않고 호스트가 자신이 만든 UI의 닫기·접근성·focus 복귀를 책임진다. + +### 이동 정책 + +- `dragDropEnabled`는 Core/React 공통으로 **기본 false**다. displayMode readOnly도 이 옵션을 대체하는 편집 잠금이 아니다. +- OFF는 포인터 이동·Alt+M·handle.move를 차단하고 `EDITING_DISABLED`를 반환한다. OFF에서도 독립 설정에 따른 Pan/Zoom/Fit·팝업·접기는 유지한다. +- ON은 Auto 직원 카드/조직 타이틀 드래그 이동, Alt+M → 방향키 대상 선택 → Enter 확정 / ESC 취소를 지원한다. 빈 공간은 Pan이다. +- Root, 자신/자손으로 조직 이동, 같은 소속, leaderId 참조를 손상시키는 조직장 이동 및 지정된 상단 직원 이동을 차단한다. 대상 회사 affiliation이 없으면 회사 간 이동도 거절한다. +- 동일 회사 직원 이동은 organizationId만 바꾸고 affiliationId를 보존한다. 회사 간 직원 이동은 affiliationId도 대상 회사로 변경한다. +- 그룹 이동은 parentId를 바꾸며 회사 간 이동은 하위 조직 전체 companyId와 소속 직원 affiliationId도 대상 회사로 바꾼다. ID·이름·직위·사진·자손 관계·조직장·정렬은 보존한다. +- 결과는 **현재 Core 인스턴스의 새 immutable snapshot**이다. 원본 JSON·DataSource·서버·파일은 수정하지 않고 영구 저장 API도 없다. 새 source/reload는 소스 데이터를 다시 적용한다. UI 회사 선택만으로 이동 snapshot을 초기화하지 않는다. +- OFF 전환은 진행 중 드래그/키보드 이동을 취소하고 이미 완료한 이동은 남긴다. 설정만 바꿀 때 reload하지 않는다. + +```ts +import type { OrgChartHandle, OrgChartMoveResult } from '@hm-design/org-chart'; + +export async function moveEmployee(chart: OrgChartHandle): Promise { + chart.setDragDropEnabled(true); + const result = await chart.move({ + kind: 'employee', + sourceId: 'hanmac-office-1-team-1-employee', + targetOrganizationId: 'hanmac-office-2-team-1', + }); + chart.setDragDropEnabled(false); + if (result.moved) console.log(result.data); // 새 snapshot, 저장은 호스트 별도 책임 + else console.error(result.code, result.message); + return result; +} +``` + +위 예제는 A를 mount한 handle 기준이다. `onMove(result)`도 성공/실패를 알리며 `result.moved`로 분기한다. 성공은 data/request/previousOrganizationId, 실패는 code/message다. React 성공 이동은 열린 HM Dialog를 닫는다. 데이터용 순수 `applyOrgChartMove`는 UI OFF 잠금과 별개인 변환 함수이며 handle.move 대신 호출해 편집 잠금을 우회하지 않는다. + +근거: [Viewport 이벤트](../../packages/org-chart/src/core/expanded-viewport.ts), [편집 이벤트](../../packages/org-chart/src/core/expanded-editing.ts), [이동 정책](../../packages/org-chart/src/data/moves.ts), [Core 팝업](../../packages/org-chart/src/core/employee-info-dialog.ts), [HM 팝업](../../packages/org-chart-react/src/HMEmployeeDialog.tsx), [HM Dialog primitive](../../packages/ui/src/components/ui/dialog.tsx). + +## 9. 데이터 업데이트 — 예제 C + +Vanilla `update(input)`는 전달 snapshot을 검증해 현재 인스턴스에 적용하고 DataSource 자체를 교체하지 않는다. `reload(nextSource?)`는 현재/새 source를 load한다. 이후 reload가 이전 source 값을 다시 적용할 수 있다는 차이를 구분한다. 인라인 JsonOrgChartDataSource는 생성 시 값을 캡처하므로 원본 객체를 나중에 바꾸는 방식은 업데이트가 아니다. + +```ts +import { + JsonOrgChartDataSource, validateOrgChartDataset, type OrgChartHandle, +} from '@hm-design/org-chart'; + +export async function updateChart(chart: OrgChartHandle, input: unknown) { + const checked = validateOrgChartDataset(input); + if (!checked.valid) throw new Error(JSON.stringify(checked.issues)); + await chart.update(checked.data); + if (chart.getState().status !== 'ready') throw new Error('업데이트 실패'); +} + +export async function replaceSource(chart: OrgChartHandle, input: unknown) { + const checked = validateOrgChartDataset(input); + if (!checked.valid) throw new Error(JSON.stringify(checked.issues)); + await chart.reload(new JsonOrgChartDataSource(checked.data)); + if (chart.getState().status !== 'ready') throw new Error('소스 변경 실패'); +} +``` + +React는 dataSource identity가 바뀌면 같은 handle에 reload한다. `useMemo`/module 상수로 source를 안정적으로 만들고 매 render에서 새 source를 만들지 않는다. 다음은 호스트가 받은 새 데이터 prop으로 갱신하는 별도 React 예제다. + +```tsx +// UpdatingChart.tsx +import { useMemo } from 'react'; +import { OrgChart } from '@hm-design/org-chart-react'; +import { JsonOrgChartDataSource, type OrgChartDataset } 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'; + +const stylesheetUrls = { engine: engineUrl, core: coreUrl }; +export function UpdatingChart({ dataset }: { dataset: OrgChartDataset }) { + const source = useMemo(() => new JsonOrgChartDataSource(dataset), [dataset]); + return { + if (state.status === 'error') console.error(state.issues); + }} + onLifecycleError={error => console.error(error)} />; +} +``` + +새 dataset 객체를 전달한다. 성공한 onMove의 data를 집계용 read model로 저장하는 것과 바로 새 source로 넣어 reload하는 것을 구분한다. Core는 최신 요청 revision을 사용해 오래된 load 결과를 무시/취소한다. 재생성 옵션 변경은 viewport/이동의 보존을 보장하지 않는다. + +## 10. AI 코딩 에이전트 적용 절차 + +1. [AI 페이지 생성 정책](ai-page-generation-rules.md#11-생성-방식-사전-선택)에 따라 생성 방식이 미지정이면 Chart Only / Full Page 선택 질문에 대한 답변을 받은 뒤 진행한다. 이미 명시된 방식은 다시 묻지 않는다. 현재 사용자 요청·Git 기준·공개 API를 확인하고 회사/조직 유형·부모 관계·분기 수·직원 수를 정리한다. Root를 포함한 조직 Depth와 직원 수 집계 기준을 구분한다. +2. 자연어의 가상 데이터를 생성하거나 제공 JSON·CSV·Excel을 Contract로 변환한다. Core가 CSV/XLSX parser나 자연어 생성기를 제공하는 것은 아니다. 외부 변환 결과는 unknown으로 취급한다. +3. 원본 이름·ID·소속·직위·사진·부모 관계를 보존한다. 조직장과 대표자를 총원에 포함할지 확인하고 employees에 한 번만 생성한다. 미지원 필드와 원본을 별도 보존하며 오류 항목을 삭제해 Validator를 통과시키지 않는다. +4. validateOrgChartDataset와 요청별 회사·조직·Depth·인원 검사를 수행한다. Auto 회사 프리셋/회사 경계/상단 직원 제약도 확인한다. Validator 성공만으로 Auto 표시가 보장되지 않는다. +5. 기존 공개 Core/React·카드·컬러·연결선을 재사용해 실제 client 진입점을 만든다. 새로운 엔진·입력 UI·개발 패널을 만들지 않는다. +6. 구현 완료 후 한 번의 통합 검증으로 핵심 동작·데이터 보존·연결선·가독성·viewport·키보드·필요한 타입/build/Guard를 확인한다. 390/768/1280/1440px 및 요청 크기에서 확인한 실행 URL/캡처와 미검증을 보고한다. + +예: 삼안 6본부, 팀 5·2·4·1·3·2=17, 총 500명은 실제 생성 데이터의 organizations와 unique employee 수로 검증한다. 한맥기술 3부문×2본부×4팀과 300명도 같은 절차로 처리한다. 이 가이드의 7명 예제 분배를 재사용하지 않는다. + +공개 `createOrgChartExampleDataset`는 기존 template에서 `employeeCount: 20 | 100 | 300`, scope `total | per-company`로 가상 예시를 만드는 helper다. 임의 500명 생성·실제 Excel 변환 API가 아니다. 회사별 모드는 선택 회사마다 N명이며 total 모드는 원래 회사별 분배 후 선택 회사만 필터링한다. 실제 사용자 데이터를 이 helper로 재생성하지 않는다. + +### Chart Only 정책과 현재 기능의 관계 + +[AI 페이지 생성 정책](ai-page-generation-rules.md)을 먼저 읽고 AGENTS.md·CLAUDE.md의 연결을 따른다. **생성 방식이 미지정이면 코드 작성 전에 Chart Only / Full Page를 질문하고 답변 후 구현한다.** Chart Only를 선택하면 조직도 캔버스만 표시하고 외부 제목·통계·설명·조작 패널을 만들지 않는다. Full Page를 선택하면 기존 HM UI와 OrgChart Core·React Wrapper로 요청된 UI와 전체 레이아웃을 구성한다. 이미 명시된 방식은 다시 묻지 않으며 Fixed·Auto는 생성 방식 선택을 대신하지 않는다. + +#### Chart Only 공개 API (Auto) + +Auto(`layoutMode: 'auto'`)에서 `presentationMode: 'chart-only'`를 지정하면 Core가 **흰색 캔버스 + 조직 노드 + 연결선**만 렌더링한다. React와 Vanilla는 같은 Core 옵션을 사용한다. 기본값은 기존 화면과 동일하다(`'default'`). + +| 옵션 | 값 | 기본값 | 동작 | +|---|---|---|---| +| `presentationMode` | `'default' \| 'chart-only'` | `'default'` | chart-only는 외부 제목·기준일·회사/인원 요약·Zoom/Fit 버튼 바·배율·조작 안내·Drag & Drop 문구·카드형 외곽(회색 패딩·테두리)을 만들지 않는다. 노드 안 조직명·직원 정보는 유지한다. 이동 결과 live status는 화면에서 숨긴 채 보조기술에만 제공한다. | +| `initialView` | `'center' \| 'fit'` | chart-only `'fit'`, 그 외 `'center'` | fit은 전체 Forest Bounds(연결선 포함)를 16px 여백 안에 담고 가로·세로 중앙에 둔다. 100%를 넘겨 확대하지 않는다. 측정 전에는 조직도를 숨겨 첫 화면이 튀지 않는다. | +| `wheelZoom` | `'modifier' \| 'direct'` | chart-only `'direct'`, 그 외 `'modifier'` | direct는 캔버스 안에서 일반 휠로 포인터 기준 확대·축소한다. 캔버스 밖은 페이지 스크롤을 유지한다. modifier는 기존 Ctrl/⌘ + 휠이다. | + +```tsx +// ChartOnly.tsx — 기본 Entry, HMDesign UI 불필요. 앞의 data.ts를 사용한다. +import { OrgChart } from '@hm-design/org-chart-react'; +import { JsonOrgChartDataSource } from '@hm-design/org-chart'; +import engineUrl from '@hm-design/org-chart/engine.css?url'; +import coreUrl from '@hm-design/org-chart/style.css?url'; +import { singleCompany } from './data'; + +const source = new JsonOrgChartDataSource(singleCompany); +export function ChartOnly() { + return ; +} +``` + +```ts +// chart-only.ts — 앞의 data.ts와 호스트가 제공하는 HTMLElement를 사용한다. +import { createOrgChart, JsonOrgChartDataSource } from '@hm-design/org-chart'; +import engineUrl from '@hm-design/org-chart/engine.css?url'; +import coreUrl from '@hm-design/org-chart/style.css?url'; +import { singleCompany } from './data'; + +export async function mountChartOnly(container: HTMLElement): Promise<() => void> { + const chart = createOrgChart({ + container, dataSource: new JsonOrgChartDataSource(singleCompany), + stylesheetUrls: { engine: engineUrl, core: coreUrl }, + layoutMode: 'auto', presentationMode: 'chart-only', + initialView: 'fit', wheelZoom: 'direct', dragDropEnabled: false, + }); + try { + await chart.mount(); + if (chart.getState().status !== 'ready') throw new Error('조직도를 표시할 수 없습니다.'); + return () => chart.destroy(); + } catch (error) { + chart.destroy(); + throw error; + } +} +``` + +- 두 예제는 기본값과 같은 `initialView`/`wheelZoom`을 명시해 선택 가능한 값을 보여준다. Vanilla 호스트는 높이를 정하고 반환 cleanup을 lifecycle 종료 때 호출한다. `/hm-dialog` Entry의 Chart Only도 같은 Props를 쓰며 팝업은 `employeePopup="hm-dialog"`를 명시한다. +- 캔버스는 컨테이너 높이를 채운다(`height: 100%`). 컨테이너 높이가 정해지지 않으면 최소 320px이다. +- 자동 Fit 시점: 최초 렌더링, `update()`/`reload()`/React `dataSource` 교체, 회사 선택·Auto layout 변경. 휠 Zoom·Pan·직원 팝업·그룹 접기/펼치기·Drag & Drop ON/OFF·React 단순 리렌더링에서는 다시 Fit하지 않는다. 사용자가 조작하기 전 캔버스 크기가 바뀌면 다시 Fit하고, 조작한 뒤에는 사용자 뷰를 유지한다. +- Fit 배율이 `zoom.min`보다 작을 수 있다(대규모 조직). 이때 휠 축소는 Fit 배율에서 멈추고, 확대는 `zoom.max`까지 가능하다. 카드 크기·글자 크기는 바꾸지 않는다. +- 빈 공간 드래그 Pan, 방향키/`+`/`-`, 직원 팝업(기본 Core, `/hm-dialog` 선택형, `employeePopupAdapter`), 그룹 접기·펼치기, Drag & Drop 기본 OFF는 그대로다. 공개 `fitToView()`/`zoomTo()`/`panBy()` handle도 그대로 동작한다. +- React Wrapper는 `autoLayout`·`interactions`·`selectedCompanyIds`·`zoom` 등 객체 옵션을 값으로 비교한다. 같은 값의 inline 객체로 리렌더링해도 다시 마운트하거나 Fit하지 않는다. +- **Fixed는 지원하지 않는다.** `presentationMode: 'chart-only'`와 Fixed(`layoutMode` 생략 포함)를 함께 쓰면 `TypeError`를 던진다. 승인된 Workdesk 구성(제목·요약·아트워크·비례 축소)을 임의로 걷어내지 않기 위해서다. Fixed에 Auto와 같은 휠 Zoom/Pan도 없다. +- 실행 예제와 브라우저 검증: [`examples/org-chart-chart-only`](../../examples/org-chart-chart-only/README.md). + +P10.2 당시에는 내장 UI와 바깥 회색 영역이 남아 Chart Only가 PARTIAL이었으며 React 초기 Fit 옵션도 없었다. 이 **과거 검증 원본은 [P10.2 통합 보고서](integration-report-p10-2.md)에 보존**한다. 이후 위 공개 옵션 3개가 구현됐고 [최종 통합 검증](final-integration-2026-10-13.md)은 Auto Chart Only React/Vanilla 51/51 PASS를 기록한다. 과거 PARTIAL을 현재 기능의 미완료 상태로 해석하지 않는다. `chartOnly`/`showToolbar`/`showHeader`라는 prop은 현재도 없으며 정확한 API는 `presentationMode`다. `displayMode: 'readOnly'`나 dataset의 `presentation` 생략은 Chart Only 선택을 대신하지 않는다. `createOrgChartTree`도 Auto Chart Only의 대체 API가 아니다. + +React 없이 생성해 달라는 명시적 요청은 이 가이드의 Vanilla Core 경로로 처리하고 [Runtime portability](../guidelines/runtime-portability.md)의 명시적 adapter 계약을 확인한다. HM React 화면을 HTML로 모방해 결과라고 제출하지 않는다. + +## 11. 오류·검증·지원 한계 + +### 검사와 오류 처리 + +- validateOrgChartDataset는 valid=true이면 깊게 freeze한 snapshot을 반환한다. false이면 `issues: {code,path,message}[]`를 반환한다. +- 기본 오류: SCHEMA_VERSION, UNKNOWN_FIELD, REQUIRED_VALUE, DUPLICATE_ID, EMPTY_ORGANIZATIONS, INVALID_TYPE/STATUS/RELATION, PARENT_REFERENCE, LEADER_REFERENCE/LEADER_APPROVAL_REQUIRED, ORGANIZATION_REFERENCE, AFFILIATION_REFERENCE, HIERARCHY_CYCLE, RELATION_REFERENCE, PRESENTATION_REFERENCE, UNSAFE_INPUT. +- organizations=[]는 오류이며 employees=[]는 유효할 수 있다. State 타입의 empty가 실제 empty UI 지원 증거는 아니다. +- Auto는 추가로 COMPANY_PRESET/COMPANY_HIERARCHY 및 TOP_NODE_CONFIG/TOP_NODE_REFERENCE를 검사한다. 기본 Validator는 회사 프리셋/회사 간 계층 경계를 검사하지 않으므로 두 층을 구분한다. +- source 오류는 SOURCE_URL/LOAD/PARSE/ABORT/SNAPSHOT, 스타일 오류는 CORE_STYLE 등으로 구별한다. Core state.error와 React onLifecycleError의 책임을 구분한다. +- calculateSummary(data).raw는 unique 직원·direct/inclusive·position·affiliation·status 집계다. 업무 승인 집계는 aggregationPolicy가 없으면 unavailable(policyRequired)이다. 현재 정책 구현은 includeLeaders=true와 membershipMode=primaryOnly 등 제한을 확인하며 임의 excludeLeaders/uniqueEmployee 지원을 선언하지 않는다. + +### 주요 미지원·별도 확인 대상 + +- 자연어→데이터 자동 생성 UI, CSV/XLSX 직접 import, 서버 저장·외부 API·CSV/PDF/이미지 export는 공개 사용 기능이 아니다. +- 임의 회사 프리셋 등록 setter·카드 교체·Compact·공개 collapse setter·React imperative ref는 없다. Chart Only는 Auto 전용 `presentationMode`로만 제공하며 Fixed Chart Only는 없다. +- 제공 데이터의 최대 인원/Depth·모든 브라우저·실제 모바일 터치/스크린리더·외부 fresh install을 이번 소스 분석으로 보장하지 않는다. +- 기존 P10 PARTIAL/Guard FAIL 10건의 원본 기록은 보존한다. [P10 Feature 통합](final-integration-2026-10-13.md)은 Chart Only·초기 Fit·직접 Wheel Zoom, GuardFix의 F3–F7 해결 5건, AI 생성 방식 사전 선택 정책을 포함했고 당시 잔여 FAIL 5건·NOT VERIFIED 9건이었다. 이후 [P11](implementation-report-p11.md)의 동일 대상 실제 Guard는 `status/staticStatus PASS`, FAIL 0건이다. Guard `runtimeStatus NOT VERIFIED`와 제품 전체 미검증을 계속 구분한다. Cell 디자인·미리보기는 제외한다. +- [P10.3 GuardReview](guard-review-p10-3.md)는 별도 Worktree에서 당시 FAIL 10건을 재현한 원본 분석이다. 현재 Auto Chart Only와 P11 외부 CSS 테마는 지원한다. Fixed Chart Only·Fixed SVG/PNG 이미지 내부 테마화는 미지원이다. [P11.2 최종 디자인 승인](design-approval.md)은 Storybook 4개 화면과 Auto Chart Only React/Vanilla의 기본 디자인 APPROVED를 기록하며 접근성·보안 위험 수용과 구분한다. main Merge·Docker 배포는 완료하지 않았다. + +### 이 가이드 예제 검증 범위 + +아래 검증 상태는 이 문서의 예제를 대상으로 한 별도 결과이며 기존 제품 회귀검사 결과가 아니다. + +- **TypeScript PASS:** 이 문서에서 추출한 TS/TSX 코드 블록 8개를 설치된 TypeScript 7.0.2의 strict·ES2022·Bundler·react-jsx 설정으로 검사했다. Core/React import는 기준 Worktree의 src 공개 진입점, CSS URL은 기존 Vite client 타입에 연결했다. 새 빌드 산출물을 지원 증거로 사용하지 않았다. +- **데이터 실행 PASS:** 기준 Worktree의 데이터 모듈을 임시 경로에 컴파일해 JSON 2개와 data.ts의 A/B 데이터 2개를 실제 Validator로 확인했다. A=7조직/7명, B=독립 Root 2개/10명, 조직장 중복 없는 집계, 직원 이동 후 원본 소속·총원 보존, 자손으로 그룹 이동 거절, 회사 프리셋 7개 값을 확인했다. +- **Runtime NOT VERIFIED:** App/UpdatingChart의 React 렌더링, mountChart의 Vanilla mount, configureLayout, handle 기반 moveEmployee/updateChart/replaceSource, HTML 진입점의 실제 실행은 확인하지 않았다. 타입 검사와 순수 applyOrgChartMove 실행을 이들 브라우저 예제의 실행 PASS로 해석하지 않는다. +- 실제 React/Vanilla 화면 렌더링·CSS load·인터랙션 시연·시각 QA: **NOT VERIFIED**. +- 설치/전체 build/Storybook 회귀·외부 소비 프로젝트 fresh install: **NOT VERIFIED**. + +위 항목은 UsageGuide 원본 문서 작업 당시의 범위다. **P10.2 추가 검증:** 통합 후 TS/TSX 예제 10개를 현재 package 공개 `.d.ts`에 연결해 strict·skipLibCheck=false로 확인했다(PASS). 기존 실제 npm pack A/B/C 설치·타입·Build·팝업 검사 PASS는 현재 배포 JS/타입/CSS의 byte 동일성을 확인해 재사용했다. 독립 Chart Only React/Vanilla Build와 위 브라우저 검사는 새로 수행했다. 이 가이드의 HMChart/CustomChart 예제 자체의 브라우저 시연, 전체 회귀·성능·SSR·스크린리더·모바일 touch는 NOT VERIFIED다. + +**P11.6 추가 검증(2026-10-14):** 현재 가이드의 TS/TSX 블록 14개를 깨끗한 빌드의 공개 `.d.ts`에 연결해 strict·skipLibCheck=false로 검사했다(PASS). 앞 절의 변수를 사용하는 외부 CSS/P11.5 부분 예제 2개에는 명시된 데이터·Import 문맥을 보충했다. JSON 예제 2개는 실제 Validator PASS다. React·Vanilla 배경 독립 설정·테마 제거 복구·Chart Only 커스텀 배경과 새 Storybook 빌드는 추가 브라우저 20/20 PASS다. 예제 각각의 전체 브라우저 실행이나 Guard Runtime PASS를 뜻하지 않는다. 검사 범위·기존 증거 재사용·배포 플랫폼 미검증은 [P11.6 정합성 보고서](consistency-review-p11-6.md)를 따른다. + +## 12. 관련 문서·실제 소스 + +필요한 부분만 확인한다. + +- [AGENTS.md](../../AGENTS.md), [CLAUDE.md](../../CLAUDE.md): 프로젝트 공통 제약. +- [AI 생성 정책](ai-page-generation-rules.md): 자연어/제공 데이터 생성·보존·최소 화면 정책. +- [Expanded 사용법](expanded-usage.md), [Framework 통합](framework-integration.md), [독립 컴포넌트](standalone-components-p1.md): 상세 소비 경로. +- [Core 구현](../../packages/org-chart/src/core/index.ts), [공개 옵션](../../packages/org-chart/src/core/types.ts): 초기화·업데이트·정리·Auto setter·이벤트. +- [React 구현](../../packages/org-chart-react/src/OrgChart.tsx), [Props](../../packages/org-chart-react/src/types.ts): lifecycle과 갱신 경계. +- [데이터 타입](../../packages/org-chart/src/data/types.ts), [검증](../../packages/org-chart/src/data/validate.ts), [이동](../../packages/org-chart/src/data/moves.ts): 실제 Contract와 정책. +- [P10 보고서](implementation-report-p10.md): 기존 PARTIAL/Guard FAIL·별도 미검증 기록. 현재 기능의 근거는 보고서보다 이 가이드 기준 소스다. + +원본 UsageGuide/P10.2의 문서 예제 검증 기록은 위에 보존했다. P10.7에서는 검증 기준 SHA·Gitea Commit 고정 절차·현재 Chart Only 지원 상태와 옵션 설명을 공개 소스와 대조했으며 새 전체 타입 검사·제품 테스트·빌드·브라우저 검사는 수행하지 않았다. P11/P11.1의 후속 검증은 [P11 보고서](implementation-report-p11.md)와 [P11.1 정합성 검증](consistency-review-p11-1.md)을 따른다. P11.1은 공개 API·제품 스타일·Story 소스를 변경하지 않는다. diff --git a/docs/org-chart/quality-diagnosis-p6.md b/docs/org-chart/quality-diagnosis-p6.md new file mode 100644 index 0000000..f91c152 --- /dev/null +++ b/docs/org-chart/quality-diagnosis-p6.md @@ -0,0 +1,131 @@ +# HMDesign OrgChart P6 핵심 품질과 배포 준비 진단 + +RESULT: **PARTIAL** + +현재 HEAD에서 Core·React Typecheck/Build, 공개 패키지 경계, Fixed/Auto 대표 실행은 통과했다. 확인한 범위의 P0 결함은 없다. Auto 관계 연결선의 낮은 대비(P1)를 배포 전 보완하도록 권장한다. Guard Runtime은 별도 증거 연결로 PASS가 되었으나 전체 CSS 디자인 의도와 모든 동적 상태는 NOT VERIFIED다. 기술적인 패키징·실행 준비는 가능하며, 무조건적인 배포 승인으로 해석하지 않는다. + +## 작업 환경과 Git + +- 실제 폴더: `D:\디자인시스템\HMDesign-orgchart-package` +- 범위: BOTH, Vanilla HTML/Classic·React 공통 Core 진단. 신규 업무 화면 개발 없음. +- 브랜치: `feature/orgchart-package` +- 시작/종료 HEAD 및 시작/종료 원격 feature SHA: `d2b76e766466e1f156f6a0f8f63710e023af900e` +- origin: `https://gitea.hmac.kr/b25027/HMDesign.git` +- Git 작성자: `b25027`, `shkim4@samaneng.com` +- 시작 Working Tree: clean. 완료 후 기존 추적 파일의 전체 SHA-256은 시작과 동일하며 staged diff 없음. +- 신규 보고서: 이 파일. 실행기·로그·캡처·tarball·빌드 결과는 ignored `test-results/org-chart-p6/`에 분리했다. +- **제품 소스, Fixed/Auto CSS·자산·데이터 스키마·Guard 수정 없음. Stage·Commit·Push·Merge·PR·main Checkout 미진행. Reset·Rebase·Clean·Force Push 없음.** + +사용자가 이번 작업에 지정한 폴더·feature 브랜치와 Commit/Push 금지가 일반 AGENTS의 공식 수정 경로/main 정책보다 우선한다. 원격 확인은 `git ls-remote`로 읽기만 수행했다. + +## 검사 결과 + +| 영역 | 현재 HEAD 결과 | 근거와 범위 | +|---|---|---| +| Git·추적 파일 | PASS | 폴더/브랜치/SHA/origin·remote 일치. Core/React/JSON/CSS/로고/placeholder/font 소스 추적. Git에 node_modules/dist/test-results/.env/log가 포함된 흔적 없음. 전체 민감정보 보안 감사는 수행하지 않음. | +| Core Typecheck / Build | PASS | 실제 workspace script. ESM·Classic IIFE·선언·CSS·engine license 생성. | +| React Typecheck / Build | PASS | Core 공개 선언 기반 Wrapper. React/Core 외부 dependency 유지. | +| 새 PC 실행 | PASS | 설치된 기존 node_modules와 bundled Node 24.19.0 사용. 의존성 설치/업그레이드·lockfile 수정 없음. | +| React production consumer | PASS | 기존 React 예제 build와 별도 Expanded 소비자 build. 500kB 초과 chunk 경고는 오류와 구분. | +| 공개 패키지·tarball | 16/16 PASS | 실제 pack(`--ignore-scripts`), 추출 후 Core/React ESM import·NodeNext 선언 소비. export 대상 파일 존재. | +| 핵심 브라우저 | 59/59 PASS | 실제 Chrome, 대표 Fixed/Auto/HTML/React 경로. 44개 성공 결과는 초기 실행에서 재사용하고 실패한 검사 조건·미완료 항목만 후속 확인. | +| 추가 CSS | 9/9 PASS | 7개 회사 title color, 3방향 Border 면, HM Theme/Fixed 격리, 입력 변수·Auto CSS override, placeholder error 복귀. | +| 실제 폰트 | 1/1 PASS | theme-free Classic HTML의 CDP glyph font: Noto Sans KR custom font. computed family도 4개 React 폭에서 동일. | +| Guard Static | PASS | 최종 diagnostic React bootstrap, PARTIAL, consumer graph. FAIL 0, 원본 RENDER-002 NOT VERIFIED 유지. | +| Guard Runtime 연결 | PASS | source/contract fingerprint 일치, `linked=true`, 4개 폭·핵심 상호작용·실제 build 자산 hash 연결. | +| CSS 포괄 판정 | NOT VERIFIED | STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT NOT VERIFIED / FINAL NOT VERIFIED. 명시한 computed font 4건의 cssEvidence는 PASS지만 위치별 전체 CSS 승인으로 확대하지 않음. | + +[빌드 로그 집계](../../test-results/org-chart-p6/build-results.json), [패키지 검사](../../test-results/org-chart-p6/package-audit.json), [브라우저 원시값](../../test-results/org-chart-p6/browser.json), [CSS 원시값](../../test-results/org-chart-p6/css.json), [glyph font](../../test-results/org-chart-p6/font.json), [최종 Git 해시 비교](../../test-results/org-chart-p6/final-state.json). + +## 기존 PASS 재사용과 현재 HEAD의 관계 + +| 과거 결과 | 재사용 근거 | 현재 HEAD 판정과의 구분 | +|---|---|---| +| P5-1B 879/879 | 보존된 browser.json에서 PASS 879·errors 0 확인 | 이후 P5-2/3/4 제품 변경이 있으므로 현재 HEAD 전체 회귀 879건으로 재인증하지 않음. | +| P5-2/3 96/96 | 보존된 browser.json PASS 96·errors 0 | P5-4의 카드 표시/viewport 변경 영향은 당시 46건 및 이번 대표 검사를 사용. | +| P5-4 46/46 | 보존된 browser.json PASS 46·errors 0 | 기록 HEAD는 `a92913d`이고 검증 대상은 당시 미커밋 소스. 해당 구현은 `064517e`, 현재 `d2b76e7`은 문서만 변경. 최종 전체 소스 manifest 부재 때문에 byte 단위 대응 전체는 NOT VERIFIED. | +| P5-0 preset unit 10/10 | 사용자 제공 결과를 과거 근거로 유지. preset 소스의 마지막 변경 Commit `80583eb` 확인 | 이번 PC에서 원본 해당 unit 로그는 찾지 못함. 전체 unit 반복 없이 현재 7회사 runtime 색상을 별도로 확인. | +| Fixed 이전 PASS | 이전 보고서의 단계별 결과·P5-4 최소 회귀 기록 보존 | 현재 Fixed 대표 실행·React parity·CSS 격리로 보완. 전체 픽셀 비교 없음. | +| 300명 성능 | P5-2/3 보고서 195.6ms 초기 mount, 42.0ms 단일 회사 선택 | 과거 조건부 수치이며 새 PC 성능 측정값이 아님. 동일 성능 benchmark를 반복하지 않음. | + +P5-4 시작 manifest 중 변경 대상 제외 25개를 비교해 23개가 현재 byte hash와 일치했다. 불일치 2개는 `verify-p5-1b.mjs`, `verify-p5-23.mjs`로 Playwright 경로 이식성 변경 대상이다. 이 불일치를 제품 회귀로 단정하지 않았다. P5-1B/2·3/4 runner의 Node syntax 검사와 현재 Playwright module/Chrome 경로 실행은 가능하며, 기존 localhost·baseline dist가 필요한 전체 runner는 재실행하지 않았다. + +[과거 집계와 hash](../../test-results/org-chart-p6/historical-evidence.json), [소스 대응 범위](../../test-results/org-chart-p6/historical-source-mapping.json). 과거 raw 증거는 Git에 포함되지 않는 test-results이며 배포물의 필수 제품 소스와 구분한다. + +## Fixed 대표 회귀 + +- Full Composition의 6개 주요 조직 표시, 하위 셀/팀 추가 표시 정상. 조직 제목 전체 수가 6이라는 뜻은 아니다. +- 대표자 badge 없음, 기존 카드 자산·실측 크기·Workdesk Gradient 유지. Auto를 동시에 mount해도 Fixed 카드 geometry와 Gradient 불변. +- 우측 인원 현황 및 footer 관계사 41명 집계 표시. Fixed canvas Fit/scroll 치수는 browser.json의 `Fixed canvas Fit and scroll dimensions`에 보존. +- 직원 정보 popup의 변환 없는 layer·focus 내부·ESC 원래 카드 복귀 확인. +- 실제 React Fixed 소비자와 Vanilla Fixed의 카드 ID/이름/직위/크기/font/border 동일. +- Fixed 전체 픽셀 비교, 모든 폭/소비자, 이전 Full Composition 전체 suite는 NOT VERIFIED. + +[Fixed 1440 캡처](../../test-results/org-chart-p6/fixed-1440.png). + +## Auto 대표 회귀 + +- Small 20명/3회사, Medium 100명/5회사, Large 300명/7회사. 고유 직원 ID의 누락·중복·카드 겹침 없음. +- 실제 DOM 부모와 데이터 parent ID 일치. Small 최대 2Depth, Medium 3Depth, Large 4Depth를 포함한 대표 재귀 배치 확인. +- 전체 Fit에서 카드가 viewport 안에 포함. 300명에서 실제 pointer Pan, UI Zoom In/Out, 그룹 Enter collapse/Space expand 확인. +- 단일 한맥 회사 선택 시 독립 root 1개, 원본 summary.raw.total은 300 유지. React 회사 해제→단일→전체 복귀 확인. +- 직원 popup, 7회사 그룹 색상, top/left/right endpoint 오차 1px 미만과 연결 방향의 한 면만 회사색 적용 확인. +- HTML 준비 예제의 300명 전환, React의 300명 선택과 고유 ID 렌더링 확인. 대용량 성능 benchmark는 재실행하지 않음. +- React 390/768/1280/1440px 가로 문서 overflow 없음, viewport hidden scrollLeft/Top=0. 문서/viewport의 scrollWidth·clientWidth·scrollHeight·clientHeight는 원시 측정에 보존. +- 임의 데이터 전 조합·모든 접힘 state·다른 브라우저·물리적 터치는 NOT VERIFIED. + +[Auto 300명 Fit](../../test-results/org-chart-p6/auto-300-fit.png), [React 390px](../../test-results/org-chart-p6/react-390.png), [React 1440px](../../test-results/org-chart-p6/react-1440.png). + +## HTML React Core와 CSS + +HTML은 `createOrgChart`와 `JsonOrgChartDataSource`, React Wrapper는 같은 공개 `createOrgChart`를 사용한다. React bundle은 Core를 external로 남기며 별도 조직도 엔진을 포함하지 않는다. 같은 20명 JSON의 HTML/React 카드 identity·표시 내용·geometry·폰트·border·연결 방향을 비교했다. Fixed/Auto 분기·회사 선택·collapse·popup 책임도 공통 Core다. + +Core `/browser`는 실제 `window.HMOrgChart`를 제공하며 준비된 `p1-classic.html`을 file URL에서 렌더링했다. public CSS는 private `@import` 없이 self-contained, 자산은 bundle의 data URL 또는 검증된 font/CSS URL을 사용한다. 실제 소비자에서 깨진 요청·console/page error 0이다. Core tarball은 engine MIT notice도 포함한다. + +OrgChart CSS는 dedicated Shadow DOM에 로드된다. 외부 probe의 색/font가 변경되지 않았고 전역 HM 색상·font probe에도 Auto/Fixed 스타일이 유지됐다. Noto는 호스트 font-face 제공 계약이다. `--hm-orgchart-bg-surface`의 instance-local 상속과 Shadow 안 `.hm-orgchart-expanded`의 name-size override는 적용되고 다른 Fixed instance에는 영향을 주지 않았다. Expanded 내부 선언 변수를 host div에 설정하는 방식은 문서대로 보장하지 않는다. + +CSS에서 선언이 없는 모든 `--hm-orgchart-*` 입력을 Theme 누락으로 처리하지 않았다. design-tokens.css는 optional host inputs를 의도적으로 미선언하고 local 기본값을 사용하며, current gradient·border와 Auto 회사색·배치 변수는 Core setProperty로 정의된다. 유한 회사 후보는 기존 7개 preset 선언과 실측으로 대조했다. [token 목록](../../test-results/org-chart-p6/token-inventory.json), [소스 정의 분류](../../test-results/org-chart-p6/token-source-review.json). 임의 외부 입력과 모든 동적 조합은 NOT VERIFIED다. + +React 소비자에는 공개 HM Button/Checkbox/Label/Select가 실제 렌더링됐다. Core native button/dialog는 기존 Vanilla adapter 계약이다. FilterBar는 검색 요구 없는 fixture 전환·즉시 회사 표시 구성이라 미사용 근거를 유지했다. 임베드 조직도에는 페이지 Navigation을 추가하지 않았다. 전체 접근성 이름·disabled/readOnly 조합·screen reader 인증은 이번 대표 점검을 넘어선다. + +## Guard 결과와 실행기 오류 + +Guard는 정적 분석기이며 브라우저를 자체 실행하지 않는다. 최종 `--result`는 실제 production build에 사용한 React bootstrap(`test-results/org-chart-p6/react-build/main.tsx`)이다. 그 source/contract fingerprint와 실제 production HTML·JS/CSS/font SHA, 4개 폭·named interaction을 연결했다. 원본의 RENDER-002는 지우지 않았으며 결합 결과만 Runtime PASS다. + +[원본 Guard](../../test-results/org-chart-p6/guard.json), [Runtime 입력](../../test-results/org-chart-p6/runtime.json), [결합 결과](../../test-results/org-chart-p6/combined.json). PARTIAL consumer graph 결과는 Core 내부 전체 CSS 검사나 운영 배포 검증이 아니다. 결합 status는 **NOT VERIFIED**, styleVerification은 **STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT NOT VERIFIED / FINAL NOT VERIFIED**다. + +초기 생성 HTML의 `/assets`를 저장소 root로 찾지 못한 Guard 원본을 [보존](../../test-results/org-chart-p6/guard-built-html-unresolved.json)했다. 검사 규칙·기대값·Guard 소스를 변경하지 않고 bootstrap 지정 경로를 교정했다. browser sandbox localhost 차단, Fixed 전체 source 직원 수를 카드 수로 기다린 오류, 전체/주요 조직 구분 및 footer selector 오류, React 자산 서버 mapping 오류도 초기 로그에 보존했다. 이는 신규 제품 결함에 합산하지 않았다. CDP font node 조회 오류는 DOM 문서를 초기화한 별도 font probe에서 해결했으며 CSS 9개 PASS는 반복하지 않았다. + +과거 `verify-p1.mjs`의 197건 중 145 PASS/52 FAIL 및 v0 `verify.mjs`의 알려진 FAIL은 [P2 보고서](implementation-report-p2.md)의 계약 변경 기록으로 보존한다. 이번 PC에는 그 원본 JSON 경로가 없어서 재분류 전체는 NOT VERIFIED다. v0의 사용자 언급 9건과 당시 저장 기록 6건/2 FAIL 차이도 확정하지 않는다. 오래된 기대값을 PASS로 변경하거나 전체 suite를 반복하지 않았다. + +## 배포 전 결함과 후속 조치 + +| 우선순위 | 위치와 재현 | 실제 결과와 기대 결과 | 영향 및 신규 여부 | 권장 조치와 예상 시간 | +|---|---|---|---|---| +| P1 | `design-tokens.css:14`, `org-chart-expanded.css:165`. 기본 Auto 300명 직원 연결선 | computed stroke `rgb(186,199,215)` / 흰 면. 대비 **1.715904:1**, 관계 전달 그래픽의 기대 3:1 미달 | Auto 관계 식별. 기존 standalone stroke를 Auto에도 사용하는 미해결 항목을 현 HEAD에서 새로 확인. P6 코드 변경으로 생긴 결함 아님 | 관계선 전용 의미 token/색상 선택 후 scoped 적용 및 관련 경로 검사. 모든 중립 border/브랜드 일괄 변경 금지. 승인 이후 약 30~60분. | +| P2 | `verify-p2.mjs`, `verify-p3*.mjs`, `verify-p5-1a.mjs` 및 일부 과거 check runner | 이전 `C:/Users/user/...` fallback 또는 고정 Chrome/tool import. 다른 PC에서 기본 실행 실패 가능 | 과거 검증 도구 이식성, shipped runtime에는 영향 없음. 기존. P5 최신 3개 runner는 env override 가능·syntax PASS | 공통 tool resolver 또는 실행 문서/환경변수 안내 정리. 약 15~30분. | +| P2 | pack의 Core/React README `../../docs/org-chart/...` 링크 | docs는 files 목록에 없어 추출 tarball의 로컬 문서 링크가 해결되지 않음 | 독립 패키지 문서 소비. 기존 README/packaging 경계, runtime 무관 | 공개 문서 URL 사용 또는 필요한 사용 문서 포함. 약 10~20분. | + +연결선 기준은 [W3C Non-text Contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html)의 내용 이해에 필요한 그래픽에 대한 3:1 요구를 적용했다. 자동 UI 경계 전체에 이 기준을 확장하지 않았다. [현재 computed 색·계산 근거](../../test-results/org-chart-p6/contrast.json). + +Fixed의 디자인기획·솔루션영업·허브에 대한 [과거 대비 REVIEW](quality-review-p1.md)는 별도 검토 이력이다. 현재 header는 20px/700으로 과거 15px 측정과 달라, 과거 수치만으로 현재 텍스트 FAIL을 확정하지 않았다. 현재 computed/크기/Gradient는 css.json에 기록했으며 텍스트 영역의 최종 pixel 대비 판정은 NOT VERIFIED다. 허브의 13px 흰 텍스트도 새 픽셀 재인증은 하지 않았다. 이전 브랜드 디자인 선택을 임의 변경하지 않는다. + +P0 0건(확인 범위). P1 1건과 P2 2건. 예상 후속 수정은 약 **55~110분**이며 디자인 선택/승인 대기와 운영 환경 점검은 별도다. 전체 Guard CSS Runtime/디자인 의도 증거를 추가하려면 소비 위치별 상태·4개 폭 근거 정리가 더 필요하다(약 45~90분, 수정 시간과 구분). + +## 남은 미검증과 배포 판단 + +배포 전 우선 조치는 Auto 관계선 대비 보완 여부 결정, 현재 consumer의 CSS 의도/입력 범위 확정, 실제 배포 base/font/CSP 경로의 smoke 확인이다. 이번 진단에서 자동 수정은 하지 않았다. + +NOT VERIFIED: fresh `npm ci`/원격 registry 설치, 운영 소비자·임의 base/CSP/origin, 전체 Storybook production build, 전 역사 회귀 및 기존 FAIL 전체 재분류, 모든 임의 데이터/override/collapse 조합, 모든 Fixed 폭·픽셀 완전 일치, 실제 화면 리더·물리 모바일·다른 엔진·SSR/hydration·heap/GC·실제 API. P0 없음은 이러한 범위의 무결성을 인증하지 않는다. + +패키지의 기술적 실행·빌드와 대표 기능은 배포 준비 가능하다. **P1 개선과 위 배포 환경 확인이 남아 최종 배포 준비 판정은 PARTIAL**이다. 현재 운영 main 병합/배포/Push는 실행하지 않았다. + +## 작업 시간 + +- Started At: 2026-10-12 22:07:12 KST +- Completed At: 2026-10-12 22:27:09 KST +- Actual Duration: 19분 57초 +- Estimated Duration: 최초 60~90분 (근거 확인 10~15분 / 실행 40~60분 / 보고 10~15분) +- Variance: 예상 하한보다 40분 3초, 상한보다 70분 3초 짧음 + +기존 PASS 재사용, 준비된 의존성/브라우저 환경, 전체 suite 제외로 최초 예상보다 빠르게 완료했다. diff --git a/docs/org-chart/quality-review-p1.md b/docs/org-chart/quality-review-p1.md new file mode 100644 index 0000000..fd0826b --- /dev/null +++ b/docs/org-chart/quality-review-p1.md @@ -0,0 +1,163 @@ +# HMDesign OrgChart P1 — 품질 보완 및 디자인 검수 + +RESULT: **PARTIAL** + +이번 변경은 기존 P1의 Gradient 대비 검사, Department Colors, 실제 로고 표시, Guard 원인 분석으로 제한했다. 사용자가 최종 확인한 대로 Workdesk 전체 디자인 재현 첨부는 참고 자료로만 사용했다. 전체 배치·카드 구조를 다시 개발하지 않았다. 기존 기능·데이터 계약·공개 API·React Wrapper·공통 Theme/Token·운영 main은 보존했다. + +## 작업 정보 + +- 작업 폴더: `D:\디자인시스템\HMDesign-OrgChart` +- 브랜치: `feature/orgchart-package` +- 전/후 HEAD: `d18e3c33801e165e17b6d971e2f6198eed483c3c` +- 실제 병렬 수행: Track A/B/C 읽기 전용 분석을 병렬 수행했다. 이후 B는 대비 모듈과 Department Colors, C는 두 Summary Story의 가상 데이터 안내, 메인은 로고 렌더링·CSS·검증·보고서를 담당했다. 같은 파일의 동시 수정은 없었다. +- Started At: 2026-10-12 11:55:44 KST +- Completed At: 2026-10-12 12:25:20 KST +- Actual Duration: 29분 36초 +- Variance: 최초 예상 하한 45분 대비 15분 24초 단축, 상한 90분 대비 60분 24초 단축. 병렬 분석 및 기존 PASS 재사용으로 재검증 범위를 줄였다. +- Estimated Duration: 45–90분. 필요한 범위만 검사하며 시간을 채우기 위한 재검증은 하지 않았다. + +## Track A — Guard: 원인 분석 PASS, 전체 해결 PARTIAL + +공통 Guard를 변경하거나 검사 규칙을 비활성화하지 않았다. 실제 `themeAnalysis`에 인메모리 fixture를 전달한 최소 재현 8/8이 통과했다. 이는 **오탐 재현 PASS이며 실제 Guard PASS가 아니다**. + +최종 PARTIAL/STORYBOOK Guard 결과는 원본 `status=FAIL`, `staticStatus=FAIL`로 보존했다. TOKEN-001 FAIL 8건, TOKEN-002 NOT VERIFIED 6건, SCOPE-001 NOT VERIFIED 3건이다. + +- Shadow CSS 연결 누락 1건: `--hm-orgchart-resolved-bg-surface`는 `design-tokens.css`에 선언되고 공개 CSS로 번들되어 Shadow DOM에 연결된다. Guard graph는 `?url` import와 Shadow link의 연결을 증명하지 못한다. +- `setProperty` 정의 미수집 4건: `current-gradient`, `current-card-background`, `current-border`는 `applyOrganizationStyle`이 정의한다. Guard가 이 DOM 선언을 수집하지 않아 참조를 미선언으로 판정한다. +- 선택적 입력 오분류 3건: 대비 후보의 `--hm-orgchart-contrast-candidate-color` 및 `--hm-orgchart-contrast-candidate-chip-background`는 로컬 fallback을 가진 외부 override 입력이다. 기본값은 각각 `#10243b`, `#ffffff`이며 실제로 정상 적용된다. 공식 Theme 변수의 누락과 동일하게 처리하는 현재 검사 정책은 이 계약을 표현하지 못한다. +- 동적 TOKEN-002 6건: 유한한 조직 preset/legend 문자열 조합을 정적으로 해석하지 못한다. +- CSS SCOPE-001 3건: 카드·콘텐츠·통계 CSS는 실제 Core CSS 번들과 Shadow link에 포함되지만 Storybook graph 범위 밖으로 판정된다. + +브라우저에서 Shadow CSS 로딩, 6개 preset의 computed 값, fallback과 HTML/React 동일 렌더링을 확인했다. 이 증거로 raw FAIL을 덮어쓰지 않았다. 검사한 범위에서 실제 Production 미선언 변수 결함은 발견하지 못했다. + +`styleEvidence`: STATIC **PASS**, RUNTIME **NOT VERIFIED**, DESIGN_INTENT **NOT VERIFIED**, FINAL **NOT VERIFIED**. 이 값은 Guard의 증거 연결 상태이며 별도 브라우저 PASS와 구분한다. 이번에는 결합 `styleVerification`으로 승격하지 않았다. + +승인 필요한 최소 공통 Guard 변경안: 증명 가능한 Shadow CSS 연결, `setProperty` 정의 수집, 유한 후보 해석, 명시적 선택적 입력 계약 지원. Guard 분석기·style inventory/증거 연결·관련 회귀 fixture와 문서에 영향이 있으므로 별도 승인 전에 구현하지 않는다. 전역 CSS 강제 연결, dummy 선언, 변수 namespace 변경, fallback 일괄 면제는 적용하지 않았다. + +근거: `test-results/org-chart-quality/guard-reproduction.json`, `guard-storybook.json`. 분석기 SHA 전/후 동일하며 기존 P1 Guard 원본도 유지했다. + +## Track B — Color Contrast: PARTIAL + +기준은 [WCAG 2.2 Contrast Minimum](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html)의 일반 텍스트 4.5:1, 큰 텍스트 3:1 및 [Non-text Contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html)의 의미 있는 UI/그래픽 3:1이다. 판정에는 반올림 전 값을 사용했다. 로고를 변색하지 않았다. + +### 측정 방법과 범위 + +Core의 제한된 opaque sRGB/세로 Gradient 모델과 실제 Chrome DPR1 PNG를 분리해 기록했다. 모델은 Gradient stop, 전체 구간, DOM Range의 텍스트 line 구간을 구분한다. PNG에서는 텍스트 옆 빈 padding 열의 실제 배경 픽셀과 computed foreground를 사용한다. 안티앨리어싱된 글자 픽셀로 전경색을 추측하지 않았다. 모델과 픽셀 차이가 2 channel 값을 초과하거나 스크롤에 가려지면 NOT VERIFIED다. + +지원하지 않는 alpha·복수 배경·다른 각도·다른 색 공간·합성 효과 등은 임의로 PASS하지 않는다. 이번 PNG 검사의 84개 가려진/일치하지 않은 probe는 NOT VERIFIED로 남겼다. 그중 솔루션영업 두 부서명은 마지막 가지로 스크롤한 별도 캡처에서 추가 검증했다. 숫자는 화면별 반복 측정이며 서로 다른 접근성 요구사항의 개수가 아니다. 전체 WCAG 인증으로 확대하지 않는다. + +### 기존 HEX와 실제 텍스트 대비 + +6개 헤더 Gradient와 흰 부서명 `#ffffff`는 Workdesk `v1.2610.9c5e`, 2026-10-12의 기존 computed 근거를 유지했다. 직원 카드 배경은 자체 기본 `#ffffff`이고 테두리 기본은 해당 조직 시작색이다. 카드 면·테두리·geometry는 **DESIGN_ADAPTATION**, 공식 Workdesk 실측으로 승격하지 않았다. 변수 prefix에 `-card-background`, `-department-color`, `-border`, `-gradient`를 각각 사용한다. + +- 사업관리: `#194d82 → #1d395c`, `--hm-orgchart-business-management-*`. 흰색 stop 최소 8.659:1. Full 실제 부서명 9.357:1 **PASS**. +- 경영기획: `#2164a8 → #254975`, `--hm-orgchart-business-planning-*`. stop 최소 6.082:1. Full 실제 부서명 약 6.654–6.676:1 **PASS**. +- 인재성장: `#0f6ace → #044a94`, `--hm-orgchart-talent-growth-*`. stop 최소 5.286:1. Full 실제 부서명 약 5.879–5.882:1 **PASS**. +- 통합시스템 Div: `#8c67b6 → #5d3494`, `--hm-orgchart-integrated-system-*`. stop 최소 4.439:1이지만 Full 실제 텍스트 구간은 약 5.008–5.197:1 **PASS**. stop 하나만으로 실제 텍스트를 FAIL 처리하지 않았다. +- 디자인기획: `#09a7a2 → #00847f`, `--hm-orgchart-design-planning-*`. stop 최소 2.974:1. Full의 15px bold 부서명 약 3.188–3.263:1 **REVIEW**. Department Colors의 19.5px bold 제목은 약 3.228:1로 큰 텍스트 기준 **PASS**, 일반 크기 비교는 약 3.263:1 **REVIEW**다. 큰 제목의 PASS를 Full에 대입하지 않았다. +- 솔루션영업: `#5a963c → #3f7629`, `--hm-orgchart-solution-sales-*`. stop 최소 3.582:1. 스크롤 후 Full 15px 부서명 약 3.916–3.917:1 **REVIEW**. Department Colors 큰 제목 약 3.922:1 **PASS**, 일반 크기 비교 약 3.922:1 **REVIEW**다. + +위 Full 수치는 1440px 대표 실측이다. Department Colors의 화면 내 MODEL 값은 픽셀 실측과 별도이며 실제 geometry·브라우저 반올림에 따라 차이가 있다. UI에 MODEL임을 명시했다. Default 값만 검사했으며 모든 소비자 override 조합의 대비를 보장하지 않는다. + +기타 확인: + +- 직원 이름 `#10243b / #ffffff`: 15.701:1 **PASS**. +- 직위·상태의 보조 텍스트 `#596575 / #ffffff`: 5.927:1 **PASS**. 기본 콘텐츠 면 `#f4f7fb` 기준 약 5.516:1. +- 직책 배지 및 통계 헤더/숫자 텍스트: 검사한 solid 조합 **PASS**. 원본 값 변경 없음. +- 협업 허브 `#45639f → #809dd7` 위 흰 일반 텍스트: 실제 약 3.321:1 **REVIEW**; stop 최소 약 2.720:1. 허브 색은 Workdesk 실측으로 확인된 브랜드 값이 아니라 자체 디자인 기본값이다. +- 관계 표현 연결선 `#bac7d7`: 실제 Storybook 배경 `#f5f0e5`와 약 1.510:1, 흰 면과 1.716:1 **REVIEW**. 모든 카드 프레임·테이블 divider를 무조건 의미 있는 UI 경계로 판정하지는 않았다. 부모-자식 관계 연결선은 정보 전달 그래픽으로 검토했다. +- 키보드 focus-visible: 실제 2px solid `#45639f`, Storybook 배경 대비 5.222:1 **PASS**. 전체 키보드/스크린리더 검증으로 확대하지 않았다. + +### 적용한 보완과 승인 대기안 + +**실제 Production 브랜드 HEX 변경: 0건.** Department Colors에 `Aa`, 실제 변수/HEX, 카드 배경과 부서명 전경 구분, 일반/큰 텍스트 대비, stop 보수값, MODEL/NOT VERIFIED 및 미적용 후보 표시를 추가했다. resize 시 실제 텍스트 구간을 다시 계산한다. + +중요한 색상·geometry 변경은 승인 없이 적용하지 않았다. 후보는 `UNAPPROVED_NOT_APPLIED`로 표시하며 Full Composition에 반영하지 않았다. + +- 디자인기획 흰 부서명 → `#000000` 후보: 전체 Gradient stop 최소 4.607:1. 텍스트 브랜드 표현 변경에 대한 승인 후 실제 화면 재검증 필요. +- 모든 Gradient의 단순 `#10243b` 전경 후보는 어두운 조직에서 실패한다. Department Colors에 실패 수치도 표시해 보편적 해결안으로 제시하지 않았다. +- 디자인기획/솔루션영업: 흰 label backing `#ffffff` + `#10243b` 후보는 15.701:1. 기존 Gradient를 유지하지만 새 시각 요소이므로 승인 대기. 솔루션영업은 검정/흰색 어느 하나도 전체 Gradient 전 구간을 일반 텍스트 기준으로 통과하지 않는다(검정 stop 최소 3.835:1). +- 큰 bold 텍스트 사용은 별도 대안이며 layout 영향을 확인하고 승인한 뒤 실제 geometry에서 다시 검사해야 한다. +- 허브: 흰 backing + 짙은 텍스트 또는 배경/크기 보완 승인 필요. 후보 수치와 actual 허브 적용은 구분했다. +- 의미 있는 연결선 `#bac7d7 → #7b899d` 후보: 실제 Storybook 배경 3.128:1, 흰 면 3.555:1, `#f4f7fb` 3.308:1. `--hm-orgchart-stroke-default` 보완 범위와 시각 의도 승인 후 적용할 수 있다. 적용하지 않았다. + +근거: `browser.json`, `contrast-pixels.json`, `contrast-last-branch.json`, `boundaries.json` 및 대응 PNG. + +## Track C — Storybook: 변경 표시 범위 PASS + +사용자용 메뉴 **Logo / Department Colors / Position Summary / Affiliation Summary / Full Composition** 5개 유지. 기존 내부 Story 8개 및 테스트 로직 유지. + +- Logo: Figma 정식 Footer에서 확보한 7개 PNG(한맥·삼안·장헌·장헌산업·피티씨·한라·바론)와 ID 매핑 유지. 473×473 원본 SHA와 RGBA 투명성 유지, 수정·재제작·가상 대체 없음. 누락 Asset **0건**. 원본 이미지 비율은 contain으로 보존했다. +- 로고 최소 수정: 이미지의 흰 CSS 배경을 transparent로 변경하고, 로고 정상일 때 fallback 글자가 투명 영역 뒤에 보이지 않도록 숨겼다. 이미지 실패 시 fallback을 다시 표시하며 관계사명 접근성 이름을 유지했다. 기존 실제 broken-image PASS를 재사용하고 이번에는 변경된 handler 표시만 검사했다. +- 한라처럼 가로 wordmark인 원본은 직원 16px mark에서 실제 ink 높이가 약 4px, Summary 28px에서는 약 7px라 작은 크기의 판독성 한계가 남는다. 원본 비율을 임의로 crop/recolor하지 않았다. 크기/여백 변경은 후속 디자인 검토 대상이다. +- Department Colors: 6개 조직명, 실제 HEX/CSS Variable, Gradient/Border, 원본 출처와 자체 디자인 구분, 실제 computed/model 대비·미승인 후보 표시 정상. 390px 줄바꿈 정상. +- Position Summary / Affiliation Summary: 두 Story에 가상 직원 자동 집계·운영 인원 아님 안내를 추가했다. Core selector/표/정책은 변경하지 않았다. 가상 raw total 41, 관계사 0명 세 항목 표시를 유지한다. 실제 인사 집계 정책 승인으로 간주하지 않는다. +- Full Composition: 리더·허브·6개 그룹과 부서/팀·직원 카드·직위 통계·관계사 통계·파견/휴직을 유지했다. Header·GNB·LNB·Dashboard Layout 없음. 390/768에서는 통계 아래 배치, 1280/1440에서는 오른쪽 통계 배치. 조직도 및 표의 내부 스크롤 유지, 변경으로 인한 새 겹침/페이지 overflow 없음. +- Position Summary 최초 390px PNG의 안내 첫 paint가 불완전해 그 화면만 다시 확인했다. DOM·최종 PNG에서 두 줄 전체 문장 정상. 원본 캡처를 보존하고 `position-note-recheck.json`과 `storybook-position-summary-390-final.png`를 최종 근거로 사용한다. +- HTML/React: 4개 폭의 computed 카드·로고 스타일 일치. 이미 촬영한 PNG를 비교한 결과 390/768/1280/1440 모두 픽셀 동일. 별도 React UI를 추가하지 않았다. + +독립 API는 변경하지 않았다. `OrgChartTree`, `PositionSummary`, `AffiliationSummary`, `StatusSummary`의 기존 factory/React Wrapper/P1 독립 사용 PASS를 재사용한다. 두 Summary의 변경 Story 렌더링은 이번 브라우저 검사로 확인했다. 사용법은 기존 [공개 컴포넌트·스타일 API](standalone-components-p1.md)를 유지한다. + +확인된 Workdesk 차이는 유지했다: IS/디자인 하위 팀의 세로 배치, 추가 root 카드·ID/raw metadata·collapse control, 더 큰 전체 높이, 18행 상세 통계, 1440px Story host 제한과 마지막 가지 내부 스크롤, 파견/휴직의 after-tree 배치. 이는 이번 변경으로 생긴 회귀가 아니며 사용자가 예고한 별도 전체 디자인 작업에서 다룬다. 픽셀 완전 일치 PASS가 아니다. + +근거: `test-results/org-chart-quality/visual-review.json`. Workdesk 최신 화면은 조직명/색/배치 참고로만 사용했고 실제 직원 개인정보·운영 인원수를 fixture에 복제하지 않았다. Figma Dev Mode 미확인 속성을 실측이라고 기재하지 않았다. + +## 검증 및 재사용 + +이번에 실행: + +- Core Typecheck: `npm.cmd run typecheck:org-chart` **PASS**. +- 변경 Story/preview source Typecheck: `npm.cmd exec -- tsc -p tsconfig.app.json --noEmit` **PASS**. +- Core Package Build: `npm.cmd run build:org-chart` **PASS**(ESM/IIFE/types/CSS). +- HTML demo prepare 및 React demo Vite Build **PASS**. React Wrapper 구현 변경 없음. +- Storybook production Build **PASS**. 기존 큰 chunk 경고가 있으며 이번 작업에서 배포 구조를 리팩터링하지 않았다. +- 신규 대비 unit **10/10 PASS**: 수학/비반올림 경계/일반·큰 텍스트/Gradient 구간/unsupported NOT VERIFIED. 기존 unit output을 지우지 않는 별도 폴더로 실행. +- 변경 UI browser **62/62 PASS**, console/runtime error 0. 5개 Story 390/1440 및 Full 768/1280, Theme 미연결 HTML/React 4개 폭 확인. +- Position 390 안내만 후속 **PASS**, 가려진 솔루션영업 제목 2곳 후속 대비 **REVIEW**. 정상 데이터/생명주기 검사를 반복하지 않았다. +- Guard 재현 **8/8 PASS**; 최종 관련 Guard 원본 **FAIL**(앞서 원인/범위 기록). +- 실제 텍스트 대비: 검증된 부분 PASS/REVIEW 구분, 가려지거나 모델 불일치한 부분 NOT VERIFIED 유지. +- `git diff --check` **PASS**. 운영 `D:\디자인시스템\HMDesign`은 읽기 전용 확인: `main`, 동일 HEAD, Working Tree clean. Commit/Push/Merge/새 브랜치/공통 코드 수정 없음. + +재사용한 근거: 기존 Core 22/22, HTML 69/69, React 79/79, P1 selector 7/7, P1 browser의 성공 196건 및 console/menu 후속 4/4. 원본 기록은 [P1 구현 보고](implementation-report-p1.md)와 `test-results/org-chart-p1/`에 유지한다. Data Source/validation/집계/JSON 갱신/lifecycle/API/Wrapper/원본 Story 로직이 변경되지 않아 전체 테스트를 다시 실행하지 않았다. 현재 검사는 변경된 스타일·표시·신규 대비 모델·관련 Guard 문제에 한정했다. + +## 변경 파일 — 이번 품질 작업만 + +시작 706파일 SHA snapshot과 비교해 기존 파일 삭제 0건, 수정 7건, 신규 소스/검증 6건과 이 보고서 1건이다. 기존 `package.json`, `package-lock.json` 미커밋 변경, 원본 자산·7개 설계 문서·P1 보고/Story·Spike·공통 코드의 시작값은 보존했다. + +수정: + +1. `packages/org-chart/src/core/affiliation-mark.ts` — fallback span과 정상/오류 표시 연결. +2. `packages/org-chart/src/core/department-colors.ts` — 대비 정보와 미적용 비교 후보. +3. `packages/org-chart/src/styles/employee-card.css` — 로고 투명 배경 및 fallback 숨김. +4. `packages/org-chart/src/styles/org-chart-content.css` — Department Colors 비교 표시만. +5. `packages/org-chart/src/styles/summary-sections.css` — standalone 주석 정정만. +6. `src/org-chart/core-component-preview.tsx` — Department Colors resize 계산/cleanup. +7. `src/org-chart/org-chart-p1.stories.tsx` — 두 독립 Summary의 가상 데이터 안내. + +신규: + +8. `packages/org-chart/src/core/contrast.ts` — 내부 대비 모델(공개 API 추가 없음). +9. `packages/org-chart/src/core/contrast.test.ts` — 신규 모델 10개 검증. +10. `examples/org-chart/verify-guard-p1.mjs` — Guard 최소 오탐 재현. +11. `examples/org-chart/verify-quality-p1.mjs` — 변경 UI 62개 검사/PNG/값 수집. +12. `examples/org-chart/audit-quality-pixels.py` — PNG 읽기 전용 대비 감사. +13. `examples/org-chart/verify-quality-boundaries.mjs` — 실제 연결선/포커스 CSS 확인. +14. `docs/org-chart/quality-review-p1.md` — 이번 결과 기록. + +`test-results/org-chart-quality/`에는 신규 증거, 실행 로그, PNG 및 baseline을 별도 보존했다. Build 산출물은 기존 생성 경로에서 갱신했다. + +## 최종 판정 / 승인 / 후속 + +**PARTIAL**: 로고 표시·Department Colors 설명·가상 안내·변경 렌더링 보완은 PASS다. 실제 대비 부족과 공통 Guard 개선 승인이 남아 전체 P1을 PASS로 처리하지 않는다. 원본 Guard FAIL은 분석상 오탐으로 구분했지만 승인·수정 없이 PASS로 치환하지 않았다. + +승인 대기: 디자인기획/솔루션영업/허브의 텍스트·backing·크기 선택, 의미 있는 연결선 대비 보완, 공통 Guard 제한적 추적 개선. 모두 미적용이다. Workdesk 전체 배치 수정은 사용자가 지정한 별도 Codex 작업으로 남긴다. + +NOT VERIFIED: 전체 Heap/GC 회수, 전체 스크린리더, 미확인 Figma Dev Mode 속성, Workdesk 픽셀 완전 일치, 실제 HR API, 미승인 집계 정책, 무관한 Guard 항목, 지원하지 않거나 가려진 contrast probe, 모든 소비자 override의 접근성. + +후속 P2: 전체 디자인 작업 결과의 확인, 승인된 대비 대안만 적용/부분 재검사, 작은 wordmark 크기 검토, 승인된 집계 정책·실제 API·전체 접근성·메모리 검증. Commit·Push·Merge는 수행하지 않았다. + +## 완료 기록 + +Started At: **2026-10-12 11:55:44 KST**. Completed At: **2026-10-12 12:25:20 KST**(최종 검사 종료 시각). Actual Duration: **29분 36초**. Estimated Duration: **45–90분**. Variance: **하한 대비 −15분 24초 / 상한 대비 −60분 24초**. 보고서 정리와 결과 표시 시간은 별도다. + +최종 보존 근거: `test-results/org-chart-quality/preservation.json` — baseline 706개 중 수정 7개, 유지 699개, 삭제 0개, 신규 7개(보고서 포함), HEAD/main 동일, diff check 0. Preview: `http://127.0.0.1:6027/?path=/story/orgchart--full-composition`. diff --git a/docs/org-chart/react-integration-report-v0.1.md b/docs/org-chart/react-integration-report-v0.1.md new file mode 100644 index 0000000..f6363e4 --- /dev/null +++ b/docs/org-chart/react-integration-report-v0.1.md @@ -0,0 +1,83 @@ +# HMDesign OrgChart v0.1 React Wrapper · Storybook 통합 검증 + +RESULT: **PASS** — 이번 요청의 React 통합 MVP 완료 조건 13개를 충족했습니다. 아래 NOT VERIFIED는 별도 한계로 보존하며 모든 기능·디자인·배포 환경의 검증 완료를 뜻하지 않습니다. + +## 구현 결과 + +1. 작업 경로 `D:\디자인시스템\HMDesign-OrgChart`, 브랜치 `feature/orgchart-package`. 사용자 첨부 요청의 별도 작업/Git 지시를 적용했습니다. 운영 `D:\디자인시스템\HMDesign` 및 `main`은 수정하지 않았습니다. +2. 작업 전/후 HEAD, Local main, 공식 원격 main/Feature 모두 `d18e3c33801e165e17b6d971e2f6198eed483c3c`. 공식 origin은 `https://gitea.hmac.kr/b25027/HMDesign.git`입니다. 시작/완료 시 원격을 읽기 전용으로 확인했습니다. +3. 신규 `packages/org-chart-react`, `@hm-design/org-chart-react@0.1.0`. Core `@hm-design/org-chart@0.1.0` dependency, React `^19.0.0` peer. React DOM은 Wrapper가 사용하지 않아 별도 dependency/peer를 추가하지 않았으며 소비 앱이 제공합니다. 설치 package/lock과 React/DOM `19.3.0`, Storybook `10.6.1`, 엔진 `6.0.0` 일치를 확인했습니다. +4. Runtime Export는 `OrgChart`. Type Export는 `OrgChartProps` 및 Core 공개 타입의 type-only re-export입니다. Props는 공개 `OrgChartOptions`에서 container/onStateChange를 제외하여 재사용합니다. 필수 `dataSource`, `stylesheetUrls`; 선택 `aggregationPolicy`, `className`, `style`, `onStateChange`, `onLifecycleError`. 제목·직원·Zoom 등 임의 Props는 추가하지 않았습니다. +5. 생명주기는 Core 생성 → 구독 → mount, Source identity 교체 → reload, cleanup → 구독 해제·destroy입니다. 최초 추가 Reload가 없고 Callback 변경만으로 재생성하지 않습니다. Core가 변경 setter를 제공하지 않는 Policy identity/CSS URL 값 변경은 정리 후 재생성합니다. 같은 URL 문자열의 새 객체는 재생성하지 않습니다. Source/Policy 내부 mutation은 변경 알림으로 취급하지 않습니다. +6. committed Callback, 소유권, Source 갱신을 순서대로 layout effect에 연결했습니다. commit과 passive cleanup 사이의 오래된 요청 Callback 가능 구간을 닫습니다. generation/operation 검사는 이전 인스턴스 Promise의 늦은 rejection을 차단하며 Core revision/AbortSignal이 오래된 데이터 응답을 차단합니다. Wrapper 안에 DOM 엔진·Validation·Summary·노드 UI·엔진 정리 로직을 복제하지 않았습니다. +7. ESM과 선언을 자체 최소 build.mjs로 생성하며 React, JSX runtime, Core를 external로 유지합니다. 산출물의 client boundary를 보존했습니다. 기존 공통 build 스크립트는 수정하지 않았습니다. 최종 Wrapper ESM 약 3.09 kB/gzip 0.92 kB입니다. +8. 기존 HM Theme/폰트를 호스트에서 제공하고 공개 engine.css/style.css의 실제 URL을 Shadow DOM에 주입합니다. Storybook의 기존 preview CSS/decorator를 그대로 사용하며 별도 소비자는 공개 Theme entry를 사용합니다. Production의 해시 Asset URL과 폰트 로드를 실제 확인했습니다. 새 Theme·브랜드 팔레트나 reset을 만들지 않았습니다. +9. `src/org-chart/org-chart.stories.tsx`, 분류 `OrgChart/Content`. 기존 glob에 포함되므로 `.storybook/main.ts`/preview 변경이 없습니다. 기존 Component/Pattern Story를 바꾸지 않았습니다. 모든 Story는 공개 Wrapper와 Strict Mode를 사용합니다. +10. 필수 Story 8개: Basic Organization, Organization Hierarchy, Employee Cards, Organization Summary, JSON Data Update, Loading / Empty / Error, Responsive Container, Multiple Instances. 기존 HTML v1/v2 JSON을 직접 import하며 추가 시나리오는 파생 가상 데이터 및 같은 Source Interface만 사용합니다. 주변 조작은 공개 HM Button을 실제 렌더링합니다. +11. Navigation·Header·Dashboard·CSV·추가 업무 메뉴·API는 없습니다. FilterBar/DataTable은 검색·필터·정렬·선택 요구가 없어 사용하지 않습니다. 노드/상태/집계는 Framework-independent Core의 승인된 책임이며 React HM 위젯으로 복제하지 않습니다. +12. 실제 empty 화면을 구현한 것으로 표시하지 않습니다. 직원 0명은 Ready, 조직 0개는 Error입니다. Zoom/Pan은 비활성 상태로 유지합니다. hubText/showDispatched/showOnLeave 등 기존 reserved 영역, 복잡한 relation overlay도 추가 구현하지 않았습니다. + +## 최종 통합 검증 + +- **PASS** Wrapper Typecheck: `npm run typecheck:org-chart-react`. 최종 생명주기 수정 후 다시 확인했습니다. +- **PASS** 루트 앱·Storybook TypeScript graph: `tsc -p tsconfig.app.json --noEmit`. 별도 React 소비자도 strict Bundler 타입 검사를 통과했습니다. +- **PASS** Wrapper Package Build: ESM/선언/client boundary/external 확인. Storybook prebuild의 기존 workspace 빌드도 Core/UI/Center Graphic을 포함해 성공했습니다. +- **PASS** 실제 npm tarball Public Export 및 선언: 추출된 Core/Wrapper에서 DOM 없는 ESM import, strict NodeNext consumer, 필수 Source와 미지원 Zoom/container 타입 경계 확인. React/compiler는 현재 검증 환경에서 제공합니다. 새 PC의 fresh dependency install 결과로 해석하지 않습니다. +- **PASS** Storybook Production Build: 최종 Wrapper/Story/CSS 상태의 `storybook build` 성공. 큰 기존 chunk 및 plugin timing 경고가 있으나 build 실패는 없습니다. +- **PASS** Storybook 실제 브라우저: Production의 8개 Story와 개발 6025의 실제 Wrapper 노드 실행. JSON v2 갱신, Unmount/Mount, Source 오류·복구, 직원/조직 0개, 취소를 무시하는 늦은 응답, A 갱신/제거 시 B identity·데이터·Callback 유지 확인. +- **PASS** 독립 React Consumer: `examples/org-chart-react`의 공개 package import·Vite Production Build·실제 Chrome 실행, 초기 mount/Source 교체/최신 Callback/Unmount. Storybook 성공을 소비자 성공으로 대신하지 않았습니다. +- **PASS** 개발 React Strict Mode: setup → cleanup → setup에서 두 번의 Source load와 최종 Ready 1회, 활성 Core host 1개. Production은 replay 없이 초기 load/Ready 각 1회임을 별도로 확인했습니다. 두 Source load는 Strict Mode의 의도된 setup 반복이며 Wrapper의 초기 이중 Reload가 아닙니다. +- **PASS** 생명주기: Callback/className 변경 시 같은 Core·추가 load 없음; Source 교체 시 같은 Core reload; Policy/CSS URL 변경 시 새 Core; 같은 CSS URL 값은 유지. 실패한 구성의 lifecycle callback, 정상 오류→Ready 복구, 늦은 응답·Unmount 후 Callback 차단, 5회 반복 mount/unmount의 DOM/관찰자 기준 유지, 최종 Core DOM/관찰자 0 확인. +- **PASS** HTML/React 공통 데이터: v1 조직 3개·직원 4명·직접 Root/A/B 1/2/1, v2 조직 3개·직원 5명·직접 1/1/3. DOM의 organization ID/parentId·employee ID·노드 텍스트·Heading·Summary를 비교하여 일치했습니다. +- **PASS** Theme CSS: HTML/React의 전경·surface·radius·폰트 computed 일치, 기존 Theme 변수 상속, 두 CSS link의 실제 stylesheet 로드, scoped Theme 변경 시 다른 인스턴스 유지, Engine selector의 light DOM 오염 없음. +- **PASS** 반응형: 실제 390/768/1280/1440px에서 소비자 및 Basic/Employee/Responsive Story의 document overflow·노드 겹침·텍스트 폭·null 사진 fallback 확인. scrollWidth/clientWidth 및 scrollHeight/clientHeight 측정을 저장했습니다. 390px은 원래 노드 크기와 내부 가로 스크롤을 유지합니다. 실제 scrollLeft 이동도 확인했습니다. 소비자 Grid item min-width와 Story Grid track을 수정하여 문서 overflow를 제거했습니다. +- **PASS** 키보드: Enter/Space 접기·펼치기 및 데이터 교체로 기존 focus가 제거될 때 안정된 Core viewport로 복귀. 접기 애니메이션이 끝나기 전 재입력은 기존 Core가 거부하므로 검사도 실제 transition 완료를 기다립니다. +- **PASS** 기존 Vanilla: 기존 HTTP HTML의 v1→v2 비교 및 `file://` classic의 Ready 4명→Update 5명 smoke. 변경 없는 기존 22개 데이터 테스트/69개 전체 브라우저 검사는 반복하지 않았습니다. +- **PASS** Chrome `155.0.8059.39`: 통합 검사 **70/70**, 추가 경계 검사 **9/9**, console/pageerror **0**. 대표 390px 긴 텍스트·Responsive 및 1440px 소비자 캡처도 육안 확인했습니다. +- **PASS** 보존: 시작 파일 **614/614 SHA-256 일치**, 기존 Core/HTML/7개 설계 문서/이전 구현 보고/UI/Center Graphic/Theme/Story/공유 build 설정 보존. `git diff --check` 통과. 운영 main Working Tree는 시작·완료 시 깨끗합니다. Staged diff는 비어 있으며 Stage/Commit/Push/Merge가 없습니다. + +## Guard와 미검증 + +- Storybook과 독립 소비자에 범위별 PARTIAL Guard를 실행했습니다. 최종 원본 status/staticStatus는 **NOT VERIFIED**, FAIL 0입니다. Storybook의 동적 width, 소비자의 동적 className 및 실제 브라우저 증명 요구를 그대로 유지합니다. +- 원본 CSS styleEvidence는 **STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT NOT VERIFIED / FINAL NOT VERIFIED**입니다. 별도 브라우저 computed·컨테이너 검사 PASS는 기록한 요소/상태/폭의 증거이며 Guard 원본이나 디자인 의도 승인을 덮어쓰지 않습니다. Guard 결합 판정은 수행하지 않았습니다. +- 최초 소비자 Guard의 target 상대경로 입력이 맞지 않아 checkedFiles=[] 진단이 발생했습니다. 원본 `guard-consumer-initial.json`을 보존하고 상대경로/entry를 교정한 실제 graph 검사를 별도로 저장했습니다. Story CSS 수정 후 CSS-only PARTIAL은 NOT APPLICABLE이어서 최종 Story graph 검사를 별도로 저장했습니다. 빈 검사 결과를 PASS로 계산하지 않았습니다. +- **NOT VERIFIED** 완전한 Heap/GC 회수, 전체 스크린리더·SSR/hydration, 모든 브라우저/모바일 touch, 매우 큰 데이터 성능·깊이, Workdesk 원본과의 시각적 일치, Theme 미연결 스타일, 실제 API/auth, fresh npm registry 설치 및 임의 배포 base/CSP/origin입니다. +- Scope 한계: 공개 Props의 arbitrary className/style 및 모든 외부 데이터·Theme 조합은 전수 검사하지 않았습니다. readOnly/disabled 입력 필드는 없는 콘텐츠 패키지이며 전체 업무 화면 접근성 검증으로 해석하지 않습니다. +- 기술적 리스크: 기존 Core의 엔진 6.0.0 내부 init observer hook 의존은 유지됩니다. 엔진 upgrade 시 정리 계약 재검토가 필요합니다. Policy identity 변경은 재생성되므로 immutable/memoized Props를 권장합니다. Core 갱신 시 expansion/scroll state 보존은 추가하지 않았습니다. +- 도구 이슈: 로컬 npm symlink EPERM 및 sandbox Chrome loopback 차단은 허용된 실행 권한으로 재시도해 해결했습니다. 최초 sandbox Storybook의 사용자 설정/registry 쓰기 경고도 접근 가능한 개발 서버 재실행 후 사라졌습니다. 운영 6024 서버를 중단하지 않았습니다. + +## 파일과 증거 + +이번 변경(기존 작업 위에 추가): + +- 수정 `package.json`: 기존 OrgChart scripts 유지, Wrapper build/typecheck·소비자 build/dev/browser scripts, Core/Wrapper root dependency 연결. +- 수정 `package-lock.json`: root Core/Wrapper dependency, Wrapper workspace link·manifest. 해당 추가를 제거하고 원래 CRLF로 재직렬화한 SHA가 시작 lockfile SHA와 일치하므로 기존 dependency graph/버전은 보존했습니다. npm 재직렬화로 line ending은 LF입니다. +- 신규 `packages/org-chart-react/{package.json,build.mjs,tsconfig.json,tsconfig.build.json,README.md,src/OrgChart.tsx,src/types.ts,src/index.ts}`. +- 신규 `src/org-chart/{org-chart.stories.tsx,org-chart-stories.css}`. +- 신규 `examples/org-chart-react/{index.html,index.css,app.tsx,vite.config.ts,README.md,verify.mjs,verify-package.mjs,verify-boundaries.mjs}`. +- 신규 이 보고서. 시작 시 기존 package/lock의 Core 관련 diff와 untracked docs/examples/Core는 이번 신규 변경과 구별하여 보존했습니다. + +검증 생성물(Git ignored): + +- `test-results/org-chart-react/report.json`: 70개 검사, HTML/React snapshots, four-width 측정과 PNG. +- `test-results/org-chart-react/boundaries.json`: 추가 9개 경계 검사. +- `test-results/org-chart-react/tarball-consumer/report.json`: public archive/declaration 검사. +- `test-results/org-chart-react/preservation.json`, `baseline.json`, `baseline-lock-reconstructed.json`: 시작/완료 보존 근거. +- `test-results/org-chart-react/guard-storybook-final.json`, `guard-consumer.json`: 최종 원본 Guard. 초기/부분 진단도 별도 보존. +- `test-results/org-chart-react/storybook-build.log`: 최종 Production Build 로그. + +확인용 개발 URL: + +- Storybook `http://127.0.0.1:6025/?path=/story/orgchart-content--basic-organization`. +- 독립 React 소비자 `http://127.0.0.1:6026/`. +- 기존 운영 Storybook `6024`, 원래 PID `19932` 유지. + +## 시간 기록 (Asia/Seoul) + +- Started At: 2026-10-12 10:24:24 KST +- Completed At: 2026-10-12 10:42:39 KST +- Actual Duration: 18분 15초 +- Estimated Duration: 최초 2~3시간 +- Variance: 예상 하한 2시간 대비 101분 45초 단축; 예상 상한 3시간 대비 161분 45초 단축 + +예상 시간은 참고값입니다. 기존 Core/설치 환경과 병렬 구현을 재사용해 실제 시간이 줄었으며 미검증을 PASS로 대체하지 않았습니다. Commit·Push·Merge는 사용자 요청대로 진행하지 않았습니다. diff --git a/docs/org-chart/release-readiness-p10-7.md b/docs/org-chart/release-readiness-p10-7.md new file mode 100644 index 0000000..e3c87e2 --- /dev/null +++ b/docs/org-chart/release-readiness-p10-7.md @@ -0,0 +1,188 @@ +# HMDesign OrgChart P10.7 — 정식 통합 승인 준비 + +문서 정리 대상: 2026-10-13. **main 병합 준비 상태: PENDING — READY 아님.** P11.2 사용자 결정으로 Storybook 4개 화면과 Auto Chart Only React/Vanilla의 기본 디자인은 **APPROVED**다. P11.1 실제 Guard는 정적 PASS / FAIL 0이며 Runtime은 NOT VERIFIED다. 보안·접근성 위험 수용·main 병합·운영 배포 승인은 **PENDING**이다. 이 문서는 병합/배포 실행 승인이 아니다. + +현재 디자인 기준은 [P11.2 최종 디자인 승인 기록](design-approval.md)을 따른다. 아래 P10.7 Git·검증 snapshot과 과거 Guard 실패 근거는 당시 기록으로 보존하며, 현재 상태와 구분한다. + +## 1. 범위와 Git 기준 + +| 항목 | 확인 결과 | +|---|---| +| 작업 경로 | `D:\디자인시스템\HMDesign-OrgChart-Recovered` | +| 브랜치 | `feature/orgchart-package` | +| 공식 origin | `https://gitea.hmac.kr/b25027/HMDesign.git` | +| 작업 전·후 Local HEAD | `d56d587fd20316f19554105f33c200060394962b` | +| 원격 Feature | `d56d587fd20316f19554105f33c200060394962b` | +| 원격 main | `c45d63a03b84eaac43f01bd400a16488a80e4229` | +| 원격 확인 방법 | P10.7에서 `git ls-remote origin refs/heads/feature/orgchart-package refs/heads/main` 읽기 전용 조회 성공 | +| 작성자 설정 | `b25027` / `shkim4@samaneng.com` | +| 시작 Working Tree | 깨끗함 | +| 완료 Working Tree | 사용 가이드 수정 1개 + 이 문서 신규 1개, unstaged | + +이 작업의 명시적 경로·Commit/Push 보류 요청을 적용했다. 보존용 `HMDesign-OrgChart`는 증거를 읽기만 했으며 수정하지 않았다. 제품 코드·Storybook·Theme·Guard·package.json·lockfile·Docker 및 AGENTS.md/CLAUDE.md/AI 생성 정책을 변경하지 않는다. Stage·Commit·Push·main Checkout·Merge·PR·Workflow 실행·서버 접속·Registry Publish 미진행. + +## 2. 개발 완료와 재사용 근거 + +현재 공개 API와 기준 SHA의 소스를 대조했다. [사용 가이드](org-chart-usage-guide.md), [최종 통합 보고서](final-integration-2026-10-13.md), [Chart Only 구현 보고서](implementation-report-chart-only.md), [최소 설치 보고서](minimal-install-report-2026-10-13.md)가 기능 근거다. + +| 대상 | 기존 검증 상태 | P10.7 해석 | +|---|---|---| +| Fixed Full Composition / Expanded | 지원, 기존 기능 검사 PASS | Fixed Chart Only 지원을 뜻하지 않음 | +| Core / React / Vanilla 독립 사용 | 기존 설치·타입·빌드·실행 PASS | HM UI는 `/hm-dialog` 선택 시에만 연결 | +| Auto/Expanded Chart Only | 최종 통합 React/Vanilla 51/51 PASS, 콘솔 오류 0 | 초기 Fit·Bounds 가로/세로 중앙·직접 휠 Zoom·Pan·팝업·접기 유지 | +| 기존 Expanded 기본 표시 | 기존 제목·요약·툴바·modifier 휠 유지 확인 | Chart Only는 명시적 선택 옵션 | +| Core 단위 테스트 | 86/86 PASS | 기존 결과, 재실행 안 함 | +| Guard 회귀 테스트 | 94/94 PASS | 분석기 자체 검사이며 실제 Guard FAIL과 별개 | +| AI 생성 정책 | 생성 방식 사전 질문 정책 통합 | 기존 정책 문서 수정 없음 | + +Chart Only 공개 값은 `presentationMode: 'default' | 'chart-only'`, `initialView: 'center' | 'fit'`, `wheelZoom: 'modifier' | 'direct'`다. Core 옵션을 React Props가 상속한다. Chart Only의 기본은 fit/direct이며 외부 제목·요약·툴바·설명을 생성하지 않는다. Fixed Full Composition에서 Chart Only를 함께 지정하면 TypeError로 거부한다. Fixed Chart Only는 **미지원**이지 승인 대기 구현이 아니다. + +**P10.6 재사용:** [HMDesign OrgChart P10.6 점검](codex://threads/01a1350c-f2eb-7ca0-9230-abda73022abc) 채팅의 2026-10-13 최종 보고를 확인했다. 관련 파일 143개가 보존본과 줄바꿈을 제외하고 동일하다는 기존 확인, 정적 main 비교, 브랜치 정책 조회, 캡처 검토 결과를 재사용한다. P10.6은 파일 변경 0개였고 별도 tracked 보고서가 없다. 이번 원격 SHA가 같으므로 전체 비교·제품 테스트·빌드·Guard·npm audit·브라우저 검사를 반복하지 않았다. + +복구 Clone에는 ignored 캡처/JSON이 없다. 아래 기존 증거는 **보존 폴더의 로컬 자료**이며 다른 PC/새 Clone에는 포함되지 않는다. 파일 존재와 기록은 확인했지만 P10.7에서 캡처를 새로 생성하거나 화면을 다시 승인하지 않았다. 과거 보고서와 원본 결과는 수정하지 않는다. + +## 3. Guard 현재 상태와 P10.7 잔여 FAIL 5건의 과거 예외 요청 + +**현재 상태(P11/P11.1): 실제 Guard `status PASS`, `staticStatus PASS`, issues 0 / FAIL 0, `runtimeStatus NOT VERIFIED`.** [P11 구현·검증](implementation-report-p11.md)과 [P11.1 정합성 검증](consistency-review-p11-1.md)의 실제 검사 결과를 따른다. CSS phases는 STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT PASS / FINAL NOT VERIFIED이며 결합 styleVerification은 미생성이다. 정적 PASS를 Runtime 또는 제품 전체 회귀 PASS로 확대하지 않는다. P11.2에서는 기존 결과만 확인하고 Guard를 재실행하지 않았다. + +아래는 **P10.7 당시 snapshot 및 예외 요청 이력**이다. P11의 공개 CSS 연결 분석 개선으로 아래 5건은 해결되어 현재 잔여 FAIL이나 진행 중인 예외 승인 요청이 아니다. 당시 실패·제안·승인 대기 기록은 보존하며 후속 기술적 해결을 당시 위험 수용 승인으로 바꾸지 않는다. + +P10.7 당시 기록: **TOKEN-001 FAIL 5 / NOT VERIFIED 9**. F3–F7 해결로 기존 FAIL 10→5, 새 FAIL 0이었다. 기존 [P10 보고서](implementation-report-p10.md), [P10.3 GuardReview](guard-review-p10-3.md)의 FAIL 10건 기록을 보존한다. + +원본 로컬 결과: `D:\디자인시스템\HMDesign-OrgChart\_local\final-integration\guard-final.json`. P10.7에서 읽은 원본은 `status/staticStatus = FAIL`, `runtimeStatus = NOT VERIFIED`이며 수정하지 않았다. `styleEvidence`는 **STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT PASS / FINAL NOT VERIFIED**, 결합 `styleVerification`은 미생성이다. 디자인 의도 상태나 회귀 94/94로 원본 Guard를 PASS로 바꾸지 않는다. + +공용 분석기는 Shadow DOM에 주입되는 공개 OrgChart CSS와 런타임 `style.setProperty` 정의를 충분히 추적하지 못한다. 현재 선언을 소스에서 다시 대조했다. + +| 기존 ID / 검사 | 실제 토큰명 | 사용 위치 | 실제 선언·연결 근거 | 기술적 영향 / 예외 사유 | 책임자·승인자 / 승인일 / 결과 | +|---|---|---|---|---|---| +| F1 / TOKEN-001 | `--hm-orgchart-current-gradient` | `department-colors.ts:47` | 같은 preview row에 `applyOrganizationStyle(row,index)` 호출(`:28`), `style-presets.ts:71` setter → swatch 자손 상속 | Department Colors 미리보기 참조. 실제 선언은 있으나 Guard 정의 목록 밖 | PENDING / PENDING / PENDING | +| F2 / TOKEN-001 | `--hm-orgchart-current-card-background` | `department-colors.ts:47` | 위 row 경로, `style-presets.ts:63–64` setter | F1과 같은 상속 경로, 미선언 토큰을 새로 허용하는 요청 아님 | PENDING / PENDING / PENDING | +| F8 / TOKEN-001 | `--hm-orgchart-resolved-bg-surface` | `style-presets.ts:9` | `src/styles/design-tokens.css:8` 선언 → `build.mjs`의 CSS inline → 공개 `@hm-design/org-chart/style.css` → Core Shadow DOM link | 실제 CSS load가 필요한 경로. 연결이 없는 소비자까지 정상으로 간주하지 않음 | PENDING / PENDING / PENDING | +| F9 / TOKEN-001 | `--hm-orgchart-current-border` | `style-presets.ts:74` | 같은 함수·element의 `:69–70` setter 뒤 참조 | Fixed/공통 스타일의 alias. 분석기 setter 수집 한계 | PENDING / PENDING / PENDING | +| F10 / TOKEN-001 | `--hm-orgchart-current-gradient` | `style-presets.ts:75` | 같은 함수·element의 `:71–72` setter 뒤 참조 | F9와 동일. 선언과 사용 범위가 일치 | PENDING / PENDING / PENDING | + +위 파일의 기준 경로는 `packages/org-chart/src/core/`이며 CSS는 `packages/org-chart/src/styles/`다. [style-presets 소스](../../packages/org-chart/src/core/style-presets.ts), [Department Colors 소스](../../packages/org-chart/src/core/department-colors.ts), [CSS 선언](../../packages/org-chart/src/styles/design-tokens.css), [공개 CSS 생성](../../packages/org-chart/build.mjs), [Shadow DOM 연결](../../packages/org-chart/src/core/index.ts)을 근거로 한다. + +**권장 결정:** 위 SHA·5개 위치·실제 연결/선언이 확인된 경로에 한정해 위험 예외 수용을 요청하고 공용 분석기 개선을 별도 과제로 분리한다. 새 파일·새 토큰·외부 입력·다른 element·미연결 CSS에는 예외를 확대하지 않는다. 새 Commit에서 이 경로가 바뀌면 근거를 다시 검토한다. 예외를 문서화하거나 승인하더라도 원본 FAIL은 남는다. + +조직 정책상 Guard PASS 필수 여부와 예외 수용 가능 여부는 **NOT VERIFIED**다. 정책이 필수라면 권한 있는 승인자의 예외 또는 후속 개선이 병합 전 필요하다. 제품 실행을 막는 결함이 확인되지 않았다는 기존 분석을 배포 허가로 해석하지 않는다. + +후속 개선: OrgChart 공개 CSS/`?url` 연결과 제한적인 literal setter 추적. 무조건 전역 정의로 수집하면 다른 요소·조건부 setter·미연결 CSS의 실제 미선언 참조까지 통과시킬 수 있다. 분석기 변경 범위와 회귀 기준을 별도 승인하고 기존 차단 판정을 보존해야 한다. 이번 검사기 수정·재실행·결과 변조 없음. + +## 4. npm 보안 위험 수용 요청 + +**과거 감사 snapshot:** [2026-10-10 퍼블리셔 안정화 보고서](../guidelines/publisher-stabilization-2026-10-10.md)의 전체 감사 High 7 / 고유 Advisory 1 / 운영(`--omit=dev`) 감사 0. `GHSA-vfj7-8cjw-p6xm`이며 분석 당시 수정 패치 미공개였다. 현재 전체 패키지의 신규 audit 결과가 아니다. P10.6에서 해당 경로의 버전 동일성을 확인했지만 이후 OrgChart 연동 때문에 lockfile 전체는 과거 감사본과 다르다. 따라서 운영 0을 현재 그래프의 보안 보증으로 사용하지 않는다. + +공식 [Advisory](https://github.com/advisories/GHSA-vfj7-8cjw-p6xm)는 deeply nested brace pattern으로 Node stack을 소진하는 DoS를 설명한다. P10.7에서는 링크·권고 내용을 읽기만 했고 npm audit나 의존성 업데이트를 하지 않았다. + +| 패키지 | 과거 설치 버전 | 개발 의존 경로 | +|---|---|---| +| shadcn | 4.21.1 | UI workspace 직접 devDependency | +| @shadcn/registry | 0.1.0 | shadcn → registry | +| ts-morph | 26.0.0 | shadcn 또는 registry → ts-morph | +| @ts-morph/common | 0.27.0 | ts-morph → common | +| fast-glob | 3.3.3 | shadcn/registry 또는 common → fast-glob | +| micromatch | 4.0.8 | fast-glob → micromatch | +| braces | 3.0.3 | micromatch → braces, 근본 취약 패키지 | + +7건은 상위 경로 전파를 포함하며 독립 취약점 7개가 아니다. 개발 도구 전용 경로라는 기존 분석을 유지하되, 개발/CI 중 공격자가 입력을 제어할 가능성은 별도로 관리한다. + +| 수용 조건·결정 항목 | 근거 / 처리 방침 | 책임자·승인자 / 승인일 / 결과 | +|---|---|---| +| 고정 Glob 사용 | 기존 Guard STORYBOOK discovery는 로컬 설정의 literal glob을 fast-glob에 전달. 신뢰하는 저장소 설정만 사용 | PENDING / PENDING / PENDING | +| 공격자 입력 경로 | 비신뢰 Storybook config·registry·중첩 pattern을 CLI/Guard에 전달하면 프로세스 종료 위험. 개발 도구 외부 노출 금지, 비신뢰 입력 경로 차단 | PENDING / PENDING / PENDING | +| 소비 앱 영향 | 기존 공개 UI pack은 CLI 개발 의존 그래프를 배포하지 않는다는 분석. 현재 모든 소비 경로의 무위험을 보장하지 않음 | PENDING / PENDING / PENDING | +| CI 필수 감사 | P10.6 main Workflow에 Guard/audit 차단 단계 없음. 조직 정책·현재 감사 요구 수준은 NOT VERIFIED. 보안 담당자가 신규 audit 필요 여부와 gate/예외 조건 결정 | PENDING / PENDING / PENDING | +| 패치 후 업데이트 | 패치 공개 시 영향 범위·호환성 확인 → 별도 package/lockfile 변경 → 관련 설치·타입·빌드·audit 검증 | PENDING / PENDING / PENDING | +| 잔여 위험 수용 | 개발 전용·고정 입력 조건, 예외 적용 환경·재검토 시점·해제 조건을 보안 담당자/사용자가 확정 | PENDING / PENDING / PENDING | + +패치 없음은 위험 없음이 아니다. 신규 감사와 최신 전체 그래프 평가는 **NOT VERIFIED**. `npm audit fix --force`, override/major 역행, package.json/lockfile 변경 없음. + +## 5. 화면별 최종 디자인 승인 체크리스트 + +기능 PASS와 사용자 디자인 승인을 분리한다. 아래 기능 검증은 기존 보고서와 P10.6 검토를 재사용하며 P11.2에서 브라우저 재검사는 없다. P10.7 당시 5개 화면의 디자인 승인·승인자·승인일은 모두 PENDING이었다. **현재는 [P11.2 사용자 디자인 승인](design-approval.md)에 따라 5개 화면 APPROVED / 사용자 디자인 승인 / 2026-10-13**이며 기준 Commit은 `346c648c093ae3b0dedc1fc754d8ffa52f00ddd0`이다. 실제 담당자의 법적 이름·직책은 확인되지 않았다. 기존 미검증 항목은 유지한다. + +기존 통합 캡처 기준 폴더: `D:\디자인시스템\HMDesign-OrgChart\test-results\org-chart-chart-only\`. 아래 링크는 현재 PC의 보존 자료이며 Git 배포 문서의 필수 실행 파일이 아니다. + +| 화면 | 사용자 검토·승인 항목 | 실제 기능 검증·근거 / 캡처 | 미해결 문제 | 디자인 승인 / 승인자 / 승인일 | +|---|---|---|---|---| +| A. Overview | 관계사 로고 7개, 중복 제목 제거, 인원 현황, 기존 페이지 스타일 | 기존 P9/P10·P10.6 확인 범위 PASS. [캡처](D:/디자인시스템/HMDesign-OrgChart/test-results/org-chart-chart-only/storybook/orgchart--logo-personnel-summary.png) | 전체 폭 수동 시각 검토는 NOT VERIFIED | APPROVED / 사용자 디자인 승인 / 2026-10-13 | +| B. Department Colors | 대표자 카드, 기존 조직 Gradient **6종**, 파견·휴직 미리보기, `홍 길 동`, 카드 크기·Typography | 기존 P10·최종 통합·P10.6 확인 범위 PASS. [캡처](D:/디자인시스템/HMDesign-OrgChart/test-results/org-chart-chart-only/storybook/orgchart--department-colors.png) | Guard F1/F2는 P11에서 해결. 6개 조직 Gradient와 Expanded의 7회사 primary 팔레트를 구분 | APPROVED / 사용자 디자인 승인 / 2026-10-13 | +| C. Full Composition | 대표자 아래 화살촉, 협업 화살표 5개, 상단·중앙·하단 반응형 배치, 파견·휴직 간격, 관계사별 인원, Core 팝업 | [P9.5 Full](implementation-report-p9-5-full-composition.md)·[팝업 검증](verification-p9-5-popup-parity.md) 및 P10.6 재사용. [캡처](D:/디자인시스템/HMDesign-OrgChart/test-results/org-chart-chart-only/storybook/orgchart--full-composition.png) | [P10.10](visual-review-p10-10.md)에서 390px 내부 스크롤·잘림 오류 재현 안 됨. 구형 내부 스크롤 기대값의 과거 실패 기록 보존, 전체 디바이스 PASS 아님 | APPROVED / 사용자 디자인 승인 / 2026-10-13 | +| D. Expanded OrgChart | 회사별 20/100/300명, 회사 컬러 7종, TB/LR/Hybrid, Drag & Drop ON/OFF, Pan/Zoom/Fit, 직원 Dialog, 그룹·연결선 | 기존 P9.4/P9.5·최종 통합·P10.6 확인 범위 PASS. [캡처](D:/디자인시스템/HMDesign-OrgChart/test-results/org-chart-chart-only/storybook/orgchart--expanded-org-chart.png) | 삼안·장헌산업 일반 제목 대비 미달. 모든 회사·폭 수동 검토 미완료 | APPROVED / 사용자 디자인 승인 / 2026-10-13 | +| E. Chart Only | 흰 캔버스, 외부 UI 없음, 초기 Fit, 가로·세로 중앙, 직접 휠 Zoom, React/Vanilla 동일 동작 | 최종 통합 51/51 PASS(기존). [React](D:/디자인시스템/HMDesign-OrgChart/test-results/org-chart-chart-only/react-100-desktop.png) · [Vanilla](D:/디자인시스템/HMDesign-OrgChart/test-results/org-chart-chart-only/vanilla-100-desktop.png) | Auto 전용. Fixed Chart Only 미지원. 같은 회사 제목 대비 위험 적용 | APPROVED / 사용자 디자인 승인 / 2026-10-13 | + +Full의 대표자 화살촉 1개와 협업 화살표 5개는 별개다. Cell Border Only 실험 디자인은 사용자 요청으로 **정식 적용 제외**했으며 승인 대상 기능에 포함하지 않는다. 이를 재적용하지 않는다. + +화면별 승인 시 체크할 접근성 항목: control ID/접근성 이름, 키보드 탐색과 focus 복귀, disabled/readOnly, 팝업 열기·닫기, 접기 후 정보 보존, 390/768/1280/1440px overflow·가독성. 기존 검사 범위 밖의 전체 조합은 **NOT VERIFIED**이며 특히 실제 스크린리더·물리 모바일·Safari/Firefox·모든 회사/폭의 수동 검토는 남아 있다. 모든 조합을 위 기능 PASS에 포함시키지 않는다. + +## 6. 색상 대비와 접근성 예외 요청 + +[회사 primary 선언](../../packages/org-chart/src/company-color-presets.ts)과 흰색 그룹 제목 정책을 유지한다. 아래 값은 P10.6의 선언색 계산 결과이며 새 브라우저 픽셀 실측이 아니다. [W3C SC 1.4.3](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html)의 일반 텍스트 AA 기준은 4.5:1이다. + +| 회사 | 배경 / 제목 | 기존 대비 계산 | 접근성 상태 | 사용자 디자인 결정 | 조직 예외 수용 / 승인자 / 승인일 | +|---|---|---|---|---|---| +| 삼안 | `#F08300` / `#FFFFFF` | 약 2.64:1 | 일반 텍스트 AA 미달 | 회사 메인색·흰 제목 유지(기존 결정) | PENDING / PENDING / PENDING | +| 장헌산업 | `#4580A6` / `#FFFFFF` | 약 4.29:1 | 일반 텍스트 AA 미달 | 회사 메인색·흰 제목 유지(기존 결정) | PENDING / PENDING / PENDING | + +대상은 해당 회사색을 쓰는 Expanded/Auto Chart Only 그룹 제목이다. 기존 디자인 유지 결정은 접근성 위험 수용이나 WCAG 준수 승인이 아니다. 예외를 수용해도 WCAG PASS로 표시하지 않는다. 조직 정책상 예외 허용 여부·책임자·적용 화면·재검토 기한을 확정해야 한다. 현재 정책은 **NOT VERIFIED**다. + +후속 개선 후보는 제목 영역·글자/배경 조합을 별도 디자인 검토하는 것이다. 회사색·흰 제목 정책을 바꾸는 구현은 별도 사용자 결정 후 수행한다. 이번 색상·카드·Typography 변경 없음. + +## 7. main 정적 비교와 자동 배포 승인 + +원격 SHA가 P10.6과 동일하므로 당시 분석을 재사용한다. 공통 조상 `d18e3c33801e165e17b6d971e2f6198eed483c3c`, main 전용 Commit **3**, Feature 전용 Commit **12**, main의 배포 관련 **6개 파일**과 Feature 변경 파일의 충돌 후보 **0**. 이 결과는 파일 집합의 정적 비교이며 실제 Merge나 통합 Linux/Docker 빌드 성공 증거가 아니다. + +P10.6 Gitea API 조회 결과: main 브랜치 보호 없음, 필수 승인 0, 필수 상태 검사 비활성. 이번 서버 설정 재조회는 하지 않았다. 이 설정을 사용자 병합 승인으로 해석하지 않는다. main Workflow의 Guard/npm audit 차단 단계 부재와 조직의 필수 gate 정책을 구분한다. + +[기준 main 배포 Workflow](https://gitea.hmac.kr/b25027/HMDesign/src/commit/c45d63a03b84eaac43f01bd400a16488a80e4229/.gitea/workflows/deploy-storybook.yml)는 **main Push 또는 수동 실행** 조건으로 해당 SHA 소스를 SSH로 보내 release directory에서 Docker 빌드 후 Storybook 서비스를 교체하는 경로를 갖는다(P10.6 및 [P10 분석](implementation-report-p10.md)). 따라서 main Merge/Push는 운영 서비스 변경으로 이어질 수 있다. 실제 Actions 활성·Runner/Secret 상태가 확인되지 않았으므로 배포 실행 여부를 단정하지 않는다. Workflow 실행·서버 접속·Docker 검토/구성/배포를 이번에 수행하지 않았다. + +| 필수 확인·승인 | 확정할 내용 | 현재 확인 상태 | 담당자·승인자 / 승인일 / 승인 결과 | +|---|---|---|---| +| main 병합 승인자 | 병합 권한자, 승인할 Feature SHA 및 문서 변경 포함 여부 | NOT VERIFIED | PENDING / PENDING / PENDING | +| 배포 담당자 | 운영 서비스 책임자와 실행·연락 주체 | NOT VERIFIED | PENDING / PENDING / PENDING | +| Gitea Actions 활성 | 저장소/서버 활성 여부, 최근 실행 여부 | NOT VERIFIED(P10.6 API 인증 제한) | PENDING / PENDING / PENDING | +| Runner 상태 | 온라인 상태·label·실행 환경·작업 가능 여부 | NOT VERIFIED | PENDING / PENDING / PENDING | +| 서버 접근 권한 | SSH Secret/변수, 대상 서버·서비스 교체 권한 | NOT VERIFIED | PENDING / PENDING / PENDING | +| 사용자 영향 | 서비스 대상·접속 경로·점검 시간·중단 가능성 | NOT VERIFIED | PENDING / PENDING / PENDING | +| 배포 실패 복구 | 이전 정상 SHA/artifact·release 경로·복구 절차·담당자·성공 확인 방법 | NOT VERIFIED, 실제 복구 절차 미확정 | PENDING / PENDING / PENDING | +| 배포 실행 허용 | 승인된 SHA의 자동/수동 배포 허용 여부와 실행 시점 | NOT VERIFIED | PENDING / PENDING / PENDING | + +**READY 전제:** 현재 디자인은 APPROVED, 실제 Guard 정적 FAIL은 0이다. Guard Runtime/조직 gate 정책 확인, 보안·접근성 위험 수용과 위 운영 항목 확정, 권한 있는 승인자의 명시적 main 병합·배포 허가가 필요하다. 후속 승인 작업에서 실행 직전 원격 SHA를 다시 확인하고 승인된 통합 상태의 필요한 빌드 검증을 수행한다. P10.7 당시 문서 변경은 미커밋이었다. 현재 P11.2 변경도 미커밋이며 기준 Feature SHA에 새 승인 기록이 포함됐다고 주장하지 않는다. main에 기존 Docker 설정이 있다는 이유로 새 Docker 구성 작업을 추가하지 않는다. + +## 8. 후속 개선 목록과 사용자 승인 기록 + +| ID | 후속 결정/작업 | 선행 조건 | 상태 / 승인자 / 승인일 | +|---|---|---|---| +| G-01 | P10.7 Guard 5건 한정 예외 요청 이력 | 당시 요청은 §3에 보존. P11/P11.1 실제 정적 FAIL 0, 조직 gate/Runtime은 별도 | 기술적 해결로 현재 예외 요청 대상 아님 / 당시 PENDING 보존 | +| G-02 | 공용 Guard 공개 CSS 연결 추적 개선 | P11은 공개 CSS 연결 분석만 개선, setter 자동 승인·규칙 우회 없음 | 구현 완료(P11) / P11.1 정적 PASS / Runtime NOT VERIFIED | +| S-01 | 개발 보안 위험 수용/추가 audit 필요 여부 | 보안 담당자·입력 통제·재검토 조건 결정 | PENDING / PENDING / PENDING | +| D-A~E | 화면 5개 최종 디자인 승인 | [승인 기록](design-approval.md), 기준 Commit `346c648c093ae3b0dedc1fc754d8ffa52f00ddd0` | APPROVED / 사용자 디자인 승인 / 2026-10-13 | +| A-01 | 삼안·장헌산업 대비 위험 수용/후속 디자인 | 조직 접근성 정책·책임자·적용 범위 확정 | PENDING / PENDING / PENDING | +| M-01 | main 병합 승인 | 위 결정과 운영 배포 항목 확정, 원격 SHA 재확인 | PENDING / PENDING / PENDING | +| R-01 | 자동 배포 실행 승인 | Actions/Runner/서버/영향/복구 담당자 확인 | PENDING / PENDING / PENDING | + +P10.7 당시 확인된 사용자 결정: 회사 메인색·흰 제목 유지, Cell Border Only 정식 적용 제외, Gitea 소스 공유 유지, 문서만 수정하고 Commit/Push 보류. 당시 최종 화면 승인과 각 위험 수용·병합·배포는 미승인이었다. **P11.2에서 화면 5개 최종 디자인 승인만 APPROVED로 갱신한다.** 7회사 기본색·흰 제목은 유지하며 Cell Border Only와 연한 배경형 B안은 적용하지 않는다. 접근성·보안 위험 수용·main 병합·운영 배포는 PENDING으로 유지한다. Guard 5건의 기술적 해결을 위험 수용 승인으로 해석하지 않는다. + +## 9. 문서 검증과 미검증 경계 + +아래 표와 13/13 결과는 **P10.7 당시 문서 검증 snapshot**이다. 당시 FAIL·승인 PENDING을 현재 상태로 해석하지 않는다. 현재 디자인·Guard 상태는 위 P11.2 갱신을 따른다. + +P10.7 검증은 문서 정합성 범위다. 기존 기능 PASS 수는 재실행 결과로 제출하지 않는다. + +| 문서 확인 항목 | 결과·방법 | +|---|---| +| Markdown 파일·캡처 링크 | PASS — 두 문서의 로컬 대상 86개 존재, 내부 앵커 3개 존재 확인 | +| 외부·채팅 참조 | PASS — 공식 Advisory/W3C 읽기 성공, 기준 Gitea Workflow HTTP 200, P10.6 채팅 읽기 성공 | +| 공개 API·React/Vanilla 예제 | PASS(정적) — 타입 선언, Props 상속, Core/React 전달, package exports, 공개 CSS, HM Entry 및 빌드 명령을 소스와 대조 | +| 현재 Feature Commit | PASS — Local HEAD와 원격 Feature, 문서 SHA 일치 | +| Chart Only·Fixed 제한 | PASS(정적) — Auto 기본 fit/direct와 Fixed TypeError 경로 대조. 기존 브라우저 결과 재사용 | +| Guard 원본 | PASS(보존 확인) — 원본 FAIL 5 / NOT VERIFIED 9 및 status/styleEvidence 유지. Guard 자체 판정은 FAIL | +| 보안 Advisory | PASS(문서 정합성) — 과거 High 7/고유 1/운영 0과 출처·시점 구분. 신규 audit 미실행 | +| 승인 상태·배포 위험 | PASS(문서 정합성) — 최종 승인 PENDING, READY 아님, main Push 배포 경로·운영 미확인 항목 명시 | +| 변경 범위 | PASS — 허용 문서 2개만 변경, staged 0, 제품/정책/과거 결과 변경 없음 | +| 공백·diff 검사 | PASS — 최종 `git diff --check` 및 신규 문서 whitespace 확인. 최종 작업 보고에 결과 연결 | + +위 API/기준/보존/범위 정적 확인 스크립트는 13/13 항목 PASS다. 이는 문서 검사 수이며 TypeScript 컴파일·제품 테스트 수가 아니다. 새 TS/TSX 예제의 전체 타입 검사·실제 브라우저 실행은 이번 작업에서 수행하지 않았다. + +**NOT VERIFIED:** 신규 npm audit/현재 전체 의존 그래프, OrgChart Submodule/Subtree 통합, 실제 Merge/통합 Linux·Docker build, Gitea Actions 실제 활성/Runner/서버/복구, 현재 조직의 Guard/audit/접근성 필수 정책, 현재 인증 계정의 Merge/Push 권한, Safari/Firefox·실제 모바일·스크린리더, 2,100명 전체 회귀·전체 baseline 픽셀·모든 회사/폭의 수동 시각 승인. Fixed Chart Only는 미검증 후보가 아니라 미지원이다. diff --git a/docs/org-chart/standalone-components-p1.md b/docs/org-chart/standalone-components-p1.md new file mode 100644 index 0000000..87eb7dc --- /dev/null +++ b/docs/org-chart/standalone-components-p1.md @@ -0,0 +1,60 @@ +# OrgChart P1 독립 디자인과 개별 컴포넌트 + +2026-10-12 최종 요청을 적용한다. 기존 공통 Theme 필수 연결 규칙은 OrgChart에 적용하지 않는다. 과거 v0.1 검증 기록은 당시 구현 기록으로 보존한다. + +## 공개 API와 스타일 + +패키지 root 공개 API에 `createOrgChartTree`, `createPositionSummary`, `createAffiliationSummary`, `createStatusSummary`를 추가한다. 기존 `createOrgChart`, Source, schema, summary 및 React Wrapper API는 유지한다. 모든 factory가 기존 `mount/update/reload/destroy/subscribe/getState` handle을 반환한다. + +```js +import { createPositionSummary, createAffiliationSummary, createOrgChartTree, + JsonOrgChartDataSource } from '@hm-design/org-chart'; +import coreUrl from '@hm-design/org-chart/style.css?url'; +import engineUrl from '@hm-design/org-chart/engine.css?url'; + +const source = new JsonOrgChartDataSource(dataset); +const position = createPositionSummary({ container: positionElement, + dataSource: source, stylesheetUrl: coreUrl }); +const affiliation = createAffiliationSummary({ container: affiliationElement, + dataSource: source, stylesheetUrl: coreUrl }); +const tree = createOrgChartTree({ container: treeElement, dataSource: source, + stylesheetUrls: { core: coreUrl, engine: engineUrl } }); +await Promise.all([position.mount(), affiliation.mount(), tree.mount()]); +// 상위 페이지가 DOM container를 자유롭게 배치하고 표시·숨김을 관리한다. +await position.update(nextDataset); +position.destroy(); affiliation.destroy(); tree.destroy(); +``` + +`?url`은 Vite 사용 예시다. 일반 HTML은 배포한 CSS 실제 URL을 전달한다. `/browser` classic bundle도 같은 factory를 `window.HMOrgChart`로 제공한다. ESM import/handle 생성은 DOM 없이 가능하며 mount에만 연결된 HTMLElement가 필요하다. Summary는 engine을 로드하지 않고 engine.css도 필요 없다. 각 모듈은 Header·Routing·전체 페이지에 의존하지 않는다. + +React에서는 기존 전체 `OrgChart` Wrapper를 그대로 사용한다. 개별 모듈은 위 factory를 ref/effect에서 mount하고 cleanup에서 destroy하면 된다. JSX 카드·테이블·데이터 계산을 복제하지 않는다. Source는 공유할 수 있으며 각 handle은 동일한 validate/normalize/calculateSummary 흐름을 사용한다. 상위 페이지가 여러 handle에 nextDataset을 전달한다. 자동 전역 데이터 저장소는 추가하지 않는다. + +## 독립 디자인 입력 + +OrgChart는 Shadow DOM 안에서 `engine.css → style.css`를 적용한다. `style.css`는 모든 내부 CSS를 포함하며 공통 HM Theme가 없어도 기본 디자인을 가진다. 공통 Theme·Token·Component는 변경하지 않는다. + +```css +.other-page-summary { + --hm-orgchart-font-family: Arial, sans-serif; + --hm-orgchart-spacing-lg: 20px; + --hm-orgchart-radius-container: 6px; + --hm-orgchart-bg-surface: #ffffff; +} +.custom-chart { + --hm-orgchart-design-planning-card-background: #f4ffff; + --hm-orgchart-design-planning-card-color: #10243b; + --hm-orgchart-design-planning-department-color: #ffffff; + --hm-orgchart-design-planning-border: #00847f; + --hm-orgchart-design-planning-gradient: linear-gradient(#00847f, #005b57); +} +``` + +위 예시는 외부 사용자 override 예시이며 공식 Workdesk 값 확정이 아니다. public 변수를 container에 설정한다. `resolved-*`, `current-*`는 내부 구현 변수다. 기본 변수 전체 목록은 `packages/org-chart/src/styles/design-tokens.css`, 조직별 목록·실측 근거는 `core/style-presets.ts`에서 관리한다. 조직별 ID에 연결하고 하위 셀·팀은 상위 variant를 상속한다. JSON 직원 객체에 컬러를 추가하지 않는다. 카드 배경·직원 텍스트·부서명 텍스트·테두리·Gradient를 각각 바꿀 수 있다. + +## Composition·데이터·디자인 근거 + +`core/org-chart-content.ts`에서 독립 Tree와 Summary 모듈을 선택해 배치한다. 기본 placement는 직위=side, 관계사=footer, 파견·휴직=belowTree이며 내부 `ContentComposition.placements`로 바꿀 수 있다. 외부 호출자는 공개 개별 API의 container를 직접 배치한다. 기존 presentation flags로 전체 조합의 표시를 관리하며 계약 필드 추가 없이 처리한다. + +Workdesk `v1.2610.9c5e` 운영 화면에서 확인한 조직 명칭과 6개 조직 헤더 Gradient·흰색 부서명 텍스트를 사용한다. 일반 배경·카드 테두리·Typography·간격·Radius는 독립 구현 기본값(DESIGN_ADAPTATION); 원본 카드 세부값은 NOT VERIFIED다. Figma 접근 가능한 정식 회사 로고 7종은 Core에서 affiliation ID로 매핑하고 카드·현황·Logo에서 재사용한다. 출처·원본 SHA256은 `packages/org-chart/src/assets/affiliations/README.md`에 기록한다. 미매핑/파일 오류는 회사 명칭 텍스트 대체를 사용하고 임의 로고를 생성하지 않는다. + +P1 fixture는 실제 조직명과 관계사명만 사용한다. 직원 41명과 직위·직책·소속·근무 상태는 가상이며 프로필은 null/이니셜 placeholder다. 관계사 3곳은 0명을 유지한다. Raw Count는 가상 JSON 고유 employee ID로 자동 계산하고 실제 HR 인원수나 승인된 집계 정책으로 표시하지 않는다. diff --git a/docs/org-chart/ui-composition.md b/docs/org-chart/ui-composition.md new file mode 100644 index 0000000..a89fa5a --- /dev/null +++ b/docs/org-chart/ui-composition.md @@ -0,0 +1,124 @@ +# OrgChart UI Composition + +## P9.5 Full Composition 적응형 앵커 (2026-10-13) + +Workdesk 조회 전용 Full Composition은 원본1920×914.5를 디자인 좌표계로 유지한다. 상단 제목·기준일·인원 표, 중앙 대표자·허브·조직/직원·연결선·화살표·장식, 하단 파견/휴직·관계사 바를 논리적 영역으로 표시한다. 상단 앵커는 콘텐츠 내 배치이며 sticky/스크롤 추적이 아니다. + +배율은 `min(1, 실제 콘텐츠 가용 폭 / 1920, 실제 가용 높이 / 914.5)`다. 원본보다 키우지 않는다. 높이가 남으면 Stage의 사용 높이를 `가용 높이 / 배율`로 확보하고, 상단·하단의 실제 Rect 사이에 중앙 내용 Rect의 중심을 맞춘다. 장식은 원본 크기와 좌표를 유지하고 중앙과 같은 수직 오프셋으로 움직인다. 높이가 부족하면914.5 기준의 기존 상대 좌표를 유지하여 전체를 비례 축소한다. 하단 그룹과 가로 중앙 정렬은 중앙 오프셋에 포함하지 않는다. + +파견·휴직은 하단 CSS 앵커를70→82디자인px로 바꾸어 기준에서12px 위로 옮겼다. 관계사 바의55.4px 높이·로고·문자와 하단 앵커는 유지하며 실제 그룹/바 간격은 약14.61디자인px다. 협업 화살표5개는 이미 정상 표시 중이어서 원본4개PNG·5개 배치를 보존했다. management 연결선은 이동된 overlay 원점을 기준으로 계산하여 중앙 이동의 이중 적용을 막는다. + +P9.2의 실제 Content Wrapper 기준 가로 중앙 계산과 Expanded/Overview/Department 경로는 유지한다. 기존 P2~P9.4 보고서는 당시 기록으로 보존하고, 현재 측정은 [Full Composition P9.5 보고서](implementation-report-p9-5-full-composition.md)에서 구분한다. + +## P3.2 후속 — 대표자 뱃지 미사용 (2026-10-12) + +대표자 카드는 모든 실행 경로와 독립 견본에서 직책·역할·상태 뱃지 및 빈 attributes/뱃지 행을 생성하지 않는다. 표시 직위 `대표이사`, 이름 `홍 길 동`, 승인 카드 146×74 및 기존 Typography·사진·로고를 유지하며 직위↔이름 간격은 0px이다. 원본 fixture의 대표자 직위는 `경영진`이므로 `대표이사`는 카드 표시에만 적용한다. 직원 JSON·정보 팝업 원문·공개 API와 다른 1Depth/2Depth 카드의 뱃지 정책은 그대로 유지한다. 아래 P3.2 및 이전 보고서·검증은 당시 기록으로 보존한다. + +## P3.2 현재 계약 (2026-10-12) + +[P3.2 보고서](implementation-report-p3.2.md)의 카드 Typography·상태 영역·로고 보정이 아래 P3.1 수치보다 우선한다. 과거 검증 기록은 그대로 보존한다. + +- 대표자 Typography는 유지한다. 대형 이름 24px/600/30px, 직위 18px/400/22px; 소형 이름 18px/600/24px, 직위 15px/400/18px이다. 기존 컬러·letter-spacing·Noto Sans KR 및 `홍 길 동`을 유지한다. 직위·뱃지 공백은 표시 계층에서만 제거한다. +- readOnly 파견·휴직은 SVG 없이 일반 소형 카드의 공통 콘텐츠 레이아웃과 Typography를 재사용한다. 카드 108×52, 사진 24×44, 사진↔텍스트 4px, 직위↔이름 0px이다. 기존 회색 표면·문자색·로고 grayscale을 유지하며 별도 상태 행은 없다. P3.1의 실제 114×54/사진 28×28에서 이번 요청값으로 보정한다. +- 상태 타이틀은 일반 2Depth 기본 20px/700/19.7px를 공유하며 회색 Gradient를 유지한다. 경영기획/인재성장 18px/600/16.9px 예외는 보존한다. 타이틀↔첫 카드 Gap 8px를 유지한다. +- 카드 로고의 실제 contain 표시 영역은 소형 12×12→18×18, 대형·대표자·파견·휴직 14×14→21×21이다. 원본 PNG·승인 SVG 및 하단 관계사 현황 로고는 수정하지 않는다. 직원 카드 selector에만 적용하며 비율·투명 배경을 유지한다. + +## P3.1 현재 계약 (2026-10-12) + +잔여 디자인 보완의 최신 계약은 [P3.1 보고서](implementation-report-p3.1.md)가 우선한다. 아래 P3/P2 및 초기 설계의 검증 기록은 당시 범위 그대로 보존한다. + +- OrgChart 전용 기본 폰트는 Noto Sans KR이다. 기존 `public/fonts/NotoSansKR-variable.ttf`와 호스트 font-face를 재사용하며 HM Theme는 수정하지 않는다. 한국어 Core shell은 `lang="ko"`로 HTML/React/Storybook의 글리프 조형을 맞춘다. +- 이름은 `홍 길 동`, 직위는 표시에서만 공백을 제거한다. Full Composition은 `jobTitle`이 `경영진`인 뱃지만 생성하지 않으며 기타 직책 문자열·원본 데이터·팝업·통계는 유지한다. 원본 `position="경영진"` 텍스트는 유지한다. +- 1Depth는 20px/700/36px·헤더 높이 36px이다. 2Depth 경영기획/인재성장은 18px/600/16.9px, 나머지는 기존 20px/700/19.7px다. 114×30.4 표면·Gradient·Radius·Shadow 및 모든 타이틀↔첫 카드 Gap 8px를 보존한다. +- 허브는 원본 21px/750/24px·아이콘 20px·Gap 8px·좌우 Padding 22/24px·높이 42px를 유지한다. 최대 폭 상속 제약을 해제하고 X 935 중심축을 유지한다. +- 사진 없음/로드 실패는 공통 `person-placeholder.svg`를 사용한다. 실제 사진이 있으면 우선한다. 승인 사진 슬롯 36×62/32×56/24×44, P3 상태 카드 슬롯 28×28 및 비원형 처리를 유지한다. +- 우측 표는 전체 330px 안에서 조직명/경영진 열을 112/46px로 재배분해 한 줄로 표시한다. 나머지 열은 잔여 폭을 나눈다. 숨김/말줄임/폰트 축소 없이 원본 14px, 기존 계산 41명·위치·셀 정렬·행 높이를 보존한다. +- 대표자 146×74, 1Depth 146×68, 하위 108×52 및 P3 상태 카드 114×54를 유지한다. 확장형 Auto Layout/layoutConfig·메뉴·Navigation·공개 API 추가 없음. + +## P3 현재 계약 (2026-10-12) + +아래 초기 설계와 P2 기록은 보존한다. 충돌하는 표시 계약은 [P3 보고서](implementation-report-p3.md)가 우선한다. 범위는 기존 독립 HTML·React 공통 Core와 해당 Storybook 페이지이며 Navigation·Header를 추가하지 않는다. 기존 semantic table/native dialog는 공통 Vanilla Core adapter를 유지하기 위한 선택이다. FilterBar·DataTable의 검색/정렬/페이지 기능은 요구되지 않아 추가하지 않는다. + +- Full Composition 기본 배경 `#F5F8FB`, 원본 장식·허브 곡선·협업 화살표 유지. 조회 전용 엔진의 하위 연결선 생성 경로는 모두 숨긴다. +- 대표자 146×74, 1Depth 146×68, 하위 108×52 유지. 모든 조직 타이틀↔첫 카드 간격 8px, 직위/뱃지↔이름 행 0px. 이름만 `홍 길 동`, 직위·뱃지는 원문. +- 하위 헤더는 Workdesk 소스의 114×30.4 표면·상위 조직 Gradient·Radius 7.1·Shadow를 적용한다. HR은 18px/600/16.9, 다른 헤더는 20px/700/19.7이다. 허브는 원본 SVG 아이콘 20px·Gap 8px·텍스트 21px/750/24를 사용한다. +- 사진 없음은 원형 기본 아바타 대신 빈 사각형 예약 영역이다. 원본에 기본 이미지가 없으며 승인 카드의 기존 폭을 유지하는 디자인 조정이다. 실제 직원 사진은 포함하지 않는다. +- 우측은 직위별 표(주요 조직 하위 포함, 루트 직속, 직위별 열, 소계, 계). 가상 직원 41명·원본 조직 관계·독립 PositionSummary API를 보존한다. 상태 그룹의 카드 하단 상태 문구만 제거한다. 관계사 바는 제목 없는 한 줄이다. +- 축소는 단일 비례 배율 `min(1, 가용 폭 / Stage 폭, 실제 컨테이너 가용 높이 / Stage 높이)`다. 연결 후 실제 Shadow/호스트 조상과 viewport를 관찰한다. 임의 Header 높이나 viewport 절반 offset 제한은 없다. +- `organizationLinks`가 지정된 1Depth 제목만 링크다. `onEmployeeClick`이 없으면 Stage 바깥 native modal에서 기존 직원 정보를 조회한다. 닫기·ESC·키보드 Focus를 지원한다. +- 사용자 메뉴는 `Logo · Personnel Summary`, `Department Colors`, `Full Composition`이다. 이전 단독 Story ID는 내부 태그로 보존한다. + +실제 브라우저 측정과 미검증은 P3 보고서에 기록한다. 과거 PASS/FAIL을 이번 상태로 덮어쓰지 않는다. + +상태: 콘텐츠 설계, 실행·시각 검증 미수행. [README](README.md)의 확정 범위와 최신 Core 독립성을 따른다. Workdesk 캡처 파일은 이번 첨부에 없어 아래 구성은 사용자 텍스트의 8영역을 기준으로 한다. 실제 이미지와의 배치·색상·카드·곡선 비교는 **NOT VERIFIED**다. + +위 상태와 초기 설계 절은 과거 기록이다. 현재 조회 전용 구현은 3a절과 [P2 구현·검증 보고서](implementation-report-p2.md)를 따른다. P2에서는 읽기 전용 Workdesk 로컬 소스와 자산을 확인하고 실제 HTML·React·Storybook을 검증했다. 운영 Workdesk와의 전체 픽셀 일치 여부는 별도 미검증이다. + +## 1. 콘텐츠 구성과 데이터 연결 + +1. **제목·설명**: PresentationConfig의 title/description과 Metadata 기준일. Header·PageHeader·브랜드 Navigation을 생성하지 않는다. +2. **최상위 리더 카드**: 승인된 root 조직의 leaderId를 Employee에 연결한다. 공석·여러 root·겸직 leader 표현은 TBD다. 같은 직원을 중복 집계하지 않는다. +3. **중앙 허브**: hubText의 콘텐츠. 조직 entity인지 설명 허브인지, 리더와의 연결 방식은 TBD다. 조직장 ID나 직원 ID를 허브에 임의 부여하지 않는다. +4. **조직별 그룹**: Organization parent/type/order로 부문·실·Div를 구성한다. 조직 사이 기본 계층 연결선은 engine가 담당한다. +5. **셀·팀·구성원**: 같은 조직 계약에서 cell/team을 표현하고 직원은 organizationId로 조회한다. 이름·직위·직책·이미지/fallback·상태를 조합한다. 직원 전체를 조직 트리의 nested 원본으로 저장하지 않는다. +6. **우측 인원 현황**: 조직 직접/하위 포함·직위별 등 승인된 집계. 우측은 넓은 컨테이너에서의 배치 후보이며 작은 화면에서는 문서 흐름으로 이동하는 안을 검토한다. +7. **파견·휴직 영역**: 같은 직원 원본의 상태별 그룹. 본 조직 카드와 동시 노출/이동 표시 정책은 TBD이며 표시와 집계를 분리한다. +8. **관계사별 인원·총원**: Affiliation과 승인 정책을 사용하는 집계. 조직별 하위 포함 수를 단순 합산하지 않는다. 집계 정책 미승인 시 미확정 상태를 설명한다. + +추가 조직 관계는 OrganizationRelation에서 별도 표시한다. 관계 의미·방향·범례·곡선·anchor는 승인 전 확정하지 않는다. 관계를 parent hierarchy로 오인하지 않도록 텍스트 설명을 함께 제공하는 안을 검토한다. 고정 좌표로 인원 수나 조직 배치를 하드코딩하지 않는다. + +## 2. HM 재사용과 Core DOM 경계 + +최신 사용자 요구에 따라 Core는 React Component를 import하지 않는다. OrgChart 전용 표준 HTML/CSS가 **공통 Component를 대체하는 신규 HM primitive**가 되지 않도록 범위를 콘텐츠에 제한한다. 기존 Component·Pattern·Theme·대표 Story는 보존한다. + +재사용의 두 경로를 구별한다. + +- Core: HM CSS Custom Properties와 기존 Typography·Spacing·Radius 기준을 참조한다. 노드에는 전용 class를 쓰고 HM `data-slot` 또는 일반 Card/Avatar selector를 흉내내지 않는다. 이 예외와 기존 React 지침의 충돌은 README에 기록했다. +- React 소비 앱(Core 콘텐츠 밖): 적합한 주변 콘텐츠에서 공개 `@hm-design/ui` API를 실제 렌더링하는 방향을 검토한다. Wrapper는 Core summary/loading/error/node를 다른 HM UI로 대체하지 않는다. Core 노드마다 React root를 만드는 설계도 채택하지 않는다. 공통 Core의 콘텐츠·디자인·인터랙션을 유지한다. + +확인한 공개 자료: + +- [Card 문서](../components/card.md), [Card All](../../src/components/card.stories.tsx), [구현](../../packages/ui/src/components/ui/card.tsx): surface/content와 Header/Title/Description/Content 조합. Core 외부의 React 콘텐츠 묶음에서 재사용 후보다. +- [Avatar 문서](../components/avatar.md), [Avatar All](../../src/components/avatar.stories.tsx), [구현](../../packages/ui/src/components/ui/avatar.tsx): 이미지/fallback, sm/default/lg. Core는 ``/안전한 텍스트 fallback을 사용하고 React 호스트에서는 공개 Avatar 후보를 검토한다. +- [Table 문서](../components/table.md), [Table All](../../src/components/table.stories.tsx): 정적 표이며 정렬/선택/페이지는 DataTable 책임이다. 요청된 Summary는 동일 Core의 semantic table로 설계한다. React 소비 앱의 별도 주변 표에서 공개 Table을 검토할 수 있지만 Wrapper가 Summary를 중복 구현하지 않는다. 기존 Table 최소 폭 520px은 Core의 확정 최소 폭이 아니며 사용한 주변 표는 390px 내부 스크롤을 확인한다. +- [공개 인덱스](../guidelines/component-pattern-reference.md)의 Badge/Alert/Skeleton/ScrollArea/Button/Label은 해당 기능 확정 시 문서·대표 Story·Props를 더 확인한다. 현재 import나 재사용 PASS로 보고하지 않는다. + +Pattern 선택 근거: FilterBar는 검색·조회 조건용이다. 현재 검색/필터 요구가 없어 추가하지 않는다. DataTable의 정렬/선택/페이지도 요구가 없어 사용하지 않는 방향이다. DashboardLayout/GNB/LNB/PageHeader는 페이지 Shell/Navigation 책임이므로 제외한다. LoadingScreen은 전체 화면용이라 콘텐츠 loading에 바로 사용하지 않는다. 상태 콘텐츠는 Core가 관리하고 React 주변 조합은 기존 Alert/Skeleton을 검토한다. 이는 개발 시 선택 근거이며 구현 재사용 검증은 **NOT VERIFIED**다. + +## 3. 컨테이너·반응형·Overflow + +Core는 부모의 실제 콘텐츠 크기를 사용한다. viewport에서 Header 높이를 빼거나 GNB/LNB/Dashboard 모드를 읽지 않는다. Dashboard 소비자가 960px 내부 콘텐츠를 주는 경우에도 이를 Core 전역 최소 폭으로 일반화하지 않는다. + +390/768/1280/1440px 각각에서 콘텐츠 컨테이너를 직접 제한하여 검사한다. 숫자는 검증 기준이며 확정된 breakpoint·카드 폭이 아니다. + +- 390px: 가로 조직도 내부 스크롤·읽을 수 있는 텍스트 배율, 요약/상태 영역 흐름 배치, touch와 focus 접근을 검토한다. 전체 화면을 축소해 텍스트를 읽을 수 없게 하는 기본값은 확정하지 않는다. +- 768px: 조직 분기 wrap과 가로 스크롤, summary의 옆/아래 배치, 긴 조직명·직위·ID를 검사한다. +- 1280/1440px: 전체 계층·리더·허브·우측 summary·추가 관계·직원 카드의 겹침과 여백을 검사한다. +- 조직 수·깊이·직원 수가 증가하면 내부 scroll container가 확장되고 페이지 전체 가로 Overflow는 발생하지 않아야 한다. CSS transform zoom은 layout 크기와 다를 수 있으므로 보이는 영역과 scrollWidth/clientWidth를 함께 측정한다. +- `ResizeObserver`·이미지 로드·폰트 로드·노드 확장·줌·스크롤마다 Overlay 좌표가 동일 좌표계에서 갱신돼야 한다. transform 중복 적용과 stale anchor를 검사한다. +- 모바일 방식은 내부 가로 scroll, branch 축소, 세로 대체 표현 중 PoC로 결정한다. 대체 표현은 승인 전 추가 개발 요구로 만들지 않는다. 최소 콘텐츠 폭·zoom 한계·초기 배율·wheel 우선순위·높이 제약·스크롤 책임은 TBD다. + +문서와 내부 컨테이너의 scrollWidth/clientWidth 및 scrollHeight/clientHeight를 기록한다. absolute 알림/overlay의 위치 기준은 해당 인스턴스의 relative 컨테이너여야 한다. 긴 label 줄바꿈과 ID 한 줄 식별 가능성, 접힌 영역에 숨은 focus, 두 인스턴스의 독립 scroll/zoom을 검사한다. + +### 3a. 조회 전용 Full Composition (`displayMode: 'readOnly'`, 2026-10-12) + +- Storybook `OrgChart / Full Composition`, HTML·React `p1.html?mode=readOnly`가 사용한다. 기본값 `interactive`와 내부 검증 Story·독립 컴포넌트는 변경하지 않는다. +- 생성하지 않는 요소: 하위 조직 접기/펼치기 button, 조직 종류·ID·원본 직원 수·조직장 안내 문구, 제목 설명·추가 관계 안내, 상태 그룹 정책 문구. 조직 그룹 외곽 카드(배경·Border·Radius·Padding)는 CSS로 제거하고 조직/셀·팀 헤더·카드·연결선은 유지한다. +- 배치: Workdesk 실측 1920 × 914.5 캔버스, 대표자·허브 축 X 935, 조직 헤더 X 32/268/505/858/1336/1689(`core/workdesk-layout.ts`, organization ID 기준 presentation preset). 통합시스템 Div·디자인기획 하위는 가로, 나머지는 세로. preset이 없는 조직이 있으면 중앙 흐름 배치로 대체한다. +- 요약: 우측 상단 `조직별 인원 현황`(루트 직속 + 주요 조직 하위 포함 인원 + 계), 좌하단 파견·휴직(제목 108px pill, 카드 유지), 하단 관계사 바(제목 없음, 로고·회사명·인원·총계). 모두 같은 Summary/selector에서 계산한다. +- 축소: `scale = min(1, 가용 폭 / 1920, (viewport 높이 − 문서 offset) / 914.5)`(offset은 viewport 절반까지만 반영). transform scale 1회, 내부 가로 스크롤 없음. +- P2 카드: 실제 `parentId` 조상 관계로 Depth를 계산한다. 대표자 146×74, 1Depth 조직장만 146×68, 2Depth 이하 조직장은 해당 부서의 108×52 소형 SVG를 사용한다. semantic leader와 직책 뱃지는 유지하며 interactive·독립 카드 선택은 보존한다. 전달 자산이 없는 조직의 기존 fallback 정책은 유지한다. +- 최신 확정 표시: 조회 전용 직원 이름은 대표자·파견·휴직을 포함해 모두 `홍 길 동`. 직위·직책·이름의 연속 한글 사이에는 표시 계층에서 공백을 넣고 접근성 이름은 원문을 사용한다. 원본 JSON 이름·ID·조직·인원 계산은 변경하지 않는다. 기존 font-size/weight/line-height/color와 내부 0px 행 간격을 유지한다. +- P2 간격: 1Depth 헤더 높이 36px, Y 약 254.19px, 대형 카드 사이 실제 8px. 2Depth 헤더와 첫 카드 사이 실제 10px. 하단 상태/관계사 슬롯은 측정 캔버스 내에서 고정 배치하여 전체 높이 914.5px와 겹침 없는 배치를 유지한다. +- P2 장식: `core/workdesk-artwork.ts`가 원본 compact 허브 곡선 3개·중앙선·사업관리 추가 곡선, PNG 협업 화살표 5개와 배경/허브 이미지 5개를 같은 Stage에 렌더한다. 원본 9종 자산은 byte 그대로 복사하고 ESM/IIFE에 포함한다. 기존 상단 pseudo 연결선은 이 preset에서 제거한다. 하위 팀의 실사용 원본 SVG는 없어 기존 engine 관계 경로를 유지한다. +- P2 검증: HTML HTTP·classic file://·React production·Storybook에서 1920/1440/1280/768/390px 484건 PASS. Guard 원본의 NOT VERIFIED와 런타임 PASS는 별도 증거로 보존한다. 상세 측정·남은 차이·기존 FAIL은 P2 보고서에 기록한다. + +## 4. 접근성·상태·개인정보 + +Core DOM은 제목 계층·조직/직원 그룹·표의 caption/header를 semantic하게 구성한다. engine 기본 DOM이 완전한 ARIA tree라고 가정하지 않는다. tree 역할 채택 시 키보드 계약 전체를 검증하고, 그렇지 않으면 계층 설명을 제공할 구조를 검토한다. + +확장·축소/zoom 조작이 구현될 경우 키보드로 접근 가능한 표준 button과 접근성 이름·focus indicator·disabled 상태를 제공한다. readOnly 데이터 조직도에 수정·drag 메뉴를 추가하지 않는다. 상호작용 후 focus 복귀, 제거된 노드 focus 이동, 오류/로딩 안내의 screen reader 노출과 과도한 알림을 검사한다. 입력 컨트롤이 새로 승인되면 React 호스트는 공개 Label, Core는 framework 독립성 때문에 native label/control 연결을 명시한다. + +이미지 접근 실패는 가상 fallback을 보여주며 불필요한 이름 중복 읽기를 피한다. 사진의 origin·권한·실패 정책은 API 연동 시 결정한다. 직원 ID·이름·사진 원본을 로그/오류 메시지에 무분별하게 남기지 않는다. reference의 실제 인물을 fixture로 재사용하지 않는다. + +Loading/Empty/Error/Ready, 직원 없는 조직, 공석 leader, 상태 그룹 비어 있음, 정책 없는 집계, 갱신 중 상태를 별도 검증한다. 갱신 중 기존 화면 유지와 부분 오류 허용 정책은 TBD다. 사용자 검토 전에 이러한 설계 후보를 기능 PASS로 표시하지 않는다. diff --git a/docs/org-chart/verification-p9-5-popup-parity.md b/docs/org-chart/verification-p9-5-popup-parity.md new file mode 100644 index 0000000..e787177 --- /dev/null +++ b/docs/org-chart/verification-p9-5-popup-parity.md @@ -0,0 +1,59 @@ +# P9.5 Core X 버튼 — 실제 소비 화면 비교 검증 + +RESULT: PASS (요청 화면의 기본/선택 팝업 디자인과 기존 동작) + +## 실제 팝업 사용 경로 + +- Storybook Full Composition: 화면은 React OrgChart Wrapper지만 팝업은 Core native dialog. Fixed는 Core 팝업을 사용한다. +- Storybook Expanded OrgChart 기본값: React HMDesign Dialog. 실제 공개 HM Dialog를 사용하는 HMEmployeeDialog Portal이다. +- Storybook Expanded OrgChart에서 직원 정보 팝업을 Core 기본 팝업으로 선택: Core native dialog. +- 실제 Vanilla index.html, p1.html?mode=readOnly (Full Composition), expanded.html: 모두 Core native dialog. React 없이 공식 Core API를 사용한다. + +열린 DOM과 위치로 구분했다. Core는 ShadowRoot 안의 dialog.hm-orgchart-employee-dialog[open], React HM은 Document Portal의 data-component=HMOrgChartEmployeeDialog / data-state=open이다. HM 모드에서도 닫힌 Core dialog DOM은 존재할 수 있으므로 단순 DOM 존재를 팝업 사용 증거로 보지 않았다. + +## 실행 범위와 결과 + +- 위 6개 경로 × 1440×900 / 390×900: 118 PASS, 0 FAIL. +- 기준: 실제 Components/Overlay/Dialog 대표 All Story에서 여는 HM Dialog의 ghost/icon-sm X 버튼. Expanded의 실제 HM 직원 Dialog도 같은 기준값을 확인했다. +- 모든 버튼 28×28px, SVG 16×16px, 두 Lucide X 경로 동일, Stroke 2, round cap/join, Padding 0, Radius 3.2px, CSS top/right 8px. 팝업 Border 1px를 포함한 실제 외곽 inset은 각각 9px. +- 기본색 rgb(10,34,29), Hover rgb(244,241,234), Focus Border rgb(38,102,81) 및 같은 색 50%의 3px Ring이 일치했다. HM의 투명 shadow layers와 Core의 단일 ring 표현은 동일한 가시 링이다. +- X 클릭/ESC 닫기, 카드 Focus 복귀, 키보드 Enter 재열기, 선택된 직원/본문 데이터 유지 PASS. 신규 Console/Page 오류 0. +- 외부 클릭 정책은 각각 유지: Core는 열린 상태 유지, React HMDesign Dialog는 닫고 직원 카드로 Focus 복귀. +- 팝업 본문 구성은 각 기존 디자인을 유지한다. HM에는 Description/Avatar/Footer 닫기 버튼이 있고 Core에는 기존 간단한 직원 정보 목록이 있다. 이번 검증에서 이를 동일 본문으로 변경하지 않았다. +- 소스와 실제 캡처를 비교 검토했다. 전체 레이아웃/Drag & Drop 회귀는 반복하지 않았다. + +## Vanilla 예제 산출물 갱신 + +시작 시 packages/org-chart/dist는 2026-10-13 수정본이었으나 examples/org-chart/dist/assets는 2026-10-12 복사본이었다. 실제 예제는 assets/index.js 및 style.css를 읽으므로 이전 복사본에서는 새 X가 반영되지 않는 상태였다. + +기존 node examples/org-chart/prepare.mjs 경로로 최신 Core 배포 파일을 실제 예제 dist에 복사했다. 제품 소스 수정 없이 생성물만 갱신했다. 갱신 후 index.js / style.css 각각의 SHA256이 Core 배포 파일과 정확히 동일함을 확인했다. 이전/이후 manifest는 artifacts-before.json / artifacts-after.json이다. + +기존 Typecheck/Core Build/Storybook Build PASS는 제품 소스가 그대로이므로 재사용했다. Guard도 UI 변경이 없어 반복하지 않았다. 이전 Guard 원본의 SCOPE-001 및 CSS Runtime/Final NOT VERIFIED는 유지하며 본 브라우저 측정은 별도 증거다. + +## 증거 및 검토 URL + +결과: test-results/org-chart-p9-5-popup-parity/result.json +스크립트: examples/org-chart/verify-p9-5-popup-parity.mjs +보존: test-results/org-chart-p9-5-popup-parity/preservation.json + +- Full Composition: http://127.0.0.1:6064/?path=/story/orgchart--full-composition +- Expanded OrgChart: http://127.0.0.1:6064/?path=/story/orgchart--expanded-org-chart +- Vanilla 기본: http://127.0.0.1:4178/index.html +- Vanilla Full Composition: http://127.0.0.1:4178/p1.html?mode=readOnly +- Vanilla Expanded: http://127.0.0.1:4178/expanded.html + +팝업 및 전체 문맥 캡처는 test-results/org-chart-p9-5-popup-parity/의 각 경로명-폭-popup.png / context.png에 보관한다. 주요 비교 캡처는 storybook-expanded-hm-1440-popup.png, storybook-expanded-core-1440-popup.png, vanilla-full-1440-popup.png이다. + +## 보존 / 미검증 / 시간 + +시작 시점 기존 authored 파일 SHA256 변화 0, 기존 P9.5 미커밋 변경 보존. 제품 소스 추가 수정 없음. 검증 스크립트와 본 보고서만 추가했고 예제 dist 생성물을 갱신했다. + +feature/orgchart-package 및 HEAD 7614507583927a25a270963a74ff044be0557b9f 유지. Staged 파일 0. Stage/Commit/Push/Merge/PR/Reset/Rebase 미진행. + +NOT VERIFIED: 다른 브라우저/OS/Theme, Classic 패키징, 별도 React 독립 예제, 팝업 사용 안 함/소비자 Custom callback 경로, 최종 디자인 승인. 요청 화면의 Core/HM 기본/선택 경로 검증에는 해당 누락이 없다. + +Started At: 2026-10-13 14:05:05 KST +Completed At: 2026-10-13 14:12:21 KST +Actual Duration: 7분 16초 +Estimated Duration: 12분 +Variance: -284초 diff --git a/docs/org-chart/visual-review-p10-10.md b/docs/org-chart/visual-review-p10-10.md new file mode 100644 index 0000000..5fe221d --- /dev/null +++ b/docs/org-chart/visual-review-p10-10.md @@ -0,0 +1,117 @@ +# HMDesign OrgChart P10.10 — 현재 브라우저 시각 검증 + +**RESULT: PASS — 요청 범위의 브라우저 렌더링·대표 동작·390px 원인 판정 완료. 사용자 최종 디자인 승인: PENDING.** 제품/Storybook 소스 수정이 필요한 오류는 재현되지 않았다. 이 결과는 main 병합·배포 승인이나 기존 Guard/보안/접근성 승인 완료를 뜻하지 않는다. + +## 실행 환경과 Git + +| 항목 | 현재 확인 | +|---|---| +| 프로젝트 | `D:\디자인시스템\HMDesign-OrgChart-Recovered` | +| 브랜치 | `feature/orgchart-package` | +| 시작·최종 Local HEAD | `e525d557c400b989be3c5ec0f74319815da3e4ff` | +| 시작·최종 원격 Feature | 위 SHA와 동일, `git ls-remote` 읽기 전용 조회 | +| origin | `https://gitea.hmac.kr/b25027/HMDesign.git` | +| 작성자 설정 | `b25027` / `shkim4@samaneng.com` | +| 시작 Working Tree | 깨끗함 | +| 완료 변경 | 이 보고서 신규 1개, unstaged. 제품 코드 변경 0개 | +| 브라우저 | 실제 Google Chrome / Chromium `155.0.8059.40`, Playwright, device scale 1 | + +요청한 복구 경로·Feature 브랜치·Commit/Push 보류 지시를 적용했다. 보존용 `HMDesign-OrgChart`는 수정하지 않았다. Stage·Commit·Push·Checkout·Merge·PR·Docker·Publish·Reset·Rebase·Force Push 미진행. 공식 원격 HEAD를 변경하지 않았다. + +복구 저장소에는 `node_modules`와 workspace `dist`가 없었다. 기존 lockfile을 그대로 사용하는 `npm.cmd ci --no-audit --no-fund`로 552개 패키지를 설치하고, 실행에 필요한 `npm.cmd run build:packages`를 한 번 완료했다. package.json/lockfile/의존성 버전을 변경하지 않았다. 전체 앱/Storybook production build·전체 Typecheck·Core 86개·Chart Only 51개·Guard 94개 회귀·npm audit는 직접 영향이 없어 반복하지 않았다. + +시작 시 6045/6046/6024 Listener가 없었다. 기존 서버를 종료하지 않고 이 작업의 서버만 실행했다. 서버는 검토용으로 유지한다. + +- Storybook: [공개 메뉴](http://127.0.0.1:6046/), [Full Composition](http://127.0.0.1:6046/?path=/story/orgchart--full-composition). +- Chart Only [React](http://127.0.0.1:6045/react.html?data=100) / [Vanilla](http://127.0.0.1:6045/vanilla.html?data=100). +- Storybook 명령: `npm.cmd run storybook -- --port 6046 --host 127.0.0.1 --exact-port`. +- Chart Only 명령: `node node_modules/vite/bin/vite.js --config examples/org-chart-chart-only/vite.config.ts --port 6045 --strictPort --host 127.0.0.1`. + +## 공개 화면과 대표 동작 + +| 화면 | 실제 확인 결과 | +|---|---| +| Overview | PASS. 회사 로고 7개, 중복 로고 제목 제거, 조직·직위별 표, 관계사별 현황·총계 41명, 공통 Container·카드 간격/정렬 표시. 이미지 로딩 정상 | +| Department Colors | PASS. 대표자, 기존 6개 조직 Gradient, 그룹장/직원, 파견/휴직 미리보기, `홍 길 동`, 기존 카드/글꼴, 공통 미리보기 배경/Border 표시. Auto 회사색 7종과 별도 유지 | +| Full Composition | PASS. 상단 제목/기준일·인원 표, 중앙 대표자/허브·6조직/직원·연결선, 하단 파견/휴직·관계사 현황 표시. 협업 화살표 5개와 대표자 화살촉 1개 유지. Core 팝업·ESC 종료·카드 focus 복귀를 390/1440에서 확인 | +| Expanded OrgChart | PASS. 회사 복수 선택, 직원 수 20/100/300 옵션, 7회사×20명 실제 초기 표시. 한맥기술 하나를 선택한 100/300명 실제 카드 수와 현황 일치. TB/LR/Hybrid 전환 후 140명 보존, 연결선/그룹 제목, Zoom/Pan/Fit, HM Dialog·ESC/focus 복귀, 접기·펼치기 확인 | +| Chart Only React | PASS. 100명 대표 화면, 흰색 캔버스·외부 제목/설명/툴바 없음. 초기 Fit/가로·세로 중앙, 직접 휠 Zoom·Pan, Core 팝업/focus, 그룹 접기·펼치기, 동일 Props 리렌더 후 사용자 뷰 유지 | +| Chart Only Vanilla | PASS. 같은 100명 대표 화면과 같은 초기 Fit/중앙, 직접 휠 Zoom·Pan·Core 팝업/focus·접기·펼치기, 조작 이후 자동 Fit 없음 | + +Expanded 기본 Drag & Drop은 OFF다. 기존 plain wheel은 확대하지 않고 Ctrl+wheel은 확대한다. Fit 버튼 후 7회사 전체를 확인했다. 초기 Expanded는 기존 100% 탐색 모드라 일부 카드가 canvas 밖에 있으며 이는 Full Composition의 전체 축소 정책과 다르다. 초기 이미지 미로딩 106개는 lazy loading 상태였고, Fit 후 140/140 이미지 로딩을 확인했다(`expanded-assets.json`). + +Chart Only React/Vanilla 초기 배율은 둘 다 `0.513493800145879`, 가로 중앙 오차 0px, 세로 오차 약 -0.000019px다. 직접 휠 이후 `0.6271827437963237`, Pan 이후 Δx=60/Δy=-40px이며 팝업·접기·펼치기 후 배율/위치를 유지했다(100→96→100명). Fixed Chart Only는 기존 미지원 계약과 TypeError 코드 경계를 보존했으며 이번 작업에서 신규 개발하지 않았다. + +회사색·카드 SVG/PNG·Typography·데이터·배경·레이아웃 소스는 변경하지 않았다. Cell Border Only 실험은 정식 적용 대상에서 제외하며 이번 검증에도 적용하지 않았다. 현재 캡처에서 요구된 시각 구성과 자산은 확인했지만, 과거 승인 캡처와의 자동 픽셀 일치 검증이나 사용자 최종 디자인 승인은 **NOT VERIFIED / PENDING**이다. + +## Full Composition 390px 판정 + +**실제 내부 가로·세로 스크롤 오류: 재현 안 됨. 제품 수정: 없음.** + +| 실제 Viewport | 문서 scroll/client W·H | 콘텐츠 Wrapper Rect (x,y,w,h) | Stage 실제 Rect (x,y,w,h) | 배율 | +|---|---|---|---|---| +| 390×844 | 390/390 · 844/844 | 16,306,358,522 | 16,306,358,521.999 | 0.186458… (`data-scale=0.1865`) | +| 1440×900 | 1440/1440 · 900/900 | 120,290,1200,570 | 121.640,290,1196.720,570 | 0.623291… (`0.6233`) | +| 1920×1080 | 1920/1920 · 1080/1080 | 360,290,1200,750 | 360,290,1200,750 | 0.625 | + +Storybook iframe의 `#storybook-root`, Story Wrapper, Core host/composition/stage/viewport의 BoundingRect, scrollWidth/clientWidth, scrollHeight/clientHeight, overflow는 `measurements.json`에 저장했다. 검증 Viewport는 iframe 콘텐츠의 실제 크기이며 Storybook manager 전체 폭과 구분한다. + +390px 내부 viewport는 `scrollWidth/clientWidth=1920/1920`, `scrollHeight/clientHeight=599/599`, overflow X/Y=`visible`이다. 그 viewport에 실제 `scrollLeft/scrollTop` 최대 이동을 시도해도 0/0이다. composition Wrapper는 `1920/358`, `2800/522`, 기존 overflow=`hidden`이다. CSS transform은 레이아웃 박스를 축소하지 않으므로 원래 Stage 크기가 scroll 지표에 남는다. 이 수치를 표시 크기 또는 실제 사용자 스크롤로 오인하면 안 된다. 기존 overflow를 새로 추가하거나 콘텐츠 잘림을 숨기는 수정은 하지 않았다. + +390/1440에서 측정한 카드·조직 제목·협업 화살표·대표자 화살촉·SVG 연결선의 Wrapper 이탈/잘림은 0개다. Stage 가로 중앙 오차는 0px다. 390px에서는 Stage bottom 약 828px < Viewport 844px이며, 상단→중앙/중앙→하단 간격은 각각 약 152.958px다. 1440에서는 상단/중앙/하단 사각형의 실제 교차가 0개다. 파견·휴직과 관계사 footer도 구분되어 있다. 1920 기준 화면에서도 전체 구성이 콘텐츠 영역 안에 표시된다. 수정/오류가 없어 1280×1024, 1920×1440 추가 검사 조건은 발생하지 않았다. + +기존 `examples/org-chart-react/verify-boundaries.mjs`는 390×900의 다른 production fixture에서 `documentScrollWidth <= 390 && internalScrollLeft > 0`을 요구한다. 최신 Fixed Full Composition은 P9.5 비례 축소 정책상 **내부 가로 스크롤 없음**을 의도한다. 이번 실제 Full Story 390×844에서 동일 이동/판정 부분을 재현하면 `internalScrollLeft=0`, 구형 predicate=false다. 따라서 과거 실패는 현재 Full Composition에 적용할 수 없는 구형 내부 스크롤 기대값 때문이며, 사용자 화면의 스크롤 결함 증거가 아니다. 원본 스크립트·과거 실패 기록을 수정하지 않았고, 원본 전체 production fixture 검사를 이번 결과로 PASS 처리하지 않았다. + +390px는 전체 구성 보존과 중앙 배치 때문에 텍스트가 매우 작고 상하 여백이 크다. 기술적 잘림/겹침 오류는 없지만 이 표현의 가독성·여백은 사용자 최종 디자인 검토 사항이다. + +## 실행한 검증과 증거 + +로컬 증거: `test-results/visual-review-p10-10/` (Git ignored, 새 Clone에는 포함되지 않음). + +| 명령/검사 | 결과 | +|---|---| +| `npm.cmd ci --no-audit --no-fund` | PASS, lockfile 기반 설치 | +| `npm.cmd run build:packages` | PASS, 실행에 필요한 4 workspace 산출물 생성 | +| `node test-results/visual-review-p10-10/capture.mjs` | 8개 현재 화면/해상도 렌더·측정·캡처, 새 콘솔/runtime 오류 0 | +| `node test-results/visual-review-p10-10/interactions.mjs` | 최초 12/16 PASS, 최초 FAIL 4개를 `interactions.json`에 보존 | +| `node test-results/visual-review-p10-10/recheck.mjs` | 실패 4개 한정 재검사 4/4 PASS + 선택 회사 100/300명/오류 검사 2/2 PASS | +| `git diff --check` | PASS | +| 시작/최종 `git ls-remote origin refs/heads/feature/orgchart-package` | 같은 SHA | + +Playwright는 프로젝트 helper의 `ORGCHART_PLAYWRIGHT_MODULE`에 이미 설치된 bundled Playwright를 연결했고 `ORGCHART_CHROME=C:\Program Files\Google\Chrome\Application\chrome.exe`를 사용했다. 브라우저 도구 다운로드/의존성 업그레이드는 하지 않았다. Codex 내장 브라우저에서도 현재 Storybook manager/Full Composition을 열어 확인하고 검토 탭을 유지했다. + +최초 FAIL 원인과 후속 근거: + +- Full 1440: 상단 인원표·하단 파견 영역은 중앙 카드와 서로 다른 x 위치에 있다. 세로 범위만 비교한 최초 검사에서 겹침으로 오판했다. 실제 x/y 사각형 교차로 확인한 겹침 0개(`recheck.json`). +- React/Vanilla popup: Pan 후 화면 밖 카드를 Playwright로 클릭하면 도구가 자동 scrollIntoView를 실행하며 위치가 바뀐다. 화면 안 카드 클릭으로 확인한 popup/접기·펼치기는 기존 배율·Pan 유지. 제품 자동 Fit으로 판정하지 않는다. +- HM Dialog focus: ESC 직후 애니메이션/비동기 종료 완료 전에 focus를 읽었다. Dialog hidden 및 focus 복귀 완료를 기다린 재검사 PASS. + +상호작용/재검사 콘솔·runtime 오류는 모두 0건이다. 통과한 검사를 전체 반복하지 않았으며 제품 코드는 진단으로 변경하지 않았다. 범용 측정 JSON의 `outside` 값은 Full Composition containment 판정에 사용했고, 독립 Department specimen의 작은 status Wrapper나 탐색 모드 Expanded를 Full Stage 경계로 해석하지 않았다. + +필수 새 캡처: + +1. [Overview](../../test-results/visual-review-p10-10/overview.png) +2. [Department Colors](../../test-results/visual-review-p10-10/department-colors.png) +3. [Full Composition 1440×900](../../test-results/visual-review-p10-10/full-1440.png) +4. [Full Composition 390×844](../../test-results/visual-review-p10-10/full-390.png) +5. [Expanded 초기 화면](../../test-results/visual-review-p10-10/expanded.png), [Fit 후 7회사 전체](../../test-results/visual-review-p10-10/expanded-fit.png) +6. [Chart Only React](../../test-results/visual-review-p10-10/chart-only-react.png) +7. [Chart Only Vanilla](../../test-results/visual-review-p10-10/chart-only-vanilla.png) + +추가 증거: `full-1920.png`, 390/1440 Core popup, React/Vanilla popup, 회사 선택, Expanded TB/LR/Hybrid, 애니메이션 종료 후 `expanded-hm-dialog.png`. 기존 캡처를 이번 증거로 제출하지 않았다. + +## 기존 미해결 상태와 승인 + +- Guard: 기존 TOKEN-001 FAIL 5 / NOT VERIFIED 9 / 분석기 회귀 94/94 기존 PASS 보존. Guard 재실행·검사기 수정·결과 덮어쓰기 없음. 기존 styleEvidence STATIC PASS / RUNTIME NOT VERIFIED / DESIGN_INTENT PASS / FINAL NOT VERIFIED와 결합 styleVerification 미생성을 그대로 유지. +- 보안: 기존 개발 High 7 / 고유 Advisory 1 / 과거 운영 감사 0 유지. 신규 감사 결과 또는 현재 의존 그래프의 무취약 보증으로 표현하지 않는다. +- 접근성: 기존 삼안 제목 약 2.64:1, 장헌산업 약 4.29:1. 회사색·흰색 그룹 제목 변경 없음. 기존 정책 승인 대기. +- 사용자 최종 디자인 승인: 4 Story와 React/Vanilla Chart Only 모두 PENDING. 특히 390px 축소 가독성·큰 여백과 1440px 배치/간격 확인 필요. 이번 브라우저 PASS를 디자인 승인으로 처리하지 않는다. +- NOT VERIFIED: 과거 baseline 픽셀 동일성, 실제 모바일 기기·touch/스크린리더, Safari/Firefox, 모든 회사×100/300명×레이아웃 조합, 1280×1024·1920×1440, 기존 20/100/500명 전체 51개 회귀 재실행, 전체 Typecheck/Guard/npm 신규 감사. 직접 영향이 없는 기존 검증을 반복하지 않은 항목들이다. + +## 작업 시간 (KST) + +- Started At: 2026-10-13 21:15:18 KST +- Completed At: 2026-10-13 21:27:30 KST +- Actual Duration: 12분 12초 +- Estimated Duration: 최초 약 25–40분 +- Variance: 최초 예상보다 12분 48초–27분 48초 짧음 diff --git a/examples/org-chart-chart-only/README.md b/examples/org-chart-chart-only/README.md new file mode 100644 index 0000000..7382443 --- /dev/null +++ b/examples/org-chart-chart-only/README.md @@ -0,0 +1,42 @@ +# OrgChart Chart Only example + +Standalone Vanilla and React consumers of the public Core option +`presentationMode: 'chart-only'` (Auto only). Each page is one white canvas that +fills the window: no title, as-of date, summary, toolbar or guidance. The chart +opens fitted and centered, and the plain mouse wheel zooms at the pointer. +Not a Storybook page. + +```bash +npm run build:org-chart +``` + +```bash +npm run build:ui +``` + +```bash +npm run build:org-chart-react +``` + +```bash +npx vite --config examples/org-chart-chart-only/vite.config.ts +``` + +Pages (port 6041): `vanilla.html`, `react.html` (default entry, Core popup) and +`react-hm-dialog.html` (optional `/hm-dialog` entry with the HM theme). +Query: `data=20|100|500` (20/100 are the existing multi-company fixtures, 500 is a +virtual single-company dataset with 6 divisions and 5·2·4·1·3·2 teams), +`layout=tb|lr|hybrid`, `embed=1` (verification only: a 480px canvas inside a +scrolling page). + +Browser verification against the running server. Playwright and a browser are +resolved through `ORGCHART_PLAYWRIGHT_MODULE` / `ORGCHART_CHROME` as in the other +OrgChart checks; evidence goes to the ignored `test-results/org-chart-chart-only/`. + +```bash +node examples/org-chart-chart-only/verify.mjs +``` + +`verify-storybook.mjs` checks the four public OrgChart Storybook menus against +`STORYBOOK_URL` (default `http://127.0.0.1:6044`) and, with `BASELINE_URL`, +compares DOM and pixels with a baseline Storybook. diff --git a/examples/org-chart-chart-only/chart-only.css b/examples/org-chart-chart-only/chart-only.css new file mode 100644 index 0000000..333e27d --- /dev/null +++ b/examples/org-chart-chart-only/chart-only.css @@ -0,0 +1,6 @@ +/* Page layout only: the chart canvas fills the window. */ +html, body { height: 100%; margin: 0; background: #ffffff; } +#chart { height: 100%; } +/* Verification only: page content above and below a bounded canvas. */ +body.embed { height: auto; } +body.embed #chart { height: 480px; margin: 600px 0 1400px; } diff --git a/examples/org-chart-chart-only/data.ts b/examples/org-chart-chart-only/data.ts new file mode 100644 index 0000000..65695f6 --- /dev/null +++ b/examples/org-chart-chart-only/data.ts @@ -0,0 +1,54 @@ +import type { Employee, OrgChartDataset, Organization } from '@hm-design/org-chart'; +import small from '../org-chart/data-expanded-small.json'; +import medium from '../org-chart/data-expanded-medium.json'; + +/** Deterministic single-company dataset: 6 divisions with 5·2·4·1·3·2 teams. + * Virtual names only; no live data. Leaders are counted in `total`. */ +export function createSamanDataset(total = 500): OrgChartDataset { + const organizations: Organization[] = []; + const employees: Employee[] = []; + const add = (organizationId: string, position: string) => { + const id = `saman-e${String(employees.length + 1).padStart(3, '0')}`; + employees.push({ id, organizationId, affiliationId: 'saman', name: `가상직원 ${String(employees.length + 1).padStart(3, '0')}`, position, jobTitle: null, profileImage: null, workStatus: 'active' }); + return id; + }; + const org = (id: string, parentId: string | null, name: string, type: Organization['type'], displayOrder: number) => { + const node: Organization = { id, companyId: 'saman', parentId, name, type, leaderId: null, displayOrder }; + organizations.push(node); + return node; + }; + const root = org('saman-root', null, '삼안', 'root', 0); + root.leaderId = add(root.id, '대표이사'); + const teams: Organization[] = []; + [5, 2, 4, 1, 3, 2].forEach((teamCount, d) => { + const division = org(`saman-d${d + 1}`, root.id, `가상 ${d + 1}본부`, 'division', d); + division.leaderId = add(division.id, '본부장'); + for (let t = 0; t < teamCount; t++) { + const team = org(`saman-d${d + 1}-t${t + 1}`, division.id, `가상 ${d + 1}-${t + 1}팀`, 'team', t); + team.leaderId = add(team.id, '팀장'); + teams.push(team); + } + }); + let cursor = 0; + while (employees.length < total) add(teams[cursor++ % teams.length]!.id, ['사원', '선임', '책임', '수석'][cursor % 4]!); + return { + schemaVersion: '0.1-draft', organizations, employees, affiliations: [{ id: 'saman', name: '삼안' }], relations: [], + presentation: { title: '삼안 조직도 · 가상 데이터', description: null, hubText: null, + groups: { showEmployees: true, showSummary: false, showDispatched: false, showOnLeave: false, showAffiliations: false }, organizationOrder: [] }, + metadata: { asOfDate: '2026-10-13', dataVersion: `chart-only-saman-${total}`, updateNote: '가상 데이터 · 운영 정보 아님' }, + } as OrgChartDataset; +} + +export const datasets: Record OrgChartDataset> = { + '20': () => structuredClone(small) as OrgChartDataset, + '100': () => structuredClone(medium) as OrgChartDataset, + '500': () => createSamanDataset(500), +}; + +export function readQuery() { + const query = new URLSearchParams(location.search); + const data = datasets[query.get('data') ?? ''] ? query.get('data')! : '20'; + const layout = (['tb', 'lr', 'hybrid'] as const).find(type => type === query.get('layout')) ?? 'tb'; + // embed=1 is a verification layout: a bounded canvas inside a scrolling page. + return { data, layout, embed: query.get('embed') === '1' }; +} diff --git a/examples/org-chart-chart-only/hm-theme.css b/examples/org-chart-chart-only/hm-theme.css new file mode 100644 index 0000000..840ff0b --- /dev/null +++ b/examples/org-chart-chart-only/hm-theme.css @@ -0,0 +1,4 @@ +/* Optional HM Dialog integration only; the default entry needs none of this. */ +@import "@hm-design/ui/theme.css"; +@import "tailwindcss" source("./"); +@import "@hm-design/ui/tailwind.css"; diff --git a/examples/org-chart-chart-only/react-hm-dialog.html b/examples/org-chart-chart-only/react-hm-dialog.html new file mode 100644 index 0000000..fa0554a --- /dev/null +++ b/examples/org-chart-chart-only/react-hm-dialog.html @@ -0,0 +1,13 @@ + + + + + + + OrgChart Chart Only · React HM Dialog + + +
+ + + diff --git a/examples/org-chart-chart-only/react-hm-dialog.tsx b/examples/org-chart-chart-only/react-hm-dialog.tsx new file mode 100644 index 0000000..9ca5ff8 --- /dev/null +++ b/examples/org-chart-chart-only/react-hm-dialog.tsx @@ -0,0 +1,20 @@ +import { StrictMode, useMemo } from 'react'; +import { createRoot } from 'react-dom/client'; +import { OrgChart } from '@hm-design/org-chart-react/hm-dialog'; +import { JsonOrgChartDataSource } from '@hm-design/org-chart'; +import engineUrl from '@hm-design/org-chart/engine.css?url'; +import coreUrl from '@hm-design/org-chart/style.css?url'; +import { datasets, readQuery } from './data'; +import './hm-theme.css'; +import './chart-only.css'; + +const { data, layout } = readQuery(); +const stylesheetUrls = { engine: engineUrl, core: coreUrl }; + +function ChartOnlyPage() { + const dataSource = useMemo(() => new JsonOrgChartDataSource(datasets[data]!()), []); + return ; +} + +createRoot(document.getElementById('chart')!).render(); diff --git a/examples/org-chart-chart-only/react.html b/examples/org-chart-chart-only/react.html new file mode 100644 index 0000000..36a0233 --- /dev/null +++ b/examples/org-chart-chart-only/react.html @@ -0,0 +1,13 @@ + + + + + + + OrgChart Chart Only · React + + +
+ + + diff --git a/examples/org-chart-chart-only/react.tsx b/examples/org-chart-chart-only/react.tsx new file mode 100644 index 0000000..1a7cd24 --- /dev/null +++ b/examples/org-chart-chart-only/react.tsx @@ -0,0 +1,31 @@ +import { StrictMode, useMemo, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { OrgChart } from '@hm-design/org-chart-react'; +import { JsonOrgChartDataSource } from '@hm-design/org-chart'; +import engineUrl from '@hm-design/org-chart/engine.css?url'; +import coreUrl from '@hm-design/org-chart/style.css?url'; +import { datasets, readQuery } from './data'; +import './chart-only.css'; + +const { data, layout, embed } = readQuery(); +if (embed) document.body.classList.add('embed'); +const stylesheetUrls = { engine: engineUrl, core: coreUrl }; +let replace: (key: string) => void = () => {}; +let rerender: () => void = () => {}; + +function ChartOnlyPage() { + const [key, setKey] = useState(data); + const [, setTick] = useState(0); + replace = setKey; + rerender = () => setTick(value => value + 1); + const dataSource = useMemo(() => new JsonOrgChartDataSource(datasets[key]!()), [key]); + // Inline option literals are intentional: equal values must not refit on re-render. + return ; +} + +createRoot(document.getElementById('chart')!).render(); +// Verification seam only. +Object.assign(window, { replaceData: (key: string) => replace(key), rerenderPage: () => rerender() }); diff --git a/examples/org-chart-chart-only/tsconfig.json b/examples/org-chart-chart-only/tsconfig.json new file mode 100644 index 0000000..70bf37e --- /dev/null +++ b/examples/org-chart-chart-only/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.app.json", + "compilerOptions": { "composite": false, "noEmit": true, "types": ["vite/client"], "resolveJsonModule": true, "tsBuildInfoFile": "../../_local/org-chart-chart-only.tsbuildinfo" }, + "include": ["*.ts", "*.tsx"] +} diff --git a/examples/org-chart-chart-only/vanilla.html b/examples/org-chart-chart-only/vanilla.html new file mode 100644 index 0000000..34af260 --- /dev/null +++ b/examples/org-chart-chart-only/vanilla.html @@ -0,0 +1,13 @@ + + + + + + + OrgChart Chart Only · Vanilla + + +
+ + + diff --git a/examples/org-chart-chart-only/vanilla.ts b/examples/org-chart-chart-only/vanilla.ts new file mode 100644 index 0000000..78a832a --- /dev/null +++ b/examples/org-chart-chart-only/vanilla.ts @@ -0,0 +1,23 @@ +import { createOrgChart, JsonOrgChartDataSource } from '@hm-design/org-chart'; +import engineUrl from '@hm-design/org-chart/engine.css?url'; +import coreUrl from '@hm-design/org-chart/style.css?url'; +import { datasets, readQuery } from './data'; +import './chart-only.css'; + +const { data, layout, embed } = readQuery(); +if (embed) document.body.classList.add('embed'); +const chart = createOrgChart({ + container: document.getElementById('chart')!, + dataSource: new JsonOrgChartDataSource(datasets[data]!()), + stylesheetUrls: { engine: engineUrl, core: coreUrl }, + displayMode: 'readOnly', + layoutMode: 'auto', + autoLayout: { type: layout }, + // Chart Only implies initialView "fit" and wheelZoom "direct". + presentationMode: 'chart-only', + // Regression fixture: Fit below the manual zoom floor, without huge data. + zoom: new URLSearchParams(location.search).has('verify-min') ? { min: 0.8 } : undefined, +}); +void chart.mount(); +// Verification seam only: replace the whole dataset through the public handle. +Object.assign(window, { orgChart: chart, replaceData: (key: string) => chart.update(datasets[key]!()) }); diff --git a/examples/org-chart-chart-only/verify-storybook.mjs b/examples/org-chart-chart-only/verify-storybook.mjs new file mode 100644 index 0000000..89d2d0d --- /dev/null +++ b/examples/org-chart-chart-only/verify-storybook.mjs @@ -0,0 +1,154 @@ +// Storybook regression for the Chart Only change. Compares this worktree's +// Storybook (STORYBOOK_URL) with a baseline Storybook (BASELINE_URL, optional) +// and checks that Expanded keeps its controls and Ctrl/⌘ + wheel policy. +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-chart-only/storybook'); +await mkdir(output, { recursive: true }); +const target = process.env.STORYBOOK_URL ?? 'http://127.0.0.1:6044'; +const baseline = process.env.BASELINE_URL; +const browser = await launchChromium(); +const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 }); +const results = []; +const consoleErrors = []; +const wheelOnly = process.argv.includes('--wheel-only'); +const record = (name, pass, detail) => { results.push({ name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}`, detail === undefined ? '' : JSON.stringify(detail)); }; +const check = async (name, fn) => { try { record(name, true, await fn()); } catch (error) { record(name, false, error.message); } }; +const stories = ['orgchart--logo-personnel-summary', 'orgchart--department-colors', 'orgchart--full-composition', 'orgchart--expanded-org-chart']; + +async function open(origin, id) { + const page = await context.newPage(); + page.on('console', message => { if (message.type() === 'error') consoleErrors.push({ origin, id, text: message.text() }); }); + page.on('pageerror', error => consoleErrors.push({ origin, id, text: String(error) })); + await page.goto(`${origin}/iframe.html?id=${id}&viewMode=story`); + await page.waitForFunction(() => { + const hosts = [...document.querySelectorAll('.hm-orgchart-host')]; + // Overview renders summaries without an OrgChart host. + return document.getElementById('storybook-root')?.childElementCount && hosts.every(host => host.shadowRoot?.querySelector('.hm-orgchart-shell')?.getAttribute('aria-busy') === 'false'); + }, null, { timeout: 60000 }); + await page.evaluate(() => document.fonts.ready); + await page.waitForTimeout(800); + return page; +} +/** Story DOM plus every OrgChart shadow root, without instance/React IDs. */ +const snapshot = page => page.evaluate(() => [document.getElementById('storybook-root').innerHTML, + ...[...document.querySelectorAll('.hm-orgchart-host')].map(host => host.shadowRoot.innerHTML)].join('\n') + .replace(/hm-orgchart-\d+/g, 'hm-orgchart-N').replace(/«[^»]*»|_r_[0-9a-z]+_/g, 'react-id')); + +async function pixelDiff(a, b) { + const page = await context.newPage(); + const result = await page.evaluate(async ([ua, ub]) => { + const load = src => new Promise(resolve => { const image = new Image(); image.onload = () => resolve(image); image.src = src; }); + const [ia, ib] = await Promise.all([load(ua), load(ub)]); + if (ia.width !== ib.width || ia.height !== ib.height) return { size: [ia.width, ia.height, ib.width, ib.height], diffPixels: Infinity }; + const read = image => { const canvas = document.createElement('canvas'); canvas.width = image.width; canvas.height = image.height; const c = canvas.getContext('2d'); c.drawImage(image, 0, 0); return c.getImageData(0, 0, image.width, image.height).data; }; + const da = read(ia), db = read(ib); let diffPixels = 0, maxDelta = 0; const box = [Infinity, Infinity, -1, -1]; + for (let i = 0; i < da.length; i += 4) { + const delta = Math.max(Math.abs(da[i] - db[i]), Math.abs(da[i + 1] - db[i + 1]), Math.abs(da[i + 2] - db[i + 2])); + if (!delta) continue; + diffPixels++; maxDelta = Math.max(maxDelta, delta); + const p = i / 4, x = p % ia.width, y = Math.floor(p / ia.width); + box[0] = Math.min(box[0], x); box[1] = Math.min(box[1], y); box[2] = Math.max(box[2], x); box[3] = Math.max(box[3], y); + } + return { size: [ia.width, ia.height], diffPixels, maxDelta, box }; + }, [`data:image/png;base64,${a.toString('base64')}`, `data:image/png;base64,${b.toString('base64')}`]); + await page.close(); + return result; +} + +const index = await (await fetch(`${target}/index.json`)).json(); +if (!wheelOnly) await check('Storybook OrgChart public menus', () => { + const visible = Object.values(index.entries).filter(entry => entry.title.startsWith('OrgChart/') && !(entry.tags ?? []).includes('internal')).map(entry => `${entry.title} › ${entry.name}`); + assert.deepEqual(visible.sort(), ['OrgChart/Department Colors › Department Colors', 'OrgChart/Expanded OrgChart › Expanded OrgChart', 'OrgChart/Full Composition › Full Composition', 'OrgChart/Overview › Overview']); + return visible; +}); + +for (const id of wheelOnly ? ['orgchart--expanded-org-chart'] : stories) { + const page = await open(target, id); + const buffer = await page.screenshot({ path: path.join(output, `${id}.png`), fullPage: true }); + const dom = await snapshot(page); + if (baseline) { + const reference = await open(baseline, id); + const referenceBuffer = await reference.screenshot({ path: path.join(output, `${id}.baseline.png`), fullPage: true }); + const referenceDom = await snapshot(reference); + // Pixel identity is reported, not assumed: when it differs, a second + // baseline capture shows whether the baseline itself is deterministic. + const pixels = buffer.equals(referenceBuffer) ? { identical: true } : { + target: await pixelDiff(buffer, referenceBuffer), + baselineSelf: await pixelDiff(referenceBuffer, await (await open(baseline, id)).screenshot({ fullPage: true })), + }; + await check(`${id} matches baseline (DOM + pixels)`, () => { + assert.equal(dom, referenceDom, 'shadow DOM differs'); + // Identical DOM with a handful of ±1 channel pixels is rasterization + // jitter of the scaled Fixed stage, not a design change. + if (!pixels.identical) assert.ok(pixels.target.diffPixels <= 50 && pixels.target.maxDelta <= 2, `pixel diff ${JSON.stringify(pixels)}`); + return { domLength: dom.length, pixels }; + }); + await reference.close(); + } + if (id === 'orgchart--expanded-org-chart') { + const state = () => page.evaluate(() => { + const sr = document.querySelector('.hm-orgchart-host').shadowRoot; + const vp = sr.querySelector('.hm-orgchart-viewport'); + return { scale: +vp.dataset.scale, x: +vp.dataset.panX, y: +vp.dataset.panY, + heading: sr.querySelector('.hm-orgchart-title')?.textContent, date: sr.querySelector('.hm-orgchart-date')?.textContent, + selection: sr.querySelector('.hm-orgchart-expanded-selection')?.textContent, + toolbar: [...sr.querySelectorAll('.hm-orgchart-expanded-toolbar > *')].map(e => e.textContent), + feedback: sr.querySelector('.hm-orgchart-move-feedback')?.textContent, presentation: sr.querySelector('.hm-orgchart-shell').dataset.presentationMode ?? null }; + }); + const before = await state(); + if (!wheelOnly) await check('Expanded keeps heading, date, summary, toolbar and guidance', () => { + assert.equal(before.presentation, null); + assert.ok(before.heading && before.date?.startsWith('기준일') && before.selection?.startsWith('선택')); + assert.deepEqual(before.toolbar.slice(0, 4), ['확대', '축소', '100% 복원', 'Fit to View']); + assert.match(before.toolbar[4], /^\d+%$/); + assert.equal(before.toolbar[5], '드래그·방향키 이동 · Ctrl/⌘ + 휠 확대'); + assert.match(before.feedback, /Drag & Drop OFF/); + return before; + }); + const canvas = page.locator('.hm-orgchart-viewport'); + await canvas.evaluate(element => element.scrollIntoView({ block: 'center', behavior: 'instant' })); + const box = await canvas.boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.wheel(0, -100); + await page.waitForTimeout(150); + const plain = await state(); + const plainScroll = await page.evaluate(() => scrollY); + // A plain wheel scrolls the story page; aim at the canvas again. + await canvas.evaluate(element => element.scrollIntoView({ block: 'center', behavior: 'instant' })); + const moved = await canvas.boundingBox(); + await page.mouse.move(moved.x + moved.width / 2, moved.y + moved.height / 2); + await page.keyboard.down('Control'); await page.mouse.wheel(0, -100); await page.keyboard.up('Control'); + await page.waitForTimeout(150); + const ctrl = await state(); + await check('Expanded plain wheel does not zoom; Ctrl + wheel zooms', () => { + assert.equal(plain.scale, before.scale); + assert.ok(ctrl.scale > plain.scale); + return { before: before.scale, plain: plain.scale, ctrl: +ctrl.scale.toFixed(4), pageScrolledByPlainWheel: plainScroll }; + }); + if (!wheelOnly) { + await page.getByRole('button', { name: 'Fit to View' }).click(); + const fitted = await state(); + await check('Expanded Fit to View button still works', () => { assert.ok(fitted.scale <= 1 && fitted.scale !== ctrl.scale); return { scale: +fitted.scale.toFixed(4) }; }); + } + } + if (id === 'orgchart--full-composition') { + await check('Full Composition keeps Fixed composition', () => page.evaluate(() => { + const shells = [...document.querySelectorAll('.hm-orgchart-host')].map(host => host.shadowRoot.querySelector('.hm-orgchart-shell')); + if (!shells.length || shells.some(shell => shell.dataset.presentationMode)) throw new Error('unexpected presentation mode'); + return shells.map(shell => ({ layout: shell.dataset.layoutMode, heading: !!shell.querySelector('.hm-orgchart-heading'), stage: !!shell.querySelector('.hm-orgchart-stage') })); + })); + } + await page.close(); +} +await browser.close(); +const failed = results.filter(result => !result.pass); +await writeFile(path.join(output, wheelOnly ? 'results-wheel.json' : 'results.json'), JSON.stringify({ target, baseline, results, consoleErrors }, null, 2)); +console.log(`\n${results.length - failed.length}/${results.length} PASS · console errors ${consoleErrors.length}`); +for (const error of consoleErrors) console.log('console:', error.origin, error.id, error.text); +if (failed.length || consoleErrors.length) process.exitCode = 1; diff --git a/examples/org-chart-chart-only/verify.mjs b/examples/org-chart-chart-only/verify.mjs new file mode 100644 index 0000000..026d133 --- /dev/null +++ b/examples/org-chart-chart-only/verify.mjs @@ -0,0 +1,400 @@ +// Chart Only browser verification against a running preview server: +// npx vite --config examples/org-chart-chart-only/vite.config.ts +// node examples/org-chart-chart-only/verify.mjs +// CHART_ONLY_URL (default http://127.0.0.1:6041) selects the server. +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-chart-only'); +await mkdir(output, { recursive: true }); +const base = process.env.CHART_ONLY_URL ?? 'http://127.0.0.1:6041'; +const browser = await launchChromium(); +const results = []; +const consoleErrors = []; +const record = (id, name, pass, detail) => { results.push({ id, name, pass, detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${name}`, detail === undefined ? '' : JSON.stringify(detail)); }; +const check = (id, name, fn) => async (...args) => { + try { const detail = await fn(...args); record(id, name, true, detail); } + catch (error) { record(id, name, false, error.message); } +}; + +/** All geometry in CSS pixels relative to the canvas; bounds include connectors. */ +const measure = page => page.evaluate(() => { + const sr = document.querySelector('.hm-orgchart-host').shadowRoot; + const vp = sr.querySelector('.hm-orgchart-viewport'); const box = vp.getBoundingClientRect(); + let l = Infinity, t = Infinity, r = -Infinity, b = -Infinity; + for (const e of sr.querySelectorAll('.hm-orgchart-forest .hm-orgchart-organization, .hm-orgchart-forest .hm-orgchart-organization-connectors path')) { + const x = e.getBoundingClientRect(); if (!x.width && !x.height) continue; + l = Math.min(l, x.left); t = Math.min(t, x.top); r = Math.max(r, x.right); b = Math.max(b, x.bottom); + } + const css = getComputedStyle; + const feedback = sr.querySelector('.hm-orgchart-move-feedback')?.getBoundingClientRect(); + return { + canvas: { left: box.left, top: box.top, width: box.width, height: box.height }, + scale: +vp.dataset.scale, x: +vp.dataset.panX, y: +vp.dataset.panY, pending: vp.dataset.placementPending ?? null, + dragDrop: vp.dataset.dragDropEnabled, + margins: { l: l - box.left, t: t - box.top, r: box.right - r, b: box.bottom - b }, + centerErrX: (l + r) / 2 - (box.left + box.width / 2), centerErrY: (t + b) / 2 - (box.top + box.height / 2), + inside: l >= box.left - 0.5 && t >= box.top - 0.5 && r <= box.right + 0.5 && b <= box.bottom + 0.5, + cards: sr.querySelectorAll('.hm-orgchart-employee').length, + organizations: sr.querySelectorAll('.hm-orgchart-organization').length, + connectors: sr.querySelectorAll('.hm-orgchart-forest .edge, .hm-orgchart-organization-connectors path, .hm-orgchart-employee-connectors path').length, + external: { + heading: !!sr.querySelector('.hm-orgchart-heading'), date: !!sr.querySelector('.hm-orgchart-date'), + selection: !!sr.querySelector('.hm-orgchart-expanded-selection'), toolbar: !!sr.querySelector('.hm-orgchart-expanded-toolbar'), + zoomRatio: !!sr.querySelector('.hm-orgchart-expanded output'), + feedbackVisible: !!feedback && feedback.width > 1 && feedback.height > 1, + siblingsOfCanvas: [...sr.querySelector('.hm-orgchart-expanded').children].filter(e => e !== vp && e.getBoundingClientRect().width > 1).map(e => e.className), + }, + colors: { page: css(document.body).backgroundColor, shell: css(sr.querySelector('.hm-orgchart-shell')).backgroundColor, content: css(sr.querySelector('.hm-orgchart-expanded')).backgroundColor, canvas: css(vp).backgroundColor, canvasBorder: css(vp).borderTopWidth, contentPadding: css(sr.querySelector('.hm-orgchart-expanded')).paddingTop }, + companyColors: [...new Set([...sr.querySelectorAll('.node[data-company-id]')].map(n => `${n.dataset.companyId}:${n.style.getPropertyValue('--hm-orgchart-expanded-primary')}`))], + pageOverflowX: document.documentElement.scrollWidth > innerWidth, + }; +}); + +async function ready(page) { + await page.waitForFunction(() => { + const sr = document.querySelector('.hm-orgchart-host')?.shadowRoot; + const vp = sr?.querySelector('.hm-orgchart-viewport'); + return vp && !vp.dataset.placementPending && sr.querySelector('.hm-orgchart-employee') && sr.querySelector('.hm-orgchart-shell')?.getAttribute('aria-busy') === 'false'; + }, null, { timeout: 30000 }); + // Two settled frames: no later Fit may move the view. + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); +} + +/** Samples the visible transform from navigation until settle; repeated + * visible Fits would show up as more than one distinct transform. */ +async function visibleTransforms(page, ms = 1500) { + return page.evaluate(ms => new Promise(resolve => { + const seen = []; const start = performance.now(); + const tick = () => { + const vp = document.querySelector('.hm-orgchart-host')?.shadowRoot?.querySelector('.hm-orgchart-viewport'); + const forest = vp?.querySelector('.hm-orgchart-forest'); + if (forest && !vp.dataset.placementPending) { const value = forest.style.transform; if (seen.at(-1) !== value) seen.push(value); } + if (performance.now() - start < ms) requestAnimationFrame(tick); else resolve(seen); + }; + tick(); + }), ms); +} + +async function open(context, url) { + const page = await context.newPage(); + page.on('console', message => { if (message.type() === 'error') consoleErrors.push({ url, text: message.text() }); }); + page.on('pageerror', error => consoleErrors.push({ url, text: String(error) })); + await page.goto(url); + return page; +} + +const near = (a, b, tolerance) => Math.abs(a - b) <= tolerance; +const desktop = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 }); + +// A/B: output and initial Fit for both consumers and all sizes. +for (const kind of ['vanilla', 'react']) { + for (const size of ['20', '100', '500']) { + const url = `${base}/${kind}.html?data=${size}`; + const page = await open(desktop, url); + const transforms = await visibleTransforms(page); + await ready(page); + const m = await measure(page); + await page.screenshot({ path: path.join(output, `${kind}-${size}-desktop.png`) }); + const label = `${kind} ${size}`; + await check('A', `${label} Chart Only output`, () => { + assert.deepEqual(Object.entries(m.external).filter(([key, value]) => key === 'siblingsOfCanvas' ? value.length : value), []); + assert.equal(m.colors.canvas, 'rgb(255, 255, 255)'); assert.equal(m.colors.content, 'rgb(255, 255, 255)'); assert.equal(m.colors.shell, 'rgb(255, 255, 255)'); + assert.equal(m.colors.canvasBorder, '0px'); assert.equal(m.colors.contentPadding, '0px'); + assert.ok(m.cards > 0 && m.organizations > 0 && m.connectors > 0); + assert.ok(near(m.canvas.width, 1440, 1) && near(m.canvas.height, 900, 1), 'canvas fills window'); + return { cards: m.cards, organizations: m.organizations, connectors: m.connectors, colors: m.companyColors }; + })(); + await check('B', `${label} initial Fit and center`, () => { + assert.ok(m.inside, 'bounds inside canvas'); + assert.ok(Math.abs(m.centerErrX) <= 1 && Math.abs(m.centerErrY) <= 1, `center error ${m.centerErrX}, ${m.centerErrY}`); + assert.ok(m.scale <= 1); + assert.ok(Math.min(m.margins.l, m.margins.r, m.margins.t, m.margins.b) >= 15.5, 'edge margin'); + assert.equal(transforms.length, 1, `visible transforms ${JSON.stringify(transforms)}`); + return { scale: +m.scale.toFixed(4), centerErrX: +m.centerErrX.toFixed(3), centerErrY: +m.centerErrY.toFixed(3), margins: Object.fromEntries(Object.entries(m.margins).map(([k, v]) => [k, +v.toFixed(2)])), visibleTransforms: transforms.length }; + })(); + await page.close(); + } +} + +// LR / Hybrid keep their layout and still fit. +for (const layout of ['lr', 'hybrid']) { + const page = await open(desktop, `${base}/vanilla.html?data=100&layout=${layout}`); + await ready(page); + const m = await measure(page); + await page.screenshot({ path: path.join(output, `vanilla-100-${layout}.png`) }); + await check('B', `vanilla 100 ${layout} Fit and center`, () => { + assert.ok(m.inside && Math.abs(m.centerErrX) <= 1 && Math.abs(m.centerErrY) <= 1, JSON.stringify(m.margins)); + return { scale: +m.scale.toFixed(4), centerErrX: +m.centerErrX.toFixed(3), centerErrY: +m.centerErrY.toFixed(3) }; + })(); + await page.close(); +} + +// C/D: wheel, Pan, popup, groups and replacement on the 500-person chart. +for (const kind of ['vanilla', 'react']) { + const page = await open(desktop, `${base}/${kind}.html?data=500`); + await ready(page); + const fitted = await measure(page); + const card = page.locator('.hm-orgchart-employee').nth(30); + const cardBox = await card.boundingBox(); + const point = { x: cardBox.x + cardBox.width / 2, y: cardBox.y + cardBox.height / 2 }; + const local = m => ({ x: (point.x - m.canvas.left - m.x) / m.scale, y: (point.y - m.canvas.top - m.y) / m.scale }); + await page.mouse.move(point.x, point.y); + await page.mouse.wheel(0, -100); + await page.waitForTimeout(50); + const zoomedIn = await measure(page); + await check('C', `${kind} plain wheel up zooms in (no Ctrl/⌘)`, () => { + assert.ok(zoomedIn.scale > fitted.scale * 1.1, `${fitted.scale} -> ${zoomedIn.scale}`); + return { from: +fitted.scale.toFixed(4), to: +zoomedIn.scale.toFixed(4) }; + })(); + await check('C', `${kind} zoom anchors at pointer`, () => { + const before = local(fitted), after = local(zoomedIn); + const error = Math.hypot(before.x - after.x, before.y - after.y) * zoomedIn.scale; + const box = { x: cardBox.x, y: cardBox.y }; + assert.ok(error < 0.5, `screen drift ${error}`); + return { screenDriftPx: +error.toFixed(4), card: box }; + })(); + await page.mouse.wheel(0, 100); + await page.waitForTimeout(50); + const zoomedOut = await measure(page); + await check('C', `${kind} plain wheel down zooms out`, () => { + assert.ok(zoomedOut.scale < zoomedIn.scale); + return { from: +zoomedIn.scale.toFixed(4), to: +zoomedOut.scale.toFixed(4) }; + })(); + for (let i = 0; i < 40; i++) await page.mouse.wheel(0, -120); + await page.waitForTimeout(50); + const maximum = (await measure(page)).scale; + for (let i = 0; i < 80; i++) await page.mouse.wheel(0, 120); + await page.waitForTimeout(50); + const minimum = (await measure(page)).scale; + await check('C', `${kind} min/max limits`, () => { + assert.equal(maximum, 3); + assert.ok(near(minimum, Math.min(0.05, fitted.scale), 1e-9), `min ${minimum} fit ${fitted.scale}`); + return { max: maximum, min: +minimum.toFixed(4), fitScale: +fitted.scale.toFixed(4) }; + })(); + // Back to a readable zoom around the same card, then Pan from empty space. + await page.mouse.move(point.x, point.y); + for (let i = 0; i < 6; i++) await page.mouse.wheel(0, -100); + await page.waitForTimeout(50); + const beforePan = await measure(page); + const empty = { x: beforePan.canvas.left + 6, y: beforePan.canvas.top + beforePan.canvas.height - 6 }; + const hit = await page.evaluate(({ x, y }) => { const sr = document.querySelector('.hm-orgchart-host').shadowRoot; const e = sr.elementFromPoint(x, y); return e?.closest('.hm-orgchart-employee, button') ? 'control' : e?.className ?? ''; }, empty); + await page.mouse.move(empty.x, empty.y); await page.mouse.down(); + await page.mouse.move(empty.x + 60, empty.y - 40, { steps: 4 }); await page.mouse.move(empty.x + 120, empty.y - 80, { steps: 4 }); + await page.mouse.up(); + const afterPan = await measure(page); + await check('C', `${kind} Pan after Zoom`, () => { + assert.notEqual(hit, 'control'); + assert.ok(near(afterPan.x - beforePan.x, 120, 0.5) && near(afterPan.y - beforePan.y, -80, 0.5), `${afterPan.x - beforePan.x}, ${afterPan.y - beforePan.y}`); + assert.equal(afterPan.scale, beforePan.scale); + return { dx: afterPan.x - beforePan.x, dy: afterPan.y - beforePan.y, scale: +afterPan.scale.toFixed(4) }; + })(); + await page.waitForTimeout(600); + const settled = await measure(page); + await check('C', `${kind} no automatic Fit after user Zoom/Pan`, () => { + assert.deepEqual([settled.scale, settled.x, settled.y], [afterPan.scale, afterPan.x, afterPan.y]); + })(); + // Ctrl + wheel also zooms in the canvas and the browser page zoom is not used. + await page.keyboard.down('Control'); await page.mouse.move(point.x, point.y); await page.mouse.wheel(0, -100); await page.keyboard.up('Control'); + await page.waitForTimeout(50); + const ctrlZoom = await measure(page); + const pageZoom = await page.evaluate(() => [visualViewport.scale, innerWidth]); + await check('C', `${kind} Ctrl + wheel stays inside the canvas`, () => { + assert.ok(ctrlZoom.scale > settled.scale); assert.deepEqual(pageZoom, [1, 1440]); + return { scale: +ctrlZoom.scale.toFixed(4) }; + })(); + + // D: employee popup keeps the view. + const view = await measure(page); + const visibleCard = page.locator('.hm-orgchart-employee').filter({ hasNot: page.locator('[hidden]') }).nth(30); + await visibleCard.click(); + const dialog = page.locator('dialog.hm-orgchart-employee-dialog'); + const opened = await dialog.evaluate(element => element.open).catch(() => false); + const dialogText = opened ? (await dialog.innerText()).replace(/\s+/g, ' ').slice(0, 80) : ''; + await page.keyboard.press('Escape'); + await page.waitForTimeout(100); + const closed = !(await dialog.evaluate(element => element.open)); + const afterDialog = await measure(page); + await check('D', `${kind} employee popup open/close keeps Zoom/Pan`, () => { + assert.ok(opened, 'dialog opened'); assert.ok(closed, 'dialog closed'); + assert.deepEqual([afterDialog.scale, afterDialog.x, afterDialog.y], [view.scale, view.x, view.y]); + return { dialogText }; + })(); + // D: group collapse/expand keeps the view and does not Fit. + const toggle = page.locator('.hm-orgchart-expanded-toggle[aria-disabled="false"]').nth(3); + const id = await toggle.getAttribute('data-organization-id'); + const cardsBefore = afterDialog.cards; + await toggle.click(); + await page.waitForFunction(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`)?.getAttribute('aria-expanded') === 'false', id); + await ready(page); + const collapsed = await measure(page); + await page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`).click(); + await page.waitForFunction(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`)?.getAttribute('aria-expanded') === 'true', id); + await ready(page); + const expanded = await measure(page); + await check('D', `${kind} group collapse/expand without auto Fit`, () => { + assert.ok(collapsed.cards < cardsBefore && expanded.cards === cardsBefore, `${cardsBefore} -> ${collapsed.cards} -> ${expanded.cards}`); + assert.deepEqual([collapsed.scale, expanded.scale], [view.scale, view.scale]); + assert.deepEqual([expanded.x, expanded.y], [view.x, view.y]); + return { cards: [cardsBefore, collapsed.cards, expanded.cards], scale: +view.scale.toFixed(4) }; + })(); + // D: Drag & Drop defaults to OFF; a card drag never changes membership. + const dragSource = page.locator('.hm-orgchart-employee').nth(40); + const sourceId = await dragSource.getAttribute('data-employee-id'); + const ownerBefore = await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-employee[data-employee-id="${id}"]`)?.closest('.node')?.dataset.organizationId, sourceId); + const sourceBox = await dragSource.boundingBox(); + const targetBox = await page.locator('.hm-orgchart-expanded-toggle').nth(1).boundingBox(); + await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2); await page.mouse.down(); + await page.mouse.move(targetBox.x + 10, targetBox.y + 5, { steps: 10 }); await page.mouse.up(); + await page.waitForTimeout(200); + const afterDrag = await measure(page); + const owner = await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-employee[data-employee-id="${id}"]`)?.closest('.node')?.dataset.organizationId, sourceId); + await page.keyboard.press('Escape'); + await check('D', `${kind} Drag & Drop default OFF`, () => { + assert.equal(afterDrag.dragDrop, 'false'); + assert.ok(owner && owner === ownerBefore, `${ownerBefore} -> ${owner}`); + return { dragDropEnabled: afterDrag.dragDrop, owner }; + })(); + // B16 / React re-render: user view survives a plain re-render, replacement Fits again. + if (kind === 'react') { + const userView = await measure(page); + await page.evaluate(() => window.rerenderPage()); + await page.waitForTimeout(400); + const rerendered = await measure(page); + await check('C', 'react plain re-render keeps user view (no Fit)', () => { + assert.deepEqual([rerendered.scale, rerendered.x, rerendered.y], [userView.scale, userView.x, userView.y]); + })(); + } + await page.evaluate(() => window.replaceData('100')); + await page.waitForFunction(() => document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-employee').length === 100); + await ready(page); + const replaced = await measure(page); + await page.screenshot({ path: path.join(output, `${kind}-replaced-100.png`) }); + await check('B', `${kind} full data replacement Fits again`, () => { + assert.ok(replaced.inside && Math.abs(replaced.centerErrX) <= 1 && Math.abs(replaced.centerErrY) <= 1, JSON.stringify(replaced.margins)); + return { scale: +replaced.scale.toFixed(4), centerErrX: +replaced.centerErrX.toFixed(3), centerErrY: +replaced.centerErrY.toFixed(3) }; + })(); + await page.close(); +} + +// C23: outside the canvas the page scrolls; inside, the wheel zooms. +for (const kind of ['vanilla', 'react']) { + const page = await open(desktop, `${base}/${kind}.html?data=100&embed=1`); + await ready(page); + await page.mouse.move(700, 300); + await page.mouse.wheel(0, 200); + await page.waitForTimeout(150); + const scrolled = await page.evaluate(() => scrollY); + const box = await page.locator('#chart').boundingBox(); + const before = await measure(page); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.wheel(0, -100); + await page.waitForTimeout(150); + const after = await measure(page); + const scrollAfter = await page.evaluate(() => scrollY); + await check('C', `${kind} page scroll outside canvas / zoom inside`, () => { + assert.ok(scrolled >= 150, `outside scrollY ${scrolled}`); + assert.equal(scrollAfter, scrolled, 'inside wheel does not scroll the page'); + assert.ok(after.scale > before.scale); + return { outsideScrollY: scrolled, insideScrollY: scrollAfter, scale: [+before.scale.toFixed(4), +after.scale.toFixed(4)] }; + })(); + await page.close(); +} + +// D28: optional HM Dialog adapter. +{ + const page = await open(desktop, `${base}/react-hm-dialog.html?data=20`); + await ready(page); + const view = await measure(page); + await page.locator('.hm-orgchart-employee').nth(4).click(); + const hmDialog = page.locator('[data-slot="dialog-content"]'); + await hmDialog.waitFor({ timeout: 5000 }); + await page.waitForTimeout(400); + await page.screenshot({ path: path.join(output, 'react-hm-dialog-open.png') }); + const text = (await hmDialog.innerText()).replace(/\s+/g, ' ').slice(0, 80); + const coreDialogOpen = await page.locator('dialog.hm-orgchart-employee-dialog').evaluate(element => element.open); + await page.keyboard.press('Escape'); + await hmDialog.waitFor({ state: 'detached', timeout: 5000 }); + const after = await measure(page); + await check('D', 'react /hm-dialog Chart Only uses HM Dialog', () => { + assert.equal(coreDialogOpen, false); + assert.deepEqual([after.scale, after.x, after.y], [view.scale, view.x, view.y]); + return { text }; + })(); + await page.close(); +} +// Preserve the untouched initial Fit through controller recreation. The manual +// minimum is deliberately above Fit to exercise the saved floor without 2,100+ data. +for (const kind of ['vanilla', 'react']) { + const page = await open(desktop, `${base}/${kind}.html?data=500&verify-min=1`); + await ready(page); + const initial = await measure(page); + const title = page.locator('.hm-orgchart-expanded-toggle').nth(1); + await title.click(); + await page.waitForTimeout(250); + const collapsed = await measure(page); + await check('R1', `${kind} untouched Fit survives collapse`, () => { + assert.ok(initial.scale < 0.8); + assert.deepEqual([collapsed.scale, collapsed.x, collapsed.y], [initial.scale, initial.x, initial.y]); + })(); + await title.click(); await page.waitForTimeout(250); + const expanded = await measure(page); + const viewport = page.locator('.hm-orgchart-viewport'); const box = await viewport.boundingBox(); + await page.mouse.move(box.x + 8, box.y + box.height - 8); await page.mouse.wheel(0, 120); + await page.waitForTimeout(150); + const zoomed = await measure(page); + await check('R2', `${kind} recreated controller retains below-min Fit floor`, () => { + assert.deepEqual([expanded.scale, expanded.x, expanded.y], [initial.scale, initial.x, initial.y]); + assert.equal(zoomed.scale, initial.scale, 'wheel out must not jump to 0.8'); + })(); + if (kind === 'vanilla') { + await page.evaluate(() => window.orgChart.setInteractions({ zoom: false })); + await page.evaluate(() => window.orgChart.setInteractions({ zoom: true })); + const changed = await measure(page); + await check('R3', 'Vanilla interaction recreation retains initial view', () => { + assert.deepEqual([changed.scale, changed.x, changed.y], [initial.scale, initial.x, initial.y]); + })(); + } + await page.close(); +} +await desktop.close(); + +// Mobile: initial Fit, touch Pan and layout only (no wheel claims). +const mobile = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }); +for (const kind of ['vanilla', 'react']) { + for (const size of ['20', '100', '500']) { + const page = await open(mobile, `${base}/${kind}.html?data=${size}`); + await ready(page); + const m = await measure(page); + if (size !== '100') await page.screenshot({ path: path.join(output, `${kind}-${size}-mobile.png`) }); + let touch; + if (size === '20') { + const cdp = await mobile.newCDPSession(page); + const start = { x: m.canvas.left + 8, y: m.canvas.top + m.canvas.height - 8 }; + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [start] }); + for (let i = 1; i <= 5; i++) await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: start.x + i * 12, y: start.y - i * 10 }] }); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + const moved = await measure(page); + touch = { dx: moved.x - m.x, dy: moved.y - m.y }; + } + await check('B', `${kind} ${size} mobile Fit${touch ? ' + touch Pan' : ''}`, () => { + assert.ok(m.inside && Math.abs(m.centerErrX) <= 1 && Math.abs(m.centerErrY) <= 1, JSON.stringify(m.margins)); + assert.equal(m.pageOverflowX, false); + if (touch) assert.ok(Math.abs(touch.dx) > 20 && Math.abs(touch.dy) > 20, JSON.stringify(touch)); + return { scale: +m.scale.toFixed(4), centerErrX: +m.centerErrX.toFixed(3), centerErrY: +m.centerErrY.toFixed(3), touch }; + })(); + await page.close(); + } +} +await mobile.close(); +await browser.close(); + +const failed = results.filter(result => !result.pass); +await writeFile(path.join(output, 'results.json'), JSON.stringify({ base, results, consoleErrors }, null, 2)); +console.log(`\n${results.length - failed.length}/${results.length} PASS · console errors ${consoleErrors.length}`); +for (const error of consoleErrors) console.log('console:', error.url, error.text); +if (failed.length || consoleErrors.length) process.exitCode = 1; diff --git a/examples/org-chart-chart-only/vite.config.ts b/examples/org-chart-chart-only/vite.config.ts new file mode 100644 index 0000000..faa5213 --- /dev/null +++ b/examples/org-chart-chart-only/vite.config.ts @@ -0,0 +1,18 @@ +import path from 'node:path'; +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; + +// Chart Only consumer preview: Vanilla and React use the same public Core. +const root = path.resolve(import.meta.dirname); +export default defineConfig({ + root, + base: './', + publicDir: path.resolve(root, '../../public'), + plugins: [react(), tailwindcss()], + server: { host: '127.0.0.1', port: 6041 }, + build: { + outDir: 'dist', emptyOutDir: true, + rolldownOptions: { input: { vanilla: path.resolve(root, 'vanilla.html'), react: path.resolve(root, 'react.html'), 'react-hm-dialog': path.resolve(root, 'react-hm-dialog.html') } }, + }, +}); diff --git a/examples/org-chart-react/README.md b/examples/org-chart-react/README.md new file mode 100644 index 0000000..887a7ad --- /dev/null +++ b/examples/org-chart-react/README.md @@ -0,0 +1,82 @@ +# OrgChart React public-package consumer + +## P3 follow-up + +`p1.html?mode=readOnly` uses the updated common Core with the position matrix, +group links and employee information dialog. No navigation URLs are preset. +The development-only `window.OrgChartP1React.setOptions({ organizationLinks, +onEmployeeClick })` helper applies public React props to the current fixture; +it is not a new Core handle method. `setData` remains available. + +After building Core/Wrapper and both examples, run the focused +`node examples/org-chart/verify-p3.mjs`. Evidence goes to +`test-results/org-chart-p3`; prior P1/P2 scripts and results remain unchanged. + +This independent Vite consumer imports the built public entries of +`@hm-design/org-chart-react`, `@hm-design/org-chart` and `@hm-design/ui/theme.css`. +It imports the original Vanilla v1/v2 JSON files directly. It is a verification +example, with fictitious data and a `window.OrgChartReactDemo` inspection seam; +it has no server/login integration or production business controls. + +From the repository root, build packages before the consumer: + +```sh +npm run build:packages +npm run build:org-chart-react-example +npm run dev:org-chart-react-example +``` + +Development URL: `http://127.0.0.1:6026/` (strict port). Strict Mode effect replay +is checked in development. The production output has its own HTTP browser smoke +and update checks. This ESM output is for HTTP deployment; it is not a direct-open +`file://` artifact. The existing Vanilla `examples/org-chart/dist/classic.html` +remains the direct-open example. + +Core CSS URL imports become deployment assets and are passed into each Shadow DOM. +The public HM Theme import emits its existing font assets. Grid/flex consumers +must allow their chart-containing items to shrink (`min-width: 0`); horizontal +overflow belongs to the Core viewport rather than the document. + +Browser tooling is supplied by the verification environment, not added to the +package dependencies. Configure `ORGCHART_PLAYWRIGHT_MODULE` and optionally +`ORGCHART_CHROME`, using absolute paths. Start the separate Storybook server on +6025 and consumer on 6026, then: + +```sh +# Set these environment variables using your shell's syntax: +# ORGCHART_CONSUMER_URL=http://127.0.0.1:6026 +# ORGCHART_STORYBOOK_URL=http://127.0.0.1:6025 +npm run test:org-chart-react-browser +node examples/org-chart-react/verify-boundaries.mjs +``` + +`verify.mjs` inspects real Core DOM, compares Vanilla/React snapshots, exercises +React and Storybook lifecycles and records screenshots/metrics at four widths. +`verify-boundaries.mjs` checks production updates, source identity semantics, +actual horizontal scrolling, global CSS isolation and direct-open Vanilla smoke. +Both write evidence under ignored `test-results/org-chart-react/`. + +To check the actual package archive and declarations without another build: + +```sh +npm pack -w @hm-design/org-chart --ignore-scripts --pack-destination test-results +npm pack -w @hm-design/org-chart-react --ignore-scripts --pack-destination test-results +node examples/org-chart-react/verify-package.mjs +``` + +The tarball test uses extracted Core/Wrapper packages with the environment's +React/compiler; it does not establish a fresh remote-registry installation. + +## P2 read-only Full Composition + +Open `p1.html?mode=readOnly` to use the same read-only Core composition as the +Vanilla example and Storybook `orgchart--full-composition`. Plain `p1.html` +retains the existing interactive mode. The query changes presentation only. +All displayed names are `홍 길 동`; the employee fixture and aggregation remain +unchanged. Department leaders use card assets by actual ancestry depth. + +After building the Core, preparing the HTML example and building this consumer, +run `node examples/org-chart/verify-p2.mjs` from the repository root. Set +`ORGCHART_STORYBOOK_URL` to the current Storybook server when checking dev. +The focused verifier records screenshots and measurements in +`test-results/org-chart-p2`; it preserves existing v0/P1 test expectations. diff --git a/examples/org-chart-react/app.tsx b/examples/org-chart-react/app.tsx new file mode 100644 index 0000000..a47e6b6 --- /dev/null +++ b/examples/org-chart-react/app.tsx @@ -0,0 +1,91 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { OrgChart, type OrgChartProps } from '@hm-design/org-chart-react'; +import { JsonOrgChartDataSource, type OrgChartDataSource, type OrgChartState } 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 v1 from '../org-chart/data-v1.json'; +import v2 from '../org-chart/data-v2.json'; +import './index.css'; + +// An independent production-built consumer of public package entries. This +// inspection seam exercises React Props only; it never reaches into the engine. +const events: { instance: string; callback: number; state: OrgChartState }[] = []; +const lifecycleErrors: string[] = []; +const requests: Record = {}; +function track(name: string, source: OrgChartDataSource): OrgChartDataSource { + requests[name] = { loads: 0, aborts: 0 }; + return { + load(options) { + requests[name].loads++; + options?.signal?.addEventListener('abort', () => { requests[name].aborts++; }, { once: true }); + return source.load(options); + }, + }; +} +const noEmployees = structuredClone(v1); +noEmployees.employees = []; +for (const organization of noEmployees.organizations) organization.leaderId = null; +const noOrganizations = structuredClone(v1); +noOrganizations.organizations = []; +const sources = { + v1: track('v1', new JsonOrgChartDataSource(v1)), + v2: track('v2', new JsonOrgChartDataSource(v2)), + zeroEmployees: track('zeroEmployees', new JsonOrgChartDataSource(noEmployees)), + zeroOrganizations: track('zeroOrganizations', new JsonOrgChartDataSource(noOrganizations)), + error: track('error', { async load() { throw new Error('Fictitious source failure'); } }), +}; +let releaseSlow: (() => void) | undefined; +const slowSource = track('slow', { + load() { return new Promise(resolve => { releaseSlow = () => resolve(structuredClone(v1)); }); }, +}); +type Configuration = { + sourceA: OrgChartDataSource; + sourceB: OrgChartDataSource; + mountedA: boolean; + mountedB: boolean; + callback: number; + aggregationPolicy: OrgChartProps['aggregationPolicy']; + stylesheetUrls: OrgChartProps['stylesheetUrls']; + className: string; +}; +let configuration: Configuration = { + sourceA: sources.v1, sourceB: sources.v2, mountedA: true, mountedB: false, + callback: 1, aggregationPolicy: undefined, + stylesheetUrls: { engine: engineUrl, core: coreUrl }, className: 'consumer-chart', +}; +const root = createRoot(document.getElementById('root')!); +function render(patch: Partial = {}) { + configuration = { ...configuration, ...patch }; + const callback = configuration.callback; + const chart = (instance: 'A' | 'B', source: OrgChartDataSource) => ( +
+ { events.push({ instance, callback, state }); }} + onLifecycleError={error => { lifecycleErrors.push(error instanceof Error ? error.message : String(error)); }} + /> +
+ ); + root.render( + +

가상 JSON 데이터 · React Strict Mode · 공개 OrgChart 패키지 사용

+
+ {configuration.mountedA && chart('A', configuration.sourceA)} + {configuration.mountedB && chart('B', configuration.sourceB)} +
+
, + ); +} +const demo = { + render, sources, slowSource, events, lifecycleErrors, requests, + releaseSlow: () => releaseSlow?.(), + get configuration() { return configuration; }, + destroy: () => root.unmount(), +}; +declare global { interface Window { OrgChartReactDemo: typeof demo } } +window.OrgChartReactDemo = demo; +render(); diff --git a/examples/org-chart-react/external-group-theme.css b/examples/org-chart-react/external-group-theme.css new file mode 100644 index 0000000..460408e --- /dev/null +++ b/examples/org-chart-react/external-group-theme.css @@ -0,0 +1,2 @@ +@import './external-theme.css'; +:host { --hm-orgchart-group-title-background: #613e8b; } diff --git a/examples/org-chart-react/external-theme.css b/examples/org-chart-react/external-theme.css new file mode 100644 index 0000000..2fec44d --- /dev/null +++ b/examples/org-chart-react/external-theme.css @@ -0,0 +1,44 @@ +/* Served to stylesheetUrls.core: imports the complete default package stylesheet. + Vite resolves this public export; plain servers use their copied /assets/style.css. + To change a theme, edit only values in this file. No !important or inline colors. */ +@import '@hm-design/org-chart/style.css'; +:host { + --hm-orgchart-bg-surface: #edf4ff; + --hm-orgchart-bg-content: #dce8f8; + --hm-orgchart-fg-primary: #172b4d; + --hm-orgchart-stroke-default: #8b9bb0; + --hm-orgchart-company-hanmac: #2457a7; + --hm-orgchart-company-saman: #385ca8; + --hm-orgchart-company-jangheon-industry: #14786b; + --hm-orgchart-company-jangheon: #503a91; + --hm-orgchart-company-ptc: #755024; + --hm-orgchart-company-halla: #207879; + --hm-orgchart-company-baron: #4d6295; + --hm-orgchart-group-title-color: #fff4d6; + --hm-orgchart-card-background: #f4f8ff; + --hm-orgchart-leader-card-background: #e3eeff; + --hm-orgchart-top-leader-card-background: #d2e3ff; + --hm-orgchart-card-border: #527aa9; + --hm-orgchart-card-hover-background: #c1d8f8; + --hm-orgchart-card-focus-color: #7650a5; + --hm-orgchart-expanded-line-color: #249a79; + --hm-orgchart-fixed-line-color: #297ab0; + --hm-orgchart-business-management-gradient: linear-gradient(180deg, #2457a7, #123568); + --hm-orgchart-business-management-border: #2457a7; + --hm-orgchart-business-planning-gradient: linear-gradient(180deg, #385ca8, #23417f); + --hm-orgchart-business-planning-border: #385ca8; + --hm-orgchart-dispatched-card-gradient: linear-gradient(180deg, #e9f9f0, #a9d8bd); + --hm-orgchart-dispatched-card-border: #278857; + --hm-orgchart-dispatched-card-color: #145d39; + --hm-orgchart-dispatched-title-background: linear-gradient(180deg, #328c61, #1e5b3d); + --hm-orgchart-on-leave-card-gradient: linear-gradient(180deg, #f7efff, #d6bbeb); + --hm-orgchart-on-leave-card-border: #8050a1; + --hm-orgchart-on-leave-card-color: #603080; + --hm-orgchart-on-leave-title-background: linear-gradient(180deg, #8d66b3, #573771); + --hm-orgchart-dialog-background: #eef2ff; + --hm-orgchart-dialog-color: #283568; + --hm-orgchart-dialog-border: #6879ba; + --hm-orgchart-dialog-close-color: #36478a; + --hm-orgchart-dialog-close-hover-background: #dce2f9; + --hm-orgchart-dialog-focus-color: #8958ba; +} diff --git a/examples/org-chart-react/host-theme.css b/examples/org-chart-react/host-theme.css new file mode 100644 index 0000000..84687c0 --- /dev/null +++ b/examples/org-chart-react/host-theme.css @@ -0,0 +1,7 @@ +/* Loaded by the host document. Colors inherit; Shadow selectors stay private. */ +.orgchart-custom-theme { + --hm-orgchart-company-hanmac: #2457a7; + --hm-orgchart-company-saman: #385ca8; + --hm-orgchart-expanded-line-color: #249a79; + --hm-orgchart-bg-surface: #edf4ff; +} diff --git a/examples/org-chart-react/index.css b/examples/org-chart-react/index.css new file mode 100644 index 0000000..b73a8af --- /dev/null +++ b/examples/org-chart-react/index.css @@ -0,0 +1,9 @@ +@import "@hm-design/ui/theme.css"; + +/* Consumer content geometry; the chart owns its internal scroll container. */ +body { margin: 0; background: var(--hm-sem-bg-content); color: var(--hm-sem-fg-primary); font-family: var(--hm-font-family-base); } +main { padding: 16px; } +.consumer-charts { display: grid; gap: 24px; min-width: 0; } +.consumer-charts > section { min-width: 0; } +.consumer-chart { min-width: 0; max-width: 100%; } +.consumer-note { margin: 0 0 16px; } diff --git a/examples/org-chart-react/index.html b/examples/org-chart-react/index.html new file mode 100644 index 0000000..1a8ae70 --- /dev/null +++ b/examples/org-chart-react/index.html @@ -0,0 +1,13 @@ + + + + + + + HM OrgChart React Consumer + + +
+ + + diff --git a/examples/org-chart-react/p1.css b/examples/org-chart-react/p1.css new file mode 100644 index 0000000..cdbbf63 --- /dev/null +++ b/examples/org-chart-react/p1.css @@ -0,0 +1,12 @@ +/* Bundler resolves the same font used by HTML and the HM Storybook host. */ +@font-face { + font-family: 'Noto Sans KR'; + src: url('../../public/fonts/NotoSansKR-variable.ttf') format('truetype'); + font-style: normal; + font-weight: 100 900; + font-display: swap; +} +body { margin: 0; background: #ffffff; } +main { padding: 16px; } +.consumer-chart { min-width: 0; } +html[data-orgchart-mode="readOnly"] body { background: #F5F8FB; } diff --git a/examples/org-chart-react/p1.html b/examples/org-chart-react/p1.html new file mode 100644 index 0000000..35319f0 --- /dev/null +++ b/examples/org-chart-react/p1.html @@ -0,0 +1,5 @@ + + +HM OrgChart P1 · React Full Composition +
+ diff --git a/examples/org-chart-react/p1.tsx b/examples/org-chart-react/p1.tsx new file mode 100644 index 0000000..31ea8f9 --- /dev/null +++ b/examples/org-chart-react/p1.tsx @@ -0,0 +1,32 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { OrgChart, type OrgChartProps } from '@hm-design/org-chart-react'; +import { JsonOrgChartDataSource, type OrgChartState } 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 fixture from '../org-chart/data-p1.json'; +import './p1.css'; + +const root = createRoot(document.getElementById('root')!); +const events: OrgChartState[] = []; +const errors: string[] = []; +const displayMode = new URLSearchParams(location.search).get('mode') === 'readOnly' ? 'readOnly' : 'interactive'; +document.documentElement.dataset.orgchartMode = displayMode; +type QueryOptions = Pick; +let queryOptions: QueryOptions = {}; +let currentData: unknown = fixture; +function setData(data: unknown) { + currentData = data; + root.render( events.push(state)} onLifecycleError={error => errors.push(String(error))} + />); +} +// Optional public-prop seam for consumer integration checks; no URLs are preset. +function setOptions(options: QueryOptions) { queryOptions = options; setData(currentData); } +const demo = { data: fixture, events, errors, setData, setOptions, destroy: () => root.unmount() }; +declare global { interface Window { OrgChartP1React: typeof demo } } +window.OrgChartP1React = demo; +setData(fixture); diff --git a/examples/org-chart-react/p11.html b/examples/org-chart-react/p11.html new file mode 100644 index 0000000..9780d27 --- /dev/null +++ b/examples/org-chart-react/p11.html @@ -0,0 +1,2 @@ + +OrgChart P11 theme verification
diff --git a/examples/org-chart-react/p11.tsx b/examples/org-chart-react/p11.tsx new file mode 100644 index 0000000..e7a3c18 --- /dev/null +++ b/examples/org-chart-react/p11.tsx @@ -0,0 +1,34 @@ +// Isolated consumer fixture; no Storybook registration or production controls. +import { createRoot } from 'react-dom/client'; +import { OrgChart } from '@hm-design/org-chart-react'; +import { createOrgChart, JsonOrgChartDataSource } from '@hm-design/org-chart'; +import coreUrl from '@hm-design/org-chart/style.css?url'; +import engineUrl from '@hm-design/org-chart/engine.css?url'; +import externalThemeUrl from './external-theme.css?url'; +import hostThemeUrl from './host-theme.css?url'; +import groupThemeUrl from './external-group-theme.css?url'; +import autoData from '../org-chart/data-expanded-small.json'; +import allCompanies from '../org-chart/data-expanded-large.json'; +import fixedData from '../org-chart/data-p1.json'; + +const query = new URLSearchParams(location.search); +const mode = query.get('mode') ?? 'auto'; +const data = structuredClone(mode === 'auto' ? query.has('all') ? allCompanies : autoData : fixedData); +if (mode === 'auto' && query.has('statuses')) { + data.employees.filter(employee => !data.organizations.some(org => org.leaderId === employee.id)).slice(0, 2).forEach((employee, index) => { employee.workStatus = index ? 'onLeave' : 'dispatched'; }); +} +const options = { + dataSource: new JsonOrgChartDataSource(data), + dragDropEnabled: query.has('drag'), + stylesheetUrls: { core: query.has('groups') ? groupThemeUrl : query.has('theme') ? externalThemeUrl : coreUrl, engine: engineUrl }, + ...(mode === 'auto' ? { layoutMode: 'auto' as const, presentationMode: 'chart-only' as const, initialView: 'fit' as const, wheelZoom: 'direct' as const } : { displayMode: mode === 'fixed' ? 'readOnly' as const : 'interactive' as const }), +}; +document.body.style.margin = '0'; +Object.assign(window, { p11HostThemeUrl: hostThemeUrl }); +const container = document.getElementById('root')!; +container.style.height = '850px'; +if (query.get('runtime') === 'vanilla') { + const chart = createOrgChart({ container, ...options }); + void chart.mount(); + Object.assign(window, { p11Chart: chart }); +} else createRoot(container).render(); diff --git a/examples/org-chart-react/verify-boundaries.mjs b/examples/org-chart-react/verify-boundaries.mjs new file mode 100644 index 0000000..a1e2876 --- /dev/null +++ b/examples/org-chart-react/verify-boundaries.mjs @@ -0,0 +1,73 @@ +import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import path from 'node:path'; +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +await loadPlaywright(); +const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url, 'http://localhost'); + const file = path.resolve(root, 'examples/org-chart-react/dist', '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname)); + const base = path.join(root, 'examples/org-chart-react/dist'); + const relative = path.relative(base, file); + if (relative.startsWith('..') || path.isAbsolute(relative)) { response.writeHead(403).end(); return; } + response.setHeader('Content-Type', { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.ttf': 'font/ttf' }[path.extname(file)] || 'application/octet-stream'); + response.end(await readFile(file)); + } catch { response.writeHead(404).end(); } +}); +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +const browser = await launchChromium({ headless: true }); +const results = [], errors = []; +const check = (condition, name, detail) => { results.push({ name, status: condition ? 'PASS' : 'FAIL', detail }); assert.ok(condition, name); }; +try { + const page = await browser.newPage({ viewport: { width: 390, height: 900 } }); + page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { if(message.type() === 'error') errors.push(message.text()); }); + await page.goto(`http://127.0.0.1:${server.address().port}`); + await page.waitForFunction(() => window.OrgChartReactDemo?.events.at(-1)?.state.status === 'ready'); + check(await page.evaluate(() => window.OrgChartReactDemo.requests.v1.loads === 1 && window.OrgChartReactDemo.events.filter(event => event.state.status === 'ready').length === 1), 'production mount has one initial load and one ready callback'); + const layout = await page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot, viewport = shadow.querySelector('.hm-orgchart-viewport'); + viewport.scrollLeft = viewport.scrollWidth - viewport.clientWidth; + return { width: document.documentElement.clientWidth, documentScrollWidth: document.documentElement.scrollWidth, internalScrollLeft: viewport.scrollLeft }; + }); + check(layout.documentScrollWidth <= 390 && layout.internalScrollLeft > 0, 'production 390px page containment and actual internal horizontal scrolling', layout); + const pollution = await page.evaluate(() => { + const container = document.createElement('div'), node = document.createElement('div'); + node.textContent = 'Light DOM CSS isolation probe'; container.append(node); document.body.append(container); + const read = () => { const style = getComputedStyle(node); return { width: style.width, margin: style.margin, background: style.backgroundColor, textAlign: style.textAlign, border: style.border, font: style.fontFamily }; }; + const before = read(); container.className = 'orgchart'; node.className = 'node'; const after = read(); container.remove(); + return { before, after }; + }); + check(JSON.stringify(pollution.before) === JSON.stringify(pollution.after), 'Engine selectors do not pollute light DOM', pollution); + const hostId = await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance'); + await page.evaluate(async () => { + const demo = window.OrgChartReactDemo; + const next = await demo.sources.v2.load(); + demo.sources.v1.load = async () => structuredClone(next); + demo.render({ callback: 9, className: 'consumer-chart boundary' }); + }); + await page.waitForFunction(() => document.querySelector('.consumer-chart.boundary')); + check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === hostId && await page.evaluate(() => window.OrgChartReactDemo.events.at(-1).state.summary.raw.total === 4), 'same source internal mutation and unrelated render do not imply reload'); + await page.evaluate(() => { const demo = window.OrgChartReactDemo; demo.render({ sourceA: { load: options => demo.sources.v1.load(options) } }); }); + await page.waitForFunction(() => window.OrgChartReactDemo.events.at(-1)?.state.dataVersion === 'mock-2'); + check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === hostId && await page.evaluate(() => window.OrgChartReactDemo.events.at(-1).state.summary.raw.total === 5 && window.OrgChartReactDemo.events.at(-1).callback === 9), 'production source identity update uses same Core with new 5-person summary and latest callback'); + await page.evaluate(() => window.OrgChartReactDemo.render({ mountedA: false })); + await page.waitForFunction(() => !document.querySelector('.hm-orgchart-host')); + check(await page.locator('.hm-orgchart-host').count() === 0, 'production unmount removes Core'); + await page.goto(pathToFileURL(path.join(root, 'examples/org-chart/dist/classic.html')).href); + await page.waitForFunction(() => window.orgChartReady); + check(await page.evaluate(() => window.OrgChartDemo.chart.getState().summary.raw.total === 4), 'existing direct-open Vanilla classic remains ready with common v1 total 4'); + await page.evaluate(() => window.OrgChartDemo.update()); + check(await page.evaluate(() => window.OrgChartDemo.chart.getState().summary.raw.total === 5), 'existing direct-open Vanilla classic v2 update remains total 5'); + check(errors.length === 0, 'boundary browser checks have zero console/runtime errors', errors); +} catch(error) { + results.push({ name: 'verification completion', status: 'FAIL', detail: error.stack }); process.exitCode = 1; +} finally { + await writeFile(path.join(root, 'test-results/org-chart-react/boundaries.json'), JSON.stringify({ browser: await browser.version(), results, errors }, null, 2)); + await browser.close(); await new Promise(resolve => server.close(resolve)); + console.log(JSON.stringify({ pass: results.filter(result => result.status === 'PASS').length, fail: results.filter(result => result.status === 'FAIL').length, errors })); +} diff --git a/examples/org-chart-react/verify-p11-stories.mjs b/examples/org-chart-react/verify-p11-stories.mjs new file mode 100644 index 0000000..a790f30 --- /dev/null +++ b/examples/org-chart-react/verify-p11-stories.mjs @@ -0,0 +1,35 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import assert from 'node:assert/strict'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +const output = path.resolve('test-results/org-chart-p11'); +const browser = await launchChromium({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); +const errors = [], checks = []; +page.on('pageerror', error => errors.push(error.message)); +page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); +try { + for (const [id, selector] of [ + ['full-composition', '.hm-orgchart-composition[data-display-mode="readOnly"]'], + ['expanded-org-chart', '.hm-orgchart-expanded'], + ['department-colors', '.hm-orgchart-department-colors'], + ['logo-personnel-summary', '.hm-orgchart-affiliation-mark'], + ]) { + await page.goto(`http://127.0.0.1:6034/iframe.html?id=orgchart--${id}&viewMode=story`); + await page.locator(selector).first().waitFor(); + await page.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => setTimeout(resolve, 800)); }); + if (id === 'department-colors') { + const gradients = await page.locator('.hm-orgchart-color-preview .hm-orgchart-color-department-name').evaluateAll(headings => headings.map(heading => getComputedStyle(heading).backgroundImage)); + assert.equal(gradients.length, 6); + assert.ok(gradients.every(gradient => gradient !== 'none')); + assert.equal(new Set(gradients).size, 6); + } + await page.screenshot({ path: path.join(output, `storybook-${id}.png`) }); + checks.push({ name: id, status: 'PASS' }); + } + assert.deepEqual(errors, []); +} finally { + await fs.writeFile(path.join(output, 'storybook-result.json'), JSON.stringify({ checks, errors }, null, 2)); + await browser.close(); +} +console.log(`Storybook: ${checks.length} checks, ${errors.length} errors`); diff --git a/examples/org-chart-react/verify-p11.mjs b/examples/org-chart-react/verify-p11.mjs new file mode 100644 index 0000000..59a1a0b --- /dev/null +++ b/examples/org-chart-react/verify-p11.mjs @@ -0,0 +1,184 @@ +import fs from 'node:fs/promises'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; + +const output = path.resolve('test-results/org-chart-p11'); +await fs.mkdir(output, { recursive: true }); +const browser = await launchChromium({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); +const errors = [], checks = [], measurements = {}; +page.on('pageerror', error => errors.push(error.message)); +page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); +const baseline = process.argv.includes('--baseline'); +const origin = process.env.ORGCHART_P11_ORIGIN ?? 'http://127.0.0.1:6026'; +async function open(runtime, mode, theme = false, extra = '') { + await page.goto(`${origin}/p11.html?runtime=${runtime}&mode=${mode}${theme ? '&theme=1' : ''}${extra}`); + await page.locator('.hm-orgchart-employee').first().waitFor(); + await page.waitForFunction(() => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('link')].every(link => link.sheet)); + await page.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => setTimeout(resolve, 600)); }); +} +async function styles() { + return page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + const selectors = '.hm-orgchart-shell,.hm-orgchart-viewport,.hm-orgchart-organization-name,.hm-orgchart-employee,.hm-orgchart-status-group > .hm-orgchart-summary-title,.hm-orgchart-employee-dialog,.hm-orgchart-employee-dialog-close'; + const fields = ['backgroundColor', 'backgroundImage', 'color', 'borderTopColor', 'borderRightColor', 'borderTopWidth', 'boxShadow', 'width', 'height', 'fontSize', 'padding', 'borderRadius']; + return [...shadow.querySelectorAll(selectors)].map(el => ({ tag: el.tagName, class: el.className, company: el.dataset.companyId ?? el.closest('[data-company-id]')?.dataset.companyId, kind: el.dataset.cardKind, status: el.dataset.workStatus, css: Object.fromEntries(fields.map(field => [field, getComputedStyle(el)[field]])) })); + }); +} +async function check(name, run) { await run(); checks.push({ name, status: 'PASS' }); } +try { + for (const runtime of ['vanilla', 'react']) for (const mode of ['auto', 'fixed', 'interactive']) { + const key = `${runtime}-${mode}`; + await open(runtime, mode); + measurements[key] = await styles(); + await page.screenshot({ path: path.join(output, `${baseline ? 'before' : 'after'}-${key}.png`) }); + if (!baseline) { + const before = JSON.parse(await fs.readFile(path.join(output, 'baseline.json'), 'utf8')); + await check(`${key}: default colors and geometry identical`, () => assert.equal(JSON.stringify(measurements[key]), JSON.stringify(before[key]))); + } + // Popup is captured separately so it cannot obscure the chart screenshot. + await page.locator('.hm-orgchart-employee').first().click(); + await page.locator('.hm-orgchart-employee-dialog[open]').waitFor(); + measurements[`${key}-popup`] = (await styles()).filter(row => row.class.includes('dialog')); + if (!baseline) { + const before = JSON.parse(await fs.readFile(path.join(output, 'baseline.json'), 'utf8')); + await check(`${key}: default popup identical`, () => assert.equal(JSON.stringify(measurements[`${key}-popup`]), JSON.stringify(before[`${key}-popup`]))); + } + } + if (baseline) await fs.writeFile(path.join(output, 'baseline.json'), JSON.stringify(measurements, null, 2)); + else { + const expectedCompanies = { hanmac: 'rgb(36, 87, 167)', saman: 'rgb(56, 92, 168)', 'jangheon-industry': 'rgb(20, 120, 107)' }; + const value = (selector, property, pseudo = null) => page.locator(selector).first().evaluate((el, { property, pseudo }) => getComputedStyle(el, pseudo)[property], { property, pseudo }); + for (const runtime of ['vanilla', 'react']) { + await open(runtime, 'auto', true); + for (const [id, color] of Object.entries(expectedCompanies)) { + await check(`${runtime}: external CSS ${id} title`, async () => assert.equal(await value(`.node[data-company-id="${id}"] .hm-orgchart-organization-name`, 'backgroundColor'), color)); + await check(`${runtime}: external CSS ${id} card accent`, async () => assert.equal(await value(`.hm-orgchart-employee[data-company-id="${id}"]`, 'borderTopColor'), color)); + } + await check(`${runtime}: canvas and title text`, async () => { + assert.equal(await value('.hm-orgchart-viewport', 'backgroundColor'), 'rgb(237, 244, 255)'); + assert.equal(await value('.hm-orgchart-organization-name', 'color'), 'rgb(255, 244, 214)'); + }); + await check(`${runtime}: independent card roles and border`, async () => { + assert.equal(await value('.hm-orgchart-employee[data-card-kind="employee"]', 'backgroundColor'), 'rgb(244, 248, 255)'); + assert.equal(await value('.hm-orgchart-employee[data-card-kind="topLeader"]', 'backgroundColor'), 'rgb(210, 227, 255)'); + assert.equal(await value('.hm-orgchart-employee', 'borderRightColor'), 'rgb(82, 122, 169)'); + }); + await check(`${runtime}: employee and organization connector theme`, async () => { + assert.equal(await value('.hm-orgchart-employee-connectors path', 'stroke'), 'rgb(36, 154, 121)'); + assert.equal(await value('.hm-orgchart-engine > ul > li > ul li > .node', 'backgroundColor', '::before'), 'rgb(36, 154, 121)'); + }); + const employee = page.locator('.hm-orgchart-employee[data-card-kind="employee"]').first(); + await employee.hover(); + await check(`${runtime}: actual card hover`, async () => assert.equal(await value('.hm-orgchart-employee[data-card-kind="employee"]', 'backgroundColor'), 'rgb(193, 216, 248)')); + await page.mouse.move(0, 0); await employee.focus(); + await check(`${runtime}: actual card focus`, async () => assert.equal(await value('.hm-orgchart-employee[data-card-kind="employee"]', 'outlineColor'), 'rgb(118, 80, 165)')); + await employee.press('Enter'); + await page.locator('.hm-orgchart-employee-dialog[open]').waitFor(); + await check(`${runtime}: Core popup background, text and border`, async () => { + assert.equal(await value('.hm-orgchart-employee-dialog', 'backgroundColor'), 'rgb(238, 242, 255)'); + assert.equal(await value('.hm-orgchart-employee-dialog', 'color'), 'rgb(40, 53, 104)'); + assert.equal(await value('.hm-orgchart-employee-dialog', 'borderTopColor'), 'rgb(104, 121, 186)'); + }); + const close = page.locator('.hm-orgchart-employee-dialog-close'); + await close.hover(); await page.waitForTimeout(200); + await check(`${runtime}: popup hover`, async () => assert.equal(await value('.hm-orgchart-employee-dialog-close', 'backgroundColor'), 'rgb(220, 226, 249)')); + await page.mouse.move(0, 0); await close.focus(); await page.waitForTimeout(200); + await check(`${runtime}: popup focus`, async () => assert.equal(await value('.hm-orgchart-employee-dialog-close', 'borderTopColor'), 'rgb(137, 88, 186)')); + await page.keyboard.press('Escape'); + await check(`${runtime}: popup Escape and focus restoration`, async () => assert.equal(await employee.evaluate(el => el.getRootNode().activeElement === el), true)); + await page.screenshot({ path: path.join(output, `custom-${runtime}-auto.png`) }); + + await open(runtime, 'fixed', true); + await check(`${runtime}: Fixed headers and background`, async () => { + assert.match(await value('[data-group-variant="0"] .hm-orgchart-organization-name', 'backgroundImage'), /rgb\(36, 87, 167\)/); + assert.equal(await value('.hm-orgchart-composition', 'backgroundColor'), 'rgb(220, 232, 248)'); + assert.equal(await value('.hm-orgchart-workdesk-overlay path[data-connector]', 'stroke'), 'rgb(41, 122, 176)'); + }); + for (const [status, color] of [['dispatched', 'rgb(39, 136, 87)'], ['onLeave', 'rgb(128, 80, 161)']]) { + await check(`${runtime}: ${status} external gradient and border`, async () => { + const selector = `.hm-orgchart-status-group[data-work-status="${status}"] .hm-orgchart-employee`; + assert.equal(await value(selector, 'borderTopColor'), color); + assert.notEqual(await value(selector, 'backgroundImage'), 'none'); + }); + } + await page.screenshot({ path: path.join(output, `custom-${runtime}-fixed.png`) }); + + await open(runtime, 'auto'); + const unthemed = await styles(); + await page.evaluate(() => { + const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = window.p11HostThemeUrl; link.id = 'host-theme'; document.head.append(link); + document.querySelector('#root').classList.add('orgchart-custom-theme'); + }); + await page.waitForFunction(() => document.querySelector('#host-theme').sheet); + await check(`${runtime}: host CSS crosses Shadow boundary without inline conflict`, async () => { + assert.equal(await value('.node[data-company-id="hanmac"] .hm-orgchart-organization-name', 'backgroundColor'), expectedCompanies.hanmac); + assert.equal(await value('.node[data-company-id="saman"] .hm-orgchart-organization-name', 'backgroundColor'), expectedCompanies.saman); + assert.equal(await value('.hm-orgchart-viewport', 'backgroundColor'), 'rgb(237, 244, 255)'); + }); + await page.evaluate(() => document.querySelector('#root').classList.remove('orgchart-custom-theme')); + await check(`${runtime}: removing host theme restores defaults immediately`, async () => assert.equal(JSON.stringify(await styles()), JSON.stringify(unthemed))); + + await open(runtime, 'auto', false, '&all=1'); + await check(`${runtime}: all seven approved default company colors`, async () => { + for (const [id, color] of Object.entries({ hanmac: 'rgb(203, 46, 40)', saman: 'rgb(240, 131, 0)', 'jangheon-industry': 'rgb(69, 128, 166)', jangheon: 'rgb(29, 65, 141)', ptc: 'rgb(132, 51, 142)', halla: 'rgb(0, 104, 183)', baron: 'rgb(12, 54, 127)' })) assert.equal(await value(`.node[data-company-id="${id}"] .hm-orgchart-organization-name`, 'backgroundColor'), color); + }); + await open(runtime, 'auto', true, '&all=1&statuses=1&drag=1'); + for (const [id, color] of Object.entries({ ...expectedCompanies, jangheon: 'rgb(80, 58, 145)', ptc: 'rgb(117, 80, 36)', halla: 'rgb(32, 120, 121)', baron: 'rgb(77, 98, 149)' })) { + await check(`${runtime}: seven-company theme ${id}`, async () => assert.equal(await value(`.node[data-company-id="${id}"] .hm-orgchart-organization-name`, 'backgroundColor'), color)); + } + await check(`${runtime}: Auto status cards use external gradients and borders`, async () => { + assert.equal(await value('.hm-orgchart-employee[data-work-status="dispatched"]', 'borderRightColor'), 'rgb(39, 136, 87)'); + assert.match(await value('.hm-orgchart-employee[data-work-status="onLeave"]', 'backgroundImage'), /rgb\(214, 187, 235\)/); + }); + await check(`${runtime}: group leader has an independent background`, async () => assert.equal(await value('.hm-orgchart-employee[data-card-kind="leader"]', 'backgroundColor'), 'rgb(227, 238, 255)')); + await page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + const removeHover = rules => { for (const rule of rules) { rule.style?.removeProperty('--hm-orgchart-card-hover-background'); if (rule.styleSheet) removeHover(rule.styleSheet.cssRules); if (rule.cssRules) removeHover(rule.cssRules); } }; + for (const link of shadow.querySelectorAll('link')) removeHover(link.sheet.cssRules); + }); + const statusCard = page.locator('.hm-orgchart-employee[data-work-status="dispatched"]').first(); + const statusGradient = await value('.hm-orgchart-employee[data-work-status="dispatched"]', 'backgroundImage'); + await statusCard.hover(); + await check(`${runtime}: unspecified hover preserves status gradient`, async () => assert.equal(await value('.hm-orgchart-employee[data-work-status="dispatched"]', 'backgroundImage'), statusGradient)); + await check(`${runtime}: Drag & Drop ON`, async () => assert.equal(await page.locator('.hm-orgchart-viewport').getAttribute('data-drag-drop-enabled'), 'true')); + await open(runtime, 'auto', true); + await check(`${runtime}: Drag & Drop OFF`, async () => assert.equal(await page.locator('.hm-orgchart-viewport').getAttribute('data-drag-drop-enabled'), 'false')); + await open(runtime, 'auto', true, '&groups=1'); + await check(`${runtime}: independent group background via external CSS file`, async () => assert.equal(await value('.hm-orgchart-organization-name', 'backgroundColor'), 'rgb(97, 62, 139)')); + await open(runtime, 'auto', true); + const forest = page.locator('.hm-orgchart-forest'); + const transform = await forest.evaluate(el => el.style.transform); + const viewport = await page.locator('.hm-orgchart-viewport').boundingBox(); + const mouseX = viewport.x + viewport.width - 12, mouseY = viewport.y + viewport.height - 12; + await page.mouse.move(mouseX, mouseY); await page.mouse.wheel(0, -150); await page.waitForTimeout(150); + await check(`${runtime}: direct wheel zoom`, async () => assert.notEqual(await forest.evaluate(el => el.style.transform), transform)); + const zoomed = await forest.evaluate(el => el.style.transform); + await page.mouse.move(mouseX, mouseY); await page.mouse.down(); await page.mouse.move(mouseX - 50, mouseY - 50, { steps: 5 }); await page.mouse.up(); + await check(`${runtime}: pan`, async () => assert.notEqual(await forest.evaluate(el => el.style.transform), zoomed)); + await open(runtime, 'auto', true); + const collapse = page.locator('.hm-orgchart-expanded-toggle').first(); + await collapse.click(); + await check(`${runtime}: collapse`, async () => assert.equal(await page.locator('.hm-orgchart-expanded-toggle').first().getAttribute('aria-expanded'), 'false')); + await page.locator('.hm-orgchart-expanded-toggle').first().click(); + await check(`${runtime}: expand`, async () => assert.equal(await page.locator('.hm-orgchart-expanded-toggle').first().getAttribute('aria-expanded'), 'true')); + for (const width of [390, 768, 1280, 1440]) { + await page.setViewportSize({ width, height: 900 }); + await open(runtime, 'auto', true); + await check(`${runtime}: ${width}px canvas fits and document does not overflow`, async () => { + const box = await page.locator('.hm-orgchart-viewport').boundingBox(); + assert.equal(box.width, width); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); + }); + await open(runtime, 'fixed', true); + await check(`${runtime}: ${width}px Fixed document does not overflow`, async () => assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true)); + } + } + } + assert.deepEqual(errors, [], 'No browser errors'); +} finally { + await fs.writeFile(path.join(output, baseline ? 'baseline-result.json' : 'browser-result.json'), JSON.stringify({ checks, errors, measurements }, null, 2)); + await browser.close(); +} +console.log(`${baseline ? 'Baseline' : 'P11'}: ${checks.length} checks, ${errors.length} errors`); diff --git a/examples/org-chart-react/verify-package.mjs b/examples/org-chart-react/verify-package.mjs new file mode 100644 index 0000000..eaba13b --- /dev/null +++ b/examples/org-chart-react/verify-package.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const consumer = path.join(root, 'test-results/org-chart-react/tarball-consumer'); +await mkdir(consumer, { recursive: true }); +for (const name of ['org-chart', 'org-chart-react']) { + const destination = path.join(consumer, 'node_modules/@hm-design', name); + await mkdir(destination, { recursive: true }); + execFileSync('tar.exe', ['-xzf', path.join(root, `test-results/hm-design-${name}-0.1.0.tgz`), '-C', destination, '--strip-components=1']); +} +await writeFile(path.join(consumer, 'package.json'), JSON.stringify({ name: 'org-chart-react-tarball-consumer', private: true, type: 'module' })); +const require = createRequire(path.join(consumer, 'package.json')); +const module = await import(pathToFileURL(require.resolve('@hm-design/org-chart-react')).href); +assert.deepEqual(Object.keys(module), ['OrgChart']); +assert.equal(typeof module.OrgChart, 'function'); +const manifest = JSON.parse(await readFile(path.join(consumer, 'node_modules/@hm-design/org-chart-react/package.json'), 'utf8')); +assert.deepEqual(manifest.dependencies, { '@hm-design/org-chart': '0.1.0' }); +assert.deepEqual(manifest.peerDependencies, { react: '^19.0.0' }); +const bundle = await readFile(require.resolve('@hm-design/org-chart-react'), 'utf8'); +assert.ok(bundle.startsWith('"use client";')); +for (const dependency of ['react', 'react/jsx-runtime', '@hm-design/org-chart']) assert.ok(bundle.includes(`from "${dependency}"`) || bundle.includes(`from '${dependency}'`)); +assert.ok(!bundle.includes('ManagedOrgChart') && !bundle.includes('validateOrgChartDataset') && !bundle.includes('JsonOrgChartDataSource')); +await writeFile(path.join(consumer, 'consumer.tsx'), `import { OrgChart, type OrgChartProps, type OrgChartDataSource, type OrgChartState, type OrgChartDataset } from '@hm-design/org-chart-react'; +import { JsonOrgChartDataSource } from '@hm-design/org-chart'; +declare const data: OrgChartDataset; +const source: OrgChartDataSource = new JsonOrgChartDataSource(data); +const props: OrgChartProps = { dataSource: source, stylesheetUrls: { engine: './engine.css', core: './style.css' }, onStateChange(state: OrgChartState) { if(state.status === 'ready') void state.summary.raw.total; }, onLifecycleError(error: unknown) { void error; } }; +export const consumer = ; +// @ts-expect-error dataSource is a required Core source interface +export const missingSource = ; +// @ts-expect-error wrapper does not expose engine controls +export const unsupportedZoom = ; +// @ts-expect-error Core-owned container cannot be supplied through React Props +export const unsupportedContainer = ; +`); +await writeFile(path.join(consumer, 'tsconfig.json'), JSON.stringify({ compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', jsx: 'react-jsx', strict: true, noEmit: true, skipLibCheck: false, types: ['react'] }, include: ['consumer.tsx'] })); +const localRequire = createRequire(import.meta.url); +const typescriptManifest = localRequire.resolve('typescript/package.json'); +const typescript = JSON.parse(await readFile(typescriptManifest, 'utf8')); +execFileSync(process.execPath, [path.resolve(path.dirname(typescriptManifest), typescript.bin.tsc), '-p', path.join(consumer, 'tsconfig.json')], { stdio: 'inherit' }); +const report = { + status: 'PASS', + checks: ['Actual tarball runtime/public export without DOM', 'Strict NodeNext declarations using extracted Core/Wrapper', 'Required source and unsupported Props type boundaries', 'React/Core remain external', 'ESM client boundary retained'], + limitation: 'React and compiler supplied by verification environment; fresh remote-registry dependency installation and SSR/hydration are not verified.', +}; +await writeFile(path.join(consumer, 'report.json'), JSON.stringify(report, null, 2)); +console.log(JSON.stringify(report)); diff --git a/examples/org-chart-react/verify.mjs b/examples/org-chart-react/verify.mjs new file mode 100644 index 0000000..63d0ddd --- /dev/null +++ b/examples/org-chart-react/verify.mjs @@ -0,0 +1,312 @@ +import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import path from 'node:path'; +import { readFile, mkdir, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-react'); +await mkdir(output, { recursive: true }); +await loadPlaywright(); +const mime = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.ttf': 'font/ttf', '.woff2': 'font/woff2', '.svg': 'image/svg+xml' }; +async function serve(directory) { + const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url, 'http://localhost'); + const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname)); + const relative = path.relative(directory, file); + if (relative.startsWith('..') || path.isAbsolute(relative)) { response.writeHead(403).end(); return; } + response.setHeader('Content-Type', mime[path.extname(file)] || 'application/octet-stream'); + response.end(await readFile(file)); + } catch { response.writeHead(404).end(); } + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + return { server, origin: `http://127.0.0.1:${server.address().port}` }; +} +const consumer = await serve(path.join(root, 'examples/org-chart-react/dist')); +const vanilla = await serve(path.join(root, 'examples/org-chart/dist')); +const storybook = await serve(path.join(root, 'storybook-static')); +const browser = await launchChromium({ headless: true }); +const results = [], errors = [], metrics = [], snapshots = {}; +function check(condition, name, detail) { + results.push({ name, status: condition ? 'PASS' : 'FAIL', detail }); + assert.ok(condition, `${name}: ${JSON.stringify(detail)}`); +} +function captureErrors(page, transport) { + page.on('pageerror', error => errors.push({ transport, type: 'pageerror', message: error.message })); + page.on('console', message => { if (message.type() === 'error') errors.push({ transport, type: 'console', message: message.text() }); }); +} +async function snapshot(page, selector = '.hm-orgchart-host') { + return page.locator(selector).first().evaluate(host => { + const shadow = host.shadowRoot; + const nodes = [...shadow.querySelectorAll('.node')]; + return { + organizations: nodes.map(node => ({ + id: node.dataset.organizationId, + parentId: node.parentElement.parentElement.closest('.hierarchy')?.querySelector(':scope > .node')?.dataset.organizationId || null, + employees: [...node.querySelectorAll('.hm-orgchart-employee')].map(employee => employee.dataset.employeeId), + text: node.querySelector('.hm-orgchart-organization').textContent, + })), + summary: shadow.querySelector('.hm-orgchart-summary')?.textContent, + heading: shadow.querySelector('.hm-orgchart-heading')?.textContent, + css: [...shadow.querySelectorAll('link')].map(link => ({ href: link.href, loaded: !!link.sheet })), + style: (() => { const s = getComputedStyle(shadow.querySelector('.hm-orgchart-organization')); return { color: s.color, background: s.backgroundColor, font: s.fontFamily, radius: s.borderRadius }; })(), + }; + }); +} +async function ready(page, instance = 'A', version) { + await page.waitForFunction(({ instance, version }) => { + const event = window.OrgChartReactDemo.events.filter(event => event.instance === instance).at(-1); + const host = document.querySelector(`[data-instance="${instance}"] .hm-orgchart-host`); + return !!host?.shadowRoot.querySelector('.hm-orgchart-shell[aria-busy="false"] .node') && event?.state.status === 'ready' && (!version || event.state.dataVersion === version); + }, { instance, version }); +} +async function patch(page, value) { await page.evaluate(value => window.OrgChartReactDemo.render(value), value); } +async function source(page, name, instance = 'A') { + await page.evaluate(({ name, instance }) => window.OrgChartReactDemo.render({ [`source${instance}`]: window.OrgChartReactDemo.sources[name] }), { name, instance }); +} +try { + const context = await browser.newContext({ viewport: { width: 1440, height: 1100 } }); + await context.addInitScript(() => { + const NativeObserver = window.MutationObserver; + const observers = new Map(); + window.MutationObserver = class extends NativeObserver { + observe(...args) { observers.set(this, args[0]); return super.observe(...args); } + disconnect() { observers.delete(this); return super.disconnect(); } + }; + window.chartObservers = () => [...observers.values()].filter(target => target.getRootNode()?.host?.classList.contains('hm-orgchart-host')).length; + }); + const html = await context.newPage(); + captureErrors(html, 'vanilla-http'); + await html.goto(`${vanilla.origin}/index.html`); + await html.waitForFunction(() => window.orgChartReady); + snapshots.htmlV1 = await snapshot(html); + const page = await context.newPage(); + captureErrors(page, 'react-production'); + if (process.env.ORGCHART_CONSUMER_URL) { + const production = await context.newPage(); captureErrors(production, 'react-production'); + await production.goto(consumer.origin); await ready(production, 'A', 'mock-1'); + snapshots.reactProduction = await snapshot(production); + check(JSON.stringify(snapshots.reactProduction.organizations) === JSON.stringify(snapshots.htmlV1.organizations) && snapshots.reactProduction.css.every(link => link.loaded), 'independent production React Consumer: public imports, assets and identical HTML nodes'); + await production.close(); + } + await page.goto(process.env.ORGCHART_CONSUMER_URL || consumer.origin); + await ready(page, 'A', 'mock-1'); + snapshots.reactV1 = await snapshot(page); + check(JSON.stringify(snapshots.reactV1.organizations) === JSON.stringify(snapshots.htmlV1.organizations), 'HTML/React v1 hierarchy, employee placement and identical node UI', snapshots.reactV1.organizations); + check(snapshots.reactV1.summary === snapshots.htmlV1.summary && snapshots.reactV1.heading === snapshots.htmlV1.heading, 'HTML/React v1 identical summary and presentation'); + check(JSON.stringify(snapshots.reactV1.style) === JSON.stringify(snapshots.htmlV1.style), 'HTML/React identical computed theme', snapshots.reactV1.style); + check(snapshots.reactV1.css.length === 2 && snapshots.reactV1.css.every(link => link.loaded), 'production Shadow DOM CSS assets loaded', snapshots.reactV1.css); + const initial = await page.evaluate(() => ({ events: window.OrgChartReactDemo.events, requests: window.OrgChartReactDemo.requests, observers: window.chartObservers() })); + const expectedLoads = process.env.ORGCHART_CONSUMER_URL ? 2 : 1; + check(initial.requests.v1.loads === expectedLoads && initial.events.filter(event => event.state.status === 'ready').length === 1, 'Strict Mode: expected initial load count and single ready (development replay; production single setup)', initial); + check(await page.locator('.hm-orgchart-host').count() === 1, 'Strict Mode: exactly one active Core host'); + const hostId = await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance'); + await patch(page, { callback: 2, className: 'consumer-chart changed' }); + await page.waitForFunction(() => document.querySelector('.consumer-chart.changed')); + check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === hostId && (await page.evaluate(() => window.OrgChartReactDemo.requests.v1.loads)) === expectedLoads, 'callback/className-only render retains Core and does not reload'); + await source(page, 'v2'); + await ready(page, 'A', 'mock-2'); + check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === hostId, 'source identity uses reload on the same Core host'); + const stateV2 = await page.evaluate(() => window.OrgChartReactDemo.events.at(-1)); + check(stateV2.callback === 2 && stateV2.state.summary.raw.total === 5 && stateV2.state.summary.raw.byOrganization['team-a'].direct === 1 && stateV2.state.summary.raw.byOrganization['team-b'].direct === 3, 'latest callback and v2 expected 1/1/3 placement, total 5', stateV2); + await html.evaluate(() => window.OrgChartDemo.update()); + snapshots.htmlV2 = await snapshot(html); + snapshots.reactV2 = await snapshot(page); + check(JSON.stringify(snapshots.reactV2.organizations) === JSON.stringify(snapshots.htmlV2.organizations) && snapshots.reactV2.summary === snapshots.htmlV2.summary, 'HTML/React v2 data, hierarchy, employee nodes and aggregation parity'); + + for (const width of [390, 768, 1280, 1440]) { + await page.setViewportSize({ width, height: 1100 }); + await page.evaluate(() => document.fonts.ready); + const metric = await page.locator('.hm-orgchart-host').evaluate(host => { + const shadow = host.shadowRoot, viewport = shadow.querySelector('.hm-orgchart-viewport'); + const rectangles = [...shadow.querySelectorAll('.node')].map(node => node.getBoundingClientRect()); + const overlap = rectangles.some((a, index) => rectangles.slice(index + 1).some(b => Math.min(a.right, b.right) - Math.max(a.left, b.left) > 1 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 1)); + const organization = shadow.querySelector('.hm-orgchart-organization'), style = getComputedStyle(organization); + const probe = document.createElement('div'); + probe.style.color = 'var(--hm-sem-fg-primary)'; probe.style.background = 'var(--hm-sem-bg-surface)'; probe.style.borderRadius = 'var(--hm-radius-container)'; shadow.append(probe); + const expectedStyle = getComputedStyle(probe), expected = { color: expectedStyle.color, background: expectedStyle.backgroundColor, radius: expectedStyle.borderRadius }; + const match = expected.color === style.color && expected.background === style.backgroundColor && expected.radius === style.borderRadius; + probe.remove(); + const nodes = [...shadow.querySelectorAll('.node')], rect = nodes[0].getBoundingClientRect(); + const centers = nodes.slice(1).map(node => { const r = node.getBoundingClientRect(); return r.x + r.width / 2; }); + return { + document: { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, clientHeight: document.documentElement.clientHeight, scrollHeight: document.documentElement.scrollHeight }, + host: { clientWidth: host.clientWidth, scrollWidth: host.scrollWidth, clientHeight: host.clientHeight, scrollHeight: host.scrollHeight }, + viewport: { clientWidth: viewport.clientWidth, scrollWidth: viewport.scrollWidth, clientHeight: viewport.clientHeight, scrollHeight: viewport.scrollHeight, overflowX: getComputedStyle(viewport).overflowX }, + overlap, themeMatch: match, expected, color: style.color, background: style.backgroundColor, radius: style.borderRadius, font: style.fontFamily, + fontLoaded: document.fonts.check('13px "Noto Sans KR"'), idWhiteSpace: getComputedStyle(shadow.querySelector('.hm-orgchart-domain-id')).whiteSpace, + connectorsAligned: Math.abs(rect.x + rect.width / 2 - (centers[0] + centers[1]) / 2) < 2, + connectorTheme: getComputedStyle(nodes[1], '::before').backgroundColor === getComputedStyle(viewport).borderTopColor, + }; + }); + metrics.push({ width, ...metric }); + check(metric.document.scrollWidth <= width && metric.host.scrollWidth <= metric.host.clientWidth && metric.viewport.overflowX === 'auto' && !metric.overlap, `${width}px container overflow/scroll/no overlap`, metric); + check(metric.themeMatch && metric.font.includes('Noto Sans KR') && metric.fontLoaded && metric.idWhiteSpace === 'nowrap' && metric.connectorsAligned && metric.connectorTheme, `${width}px theme/fonts/IDs/connectors`, metric); + await page.screenshot({ path: path.join(output, `react-consumer-${width}.png`), fullPage: true }); + } + const toggle = page.locator('.hm-orgchart-toggle').first(); + await toggle.focus(); await toggle.press('Enter'); + await page.waitForFunction(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.sliding')); + check(await toggle.getAttribute('aria-expanded') === 'false', 'React keyboard Enter collapses'); + await toggle.press('Space'); + await page.waitForFunction(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.sliding')); + check(await toggle.getAttribute('aria-expanded') === 'true', 'React keyboard Space expands'); + await source(page, 'v1'); await ready(page, 'A', 'mock-1'); + check(await page.locator('.hm-orgchart-viewport').evaluate(element => element.getRootNode().activeElement === element), 'source reload restores removed keyboard focus to stable viewport'); + + await page.evaluate(() => window.OrgChartReactDemo.render({ sourceA: window.OrgChartReactDemo.slowSource })); + await page.waitForFunction(() => window.OrgChartReactDemo.requests.slow.loads === 1); + await source(page, 'v2'); await ready(page, 'A', 'mock-2'); + const beforeLate = await page.evaluate(() => window.OrgChartReactDemo.events.length); + await page.evaluate(() => window.OrgChartReactDemo.releaseSlow()); + await page.waitForTimeout(100); + check(await page.evaluate(() => window.OrgChartReactDemo.events.at(-1).state.dataVersion === 'mock-2' && window.OrgChartReactDemo.requests.slow.aborts === 1) && (await page.evaluate(() => window.OrgChartReactDemo.events.length)) === beforeLate, 'superseded abort-ignoring source cannot update DOM/callback'); + await source(page, 'error'); + await page.waitForFunction(() => window.OrgChartReactDemo.events.at(-1).state.status === 'error'); + check(await page.locator('.hm-orgchart-issues').count() === 1, 'source failure controlled error state'); + await source(page, 'v1'); await ready(page, 'A', 'mock-1'); + check(await page.locator('.hm-orgchart-issues').count() === 0, 'Error to Ready recovery via source replacement'); + await source(page, 'zeroEmployees'); await ready(page); + check((await page.evaluate(() => window.OrgChartReactDemo.events.at(-1).state.summary.raw.total)) === 0, 'zero employees remains Ready with total 0'); + await source(page, 'zeroOrganizations'); + await page.waitForFunction(() => window.OrgChartReactDemo.events.at(-1).state.status === 'error'); + check(await page.locator('.hm-orgchart-issues').count() === 1, 'zero organizations is Error; no fabricated empty state'); + await source(page, 'v1'); await ready(page, 'A', 'mock-1'); + await page.evaluate(() => window.OrgChartReactDemo.render({ aggregationPolicy: { policyVersion: 'consumer-test', includedStatuses: ['active'], activeStatuses: ['active'], includeLeaders: true, membershipMode: 'primaryOnly' } })); + await page.waitForFunction(() => window.OrgChartReactDemo.events.at(-1).state.aggregation?.status === 'ready'); + check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') !== hostId && (await page.evaluate(() => window.OrgChartReactDemo.events.at(-1).state.aggregation.counts.total)) === 2, 'policy change safely recreates Core and uses explicit test policy'); + let currentHost = await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance'); + await page.evaluate(() => window.OrgChartReactDemo.render({ stylesheetUrls: { ...window.OrgChartReactDemo.configuration.stylesheetUrls } })); + await page.waitForTimeout(70); + check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === currentHost, 'equal stylesheet URL values retain Core'); + await page.evaluate(() => { const urls = window.OrgChartReactDemo.configuration.stylesheetUrls; window.OrgChartReactDemo.render({ stylesheetUrls: { engine: urls.engine + '?consumer=2', core: urls.core }, aggregationPolicy: undefined }); }); + await page.waitForFunction(oldId => document.querySelector('.hm-orgchart-host')?.dataset.orgchartInstance !== oldId, currentHost); + await ready(page); + check(await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') !== currentHost && (await snapshot(page)).css.every(link => link.loaded), 'changed stylesheet URL safely recreates and loads assets'); + + await patch(page, { mountedB: true }); await ready(page, 'B', 'mock-2'); + const bHost = await page.locator('[data-instance="B"] .hm-orgchart-host').getAttribute('data-orgchart-instance'); + const bSnapshot = await snapshot(page, '[data-instance="B"] .hm-orgchart-host'); + await source(page, 'v2'); await ready(page, 'A', 'mock-2'); + check(await page.locator('.hm-orgchart-host').count() === 2 && JSON.stringify(await snapshot(page, '[data-instance="B"] .hm-orgchart-host')) === JSON.stringify(bSnapshot), 'two simultaneous instances; A update preserves B'); + await page.evaluate(() => { document.querySelector('[data-instance="A"]').style.setProperty('--hm-sem-bg-surface', getComputedStyle(document.documentElement).getPropertyValue('--hm-sem-bg-content')); }); + check((await snapshot(page, '[data-instance="A"] .hm-orgchart-host')).style.background !== bSnapshot.style.background && (await snapshot(page, '[data-instance="B"] .hm-orgchart-host')).style.background === bSnapshot.style.background, 'scoped existing Theme variable change inherits through Shadow DOM and isolates B'); + await patch(page, { mountedA: false }); + await page.waitForFunction(() => !document.querySelector('[data-instance="A"]')); + check(await page.locator('.hm-orgchart-host').count() === 1 && await page.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === bHost, 'A unmount preserves B and its Core identity'); + const bObservers = await page.evaluate(() => window.chartObservers()); + for (let iteration = 0; iteration < 5; iteration++) { + await patch(page, { mountedA: true }); await ready(page, 'A', 'mock-2'); + await page.waitForFunction(() => document.querySelectorAll('.hm-orgchart-host').length === 2); + await patch(page, { mountedA: false }); + await page.waitForFunction(() => document.querySelectorAll('.hm-orgchart-host').length === 1); + } + check(await page.evaluate(() => window.chartObservers()) === bObservers, 'five Strict Mode mount/unmount cycles preserve B observer baseline'); + await patch(page, { mountedA: true }); await ready(page, 'A', 'mock-2'); + await page.evaluate(() => window.OrgChartReactDemo.render({ sourceA: window.OrgChartReactDemo.slowSource })); + await page.waitForFunction(() => window.OrgChartReactDemo.requests.slow.loads === 2); + await patch(page, { mountedA: false }); + await page.waitForFunction(() => !document.querySelector('[data-instance="A"]')); + const unmountEvents = await page.evaluate(() => window.OrgChartReactDemo.events.length); + await page.evaluate(() => window.OrgChartReactDemo.releaseSlow()); await page.waitForTimeout(100); + check((await page.evaluate(() => window.OrgChartReactDemo.events.length)) === unmountEvents, 'late response after unmount cannot emit callback'); + check((await page.evaluate(() => window.OrgChartReactDemo.lifecycleErrors.length)) === 0, 'normal lifecycle has no rejected-use errors'); + await patch(page, { mountedA: true, stylesheetUrls: { engine: '', core: '' } }); + await page.waitForFunction(() => window.OrgChartReactDemo.lifecycleErrors.length > 0); + check(await page.locator('.hm-orgchart-host').count() === 0, 'invalid Core configuration is reported via lifecycle error without leaked hosts'); + await page.evaluate(() => window.OrgChartReactDemo.destroy()); + check(await page.locator('.hm-orgchart-host').count() === 0 && await page.evaluate(() => window.chartObservers()) === 0, 'terminal React root unmount removes all Core DOM/observers'); + + const index = JSON.parse(await readFile(path.join(root, 'storybook-static/index.json'), 'utf8')); + const stories = Object.values(index.entries).filter(entry => entry.title === 'OrgChart/Content' && entry.type === 'story'); + check(stories.length === 8, 'production Storybook registers exactly eight OrgChart stories', stories.map(story => ({ id: story.id, name: story.name }))); + const sb = await context.newPage(); captureErrors(sb, 'storybook-production'); + for (const story of stories) { + await sb.goto(`${storybook.origin}/iframe.html?id=${story.id}&viewMode=story`); + await sb.waitForFunction(() => [...document.querySelectorAll('.hm-orgchart-host')].some(host => host.shadowRoot.querySelector('.hm-orgchart-organization, .hm-orgchart-issues, .hm-orgchart-state:not(:empty)'))); + if (story.id.includes('loading')) { + check((await sb.locator('.hm-orgchart-state').allTextContents()).some(text => text.includes('불러오는')), `${story.name}: actual loading source`); + } else { + await sb.waitForFunction(() => [...document.querySelectorAll('.hm-orgchart-host')].every(host => host.shadowRoot.querySelector('.hm-orgchart-organization'))); + check(await sb.locator('.node').count() >= 3, `${story.name}: real Core nodes rendered`); + } + if (story.id.includes('json-data-update')) { + await sb.getByRole('button', { name: 'JSON v2', exact: true }).click(); + await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('mock-2')); + check((await sb.locator('.hm-orgchart-raw-total').textContent()).includes('5명'), 'Storybook JSON v1 to v2 recalculates 5 employees'); + await sb.getByRole('button', { name: 'Unmount', exact: true }).click(); + check(await sb.locator('.hm-orgchart-host').count() === 0, 'Storybook Unmount removes Core'); + await sb.getByRole('button', { name: 'Mount', exact: true }).click(); + await sb.waitForFunction(() => document.querySelector('.hm-orgchart-host')?.shadowRoot.querySelector('.node')); + check(await sb.locator('.hm-orgchart-host').count() === 1, 'Storybook remount creates one Core'); + } + if (story.id.includes('loading')) { + await sb.getByRole('button', { name: 'Source Error', exact: true }).click(); + await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('error')); + check(await sb.locator('.hm-orgchart-issues').count() === 1, 'Storybook source error controlled by Core'); + await sb.getByRole('button', { name: 'Ready v2 / 복구', exact: true }).click(); + await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('mock-2')); + check(await sb.locator('.node').count() === 3, 'Storybook Error to Ready recovery'); + await sb.getByRole('button', { name: '직원 0명 (Ready)', exact: true }).click(); + await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('story-zero-employees')); + check((await sb.locator('.hm-orgchart-raw-total').textContent()).includes('0명'), 'Storybook zero employees Ready'); + await sb.getByRole('button', { name: '조직 0개 (Error)', exact: true }).click(); + await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('error')); + check(await sb.locator('.hm-orgchart-issues').count() === 1, 'Storybook zero organizations Error'); + await sb.getByRole('button', { name: '느린 v1 (취소 무시)', exact: true }).click(); + await sb.getByRole('button', { name: 'Ready v2 / 복구', exact: true }).click(); + await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('mock-2')); + const outputBefore = await sb.locator('output').textContent(); + await sb.waitForTimeout(4100); + check(await sb.locator('output').textContent() === outputBefore, 'Storybook stale abort-ignoring response produces no callback'); + } + if (story.id.includes('multiple-instances')) { + const b = sb.getByRole('region', { name: 'OrgChart B', exact: true }); + const bIdentity = await b.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance'); + const bState = await b.locator('output').textContent(); + await sb.getByRole('button', { name: 'A → JSON v2', exact: true }).click(); + await sb.waitForFunction(() => document.querySelector('output')?.textContent.includes('mock-2')); + await sb.getByRole('button', { name: 'A Unmount', exact: true }).click(); + check(await sb.locator('.hm-orgchart-host').count() === 1 && await b.locator('.hm-orgchart-host').getAttribute('data-orgchart-instance') === bIdentity && await b.locator('output').textContent() === bState, 'Storybook A update/unmount leaves B identity, data and callback count unchanged'); + } + if (story.id.includes('basic-organization') || story.id.includes('employee-cards') || story.id.includes('responsive-container')) { + for (const width of [390, 768, 1280, 1440]) { + await sb.setViewportSize({ width, height: 1100 }); + if (story.id.includes('responsive-container')) await sb.getByRole('button', { name: `${width}px`, exact: true }).click(); + const dimensions = await sb.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + const nodes = [...shadow.querySelectorAll('.node')], viewport = shadow.querySelector('.hm-orgchart-viewport'); + const rects = nodes.map(node => node.getBoundingClientRect()); + return { + clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, + viewport: { clientWidth: viewport.clientWidth, scrollWidth: viewport.scrollWidth, clientHeight: viewport.clientHeight, scrollHeight: viewport.scrollHeight }, + nodeWidths: rects.map(rect => rect.width), + overlap: rects.some((a, index) => rects.slice(index + 1).some(b => Math.min(a.right,b.right)-Math.max(a.left,b.left)>1 && Math.min(a.bottom,b.bottom)-Math.max(a.top,b.top)>1)), + avatars: [...shadow.querySelectorAll('.hm-orgchart-avatar')].every(avatar => avatar.textContent && !avatar.querySelector('img')), + textFits: [...shadow.querySelectorAll('.hm-orgchart-person-name,.hm-orgchart-person-role,.hm-orgchart-organization-name')].every(element => element.scrollWidth <= element.clientWidth), + }; + }); + metrics.push({ story: story.name, width, ...dimensions }); + check(dimensions.scrollWidth <= width && !dimensions.overlap && dimensions.textFits && dimensions.avatars, `${story.name}: ${width}px document scroll/overlap/long text/profile fallback`, dimensions); + await sb.screenshot({ path: path.join(output, `${story.id}-${width}.png`), fullPage: true }); + } + } + await sb.screenshot({ path: path.join(output, `${story.id}.png`), fullPage: true }); + } + if (process.env.ORGCHART_STORYBOOK_URL) { + await sb.goto(`${process.env.ORGCHART_STORYBOOK_URL}/iframe.html?id=orgchart-content--basic-organization&viewMode=story`); + await sb.waitForFunction(() => document.querySelector('.hm-orgchart-host')?.shadowRoot.querySelector('.hm-orgchart-organization')); + check(await sb.locator('.node').count() === 3, 'development Storybook on reserved separate port executes real Wrapper'); + } + check(errors.length === 0, 'browser console/runtime errors are zero across consumer, Vanilla and Storybook', errors); +} catch (error) { + results.push({ name: 'verification completion', status: 'FAIL', detail: error.stack }); + process.exitCode = 1; +} finally { + await writeFile(path.join(output, 'report.json'), JSON.stringify({ browser: await browser.version(), results, errors, metrics, snapshots }, null, 2)); + await browser.close(); + for (const { server } of [consumer, vanilla, storybook]) await new Promise(resolve => server.close(resolve)); + console.log(JSON.stringify({ pass: results.filter(result => result.status === 'PASS').length, fail: results.filter(result => result.status === 'FAIL').length, errors, report: path.join(output, 'report.json') })); +} diff --git a/examples/org-chart-react/vite.config.ts b/examples/org-chart-react/vite.config.ts new file mode 100644 index 0000000..103783e --- /dev/null +++ b/examples/org-chart-react/vite.config.ts @@ -0,0 +1,11 @@ +import path from 'node:path'; +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + root: path.resolve(import.meta.dirname), + base: './', + plugins: [react()], + server: { host: '127.0.0.1', port: 6026, strictPort: true }, + build: { outDir: 'dist', emptyOutDir: true, rolldownOptions: { input: { index: path.resolve(import.meta.dirname, 'index.html'), p1: path.resolve(import.meta.dirname, 'p1.html'), p11: path.resolve(import.meta.dirname, 'p11.html') } } }, +}); diff --git a/examples/org-chart/app.js b/examples/org-chart/app.js new file mode 100644 index 0000000..b65be54 --- /dev/null +++ b/examples/org-chart/app.js @@ -0,0 +1,32 @@ +export function startDemo(api, inlineFixtures) { + const { createOrgChart, JsonOrgChartDataSource } = api; + const container = document.getElementById('chart-host'); + const status = document.getElementById('status'); + const stylesheetUrls = { + engine: new URL('./assets/engine.css', location.href).href, + core: new URL('./assets/style.css', location.href).href, + }; + const source = () => new JsonOrgChartDataSource(inlineFixtures ? inlineFixtures.initial : './data-v1.json'); + let chart; + function create() { + chart?.destroy(); + chart = createOrgChart({ container, dataSource: source(), stylesheetUrls, onStateChange(state) { + status.textContent = state.status === 'ready' ? `Ready · ${state.dataVersion}` + : state.status === 'error' ? `Error · ${state.issues.map(issue => issue.code).join(', ')}` : state.status; + } }); + return chart.mount(); + } + async function update() { + const data = inlineFixtures ? inlineFixtures.changed : await (await fetch('./data-v2.json')).json(); + await chart.update(data); + } + function destroy() { chart.destroy(); status.textContent = 'Destroyed · 새 인스턴스로 다시 생성할 수 있습니다.'; } + const actions = { reload: () => chart.reload(), update, destroy, recreate: create }; + for (const [id, action] of Object.entries(actions)) document.getElementById(id).addEventListener('click', () => { + Promise.resolve().then(action).catch(() => { status.textContent = 'Core 호출 순서를 확인하세요.'; }); + }); + window.addEventListener('pagehide', () => chart.destroy(), { once: true }); + // This example's inspection seam uses only public Core APIs, never engine internals. + window.OrgChartDemo = { api, container, stylesheetUrls, source, create, update, destroy, get chart() { return chart; } }; + create().then(() => { window.orgChartReady = true; }).catch(() => { status.textContent = 'Mount failed'; }); +} diff --git a/examples/org-chart/audit-quality-pixels.py b/examples/org-chart/audit-quality-pixels.py new file mode 100644 index 0000000..ca82e22 --- /dev/null +++ b/examples/org-chart/audit-quality-pixels.py @@ -0,0 +1,91 @@ +"""Read browser PNG backgrounds beside text; never edit production images. + +Sample positions cover DOM text line bounds, with a clear padding-column probe +for the current vertical opaque gradients. Pixel sampling and the sRGB CSS model +are distinct evidence; unsupported/clipped painting is NOT VERIFIED. +""" +import json +import math +import re +import sys +from pathlib import Path +from PIL import Image + +ROOT = Path(__file__).resolve().parents[2] +OUT = ROOT / 'test-results' / 'org-chart-quality' +report = json.loads((OUT / (sys.argv[1] if len(sys.argv) > 1 else 'browser.json')).read_text(encoding='utf-8')) + +def rgb(value): + match = re.fullmatch(r'rgba?\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)(?:,\s*(1))?\)', value) + return tuple(map(float, match.groups()[:3])) if match else None + +def luminance(color): + def linear(channel): + encoded = channel / 255 + return encoded / 12.92 if encoded <= .04045 else ((encoded + .055) / 1.055) ** 2.4 + return sum(weight * linear(channel) for weight, channel in zip((.2126, .7152, .0722), color)) + +def contrast(first, second): + a, b = sorted((luminance(first), luminance(second))) + return (b + .05) / (a + .05) + +evidence = [] +for measurement in report['measurements']: + image = Image.open(OUT / measurement['screenshot']).convert('RGB') + for item in measurement['colors']: + foreground = rgb(item['color']) + behind = item['behind'] + entry = dict(label=measurement['label'], width=measurement['width'], selector=item['className'], + organizationId=item.get('organizationId'), variant=item.get('variant'), target=item.get('target'), + foreground=item['color'], fontSize=item['fontSize'], fontWeight=item['fontWeight']) + threshold = 3 if item['fontSize'] >= 24 or (float(item['fontWeight']) >= 700 and item['fontSize'] >= 14 * 96 / 72) else 4.5 + entry['threshold'] = threshold + if not foreground or not behind: + entry.update(status='NOT VERIFIED', reason='Unknown foreground/background composition') + elif behind['gradient'] == 'none': + background = rgb(behind['background']) + if background: + value = contrast(foreground, background) + entry.update(status='PASS' if value >= threshold else 'REVIEW', method='computed opaque solid', ratio=value, background=behind['background']) + else: + entry.update(status='NOT VERIFIED', reason='Transparent/unsupported solid composition') + else: + stops = re.findall(r'rgb\((\d+),\s*(\d+),\s*(\d+)\)\s+(0|100)%', behind['gradient']) + box, lines, clip = behind['box'], item['ranges'], item.get('clip') + angle = re.search(r'linear-gradient\(([^r][^,]*),', behind['gradient']) + if len(stops) != 2 or not lines or (angle and angle.group(1).strip() != '180deg'): + entry.update(status='NOT VERIFIED', reason='Unsupported gradient/text range') + else: + colors = [tuple(map(int, stop[:3])) for stop in stops] + # Probe an empty padding column, inside corners and before text. + text_left = min(line['x'] for line in lines) + x = math.floor(min(text_left - 2, box['x'] + max(4, (text_left - box['x']) / 2))) + samples, deltas = [], [] + for line in lines: + for y in range(math.ceil(line['y']), math.floor(line['y'] + line['height']) + 1): + if not (0 <= x < image.width and 0 <= y < image.height): + continue + if clip and not (clip['x'] + 1 <= x < clip['x'] + clip['width'] - 1 and clip['y'] + 1 <= y < clip['y'] + clip['height'] - 1): + continue + offset = (y + .5 - box['y']) / box['height'] + if not 0 <= offset <= 1: + continue + pixel = image.getpixel((x, y)) + modeled = tuple(a + (b - a) * offset for a, b in zip(*colors)) + delta = max(abs(a - b) for a, b in zip(pixel, modeled)) + deltas.append(delta) + samples.append(dict(x=x, y=y, background=pixel, contrast=contrast(foreground, pixel), modelDelta=delta)) + if not samples or max(deltas) > 2: + entry.update(status='NOT VERIFIED', reason='Probe clipped, overpainted or disagrees with supported sRGB painting model', sampled=len(samples), maxModelDelta=max(deltas) if deltas else None) + else: + minimum = min(samples, key=lambda sample: sample['contrast']) + entry.update(status='PASS' if minimum['contrast'] >= threshold else 'REVIEW', method='PNG background beside actual DOM text lines', + ratio=minimum['contrast'], sampled=len(samples), maxModelDelta=max(deltas), weakest=minimum, + conservativeStopMinimum=min(contrast(foreground, color) for color in colors)) + evidence.append(entry) + +result = dict(method='DPR1 Chrome PNG; opaque foreground from computed CSS; padding-column gradient probes over DOM Range line bounds', + thresholds='Unrounded 4.5 normal / 3 large; no logo recoloring; no full WCAG certification', + results=evidence, statusCounts={status: sum(item['status'] == status for item in evidence) for status in ('PASS', 'REVIEW', 'NOT VERIFIED')}) +(OUT / (sys.argv[2] if len(sys.argv) > 2 else 'contrast-pixels.json')).write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8') +print(json.dumps(result['statusCounts'])) diff --git a/examples/org-chart/classic.html b/examples/org-chart/classic.html new file mode 100644 index 0000000..e6cc7d4 --- /dev/null +++ b/examples/org-chart/classic.html @@ -0,0 +1,25 @@ + + + + + HM OrgChart · 직접 열기 예제 + + + + +
+

OrgChart Core · 직접 열기 예제

+

가상 JSON을 Classic 번들로 실행합니다. file://에서 외부 JSON Fetch는 사용하지 않습니다.

+
+ + + + +
+

준비 중

+
+ + + + + diff --git a/examples/org-chart/create-business-card-assets.py b/examples/org-chart/create-business-card-assets.py new file mode 100644 index 0000000..9ab2adf --- /dev/null +++ b/examples/org-chart/create-business-card-assets.py @@ -0,0 +1,116 @@ +"""Extract business-management PNG colors into matching vector card templates. + +Reads the reference only. No bitmap is embedded in the generated SVGs. +Usage: python create-business-card-assets.py +""" +import hashlib +import json +import re +import shutil +import sys +from pathlib import Path + +import numpy as np +from PIL import Image + +root = Path(__file__).resolve().parents[2] +reference = Path(sys.argv[1]) +image = Image.open(reference).convert('RGBA') +pixels = np.asarray(image, dtype=float) +width, height = image.size +scale = np.array([width / 146, height / 68]) +start = np.array([124.527, 13.2139]) * scale +end = np.array([91.4091, 70.3397]) * scale +vector = end - start + +# Probe only the flat outer bands, away from rounded corners and inner face. +probes = ([(x, 2) for x in range(20, width - 20)] + + [(3, y) for y in range(20, height - 15)] + + [(width - 4, y) for y in range(20, height - 15)] + + [(x, height - 3) for x in range(20, width - 20)]) +coordinates = np.array(probes, dtype=float) + 0.5 +times = np.clip((coordinates - start) @ vector / (vector @ vector), 0, 1) +values = np.array([pixels[y, x] for x, y in probes]) +basis = np.zeros((len(times), 3)) +first = times <= 0.72 +basis[first, 0] = 1 - times[first] / 0.72 +basis[first, 1] = times[first] / 0.72 +basis[~first, 1] = (1 - times[~first]) / 0.28 +basis[~first, 2] = (times[~first] - 0.72) / 0.28 + +# Test both interpolation representations rather than assuming raster alpha. +fits = [] +for premultiplied in (False, True): + samples = values.copy() + if premultiplied: + samples[:, :3] *= samples[:, 3:] / 255 + stops = np.linalg.lstsq(basis, samples, rcond=None)[0] + predicted = basis @ stops + if premultiplied: + predicted[:, :3] /= np.maximum(predicted[:, 3:], 1) / 255 + stops[:, :3] /= np.maximum(stops[:, 3:], 1) / 255 + error = float(np.abs(predicted - values).mean()) + fits.append((error, premultiplied, np.clip(stops, 0, 255))) +error, premultiplied, stops = min(fits, key=lambda item: item[0]) + +# Use plateau medians for the clamped endpoints, reducing raster edge noise. +stops[0] = np.median(values[times == 0], axis=0) +stops[2] = np.median(values[times == 1], axis=0) +colors = ['#' + ''.join(f'{int(round(channel)):02X}' for channel in row[:3]) for row in stops] +opacities = [round(float(row[3] / 255), 2) for row in stops] +inner_top = pixels[9:35, 20:150].reshape(-1, 4) +inner_bottom = pixels[height - 13:height - 6, 20:150].reshape(-1, 4) +inner_colors = ['#' + ''.join(f'{int(round(channel)):02X}' for channel in row[:3]) + for row in [np.median(inner_top, axis=0), np.median(inner_bottom, axis=0)]] + +# Recover the inner face's gradient direction from its unclamped RGB plane. +# Colors alone are insufficient: retaining the template's gradient direction +# would leave a visible tone difference despite matching all endpoint colors. +inner_start_rgb = np.array([int(inner_colors[0][i:i + 2], 16) for i in (1, 3, 5)]) +inner_end_rgb = np.array([int(inner_colors[1][i:i + 2], 16) for i in (1, 3, 5)]) +delta = inner_start_rgb - inner_end_rgb +inner_points, inner_times = [], [] +for y in range(12, height - 12, 2): + for x in range(20, min(220, width // 2 + 16), 2): + rgb = pixels[y, x, :3] + t = float((inner_start_rgb - rgb) @ delta / (delta @ delta)) + if 0.06 < t < 0.94 and pixels[y, x, 3] == 255: + inner_points.append([(x + 0.5) / scale[0], (y + 0.5) / scale[1], 1]) + inner_times.append(t) +plane = np.linalg.lstsq(np.array(inner_points), np.array(inner_times), rcond=None)[0] +gradient_vector = plane[:2] / (plane[:2] @ plane[:2]) +inner_start = np.array([78.0741, (-plane[2] - plane[0] * 78.0741) / plane[1]]) +inner_end = inner_start + gradient_vector + +assets = root / 'packages/org-chart/src/assets/cards' +for suffix in ('', '-sm'): + template = (assets / f'경영기획팀{suffix}.svg').read_text(encoding='utf-8') + # Retain the supplied department geometry and its size-specific gradients. + for original, replacement in zip(['#DEE6F0', '#A8BBD1', '#577BA5'], colors): + template = template.replace(f'stop-color="{original}"', f'stop-color="{replacement}"') + template = template.replace(f'stop-color="{colors[2]}"', f'stop-color="{colors[2]}" stop-opacity="{opacities[2]}"') + template = template.replace('stop-color="white"', f'stop-color="{inner_colors[0]}"') + template = template.replace('stop-color="#E0E6F4"', f'stop-color="{inner_colors[1]}"') + template = template.replace('paint0_linear_', 'business_management_outer_').replace('paint1_linear_', 'business_management_inner_') + size_scale = np.array([1, 1]) if not suffix else np.array([108 / 146, 52 / 68]) + p1, p2 = inner_start * size_scale, inner_end * size_scale + template = re.sub(r'(]+>', + lambda match: f'{match[1]} x1="{p1[0]:.4f}" y1="{p1[1]:.4f}" x2="{p2[0]:.4f}" y2="{p2[1]:.4f}" gradientUnits="userSpaceOnUse">', template) + template = template.replace('offset="0.97"', 'offset="1"') + (assets / f'사업관리팀{suffix}.svg').write_text(template, encoding='utf-8') + +output = root / 'test-results/org-chart-card-design' +output.mkdir(parents=True, exist_ok=True) +shutil.copyfile(reference, output / 'business-management-reference.png') +evidence = { + 'reference': str(reference), 'sha256': hashlib.sha256(reference.read_bytes()).hexdigest(), + 'referenceSize': [width, height], 'outerStops': colors, 'outerOpacities': opacities, + 'innerStops': inner_colors, 'outerStopOffsets': [0, 0.72, 1], + 'innerGradientStart': inner_start.tolist(), 'innerGradientEnd': inner_end.tolist(), + 'innerSampleCount': len(inner_points), + 'sampleCount': len(probes), 'fitMeanAbsoluteRGBAError': error, + 'premultipliedFit': premultiplied, + 'method': 'Outer-band RGBA samples fitted to the supplied department gradient geometry; endpoint plateau medians and inner-face medians. Inner gradient direction recovered from unclamped RGB plane. Reference PNG is preserved; geometry reuses department SVG paths.', +} +(output / 'business-management-colors.json').write_text(json.dumps(evidence, ensure_ascii=False, indent=2), encoding='utf-8') +print(json.dumps(evidence, ensure_ascii=False, indent=2)) 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/create-fixtures.mjs b/examples/org-chart/create-fixtures.mjs new file mode 100644 index 0000000..e8d197b --- /dev/null +++ b/examples/org-chart/create-fixtures.mjs @@ -0,0 +1,15 @@ +// One-time fixture authoring helper; normal example builds read the checked-in fixtures. +import { readFile, writeFile } from 'node:fs/promises'; +const document = await readFile(new URL('../../docs/org-chart/data-contract.md', import.meta.url), 'utf8'); +const snippets = [...document.matchAll(/```json\r?\n([\s\S]*?)\r?\n```/g)].map(match => JSON.parse(match[1])); +const initial = snippets.find(value => Array.isArray(value.organizations)); +if (!initial) throw new Error('No documented fictitious dataset'); +initial.schemaVersion = '0.1-draft'; +initial.presentation.description = '가상 데이터 · 원본 집계와 업무상 총원은 구분됩니다.'; +const changed = structuredClone(initial); +changed.metadata.dataVersion = 'mock-2'; +changed.organizations[1].name = '가상 전략기획팀'; +changed.employees.find(person => person.id === 'emp-c').organizationId = 'team-b'; +changed.employees.push({ id: 'emp-e', organizationId: 'team-b', affiliationId: 'aff-a', name: '가상직원 마', position: '책임', jobTitle: null, profileImage: null, workStatus: 'active' }); +for (const [file, value] of [['data-v1.json', initial], ['data-v2.json', changed]]) + await writeFile(new URL(file, import.meta.url), JSON.stringify(value, null, 2) + '\n'); diff --git a/examples/org-chart/create-p1-fixture.mjs b/examples/org-chart/create-p1-fixture.mjs new file mode 100644 index 0000000..98c26c4 --- /dev/null +++ b/examples/org-chart/create-p1-fixture.mjs @@ -0,0 +1,56 @@ +import { writeFile } from 'node:fs/promises'; + +// Organization names were observed on Workdesk. Membership, employees and +// every count below are synthetic, not an export of production HR data. +const groups = [ + ['business-management', '사업관리', 'office', []], + ['business-planning', '경영기획', 'office', ['경영기획 셀', '사업관리 셀']], + ['talent-growth', '인재성장', 'office', ['HR기획 셀', '자산관리 셀']], + ['integrated-system', '통합시스템 Div', 'div', ['IS 1팀', 'IS 2팀', 'IS 3팀']], + ['design-planning', '디자인기획', 'office', ['UX 셀', '영상 셀', '편집 셀']], + ['solution-sales', '솔루션영업', 'office', ['S/W 셀']], +]; +const affiliations = [ + ['hanmac', '(주)한맥기술'], ['saman', '(주)삼안'], ['jangheon', '(주)장헌'], + ['jangheon-industry', '장헌산업'], ['ptc', 'PTC'], ['halla', '한라산업개발'], ['baron', '바론컨설턴트'], +].map(([id, name]) => ({ id, name })); +const organizations = [{ id: 'general-planning', parentId: null, name: '총괄기획실', type: 'root', leaderId: 'mock-leader-00', displayOrder: 0 }]; +// Invented display names only; never sourced from a Workdesk employee capture. +const names = [ + '김솔', '이도윤', '박하준', '정지우', '최예린', '한준서', '오수아', + '강민서', '윤시우', '서다은', '배지안', '임서준', '송하린', '조유찬', + '신도하', '문예은', '권지호', '안채원', '유시온', '홍서아', '고은우', + '남하윤', '백도현', '노유나', '장태오', '양서진', '진서율', '구민재', + '전하은', '심이준', '차소율', '우지율', '손예준', '함다온', '허지유', + '황서우', '성유빈', '곽지후', '류다인', '하주원', '이봄', +]; +const employees = [{ id: 'mock-leader-00', organizationId: 'general-planning', affiliationId: 'hanmac', name: names[0], position: '경영진', jobTitle: '실장', profileImage: null, workStatus: 'active' }]; +let person = 1; +for (const [index, [id, name, type, cells]] of groups.entries()) { + const leaderId = `mock-leader-${String(index + 1).padStart(2, '0')}`; + organizations.push({ id, parentId: 'general-planning', name, type, leaderId, displayOrder: index }); + employees.push({ id: leaderId, organizationId: id, affiliationId: ['hanmac', 'saman', 'baron'][index % 3], name: names[employees.length], position: '수석', jobTitle: type === 'div' ? '디비전장' : '팀장', profileImage: null, workStatus: 'active' }); + const employeeOrganizations = cells.length ? cells.map((cell, cellIndex) => { + const childId = `${id}-${cellIndex + 1}`; + organizations.push({ id: childId, parentId: id, name: cell, type: cell.includes('팀') ? 'team' : 'cell', leaderId: null, displayOrder: cellIndex }); + return childId; + }) : [id]; + for (const childId of employeeOrganizations) for (let member = 0; member < (cells.length ? 3 : 1); member++) { + const employeeId = `mock-employee-${String(person).padStart(2, '0')}`; + const isTeamLeader = childId.startsWith('integrated-system-') && member === 0; + employees.push({ id: employeeId, organizationId: childId, affiliationId: ['hanmac', 'saman', 'ptc', 'baron'][person % 4], name: names[employees.length], position: ['책임', '선임', '연구'][member % 3], jobTitle: isTeamLeader ? '팀장' : null, profileImage: null, workStatus: person === 3 ? 'dispatched' : person === 25 ? 'onLeave' : 'active' }); + if (isTeamLeader) organizations.find(item => item.id === childId).leaderId = employeeId; + person++; + } +} +const fixture = { + schemaVersion: '0.1-draft', organizations, employees, affiliations, relations: [], + presentation: { + title: '총괄기획실 조직도', description: '실제 조직명 · 가상 직원 정보 및 인원 통계 · 운영 인원수가 아닙니다.', hubText: '협업 · 소통 허브', + groups: { showEmployees: true, showSummary: true, showDispatched: true, showOnLeave: true, showAffiliations: true }, + organizationOrder: ['general-planning', ...groups.map(([id]) => id)], + }, + metadata: { asOfDate: '2026-10-12', dataVersion: 'p1-virtual-employees-v1', updateNote: '운영 HR 정보와 무관한 가상 구성' }, +}; +await writeFile(new URL('./data-p1.json', import.meta.url), `${JSON.stringify(fixture, null, 2)}\n`); +console.log(`P1 fixture: ${organizations.length} organizations / ${employees.length} synthetic employees`); 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 new file mode 100644 index 0000000..5779548 --- /dev/null +++ b/examples/org-chart/data-expanded.json @@ -0,0 +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": "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-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": "바론컨설턴트" + } + ], + "relations": [], + "presentation": { + "title": "확장형 조직도", + "description": "자동 계층 배치 · 가상 직원 정보", + "hubText": null, + "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-1b-expanded-hierarchy-v1", + "updateNote": "P5-1B 최소 재귀 계층 데이터 · 운영 HR 정보와 무관" + } +} diff --git a/examples/org-chart/data-p1.json b/examples/org-chart/data-p1.json new file mode 100644 index 0000000..9b5e57f --- /dev/null +++ b/examples/org-chart/data-p1.json @@ -0,0 +1,618 @@ +{ + "schemaVersion": "0.1-draft", + "organizations": [ + { + "id": "general-planning", + "parentId": null, + "name": "총괄기획실", + "type": "root", + "leaderId": "mock-leader-00", + "displayOrder": 0 + }, + { + "id": "business-management", + "parentId": "general-planning", + "name": "사업관리", + "type": "office", + "leaderId": "mock-leader-01", + "displayOrder": 0 + }, + { + "id": "business-planning", + "parentId": "general-planning", + "name": "경영기획", + "type": "office", + "leaderId": "mock-leader-02", + "displayOrder": 1 + }, + { + "id": "business-planning-1", + "parentId": "business-planning", + "name": "경영기획 셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "business-planning-2", + "parentId": "business-planning", + "name": "사업관리 셀", + "type": "cell", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "talent-growth", + "parentId": "general-planning", + "name": "인재성장", + "type": "office", + "leaderId": "mock-leader-03", + "displayOrder": 2 + }, + { + "id": "talent-growth-1", + "parentId": "talent-growth", + "name": "HR기획 셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "talent-growth-2", + "parentId": "talent-growth", + "name": "자산관리 셀", + "type": "cell", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "integrated-system", + "parentId": "general-planning", + "name": "통합시스템 Div", + "type": "div", + "leaderId": "mock-leader-04", + "displayOrder": 3 + }, + { + "id": "integrated-system-1", + "parentId": "integrated-system", + "name": "IS 1팀", + "type": "team", + "leaderId": "mock-employee-14", + "displayOrder": 0 + }, + { + "id": "integrated-system-2", + "parentId": "integrated-system", + "name": "IS 2팀", + "type": "team", + "leaderId": "mock-employee-17", + "displayOrder": 1 + }, + { + "id": "integrated-system-3", + "parentId": "integrated-system", + "name": "IS 3팀", + "type": "team", + "leaderId": "mock-employee-20", + "displayOrder": 2 + }, + { + "id": "design-planning", + "parentId": "general-planning", + "name": "디자인기획", + "type": "office", + "leaderId": "mock-leader-05", + "displayOrder": 4 + }, + { + "id": "design-planning-1", + "parentId": "design-planning", + "name": "UX 셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + }, + { + "id": "design-planning-2", + "parentId": "design-planning", + "name": "영상 셀", + "type": "cell", + "leaderId": null, + "displayOrder": 1 + }, + { + "id": "design-planning-3", + "parentId": "design-planning", + "name": "편집 셀", + "type": "cell", + "leaderId": null, + "displayOrder": 2 + }, + { + "id": "solution-sales", + "parentId": "general-planning", + "name": "솔루션영업", + "type": "office", + "leaderId": "mock-leader-06", + "displayOrder": 5 + }, + { + "id": "solution-sales-1", + "parentId": "solution-sales", + "name": "S/W 셀", + "type": "cell", + "leaderId": null, + "displayOrder": 0 + } + ], + "employees": [ + { + "id": "mock-leader-00", + "organizationId": "general-planning", + "affiliationId": "hanmac", + "name": "김솔", + "position": "경영진", + "jobTitle": "실장", + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-leader-01", + "organizationId": "business-management", + "affiliationId": "hanmac", + "name": "이도윤", + "position": "수석", + "jobTitle": "팀장", + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-01", + "organizationId": "business-management", + "affiliationId": "saman", + "name": "박하준", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-leader-02", + "organizationId": "business-planning", + "affiliationId": "saman", + "name": "정지우", + "position": "수석", + "jobTitle": "팀장", + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-02", + "organizationId": "business-planning-1", + "affiliationId": "ptc", + "name": "최예린", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-03", + "organizationId": "business-planning-1", + "affiliationId": "baron", + "name": "한준서", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "dispatched" + }, + { + "id": "mock-employee-04", + "organizationId": "business-planning-1", + "affiliationId": "hanmac", + "name": "오수아", + "position": "연구", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-05", + "organizationId": "business-planning-2", + "affiliationId": "saman", + "name": "강민서", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-06", + "organizationId": "business-planning-2", + "affiliationId": "ptc", + "name": "윤시우", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-07", + "organizationId": "business-planning-2", + "affiliationId": "baron", + "name": "서다은", + "position": "연구", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-leader-03", + "organizationId": "talent-growth", + "affiliationId": "baron", + "name": "배지안", + "position": "수석", + "jobTitle": "팀장", + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-08", + "organizationId": "talent-growth-1", + "affiliationId": "hanmac", + "name": "임서준", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-09", + "organizationId": "talent-growth-1", + "affiliationId": "saman", + "name": "송하린", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-10", + "organizationId": "talent-growth-1", + "affiliationId": "ptc", + "name": "조유찬", + "position": "연구", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-11", + "organizationId": "talent-growth-2", + "affiliationId": "baron", + "name": "신도하", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-12", + "organizationId": "talent-growth-2", + "affiliationId": "hanmac", + "name": "문예은", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-13", + "organizationId": "talent-growth-2", + "affiliationId": "saman", + "name": "권지호", + "position": "연구", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-leader-04", + "organizationId": "integrated-system", + "affiliationId": "hanmac", + "name": "안채원", + "position": "수석", + "jobTitle": "디비전장", + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-14", + "organizationId": "integrated-system-1", + "affiliationId": "ptc", + "name": "유시온", + "position": "책임", + "jobTitle": "팀장", + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-15", + "organizationId": "integrated-system-1", + "affiliationId": "baron", + "name": "홍서아", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-16", + "organizationId": "integrated-system-1", + "affiliationId": "hanmac", + "name": "고은우", + "position": "연구", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-17", + "organizationId": "integrated-system-2", + "affiliationId": "saman", + "name": "남하윤", + "position": "책임", + "jobTitle": "팀장", + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-18", + "organizationId": "integrated-system-2", + "affiliationId": "ptc", + "name": "백도현", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-19", + "organizationId": "integrated-system-2", + "affiliationId": "baron", + "name": "노유나", + "position": "연구", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-20", + "organizationId": "integrated-system-3", + "affiliationId": "hanmac", + "name": "장태오", + "position": "책임", + "jobTitle": "팀장", + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-21", + "organizationId": "integrated-system-3", + "affiliationId": "saman", + "name": "양서진", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-22", + "organizationId": "integrated-system-3", + "affiliationId": "ptc", + "name": "진서율", + "position": "연구", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-leader-05", + "organizationId": "design-planning", + "affiliationId": "saman", + "name": "구민재", + "position": "수석", + "jobTitle": "팀장", + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-23", + "organizationId": "design-planning-1", + "affiliationId": "baron", + "name": "전하은", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-24", + "organizationId": "design-planning-1", + "affiliationId": "hanmac", + "name": "심이준", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-25", + "organizationId": "design-planning-1", + "affiliationId": "saman", + "name": "차소율", + "position": "연구", + "jobTitle": null, + "profileImage": null, + "workStatus": "onLeave" + }, + { + "id": "mock-employee-26", + "organizationId": "design-planning-2", + "affiliationId": "ptc", + "name": "우지율", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-27", + "organizationId": "design-planning-2", + "affiliationId": "baron", + "name": "손예준", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-28", + "organizationId": "design-planning-2", + "affiliationId": "hanmac", + "name": "함다온", + "position": "연구", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-29", + "organizationId": "design-planning-3", + "affiliationId": "saman", + "name": "허지유", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-30", + "organizationId": "design-planning-3", + "affiliationId": "ptc", + "name": "황서우", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-31", + "organizationId": "design-planning-3", + "affiliationId": "baron", + "name": "성유빈", + "position": "연구", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-leader-06", + "organizationId": "solution-sales", + "affiliationId": "baron", + "name": "곽지후", + "position": "수석", + "jobTitle": "팀장", + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-32", + "organizationId": "solution-sales-1", + "affiliationId": "hanmac", + "name": "류다인", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-33", + "organizationId": "solution-sales-1", + "affiliationId": "saman", + "name": "하주원", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + }, + { + "id": "mock-employee-34", + "organizationId": "solution-sales-1", + "affiliationId": "ptc", + "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": "바론컨설턴트" + } + ], + "relations": [], + "presentation": { + "title": "총괄기획실 조직도", + "description": "실제 조직명 · 가상 직원 정보 및 인원 통계 · 운영 인원수가 아닙니다.", + "hubText": "협업 · 소통 허브", + "groups": { + "showEmployees": true, + "showSummary": true, + "showDispatched": true, + "showOnLeave": true, + "showAffiliations": true + }, + "organizationOrder": [ + "general-planning", + "business-management", + "business-planning", + "talent-growth", + "integrated-system", + "design-planning", + "solution-sales" + ] + }, + "metadata": { + "asOfDate": "2026-10-12", + "dataVersion": "p1-virtual-employees-v1", + "updateNote": "운영 HR 정보와 무관한 가상 구성" + } +} diff --git a/examples/org-chart/data-v1.json b/examples/org-chart/data-v1.json new file mode 100644 index 0000000..625dca7 --- /dev/null +++ b/examples/org-chart/data-v1.json @@ -0,0 +1,113 @@ +{ + "organizations": [ + { + "id": "org-root", + "parentId": null, + "name": "가상 사업본부", + "type": "root", + "leaderId": "emp-a", + "displayOrder": 0 + }, + { + "id": "team-a", + "parentId": "org-root", + "name": "가상 기획팀", + "type": "team", + "leaderId": "emp-b", + "displayOrder": 1 + }, + { + "id": "team-b", + "parentId": "org-root", + "name": "가상 운영팀", + "type": "team", + "leaderId": null, + "displayOrder": 2 + } + ], + "employees": [ + { + "id": "emp-a", + "organizationId": "org-root", + "affiliationId": "aff-a", + "name": "가상직원 가", + "position": "대표", + "jobTitle": "본부장", + "profileImage": null, + "workStatus": "active" + }, + { + "id": "emp-b", + "organizationId": "team-a", + "affiliationId": "aff-a", + "name": "가상직원 나", + "position": "책임", + "jobTitle": "팀장", + "profileImage": null, + "workStatus": "active" + }, + { + "id": "emp-c", + "organizationId": "team-a", + "affiliationId": "aff-b", + "name": "가상직원 다", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "dispatched" + }, + { + "id": "emp-d", + "organizationId": "team-b", + "affiliationId": "aff-b", + "name": "가상직원 라", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "onLeave" + } + ], + "affiliations": [ + { + "id": "aff-a", + "name": "가상 관계사 A" + }, + { + "id": "aff-b", + "name": "가상 관계사 B" + } + ], + "relations": [ + { + "id": "rel-a", + "sourceOrganizationId": "team-a", + "targetOrganizationId": "team-b", + "type": "cooperation" + } + ], + "presentation": { + "title": "가상 조직도", + "description": "가상 데이터 · 원본 집계와 업무상 총원은 구분됩니다.", + "hubText": "협업 중심", + "groups": { + "showEmployees": true, + "showSummary": true, + "showDispatched": true, + "showOnLeave": true, + "showAffiliations": true + }, + "organizationOrder": [ + "org-root", + "team-a", + "team-b" + ] + }, + "metadata": { + "schemaVersion": "0.1-draft", + "asOfDate": "2026-10-12", + "dataVersion": "mock-1", + "updatedAt": null, + "updateNote": "초안 예시" + }, + "schemaVersion": "0.1-draft" +} diff --git a/examples/org-chart/data-v2.json b/examples/org-chart/data-v2.json new file mode 100644 index 0000000..7fe03bf --- /dev/null +++ b/examples/org-chart/data-v2.json @@ -0,0 +1,123 @@ +{ + "organizations": [ + { + "id": "org-root", + "parentId": null, + "name": "가상 사업본부", + "type": "root", + "leaderId": "emp-a", + "displayOrder": 0 + }, + { + "id": "team-a", + "parentId": "org-root", + "name": "가상 전략기획팀", + "type": "team", + "leaderId": "emp-b", + "displayOrder": 1 + }, + { + "id": "team-b", + "parentId": "org-root", + "name": "가상 운영팀", + "type": "team", + "leaderId": null, + "displayOrder": 2 + } + ], + "employees": [ + { + "id": "emp-a", + "organizationId": "org-root", + "affiliationId": "aff-a", + "name": "가상직원 가", + "position": "대표", + "jobTitle": "본부장", + "profileImage": null, + "workStatus": "active" + }, + { + "id": "emp-b", + "organizationId": "team-a", + "affiliationId": "aff-a", + "name": "가상직원 나", + "position": "책임", + "jobTitle": "팀장", + "profileImage": null, + "workStatus": "active" + }, + { + "id": "emp-c", + "organizationId": "team-b", + "affiliationId": "aff-b", + "name": "가상직원 다", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "dispatched" + }, + { + "id": "emp-d", + "organizationId": "team-b", + "affiliationId": "aff-b", + "name": "가상직원 라", + "position": "선임", + "jobTitle": null, + "profileImage": null, + "workStatus": "onLeave" + }, + { + "id": "emp-e", + "organizationId": "team-b", + "affiliationId": "aff-a", + "name": "가상직원 마", + "position": "책임", + "jobTitle": null, + "profileImage": null, + "workStatus": "active" + } + ], + "affiliations": [ + { + "id": "aff-a", + "name": "가상 관계사 A" + }, + { + "id": "aff-b", + "name": "가상 관계사 B" + } + ], + "relations": [ + { + "id": "rel-a", + "sourceOrganizationId": "team-a", + "targetOrganizationId": "team-b", + "type": "cooperation" + } + ], + "presentation": { + "title": "가상 조직도", + "description": "가상 데이터 · 원본 집계와 업무상 총원은 구분됩니다.", + "hubText": "협업 중심", + "groups": { + "showEmployees": true, + "showSummary": true, + "showDispatched": true, + "showOnLeave": true, + "showAffiliations": true + }, + "organizationOrder": [ + "org-root", + "team-a", + "team-b" + ] + }, + "metadata": { + "schemaVersion": "0.1-draft", + "asOfDate": "2026-10-12", + "dataVersion": "mock-2", + "updatedAt": null, + "updateNote": "초안 예시" + }, + "schemaVersion": "0.1-draft" +} diff --git a/examples/org-chart/demo.css b/examples/org-chart/demo.css new file mode 100644 index 0000000..49a90fc --- /dev/null +++ b/examples/org-chart/demo.css @@ -0,0 +1,8 @@ +body { margin: 0; font-family: var(--hm-font-family-base); color: var(--hm-sem-fg-primary); background: var(--hm-sem-bg-content); } +main { padding: var(--hm-card-padding); } +h1 { margin: 0; font-size: var(--hm-card-title-font-size); font-weight: var(--hm-card-title-font-weight); } +main > p { color: var(--hm-sem-fg-secondary); } +.demo-controls { display: flex; flex-wrap: wrap; gap: 8px; } +.demo-controls button { font: inherit; color: var(--hm-sem-fg-primary); background: var(--hm-sem-bg-surface); border: 1px solid var(--hm-sem-stroke-default); border-radius: var(--hm-radius-control-md); padding: 8px 12px; cursor: pointer; } +.demo-controls button:focus-visible { outline: 2px solid var(--hm-sem-stroke-input-focus); outline-offset: 2px; } +#chart-host { min-width: 0; } 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/index.html b/examples/org-chart/index.html new file mode 100644 index 0000000..74562ff --- /dev/null +++ b/examples/org-chart/index.html @@ -0,0 +1,28 @@ + + + + + HM OrgChart · Vanilla JSON 예제 + + + + +
+

OrgChart Core · JSON 예제

+

가상 조직·직원 데이터입니다. 표시된 원본 직원 수는 업무상 재직 총원과 구분됩니다.

+
+ + + + +
+

준비 중

+
+
+ + + diff --git a/examples/org-chart/p1-app.js b/examples/org-chart/p1-app.js new file mode 100644 index 0000000..af84a33 --- /dev/null +++ b/examples/org-chart/p1-app.js @@ -0,0 +1,15 @@ +export async function startP1(api, inlineFixture) { + const data = inlineFixture ?? await new api.JsonOrgChartDataSource('./data-p1.json').load(); + const stylesheetUrls = { + engine: new URL('./assets/engine.css', location.href).href, + core: new URL('./assets/style.css', location.href).href, + }; + // ?mode=readOnly shows the view-only Workdesk composition; the default stays interactive. + const displayMode = new URLSearchParams(location.search).get('mode') === 'readOnly' ? 'readOnly' : 'interactive'; + document.documentElement.dataset.orgchartMode = displayMode; + const chart = api.createOrgChart({ container: document.getElementById('chart-host'), dataSource: new api.JsonOrgChartDataSource(data), stylesheetUrls, displayMode }); + // Development inspection seam: public Core API only. No engine internals. + window.OrgChartP1 = { api, data, chart, stylesheetUrls, setData: next => chart.update(next) }; + window.addEventListener('pagehide', () => chart.destroy(), { once: true }); + await chart.mount(); +} diff --git a/examples/org-chart/p1-classic.html b/examples/org-chart/p1-classic.html new file mode 100644 index 0000000..171fe4d --- /dev/null +++ b/examples/org-chart/p1-classic.html @@ -0,0 +1,13 @@ + + + + + HM OrgChart P1 · 직접 열기 + + + +
+ + + + diff --git a/examples/org-chart/p1.css b/examples/org-chart/p1.css new file mode 100644 index 0000000..338be5d --- /dev/null +++ b/examples/org-chart/p1.css @@ -0,0 +1,12 @@ +/* Reuse the existing local variable font; only OrgChart selects this family. */ +@font-face { + font-family: 'Noto Sans KR'; + src: url('./assets/fonts/NotoSansKR-variable.ttf') format('truetype'); + font-style: normal; + font-weight: 100 900; + font-display: swap; +} +body { margin: 0; background: #ffffff; } +main { padding: 16px; } +#chart-host { min-width: 0; } +html[data-orgchart-mode="readOnly"] body { background: #F5F8FB; } diff --git a/examples/org-chart/p1.html b/examples/org-chart/p1.html new file mode 100644 index 0000000..0d0a7c5 --- /dev/null +++ b/examples/org-chart/p1.html @@ -0,0 +1,16 @@ + + + + + HM OrgChart P1 · Full Composition + + + +
+ + + diff --git a/examples/org-chart/prepare.mjs b/examples/org-chart/prepare.mjs new file mode 100644 index 0000000..041aef1 --- /dev/null +++ b/examples/org-chart/prepare.mjs @@ -0,0 +1,42 @@ +import { cp, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +const example = fileURLToPath(new URL('./', import.meta.url)); +const root = path.resolve(example, '../..'); +const output = path.join(example, 'dist'); +await mkdir(path.join(output, 'assets'), { recursive: true }); +// The host's theme is generated from the existing public CSS entry; no maintained theme fork. +async function flatten(file, seen = new Set()) { + const full = path.resolve(file); + if (seen.has(full)) throw new Error('Theme CSS import cycle'); + const stack = new Set(seen).add(full); + const source = await readFile(full, 'utf8'); + const matches = [...source.matchAll(/@import\s+["']([^"']+)["'];/g)]; + let result = '', cursor = 0; + for (const match of matches) { + result += source.slice(cursor, match.index) + await flatten(path.resolve(path.dirname(full), match[1]), stack); + cursor = match.index + match[0].length; + } + return result + source.slice(cursor); +} +const theme = (await flatten(path.join(root, 'packages/ui/src/theme.css'))) + .replace('/fonts/NotoSansKR-variable.ttf', './fonts/NotoSansKR-variable.ttf'); +await writeFile(path.join(output, 'assets/hm-theme.css'), theme); +await cp(path.join(root, 'public/fonts'), path.join(output, 'assets/fonts'), { recursive: true }); +for (const file of ['index.js', 'orgchart.browser.js', 'style.css', 'engine.css']) + await cp(path.join(root, 'packages/org-chart/dist', file), path.join(output, 'assets', file)); +await cp(path.join(root, 'packages/org-chart/dist/licenses'), path.join(output, 'assets/licenses'), { recursive: true }); +for (const file of ['index.html', 'classic.html', 'app.js', 'demo.css', 'data-v1.json', 'data-v2.json', 'data-p1.json', 'p1.html', 'p1-classic.html', 'p1-app.js', 'p1.css']) + await cp(path.join(example, file), path.join(output, file)); +const initial = JSON.parse(await readFile(path.join(example, 'data-v1.json'), 'utf8')); +const changed = JSON.parse(await readFile(path.join(example, 'data-v2.json'), 'utf8')); +await writeFile(path.join(output, 'fixtures.js'), `window.OrgChartFixtures = ${JSON.stringify({initial, changed})};\n`); +const app = await readFile(path.join(example, 'app.js'), 'utf8'); +await writeFile(path.join(output, 'app-classic.js'), app.replace('export function startDemo', 'function startDemo')); +const p1 = JSON.parse(await readFile(path.join(example, 'data-p1.json'), 'utf8')); +await writeFile(path.join(output, 'p1-fixture.js'), `window.OrgChartP1Fixture = ${JSON.stringify(p1)};\n`); +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/serve.mjs b/examples/org-chart/serve.mjs new file mode 100644 index 0000000..2966784 --- /dev/null +++ b/examples/org-chart/serve.mjs @@ -0,0 +1,18 @@ +import http from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +const base = fileURLToPath(new URL('./dist/', import.meta.url)); +const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.css': 'text/css; charset=utf-8', '.ttf': 'font/ttf' }; +const port = Number(process.env.ORGCHART_PORT || 4178); +http.createServer(async (request, response) => { + try { + const url = new URL(request.url, 'http://localhost'); + const file = path.resolve(base, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname)); + const relative = path.relative(base, file); + if (relative.startsWith('..') || path.isAbsolute(relative)) { response.writeHead(403).end(); return; } + const data = await readFile(file); + response.setHeader('Content-Type', mime[path.extname(file)] || 'application/octet-stream'); + response.setHeader('Cache-Control', 'no-store'); response.end(data); + } catch { response.writeHead(404).end(); } +}).listen(port, '127.0.0.1', () => console.log(`OrgChart example: http://127.0.0.1:${port}`)); diff --git a/examples/org-chart/verify-card-content.mjs b/examples/org-chart/verify-card-content.mjs new file mode 100644 index 0000000..eefc964 --- /dev/null +++ b/examples/org-chart/verify-card-content.mjs @@ -0,0 +1,205 @@ +import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +import http from 'node:http'; +import path from 'node:path'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +// Stage 2 only: typography, data placement, overflow and accessible tooltip. +// Do not repeat Stage 1's gradient/data/Summary/engine suites. +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-card-content'); +await mkdir(output, { recursive: true }); +await loadPlaywright(); +const results = [], errors = [], measurements = [], servers = []; +const check = (value, name, detail) => results.push({ name, status: value ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); +const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ttf': 'font/ttf' }; +async function serve(directory) { + const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url, 'http://127.0.0.1'); + if (url.pathname === '/profile-fixture.svg') { + response.setHeader('Content-Type', 'image/svg+xml'); + return response.end(''); + } + const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname)); + const relative = path.relative(directory, file); + if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end(); + response.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); + response.end(await readFile(file)); + } catch { response.writeHead(404).end(); } + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); servers.push(server); + return `http://127.0.0.1:${server.address().port}`; +} +async function ready(page) { + await page.waitForFunction(() => { + const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot; + return shadow?.querySelector('[data-card-asset] .hm-orgchart-person-name[data-name-overflow]') + && [...shadow.querySelectorAll('link')].every(link => !!link.sheet); + }); + await page.evaluate(async () => { + await document.fonts.ready; + const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot; + await Promise.all([...shadow.querySelectorAll('img')].map(img => img.decode().catch(() => {}))); + }); +} +async function measure(page) { + return page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot; + const rect = element => { const b = element.getBoundingClientRect(); return { left: b.left, right: b.right, top: b.top, bottom: b.bottom, width: b.width, height: b.height }; }; + const typography = element => { + const style = getComputedStyle(element); + return Object.fromEntries(['color', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'margin', 'padding', 'whiteSpace', 'textOverflow', 'fontFamily'].map(key => [key, style[key]])); + }; + const cards = [...shadow.querySelectorAll('[data-card-asset]')].map(card => { + const role = card.querySelector('.hm-orgchart-person-role'), name = card.querySelector('.hm-orgchart-person-name'); + const avatar = card.querySelector('.hm-orgchart-avatar'), logo = card.querySelector('.hm-orgchart-affiliation-mark'); + const bounds = rect(card), roleBounds = rect(role), nameBounds = rect(name), issues = []; + const overlap = (a, b) => Math.min(a.right, b.right) - Math.max(a.left, b.left) > 1 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 1; + const contained = b => b.left >= bounds.left - 1 && b.right <= bounds.right + 1 && b.top >= bounds.top - 1 && b.bottom <= bounds.bottom + 1; + for (const [label, element] of [['role', role], ['name', name], ['avatar', avatar], ['logo', logo]]) if (!contained(rect(element))) issues.push(`${label} outside card`); + for (const element of [role, name]) { + // A clipped name's DOM Range intentionally retains the full text width; + // visible text is intersected with its clipping box for collision checks. + const range = document.createRange(); range.selectNodeContents(element); + for (const r of range.getClientRects()) { + const box = rect(element); + const visible = { left: Math.max(r.left, box.left), right: Math.min(r.right, box.right), top: Math.max(r.top, box.top), bottom: Math.min(r.bottom, box.bottom) }; + if (overlap(visible, rect(avatar))) issues.push(`${element.className}: avatar overlap`); + if (overlap(visible, rect(logo))) issues.push(`${element.className}: logo overlap`); + } + } + const badge = card.querySelector('.hm-orgchart-job-title'); + if (badge) { + if (!contained(rect(badge))) issues.push('badge outside card'); + if (overlap(rect(badge), rect(avatar)) || overlap(rect(badge), nameBounds)) issues.push('badge overlap'); + } + return { id: card.dataset.employeeId, size: card.dataset.cardAssetSize, name: name.textContent, role: typography(role), person: typography(name), + width: bounds.width, height: bounds.height, avatarWidth: rect(avatar).width, avatarHeight: rect(avatar).height, + textGap: nameBounds.top - roleBounds.bottom, cssGap: getComputedStyle(card.querySelector('.hm-orgchart-person-text')).gap, + overflow: name.dataset.nameOverflow, focusable: name.getAttribute('tabindex'), description: !!name.getAttribute('aria-describedby'), + nameWidth: name.clientWidth, nameScrollWidth: name.scrollWidth, issues, + imageLoaded: !!avatar.querySelector('img')?.naturalWidth, + logoTransparent: getComputedStyle(logo.querySelector('img')).backgroundColor === 'rgba(0, 0, 0, 0)', + fallbackHidden: getComputedStyle(logo.querySelector('.hm-orgchart-affiliation-fallback')).visibility === 'hidden' }; + }); + const viewport = shadow.querySelector('.hm-orgchart-viewport'); + return { cards, pageOverflow: document.documentElement.scrollWidth > innerWidth + 1, + extents: { document: [document.documentElement.scrollWidth, document.documentElement.scrollHeight, document.documentElement.clientWidth, document.documentElement.clientHeight], + viewport: viewport ? [viewport.scrollWidth, viewport.scrollHeight, viewport.clientWidth, viewport.clientHeight] : null }, + orphanTooltips: [...shadow.querySelectorAll('[role=tooltip]')].filter(tip => !shadow.querySelector(`[aria-describedby="${tip.id}"]`)).length }; + }); +} +function verifyTypography(value, label) { + for (const size of ['representative', 'leader', 'employee']) { + const cards = value.cards.filter(card => card.size === size), small = size === 'employee'; + check(cards.length > 0 && cards.every(card => card.role.color === (size === 'representative' ? 'rgb(34, 67, 135)' : 'rgb(121, 121, 121)') + && card.role.fontSize === (small ? '15px' : '20px') && card.role.fontWeight === '400' && card.role.lineHeight === (small ? '18px' : '22px') + && card.role.letterSpacing === (small ? '-0.6px' : '-0.8px')), `${label}/${size}: position exact computed typography`); + check(cards.every(card => card.person.color === 'rgb(7, 29, 58)' && card.person.fontSize === (small ? '20px' : '24px') && card.person.fontWeight === '600' + && card.person.lineHeight === (small ? '24px' : '30px') && card.person.letterSpacing === (size === 'leader' ? '-0.96px' : '-0.8px')), `${label}/${size}: name exact computed typography`); + check(cards.every(card => card.cssGap === '0px' && card.textGap === 0 && card.role.margin === '0px' && card.role.padding === '0px' + && card.person.margin === '0px' && card.person.padding === '0px'), `${label}/${size}: zero gap/margins/padding between two text rows`); + check(cards.every(card => card.width === (small ? 108 : 146) && card.height === (size === 'representative' ? 74 : small ? 52 : 68) + && card.avatarWidth === (size === 'representative' ? 36 : small ? 24 : 32) && card.avatarHeight === card.avatarWidth), `${label}/${size}: original card/profile dimensions retained`); + } + check(value.cards.every(card => card.issues.length === 0), `${label}: content collisions/bounds`, value.cards.filter(card => card.issues.length)); + check(value.cards.every(card => card.logoTransparent && card.fallbackHidden), `${label}: transparent original logos/fallback retained`); + check(!value.pageOverflow && value.orphanTooltips === 0, `${label}: no new page overflow/orphan tooltip`); +} +async function tooltipChecks(page, label) { + const names = page.locator('[data-card-asset] .hm-orgchart-person-name[data-name-overflow=true]'); + for (const size of ['representative', 'leader', 'employee']) { + const name = page.locator(`[data-card-asset-size="${size}"] .hm-orgchart-person-name[data-name-overflow=true]`).first(); + await name.scrollIntoViewIfNeeded(); + await name.hover(); + const description = await name.getAttribute('aria-describedby'), tip = page.locator(`#${description}`); + check(await tip.isVisible() && await tip.innerText() === await name.innerText(), `${label}/${size}: hover shows complete original name`); + await tip.hover(); + check(await tip.isVisible(), `${label}/${size}: tooltip hoverable`); + await page.mouse.move(0, 0); + await page.waitForFunction(() => [...document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot.querySelectorAll('[role=tooltip]')].every(tip => tip.hidden)); + // Tab through the real keyboard order from this explicit preceding control. + await name.evaluate(element => { const previous = document.createElement('button'); previous.textContent = 'keyboard test start'; element.before(previous); previous.focus(); }); + await page.keyboard.press('Tab'); + check(await name.evaluate(element => element.getRootNode().activeElement === element) && await tip.isVisible(), `${label}/${size}: Tab focus reveals full name`); + check((await name.ariaSnapshot()).includes(await name.innerText()) && await name.getAttribute('aria-describedby') === await tip.getAttribute('id'), `${label}/${size}: full text retained in Chromium accessibility representation/description`); + await page.keyboard.press('Escape'); + check(!(await tip.isVisible()) && await name.evaluate(element => element.getRootNode().activeElement === element), `${label}/${size}: Escape dismisses, focus retained`); + const tipBox = await tip.evaluate(element => { element.hidden = false; const b = element.getBoundingClientRect(); element.hidden = true; return { left: b.left, right: b.right, top: b.top, bottom: b.bottom }; }); + const screen = page.viewportSize(); + check(tipBox.left >= 0 && tipBox.right <= screen.width + 1 && tipBox.top >= 0 && tipBox.bottom <= screen.height + 1, `${label}/${size}: tooltip inside browser viewport`); + await name.evaluate(element => element.previousElementSibling.remove()); + } + check(await names.count() === 3, `${label}: only the three explicit long-name cases are focusable`); +} +let browser; +try { + const origins = await Promise.all(['storybook-static', 'examples/org-chart/dist', 'examples/org-chart-react/dist'].map(directory => serve(path.join(root, directory)))); + browser = await launchChromium({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, reducedMotion: 'reduce' }); + const routes = [['department-colors', `${origins[0]}/iframe.html?id=orgchart--department-colors&viewMode=story`], + ['full-composition', `${origins[0]}/iframe.html?id=orgchart--full-composition&viewMode=story`], ['html', `${origins[1]}/p1.html`], ['react', `${origins[2]}/p1.html`]]; + for (const [label, url] of routes) { + const page = await context.newPage(); + page.on('pageerror', error => errors.push({ label, message: error.message })); + page.on('console', message => { if (message.type() === 'error') errors.push({ label, message: message.text() }); }); + await page.goto(url); await ready(page); + for (const width of [390, 768, 1280, 1440]) { + await page.setViewportSize({ width, height: 1000 }); await ready(page); + const value = await measure(page); measurements.push({ label, width, ...value }); verifyTypography(value, `${label}/${width}`); + check(value.cards.every(card => /^[가-힣]{2,3}$/.test(card.name) && card.overflow === 'false' && card.focusable === null && card.person.textOverflow === 'clip'), `${label}/${width}: natural short names remain complete and outside tab order`); + } + if (label === 'department-colors') { + await page.locator('.hm-orgchart-representative-preview').screenshot({ path: path.join(output, 'representative-normal.png') }); + for (const [index, department] of [[0, 'business-management'], [1, 'business-planning'], [3, 'integrated-system']]) await page.locator('.hm-orgchart-preview-grid').nth(index).screenshot({ path: path.join(output, `${department}-normal.png`) }); + await page.getByRole('button', { name: '긴 이름 검증 시작' }).click(); await ready(page); + for (const width of [390, 1440]) { + await page.setViewportSize({ width, height: 1000 }); await ready(page); + const value = await measure(page); measurements.push({ label: 'storybook-long', width, ...value }); verifyTypography(value, `storybook-long/${width}`); + check(value.cards.filter(card => card.overflow === 'true').length === 3 && value.cards.filter(card => card.overflow === 'true').every(card => card.person.textOverflow === 'ellipsis' && card.focusable === '0' && card.description), `storybook-long/${width}: ellipsis and focus only on overflowing names`); + await tooltipChecks(page, `storybook-long/${width}`); + } + await page.locator('.hm-orgchart-representative-preview').scrollIntoViewIfNeeded(); + await page.locator('[data-card-asset-size=representative] .hm-orgchart-person-name').focus(); + await page.screenshot({ path: path.join(output, 'representative-long-focus.png') }); + await page.getByRole('button', { name: '긴 이름 검증 종료' }).click(); await ready(page); + check((await measure(page)).orphanTooltips === 0 && await page.locator('[role=tooltip]').count() === 0, 'Story long -> normal disposes tooltip nodes'); + } + if (label === 'full-composition') { + await page.locator('.node[data-organization-id=business-management]').scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(output, 'composition-1440.png') }); + } + if (label === 'html') { + await page.evaluate(async () => { + const next = structuredClone(window.OrgChartP1.data); + for (const id of ['mock-leader-00', 'mock-leader-01', 'mock-employee-01']) { + const person = next.employees.find(item => item.id === id); + person.name = '아주긴가상이름표시검증사례'; person.profileImage = `${location.origin}/profile-fixture.svg`; + } + await window.OrgChartP1.setData(next); + }); await ready(page); + const value = await measure(page); verifyTypography(value, 'html-long-images'); + check(value.cards.filter(card => card.overflow === 'true').every(card => card.imageLoaded), 'long names keep original-size profile images'); + await tooltipChecks(page, 'html-long-images'); + } + await page.close(); + } + for (const width of [390, 768, 1280, 1440]) { + const html = measurements.find(item => item.label === 'html' && item.width === width); + const react = measurements.find(item => item.label === 'react' && item.width === width); + check(JSON.stringify(html.cards) === JSON.stringify(react.cards), `HTML/React computed typography/content parity/${width}`); + } + const before = JSON.parse(await readFile(path.join(output, 'data-p1-before.json'), 'utf8')); + const after = JSON.parse(await readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8')); + for (const employee of before.employees) employee.name = after.employees.find(item => item.id === employee.id).name; + check(JSON.stringify(before) === JSON.stringify(after), 'fixture change is exclusively synthetic display names; contract/count/membership/profile preserved'); + check(errors.length === 0, 'browser console/runtime errors', errors); +} finally { + await writeFile(path.join(output, 'browser.json'), JSON.stringify({ browser: browser?.version(), results, errors, measurements }, null, 2)); + await browser?.close(); + for (const server of servers) await new Promise(resolve => server.close(resolve)); +} +const failures = results.filter(result => result.status === 'FAIL'); +console.log(JSON.stringify({ checks: results.length, failures: failures.map(result => ({ name: result.name, issues: result.detail?.length })) }, null, 2)); +if (failures.length) process.exitCode = 1; diff --git a/examples/org-chart/verify-card-design.mjs b/examples/org-chart/verify-card-design.mjs new file mode 100644 index 0000000..199db3e --- /dev/null +++ b/examples/org-chart/verify-card-design.mjs @@ -0,0 +1,202 @@ +import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +import http from 'node:http'; +import path from 'node:path'; +import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +// Verify only the new card assets/layout; do not repeat Core/Summary suites. +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-card-design'); +await mkdir(output, { recursive: true }); +await loadPlaywright(); +const results = [], measurements = [], errors = [], servers = []; +const check = (value, name, detail) => results.push({ name, status: value ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); +const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ttf': 'font/ttf', '.svg': 'image/svg+xml' }; +async function serve(directory) { + const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url, 'http://127.0.0.1'); + if (url.pathname === '/profile-fixture.svg') { + response.setHeader('Content-Type', 'image/svg+xml'); + return response.end(''); + } + const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname)); + const relative = path.relative(directory, file); + if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end(); + response.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); + response.end(await readFile(file)); + } catch { response.writeHead(404).end(); } + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + servers.push(server); + return `http://127.0.0.1:${server.address().port}`; +} +async function ready(page) { + await page.waitForFunction(() => { + const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot; + return shadow?.querySelector('[data-card-asset]') && [...shadow.querySelectorAll('link')].every(link => !!link.sheet); + }); + await page.evaluate(async () => { + await document.fonts.ready; + const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot; + await Promise.all([...shadow.querySelectorAll('img')].map(img => img.decode().catch(() => {}))); + }); +} +async function measure(page) { + return page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot; + const cards = [...shadow.querySelectorAll('[data-card-asset]')].map(card => { + const bounds = card.getBoundingClientRect(), background = card.querySelector('.hm-orgchart-card-background'); + const avatar = card.querySelector('.hm-orgchart-avatar').getBoundingClientRect(); + const logo = card.querySelector('.hm-orgchart-affiliation-mark')?.getBoundingClientRect(); + const style = getComputedStyle(card), issues = []; + const contained = rect => rect.left >= bounds.left - 1 && rect.right <= bounds.right + 1 && rect.top >= bounds.top - 1 && rect.bottom <= bounds.bottom + 1; + const overlap = (a, b) => b && Math.min(a.right, b.right) - Math.max(a.left, b.left) > 1 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 1; + if (!contained(avatar)) issues.push('avatar outside card'); + if (logo && !contained(logo)) issues.push('logo outside card'); + for (const element of card.querySelectorAll('.hm-orgchart-person-name, .hm-orgchart-person-role, .hm-orgchart-job-title, .hm-orgchart-status-label')) { + const range = document.createRange(); range.selectNodeContents(element); + for (const rect of range.getClientRects()) { + if (!contained(rect)) issues.push(`${element.className}: text outside card`); + if (overlap(rect, avatar)) issues.push(`${element.className}: avatar overlap`); + if (overlap(rect, logo)) issues.push(`${element.className}: logo overlap`); + } + } + return { id: card.dataset.employeeId, department: card.dataset.cardAsset, size: card.dataset.cardAssetSize, kind: card.dataset.cardKind, inTopSlot: !!card.closest('.hm-orgchart-top-leaders'), + width: bounds.width, height: bounds.height, backgroundLoaded: background.complete && background.naturalWidth > 0, + naturalWidth: background.naturalWidth, naturalHeight: background.naturalHeight, embedded: background.src.startsWith('data:image/svg+xml'), + border: style.borderWidth, radius: style.borderRadius, fill: style.backgroundColor, + decoration: getComputedStyle(card, '::before').content, issues, + logoLoaded: !!card.querySelector('.hm-orgchart-affiliation-mark img')?.naturalWidth, + fallbackHidden: getComputedStyle(card.querySelector('.hm-orgchart-affiliation-fallback')).visibility === 'hidden', + logoBackground: getComputedStyle(card.querySelector('.hm-orgchart-affiliation-mark img')).backgroundColor, + avatarImage: !!card.querySelector('.hm-orgchart-avatar img')?.naturalWidth }; + }); + return { cards, documentOverflow: document.documentElement.scrollWidth > innerWidth + 1, + legacyCards: [...shadow.querySelectorAll('.hm-orgchart-employee:not([data-card-asset])')].map(card => ({ id: card.dataset.employeeId, status: card.dataset.workStatus, kind: card.dataset.cardKind })), + contrastCount: shadow.querySelectorAll('.hm-orgchart-contrast-result').length, + headings: [...shadow.querySelectorAll('.hm-orgchart-color-department-name')].map(item => item.textContent) }; + }); +} +function verify(value, label) { + check(value.cards.length > 0 && value.cards.every(card => card.backgroundLoaded && card.embedded), `${label}: actual embedded SVGs load`); + check(value.cards.every(card => card.width === (card.size === 'employee' ? 108 : 146) && card.height === (card.size === 'representative' ? 74 : card.size === 'leader' ? 68 : 52) + && card.naturalWidth === card.width && card.naturalHeight === card.height), `${label}: native card sizes/ratios`); + check(value.cards.every(card => card.size === (card.kind === 'topLeader' ? 'representative' : card.kind === 'employee' ? 'employee' : 'leader')), `${label}: representative/leader/employee remain distinct`); + check(value.cards.every(card => card.border === '0px' && card.radius === '0px' && card.fill === 'rgba(0, 0, 0, 0)' && card.decoration === 'none'), `${label}: original SVG shape without legacy box decoration`); + check(value.cards.every(card => card.issues.length === 0), `${label}: text/avatar/logo bounds and collisions`, value.cards.filter(card => card.issues.length)); + check(value.cards.every(card => card.logoLoaded && card.fallbackHidden && card.logoBackground === 'rgba(0, 0, 0, 0)'), `${label}: existing transparent logos/fallback preserved`); + check(!value.documentOverflow, `${label}: no page overflow`); +} +let browser; +try { + const origins = await Promise.all(['storybook-static', 'examples/org-chart/dist', 'examples/org-chart-react/dist'].map(directory => serve(path.join(root, directory)))); + browser = await launchChromium({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1, reducedMotion: 'reduce' }); + const index = JSON.parse(await readFile(path.join(root, 'storybook-static/index.json'), 'utf8')); + const entries = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title.startsWith('OrgChart')); + check(entries.filter(entry => !entry.tags?.includes('internal')).map(entry => entry.id).sort().join() === ['logo', 'department-colors', 'position-summary', 'affiliation-summary', 'full-composition'].map(id => `orgchart--${id}`).sort().join(), 'five public Storybook menus preserved'); + check(entries.filter(entry => entry.title === 'OrgChart/Content' && entry.tags.includes('internal')).length === 8, 'eight internal Stories preserved'); + const assets = await readdir(path.join(root, 'packages/org-chart/src/assets/cards')); + const source = process.env.ORGCHART_CARD_SOURCE; + if (source) for (const file of assets.filter(file => file.endsWith('.svg') && !['대표자.svg', '사업관리팀.svg', '사업관리팀-sm.svg'].includes(file))) { + const hash = buffer => createHash('sha256').update(buffer).digest('hex'); + check(hash(await readFile(path.join(source, file))) === hash(await readFile(path.join(root, 'packages/org-chart/src/assets/cards', file))), `original SHA-256 preserved: ${file}`); + } + const routes = [ + ['department-colors', `${origins[0]}/iframe.html?id=orgchart--department-colors&viewMode=story`], + ['full-composition', `${origins[0]}/iframe.html?id=orgchart--full-composition&viewMode=story`], + ['html', `${origins[1]}/p1.html`], ['react', `${origins[2]}/p1.html`], + ['file-classic', pathToFileURL(path.join(root, 'examples/org-chart/dist/p1-classic.html')).href], + ]; + for (const [label, url] of routes) { + const page = await context.newPage(); + page.on('pageerror', error => errors.push({ label, message: error.message })); + page.on('console', message => { if (message.type() === 'error') errors.push({ label, message: message.text() }); }); + await page.goto(url); await ready(page); + for (const width of [390, 768, 1280, 1440]) { + await page.setViewportSize({ width, height: 1000 }); await ready(page); + const value = await measure(page); measurements.push({ label, width, ...value }); verify(value, `${label}/${width}`); + if (label === 'department-colors') { + check(value.cards.length === 13 && new Set(value.cards.filter(card => card.department !== 'representative').map(card => card.department)).size === 6, `${label}/${width}: six departments, both card sizes plus representative`); + check(value.contrastCount === 24 && value.headings.length === 6, `${label}/${width}: existing contrast display retained`); + } else { + check(new Set(value.cards.filter(card => card.department !== 'representative').map(card => card.department)).size === 6, `${label}/${width}: all six mapped branches present`); + check(value.cards.filter(card => card.department === 'representative').length === 1 && value.cards.some(card => card.id === 'mock-leader-00' && card.size === 'representative' && card.inTopSlot), `${label}/${width}: representative alone in top-leader slot`); + check(value.cards.some(card => card.id === 'mock-leader-01' && card.department === 'business-management' && card.size === 'leader') + && value.cards.some(card => card.id === 'mock-employee-01' && card.department === 'business-management' && card.size === 'employee'), `${label}/${width}: business-management maps its own two sizes`); + check(value.legacyCards.some(card => card.status !== 'active'), `${label}/${width}: supplementary status cards unchanged`); + } + } + if (label === 'department-colors') { + await page.screenshot({ path: path.join(output, 'department-colors-1440.png'), fullPage: true }); + await page.locator('.hm-orgchart-representative-preview').screenshot({ path: path.join(output, 'representative-card.png') }); + for (const [number, department] of [[0, 'business-management'], [1, 'business-planning'], [2, 'talent-growth'], [3, 'integrated-system'], [4, 'design-planning'], [5, 'solution-sales']]) { + await page.locator('.hm-orgchart-preview-grid').nth(number).screenshot({ path: path.join(output, `${department}-cards.png`) }); + } + } + if (label === 'full-composition') { + for (const department of ['business-management', 'business-planning', 'integrated-system', 'solution-sales']) { + await page.locator(`.node[data-organization-id="${department}"]`).scrollIntoViewIfNeeded(); + await page.screenshot({ path: path.join(output, `composition-${department}-1440.png`) }); + } + } + if (label === 'html') { + // A local image fixture tests the retained profile data layer without + // changing the source JSON or copying personal employee data. + await page.evaluate(async () => { + const next = structuredClone(window.OrgChartP1.data); + for (const id of ['mock-leader-00', 'mock-leader-02', 'mock-employee-02']) next.employees.find(item => item.id === id).profileImage = `${location.origin}/profile-fixture.svg`; + await window.OrgChartP1.setData(next); + }); + await ready(page); + const value = await measure(page); + const profiles = value.cards.filter(card => ['mock-leader-00', 'mock-leader-02', 'mock-employee-02'].includes(card.id)); + check(profiles.length === 3 && profiles.every(card => card.avatarImage && card.issues.length === 0), 'representative/leader/employee profile images retain bounds over SVG'); + } + await page.close(); + } + for (const width of [390, 768, 1280, 1440]) { + const canonical = value => JSON.stringify(value.cards); + const html = measurements.find(item => item.label === 'html' && item.width === width); + const react = measurements.find(item => item.label === 'react' && item.width === width); + const classic = measurements.find(item => item.label === 'file-classic' && item.width === width); + check(canonical(html) === canonical(react) && canonical(html) === canonical(classic), `HTML/React/file:// shared card rendering parity/${width}`); + } + const colorPage = await context.newPage(); + const reference = await readFile(path.join(output, 'business-management-reference.png')); + const vector = await readFile(path.join(root, 'packages/org-chart/src/assets/cards/사업관리팀.svg')); + const comparison = await colorPage.evaluate(async ({ reference, vector }) => { + const render = async (src) => { + const image = new Image(); image.src = src; await image.decode(); + const canvas = document.createElement('canvas'); canvas.width = 408; canvas.height = 191; + const ctx = canvas.getContext('2d'); ctx.drawImage(image, 0, 0, 408, 191); + return { pixels: ctx.getImageData(0, 0, 408, 191).data, png: canvas.toDataURL() }; + }; + const a = await render(reference), b = await render(vector), errors = []; + for (let i = 0; i < a.pixels.length; i += 4) { + for (let channel = 0; channel < 3; channel++) { + const ca = a.pixels[i + channel] * a.pixels[i + 3] / 255 + 255 - a.pixels[i + 3]; + const cb = b.pixels[i + channel] * b.pixels[i + 3] / 255 + 255 - b.pixels[i + 3]; + errors.push(Math.abs(ca - cb)); + } + } + const mean = errors.reduce((sum, value) => sum + value, 0) / errors.length; + errors.sort((x, y) => x - y); + return { meanRGBChannelError: mean, percentile95ChannelError: errors[Math.floor(errors.length * 0.95)], generatedPreview: b.png }; + }, { reference: `data:image/png;base64,${reference.toString('base64')}`, vector: `data:image/svg+xml;base64,${vector.toString('base64')}` }); + await writeFile(path.join(output, 'business-management-generated.png'), Buffer.from(comparison.generatedPreview.split(',')[1], 'base64')); + delete comparison.generatedPreview; + await writeFile(path.join(output, 'business-management-comparison.json'), JSON.stringify({ ...comparison, method: 'Chrome canvas, reference and generated SVG at reference raster dimensions, RGB over white, all pixels. Geometry/antialias differences included; not a perceptual certification.' }, null, 2)); + check(comparison.meanRGBChannelError < 3 && comparison.percentile95ChannelError < 8, 'business-management PNG versus SVG rendered color similarity', comparison); + await colorPage.close(); + check(errors.length === 0, 'browser console/runtime errors', errors); +} finally { + await writeFile(path.join(output, 'browser.json'), JSON.stringify({ browser: browser?.version(), results, errors, measurements }, null, 2)); + await browser?.close(); + for (const server of servers) await new Promise(resolve => server.close(resolve)); +} +const failures = results.filter(result => result.status === 'FAIL'); +console.log(JSON.stringify({ checks: results.length, failures: failures.map(result => ({ name: result.name, detail: result.detail?.length })) }, null, 2)); +if (failures.length) process.exitCode = 1; diff --git a/examples/org-chart/verify-final-card-check.mjs b/examples/org-chart/verify-final-card-check.mjs new file mode 100644 index 0000000..617517b --- /dev/null +++ b/examples/org-chart/verify-final-card-check.mjs @@ -0,0 +1,142 @@ +import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; + +// Only the eight final card requirements; existing 368-check evidence is untouched. +await loadPlaywright(); +const output = 'test-results/org-chart-final-check'; +await mkdir(output, { recursive: true }); +const fixture = JSON.parse(await readFile('examples/org-chart/data-p1.json', 'utf8')); +const results = [], measurements = [], errors = []; +const check = (ok, name, actual) => results.push({ name, status: ok ? 'PASS' : 'FAIL', actual }); +const browser = await launchChromium({ headless: true }); +const origin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6031'; +const collisionRepair = process.argv.includes('--collision-repair'); +async function capture(story, scenario, data) { + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + page.on('pageerror', error => errors.push({ story, scenario, message: error.message })); + page.on('console', message => { if (message.type() === 'error') errors.push({ story, scenario, message: message.text() }); }); + if (data) await page.route('**/examples/org-chart/data-p1.json*', route => route.fulfill({ + status: 200, contentType: 'application/javascript', body: `export default ${JSON.stringify(data)};`, + })); + await page.goto(`${origin}/iframe.html?id=orgchart--${story}&viewMode=story`); + await page.waitForFunction(() => { + const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot; + return shadow?.querySelector('[data-card-asset] .hm-orgchart-person-name[data-name-overflow]') + && [...shadow.querySelectorAll('link')].every(link => link.sheet); + }); + await page.evaluate(async () => { + await document.fonts.ready; + const root = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot; + await Promise.all([...root.querySelectorAll('img')].map(image => image.decode().catch(() => {}))); + }); + const cards = await page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot; + const box = element => { const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, right: r.right, bottom: r.bottom, width: r.width, height: r.height }; }; + // Collision checks use painted logo pixels, not the transparent container. + const logoInk = mark => { + const img = mark?.querySelector('img'); + if (!img?.naturalWidth) return null; + const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; + const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0); + const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height).data; + let left = canvas.width, right = -1, top = canvas.height, bottom = -1; + for (let y = 0; y < canvas.height; y++) for (let x = 0; x < canvas.width; x++) if (pixels[(y * canvas.width + x) * 4 + 3] > 16) { + left = Math.min(left, x); right = Math.max(right, x + 1); top = Math.min(top, y); bottom = Math.max(bottom, y + 1); + } + if (right < 0) return null; + const b = box(img), scale = Math.min(b.width / canvas.width, b.height / canvas.height); + const x = b.x + (b.width - canvas.width * scale) / 2, y = b.y + (b.height - canvas.height * scale) / 2; + return { x: x + left * scale, y: y + top * scale, right: x + right * scale, bottom: y + bottom * scale }; + }; + return [...shadow.querySelectorAll('[data-card-asset]')].map(card => { + const avatar = card.querySelector('.hm-orgchart-avatar'), text = card.querySelector('.hm-orgchart-person-text'); + const name = card.querySelector('.hm-orgchart-person-name'), position = card.querySelector('.hm-orgchart-person-role'); + const row = card.querySelector('.hm-orgchart-position-row'); + const badge = card.querySelector('.hm-orgchart-job-title'), attrs = card.querySelector('.hm-orgchart-person-attributes'); + const logo = card.querySelector('.hm-orgchart-affiliation-mark'); + const nameStyle = getComputedStyle(name), positionStyle = getComputedStyle(position), cardStyle = getComputedStyle(card); + const a = box(avatar), t = box(text), n = box(name), p = box(position), b = badge && box(badge), l = logoInk(logo); + const overlap = (u, v) => Math.min(u.right, v.right) - Math.max(u.x, v.x) > 1 && Math.min(u.bottom, v.bottom) - Math.max(u.y, v.y) > 1; + const collisions = []; + if (overlap(a, t)) collisions.push('avatar/text'); + if (b && overlap(b, a)) collisions.push('badge/avatar'); + if (b && overlap(b, n)) collisions.push('badge/name'); + if (l && overlap(l, a)) collisions.push('logo/avatar'); + if (l && overlap(l, n)) collisions.push('logo/name'); + if (l && b && overlap(l, b)) collisions.push('logo/badge'); + if (l) { + const range = document.createRange(); range.selectNodeContents(position); + for (const r of range.getClientRects()) if (overlap(l, { x: r.x, y: r.y, right: r.right, bottom: r.bottom })) collisions.push('logo/position'); + } + const bs = badge && getComputedStyle(badge); + return { id: card.dataset.employeeId, size: card.dataset.cardAssetSize, bounds: box(card), avatar: a, + columnGap: cardStyle.columnGap, photoTextGap: t.x - a.right, textGap: getComputedStyle(text).rowGap, + actualTextGap: n.y - p.bottom, nameColor: nameStyle.color, + row: row ? { display: getComputedStyle(row).display, alignItems: getComputedStyle(row).alignItems, gap: getComputedStyle(row).columnGap, + nameGap: n.y - box(row).bottom, badgeGap: b ? b.x - p.right : null, badgeMargin: bs?.margin ?? null, children: row.children.length } : null, + nameMargin: nameStyle.margin, namePadding: nameStyle.padding, positionMargin: positionStyle.margin, positionPadding: positionStyle.padding, + badge: badge ? { text: badge.textContent, fontSize: bs.fontSize, fontWeight: bs.fontWeight, + color: bs.color, background: bs.backgroundColor, bounds: b } : null, + attributesHeight: box(attrs).height, attributesPosition: getComputedStyle(attrs).position, + collisions }; + }); + }); + measurements.push({ story, scenario, cards }); + for (const card of cards) { + const prefix = `${story}/${scenario}/${card.id}/${card.size}`; + if (!collisionRepair) { + if (card.size === 'leader') check(card.columnGap === '10px' && Math.abs(card.photoTextGap - 10) < 0.1, `${prefix}: leader gap`, [card.columnGap, card.photoTextGap]); + if (card.size === 'employee') { + check(card.columnGap === '4px' && Math.abs(card.photoTextGap - 4) < 0.1, `${prefix}: employee gap`, [card.columnGap, card.photoTextGap]); + check(card.nameColor === 'rgb(50, 50, 50)', `${prefix}: name color`, card.nameColor); + } + check(card.textGap === '0px' && Math.abs(card.actualTextGap) < 0.1 && [card.nameMargin, card.namePadding, card.positionMargin, card.positionPadding].every(value => value === '0px'), `${prefix}: text gap`, card.actualTextGap); + const person = (data ?? fixture).employees.find(item => item.id === card.id); + check(card.badge?.text === person.jobTitle || (!person.jobTitle && !card.badge), `${prefix}: jobTitle-driven badge`, { jobTitle: person.jobTitle, badge: card.badge?.text ?? null }); + if (!person.jobTitle) check(card.attributesHeight === 0 && card.attributesPosition === 'absolute', `${prefix}: no empty badge space`, card.attributesHeight); + if (card.badge && card.size !== 'representative') check(card.badge.fontSize === (card.size === 'leader' ? '9px' : '8.5px') && card.badge.fontWeight === (card.size === 'leader' ? '700' : '600'), `${prefix}: badge typography`, card.badge); + if (card.size !== 'representative') { + check(card.row?.display === 'flex' && card.row.alignItems === 'center' && card.row.gap === '4px' && Math.abs(card.row.nameGap) < 0.1, `${prefix}: position/badge row and zero name gap`, card.row); + if (card.badge) check(Math.abs(card.row.badgeGap - 4) < 0.1 && card.row.badgeMargin === '0px', `${prefix}: actual position/badge gap and margin`, card.row); + else check(card.row.children === 1, `${prefix}: position only without badge`, card.row.children); + } + const dimensions = card.size === 'employee' ? [108, 52, 24] : card.size === 'leader' ? [146, 68, 32] : [146, 74, 36]; + check(card.bounds.width === dimensions[0] && card.bounds.height === dimensions[1] && card.avatar.width === dimensions[2] && card.avatar.height === dimensions[2], `${prefix}: preserved dimensions`, dimensions); + } + check(card.collisions.length === 0, `${prefix}: related collisions`, card.collisions); + } + if (!collisionRepair && (scenario === 'normal' || scenario === 'roles')) await page.screenshot({ path: `${output}/${story}-${scenario}.png`, fullPage: true }); + await page.close(); + return cards; +} +try { + const sample = await capture('department-colors', 'normal'); + await capture('full-composition', 'normal'); + if (!collisionRepair) { + const smallIds = sample.filter(card => card.size === 'employee').slice(0, 2).map(card => card.id); + check(smallIds.length === 2, 'two small-card role fixtures available', smallIds); + const roles = structuredClone(fixture); + smallIds.forEach((id, index) => { roles.employees.find(item => item.id === id).jobTitle = index ? '셀장' : '팀장'; }); + const noRole = structuredClone(fixture); + const leaderId = sample.find(card => card.size === 'leader').id; + noRole.employees.find(item => item.id === leaderId).jobTitle = null; + for (const story of ['department-colors', 'full-composition']) { + const cards = await capture(story, 'roles', roles); + for (const [index, id] of smallIds.entries()) check(cards.some(card => card.id === id && card.size === 'employee' && card.badge?.text === (index ? '셀장' : '팀장')), `${story}: small ${index ? 'cell' : 'team'} badge`, id); + const empty = await capture(story, 'no-role', noRole); + check(empty.some(card => card.id === leaderId && !card.badge && card.attributesHeight === 0), `${story}: leader without role has no badge/space`, leaderId); + } + } +} finally { await browser.close(); } +check(errors.length === 0, 'browser console/runtime errors', errors); +const baseline = JSON.parse(await readFile(`${output}/baseline.json`, 'utf8')); +const changed = []; +for (const [file, hash] of Object.entries(baseline)) { + const current = createHash('sha256').update(await readFile(file)).digest('hex'); + if (current !== hash) changed.push(file); +} +if (!collisionRepair) check(changed.length === 3 && changed.includes('packages/org-chart/src/core/employee-card.ts') && changed.includes('packages/org-chart/src/styles/employee-card.css') && changed.includes('packages/org-chart/src/styles/summary-sections.css'), 'preserved SVG/logo/JSON/API/Stories/368 evidence/Guard; only 3 existing files changed', changed); +await writeFile(`${output}/${collisionRepair ? 'card-collision-repair' : 'browser'}.json`, JSON.stringify({ results, measurements, errors, changed, fixtureNote: 'Role/no-role scenarios alter only intercepted in-memory browser JSON modules; repository JSON is unchanged.' }, null, 2)); +console.log(JSON.stringify({ checks: results.length, pass: results.filter(item => item.status === 'PASS').length, failures: results.filter(item => item.status === 'FAIL'), errors, changed }, null, 2)); +if (results.some(item => item.status === 'FAIL')) process.exitCode = 1; diff --git a/examples/org-chart/verify-final-logo-check.mjs b/examples/org-chart/verify-final-logo-check.mjs new file mode 100644 index 0000000..3d861b3 --- /dev/null +++ b/examples/org-chart/verify-final-logo-check.mjs @@ -0,0 +1,70 @@ +import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +import { readFile, writeFile } from 'node:fs/promises'; +await loadPlaywright(); +const output = 'test-results/org-chart-final-check'; +const baselineMode = process.argv.includes('--baseline'); +const repairMode = process.argv.includes('--geometry-repair'); +const browser = await launchChromium({ headless: true }); +const measurements = [], results = [], errors = []; +const check = (ok, name, actual) => results.push({ name, status: ok ? 'PASS' : 'FAIL', actual }); +const previous = baselineMode ? [] : JSON.parse(await readFile(`${output}/logo-before.json`, 'utf8')); +try { + for (const story of ['department-colors', 'full-composition']) { + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + let expectedImageFailure = false; + page.on('pageerror', error => errors.push(error.message)); + page.on('console', item => { if (item.type() === 'error' && !(expectedImageFailure && item.text().includes('ERR_INVALID_URL'))) errors.push(item.text()); }); + await page.goto(`${process.env.ORGCHART_STORY_ORIGIN}/iframe.html?id=orgchart--${story}&viewMode=story`); + await page.waitForFunction(() => { + const root = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot; + return root?.querySelector('[data-card-asset]') && [...root.querySelectorAll('link')].every(link => link.sheet); + }); + await page.evaluate(async () => { + await document.fonts.ready; + const root = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot; + await Promise.all([...root.querySelectorAll('img')].map(image => image.decode().catch(() => {}))); + }); + const logos = await page.evaluate(() => { + const root = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot; + const rect = element => { const b = element.getBoundingClientRect(); return { x: b.x, y: b.y, width: b.width, height: b.height }; }; + return [...root.querySelectorAll('[data-card-kind] .hm-orgchart-affiliation-mark')].map(mark => { + const img = mark.querySelector('img'), style = getComputedStyle(mark), fallback = mark.querySelector('.hm-orgchart-affiliation-fallback'); + return { id: mark.closest('[data-card-kind]').dataset.employeeId, kind: mark.closest('[data-card-kind]').dataset.cardKind, + bounds: rect(mark), image: img ? rect(img) : null, imageFit: img && getComputedStyle(img).objectFit, + background: style.backgroundColor, borders: [style.borderTopWidth, style.borderRightWidth, style.borderBottomWidth, style.borderLeftWidth], + borderStyle: style.borderTopStyle, shadow: style.boxShadow, + imageBackground: img && getComputedStyle(img).backgroundColor, + before: getComputedStyle(mark, '::before').content, after: getComputedStyle(mark, '::after').content, + loaded: !!img?.naturalWidth, fallbackVisibility: getComputedStyle(fallback).visibility }; + }); + }); + measurements.push({ story, logos }); + if (!baselineMode) { + for (const [index, logo] of logos.entries()) { + const before = previous.find(item => item.story === story).logos[index]; + if (!repairMode) check(logo.background === 'rgba(0, 0, 0, 0)' && logo.borders.every(value => value === '0px') && logo.borderStyle === 'none' && logo.shadow === 'none', `${story}/${logo.id}: transparent borderless container`, logo); + check(JSON.stringify(logo.bounds) === JSON.stringify(before.bounds) && JSON.stringify(logo.image) === JSON.stringify(before.image) && logo.imageFit === 'contain', `${story}/${logo.id}: unchanged container/image geometry`, { before, after: logo }); + if (!repairMode) check(logo.loaded && logo.fallbackVisibility === 'hidden' && logo.imageBackground === 'rgba(0, 0, 0, 0)' && ['none', 'normal'].includes(logo.before) && ['none', 'normal'].includes(logo.after), `${story}/${logo.id}: loaded transparent image and no fallback/decorations`, logo); + } + await page.locator('[data-employee-id="mock-leader-04"]').screenshot({ path: `${output}/${story}-logo-card.png` }); + expectedImageFailure = true; + const failure = await page.evaluate(async () => { + const root = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot; + const mark = root.querySelector('[data-card-asset] .hm-orgchart-affiliation-mark'); + mark.querySelector('img').src = 'data:image/png;base64,invalid-image'; + await new Promise(resolve => setTimeout(resolve, 100)); + const fallback = mark.querySelector('.hm-orgchart-affiliation-fallback'); + return { noImage: !mark.querySelector('img'), available: mark.dataset.logoAvailable, visibility: getComputedStyle(fallback).visibility, text: fallback.textContent }; + }); + if (!repairMode) check(failure.noImage && failure.available === 'false' && failure.visibility === 'visible' && !!failure.text, `${story}: failed image retains fallback`, failure); + } + await page.close(); + } +} finally { await browser.close(); } +if (baselineMode) await writeFile(`${output}/logo-before.json`, JSON.stringify(measurements, null, 2)); +else { + check(errors.length === 0, 'browser console/runtime errors', errors); + await writeFile(`${output}/${repairMode ? 'logo-geometry-repair' : 'logo-browser'}.json`, JSON.stringify({ results, measurements, errors }, null, 2)); + console.log(JSON.stringify({ checks: results.length, pass: results.filter(item => item.status === 'PASS').length, failures: results.filter(item => item.status === 'FAIL'), errors }, null, 2)); + if (results.some(item => item.status === 'FAIL')) process.exitCode = 1; +} diff --git a/examples/org-chart/verify-final-status-check.mjs b/examples/org-chart/verify-final-status-check.mjs new file mode 100644 index 0000000..2297faa --- /dev/null +++ b/examples/org-chart/verify-final-status-check.mjs @@ -0,0 +1,107 @@ +import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +import http from 'node:http'; +import path from 'node:path'; +import { readFile, writeFile } from 'node:fs/promises'; +await loadPlaywright(); +const output = 'test-results/org-chart-final-check'; +const baselineMode = process.argv.includes('--baseline'); +const colorsRepair = process.argv.includes('--colors-repair'); +const browser = await launchChromium({ headless: true }); +const results = [], measurements = [], errors = []; +const check = (ok, name, actual) => results.push({ name, status: ok ? 'PASS' : 'FAIL', actual }); +async function measure(url, label) { + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + page.on('pageerror', error => errors.push({ label, message: error.message })); + page.on('console', message => { if (message.type() === 'error') errors.push({ label, message: message.text() }); }); + await page.goto(url); + await page.waitForFunction(() => { + const root = document.querySelector('.hm-orgchart-host')?.shadowRoot; + return root?.querySelector('.hm-orgchart-status-group') && [...root.querySelectorAll('link')].every(link => link.sheet); + }); + await page.evaluate(async () => { + await document.fonts.ready; + const root = document.querySelector('.hm-orgchart-host').shadowRoot; + await Promise.all([...root.querySelectorAll('img')].map(image => image.decode().catch(() => {}))); + }); + const value = await page.evaluate(() => { + const root = document.querySelector('.hm-orgchart-host').shadowRoot; + const properties = (element, keys) => { const s = getComputedStyle(element); return Object.fromEntries(keys.map(key => [key, s[key]])); }; + const typography = element => properties(element, ['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'margin', 'padding']); + const box = element => { const b = element.getBoundingClientRect(); return { x: b.x, y: b.y, right: b.right, bottom: b.bottom }; }; + const overlap = (a, b) => Math.min(a.right, b.right) - Math.max(a.x, b.x) > 1 && Math.min(a.bottom, b.bottom) - Math.max(a.y, b.y) > 1; + const organization = [...root.querySelectorAll('[data-card-asset]')].map(card => ({ id: card.dataset.employeeId, + card: properties(card, ['backgroundImage', 'border', 'width', 'height', 'columnGap']), + name: properties(card.querySelector('.hm-orgchart-person-name'), ['color', 'fontSize', 'fontWeight']), + logo: properties(card.querySelector('.hm-orgchart-affiliation-mark img'), ['filter', 'opacity', 'width', 'height']) })); + const groups = [...root.querySelectorAll('.hm-orgchart-status-group')].map(group => ({ + status: group.dataset.workStatus, titleText: group.querySelector('h3').textContent, + title: properties(group.querySelector('h3'), ['backgroundImage', 'border', 'boxShadow']), + cards: [...group.querySelectorAll('.hm-orgchart-employee')].map(card => { + const role = card.querySelector('.hm-orgchart-person-role'), name = card.querySelector('.hm-orgchart-person-name'); + const avatar = card.querySelector('.hm-orgchart-avatar'), mark = card.querySelector('.hm-orgchart-affiliation-mark'), image = mark.querySelector('img'); + const collisions = []; + const roleRange = document.createRange(); roleRange.selectNodeContents(role); + const nameRange = document.createRange(); nameRange.selectNodeContents(name); + for (const [kind, range] of [['role', roleRange], ['name', nameRange]]) for (const r of range.getClientRects()) { + const textBox = { x: r.x, y: r.y, right: r.right, bottom: r.bottom }; + if (overlap(textBox, box(avatar))) collisions.push(`${kind}/avatar`); + if (overlap(textBox, box(image))) collisions.push(`${kind}/logo`); + } + return { id: card.dataset.employeeId, card: properties(card, ['margin', 'backgroundImage', 'border', 'borderRadius', 'cursor', 'columnGap', 'filter', 'opacity']), + roleColor: getComputedStyle(role).color, nameColor: getComputedStyle(name).color, + roleTypography: typography(role), nameTypography: typography(name), + logo: properties(image, ['filter', 'opacity']), mark: properties(mark, ['backgroundColor', 'border', 'boxShadow', 'filter', 'opacity']), collisions }; + }) + })); + return { organization, groups, radiusVariable: getComputedStyle(root.querySelector('.hm-orgchart-shell')).getPropertyValue('--br-3_5').trim(), + resolvedRadius: getComputedStyle(root.querySelector('.hm-orgchart-shell')).getPropertyValue('--hm-orgchart-resolved-radius-container').trim() }; + }); + if (!baselineMode) await page.locator('.hm-orgchart-status-summary').screenshot({ path: `${output}/${label}-status.png` }); + await page.close(); + return value; +} +let server; +try { + const storyUrl = `${process.env.ORGCHART_STORY_ORIGIN}/iframe.html?id=orgchart--full-composition&viewMode=story`; + const react = await measure(storyUrl, 'react'); + if (baselineMode) await writeFile(`${output}/status-before.json`, JSON.stringify(react, null, 2)); + else { + const baseline = JSON.parse(await readFile(`${output}/status-before.json`, 'utf8')); + if (!colorsRepair) check(JSON.stringify(react.organization) === JSON.stringify(baseline.organization), 'normal organization cards and colored logos unchanged', react.organization.length); + const directory = path.resolve('examples/org-chart/dist'); + server = http.createServer(async (req, res) => { + try { + const file = path.resolve(directory, '.' + decodeURIComponent(new URL(req.url, 'http://localhost').pathname)); + if (path.relative(directory, file).startsWith('..')) return res.writeHead(403).end(); + res.setHeader('Content-Type', ({ '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', '.json': 'application/json', '.ttf': 'font/ttf' })[path.extname(file)] ?? 'application/octet-stream'); + res.end(await readFile(file)); + } catch { res.writeHead(404).end(); } + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const html = await measure(`http://127.0.0.1:${server.address().port}/p1.html`, 'html'); + measurements.push({ label: 'react', ...react }, { label: 'html', ...html }); + check(JSON.stringify(react.groups) === JSON.stringify(html.groups), 'HTML and React status groups computed styles match', react.groups.map(group => group.status)); + for (const group of react.groups) { + const before = baseline.groups.find(item => item.status === group.status); + if (colorsRepair) { + for (const card of group.cards) check(card.roleColor === 'rgb(85, 95, 106)' && card.nameColor === 'rgb(62, 70, 80)', `${group.status}/${card.id}: repaired text colors`, [card.roleColor, card.nameColor]); + continue; + } + check(group.title.backgroundImage === 'linear-gradient(rgb(135, 144, 156) 0%, rgb(104, 114, 126) 100%)' && group.title.border === '1px solid rgba(92, 101, 112, 0.52)' && group.title.boxShadow === 'rgba(75, 84, 96, 0.18) 0px 7px 16px 0px', `${group.status}: title gradient/border/shadow`, group.title); + for (const card of group.cards) { + const old = before.cards.find(item => item.id === card.id); + check(card.card.backgroundImage === 'linear-gradient(rgb(244, 245, 246) 0%, rgb(223, 227, 231) 100%)' && card.card.border === '1px solid rgba(120, 130, 142, 0.35)' && card.card.borderRadius === '4px' && card.card.margin === '0px' && card.card.cursor === 'pointer', `${group.status}/${card.id}: card surface`, card.card); + check(card.roleColor === 'rgb(85, 95, 106)' && card.nameColor === 'rgb(62, 70, 80)', `${group.status}/${card.id}: text colors`, [card.roleColor, card.nameColor]); + check(JSON.stringify(card.roleTypography) === JSON.stringify(old.roleTypography) && JSON.stringify(card.nameTypography) === JSON.stringify(old.nameTypography) && card.card.columnGap === old.card.columnGap, `${group.status}/${card.id}: existing typography and gap preserved`, { role: card.roleTypography, name: card.nameTypography, gap: card.card.columnGap }); + check(card.logo.filter === 'grayscale(1)' && card.logo.opacity === '0.72' && card.mark.filter === 'none' && card.mark.opacity === '1' && card.card.filter === 'none' && card.card.opacity === '1', `${group.status}/${card.id}: image-only filter/opacity`, card.logo); + check(card.mark.backgroundColor === 'rgba(0, 0, 0, 0)' && card.mark.border === '0px none rgb(89, 101, 117)' && card.mark.boxShadow === 'none', `${group.status}/${card.id}: transparent logo frame`, card.mark); + check(card.collisions.length === 0, `${group.status}/${card.id}: profile/text/logo collisions`, card.collisions); + } + check(group.titleText === before.titleText && group.cards.length === before.cards.length, `${group.status}: same employee IDs/counts`, group.cards.map(card => card.id)); + } + check(errors.length === 0, 'browser errors', errors); + await writeFile(`${output}/${colorsRepair ? 'status-colors-repair' : 'status-browser'}.json`, JSON.stringify({ results, measurements, errors }, null, 2)); + console.log(JSON.stringify({ checks: results.length, pass: results.filter(item => item.status === 'PASS').length, failures: results.filter(item => item.status === 'FAIL'), errors }, null, 2)); + if (results.some(item => item.status === 'FAIL')) process.exitCode = 1; + } +} finally { await browser.close(); if (server) await new Promise(resolve => server.close(resolve)); } diff --git a/examples/org-chart/verify-guard-p1.mjs b/examples/org-chart/verify-guard-p1.mjs new file mode 100644 index 0000000..f78a202 --- /dev/null +++ b/examples/org-chart/verify-guard-p1.mjs @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { performance } from 'node:perf_hooks'; +import { parse } from '@babel/parser'; +import postcss from 'postcss'; +import { themeAnalysis } from '../../scripts/guard-theme-analysis.mjs'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const script = fileURLToPath(import.meta.url); +const rawFile = path.join(root, 'test-results/org-chart-p1/guard-storybook-menu-recheck.json'); +const output = path.join(root, 'test-results/org-chart-quality/guard-reproduction.json'); +const started = performance.now(); +const hash = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const rawHashBefore = hash(rawFile); +const raw = JSON.parse(fs.readFileSync(rawFile, 'utf8')); +const analyzerHashBefore = hash(path.join(root, 'scripts/guard-theme-analysis.mjs')); + +function walk(node, visitor, parent = null, field = null) { + if (!node || typeof node !== 'object') return; + if (node.type) visitor(node, parent, field); + for (const [key, value] of Object.entries(node)) { + if (['loc', 'start', 'end', 'comments', 'tokens', 'extra'].includes(key)) continue; + if (Array.isArray(value)) value.forEach(item => walk(item, visitor, node, key)); + else if (value && typeof value === 'object') walk(value, visitor, node, key); + } +} + +/** In-memory fixtures use the function's existing injected parser interface. + * The script itself is a readable CSS-file sentinel because the real analyzer + * reads files before invoking its parser. The parser returns the supplied + * fixture CSS AST; no fixture files or production code are changed. */ +function analyze(label, jsSource, cssSource) { + const jsFile = `memory://${label}.js`; + const records = [{ file: jsFile, edges: [], ast: parse(jsSource, { sourceType: 'module' }) }]; + if (cssSource !== undefined) records.push({ file: script, edges: [], css: true }); + const issues = []; + const result = themeAnalysis({ + records, workspace: root, + postcss: { parse: () => postcss.parse(cssSource ?? '', { from: `memory://${label}.css` }) }, + walk, selectedFile: () => true, + add: (severity, code, file, node, message) => issues.push({ + severity, code, file: file === script ? `memory://${label}.css` : file, + line: node?.loc?.start?.line ?? node?.source?.start?.line ?? 1, message, + }), + }); + return { + label, fixture: { js: jsSource, css: cssSource ?? null }, + analyzerStatus: issues.some(issue => issue.severity === 'FAIL') ? 'FAIL' + : issues.length ? 'NOT VERIFIED' : 'PASS', + definitions: result.definitions, references: result.references.map(ref => ({ + ...ref, file: ref.file === script ? `memory://${label}.css` : ref.file, + })), dynamic: result.dynamic, incomplete: result.incomplete, issues, + }; +} + +const measurements = []; +const assertions = []; +function check(name, condition) { + assertions.push({ name, status: condition ? 'PASS' : 'FAIL' }); + assert.ok(condition, name); +} +let failure; +try { + const graphSource = 'const background = "var(--hm-orgchart-resolved-bg-surface)";'; + const excluded = analyze('shadow-css-excluded', graphSource); + const included = analyze('shadow-css-included', graphSource, + '.hm-orgchart-shell { --hm-orgchart-resolved-bg-surface: #ffffff; }'); + measurements.push(excluded, included); + check('Excluded Shadow CSS reference reproduces TOKEN-001 FAIL', + excluded.issues.some(issue => issue.code === 'TOKEN-001' && issue.severity === 'FAIL')); + check('Connected CSS declaration resolves the identical reference', + included.analyzerStatus === 'PASS' && included.definitions.includes('--hm-orgchart-resolved-bg-surface')); + + const setterSource = 'element.style.setProperty("--hm-orgchart-current-border", "#194d82");\n' + + 'element.style.setProperty("--hm-orgchart-accent", "var(--hm-orgchart-current-border)");'; + const setterOnly = analyze('set-property-not-collected', setterSource); + const setterWithCss = analyze('set-property-with-css-declaration', setterSource, + '.hm-orgchart-group { --hm-orgchart-current-border: #194d82; }'); + measurements.push(setterOnly, setterWithCss); + check('Actual literal setProperty definition is absent from analyzer definitions', + !setterOnly.definitions.includes('--hm-orgchart-current-border') && setterOnly.analyzerStatus === 'FAIL'); + check('CSS definition resolves setProperty reference without changing JS fixture', + setterWithCss.analyzerStatus === 'PASS'); + + const optional = analyze('optional-public-input-with-fallback', '', + '.hm-orgchart-shell { --hm-orgchart-resolved-fg-primary: var(--hm-orgchart-fg-primary, #10243b); }'); + measurements.push(optional); + const optionalReference = optional.references.find(ref => ref.name === '--hm-orgchart-fg-primary'); + check('Optional standalone input with valid fallback is still classified TOKEN-001 FAIL', + optionalReference?.fallback === true && optionalReference.status === 'FAIL' + && optional.issues.some(issue => issue.code === 'TOKEN-001' && issue.severity === 'FAIL')); + + // Diagnostic namespace control only. Renaming OrgChart inputs is NOT a fix. + const otherNamespace = analyze('external-input-namespace-control', '', + '.content { --resolved-fg-primary: var(--external-fg-primary, #10243b); }'); + measurements.push(otherNamespace); + check('Same fallback shape outside HM namespace is NOT VERIFIED, not FAIL', + otherNamespace.analyzerStatus === 'NOT VERIFIED' && otherNamespace.references[0]?.fallback === true); + check('Historical raw report remains FAIL with the original three TOKEN-001 findings', + raw.status === 'FAIL' && raw.staticStatus === 'FAIL' + && raw.issues.filter(issue => issue.code === 'TOKEN-001').length === 3); + check('Original Guard output and analyzer source hashes remain unchanged', + hash(rawFile) === rawHashBefore && hash(path.join(root, 'scripts/guard-theme-analysis.mjs')) === analyzerHashBefore); +} catch (error) { + failure = { name: error.name, message: error.message }; +} + +const report = { + schemaVersion: 1, + purpose: 'Minimal reproduction of existing analyzer behavior; assertion PASS is NOT OrgChart quality or Guard PASS', + reproductionStatus: failure ? 'FAIL' : 'PASS', + generatedAt: new Date().toISOString(), durationMs: Math.round((performance.now() - started) * 100) / 100, + assertions, measurements, + originalGuard: { + file: 'test-results/org-chart-p1/guard-storybook-menu-recheck.json', + sha256Before: rawHashBefore, sha256After: hash(rawFile), status: raw.status, staticStatus: raw.staticStatus, + relevantIssues: raw.issues.filter(issue => ['TOKEN-001', 'TOKEN-002'].includes(issue.code)), + }, + analyzer: { file: 'scripts/guard-theme-analysis.mjs', sha256Before: analyzerHashBefore, + sha256After: hash(path.join(root, 'scripts/guard-theme-analysis.mjs')) }, + scope: 'In-memory fixtures passed directly to the actual exported themeAnalysis; no full Guard suite or browser checks', + limitations: [ + 'This demonstrates analyzer limitations; it does not implement a revised Guard policy', + 'Fixture graph inclusion is controlled directly; real Shadow URL dataflow is not implemented or rerun', + 'The CSS declaration control is a fixture, not a production dummy declaration proposal', + 'The external namespace control is diagnostic; production namespace renaming would be a bypass', + 'Original Guard FAIL remains unchanged; runtime evidence cannot overwrite that FAIL', + 'Common Guard changes require separate user approval', + ], + ...(failure ? { failure } : {}), +}; +fs.mkdirSync(path.dirname(output), { recursive: true }); +fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`); +console.log(JSON.stringify({ reproductionStatus: report.reproductionStatus, assertions: assertions.length, + measurements: measurements.length, originalGuardStatus: raw.status, output: path.relative(root, output) })); +if (failure) process.exitCode = 1; diff --git a/examples/org-chart/verify-p1.mjs b/examples/org-chart/verify-p1.mjs new file mode 100644 index 0000000..6ca768a --- /dev/null +++ b/examples/org-chart/verify-p1.mjs @@ -0,0 +1,568 @@ +import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +import http from 'node:http'; +import path from 'node:path'; +import { readFile, mkdir, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +// Verification tooling is supplied by the environment, never shipped in Core. +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-p1'); +await mkdir(output, { recursive: true }); +const fixture = JSON.parse(await readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8')); +await loadPlaywright(); +const mime = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.ttf': 'font/ttf', '.woff2': 'font/woff2', '.png': 'image/png', '.svg': 'image/svg+xml' }; +async function serve(directory) { + const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url, 'http://127.0.0.1'); + const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname)); + const relative = path.relative(directory, file); + if (relative.startsWith('..') || path.isAbsolute(relative)) { response.writeHead(403).end(); return; } + response.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); + response.end(await readFile(file)); + } catch { response.writeHead(404).end(); } + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + return { server, origin: `http://127.0.0.1:${server.address().port}` }; +} + +const results = [], errors = [], metrics = [], snapshots = []; +const check = (condition, name, detail) => results.push({ name, status: condition ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); +const same = (left, right) => JSON.stringify(left) === JSON.stringify(right); +const clone = data => structuredClone(data); +const dataWith = (data, version) => { const next = clone(data); next.metadata.dataVersion = version; return next; }; +function captureErrors(page, label) { + page.on('pageerror', error => errors.push({ label, type: 'pageerror', message: error.message })); + page.on('console', message => { if (message.type() === 'error') errors.push({ label, type: 'console', message: message.text() }); }); +} + +async function ready(page, transport, version) { + await page.waitForFunction(({ transport, version }) => { + const api = transport === 'vanilla' ? window.OrgChartP1 : window.OrgChartP1React; + const state = transport === 'vanilla' ? api?.chart?.getState() : api?.events?.at(-1); + const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; + return state?.status === 'ready' && state.dataVersion === version && shadow?.querySelector('.hm-orgchart-composition .node'); + }, { transport, version }, { timeout: 20000 }); + await page.evaluate(async () => { + await document.fonts.ready; + const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; + await Promise.all([...shadow.querySelectorAll('.hm-orgchart-affiliation-mark img')].map(image => image.complete ? Promise.resolve() : new Promise(resolve => { + image.addEventListener('load', resolve, { once: true }); image.addEventListener('error', resolve, { once: true }); + }))); + }); +} +async function setData(page, transport, data) { + await page.evaluate(async ({ transport, data }) => { + const api = transport === 'vanilla' ? window.OrgChartP1 : window.OrgChartP1React; + await api.setData(data); + }, { transport, data }); + await ready(page, transport, data.metadata.dataVersion); +} + +/** Transport-neutral snapshot: no generated IDs, stylesheet hashes or host URLs. */ +async function snapshot(page) { + return page.evaluate(() => { + const host = document.querySelector('.hm-orgchart-host'); + const shadow = host.shadowRoot; + const text = element => element?.textContent?.replace(/\s+/g, ' ').trim() ?? ''; + const employees = scope => [...scope.querySelectorAll('.hm-orgchart-employee')].map(card => ({ + id: card.dataset.employeeId, kind: card.dataset.cardKind, status: card.dataset.workStatus, + name: text(card.querySelector('.hm-orgchart-person-name')), position: text(card.querySelector('.hm-orgchart-person-role')), + jobTitle: text(card.querySelector('.hm-orgchart-job-title')), + affiliation: card.querySelector('.hm-orgchart-affiliation-mark')?.dataset.affiliationId ?? null, + })); + return { + heading: text(shadow.querySelector('.hm-orgchart-heading')), + title: text(shadow.querySelector('.hm-orgchart-title')), + hub: text(shadow.querySelector('.hm-orgchart-hub')), + nodes: [...shadow.querySelectorAll('.node')].map(node => { + const hierarchy = node.closest('.hierarchy'); + const parent = hierarchy?.parentElement?.closest('.hierarchy')?.querySelector(':scope > .node'); + return { id: node.dataset.organizationId, type: node.dataset.organizationType, depth: Number(node.dataset.depth), parentId: parent?.dataset.organizationId ?? null, + name: text(node.querySelector('.hm-orgchart-organization-name')), employees: employees(node) }; + }), + topLeaders: employees(shadow.querySelector('.hm-orgchart-top-leaders') ?? document.createElement('div')), + components: [...shadow.querySelectorAll('[data-component]')].map(element => element.dataset.component), + positions: shadow.querySelector('.hm-orgchart-position-summary') ? { + total: text(shadow.querySelector('.hm-orgchart-position-summary .hm-orgchart-raw-total')), + policy: [...shadow.querySelectorAll('.hm-orgchart-position-summary .hm-orgchart-policy')].map(text), + headers: [...shadow.querySelectorAll('.hm-orgchart-position-table thead th')].map(text), + rows: [...shadow.querySelectorAll('.hm-orgchart-position-table tbody tr')].map(row => ({ id: row.dataset.organizationId, name: text(row.querySelector('th')), cells: [...row.querySelectorAll('td')].map(text) })), + totals: [...shadow.querySelectorAll('.hm-orgchart-position-table tfoot td')].map(text), + statuses: text(shadow.querySelector('.hm-orgchart-status-totals')), + } : null, + affiliations: [...shadow.querySelectorAll('.hm-orgchart-affiliation-item')].map(item => ({ + id: item.dataset.affiliationId, count: text(item.querySelector('dd')), + label: item.querySelector('.hm-orgchart-affiliation-mark')?.getAttribute('aria-label'), + logo: item.querySelector('.hm-orgchart-affiliation-mark img')?.getAttribute('src') ?? null, + })), + affiliationTotal: text(shadow.querySelector('.hm-orgchart-affiliation-summary .hm-orgchart-raw-total')), + statuses: [...shadow.querySelectorAll('.hm-orgchart-status-group')].map(group => ({ status: group.dataset.workStatus, + heading: text(group.querySelector('h3')), employees: employees(group), empty: text(group.querySelector('.hm-orgchart-content-empty')) })), + }; + }); +} + +function expectedCounts(data) { + const employees = [...new Map(data.employees.map(employee => [employee.id, employee])).values()]; + const organizations = new Map(data.organizations.map(org => [org.id, org])); + const positions = new Map(), affiliations = new Map(data.affiliations.map(item => [item.id, 0])); + const direct = new Map(data.organizations.map(org => [org.id, 0])); + const inclusive = new Map(direct), matrix = new Map(data.organizations.map(org => [org.id, new Map()])); + const statuses = { active: 0, dispatched: 0, onLeave: 0 }; + for (const employee of employees) { + positions.set(employee.position, (positions.get(employee.position) ?? 0) + 1); + affiliations.set(employee.affiliationId, (affiliations.get(employee.affiliationId) ?? 0) + 1); + direct.set(employee.organizationId, direct.get(employee.organizationId) + 1); + const row = matrix.get(employee.organizationId); row.set(employee.position, (row.get(employee.position) ?? 0) + 1); + statuses[employee.workStatus]++; + let org = organizations.get(employee.organizationId); + while (org) { inclusive.set(org.id, inclusive.get(org.id) + 1); org = organizations.get(org.parentId); } + } + return { total: employees.length, employees, positions, affiliations, direct, inclusive, matrix, statuses }; +} +function checkCounts(current, data, label) { + const expected = expectedCounts(data); + const positionNames = current.positions.headers.slice(1, -2); + check(current.positions.rows.length === data.organizations.length, `${label}: every organization has a cross-table row`); + check(current.positions.rows.every(row => { + const cells = row.cells.map(Number); + return positionNames.every((position, index) => cells[index] === (expected.matrix.get(row.id)?.get(position) ?? 0)) + && cells.at(-2) === expected.direct.get(row.id) && cells.at(-1) === expected.inclusive.get(row.id); + }), `${label}: direct organization×position cells and inclusive counts match unique primary membership`, current.positions.rows); + check(positionNames.every((position, index) => Number(current.positions.totals[index]) === expected.positions.get(position)) + && Number(current.positions.totals.at(-2)) === expected.total && current.positions.totals.at(-1) === '—', `${label}: direct totals are disjoint; inclusive counts are not added`, current.positions.totals); + check(current.positions.total.includes(`${expected.total}명`) && current.affiliationTotal.includes(`${expected.total}명`), `${label}: Position and Affiliation totals reuse the same Raw Count`); + check(current.affiliations.length === data.affiliations.length && current.affiliations.every(item => parseInt(item.count) === expected.affiliations.get(item.id)), `${label}: every declared affiliation count is correct`, current.affiliations.map(({ id, count }) => ({ id, count }))); + check(current.statuses.every(group => { + const matching = expected.employees.filter(employee => employee.workStatus === group.status); + return parseInt(group.heading.split('·').at(-1)) === matching.length && same(group.employees.map(employee => employee.id).sort(), matching.map(employee => employee.id).sort()); + }), `${label}: status groups use original employee IDs without inflating total`, current.statuses.map(group => ({ status: group.status, ids: group.employees.map(employee => employee.id) }))); + check(current.positions.policy.some(text => text.includes('총원 미확정')) && current.positions.policy.some(text => text.includes('조직장') && text.includes('파견') && text.includes('휴직')), `${label}: Raw inclusion policy is visible; business total remains unapproved`); +} + +async function measure(page) { + return page.evaluate(() => { + const host = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host'); + const shadow = host.shadowRoot; + const viewport = shadow.querySelector('.hm-orgchart-viewport'); + const summary = shadow.querySelector('.hm-orgchart-summary-scroll'); + const dimensions = element => element && ({ clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, overflowX: getComputedStyle(element).overflowX }); + const textElements = [...shadow.querySelectorAll('.hm-orgchart-person-name, .hm-orgchart-person-role, .hm-orgchart-organization-name')].filter(element => element.getBoundingClientRect().height > 0); + const cards = [...shadow.querySelectorAll('.node')].map(element => element.getBoundingClientRect()).filter(rect => rect.height > 0); + const sample = shadow.querySelector('.hm-orgchart-employee, .hm-orgchart-position-summary'); + return { + document: dimensions(document.documentElement), host: dimensions(host), viewport: dimensions(viewport), summary: dimensions(summary), + ids: [...shadow.querySelectorAll('.hm-orgchart-domain-id')].map(element => ({ text: element.textContent, title: element.title, whiteSpace: getComputedStyle(element).whiteSpace, height: element.clientHeight, scrollHeight: element.scrollHeight })), + textFits: textElements.every(element => element.scrollWidth <= element.clientWidth + 1 && element.scrollHeight <= element.clientHeight + 1), + textIssues: textElements.filter(element => element.scrollWidth > element.clientWidth + 1 || element.scrollHeight > element.clientHeight + 1).map(element => ({ className: element.className, text: element.textContent, ...dimensions(element) })), + nodeOverlap: cards.some((a, index) => cards.slice(index + 1).some(b => Math.min(a.right, b.right) - Math.max(a.left, b.left) > 1 && Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 1)), + font: sample ? getComputedStyle(sample).fontFamily : null, foreground: sample ? getComputedStyle(sample).color : null, + background: sample ? getComputedStyle(sample).backgroundColor : null, radius: sample ? getComputedStyle(sample).borderRadius : null, + tokens: [...getComputedStyle(shadow.querySelector('.hm-orgchart-shell'))].filter(key => key.startsWith('--hm-orgchart-')).map(key => ({ key, value: getComputedStyle(shadow.querySelector('.hm-orgchart-shell')).getPropertyValue(key).trim() })), + cssLoaded: [...shadow.querySelectorAll('link[rel=stylesheet]')].every(link => !!link.sheet), + portraitsFallback: [...shadow.querySelectorAll('.hm-orgchart-avatar')].every(avatar => avatar.textContent.trim() && !avatar.querySelector('img')), + logos: [...shadow.querySelectorAll('.hm-orgchart-affiliation-item .hm-orgchart-affiliation-mark')].map(mark => ({ id: mark.dataset.affiliationId, available: mark.dataset.logoAvailable, src: mark.querySelector('img')?.getAttribute('src'), loaded: !!mark.querySelector('img')?.complete && mark.querySelector('img').naturalWidth > 0 })), + navigationCount: shadow.querySelectorAll('nav, .hm-dashboard-header, [data-slot=sidebar], .hm-gnb, .hm-lnb').length, + }; + }); +} + +async function styleIsolation(page, transport) { + const evidence = await page.evaluate(async () => { + const host = document.querySelector('.hm-orgchart-host'); + const shadow = host.shadowRoot; + const sample = shadow.querySelector('.hm-orgchart-employee[data-card-kind=employee]'); + const signature = () => { + const style = getComputedStyle(sample); + return { foreground: style.color, background: style.backgroundColor, font: style.fontFamily, radius: style.borderRadius, border: style.borderTopColor }; + }; + const initial = signature(); + // Document CSS must be optional; private Shadow stylesheet links stay mounted. + const documentLinks = [...document.querySelectorAll('link[rel=stylesheet]')]; + documentLinks.forEach(link => link.remove()); + await new Promise(resolve => requestAnimationFrame(resolve)); + const withoutTheme = signature(); + for (const [property, value] of [['--hm-sem-bg-surface', '#ff0000'], ['--hm-sem-fg-primary', '#ff0000'], ['--hm-radius-container', '99px'], ['--hm-font-family-base', 'monospace']]) host.style.setProperty(property, value); + const alteredHM = signature(); + const sentinel = document.createElement('span'); + sentinel.textContent = 'Host style isolation sentinel'; + sentinel.style.color = 'rgb(55, 66, 77)'; sentinel.style.backgroundColor = 'rgb(88, 99, 110)'; + document.body.append(sentinel); + const sentinelBefore = { color: getComputedStyle(sentinel).color, background: getComputedStyle(sentinel).backgroundColor }; + // Public inputs intentionally remain undeclared; defaults resolve on the + // shadow shell so a host input can inherit without being masked by :host. + const token = '--hm-orgchart-bg-surface'; + host.style.setProperty(token, 'rgb(7, 23, 41)'); + const scopedOverride = signature(); + const sentinelAfter = { color: getComputedStyle(sentinel).color, background: getComputedStyle(sentinel).backgroundColor }; + if (token) host.style.removeProperty(token); + for (const property of ['--hm-sem-bg-surface', '--hm-sem-fg-primary', '--hm-radius-container', '--hm-font-family-base']) host.style.removeProperty(property); + sentinel.remove(); + await Promise.all(documentLinks.map(link => new Promise(resolve => { + link.addEventListener('load', resolve, { once: true }); link.addEventListener('error', resolve, { once: true }); document.head.append(link); + }))); + return { initial, withoutTheme, alteredHM, scopedOverride, token, sentinelBefore, sentinelAfter, restored: signature(), removedDocumentStyles: documentLinks.length }; + }); + metrics.push({ transport, stage: 'private-style-isolation', ...evidence }); + check(same(evidence.initial, evidence.withoutTheme), `${transport}: Core style is unchanged without host HM Theme CSS`, evidence); + check(same(evidence.withoutTheme, evidence.alteredHM), `${transport}: unrelated host HM color/radius/font variables cannot change private OrgChart style`, evidence); + check(evidence.token && evidence.scopedOverride.background === 'rgb(7, 23, 41)' && same(evidence.sentinelBefore, evidence.sentinelAfter), `${transport}: scoped OrgChart surface override affects its instance only`, evidence); + check(same(evidence.initial, evidence.restored), `${transport}: removing the scoped override restores standalone defaults`, evidence); +} + +async function standaloneFactories(page) { + const evidence = await page.evaluate(async data => { + const api = window.OrgChartP1.api; + const stylesheetUrls = window.OrgChartP1.stylesheetUrls; + const existing = document.querySelector('.hm-orgchart-host'); + const originalBackground = getComputedStyle(existing.shadowRoot.querySelector('.hm-orgchart-employee[data-card-kind=employee]')).backgroundColor; + const results = []; + for (const [factory, selector] of [['createPositionSummary', '.hm-orgchart-position-summary'], ['createAffiliationSummary', '.hm-orgchart-affiliation-summary'], ['createStatusSummary', '.hm-orgchart-status-summary']]) { + const container = document.createElement('div'); container.style.maxWidth = '760px'; document.body.append(container); + const events = []; + const handle = api[factory]({ container, dataSource: new api.JsonOrgChartDataSource(data), stylesheetUrl: stylesheetUrls.core, onStateChange: state => events.push(state) }); + await handle.mount(); + const shadow = container.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot; + const mounted = { state: handle.getState().status, total: handle.getState().summary?.raw.total, + modules: shadow.querySelectorAll(selector).length, nodes: shadow.querySelectorAll('.node').length, + foreign: shadow.querySelectorAll('.hm-orgchart-composition, .hm-orgchart-heading, .hm-orgchart-hub, .hm-orgchart-viewport').length, + links: [...shadow.querySelectorAll('link')].map(link => link.getAttribute('href')), + headings: shadow.textContent, + }; + const zero = structuredClone(data); zero.metadata.dataVersion = `${factory}-zero`; + zero.employees = []; for (const org of zero.organizations) org.leaderId = null; + await handle.update(zero); + const currentShadow = container.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot; + const updated = { state: handle.getState().status, version: handle.getState().dataVersion, total: handle.getState().summary?.raw.total, + employees: currentShadow.querySelectorAll('[data-employee-id]').length, + affiliations: [...currentShadow.querySelectorAll('.hm-orgchart-affiliation-count')].map(element => element.textContent), + emptyGroups: [...currentShadow.querySelectorAll('.hm-orgchart-content-empty')].map(element => element.textContent), + }; + const sibling = document.createElement('p'); sibling.textContent = 'Host-owned sibling'; container.append(sibling); + handle.destroy(); handle.destroy(); + results.push({ factory, mounted, updated, eventStatuses: events.map(state => state.status), + destroyed: handle.getState().status, remaining: container.children.length, siblingPreserved: sibling.isConnected }); + container.remove(); + } + const container = document.createElement('div'); document.body.append(container); + const tree = api.createOrgChartTree({ container, dataSource: new api.JsonOrgChartDataSource(data), stylesheetUrls }); + await tree.mount(); + const treeShadow = container.querySelector('.hm-orgchart-host').shadowRoot; + const onlyTree = { ready: tree.getState().status, nodes: treeShadow.querySelectorAll('.node').length, + employeeIDs: [...new Set([...treeShadow.querySelectorAll('[data-employee-id]')].map(element => element.dataset.employeeId))].sort(), + forbidden: treeShadow.querySelectorAll('.hm-orgchart-heading,.hm-orgchart-hub,.hm-orgchart-composition,.hm-orgchart-position-summary,.hm-orgchart-affiliation-summary,.hm-orgchart-status-summary').length, + viewport: treeShadow.querySelectorAll('.hm-orgchart-viewport').length, + }; + const moved = structuredClone(data); moved.metadata.dataVersion = 'standalone-tree-updated'; + moved.employees.find(employee => employee.id === 'mock-employee-01').organizationId = 'solution-sales-1'; + await tree.update(moved); + const nextShadow = container.querySelector('.hm-orgchart-host').shadowRoot; + onlyTree.updatedVersion = tree.getState().dataVersion; + onlyTree.employeeMoved = !![...nextShadow.querySelectorAll('.node')].find(node => node.dataset.organizationId === 'solution-sales-1')?.querySelector('[data-employee-id="mock-employee-01"]'); + tree.destroy(); onlyTree.destroyed = container.children.length === 0; container.remove(); + const existingBackground = getComputedStyle(existing.shadowRoot.querySelector('.hm-orgchart-employee[data-card-kind=employee]')).backgroundColor; + return { summaries: results, tree: onlyTree, originalBackground, existingBackground }; + }, fixture); + metrics.push({ stage: 'standalone-public-factories', ...evidence }); + for (const item of evidence.summaries) { + check(item.mounted.state === 'ready' && item.mounted.total === 41 && item.mounted.modules === 1 && item.mounted.nodes === 0 && item.mounted.foreign === 0 && item.mounted.links.length === 1, `${item.factory}: public factory mounts only its own summary without engine, heading or composition`, item.mounted); + check(item.updated.state === 'ready' && item.updated.version === `${item.factory}-zero` && item.updated.total === 0 && item.updated.employees === 0 + && (item.factory !== 'createAffiliationSummary' || item.updated.affiliations.length === 7 && item.updated.affiliations.every(text => text === '0명')) + && (item.factory !== 'createStatusSummary' || item.updated.emptyGroups.length === 2), `${item.factory}: update refreshes shared Raw Count and removes stale cards`, item.updated); + check(item.destroyed === 'destroyed' && item.remaining === 1 && item.siblingPreserved, `${item.factory}: destroy releases only owned auxiliary DOM and preserves host siblings`, item); + } + check(evidence.tree.ready === 'ready' && evidence.tree.nodes === 18 && evidence.tree.viewport === 1 && evidence.tree.forbidden === 0 + && same(evidence.tree.employeeIDs, fixture.employees.map(employee => employee.id).sort()), 'createOrgChartTree: public standalone tree preserves all source employees with no heading/hub/summary composition', evidence.tree); + check(evidence.tree.updatedVersion === 'standalone-tree-updated' && evidence.tree.employeeMoved && evidence.tree.destroyed, 'createOrgChartTree: standalone update and destroy retain engine lifecycle', evidence.tree); + check(evidence.originalBackground === evidence.existingBackground, 'standalone factories do not alter the existing full-composition instance'); +} + +const expectedOldIds = ['basic-organization', 'organization-hierarchy', 'employee-cards', 'organization-summary', 'json-data-update', 'loading-empty-error', 'responsive-container', 'multiple-instances'].map(id => `orgchart-content--${id}`); +const canonicalStoryIds = ['logo', 'department-colors', 'position-summary', 'affiliation-summary', 'full-composition'].map(id => `orgchart--${id}`); +const internalStoryIds = [ + ...['business-management', 'business-planning', 'talent-growth', 'integrated-system', 'design-planning', 'solution-sales', 'top-leader', 'organization-leader', 'employee'].map(id => `orgchart-components-cards--${id}`), + ...['position-summary', 'affiliation-summary', 'status-summary'].map(id => `orgchart-components-summaries--${id}`), +]; +// Canonical entries cover every component once; only the three distinct employee +// kinds additionally need their internal preview. Registry preservation is static. +const renderedStoryIds = [...canonicalStoryIds, ...internalStoryIds.slice(6, 9)]; +const measuredGradients = [ + ['#194d82', '#1d395c', 'rgb(25, 77, 130)', 'rgb(29, 57, 92)'], + ['#2164a8', '#254975', 'rgb(33, 100, 168)', 'rgb(37, 73, 117)'], + ['#0f6ace', '#044a94', 'rgb(15, 106, 206)', 'rgb(4, 74, 148)'], + ['#8c67b6', '#5d3494', 'rgb(140, 103, 182)', 'rgb(93, 52, 148)'], + ['#09a7a2', '#00847f', 'rgb(9, 167, 162)', 'rgb(0, 132, 127)'], + ['#5a963c', '#3f7629', 'rgb(90, 150, 60)', 'rgb(63, 118, 41)'], +]; + +/** A Story metadata fix only invalidates its console checks. Preserve the full + * browser.json run and recheck those five iframes plus the newly requested + * actual manager sidebar, without repeating Core/React/summary/width checks. */ +async function recheckStorybookConsole() { + const retryResults = [], retryErrors = [], rendered = []; + const checkRetry = (condition, name, detail) => retryResults.push({ name, status: condition ? 'PASS' : 'FAIL', detail }); + const server = await serve(path.join(root, 'storybook-static')); + let retryBrowser; + let sidebar; + try { + retryBrowser = await launchChromium({ headless: true }); + const context = await retryBrowser.newContext({ viewport: { width: 1440, height: 1080 } }); + const iframe = await context.newPage(); + iframe.on('pageerror', error => retryErrors.push({ surface: 'iframe', type: 'pageerror', message: error.message })); + iframe.on('console', message => { if (message.type() === 'error') retryErrors.push({ surface: 'iframe', type: 'console', message: message.text() }); }); + for (const id of canonicalStoryIds) { + await iframe.goto(`${server.origin}/iframe.html?id=${id}&viewMode=story`); + await iframe.waitForFunction(() => { + const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot; + return shadow?.querySelector('[data-component]') && [...shadow.querySelectorAll('link')].every(link => link.sheet); + }, undefined, { timeout: 20000 }); + rendered.push(id); + } + checkRetry(retryErrors.filter(error => error.surface === 'iframe').length === 0 && rendered.length === 5, 'five corrected canonical Story iframes emit no console/runtime errors', { rendered, errors: retryErrors.filter(error => error.surface === 'iframe') }); + const manager = await context.newPage(); + manager.on('pageerror', error => retryErrors.push({ surface: 'manager', type: 'pageerror', message: error.message })); + manager.on('console', message => { if (message.type() === 'error') retryErrors.push({ surface: 'manager', type: 'console', message: message.text() }); }); + await manager.goto(`${server.origin}/?path=/story/orgchart--full-composition`); + await manager.waitForSelector('#storybook-explorer-tree [data-item-id="orgchart--full-composition"]', { timeout: 20000 }); + sidebar = await manager.evaluate(() => [...document.querySelectorAll('#storybook-explorer-tree [data-item-id]')].filter(row => row.dataset.itemId.startsWith('orgchart') && row.getBoundingClientRect().height > 0).map(row => ({ id: row.dataset.itemId, label: row.textContent.trim() }))); + const visibleStories = sidebar.filter(row => row.id.startsWith('orgchart--')).map(row => row.id).sort(); + checkRetry(same(visibleStories, [...canonicalStoryIds].sort()), 'actual Storybook sidebar exposes exactly the approved five OrgChart Story entries', sidebar); + checkRetry(sidebar.every(row => !row.id.startsWith('orgchart-content') && !row.id.startsWith('orgchart-components')), + 'actual default sidebar hides the original/internal reference groups while preserving their direct routes', sidebar); + await manager.screenshot({ path: path.join(output, 'storybook-sidebar.png'), fullPage: true }); + checkRetry(retryErrors.filter(error => error.surface === 'manager').length === 0, 'actual Storybook manager and its preview emit no console/runtime errors', retryErrors.filter(error => error.surface === 'manager')); + } catch (error) { + retryResults.push({ name: 'selected Storybook console/sidebar recheck completion', status: 'FAIL', detail: error.stack }); + } finally { + const report = { browser: retryBrowser?.version() ?? null, mode: 'storybook-console-only', preservedInitialReport: path.join(output, 'browser.json'), results: retryResults, errors: retryErrors, rendered, sidebar, + limits: ['196 previously successful Core/React/component checks were not repeated', 'This run only rechecks corrected Story metadata and newly verifies the default manager sidebar'] }; + const reportPath = path.join(output, 'storybook-console-recheck.json'); + await writeFile(reportPath, JSON.stringify(report, null, 2)); + if (retryBrowser) await retryBrowser.close(); + await new Promise(resolve => server.server.close(resolve)); + const failed = retryResults.filter(result => result.status === 'FAIL'); + if (failed.length) process.exitCode = 1; + console.log(JSON.stringify({ passed: retryResults.filter(result => result.status === 'PASS').length, failed, report: reportPath, sidebarScreenshot: path.join(output, 'storybook-sidebar.png') }, null, 2)); + } +} + +if (process.argv.includes('--storybook-console-only')) { + await recheckStorybookConsole(); + process.exit(process.exitCode ?? 0); +} + +let browser; +const servers = []; +try { + const productionStyles = await readFile(path.join(root, 'packages/org-chart/dist/style.css'), 'utf8'); + const foreignHMVariables = [...productionStyles.matchAll(/var\(\s*(--hm-(?!orgchart-)[a-z0-9-]+)/g)].map(match => match[1]); + check(foreignHMVariables.length === 0, 'shipped Core stylesheet references only the private OrgChart namespace rather than host HM Theme variables', [...new Set(foreignHMVariables)]); + const vanilla = await serve(path.join(root, 'examples/org-chart/dist')); servers.push(vanilla); + const react = await serve(path.join(root, 'examples/org-chart-react/dist')); servers.push(react); + const storybook = await serve(path.join(root, 'storybook-static')); servers.push(storybook); + browser = await launchChromium({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1440, height: 1080 } }); + const pages = new Map(); + for (const [transport, origin] of [['vanilla', vanilla.origin], ['react', react.origin]]) { + const page = await context.newPage(); pages.set(transport, page); captureErrors(page, transport); + await page.goto(`${origin}/p1.html`); + await ready(page, transport, fixture.metadata.dataVersion); + const initial = await snapshot(page); snapshots.push({ transport, stage: 'initial', value: initial }); + check(initial.nodes.length === 18 && expectedCounts(fixture).total === 41, `${transport}: P1 fixture contains 18 real-name organizations and 41 virtual employee IDs`); + check(initial.title === fixture.presentation.title && initial.hub === fixture.presentation.hubText, `${transport}: heading and independent hub come from JSON`); + check(initial.nodes.every(node => fixture.organizations.find(org => org.id === node.id)?.parentId === node.parentId), `${transport}: rendered hierarchy retains every original parentId`, initial.nodes.map(({ id, parentId }) => ({ id, parentId }))); + const topEmployee = fixture.organizations.find(org => org.parentId === null)?.leaderId; + check(initial.topLeaders.length === 1 && initial.topLeaders[0].id === topEmployee && initial.topLeaders[0].kind === 'topLeader' + && initial.nodes.every(node => node.employees.every(employee => employee.id !== topEmployee)), `${transport}: top leader is independent and is not duplicated in the tree`); + check(initial.nodes.filter(node => node.depth === 1).length === 6 && initial.components.includes('OrgChartTree'), `${transport}: six primary organization groups preserve the engine tree boundary`); + check(['PositionSummary', 'AffiliationSummary', 'StatusSummary'].every(name => initial.components.filter(value => value === name).length === 1), `${transport}: three independent summary modules are composed once`); + checkCounts(initial, fixture, `${transport}: initial`); + for (const width of [390, 768, 1280, 1440]) { + await page.setViewportSize({ width, height: 1080 }); + const measurement = await measure(page); metrics.push({ transport, stage: 'initial', width, ...measurement }); + check(measurement.document.scrollWidth <= measurement.document.clientWidth + 1, `${transport}: ${width}px document has no horizontal overflow`, measurement.document); + check(measurement.viewport.scrollWidth > measurement.viewport.clientWidth && measurement.viewport.overflowX === 'auto', `${transport}: ${width}px wide tree uses internal scrolling`, measurement.viewport); + check(measurement.textFits && !measurement.nodeOverlap && measurement.ids.every(item => item.whiteSpace === 'nowrap' && item.title === item.text && item.scrollHeight <= item.height + 1), `${transport}: ${width}px text bounds, node separation and one-line IDs`, { textIssues: measurement.textIssues, overlap: measurement.nodeOverlap, ids: measurement.ids }); + check(measurement.cssLoaded && measurement.tokens.length >= 10 && measurement.tokens.every(item => item.value) && measurement.font && measurement.background !== 'rgba(0, 0, 0, 0)' && measurement.radius !== '0px', `${transport}: ${width}px independent OrgChart defaults and loaded Shadow styles`, { tokens: measurement.tokens, font: measurement.font, background: measurement.background, radius: measurement.radius }); + check(measurement.logos.length === 7 && measurement.logos.every(item => item.available === 'true' && item.loaded && item.src.startsWith('data:image/')), `${transport}: ${width}px all seven acquired affiliation logos load`, measurement.logos.map(({ id, available, loaded }) => ({ id, available, loaded }))); + check(measurement.portraitsFallback && measurement.navigationCount === 0, `${transport}: ${width}px virtual portrait fallback and content-only UI`); + await page.screenshot({ path: path.join(output, `${transport}-${width}.png`), fullPage: true }); + } + await page.setViewportSize({ width: 1440, height: 1080 }); + await styleIsolation(page, transport); + const toggle = page.locator('.hm-orgchart-toggle').first(); + await toggle.focus(); await toggle.press('Enter'); + await page.waitForFunction(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.sliding')); + check(await toggle.getAttribute('aria-expanded') === 'false' && await toggle.evaluate(element => element.getRootNode().activeElement === element), `${transport}: keyboard Enter collapses hierarchy and keeps focus`); + await toggle.press('Space'); + await page.waitForFunction(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.sliding')); + check(await toggle.getAttribute('aria-expanded') === 'true', `${transport}: keyboard Space expands hierarchy`); + } + check(same(snapshots.find(item => item.transport === 'vanilla').value, snapshots.find(item => item.transport === 'react').value), 'HTML and React initial Core DOM and automatic summary snapshots are identical'); + + const changed = dataWith(fixture, 'p1-browser-json-v2'); + const moved = changed.employees.find(employee => employee.id === 'mock-employee-01'); + moved.organizationId = 'solution-sales-1'; moved.affiliationId = 'halla'; moved.workStatus = 'onLeave'; + moved.name = '가상직원 긴 이름과 콘텐츠 줄바꿈 검증 01'; moved.position = '가상 장기 프로젝트 연구 및 운영 담당 수석'; + moved.jobTitle = '가상 프로젝트 담당'; + changed.presentation.title = 'JSON 갱신 · 가상 직원 이동 및 상태 변경'; + for (const [transport, page] of pages) { + await page.locator('.hm-orgchart-summary-scroll').focus(); + await setData(page, transport, changed); + check(await page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.classList.contains('hm-orgchart-viewport')), `${transport}: summary keyboard focus returns to stable tree after composition regeneration`); + const current = await snapshot(page); snapshots.push({ transport, stage: 'changed', value: current }); + check(current.title === changed.presentation.title && current.nodes.find(node => node.id === moved.organizationId)?.employees.some(employee => employee.id === moved.id && employee.name === moved.name && employee.position === moved.position), `${transport}: changed JSON moves and updates the same employee card`); + checkCounts(current, changed, `${transport}: changed JSON`); + for (const width of [390, 768, 1280, 1440]) { + await page.setViewportSize({ width, height: 1080 }); + const measurement = await measure(page); metrics.push({ transport, stage: 'long-text', width, ...measurement }); + check(measurement.textFits && measurement.document.scrollWidth <= width + 1, `${transport}: ${width}px long employee name, position and job title fit with contained table scrolling`, { textIssues: measurement.textIssues, document: measurement.document, summary: measurement.summary }); + } + check(current.statuses.find(group => group.status === 'onLeave').employees.some(employee => employee.id === moved.id) && !current.statuses.find(group => group.status === 'dispatched').employees.some(employee => employee.id === moved.id), `${transport}: state groups update without stale employee cards`); + // Flags select independent components rather than alter the employee source or counts. + const cases = [ + ['hide-position', { showSummary: false }, { position: false, affiliations: true, dispatched: true, onLeave: true }], + ['hide-affiliation', { showAffiliations: false }, { position: true, affiliations: false, dispatched: true, onLeave: true }], + ['hide-dispatched', { showDispatched: false }, { position: true, affiliations: true, dispatched: false, onLeave: true }], + ['hide-onleave', { showOnLeave: false }, { position: true, affiliations: true, dispatched: true, onLeave: false }], + ['hide-both-status', { showDispatched: false, showOnLeave: false }, { position: true, affiliations: true, dispatched: false, onLeave: false }], + ]; + for (const [name, flags, expected] of cases) { + const data = dataWith(changed, `${transport}-${name}`); Object.assign(data.presentation.groups, flags); + await setData(page, transport, data); + const current = await snapshot(page); + check(!!current.positions === expected.position && !!current.affiliations.length === expected.affiliations + && current.statuses.some(group => group.status === 'dispatched') === expected.dispatched + && current.statuses.some(group => group.status === 'onLeave') === expected.onLeave + && current.components.includes('StatusSummary') === (expected.dispatched || expected.onLeave), `${transport}: ${name} changes only its independent component`, current.components); + check(!current.positions || Number(current.positions.totals.at(-2)) === 41, `${transport}: ${name} cannot alter Raw Count`); + } + const zeroAffiliation = dataWith(changed, `${transport}-zero-affiliation`); + for (const employee of zeroAffiliation.employees) if (employee.affiliationId === 'baron') employee.affiliationId = 'hanmac'; + await setData(page, transport, zeroAffiliation); + check((await snapshot(page)).affiliations.find(item => item.id === 'baron')?.count === '0명', `${transport}: zero-count declared affiliations stay visible`); + const zeroStatus = dataWith(changed, `${transport}-zero-status`); + for (const employee of zeroStatus.employees) employee.workStatus = 'active'; + await setData(page, transport, zeroStatus); + check((await snapshot(page)).statuses.every(group => group.employees.length === 0 && group.empty && group.heading.includes('0명')), `${transport}: enabled empty status groups have explicit empty content`); + // A successful HTTP response containing non-image bytes triggers img.error, + // avoiding expected network-console failures while testing the actual fallback. + await page.route('**/p1-invalid-portrait.png', route => route.fulfill({ status: 200, contentType: 'image/png', body: 'not a png' })); + const brokenImage = dataWith(changed, `${transport}-image-fallback`); + brokenImage.employees.find(employee => employee.id === moved.id).profileImage = './p1-invalid-portrait.png'; + await setData(page, transport, brokenImage); + const brokenCards = page.locator(`.hm-orgchart-employee[data-employee-id="${moved.id}"]`); + for (let index = 0; index < await brokenCards.count(); index++) await brokenCards.nth(index).scrollIntoViewIfNeeded(); + await page.waitForFunction(id => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-employee')].filter(card => card.dataset.employeeId === id).every(card => !card.querySelector('.hm-orgchart-avatar img')), moved.id); + check(await page.evaluate(id => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-employee')].filter(card => card.dataset.employeeId === id).every(card => card.querySelector('.hm-orgchart-avatar').textContent.trim()), moved.id), `${transport}: invalid profile images safely return to initials`); + await page.unroute('**/p1-invalid-portrait.png'); + await setData(page, transport, dataWith(changed, 'p1-browser-final-changed')); + snapshots.push({ transport, stage: 'restored-changed', value: await snapshot(page) }); + check(transport !== 'react' || await page.evaluate(() => window.OrgChartP1React.errors.length === 0), `${transport}: auxiliary regeneration has no lifecycle error`); + } + check(same(...snapshots.filter(item => item.stage === 'restored-changed').map(item => item.value)), 'HTML and React Core snapshots remain identical after JSON and independent visibility updates'); + + const index = JSON.parse(await readFile(path.join(root, 'storybook-static/index.json'), 'utf8')); + const old = Object.values(index.entries).filter(entry => entry.title === 'OrgChart/Content' && entry.type === 'story'); + check(old.length === 8 && same(old.map(entry => entry.id).sort(), [...expectedOldIds].sort()) && old.every(entry => entry.tags.includes('internal')), 'all original eight Storybook entries remain registered as internal without re-running their baseline suite', old.map(entry => entry.id)); + check(internalStoryIds.every(id => index.entries[id]?.type === 'story' && index.entries[id].tags.includes('internal')), 'twelve isolated card/summary Stories are preserved as internal references', internalStoryIds); + const visible = Object.values(index.entries).filter(entry => entry.title === 'OrgChart' && entry.type === 'story' && !entry.tags.includes('internal')).map(entry => entry.id).sort(); + check(same(visible, [...canonicalStoryIds].sort()), 'canonical OrgChart offers exactly the five approved user-visible entries', visible); + const sb = await context.newPage(); captureErrors(sb, 'storybook'); + for (const id of renderedStoryIds) { + if (!index.entries[id]) continue; + await sb.goto(`${storybook.origin}/iframe.html?id=${id}&viewMode=story`); + await sb.waitForFunction(() => { + const host = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host'); + const shadow = host?.shadowRoot; + return shadow?.querySelector('[data-component]') && [...shadow.querySelectorAll('link')].every(link => link.sheet); + }, undefined, { timeout: 20000 }); + await sb.waitForFunction(() => [...document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot.querySelectorAll('.hm-orgchart-affiliation-mark img')].every(image => image.complete)); + const component = await sb.evaluate(() => { + const host = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host'), shadow = host.shadowRoot; + const organizationName = shadow.querySelector('.hm-orgchart-organization-name'); + const node = shadow.querySelector('.node'); + return { components: [...shadow.querySelectorAll('[data-component]')].map(element => element.dataset.component), nodeCount: shadow.querySelectorAll('.node').length, + employees: [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({ id: card.dataset.employeeId, kind: card.dataset.cardKind })), + gradients: organizationName ? { background: getComputedStyle(organizationName).backgroundImage, variable: getComputedStyle(node).getPropertyValue('--hm-orgchart-group-background').trim() } : null, + body: document.body.textContent, title: organizationName?.textContent, + hasPosition: !!shadow.querySelector('.hm-orgchart-position-summary'), hasAffiliation: !!shadow.querySelector('.hm-orgchart-affiliation-summary'), hasStatus: !!shadow.querySelector('.hm-orgchart-status-summary'), + linksLoaded: [...shadow.querySelectorAll('link')].every(link => link.sheet), + }; + }); + snapshots.push({ transport: 'storybook', stage: id, value: component }); + check(component.linksLoaded, `${id}: real Core preview and Shadow CSS load`); + if (id === 'orgchart--position-summary' || id === 'orgchart--affiliation-summary') { + const kind = id.endsWith('position-summary') ? 'PositionSummary' : id.endsWith('affiliation-summary') ? 'AffiliationSummary' : 'StatusSummary'; + check(component.nodeCount === 0 && same(component.components, [kind]) + && component.hasPosition === (kind === 'PositionSummary') && component.hasAffiliation === (kind === 'AffiliationSummary') && component.hasStatus === (kind === 'StatusSummary'), `${id}: standalone summary has exactly its Core module and no engine tree`, component.components); + } else if (id.endsWith('full-composition')) { + check(component.nodeCount === 18 && ['OrgChartTree', 'PositionSummary', 'AffiliationSummary', 'StatusSummary'].every(name => component.components.includes(name)), `${id}: composition contains shared tree plus all independent summaries`); + for (const width of [390, 768, 1280, 1440]) { + await sb.setViewportSize({ width, height: 1080 }); + const measurement = await measure(sb); metrics.push({ transport: 'storybook', stage: id, width, ...measurement }); + check(measurement.document.scrollWidth <= width + 1 && measurement.textFits && measurement.navigationCount === 0, `${id}: ${width}px content composition has no document overflow or clipped text`, { document: measurement.document, textIssues: measurement.textIssues }); + await sb.screenshot({ path: path.join(output, `${id}-${width}.png`), fullPage: true }); + } + await sb.setViewportSize({ width: 1440, height: 1080 }); + await sb.getByRole('button', { name: 'PositionSummary 숨기기', exact: true }).click(); + await sb.waitForFunction(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-position-summary')); + check(await sb.locator('.hm-orgchart-affiliation-summary').count() === 1 && await sb.locator('.hm-orgchart-status-summary').count() === 1, `${id}: Story button independently hides PositionSummary`); + await sb.getByRole('button', { name: '가상 JSON v2 반영', exact: true }).click(); + await sb.waitForFunction(() => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('[data-work-status=onLeave] [data-employee-id]')].some(element => element.dataset.employeeId === 'mock-employee-01')); + check(await sb.locator('.hm-orgchart-affiliation-summary .hm-orgchart-raw-total').textContent() === 'Raw Count · 전체 41명', `${id}: Story JSON update reuses 41 unique virtual employees`); + } else if (id.endsWith('top-leader') || id.endsWith('organization-leader') || id.endsWith('--employee')) { + const kind = id.endsWith('top-leader') ? 'topLeader' : id.endsWith('organization-leader') ? 'leader' : 'employee'; + check(component.nodeCount === 0 && component.employees.length === 1 && component.employees[0].kind === kind && same(component.components, ['EmployeeCard']), `${id}: standalone ${kind} uses one actual Core employee card`, component.employees); + } else if (id === 'orgchart--department-colors') { + await sb.waitForFunction(() => { + const values = [...document.querySelector('.hm-orgchart-component-host').shadowRoot.querySelectorAll('.hm-orgchart-color-value')]; + return values.length === 30 && values.every(element => element.textContent.trim()); + }); + const colors = await sb.evaluate(() => [...document.querySelector('.hm-orgchart-component-host').shadowRoot.querySelectorAll('.hm-orgchart-color-preview[data-group-variant]')].map(row => ({ + variant: Number(row.dataset.groupVariant), + text: row.textContent, + background: getComputedStyle(row.querySelector('[data-color-variable$="-gradient"]')).backgroundImage, + variable: getComputedStyle(row).getPropertyValue('--hm-orgchart-group-background').trim(), + declarations: [...row.querySelectorAll('[data-color-variable]')].map(element => ({ variable: element.dataset.colorVariable, property: element.dataset.cssProperty, applied: getComputedStyle(element).getPropertyValue(element.dataset.cssProperty), valueText: element.parentElement.querySelector('.hm-orgchart-color-value')?.textContent })), + card: (() => { const card = row.querySelector('.hm-orgchart-employee'); const style = getComputedStyle(card); return { background: style.backgroundColor, color: style.color, border: style.borderTopColor }; })(), + }))); + check(colors.length === 6, `${id}: all six department color components are rendered`, colors); + for (const color of colors) { + const [start, end, rgbStart, rgbEnd] = measuredGradients[color.variant]; + check(color.background.includes('linear-gradient') && color.background.includes(rgbStart) && color.background.includes(rgbEnd) + && color.variable === `linear-gradient(180deg, ${start} 0%, ${end} 100%)` + && color.text.includes(start) && color.text.includes(end) && color.declarations.length === 5 + && color.declarations.every(item => item.variable.startsWith('--hm-orgchart-') && item.valueText?.includes('#')), `${id}: variant ${color.variant} applies exact measured gradient and documents HEX/CSS variables`, color); + check(color.declarations.find(item => item.variable.endsWith('-card-background')).applied === color.card.background + && color.declarations.find(item => item.variable.endsWith('-card-color')).applied === color.card.color + && color.declarations.find(item => item.variable.endsWith('-border')).applied === color.card.border, `${id}: variant ${color.variant} legend values match the actual card styles`, color.card); + } + } else if (id === 'orgchart--logo') { + const logos = await sb.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-component-host').shadowRoot; + return { marks: [...shadow.querySelectorAll('.hm-orgchart-affiliation-mark')].map(mark => ({ id: mark.dataset.affiliationId, label: mark.getAttribute('aria-label'), src: mark.querySelector('img')?.getAttribute('src'), loaded: !!mark.querySelector('img')?.complete && mark.querySelector('img').naturalWidth > 0 })), text: shadow.textContent, + counts: shadow.querySelectorAll('.hm-orgchart-affiliation-count, .hm-orgchart-raw-total, .hm-orgchart-position-table').length }; + }); + check(component.nodeCount === 0 && component.components.includes('Logo') && logos.marks.length === 7 && logos.marks.every(mark => mark.loaded && mark.src.startsWith('data:image/') && mark.label) + && fixture.affiliations.every(affiliation => logos.text.includes(affiliation.name)) && logos.counts === 0, `${id}: standalone Logo uses all seven named assets without statistics`, logos.marks.map(({ id, label, loaded }) => ({ id, label, loaded }))); + } + } + await standaloneFactories(pages.get('vanilla')); + await pages.get('vanilla').evaluate(() => window.OrgChartP1.chart.destroy()); + check(await pages.get('vanilla').locator('.hm-orgchart-host').count() === 0, 'destroy releases composed tree and all auxiliary summary DOM'); + check(await pages.get('react').evaluate(() => window.OrgChartP1React.errors.length === 0), 'React wrapper lifecycle error boundary remains clear'); + check(errors.length === 0, 'targeted P1 Vanilla, React and canonical/employee Stories have no console/runtime errors', errors); +} catch (error) { + results.push({ name: 'P1 browser harness completion', status: 'FAIL', detail: error.stack }); +} finally { + const report = { browser: browser?.version() ?? null, results, errors, metrics, snapshots, + limits: ['Figma Dev Mode detailed properties NOT VERIFIED', 'Workdesk pixel parity NOT VERIFIED; gradient checks verify the recorded measured header sources only', 'Screen-reader announcements and heap/GC behavior NOT VERIFIED', 'Original 69/79 browser suites deliberately not repeated'] }; + await writeFile(path.join(output, 'browser.json'), JSON.stringify(report, null, 2)); + if (browser) await browser.close(); + for (const { server } of servers) await new Promise(resolve => server.close(resolve)); + const failed = results.filter(result => result.status === 'FAIL'); + if (failed.length) process.exitCode = 1; + console.log(JSON.stringify({ passed: results.filter(result => result.status === 'PASS').length, failed, report: path.join(output, 'browser.json') }, null, 2)); +} diff --git a/examples/org-chart/verify-p11-standalone.mjs b/examples/org-chart/verify-p11-standalone.mjs new file mode 100644 index 0000000..dfb896a --- /dev/null +++ b/examples/org-chart/verify-p11-standalone.mjs @@ -0,0 +1,62 @@ +// Plain HTTP consumer: only the published Core ESM and CSS, no React runtime. +import fs from 'node:fs/promises'; +import path from 'node:path'; +import assert from 'node:assert/strict'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +const output = path.resolve('test-results/org-chart-p11'); +const fixture = path.resolve('examples/org-chart-react/dist/plain-p11'); +await fs.mkdir(fixture, { recursive: true }); +for (const file of ['index.js', 'style.css', 'engine.css']) await fs.copyFile(`packages/org-chart/dist/${file}`, path.join(fixture, file)); +await fs.copyFile('examples/org-chart/data-expanded-small.json', path.join(fixture, 'data.json')); +const theme = (await fs.readFile('examples/org-chart-react/external-theme.css', 'utf8')).replace("@import '@hm-design/org-chart/style.css';", "@import './style.css';"); +await fs.writeFile(path.join(fixture, 'external-theme.css'), theme); +await fs.writeFile(path.join(fixture, 'index.html'), 'Plain Core P11
'); +await fs.writeFile(path.join(fixture, 'app.js'), `import { createOrgChart, JsonOrgChartDataSource } from './index.js'; +const data = await (await fetch('./data.json')).json(); +window.chart = createOrgChart({ container: document.querySelector('#root'), dataSource: new JsonOrgChartDataSource(data), layoutMode: 'auto', presentationMode: 'chart-only', initialView: 'fit', wheelZoom: 'direct', stylesheetUrls: { engine: './engine.css', core: location.search ? './external-theme.css' : './style.css' } }); +await window.chart.mount();`); +const browser = await launchChromium({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); +const requests = [], errors = [], checks = []; +page.on('request', request => requests.push(request.url())); +page.on('pageerror', error => errors.push(error.message)); +page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); +try { + for (const custom of [false, true]) { + await page.goto(`http://127.0.0.1:6032/plain-p11/index.html${custom ? '?theme=1' : ''}`); + await page.locator('.hm-orgchart-employee').first().waitFor(); + await page.waitForFunction(() => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('link')].every(link => link.sheet)); + for (const [id, color] of Object.entries(custom ? { hanmac: 'rgb(36, 87, 167)', saman: 'rgb(56, 92, 168)' } : { hanmac: 'rgb(203, 46, 40)', saman: 'rgb(240, 131, 0)' })) { + assert.equal(await page.locator(`.node[data-company-id="${id}"] .hm-orgchart-organization-name`).first().evaluate(el => getComputedStyle(el).backgroundColor), color); + checks.push({ name: `${custom ? 'external' : 'default'} ${id}`, status: 'PASS' }); + } + if (custom) { + await page.locator('.hm-orgchart-employee').first().click(); + await page.locator('.hm-orgchart-employee-dialog[open]').waitFor(); + assert.equal(await page.locator('.hm-orgchart-employee-dialog').evaluate(el => getComputedStyle(el).backgroundColor), 'rgb(238, 242, 255)'); + checks.push({ name: 'plain HTTP @import and native popup', status: 'PASS' }); + } + } + const viewport = await page.evaluate(() => { + window.chart.fitToView(); const fit = window.chart.getViewportState(); + window.chart.zoomTo(Math.min(1, fit.scale * 1.3)); const zoom = window.chart.getViewportState(); + window.chart.panBy(30, 20); const pan = window.chart.getViewportState(); + window.chart.fitToView(); const restored = window.chart.getViewportState(); + return { fit, zoom, pan, restored }; + }); + assert.notEqual(viewport.fit.scale, viewport.zoom.scale); + assert.equal(viewport.pan.x - viewport.zoom.x, 30); + assert.equal(viewport.pan.y - viewport.zoom.y, 20); + assert.ok(['scale', 'x', 'y'].every(key => Math.abs(viewport.restored[key] - viewport.fit[key]) < 0.01)); + checks.push({ name: 'public zoomTo / panBy / fitToView', status: 'PASS' }); + await page.evaluate(() => window.chart.destroy()); + assert.equal(await page.locator('.hm-orgchart-host').count(), 0); + checks.push({ name: 'public destroy cleanup', status: 'PASS' }); + assert.ok(requests.every(url => !/react|node_modules/.test(new URL(url).pathname))); + assert.deepEqual(errors, []); + checks.push({ name: 'Core ESM runs without React or bundler requests', status: 'PASS' }); +} finally { + await fs.writeFile(path.join(output, 'standalone-vanilla-result.json'), JSON.stringify({ checks, requests, errors }, null, 2)); + await browser.close(); +} +console.log(`Standalone Vanilla: ${checks.length} checks, ${errors.length} errors`); diff --git a/examples/org-chart/verify-p2.mjs b/examples/org-chart/verify-p2.mjs new file mode 100644 index 0000000..5af73c5 --- /dev/null +++ b/examples/org-chart/verify-p2.mjs @@ -0,0 +1,338 @@ +import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +import http from 'node:http'; +import path from 'node:path'; +import { readFile, mkdir, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +// P2-only browser evidence. Existing v0/P1 expectations and reports are retained. +// ESM HTML and React use HTTP; the classic HTML is opened directly with file://. +// No Chrome file-access flag is used, so classic portability is tested honestly. +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-p2'); +await mkdir(output, { recursive: true }); +const fixturePath = path.join(root, 'examples/org-chart/data-p1.json'); +const fixtureBytes = await readFile(fixturePath); +const fixtureHash = createHash('sha256').update(fixtureBytes).digest('hex'); +const fixture = JSON.parse(fixtureBytes.toString('utf8')); +await loadPlaywright(); +const viewports = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 1280, height: 800 }, { width: 768, height: 1024 }, { width: 390, height: 844 }]; +const expectedArrows = [[194.15, 311.75, 66.25, 19.8], [430.4, 311.75, 66.25, 19.8], [666.65, 302, 183.75, 42], [1044, 302, 258.92, 42], [1520.55, 305, 135.35, 36]]; +const results = [], measurements = [], errors = [], screenshots = [], servers = []; +const check = (condition, name, detail) => results.push({ name, status: condition ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); +const near = (actual, expected, tolerance = 0.2) => Number.isFinite(actual) && Math.abs(actual - expected) <= tolerance; +const spacedKorean = text => text.replace(/([가-힣])(?=[가-힣])/g, '$1 '); +const mime = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }; +async function serve(directory) { + const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url, 'http://127.0.0.1'); + const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname)); + const relative = path.relative(directory, file); + if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end(); + response.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); + response.end(await readFile(file)); + } catch { response.writeHead(404).end(); } + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + servers.push(server); + return `http://127.0.0.1:${server.address().port}`; +} +function captureErrors(page, route) { + page.on('pageerror', error => errors.push({ route, type: 'pageerror', message: error.message })); + page.on('console', message => { if (message.type() === 'error') errors.push({ route, type: 'console', message: message.text() }); }); +} +async function ready(page, mode = 'readOnly') { + await page.waitForFunction(expectedMode => { + const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; + return shadow?.querySelector(`.hm-orgchart-composition[data-display-mode="${expectedMode}"] [data-card-asset]`) + && !shadow.querySelector('.hm-orgchart-state')?.textContent + && [...shadow.querySelectorAll('link[rel=stylesheet]')].every(link => !!link.sheet); + }, mode, { timeout: 20000 }); + await page.evaluate(async () => { + await document.fonts.ready; + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + await Promise.all([...shadow.querySelectorAll('img')].map(image => image.decode().catch(() => {}))); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }); +} +async function measure(page) { + return page.evaluate(async () => { + const host = document.querySelector('.hm-orgchart-host'); + const shadow = host.shadowRoot; + const composition = shadow.querySelector('.hm-orgchart-composition'); + const stage = shadow.querySelector('.hm-orgchart-stage'); + const origin = stage.getBoundingClientRect(); + // dataset.scale is rounded to four digits; measured transform is required + // to recover the small cards accurately at the 390px viewport. + const scale = origin.width / stage.offsetWidth; + const rect = element => { + const box = element.getBoundingClientRect(); + return { x: (box.left - origin.left) / scale, y: (box.top - origin.top) / scale, width: box.width / scale, height: box.height / scale }; + }; + const dimensions = element => element ? { clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, overflowX: getComputedStyle(element).overflowX, overflowY: getComputedStyle(element).overflowY } : null; + const text = element => element?.textContent?.replace(/\s+/g, ' ').trim() ?? ''; + const overlaps = (a, b) => Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > 0.75 && Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > 0.75; + const fingerprint = source => { + let hash = 2166136261; + for (let index = 0; index < source.length; index++) hash = Math.imul(hash ^ source.charCodeAt(index), 16777619); + return (hash >>> 0).toString(16); + }; + const cards = [...shadow.querySelectorAll('.hm-orgchart-employee[data-card-asset]')].map(card => { + const node = card.closest('.node[data-organization-id]'); + const background = card.querySelector('.hm-orgchart-card-background'); + const css = getComputedStyle(card); + const personName = card.querySelector('.hm-orgchart-person-name'); + const cardBox = rect(card), avatarBox = rect(card.querySelector('.hm-orgchart-avatar')); + const logoBox = card.querySelector('.hm-orgchart-affiliation-mark') ? rect(card.querySelector('.hm-orgchart-affiliation-mark')) : null; + const contentIssues = [], textBoxes = [], fontRangeOverlaps = []; + const personText = card.querySelector('.hm-orgchart-person-text'); + const positionLine = personText?.firstElementChild; + const lineBoxes = { position: positionLine ? rect(positionLine) : null, name: rect(personName) }; + const role = card.querySelector('.hm-orgchart-person-role'); + const badge = card.querySelector('.hm-orgchart-position-row .hm-orgchart-job-title'); + const horizontalRoleBadgeGap = badge ? rect(badge).x - rect(role).x - rect(role).width : null; + if (lineBoxes.position && lineBoxes.position.y + lineBoxes.position.height > lineBoxes.name.y + 0.3) contentIssues.push('position/name: actual line-box overlap'); + if (horizontalRoleBadgeGap !== null && horizontalRoleBadgeGap < -0.3) contentIssues.push('role/badge: horizontal element-box overlap'); + const contained = box => box.x >= cardBox.x - 0.5 && box.y >= cardBox.y - 0.5 && box.x + box.width <= cardBox.x + cardBox.width + 0.5 && box.y + box.height <= cardBox.y + cardBox.height + 0.5; + for (const element of card.querySelectorAll('.hm-orgchart-person-name, .hm-orgchart-person-role, .hm-orgchart-job-title')) { + if (element.clientWidth > 0 && element.scrollWidth > element.clientWidth + 1) contentIssues.push(`${element.className}: horizontal text overflow`); + const range = document.createRange(); range.selectNodeContents(element); + for (const box of range.getClientRects()) { + const textBox = { x: (box.left - origin.left) / scale, y: (box.top - origin.top) / scale, width: box.width / scale, height: box.height / scale }; + if (!contained(textBox)) contentIssues.push(`${element.className}: text outside card`); + if (overlaps(textBox, avatarBox)) contentIssues.push(`${element.className}: avatar overlap`); + if (logoBox && overlaps(textBox, logoBox)) contentIssues.push(`${element.className}: logo overlap`); + textBoxes.push({ className: element.className, box: textBox }); + } + } + // Range rectangles contain the font ascent/descent envelope, including + // unpainted space. Noto Sans KR can make adjacent 0-gap line envelopes + // intersect despite distinct element line boxes and non-overlapping ink. + // Retain these metrics for visual review; never classify them as glyph + // collisions or silently claim that screenshot pixels were verified. + for (const [index, a] of textBoxes.entries()) for (const b of textBoxes.slice(index + 1)) if (a.className !== b.className && overlaps(a.box, b.box)) fontRangeOverlaps.push({ a: a.className, b: b.className, horizontal: Math.min(a.box.x + a.box.width, b.box.x + b.box.width) - Math.max(a.box.x, b.box.x), vertical: Math.min(a.box.y + a.box.height, b.box.y + b.box.height) - Math.max(a.box.y, b.box.y) }); + if (!contained(avatarBox)) contentIssues.push('avatar outside card'); + if (logoBox && !contained(logoBox)) contentIssues.push('logo outside card'); + return { + employeeId: card.dataset.employeeId, organizationId: node?.dataset.organizationId ?? null, + depth: node ? Number(node.dataset.organizationDepth ?? node.dataset.depth) : null, + kind: card.dataset.cardKind, asset: card.dataset.cardAsset, size: card.dataset.cardAssetSize, + topSlot: !!card.closest('.hm-orgchart-top-leaders'), box: rect(card), + naturalWidth: background?.naturalWidth ?? 0, naturalHeight: background?.naturalHeight ?? 0, + embedded: !!background?.src.startsWith('data:image/svg+xml'), loaded: !!background?.complete && background.naturalWidth > 0, + name: text(personName), role: text(card.querySelector('.hm-orgchart-person-role')), badge: text(card.querySelector('.hm-orgchart-job-title')), + nameAccessible: personName.getAttribute('aria-label'), roleAccessible: card.querySelector('.hm-orgchart-person-role')?.getAttribute('aria-label'), badgeAccessible: card.querySelector('.hm-orgchart-job-title')?.getAttribute('aria-label'), + nameStyle: { fontSize: getComputedStyle(personName).fontSize, fontWeight: getComputedStyle(personName).fontWeight, textOverflow: getComputedStyle(personName).textOverflow }, + contentIssues, textBoxes, lineBoxes, horizontalRoleBadgeGap, fontRangeOverlaps, + radius: css.borderRadius, border: css.borderWidth, fill: css.backgroundColor, + }; + }); + const headers = [...shadow.querySelectorAll('.node[data-organization-id] .hm-orgchart-organization-name')].map(header => { + const node = header.closest('.node'); + const card = node.querySelector('.hm-orgchart-employee'); + const box = rect(header); + const wrapperCss = getComputedStyle(header.parentElement); + return { organizationId: node.dataset.organizationId, depth: Number(node.dataset.organizationDepth ?? node.dataset.depth), text: text(header), box, + firstCardKind: card?.dataset.cardKind ?? null, headerCardGap: card ? rect(card).y - box.y - box.height : null, + wrapperBorder: wrapperCss.borderWidth, wrapperFill: wrapperCss.backgroundColor, wrapperShadow: wrapperCss.boxShadow }; + }); + const overlay = shadow.querySelector('.hm-orgchart-workdesk-overlay'); + const arrows = await Promise.all([...shadow.querySelectorAll('.hm-orgchart-workdesk-overlay image[data-arrow]')].map(async element => { + const source = element.getAttribute('href') ?? element.getAttributeNS('http://www.w3.org/1999/xlink', 'href') ?? ''; + const image = new Image(); image.src = source; + await image.decode().catch(() => {}); + return { id: element.dataset.arrow, x: Number(element.getAttribute('x')), y: Number(element.getAttribute('y')), width: Number(element.getAttribute('width')), height: Number(element.getAttribute('height')), + box: rect(element), embedded: source.startsWith('data:image/png'), fingerprint: fingerprint(source), loaded: image.complete && image.naturalWidth > 0, naturalWidth: image.naturalWidth, naturalHeight: image.naturalHeight, + preserveAspectRatio: element.getAttribute('preserveAspectRatio') }; + })); + const decorations = [...shadow.querySelectorAll('img[data-workdesk-asset]')].map(image => ({ asset: image.dataset.workdeskAsset, box: rect(image), embedded: image.src.startsWith('data:image/'), fingerprint: fingerprint(image.src), loaded: image.complete && image.naturalWidth > 0, naturalWidth: image.naturalWidth, naturalHeight: image.naturalHeight })); + const connectors = [...shadow.querySelectorAll('.hm-orgchart-workdesk-overlay path[data-connector]')].map(element => ({ id: element.dataset.connector, path: element.getAttribute('d'), stroke: getComputedStyle(element).stroke, strokeWidth: getComputedStyle(element).strokeWidth, dasharray: getComputedStyle(element).strokeDasharray, fill: getComputedStyle(element).fill, box: rect(element) })); + const physical = [...cards.map(card => ({ type: 'card', id: card.employeeId, box: card.box })), ...headers.map(header => ({ type: 'header', id: header.organizationId, box: header.box }))]; + const collisions = []; + for (const [index, a] of physical.entries()) for (const b of physical.slice(index + 1)) if (overlaps(a.box, b.box)) collisions.push({ a: `${a.type}:${a.id}`, b: `${b.type}:${b.id}` }); + const arrowCollisions = arrows.flatMap(arrow => physical.filter(item => overlaps(arrow.box, item.box)).map(item => ({ arrow: arrow.id, item: `${item.type}:${item.id}` }))); + const summaries = [...shadow.querySelectorAll('.hm-orgchart-organization-totals, .hm-orgchart-affiliation-bar')].map(element => ({ className: element.className, box: rect(element), text: text(element) })); + const summaryCollisions = summaries.flatMap(summary => physical.filter(item => overlaps(summary.box, item.box)).map(item => ({ summary: summary.className, item: `${item.type}:${item.id}` }))); + const pseudo = selector => [...shadow.querySelectorAll(selector)].map(element => { + const before = getComputedStyle(element, '::before'), after = getComputedStyle(element, '::after'); + return { className: element.className, before: { content: before.content, display: before.display }, after: { content: after.content, display: after.display } }; + }); + return { + scale, recordedScale: composition.dataset.scale, baseWidth: origin.width / scale, baseHeight: origin.height / scale, + stageTransform: getComputedStyle(stage).transform, displayMode: composition.dataset.displayMode, + document: dimensions(document.documentElement), viewport: dimensions(shadow.querySelector('.hm-orgchart-viewport')), + cards, headers, arrows, decorations, connectors, overlayViewBox: overlay?.getAttribute('viewBox') ?? null, + presentationNames: [...shadow.querySelectorAll('.hm-orgchart-employee .hm-orgchart-person-name')].map(element => ({ text: text(element), accessible: element.getAttribute('aria-label'), overflow: element.scrollWidth > element.clientWidth })), + presentationPeople: [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({ employeeId: card.dataset.employeeId, + role: text(card.querySelector('.hm-orgchart-person-role')), roleAccessible: card.querySelector('.hm-orgchart-person-role')?.getAttribute('aria-label'), + badge: text(card.querySelector('.hm-orgchart-job-title')), badgeAccessible: card.querySelector('.hm-orgchart-job-title')?.getAttribute('aria-label') })), + collisions, arrowCollisions, summaryCollisions, summaries, + debugCount: shadow.querySelectorAll('.hm-orgchart-toggle, .hm-orgchart-kind, .hm-orgchart-domain-id, .hm-orgchart-node-count, .hm-orgchart-vacancy, .hm-orgchart-description, .hm-orgchart-policy').length, + hostControls: document.querySelectorAll('.hm-orgchart-story-controls').length, + largeMatrixCount: shadow.querySelectorAll('.hm-orgchart-position-table, .hm-orgchart-position-summary').length, + totalsCount: shadow.querySelectorAll('.hm-orgchart-organization-totals').length, + affiliationBarCount: shadow.querySelectorAll('.hm-orgchart-affiliation-bar').length, + affiliationBarRows: [...shadow.querySelectorAll('.hm-orgchart-affiliation-bar-item')].map(element => rect(element).y), + affiliationTotal: text(shadow.querySelector('.hm-orgchart-affiliation-bar-total dd')), + statusGroups: [...shadow.querySelectorAll('.hm-orgchart-status-group')].map(element => ({ status: element.dataset.workStatus, text: text(element.querySelector('h3')), box: rect(element), border: getComputedStyle(element).borderWidth })), + originalBranchPseudos: pseudo('.hm-orgchart-axis, .hm-orgchart-hub, .hm-orgchart-forest, .hm-orgchart-tree'), + }; + }); +} +function verify(value, label, viewport) { + const top = value.cards.filter(card => card.kind === 'topLeader'); + const depth1 = value.cards.filter(card => card.kind === 'leader' && card.depth === 1); + const deeper = value.cards.filter(card => card.kind === 'leader' && card.depth >= 2); + const sizeMatches = (card, size, width, height) => card.size === size && near(card.box.width, width) && near(card.box.height, height) && card.naturalWidth === width && card.naturalHeight === height; + check(top.length === 1 && top[0].topSlot && sizeMatches(top[0], 'representative', 146, 74), `${label}: representative alone uses 146×74 card`); + check(depth1.length === 6 && depth1.every(card => sizeMatches(card, 'leader', 146, 68)), `${label}: all six actual Depth 1 leaders use 146×68 cards`, depth1.map(card => ({ id: card.employeeId, size: card.size, box: card.box }))); + check(deeper.length > 0 && deeper.every(card => sizeMatches(card, 'employee', 108, 52)), `${label}: every actual Depth ≥2 leader uses 108×52 card`, deeper.map(card => ({ id: card.employeeId, depth: card.depth, size: card.size, box: card.box }))); + check(deeper.every(card => spacedKorean(fixture.employees.find(employee => employee.id === card.employeeId)?.jobTitle ?? '') === card.badge && card.badge), `${label}: small leader cards preserve supplied job-title badges with requested Korean spacing`, deeper.map(card => ({ id: card.employeeId, badge: card.badge }))); + check(value.presentationNames.length > 0 && value.presentationNames.every(name => name.text === '홍 길 동' && name.accessible === '홍길동' && !name.overflow), `${label}: every displayed person name is 홍 길 동, fits, and retains an unspaced accessible name`, value.presentationNames); + check(value.presentationPeople.every(card => { + const employee = fixture.employees.find(employee => employee.id === card.employeeId); + return employee && card.role === spacedKorean(employee.position) && card.roleAccessible === employee.position && card.badge === spacedKorean(employee.jobTitle ?? '') && (!employee.jobTitle || card.badgeAccessible === employee.jobTitle); + }), `${label}: positions and job-title badges use one normal inter-Korean space while accessible labels retain original text`); + check(value.cards.every(card => card.contentIssues.length === 0), `${label}: text stays within card/avatar/logo boundaries; actual line boxes and horizontal role/badge boxes do not overlap`, value.cards.filter(card => card.contentIssues.length).map(card => ({ id: card.employeeId, issues: card.contentIssues, textBoxes: card.textBoxes, lineBoxes: card.lineBoxes, horizontalRoleBadgeGap: card.horizontalRoleBadgeGap }))); + check(value.cards.every(card => card.loaded && card.embedded), `${label}: original embedded card SVG assets load`); + const leaders = value.headers.filter(header => header.firstCardKind === 'leader'); + check(leaders.filter(header => header.depth === 1).length === 6 && leaders.every(header => near(header.headerCardGap, header.depth === 1 ? 8 : 10, 0.3)), `${label}: header-to-leader boundingRect gap is 8px at Depth 1 and 10px below`, leaders.map(header => ({ id: header.organizationId, depth: header.depth, gap: header.headerCardGap }))); + check(value.displayMode === 'readOnly' && value.debugCount === 0 && value.hostControls === 0, `${label}: read-only composition has no collapse/test/debug content`); + check(value.headers.every(header => header.wrapperBorder === '0px' && header.wrapperFill === 'rgba(0, 0, 0, 0)' && header.wrapperShadow === 'none'), `${label}: organization groups retain unframed presentation`); + check(value.totalsCount === 1 && value.affiliationBarCount === 1 && value.largeMatrixCount === 0, `${label}: compact organization totals and untitled affiliation bar are retained`); + check(value.affiliationTotal === `${new Set(fixture.employees.map(employee => employee.id)).size}명` && Math.max(...value.affiliationBarRows) - Math.min(...value.affiliationBarRows) < 1, `${label}: affiliation counts retain one base-canvas row and fixture total`); + check(value.statusGroups.length === 2 && value.statusGroups.every(group => group.border === '0px' && !group.text.includes('·')), `${label}: dispatched/on-leave groups retain compact presentation`); + check(near(value.baseWidth, 1920, 0.3) && near(value.baseHeight, 914.5, 1) && value.scale > 0 && value.scale <= 1, `${label}: entire 1920×914.5 base stage scales uniformly`, { width: value.baseWidth, height: value.baseHeight, scale: value.scale }); + check(value.document.scrollWidth <= viewport.width + 1, `${label}: document has no horizontal overflow`, value.document); + check(value.overlayViewBox?.replace(/\s+/g, ' ').trim() === '0 0 1920 914.5', `${label}: connectors and arrows share stage coordinates`); + check(value.arrows.length === 5 && value.arrows.every((arrow, index) => [arrow.x, arrow.y, arrow.width, arrow.height].every((number, coordinate) => near(number, expectedArrows[index][coordinate], 0.05)) && arrow.preserveAspectRatio === 'none' && arrow.embedded && arrow.loaded), `${label}: five embedded collaboration arrows retain source placement`, value.arrows); + check(value.decorations.length === 5 && value.decorations.every(asset => asset.embedded && asset.loaded), `${label}: five Workdesk decorations load without external URLs`, value.decorations); + check(value.connectors.length === 5 && value.connectors.some(connector => /[Cc]/.test(connector.path ?? '')) && value.connectors.every(connector => connector.path && connector.fill === 'none' && connector.stroke === 'rgb(69, 99, 159)' && near(parseFloat(connector.strokeWidth), 0.893, 0.03) && connector.dasharray.split(/[ ,]+/).every(number => near(parseFloat(number), 1.79, 0.03))), `${label}: source overlay curves retain stroke, fill, width and dash pattern`, value.connectors); + check(value.collisions.length === 0, `${label}: card/header rectangles do not overlap`, value.collisions); + check(value.arrowCollisions.length === 0, `${label}: collaboration arrow rectangles do not intersect cards/headers`, value.arrowCollisions); + check(value.summaryCollisions.length === 0, `${label}: compact summary rectangles do not intersect cards/headers`, value.summaryCollisions); +} +async function tooltip(page, transport) { + const lowerOrganization = fixture.organizations.find(organization => organization.parentId && fixture.organizations.find(parent => parent.id === organization.parentId)?.parentId && organization.leaderId); + const employeeId = lowerOrganization.leaderId; + const fullName = '홍 길 동'; + const name = page.locator(`.hm-orgchart-employee[data-employee-id="${employeeId}"] .hm-orgchart-person-name`); + await name.waitFor({ state: 'visible' }); + // All requested default names must fit. Induce a narrow name box locally to + // exercise overflow behavior without altering fixture or presentation text. + const originalInlineStyle = await name.getAttribute('style'); + await name.evaluate(element => { element.style.width = '8px'; element.style.maxWidth = '8px'; }); + await page.waitForFunction(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-employee[data-employee-id="${id}"] .hm-orgchart-person-name`)?.dataset.nameOverflow === 'true', employeeId); + const before = await page.evaluate(id => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + const name = shadow.querySelector(`.hm-orgchart-employee[data-employee-id="${id}"] .hm-orgchart-person-name`); + const tip = shadow.getElementById(name.getAttribute('aria-describedby')); + return { fullText: name.textContent, tabIndex: name.tabIndex, ellipsis: getComputedStyle(name).textOverflow, tipHidden: tip.hidden, documentHeight: document.documentElement.scrollHeight, viewportHeight: shadow.querySelector('.hm-orgchart-viewport').scrollHeight }; + }, employeeId); + check(before.fullText === fullName && before.tabIndex === 0 && before.ellipsis === 'ellipsis' && before.tipHidden, `${transport}: induced overflow preserves 홍 길 동 and exposes focusable ellipsis`, before); + await name.hover(); + const tip = page.locator('.hm-orgchart-person-name-tooltip:not([hidden])'); + await tip.waitFor({ state: 'visible' }); + check(await tip.textContent() === fullName, `${transport}: hover exposes complete requested name in tooltip`); + await tip.hover(); + check(await tip.isVisible(), `${transport}: tooltip remains hoverable`); + await page.mouse.move(0, 0); + await page.waitForFunction(() => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('[role=tooltip]')].every(tip => tip.hidden)); + // The only overflowed name is in the normal tab order: use the keyboard, + // rather than locator.focus(), to prove actual Tab activation. + await page.evaluate(() => document.activeElement?.blur()); + let reached = false; + for (let step = 0; step < 100 && !reached; step++) { + await page.keyboard.press('Tab'); + reached = await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.closest(`[data-employee-id="${id}"]`)?.dataset.employeeId === id, employeeId); + } + check(reached && await tip.isVisible(), `${transport}: keyboard Tab focus opens complete tooltip`); + await page.keyboard.press('Escape'); + check(!(await tip.isVisible()), `${transport}: Escape dismisses tooltip while focus stays on name`); + const after = await page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + return { documentHeight: document.documentElement.scrollHeight, viewportHeight: shadow.querySelector('.hm-orgchart-viewport').scrollHeight, activeClass: shadow.activeElement?.className }; + }); + check(before.documentHeight === after.documentHeight && before.viewportHeight === after.viewportHeight && after.activeClass === 'hm-orgchart-person-name', `${transport}: hidden tooltip does not inflate scroll extents or lose focus`, { before, after }); + await page.keyboard.press('Tab'); + check(!(await tip.isVisible()), `${transport}: moving focus keeps tooltip dismissed`); + await name.evaluate((element, originalInlineStyle) => originalInlineStyle === null ? element.removeAttribute('style') : element.setAttribute('style', originalInlineStyle), originalInlineStyle); + await page.waitForFunction(id => document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`.hm-orgchart-employee[data-employee-id="${id}"] .hm-orgchart-person-name`)?.dataset.nameOverflow === 'false', employeeId); + check(await name.getAttribute('tabindex') === null && await name.getAttribute('aria-describedby') === null, `${transport}: restoring the name box removes overflow-only focus and tooltip attributes`); +} +function sameGeometry(a, b) { + if (a.cards.length !== b.cards.length || a.headers.length !== b.headers.length) return false; + const boxesEqual = (first, second) => ['x', 'y', 'width', 'height'].every(key => near(first[key], second[key], 0.4)); + return a.cards.every((card, index) => { + const other = b.cards[index]; + return ['employeeId', 'organizationId', 'depth', 'kind', 'asset', 'size', 'badge', 'name', 'role'].every(key => card[key] === other[key]) && boxesEqual(card.box, other.box); + }) && a.headers.every((header, index) => header.organizationId === b.headers[index].organizationId && boxesEqual(header.box, b.headers[index].box)) + && JSON.stringify(a.connectors.map(({ id, path }) => ({ id, path }))) === JSON.stringify(b.connectors.map(({ id, path }) => ({ id, path }))) + && JSON.stringify(a.arrows.map(({ fingerprint, x, y, width, height }) => ({ fingerprint, x, y, width, height }))) === JSON.stringify(b.arrows.map(({ fingerprint, x, y, width, height }) => ({ fingerprint, x, y, width, height }))); +} +let browser; +try { + const [htmlOrigin, reactOrigin] = await Promise.all([serve(path.join(root, 'examples/org-chart/dist')), serve(path.join(root, 'examples/org-chart-react/dist'))]); + const storybookOrigin = process.env.ORGCHART_STORYBOOK_URL?.replace(/\/$/, '') ?? await serve(path.join(root, 'storybook-static')); + browser = await launchChromium({ headless: true }); + const context = await browser.newContext({ deviceScaleFactor: 1, reducedMotion: 'reduce' }); + const routes = [ + { label: 'html', transport: 'html', url: `${htmlOrigin}/p1.html?mode=readOnly` }, + { label: 'file-classic', transport: 'html', url: `${pathToFileURL(path.join(root, 'examples/org-chart/dist/p1-classic.html')).href}?mode=readOnly` }, + { label: 'react', transport: 'react', url: `${reactOrigin}/p1.html?mode=readOnly` }, + { label: 'storybook', transport: null, url: `${storybookOrigin}/iframe.html?id=orgchart--full-composition&viewMode=story` }, + ]; + for (const route of routes) { + const page = await context.newPage(); captureErrors(page, route.label); + try { + await page.setViewportSize(viewports[0]); + await page.goto(route.url); await ready(page); + for (const viewport of viewports) { + await page.setViewportSize(viewport); await ready(page); + const value = await measure(page); + measurements.push({ route: route.label, ...viewport, ...value }); + verify(value, `${route.label}/${viewport.width}×${viewport.height}`, viewport); + const filename = `${route.label}-${viewport.width}x${viewport.height}.png`; + await page.screenshot({ path: path.join(output, filename), fullPage: true }); screenshots.push(filename); + } + if (route.transport && route.label !== 'file-classic') { + await page.setViewportSize(viewports[1]); await ready(page); + await tooltip(page, route.transport); + } + } catch (error) { check(false, `${route.label}: browser verification completed`, { message: error.message }); } + finally { await page.close(); } + } + for (const viewport of viewports) { + const values = ['html', 'file-classic', 'react', 'storybook'].map(route => measurements.find(item => item.route === route && item.width === viewport.width)); + check(values.every(Boolean) && values.slice(1).every(value => sameGeometry(values[0], value)), `HTML/React/classic/Story shared Core geometry and asset parity/${viewport.width}`, { routes: values.map(value => value?.route ?? 'missing'), toleranceInBasePixels: 0.4 }); + } + const interactive = await context.newPage(); captureErrors(interactive, 'interactive'); + await interactive.goto(`${htmlOrigin}/p1.html`); await ready(interactive, 'interactive'); + const preserved = await interactive.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + const node = [...shadow.querySelectorAll('.node')].find(node => Number(node.dataset.organizationDepth ?? node.dataset.depth) >= 2 && node.querySelector('[data-card-kind=leader]')); + const card = node?.querySelector('[data-card-kind=leader]'); + return { organizationId: node?.dataset.organizationId, size: card?.dataset.cardAssetSize, width: card?.offsetWidth, height: card?.offsetHeight, toggleCount: shadow.querySelectorAll('.hm-orgchart-toggle').length, overlayCount: shadow.querySelectorAll('.hm-orgchart-workdesk-overlay').length }; + }); + check(preserved.size === 'leader' && preserved.width === 146 && preserved.height === 68 && preserved.toggleCount > 0 && preserved.overlayCount === 0, 'interactive default preserves existing lower-leader large-card policy and controls', preserved); + await interactive.close(); + check(createHash('sha256').update(await readFile(fixturePath)).digest('hex') === fixtureHash, 'P2 browser verifier preserves original employee fixture bytes and IDs', { sha256: fixtureHash }); + check(errors.length === 0, 'P2 routes have no browser console/runtime errors', errors); +} catch (error) { check(false, 'P2 verifier setup/completion', { message: error.message }); } +finally { + await writeFile(path.join(output, 'browser.json'), JSON.stringify({ + browser: browser?.version(), scope: 'P2 only; no existing v0/P1 suite expectation changes', + transportPolicy: 'HTTP for ESM/React; true file:// classic without file-access browser flag', + tooltipMethod: 'Induced 8px name width on one rendered 홍 길 동, restored after real hover/Tab/Escape. Fixture and displayed text are unchanged.', + fixtureSha256: fixtureHash, + connectorCollisionVerification: 'NOT VERIFIED by automation: path bounding boxes do not represent curved segments. Review saved full-page screenshots for routing and overlap.', + textInkCollisionVerification: 'NOT VERIFIED by automation: font Range ascent/descent envelopes may overlap at the confirmed 0px internal row gap. Actual element line boxes, horizontal role/badge boxes, horizontal overflow and card/avatar/logo boundaries are checked; fontRangeOverlaps retain raw envelopes for screenshot pixel review.', + results, errors, measurements, screenshots, + }, null, 2)); + await browser?.close(); + for (const server of servers) await new Promise(resolve => server.close(resolve)); +} +const failures = results.filter(result => result.status === 'FAIL'); +console.log(JSON.stringify({ output, checks: results.length, failureCount: failures.length, failures: failures.slice(0, 20).map(result => result.name), ...(failures.length > 20 ? { additionalFailures: failures.length - 20 } : {}) }, null, 2)); +if (failures.length) process.exitCode = 1; diff --git a/examples/org-chart/verify-p3.1.mjs b/examples/org-chart/verify-p3.1.mjs new file mode 100644 index 0000000..6106504 --- /dev/null +++ b/examples/org-chart/verify-p3.1.mjs @@ -0,0 +1,196 @@ +import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +import http from 'node:http'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +// P3.1 change-specific checks. The prior 732 checks and their evidence are retained. +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-p3.1'); +const fixtureBytes = await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json')); +const fixture = JSON.parse(fixtureBytes); +await loadPlaywright(); +const viewports = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 1280, height: 800 }, { width: 768, height: 1024 }, { width: 390, height: 844 }]; +const results = [], measurements = [], fontEvidence = [], errors = [], servers = []; +const check = (pass, name, detail) => results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); +const near = (a, b, tolerance = .4) => Number.isFinite(a) && Math.abs(a - b) <= tolerance; +const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ttf': 'font/ttf', '.svg': 'image/svg+xml' }; +await fs.mkdir(output, { recursive: true }); +async function serve(directory) { + const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url, 'http://127.0.0.1'); + if (url.pathname === '/__p31_link') return response.end('

Group link reached

'); + if (url.pathname === '/__p31_photo.svg') { response.setHeader('Content-Type', 'image/svg+xml'); return response.end(''); } + if (url.pathname === '/__p31_bad_photo.png') { response.setHeader('Content-Type', 'image/png'); return response.end('intentional invalid image bytes'); } + const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname)); + const relative = path.relative(directory, file); + if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end(); + response.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); + response.end(await fs.readFile(file)); + } catch { response.writeHead(404).end(); } + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); servers.push(server); + return `http://127.0.0.1:${server.address().port}`; +} +async function ready(page, mode = 'readOnly') { + await page.waitForFunction(mode => { + const s = document.querySelector('.hm-orgchart-host')?.shadowRoot; + return s?.querySelector(`.hm-orgchart-composition[data-display-mode="${mode}"] [data-card-asset]`) && [...s.querySelectorAll('link[rel=stylesheet]')].every(l => l.sheet); + }, mode, { timeout: 25000 }); + await page.evaluate(async () => { + await document.fonts.load('700 20px "Noto Sans KR"', '홍 길 동 협업 소통 허브 경영진 통합시스템'); + await document.fonts.ready; + await Promise.all([...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('img')].map(i => i.decode().catch(() => {}))); + await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); + }); +} +async function measure(page) { + return page.evaluate(() => { + const s = document.querySelector('.hm-orgchart-host').shadowRoot, stage = s.querySelector('.hm-orgchart-stage'); + const origin = stage.getBoundingClientRect(), scale = origin.width / 1920; + const rect = e => { const r = e.getBoundingClientRect(); return { x: (r.x - origin.x) / scale, y: (r.y - origin.y) / scale, width: r.width / scale, height: r.height / scale }; }; + const style = (e, props) => Object.fromEntries(props.map(p => [p, getComputedStyle(e)[p]])); + const props = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing']; + const cards = [...s.querySelectorAll('.hm-orgchart-employee')].map(c => ({ + id: c.dataset.employeeId, size: c.dataset.cardAssetSize, kind: c.dataset.cardKind, box: rect(c), + name: c.querySelector('.hm-orgchart-person-name').textContent, role: c.querySelector('.hm-orgchart-person-role').textContent, + badge: c.querySelector('.hm-orgchart-job-title')?.textContent ?? '', photo: c.dataset.photoState, + image: c.querySelector('.hm-orgchart-avatar img') && { src: c.querySelector('.hm-orgchart-avatar img').src, naturalWidth: c.querySelector('.hm-orgchart-avatar img').naturalWidth, placeholder: c.querySelector('.hm-orgchart-avatar img').dataset.photoPlaceholder }, + avatar: { box: rect(c.querySelector('.hm-orgchart-avatar')), ...style(c.querySelector('.hm-orgchart-avatar'), ['borderRadius']) }, + emptyAttributes: !!c.querySelector('.hm-orgchart-person-attributes:empty'), + text: [...c.querySelectorAll('.hm-orgchart-person-name,.hm-orgchart-person-role,.hm-orgchart-job-title')].map(e => ({ text: e.textContent, box: rect(e), clientWidth: e.clientWidth, scrollWidth: e.scrollWidth, ...style(e, props) })), + })); + const headers = [...s.querySelectorAll('.node[data-organization-id]')].map(n => { + const h = n.querySelector('.hm-orgchart-organization-name'), first = n.querySelector('.hm-orgchart-employee'); + return { id: n.dataset.organizationId, depth: Number(n.dataset.organizationDepth), box: rect(h), gap: first ? rect(first).y - rect(h).y - rect(h).height : null, ...style(h, [...props, 'backgroundImage', 'textAlign', 'borderRadius', 'boxShadow']) }; + }); + const hub = s.querySelector('.hm-orgchart-hub'), label = hub.querySelector('.hm-orgchart-hub-label'), icon = hub.querySelector('.hm-orgchart-hub-icon'); + const cells = [...s.querySelectorAll('.hm-orgchart-workdesk-position-table th,.hm-orgchart-workdesk-position-table td')].map(e => { + const r = document.createRange(); r.selectNodeContents(e); + const ranges = [...r.getClientRects()].filter(r => r.width > 0).map(r => ({ x: (r.x - origin.x) / scale, y: (r.y - origin.y) / scale, width: r.width / scale, height: r.height / scale })); + return { text: e.textContent, box: rect(e), ranges, ...style(e, ['whiteSpace', 'textOverflow', 'overflowX', 'paddingLeft', 'paddingRight', ...props]) }; + }); + const fontViolations = [...s.querySelectorAll('.hm-orgchart-shell *')].filter(e => [...e.childNodes].some(n => n.nodeType === 3 && n.textContent.trim()) && !getComputedStyle(e).fontFamily.includes('Noto Sans KR')).map(e => ({ class: e.className, text: e.textContent, fontFamily: getComputedStyle(e).fontFamily })); + const table = s.querySelector('.hm-orgchart-workdesk-position-table'); + return { scale, cards, headers, hub: { box: rect(hub), icon: rect(icon), label: rect(label), axis: rect(s.querySelector('.hm-orgchart-axis')), ...style(hub, ['paddingLeft', 'paddingRight', 'gap']), font: style(label, props), paths: icon.querySelectorAll('path').length }, cells, fontViolations, + table: { box: rect(table), rows: [...table.rows].map(r => [...r.cells].map(c => c.textContent)), total: table.querySelector('[data-total-count]').textContent }, + document: { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, clientHeight: document.documentElement.clientHeight, scrollHeight: document.documentElement.scrollHeight }, + outside: cards.filter(c => c.box.x < -.5 || c.box.y < -.5 || c.box.x + c.box.width > 1920.5 || c.box.y + c.box.height > 915), + connectorPaths: s.querySelectorAll('.hm-orgchart-workdesk-overlay path').length, + footer: s.querySelector('.hm-orgchart-affiliation-bar').textContent, + }; + }); +} +async function glyphFonts(page, route, selectors) { + const session = await page.context().newCDPSession(page); + await session.send('DOM.enable'); await session.send('CSS.enable'); + for (const [name, selector] of selectors) { + const target = page.locator(selector).first(); + await target.evaluate(e => e.setAttribute('data-p31-font-proof', 'yes')); + const { root: dom } = await session.send('DOM.getDocument', { depth: -1, pierce: true }); + const find = node => node.attributes?.some((a, i) => i % 2 === 0 && a === 'data-p31-font-proof') ? node : [...(node.children ?? []), ...(node.shadowRoots ?? [])].map(find).find(Boolean); + const node = find(dom); + const value = await session.send('CSS.getPlatformFontsForNode', { nodeId: node.nodeId }); + fontEvidence.push({ route, name, ...value }); + // This variable font's internal family is "Noto Sans KR Thin" even at bold weights. + check(value.fonts.length > 0 && value.fonts.every(f => f.familyName.startsWith('Noto Sans KR') && f.postScriptName.startsWith('NotoSansKR') && f.isCustomFont && f.glyphCount > 0), `${route}: actual glyph font ${name}`, value.fonts); + await target.evaluate(e => e.removeAttribute('data-p31-font-proof')); + } + await session.detach(); +} +function verify(m, route, v) { + const name = `${route}/${v.width}×${v.height}`; + check(!m.fontViolations.length, `${name}: every text family is scoped Noto Sans KR`, m.fontViolations); + check(m.cards.length === 43 && m.cards.every(c => c.name === '홍 길 동' && c.role === fixture.employees.find(e => e.id === c.id).position.replace(/\s+/gu, '')), `${name}: display-only name/position; 43 cards`, m.cards.map(c => [c.id,c.name,c.role])); + check(m.cards.every(c => c.badge !== '경영진' && c.badge === ((fixture.employees.find(e => e.id === c.id).jobTitle?.trim() === '경영진') ? '' : fixture.employees.find(e => e.id === c.id).jobTitle ?? '')), `${name}: executive badge absent; other raw badges retained`); + check(m.cards.every(c => !c.emptyAttributes), `${name}: no empty badge/attribute container`); + check(m.cards.every(c => { const [w,h] = c.size === 'representative' ? [146,74] : c.size === 'leader' ? [146,68] : c.size === 'employee' ? [108,52] : [114,54]; return near(c.box.width,w) && near(c.box.height,h); }), `${name}: approved card sizes and existing P3 status sizes preserved`); + check(m.cards.every(c => { const [w,h] = c.size === 'representative' ? [36,62] : c.size === 'leader' ? [32,56] : c.size === 'employee' ? [24,44] : [28,28]; return c.photo === 'placeholder' && c.image?.placeholder === 'true' && c.image.naturalWidth > 0 && near(c.avatar.box.width,w) && near(c.avatar.box.height,h) && c.avatar.borderRadius !== '50%'; }) && new Set(m.cards.map(c => c.image?.src)).size === 1, `${name}: common decoded square icon in every noncircular slot`); + check(m.headers.filter(h => h.depth === 1).every(h => h.fontSize === '20px' && h.fontWeight === '700' && h.lineHeight === '36px' && near(h.box.height,36)), `${name}: 1Depth 20/700/36 and header36`, m.headers.filter(h => h.depth === 1)); + check(m.headers.filter(h => h.depth >= 2).every(h => { const small = h.id.startsWith('talent-growth-') || h.id.startsWith('business-planning-'); return h.fontSize === (small ? '18px':'20px') && h.fontWeight === (small?'600':'700') && h.lineHeight === (small?'16.9px':'19.7px') && near(h.box.width,114) && near(h.box.height,30.4); }), `${name}: 2Depth source typography/surface`, m.headers.filter(h => h.depth >= 2)); + check(m.headers.every(h => h.gap === null || near(h.gap,8)), `${name}: all title-to-first-card gaps 8px`, m.headers.map(h => [h.id,h.gap])); + check(m.hub.font.fontSize === '21px' && m.hub.font.fontWeight === '750' && m.hub.font.lineHeight === '24px' && m.hub.paddingLeft === '22px' && m.hub.paddingRight === '24px' && m.hub.gap === '8px' && near(m.hub.box.height,42) && near(m.hub.icon.width,20) && near(m.hub.icon.height,20) && near(m.hub.icon.y+10,m.hub.label.y+m.hub.label.height/2) && near(m.hub.box.x+m.hub.box.width/2,m.hub.axis.x+m.hub.axis.width/2) && m.hub.paths === 3, `${name}: hub font/padding/icon/axis alignment`, m.hub); + check(m.cells.every(c => c.whiteSpace === 'nowrap' && c.textOverflow !== 'ellipsis' && c.ranges.length === 1 && c.ranges[0].x >= c.box.x + parseFloat(c.paddingLeft) - .5 && c.ranges[0].x+c.ranges[0].width <= c.box.x+c.box.width-parseFloat(c.paddingRight)+.5), `${name}: every table label fits on one line without clipping`, m.cells.filter(c => c.ranges.length !== 1 || c.ranges[0].width > c.box.width-parseFloat(c.paddingLeft)-parseFloat(c.paddingRight)+.5)); + check(near(m.table.box.width,330,1) && m.table.total === '41' && m.footer.includes('41'), `${name}: table width/count and footer retained`, { table:m.table.box,total:m.table.total }); + check(m.document.scrollWidth === m.document.clientWidth && m.document.scrollHeight === m.document.clientHeight && !m.outside.length, `${name}: no page scroll; every card inside stage`, m.document); + check(m.cards.every(c => c.text.every(t => t.scrollWidth <= t.clientWidth+1 && t.box.x >= c.box.x-.5 && t.box.x+t.box.width <= c.box.x+c.box.width+.5)), `${name}: card typography has no new horizontal overflow`); +} +const dialogTargets = [['representative','.hm-orgchart-top-leaders .hm-orgchart-employee'],['depth1','.node[data-organization-depth="1"] .hm-orgchart-employee[data-card-kind="leader"]'],['depth2','.node[data-organization-depth="2"] .hm-orgchart-employee[data-card-kind="leader"]'],['employee','.hm-orgchart-engine .hm-orgchart-employee[data-card-kind="employee"]'],['dispatched','.hm-orgchart-status-group[data-work-status="dispatched"] .hm-orgchart-employee'],['onLeave','.hm-orgchart-status-group[data-work-status="onLeave"] .hm-orgchart-employee']]; +async function dialogs(page, route, viewport) { + await page.setViewportSize(viewport); await ready(page); + for (const [kind, selector] of dialogTargets) { + const card = page.locator(selector).first(), dialog = page.locator('dialog[open]'); + const id = await card.getAttribute('data-employee-id'); + await card.click(); await dialog.waitFor({ state:'visible' }); + const value = await dialog.evaluate(d => ({ fields:Object.fromEntries([...d.querySelectorAll('dt')].map(t=>[t.textContent,t.nextElementSibling.textContent])), font:getComputedStyle(d).fontFamily, width:d.getBoundingClientRect().width, scaled:!!d.closest('.hm-orgchart-stage'), transform:getComputedStyle(d).transform })); + const employee = fixture.employees.find(e=>e.id===id); + check(value.fields['이름']==='홍 길 동' && value.fields['직위']===employee.position && (!employee.jobTitle || value.fields['직책']===employee.jobTitle) && value.font.includes('Noto Sans KR') && !value.scaled && value.transform==='none' && near(value.width,Math.min(420,viewport.width-32)), `${route}/${viewport.width}: ${kind} popup retains data/font/unscaled size`, value); + if (kind==='representative' && viewport.width===1920) await glyphFonts(page,route,[['popup name','dialog[open] dd']]); + await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab'); + check(await dialog.evaluate(d=>d.contains(d.getRootNode().activeElement)),`${route}/${viewport.width}: ${kind} modal focus trap`); + await page.keyboard.press('Escape'); await dialog.waitFor({state:'hidden'}); + check(await card.evaluate(e=>e.getRootNode().activeElement===e),`${route}/${viewport.width}: ${kind} ESC focus return`); + if(kind==='representative') { await card.press('Enter'); await dialog.waitFor({state:'visible'}); await dialog.locator('button').click(); await dialog.waitFor({state:'hidden'}); await card.press('Space'); await dialog.waitFor({state:'visible'}); await page.keyboard.press('Escape'); check(await card.evaluate(e=>e.getRootNode().activeElement===e),`${route}/${viewport.width}: Enter/Space/close preserve activation`); } + } +} +async function setData(page,data,transport) { await page.evaluate(async({data,transport})=>{ if(transport==='react')window.OrgChartP1React.setData(data);else await window.OrgChartP1.setData(data); },{data,transport}); await ready(page); } +let browser; +try { + const htmlOrigin = await serve(path.join(root,'examples/org-chart/dist')); + const reactOrigin = await serve(path.join(root,'examples/org-chart-react/dist')); + browser = await launchChromium({ headless: true }); + const context = await browser.newContext(); + const routes = [{name:'html',url:`${htmlOrigin}/p1.html?mode=readOnly`},{name:'file-classic',url:pathToFileURL(path.join(root,'examples/org-chart/dist/p1-classic.html')).href+'?mode=readOnly'},{name:'react',url:`${reactOrigin}/p1.html?mode=readOnly`},{name:'storybook',url:'http://127.0.0.1:6041/iframe.html?id=orgchart--full-composition&viewMode=story'}]; + for(const route of routes) { + const page = await context.newPage(); page.on('pageerror',e=>errors.push({route:route.name,type:'runtime',message:e.message})); page.on('console',e=>{if(e.type()==='error')errors.push({route:route.name,type:'console',message:e.text()});}); + try { + await page.goto(route.url); await ready(page); + for(const viewport of viewports) { await page.setViewportSize(viewport); await ready(page); const m = await measure(page); measurements.push({route:route.name,...viewport,...m}); verify(m,route.name,viewport); if([1920,390].includes(viewport.width))await page.screenshot({path:path.join(output,`${route.name}-${viewport.width}x${viewport.height}.png`)}); } + await page.setViewportSize(viewports[0]); await ready(page); + await glyphFonts(page,route.name,[['card name','.hm-orgchart-person-name'],['card position','.hm-orgchart-person-role'],['1Depth','.node[data-organization-depth="1"] .hm-orgchart-organization-name'],['2Depth','.node[data-organization-depth="2"] .hm-orgchart-organization-name'],['hub','.hm-orgchart-hub-label'],['table','.hm-orgchart-workdesk-position-table th'],['footer','.hm-orgchart-affiliation-bar-item dt'],['status','.hm-orgchart-status-group .hm-orgchart-summary-title']]); + await dialogs(page,route.name,viewports[0]); await dialogs(page,route.name,viewports.at(-1)); + if(['html','react'].includes(route.name)) { + const data = structuredClone(fixture); data.employees[0].profileImage=`${htmlOrigin}/__p31_photo.svg`; data.employees[1].profileImage=`${htmlOrigin}/__p31_bad_photo.png`; data.employees[2].position='책 임'; + await setData(page,data,route.name); + const imageState = await page.evaluate(ids=>ids.map(id=>{const c=document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`[data-employee-id="${id}"]`),i=c.querySelector('.hm-orgchart-avatar img');return {id,photo:c.dataset.photoState,src:i.src,width:i.naturalWidth,role:c.querySelector('.hm-orgchart-person-role').textContent};}),data.employees.slice(0,3).map(e=>e.id)); + check(imageState[0].photo==='image' && imageState[0].width===80 && imageState[1].photo==='placeholder' && imageState[1].width===64 && imageState[2].role==='책임' && data.employees[2].position==='책 임',`${route.name}: real image precedence/load-failure fallback/display whitespace`,imageState); + await setData(page,fixture,route.name); + // The preserved fixture has position=경영진 but no jobTitle=경영진. + // Exercise the requested badge policy with in-memory variants, never edit JSON. + const executiveIds = await page.evaluate(selectors=>selectors.map(selector=>document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(selector).dataset.employeeId),dialogTargets.map(t=>t[1])); + const executiveData = structuredClone(fixture); + for(const employee of executiveData.employees)if(executiveIds.includes(employee.id))employee.jobTitle='경영진'; + await setData(page,executiveData,route.name); + const executiveState = await page.evaluate(ids=>[...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-employee')].filter(c=>ids.includes(c.dataset.employeeId)).map(c=>({id:c.dataset.employeeId,badge:c.querySelector('.hm-orgchart-job-title')?.textContent,role:c.querySelector('.hm-orgchart-person-role').textContent,empty:!!c.querySelector('.hm-orgchart-person-attributes:empty')})),executiveIds); + check(executiveState.length>=6 && executiveState.every(c=>!c.badge&&!c.empty&&c.role===fixture.employees.find(e=>e.id===c.id).position),`${route.name}: executive badge suppressed in all six card roles; positions preserved`,executiveState); + await page.locator(dialogTargets[0][1]).click();await page.locator('dialog[open]').waitFor({state:'visible'}); + const rawRole=await page.locator('dialog[open]').evaluate(d=>[...d.querySelectorAll('dt')].find(t=>t.textContent==='직책')?.nextElementSibling.textContent); + check(rawRole==='경영진' && executiveData.employees.find(e=>e.id===executiveIds[0]).jobTitle==='경영진',`${route.name}: hidden badge remains in data and popup contract`);await page.keyboard.press('Escape'); + await setData(page,fixture,route.name); + const id=fixture.organizations.find(o=>o.parentId===fixture.organizations.find(o=>o.parentId===null).id).id; + const links={[id]:`${route.name==='html'?htmlOrigin:reactOrigin}/__p31_link`}; + await page.evaluate(async({links,transport})=>{if(transport==='react')window.OrgChartP1React.setOptions({organizationLinks:links});else {const s=window.OrgChartP1;s.chart.destroy();s.chart=s.api.createOrgChart({container:document.getElementById('chart-host'),dataSource:new s.api.JsonOrgChartDataSource(s.data),stylesheetUrls:s.stylesheetUrls,displayMode:'readOnly',organizationLinks:links});await s.chart.mount();}},{links,transport:route.name}); await ready(page); + const link=page.locator('.hm-orgchart-organization-link');check(await link.count()===1,`${route.name}: only configured 1Depth title links`);await link.focus();await Promise.all([page.waitForURL(links[id]),page.keyboard.press('Enter')]);check(await page.locator('#proof').textContent()==='Group link reached',`${route.name}: keyboard group link navigation`); + } + } catch(error) { check(false,`${route.name}: focused verification completed`,{message:error.message,stack:error.stack}); } + await page.close(); + } + for(const v of viewports) { + const variants=measurements.filter(m=>m.width===v.width); + const base=variants.find(m=>m.route==='html'); + const equal=(a,b)=>['x','y','width','height'].every(k=>near(a[k],b[k])); + check(variants.length===4 && variants.every(m=>m.cards.length===base.cards.length && m.cards.every((c,i)=>equal(c.box,base.cards[i].box)&&c.name===base.cards[i].name&&c.role===base.cards[i].role&&c.badge===base.cards[i].badge&&c.image.src===base.cards[i].image.src)&&equal(m.hub.box,base.hub.box)&&JSON.stringify(m.table.rows)===JSON.stringify(base.table.rows)&&equal(m.table.box,base.table.box)),`HTML/classic/React/Storybook identical content/geometry/${v.width}`); + } + const standalone=await context.newPage();await standalone.goto(`${htmlOrigin}/p1.html`);await ready(standalone,'interactive'); + const standaloneData=structuredClone(fixture);standaloneData.employees[0].jobTitle='경영진';await standalone.evaluate(async data=>window.OrgChartP1.setData(data),standaloneData);await ready(standalone,'interactive'); + check((await standalone.locator('.hm-orgchart-job-title').allTextContents()).includes('경영진'),'shared renderer preserves executive badge outside readOnly Full Composition');await standalone.close(); + const index=await fetch('http://127.0.0.1:6041/index.json').then(r=>r.json());const userStories=Object.values(index.entries).filter(e=>e.title==='OrgChart'&&e.type==='story'&&!e.tags?.includes('internal'));check(userStories.length===3,'existing Storybook three user menus retained',userStories.map(e=>e.name)); + check(!errors.length,'no new browser console/runtime errors',errors); +} finally { + await browser?.close(); for(const server of servers)await new Promise(r=>server.close(r)); + 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:'P3.1 only; P3 732 checks retained without rerun',fixtureSha256:createHash('sha256').update(fixtureBytes).digest('hex'),summary,results,measurements,fontEvidence,errors},null,2)); + console.log(JSON.stringify({summary,errors,failures:results.filter(r=>r.status==='FAIL').map(r=>r.name)},null,2));if(summary.fail)process.exitCode=1; +} diff --git a/examples/org-chart/verify-p3.2.mjs b/examples/org-chart/verify-p3.2.mjs new file mode 100644 index 0000000..6e12309 --- /dev/null +++ b/examples/org-chart/verify-p3.2.mjs @@ -0,0 +1,255 @@ +import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +import http from 'node:http'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +// P3.2 change-specific evidence. Existing P3.1 486-check evidence is preserved. +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-p3.2'); +const baselineMode = process.argv.includes('--baseline'); +const policiesOnly = process.argv.includes('--policies-only'); +const fixtureBytes = await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json')); +const fixture = JSON.parse(fixtureBytes); +await loadPlaywright(); +const viewports = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 1280, height: 800 }, { width: 768, height: 1024 }, { width: 390, height: 844 }]; +const storyOrigin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6041'; +const results = [], measurements = [], errors = [], servers = []; +const check = (pass, name, detail) => results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); +const near = (a, b, tolerance = .4) => Number.isFinite(a) && Math.abs(a - b) <= tolerance; +const sameBox = (a, b) => a && b && ['x', 'y', 'width', 'height'].every(k => near(a[k], b[k])); +const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.ttf': 'font/ttf', '.svg': 'image/svg+xml' }; +await fs.mkdir(output, { recursive: true }); +const previous = baselineMode || policiesOnly ? null : JSON.parse(await fs.readFile(path.join(output, 'baseline-browser.json'), 'utf8')); +async function serve(directory) { + const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url, 'http://127.0.0.1'); + if (url.pathname === '/__p32_link') return response.end('

Group link reached

'); + const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname)); + const relative = path.relative(directory, file); + if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end(); + response.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); + response.end(await fs.readFile(file)); + } catch { response.writeHead(404).end(); } + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); servers.push(server); + return `http://127.0.0.1:${server.address().port}`; +} +async function ready(page) { + await page.waitForFunction(() => { + const s = document.querySelector('.hm-orgchart-host')?.shadowRoot; + return s?.querySelector('.hm-orgchart-composition[data-display-mode="readOnly"] [data-card-asset]') && [...s.querySelectorAll('link[rel=stylesheet]')].every(l => l.sheet); + }, null, { timeout: 25000 }); + await page.evaluate(async () => { + await document.fonts.load('600 18px "Noto Sans KR"', '홍 길 동'); + await document.fonts.ready; + await Promise.all([...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('img')].map(i => i.decode().catch(() => {}))); + await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); + }); +} +async function measure(page) { + return page.evaluate(() => { + const s = document.querySelector('.hm-orgchart-host').shadowRoot, stage = s.querySelector('.hm-orgchart-stage'); + const origin = stage.getBoundingClientRect(), scale = origin.width / 1920; + const rect = e => { const r = e.getBoundingClientRect(); return { x: (r.x - origin.x) / scale, y: (r.y - origin.y) / scale, width: r.width / scale, height: r.height / scale }; }; + const relative = (r, c) => ({ x: r.x - c.x, y: r.y - c.y, width: r.width, height: r.height }); + const style = (e, props) => Object.fromEntries(props.map(p => [p, getComputedStyle(e)[p]])); + const typography = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'color', 'letterSpacing']; + const contained = (a, b, tolerance = .5) => a.x >= b.x - tolerance && a.y >= b.y - tolerance && a.x + a.width <= b.x + b.width + tolerance && a.y + a.height <= b.y + b.height + tolerance; + const overlap = (a, b) => a && b && Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > .5 && Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > .5; + const inks = new Map(); + // Reuse the approved P3 logo test method: natural PNG alpha bounds (>16), + // transformed through object-fit:contain. Transparent image padding is excluded. + const logoPixels = img => { + if (!img?.naturalWidth) return null; + if (!inks.has(img.src)) { + const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; + const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0); + const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height).data; + let left = canvas.width, right = -1, top = canvas.height, bottom = -1; + for (let y = 0; y < canvas.height; y++) for (let x = 0; x < canvas.width; x++) if (pixels[(y * canvas.width + x) * 4 + 3] > 16) { + left = Math.min(left, x); right = Math.max(right, x + 1); top = Math.min(top, y); bottom = Math.max(bottom, y + 1); + } + inks.set(img.src, { left, right, top, bottom }); + } + const a = inks.get(img.src), b = rect(img), fit = Math.min(b.width / img.naturalWidth, b.height / img.naturalHeight); + const x = b.x + (b.width - img.naturalWidth * fit) / 2, y = b.y + (b.height - img.naturalHeight * fit) / 2; + return { x: x + a.left * fit, y: y + a.top * fit, width: (a.right - a.left) * fit, height: (a.bottom - a.top) * fit }; + }; + const textMeasure = e => { + const box = rect(e), r = document.createRange(); r.selectNodeContents(e); + const ranges = [...r.getClientRects()].filter(r => r.width > 0).map(r => ({ x: (r.x - origin.x) / scale, y: (r.y - origin.y) / scale, width: r.width / scale, height: r.height / scale })); + return { text: e.textContent, box, ranges, clientWidth: e.clientWidth, scrollWidth: e.scrollWidth, ...style(e, typography) }; + }; + const cards = [...s.querySelectorAll('.hm-orgchart-employee')].map(c => { + const box = rect(c), status = c.closest('.hm-orgchart-status-group')?.dataset.workStatus ?? null; + const avatar = rect(c.querySelector('.hm-orgchart-avatar')), mark = c.querySelector('.hm-orgchart-affiliation-mark'), img = mark?.querySelector('img'); + const imageBox = img && rect(img), fit = img?.naturalWidth ? Math.min(imageBox.width / img.naturalWidth, imageBox.height / img.naturalHeight) : 0; + const contentBox = img ? { x: imageBox.x + (imageBox.width - img.naturalWidth * fit) / 2, y: imageBox.y + (imageBox.height - img.naturalHeight * fit) / 2, width: img.naturalWidth * fit, height: img.naturalHeight * fit } : null; + const logo = img ? { box: imageBox, contentBox, local: relative(imageBox, box), ink: logoPixels(img), naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight, src: img.src, ...style(img, ['objectFit', 'filter', 'opacity', 'backgroundColor']) } : null; + const text = [...c.querySelectorAll('.hm-orgchart-person-name,.hm-orgchart-person-role,.hm-orgchart-job-title')].map(textMeasure); + const collisions = [], outside = []; + if (!contained(avatar, box)) outside.push('avatar'); + if (logo && !contained(logo.box, box)) outside.push('logo image box'); + if (logo?.ink && overlap(logo.ink, avatar)) collisions.push('logo/avatar'); + for (const t of text) { + if (t.scrollWidth > t.clientWidth + 1) outside.push(`${t.text}: horizontal overflow`); + for (const r of t.ranges) { + // Text Range includes font ascent/descent, and is conservative for logo + // and avatar tests. Adjacent Range-to-Range intersections are recorded + // separately; they are not treated as painted glyph collisions. + const visible = { x: Math.max(r.x, t.box.x), y: r.y, width: Math.max(0, Math.min(r.x + r.width, t.box.x + t.box.width) - Math.max(r.x, t.box.x)), height: r.height }; + if (!contained(r, box)) outside.push(`${t.text}: text Range outside card`); + if (overlap(visible, avatar)) collisions.push(`${t.text}/avatar`); + if (logo?.ink && overlap(visible, logo.ink)) collisions.push(`${t.text}/logo ink`); + } + } + const fontRangeOverlaps = []; + for (const [i, a] of text.entries()) for (const b of text.slice(i + 1)) for (const ar of a.ranges) for (const br of b.ranges) if (overlap(ar, br)) fontRangeOverlaps.push([a.text, b.text]); + const name = textMeasure(c.querySelector('.hm-orgchart-person-name')), role = textMeasure(c.querySelector('.hm-orgchart-person-role')); + const photo = c.querySelector('.hm-orgchart-avatar img'); + return { key: `${status ?? 'tree'}:${c.dataset.employeeId}`, id: c.dataset.employeeId, status, size: c.dataset.cardAssetSize ?? null, kind: c.dataset.cardKind, box, + name, role, badge: c.querySelector('.hm-orgchart-job-title')?.textContent ?? '', statusLabel: c.querySelector('.hm-orgchart-status-label')?.textContent ?? '', + avatar: { box: avatar, local: relative(avatar, box), photoState: c.dataset.photoState, placeholder: photo.dataset.photoPlaceholder, naturalWidth: photo.naturalWidth }, + nameLocal: relative(name.box, box), roleLocal: relative(role.box, box), text, logo, mark: mark ? { box: rect(mark), ...style(mark, ['backgroundColor', 'border', 'boxShadow']) } : null, + surface: style(c, ['backgroundImage', 'border', 'borderRadius', 'padding', 'columnGap']), collisions, outside, fontRangeOverlaps }; + }); + const headers = [...s.querySelectorAll('.node[data-organization-id]')].map(n => { + const h = n.querySelector('.hm-orgchart-organization-name'), first = n.querySelector('.hm-orgchart-employee'); + return { id: n.dataset.organizationId, depth: Number(n.dataset.organizationDepth), box: rect(h), gap: first ? rect(first).y - rect(h).y - rect(h).height : null, ...style(h, typography) }; + }); + const statusHeaders = [...s.querySelectorAll('.hm-orgchart-status-group')].map(g => { + const h = g.querySelector('.hm-orgchart-summary-title'), first = g.querySelector('.hm-orgchart-employee'); + return { status: g.dataset.workStatus, text: h.textContent, box: rect(h), gap: first ? rect(first).y - rect(h).y - rect(h).height : null, ...style(h, [...typography, 'backgroundImage']) }; + }); + const footer = s.querySelector('.hm-orgchart-affiliation-bar'), table = s.querySelector('.hm-orgchart-workdesk-position-table'); + return { scale, cards, headers, statusHeaders, + footer: { box: rect(footer), text: footer.textContent, logos: [...footer.querySelectorAll('.hm-orgchart-affiliation-mark img')].map(img => ({ id: img.parentElement.dataset.affiliationId, box: rect(img), ...style(img, ['width', 'height']) })) }, + table: { box: rect(table), text: table.textContent, total: table.querySelector('[data-total-count]').textContent }, + document: { clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, clientHeight: document.documentElement.clientHeight, scrollHeight: document.documentElement.scrollHeight }, + policies: { displayMode: s.querySelector('.hm-orgchart-composition').dataset.displayMode, collapseButtons: s.querySelectorAll('.hm-orgchart-toggle').length, connectorPaths: s.querySelectorAll('.hm-orgchart-workdesk-overlay path').length, arrows: s.querySelectorAll('.hm-orgchart-workdesk-overlay [data-arrow]').length, background: getComputedStyle(s.querySelector('.hm-orgchart-composition')).backgroundColor }, + stage: rect(stage) }; + }); +} +async function measurePolicies(page) { + return page.evaluate(() => { + const s = document.querySelector('.hm-orgchart-host').shadowRoot; + const composition = s.querySelector('.hm-orgchart-composition'); + return { displayMode: composition.dataset.displayMode, collapseButtons: s.querySelectorAll('.hm-orgchart-toggle').length, + connectorPaths: s.querySelectorAll('.hm-orgchart-workdesk-overlay path').length, + arrows: s.querySelectorAll('.hm-orgchart-workdesk-overlay [data-arrow]').length, + background: getComputedStyle(composition).backgroundColor }; + }); +} +function verify(m, before, route, viewport) { + const label = `${route}/${viewport.width}×${viewport.height}`; + check(m.cards.length === before.cards.length && m.cards.every(c => before.cards.some(b => b.key === c.key)), `${label}: same employee IDs and rendered count`); + check(m.cards.every(c => c.name.text === '홍 길 동' && !/\s/u.test(c.role.text) && !/\s/u.test(c.badge) && c.name.fontFamily.includes('Noto Sans KR') && c.role.fontFamily.includes('Noto Sans KR')), `${label}: Noto Sans KR, spaced names and compact position/badge text`); + check(m.cards.every(c => { + const b = before.cards.find(b => b.key === c.key); + const preserved = ['fontWeight', 'lineHeight', 'color', 'letterSpacing']; + if (c.status) return c.name.fontSize === '18px' && c.role.fontSize === '15px'; + if (c.size === 'representative') return [...preserved, 'fontSize'].every(p => c.name[p] === b.name[p] && c.role[p] === b.role[p]); + return c.name.fontSize === (c.size === 'leader' ? '24px' : '18px') && c.role.fontSize === (c.size === 'leader' ? '18px' : '15px') && preserved.every(p => c.name[p] === b.name[p] && c.role[p] === b.role[p]); + }), `${label}: requested card typography; representative and other approved properties preserved`); + const general = m.headers.find(h => h.id === 'integrated-system-1'); + check(general && m.statusHeaders.every(h => ['fontSize', 'fontWeight', 'lineHeight'].every(p => h[p] === general[p]) && h.backgroundImage === before.statusHeaders.find(b => b.status === h.status).backgroundImage), `${label}: status title equals standard 2Depth typography; gray gradient retained`, m.statusHeaders); + const normal = m.cards.find(c => !c.status && c.size === 'employee' && c.kind === 'employee' && !c.badge); + const common = ['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing']; + check(normal && m.cards.filter(c => c.status).every(c => sameBox(c.avatar.local, normal.avatar.local) && sameBox(c.nameLocal, normal.nameLocal) && sameBox(c.roleLocal, normal.roleLocal) && common.every(p => c.name[p] === normal.name[p] && c.role[p] === normal.role[p]) && c.statusLabel === '' && c.name.color === before.cards.find(b => b.key === c.key).name.color && c.role.color === before.cards.find(b => b.key === c.key).role.color), `${label}: status photo/text geometry and typography equals compact cards; own colors retained; no status line`, m.cards.filter(c => c.status).map(c => ({ key: c.key, avatar: c.avatar.local, name: c.nameLocal, role: c.roleLocal }))); + check(m.cards.filter(c => c.status).every(c => { const b = before.cards.find(b => b.key === c.key); return ['backgroundImage', 'border', 'borderRadius'].every(p => c.surface[p] === b.surface[p]) && c.logo.filter === b.logo.filter && c.logo.opacity === b.logo.opacity; }), `${label}: status card surface and image-only grayscale/opacity retained`); + check(m.cards.every(c => { const [w, h] = c.size === 'representative' ? [146, 74] : c.size === 'leader' ? [146, 68] : [108, 52]; return near(c.box.width, w) && near(c.box.height, h); }), `${label}: card sizes remain 146×74/146×68/108×52; status corrected to requested108×52`); + check([...m.headers, ...m.statusHeaders].every(h => h.gap === null || near(h.gap, 8)), `${label}: every title↔first card gap8px`); + check(m.cards.every(c => c.logo && c.logo.naturalWidth && c.logo.objectFit === 'contain' && c.logo.src === before.cards.find(b => b.key === c.key).logo?.src && ['width', 'height'].every(p => { const old = before.cards.find(b => b.key === c.key).logo; const fitted = old.contentBox ?? { width: old.naturalWidth * Math.min(old.box.width / old.naturalWidth, old.box.height / old.naturalHeight), height: old.naturalHeight * Math.min(old.box.width / old.naturalWidth, old.box.height / old.naturalHeight) }; return near(c.logo.contentBox[p] / fitted[p], 1.5, .005); }) && ['width', 'height'].every(p => near(c.logo.ink[p] / before.cards.find(b => b.key === c.key).logo.ink[p], 1.5, .005))), `${label}: all actual card logo contained images and painted ink scale1.5×; assets/ratio retained`, m.cards.map(c => ({ key: c.key, image: c.logo?.box, fitted: c.logo?.contentBox, ink: c.logo?.ink }))); + check(m.cards.every(c => c.mark.backgroundColor === 'rgba(0, 0, 0, 0)' && c.mark.border.startsWith('0px none') && c.mark.boxShadow === 'none'), `${label}: card logos have no added frame/background/shadow`); + check(m.cards.every(c => !c.collisions.length && !c.outside.length), `${label}: no logo/avatar/visible text overlap or clipping`, m.cards.filter(c => c.collisions.length || c.outside.length).map(c => ({ key: c.key, collisions: c.collisions, outside: c.outside }))); + check(m.cards.every(c => c.avatar.photoState === 'placeholder' && c.avatar.placeholder === 'true' && c.avatar.naturalWidth > 0 && c.badge !== '경영진'), `${label}: common photo placeholder and no executive badge`); + check(m.footer.text === before.footer.text && sameBox(m.footer.box, before.footer.box) && m.footer.logos.length === before.footer.logos.length && m.footer.logos.every((l, i) => sameBox(l.box, before.footer.logos[i].box)), `${label}: footer counts/geometry/logos unchanged`); + check(m.table.text === before.table.text && m.table.total === '41' && sameBox(m.table.box, before.table.box) && m.policies.displayMode === 'readOnly' && m.policies.collapseButtons === 0 && m.policies.connectorPaths === before.policies.connectorPaths && m.policies.arrows === 5 && m.policies.background === 'rgb(245, 248, 251)', `${label}: readOnly/background/connectors/statistics policies retained`, m.policies); + check(m.document.scrollWidth === m.document.clientWidth && m.document.scrollHeight === m.document.clientHeight && m.cards.every(c => c.box.x >= -.5 && c.box.y >= -.5 && c.box.x + c.box.width <= 1920.5 && c.box.y + c.box.height <= 915), `${label}: no page scroll; cards contained in scaled stage`, m.document); +} +const dialogTargets = [['representative', '.hm-orgchart-top-leaders .hm-orgchart-employee'], ['depth1', '.node[data-organization-depth="1"] .hm-orgchart-employee[data-card-kind="leader"]'], ['small', '.hm-orgchart-engine .hm-orgchart-employee[data-card-asset-size="employee"]'], ['dispatched', '.hm-orgchart-status-group[data-work-status="dispatched"] .hm-orgchart-employee'], ['onLeave', '.hm-orgchart-status-group[data-work-status="onLeave"] .hm-orgchart-employee']]; +async function dialogs(page, route, viewport) { + await page.setViewportSize(viewport); await ready(page); + for (const [kind, selector] of dialogTargets) { + const card = page.locator(selector).first(), dialog = page.locator('dialog[open]'), id = await card.getAttribute('data-employee-id'); + await card.click(); await dialog.waitFor({ state: 'visible' }); + const value = await dialog.evaluate(d => ({ fields: Object.fromEntries([...d.querySelectorAll('dt')].map(t => [t.textContent, t.nextElementSibling.textContent])), scaled: !!d.closest('.hm-orgchart-stage'), transform: getComputedStyle(d).transform, width: d.getBoundingClientRect().width })); + const employee = fixture.employees.find(e => e.id === id); + check(value.fields['이름'] === '홍 길 동' && value.fields['직위'] === employee.position && (!employee.jobTitle || value.fields['직책'] === employee.jobTitle) && !value.scaled && value.transform === 'none' && near(value.width, Math.min(420, viewport.width - 32)), `${route}/${viewport.width}: ${kind} popup retains data/unscaled size`, value); + await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab'); + check(await dialog.evaluate(d => d.contains(d.getRootNode().activeElement)), `${route}/${viewport.width}: ${kind} popup focus trap`); + await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' }); + check(await card.evaluate(e => e.getRootNode().activeElement === e), `${route}/${viewport.width}: ${kind} ESC focus return`); + } +} +async function links(page, route, origin) { + const id = fixture.organizations.find(o => o.parentId === fixture.organizations.find(o => o.parentId === null).id).id; + const configured = { [id]: `${origin}/__p32_link` }; + await page.evaluate(async ({ configured, transport }) => { + if (transport === 'react') window.OrgChartP1React.setOptions({ organizationLinks: configured }); + else { const s = window.OrgChartP1; s.chart.destroy(); s.chart = s.api.createOrgChart({ container: document.getElementById('chart-host'), dataSource: new s.api.JsonOrgChartDataSource(s.data), stylesheetUrls: s.stylesheetUrls, displayMode: 'readOnly', organizationLinks: configured }); await s.chart.mount(); } + }, { configured, transport: route }); + await ready(page); + const link = page.locator('.hm-orgchart-organization-link'); check(await link.count() === 1, `${route}: only configured 1Depth title becomes link`); + await link.focus(); await Promise.all([page.waitForURL(configured[id]), page.keyboard.press('Enter')]); + check(await page.locator('#proof').textContent() === 'Group link reached', `${route}: real1Depth keyboard link navigation`); +} +let browser; +try { + const htmlOrigin = await serve(path.join(root, 'examples/org-chart/dist')); + const reactOrigin = await serve(path.join(root, 'examples/org-chart-react/dist')); + browser = await launchChromium({ headless: true }); + const context = await browser.newContext(); + const routes = [{ name: 'html', url: `${htmlOrigin}/p1.html?mode=readOnly`, origin: htmlOrigin }, { name: 'file-classic', url: pathToFileURL(path.join(root, 'examples/org-chart/dist/p1-classic.html')).href + '?mode=readOnly' }, { name: 'react', url: `${reactOrigin}/p1.html?mode=readOnly`, origin: reactOrigin }, { name: 'storybook', url: `${storyOrigin}/iframe.html?id=orgchart--full-composition&viewMode=story` }]; + for (const route of routes) { + const page = await context.newPage(); + page.on('pageerror', e => errors.push({ route: route.name, type: 'runtime', message: e.message })); + page.on('console', e => { if (e.type() === 'error') errors.push({ route: route.name, type: 'console', message: e.text() }); }); + try { + await page.goto(route.url); await ready(page); + if (policiesOnly) { + await page.setViewportSize(viewports[0]); await ready(page); + const policies = await measurePolicies(page); + measurements.push({ route: route.name, ...viewports[0], policies }); + check(policies.displayMode === 'readOnly' && policies.collapseButtons === 0, `${route.name}: readOnly has no actual hm-orgchart-toggle controls`, policies); + check(policies.background === 'rgb(245, 248, 251)', `${route.name}: actual composition background is #F5F8FB`, policies.background); + check(policies.connectorPaths === 5 && policies.arrows === 5, `${route.name}: actual overlay retains five connector paths and five collaboration arrows`, policies); + } else { + for (const viewport of viewports) { + await page.setViewportSize(viewport); await ready(page); + const m = await measure(page); measurements.push({ route: route.name, ...viewport, ...m }); + if (!baselineMode) verify(m, previous.measurements.find(b => b.route === route.name && b.width === viewport.width), route.name, viewport); + if ([1920, 390].includes(viewport.width)) await page.screenshot({ path: path.join(output, `${baselineMode ? 'baseline-' : ''}${route.name}-${viewport.width}x${viewport.height}.png`) }); + } + if (!baselineMode) { + await dialogs(page, route.name, viewports[0]); await dialogs(page, route.name, viewports.at(-1)); + if (['html', 'react'].includes(route.name)) await links(page, route.name, route.origin); + } + } + } catch (error) { check(false, `${route.name}: focused browser capture completed`, { message: error.message, stack: error.stack }); } + await page.close(); + } + if (policiesOnly) check(!errors.length, 'no new console/runtime errors during policy-only capture', errors); + else if (!baselineMode) { + for (const viewport of viewports) { + const variants = measurements.filter(m => m.width === viewport.width), base = variants.find(m => m.route === 'html'); + check(variants.length === 4 && variants.every(m => m.cards.length === base.cards.length && m.cards.every((c, i) => sameBox(c.box, base.cards[i].box) && sameBox(c.nameLocal, base.cards[i].nameLocal) && sameBox(c.roleLocal, base.cards[i].roleLocal) && c.name.fontSize === base.cards[i].name.fontSize && c.role.fontSize === base.cards[i].role.fontSize && sameBox(c.logo.box, base.cards[i].logo.box))), `HTML/classic/React/Storybook same card typography/geometry/${viewport.width}`); + } + const index = await fetch(`${storyOrigin}/index.json`).then(r => r.json()); + const stories = Object.values(index.entries).filter(e => e.title === 'OrgChart' && e.type === 'story' && !e.tags?.includes('internal')); + check(stories.length === 3, 'existing Storybook three user menus retained', stories.map(e => e.name)); + check(!errors.length, 'no new console/runtime errors', errors); + } +} finally { + await browser?.close(); for (const server of servers) 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 report = { scope: policiesOnly ? 'P3.2 measurement-selector correction only; retain browser.json and do not repeat passed typography/logo/layout/dialog/link checks' : 'P3.2 changes only; prior P3.1 486 checks retained without rerun', baselineMode, policiesOnly, fixtureSha256: createHash('sha256').update(fixtureBytes).digest('hex'), summary, results, measurements, errors, + collisionMethod: 'Natural PNG alpha bounds >16 transformed through object-fit contain, versus visible text Range and avatar rectangles. Adjacent font Range envelopes are recorded only; painted font glyph interpenetration is not certified by this test.' }; + await fs.writeFile(path.join(output, policiesOnly ? 'policy-browser.json' : baselineMode ? 'baseline-browser.json' : 'browser.json'), JSON.stringify(report, null, 2)); + console.log(JSON.stringify({ baselineMode, policiesOnly, captures: measurements.length, summary, errors, failures: results.filter(r => r.status === 'FAIL').map(r => r.name) }, null, 2)); + if (summary.fail || (baselineMode && measurements.length !== routesLength())) process.exitCode = 1; +} +function routesLength() { return 4 * viewports.length; } diff --git a/examples/org-chart/verify-p3.mjs b/examples/org-chart/verify-p3.mjs new file mode 100644 index 0000000..17ecfef --- /dev/null +++ b/examples/org-chart/verify-p3.mjs @@ -0,0 +1,436 @@ +import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +import http from 'node:http'; +import path from 'node:path'; +import { readFile, mkdir, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +// P3 impact checks only. P2's 484 checks and every earlier report are retained. +// Runtime layout, accessibility interaction, and data aggregation are measured; +// font ink and curved connector routing still require saved-image review. +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-p3'); +const fixturePath = path.join(root, 'examples/org-chart/data-p1.json'); +const fixtureBytes = await readFile(fixturePath); +const fixtureHash = createHash('sha256').update(fixtureBytes).digest('hex'); +const fixture = JSON.parse(fixtureBytes.toString('utf8')); +await loadPlaywright(); +const viewports = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 1280, height: 800 }, { width: 768, height: 1024 }, { width: 390, height: 844 }]; +const results = [], measurements = [], interactions = [], errors = [], screenshots = [], servers = []; +const check = (condition, name, detail) => results.push({ name, status: condition ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); +const near = (actual, expected, tolerance = 0.25) => Number.isFinite(actual) && Math.abs(actual - expected) <= tolerance; +const mime = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }; +await mkdir(output, { recursive: true }); +async function serve(directory) { + const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url, 'http://127.0.0.1'); + if (url.pathname === '/__p3_link_proof') { + response.setHeader('Content-Type', 'text/html'); + return response.end('P3 verifier navigation proof

Configured organization link reached

'); + } + const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname)); + const relative = path.relative(directory, file); + if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end(); + response.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); + response.end(await readFile(file)); + } catch { response.writeHead(404).end(); } + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + servers.push(server); + return `http://127.0.0.1:${server.address().port}`; +} +function captureErrors(page, route) { + page.on('pageerror', error => errors.push({ route, type: 'pageerror', message: error.message })); + page.on('console', message => { if (message.type() === 'error') errors.push({ route, type: 'console', message: message.text() }); }); +} +async function ready(page, mode = 'readOnly') { + await page.waitForFunction(expectedMode => { + const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; + return shadow?.querySelector(`.hm-orgchart-composition[data-display-mode="${expectedMode}"] [data-card-asset]`) + && !shadow.querySelector('.hm-orgchart-state')?.textContent + && [...shadow.querySelectorAll('link[rel=stylesheet]')].every(link => !!link.sheet); + }, mode, { timeout: 20000 }); + await page.evaluate(async () => { + await document.fonts.ready; + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + await Promise.all([...shadow.querySelectorAll('img')].map(image => image.decode().catch(() => {}))); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }); +} +async function measure(page) { + return page.evaluate(() => { + const host = document.querySelector('.hm-orgchart-host'), shadow = host.shadowRoot; + // Chrome can serialize a zero letter-spacing as "normal". Compare against + // an independently authored zero-spacing probe, not a guessed string. + const zeroSpacingProbe = document.createElement('span'); + zeroSpacingProbe.textContent = '홍 길 동'; + zeroSpacingProbe.style.letterSpacing = '0'; + shadow.append(zeroSpacingProbe); + const zeroSpacingSerialization = { authored: zeroSpacingProbe.style.letterSpacing, computed: getComputedStyle(zeroSpacingProbe).letterSpacing }; + zeroSpacingProbe.remove(); + const composition = shadow.querySelector('.hm-orgchart-composition'), stage = shadow.querySelector('.hm-orgchart-stage'); + const origin = stage.getBoundingClientRect(), scale = origin.width / stage.offsetWidth; + const rect = element => { + if (!element) return null; + const box = element.getBoundingClientRect(); + return { x: (box.left - origin.left) / scale, y: (box.top - origin.top) / scale, width: box.width / scale, height: box.height / scale }; + }; + const screenRect = element => { + const box = element.getBoundingClientRect(); + return { x: box.x, y: box.y, width: box.width, height: box.height, right: box.right, bottom: box.bottom }; + }; + const dimensions = element => ({ clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, overflowX: getComputedStyle(element).overflowX, overflowY: getComputedStyle(element).overflowY }); + const text = element => element?.textContent?.replace(/\s+/g, ' ').trim() ?? ''; + const overlaps = (a, b) => Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > 0.75 && Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > 0.75; + const contained = (inner, outer, tolerance = 0.75) => inner.x >= outer.x - tolerance && inner.y >= outer.y - tolerance && inner.x + inner.width <= outer.x + outer.width + tolerance && inner.y + inner.height <= outer.y + outer.height + tolerance; + const cssValues = (element, properties) => element ? Object.fromEntries(properties.map(name => [name, getComputedStyle(element)[name]])) : null; + const cards = [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => { + const node = card.closest('.node'), box = rect(card), name = card.querySelector('.hm-orgchart-person-name'); + const role = card.querySelector('.hm-orgchart-person-role'), badge = card.querySelector('.hm-orgchart-job-title'); + const row = card.querySelector('.hm-orgchart-position-row'), avatar = card.querySelector('.hm-orgchart-avatar'); + const textBoxes = [...card.querySelectorAll('.hm-orgchart-person-role, .hm-orgchart-person-name, .hm-orgchart-job-title')].map(element => { + const range = document.createRange(); range.selectNodeContents(element); + const envelopes = [...range.getClientRects()].map(rangeBox => ({ x: (rangeBox.x - origin.left) / scale, y: (rangeBox.y - origin.top) / scale, width: rangeBox.width / scale, height: rangeBox.height / scale })); + return { className: element.className, box: rect(element), clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, envelopes }; + }); + const issues = textBoxes.flatMap(item => [ + ...(item.scrollWidth > item.clientWidth + 1 ? [`${item.className}: horizontal text overflow`] : []), + ...(!contained(item.box, box) ? [`${item.className}: element outside card`] : []), + ...(item.envelopes.some(envelope => envelope.x < box.x - 0.75 || envelope.x + envelope.width > box.x + box.width + 0.75) ? [`${item.className}: text outside horizontal card bounds`] : []), + ]); + const logo = card.querySelector('.hm-orgchart-affiliation-mark'); + const avatarBox = rect(avatar), logoBox = rect(logo); + for (const item of textBoxes) { + if (overlaps(item.box, avatarBox)) issues.push(`${item.className}: avatar element overlap`); + if (logoBox && item.envelopes.some(envelope => overlaps(envelope, logoBox))) issues.push(`${item.className}: text envelope/logo overlap`); + } + return { + id: card.dataset.employeeId, kind: card.dataset.cardKind, asset: card.dataset.cardAsset ?? null, size: card.dataset.cardAssetSize ?? null, + organizationId: node?.dataset.organizationId ?? null, depth: node ? Number(node.dataset.organizationDepth ?? node.dataset.depth) : null, + statusGroup: card.closest('.hm-orgchart-status-group')?.dataset.workStatus ?? null, box, + name: text(name), role: text(role), badge: text(badge), nameAccessible: name.getAttribute('aria-label'), + photoState: card.dataset.photoState, avatarText: text(avatar), avatarBox, avatarStyle: cssValues(avatar, ['borderRadius', 'backgroundColor']), + photoStyle: cssValues(avatar.querySelector('img'), ['objectFit']), + rowGap: row ? rect(name).y - rect(row).y - rect(row).height : null, + badgeGap: row && badge ? rect(badge).x - rect(role).x - rect(role).width : null, + badgeInRow: !badge || !!badge.closest('.hm-orgchart-position-row'), + badgeStyle: cssValues(badge, ['color', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderRadius', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'paddingLeft', 'paddingRight']), + nameStyle: cssValues(name, ['fontSize', 'fontWeight', 'lineHeight']), + roleStyle: cssValues(role, ['fontSize', 'fontWeight', 'lineHeight']), + statusLabels: card.querySelectorAll('.hm-orgchart-status-label').length, + keyboardRole: card.getAttribute('role'), tabIndex: card.tabIndex, issues, textBoxes, + }; + }); + const headers = [...shadow.querySelectorAll('.node[data-organization-id] .hm-orgchart-organization-name')].map(header => { + const node = header.closest('.node'), box = rect(header), first = node.querySelector('.hm-orgchart-employee'); + return { id: node.dataset.organizationId, depth: Number(node.dataset.organizationDepth ?? node.dataset.depth), text: text(header), box, + gap: first ? rect(first).y - box.y - box.height : null, + style: cssValues(header, ['backgroundImage', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'textAlign', 'padding', 'borderRadius', 'boxShadow']), link: header.querySelector('a')?.getAttribute('href') ?? null }; + }); + const pseudoRoutes = [...shadow.querySelectorAll('.hm-orgchart-engine .hierarchy, .hm-orgchart-engine .node')].flatMap(element => ['::before', '::after'].map(pseudo => ({ className: element.className, depth: element.dataset.depth ?? null, pseudo, content: getComputedStyle(element, pseudo).content, display: getComputedStyle(element, pseudo).display }))); + const overlay = shadow.querySelector('.hm-orgchart-workdesk-overlay'); + const connectors = [...shadow.querySelectorAll('.hm-orgchart-workdesk-overlay path[data-connector]')].map(element => ({ id: element.dataset.connector, path: element.getAttribute('d') })); + const arrows = [...shadow.querySelectorAll('.hm-orgchart-workdesk-overlay image[data-arrow]')].map(element => ({ id: element.dataset.arrow, href: (element.getAttribute('href') ?? '').slice(0, 40), box: rect(element) })); + const table = shadow.querySelector('.hm-orgchart-workdesk-position-table'); + const tableData = table ? { + title: text(shadow.querySelector('.hm-orgchart-organization-totals-title')), + header: [...table.querySelectorAll('thead th')].map(text), + rows: [...table.querySelectorAll('tbody tr')].map(row => ({ id: row.dataset.organizationId, scope: row.dataset.countScope, values: [...row.children].map(text) })), + total: [...table.querySelectorAll('tfoot tr > *')].map(text), box: rect(table), + } : null; + const hub = shadow.querySelector('.hm-orgchart-hub'), hubIcon = hub?.querySelector('.hm-orgchart-hub-icon'), hubLabel = hub?.querySelector('.hm-orgchart-hub-label'); + const bar = shadow.querySelector('.hm-orgchart-affiliation-bar'); + const barItems = [...shadow.querySelectorAll('.hm-orgchart-affiliation-bar-item')].map(item => ({ id: item.dataset.affiliationId ?? 'total', count: Number(item.dataset.employeeCount ?? item.dataset.totalCount), box: rect(item), logoLoaded: [...item.querySelectorAll('img')].every(image => image.complete && image.naturalWidth > 0) })); + const modules = [...shadow.querySelectorAll('.hm-orgchart-organization-totals, .hm-orgchart-affiliation-bar, .hm-orgchart-hub')].map(element => ({ id: element.className, box: rect(element) })); + const physical = [...cards.map(card => ({ id: `card:${card.id}:${card.statusGroup ?? 'tree'}`, box: card.box })), ...headers.map(header => ({ id: `header:${header.id}`, box: header.box }))]; + const collisions = []; + for (const [index, a] of physical.entries()) for (const b of physical.slice(index + 1)) if (overlaps(a.box, b.box)) collisions.push({ a: a.id, b: b.id }); + const moduleCollisions = modules.flatMap(module => physical.filter(item => overlaps(module.box, item.box)).map(item => ({ module: module.id, item: item.id }))); + for (const [index, a] of modules.entries()) for (const b of modules.slice(index + 1)) if (overlaps(a.box, b.box)) moduleCollisions.push({ a: a.id, b: b.id }); + const outsideStage = [...physical, ...modules].filter(item => !contained(item.box, { x: 0, y: 0, width: stage.offsetWidth, height: stage.offsetHeight })).map(item => item.id); + return { + scale, recordedScale: composition.dataset.scale, baseWidth: stage.offsetWidth, baseHeight: stage.offsetHeight, zeroSpacingSerialization, + screen: { stage: screenRect(stage), composition: screenRect(composition), host: screenRect(host) }, + background: { body: getComputedStyle(document.body).backgroundColor, composition: getComputedStyle(composition).backgroundColor, stage: getComputedStyle(stage).backgroundColor }, + document: dimensions(document.documentElement), composition: dimensions(composition), stage: dimensions(stage), host: dimensions(host), + cards, headers, pseudoRoutes, connectors, arrows, overlayViewBox: overlay?.getAttribute('viewBox'), + decorations: [...shadow.querySelectorAll('img[data-workdesk-asset]')].map(image => ({ id: image.dataset.workdeskAsset, loaded: image.complete && image.naturalWidth > 0 })), + table: tableData, bar: bar ? { box: rect(bar), titleCount: bar.querySelectorAll('h1,h2,h3').length, items: barItems } : null, + hub: { box: rect(hub), iconBox: rect(hubIcon), labelBox: rect(hubLabel), paths: hubIcon?.querySelectorAll('path').length ?? 0, + gap: hubLabel && hubIcon ? rect(hubLabel).x - rect(hubIcon).x - rect(hubIcon).width : null, + style: cssValues(hubLabel, ['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing']), text: text(hubLabel) }, + status: [...shadow.querySelectorAll('.hm-orgchart-status-group')].map(group => ({ status: group.dataset.workStatus, heading: text(group.querySelector('h3')) })), + collisions, moduleCollisions, outsideStage, debugCount: shadow.querySelectorAll('.hm-orgchart-toggle, .hm-orgchart-kind, .hm-orgchart-domain-id, .hm-orgchart-node-count, .hm-orgchart-vacancy, .hm-orgchart-description, .hm-orgchart-policy').length, + }; + }); +} + +const preferredPositions = ['경영진', '수석', '책임', '선임', '연구']; +const positions = [...preferredPositions.filter(position => fixture.employees.some(employee => employee.position === position)), ...new Set(fixture.employees.map(employee => employee.position).filter(position => !preferredPositions.includes(position)))]; +const organizationById = new Map(fixture.organizations.map(organization => [organization.id, organization])); +const depthOf = id => { let depth = 0; while (organizationById.get(id)?.parentId) { id = organizationById.get(id).parentId; depth++; } return depth; }; +const majorRows = fixture.organizations.filter(organization => depthOf(organization.id) <= 1); +const descendantsOf = id => { const ids = [id]; for (let index = 0; index < ids.length; index++) ids.push(...fixture.organizations.filter(organization => organization.parentId === ids[index]).map(organization => organization.id)); return ids; }; +const expectedRows = majorRows.map(organization => { + const scope = organization.parentId === null && fixture.organizations.some(item => item.parentId === organization.id) ? 'direct' : 'inclusive'; + const ids = scope === 'direct' ? [organization.id] : descendantsOf(organization.id); + const employees = fixture.employees.filter(employee => ids.includes(employee.organizationId)); + return { id: organization.id, scope, values: [organization.name, ...positions.map(position => String(employees.filter(employee => employee.position === position).length)), String(employees.length)] }; +}).filter(row => row.scope !== 'direct' || Number(row.values.at(-1)) > 0); +const expectedTotal = ['계', ...positions.map(position => String(fixture.employees.filter(employee => employee.position === position).length)), String(fixture.employees.length)]; +function verify(value, label, viewport) { + const assetCards = value.cards.filter(card => card.asset); + const first = assetCards.filter(card => card.depth === 1 && card.kind === 'leader'); + const smaller = assetCards.filter(card => card.depth >= 2 || card.kind === 'employee'); + check(value.background.body === 'rgb(245, 248, 251)' && value.background.composition === 'rgb(245, 248, 251)' && value.background.stage === 'rgb(245, 248, 251)', `${label}: Full Composition background #F5F8FB`, value.background); + check(value.pseudoRoutes.every(pseudo => ['none', 'normal'].includes(pseudo.content) || pseudo.display === 'none'), `${label}: every engine hierarchy/node pseudo connector is hidden`, value.pseudoRoutes.filter(pseudo => !['none', 'normal'].includes(pseudo.content) && pseudo.display !== 'none')); + check(value.connectors.length === 5 && value.connectors.every(connector => connector.path) && value.arrows.length === 5 && value.overlayViewBox === '0 0 1920 914.5', `${label}: existing five Workdesk curve paths and five collaboration arrow assets retained`, { connectors: value.connectors, arrows: value.arrows.map(arrow => arrow.id) }); + check(value.decorations.length > 0 && value.decorations.every(image => image.loaded), `${label}: original Workdesk decoration assets remain loaded`, value.decorations); + check(value.cards.every(card => card.name === '홍 길 동' && card.nameAccessible === '홍길동' && card.role === fixture.employees.find(employee => employee.id === card.id)?.position && card.badge === (fixture.employees.find(employee => employee.id === card.id)?.jobTitle ?? '')), `${label}: only names are spaced; positions and badges preserve raw strings`, value.cards.map(({ id, name, role, badge }) => ({ id, name, role, badge }))); + check(assetCards.every(card => near(card.rowGap, 0) && card.badgeInRow && (card.badgeGap === null || near(card.badgeGap, 4))), `${label}: role and badge share one row, gap4; position/name gap0`, assetCards.map(({ id, rowGap, badgeGap, badgeInRow }) => ({ id, rowGap, badgeGap, badgeInRow }))); + check(value.zeroSpacingSerialization.authored === '0px' && assetCards.filter(card => card.badge).every(card => { + const small = card.size === 'employee', style = card.badgeStyle; + return style.borderTopWidth === '1px' && style.borderRadius === '4px' && style.letterSpacing === value.zeroSpacingSerialization.computed && style.fontSize === (small ? '8.5px' : '9px') && style.fontWeight === (small ? '600' : '700') && style.lineHeight === (small ? '8.5px' : '9px') && style.paddingLeft === (small ? '2px' : '3px') && style.paddingRight === (small ? '2px' : '3px'); + }), `${label}: badge source border/radius/font restored`, { zeroSpacingSerialization: value.zeroSpacingSerialization, badges: assetCards.filter(card => card.badge).map(({ id, badgeStyle }) => ({ id, badgeStyle })) }); + const tagColors = { 'business-planning': 'rgb(37, 78, 126)', 'talent-growth': 'rgb(14, 103, 200)', 'integrated-system': 'rgb(110, 71, 160)', 'design-planning': 'rgb(6, 154, 149)', 'solution-sales': 'rgb(81, 139, 53)' }; + check(assetCards.filter(card => card.badge).every(card => card.badgeStyle.color === (tagColors[card.asset] ?? 'rgb(14, 84, 146)')), `${label}: organization-specific badge source colors`, assetCards.filter(card => card.badge).map(({ id, asset, badgeStyle }) => ({ id, asset, color: badgeStyle.color }))); + check(assetCards.filter(card => card.badge).every(card => { + const color = tagColors[card.asset] ?? 'rgb(14, 84, 146)', alpha = card.asset in tagColors ? ['0.1', '0.26'] : ['0.08', '0.2']; + const rgba = value => color.replace('rgb(', 'rgba(').replace(')', `, ${value})`); + return card.badgeStyle.backgroundColor === rgba(alpha[0]) && card.badgeStyle.borderTopColor === rgba(alpha[1]); + }), `${label}: badge source background and border tint`, assetCards.filter(card => card.badge).map(({ id, badgeStyle }) => ({ id, background: badgeStyle.backgroundColor, border: badgeStyle.borderTopColor }))); + check(value.cards.every(card => card.avatarStyle.borderRadius === '3.5px' && card.avatarText === '' && card.photoState === 'empty'), `${label}: no-photo state is empty rectangular slot with no circular avatar or copied photo`, value.cards.map(({ id, avatarStyle, avatarText, photoState }) => ({ id, avatarStyle, avatarText, photoState }))); + check(assetCards.some(card => card.kind === 'topLeader' && near(card.box.width, 146) && near(card.box.height, 74)) && first.length === 6 && first.every(card => near(card.box.width, 146) && near(card.box.height, 68)) && smaller.length > 0 && smaller.every(card => near(card.box.width, 108) && near(card.box.height, 52)), `${label}: approved representative/Depth1/deeper card sizes preserved`); + check(value.headers.filter(header => header.gap !== null).every(header => near(header.gap, 8)), `${label}: every organization header-to-first-card gap8`, value.headers.map(({ id, depth, gap }) => ({ id, depth, gap }))); + check(value.headers.filter(header => header.depth >= 2).every(header => near(header.box.width, 114) && near(header.box.height, 30.4) && header.style.backgroundImage.startsWith('linear-gradient') && header.style.borderRadius === '7.1px' && header.style.textAlign === 'center'), `${label}: deeper group source header geometry and gradient`, value.headers.filter(header => header.depth >= 2)); + const headerGradients = { 'business-management': ['rgb(25, 77, 130)', 'rgb(29, 57, 92)'], 'business-planning': ['rgb(33, 100, 168)', 'rgb(37, 73, 117)'], 'talent-growth': ['rgb(15, 106, 206)', 'rgb(4, 74, 148)'], 'integrated-system': ['rgb(140, 103, 182)', 'rgb(93, 52, 148)'], 'design-planning': ['rgb(9, 167, 162)', 'rgb(0, 132, 127)'], 'solution-sales': ['rgb(90, 150, 60)', 'rgb(63, 118, 41)'] }; + check(value.headers.filter(header => header.depth >= 2).every(header => { + let id = header.id; while (depthOf(id) > 1) id = organizationById.get(id).parentId; + const expected = headerGradients[id], hr = id === 'talent-growth'; + return expected?.every(color => header.style.backgroundImage.includes(color)) && header.style.fontSize === (hr ? '18px' : '20px') && header.style.fontWeight === (hr ? '600' : '700') && header.style.lineHeight === (hr ? '16.9px' : '19.7px'); + }), `${label}: deeper headers inherit original parent color and source typography`, value.headers.filter(header => header.depth >= 2).map(({ id, style }) => ({ id, style }))); + check(value.hub.paths === 3 && near(value.hub.iconBox?.width, 20) && near(value.hub.iconBox?.height, 20) && near(value.hub.gap, 8) && value.hub.style.fontSize === '21px' && value.hub.style.fontWeight === '750' && value.hub.style.lineHeight === '24px', `${label}: original hub icon and source text metrics`, value.hub); + check(value.hub.iconBox && value.hub.labelBox && [value.hub.iconBox, value.hub.labelBox].every(box => box.x >= value.hub.box.x - 0.5 && box.y >= value.hub.box.y - 0.5 && box.x + box.width <= value.hub.box.x + value.hub.box.width + 0.5 && box.y + box.height <= value.hub.box.y + value.hub.box.height + 0.5), `${label}: hub icon and label remain inside source pill`, value.hub); + check(value.status.every(group => group.heading === (group.status === 'dispatched' ? '파견' : '휴직')) && value.cards.filter(card => card.statusGroup).every(card => card.statusLabels === 0), `${label}: compact status headers retained and below-name labels removed`, value.status); + check(value.table?.title === '직위별 인원 현황 (팀별)' && JSON.stringify(value.table?.header) === JSON.stringify(['팀원', ...positions, '소계']) && JSON.stringify(value.table?.rows) === JSON.stringify(expectedRows) && JSON.stringify(value.table?.total) === JSON.stringify(expectedTotal) && fixture.employees.length === 41, `${label}: position matrix cross-counts original 41 employees`, { actual: value.table, expectedRows, expectedTotal }); + check(value.bar && value.bar.titleCount === 0 && near(value.bar.box.height, 55.4) && value.bar.items.every(item => near(item.box.y, value.bar.items[0].box.y)) && value.bar.items.every(item => item.logoLoaded) && value.bar.items.every(item => item.count === (item.id === 'total' ? fixture.employees.length : fixture.employees.filter(employee => employee.affiliationId === item.id).length)), `${label}: untitled one-row affiliation bar height55.4 and original counts/logos`, value.bar); + check(value.cards.every(card => card.issues.length === 0), `${label}: card text has no horizontal overflow or element collisions`, value.cards.filter(card => card.issues.length).map(({ id, issues, textBoxes }) => ({ id, issues, textBoxes }))); + check(value.collisions.length === 0 && value.moduleCollisions.length === 0 && value.outsideStage.length === 0, `${label}: cards/headers/modules do not overlap or fall outside stage`, { collisions: value.collisions, modules: value.moduleCollisions, outside: value.outsideStage }); + check(value.document.scrollWidth <= value.document.clientWidth && value.document.scrollHeight <= value.document.clientHeight && value.screen.stage.right <= viewport.width + 1 && value.screen.stage.bottom <= viewport.height + 1 && value.screen.composition.width <= viewport.width && value.screen.composition.height >= value.screen.stage.height - 1, `${label}: proportionally fitted whole composition; no document scrolling or viewport clipping`, { scale: value.scale, baseWidth: value.baseWidth, baseHeight: value.baseHeight, document: value.document, screen: value.screen }); + check(value.debugCount === 0 && value.cards.every(card => card.keyboardRole === 'button' && card.tabIndex === 0), `${label}: view-only cards retain keyboard information lookup without edit/collapse UI`, { debugCount: value.debugCount }); +} + +async function dialogs(page, label, viewport) { + await page.setViewportSize(viewport); await ready(page); + const targets = await page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + const selectors = [ + ['representative', '.hm-orgchart-top-leaders .hm-orgchart-employee'], + ['depth1', '.node[data-organization-depth="1"] .hm-orgchart-employee[data-card-kind="leader"]'], + ['depth2', '.node[data-organization-depth="2"] .hm-orgchart-employee[data-card-kind="leader"]'], + ['employee', '.hm-orgchart-engine .hm-orgchart-employee[data-card-kind="employee"]'], + ['dispatched', '.hm-orgchart-status-group[data-work-status="dispatched"] .hm-orgchart-employee'], + ['onLeave', '.hm-orgchart-status-group[data-work-status="onLeave"] .hm-orgchart-employee'], + ]; + return selectors.map(([kind, selector]) => ({ kind, selector, id: shadow.querySelector(selector)?.dataset.employeeId })); + }); + for (const target of targets) { + if (!target.id) { check(false, `${label}: ${target.kind} dialog target exists`); continue; } + const trigger = page.locator(target.selector).first(), dialog = page.locator('dialog.hm-orgchart-employee-dialog[open]'); + await trigger.click(); await dialog.waitFor({ state: 'visible' }); + const value = await dialog.evaluate(element => { + const rect = element.getBoundingClientRect(), shadow = element.getRootNode(); + const fields = [...element.querySelectorAll('dt')].map(term => [term.textContent, term.nextElementSibling.textContent]); + return { employeeId: element.dataset.employeeId, fields, rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height, right: rect.right, bottom: rect.bottom }, insideStage: !!element.closest('.hm-orgchart-stage'), transform: getComputedStyle(element).transform, ariaModal: element.getAttribute('aria-modal'), focus: shadow.activeElement?.className, editCount: element.querySelectorAll('input,select,textarea,[contenteditable]').length }; + }); + interactions.push({ route: label, viewport, target: target.kind, ...value }); + const employee = fixture.employees.find(employee => employee.id === target.id); + const fields = Object.fromEntries(value.fields); + check(value.employeeId === employee.id && fields['이름'] === '홍 길 동' && fields['직위'] === employee.position && fields['소속'] === organizationById.get(employee.organizationId)?.name && (!employee.jobTitle || fields['직책'] === employee.jobTitle) && value.fields.every(([field]) => ['이름', '직위', '소속', '직책', '관계사', '근무 상태'].includes(field)) && value.editCount === 0, `${label}/${viewport.width}: ${target.kind} dialog shows only real fixture fields`, value); + check(!value.insideStage && value.transform === 'none' && value.ariaModal === 'true' && value.rect.width >= Math.min(300, viewport.width - 32) && value.rect.right <= viewport.width + 1 && value.rect.bottom <= viewport.height + 1 && value.rect.x >= 0 && value.rect.y >= 0, `${label}/${viewport.width}: ${target.kind} modal remains unscaled and unclipped`, value.rect); + await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab'); + const trapped = await dialog.evaluate(element => element.contains(element.getRootNode().activeElement)); + check(trapped && value.focus === 'hm-orgchart-employee-dialog-close', `${label}/${viewport.width}: ${target.kind} modal receives and traps keyboard focus`); + await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' }); + check(await trigger.evaluate(element => element.getRootNode().activeElement === element), `${label}/${viewport.width}: ${target.kind} ESC restores triggering card focus`); + await trigger.press('Enter'); await dialog.waitFor({ state: 'visible' }); + await dialog.locator('.hm-orgchart-employee-dialog-close').click(); await dialog.waitFor({ state: 'hidden' }); + check(await trigger.evaluate(element => element.getRootNode().activeElement === element), `${label}/${viewport.width}: ${target.kind} Enter opens; Close restores card focus`); + } +} + +async function configure(page, transport, options) { + if (transport === 'react') { + await page.evaluate(({ links, callback }) => { + if (typeof window.OrgChartP1React?.setOptions !== 'function') throw new Error('React P1 example does not expose public-prop setOptions seam'); + window.OrgChartP1React.setOptions({ organizationLinks: links, onEmployeeClick: callback ? (employee, context) => { + window.__p3CallbackEvents.push({ employeeId: employee.id, organizationId: context.organization?.id, affiliationId: context.affiliation?.id, triggerId: context.trigger.dataset.employeeId, eventType: context.originalEvent.type }); + } : undefined }); + }, options); + } else { + await page.evaluate(async ({ links, callback }) => { + const seam = window.OrgChartP1; seam.chart.destroy(); + seam.chart = seam.api.createOrgChart({ container: document.getElementById('chart-host'), dataSource: new seam.api.JsonOrgChartDataSource(seam.data), stylesheetUrls: seam.stylesheetUrls, displayMode: 'readOnly', organizationLinks: links, + onEmployeeClick: callback ? (employee, context) => { + window.__p3CallbackEvents.push({ employeeId: employee.id, organizationId: context.organization?.id, affiliationId: context.affiliation?.id, triggerId: context.trigger.dataset.employeeId, eventType: context.originalEvent.type }); + } : undefined }); + await seam.chart.mount(); + }, options); + } + await ready(page); +} +async function linksAndCallbacks(page, transport, routeUrl, targetUrl) { + const major = fixture.organizations.find(organization => depthOf(organization.id) === 1); + await page.evaluate(() => { window.__p3CallbackEvents = []; }); + await configure(page, transport, { links: { [major.id]: targetUrl }, callback: true }); + const info = await page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + return { links: [...shadow.querySelectorAll('.hm-orgchart-organization-link')].map(link => ({ href: link.href, role: link.tagName, tabIndex: link.tabIndex, id: link.closest('.node').dataset.organizationId })), ordinaryTitles: [...shadow.querySelectorAll('.node[data-organization-depth="1"] .hm-orgchart-organization-name')].filter(header => !header.querySelector('a')).length }; + }); + check(info.links.length === 1 && info.links[0].id === major.id && info.links[0].href === targetUrl && info.links[0].role === 'A' && info.links[0].tabIndex === 0 && info.ordinaryTitles === 5, `${transport}: only explicitly mapped 1Depth title is a native link`, info); + const card = page.locator('.hm-orgchart-top-leaders .hm-orgchart-employee'); + await card.click(); + await page.waitForFunction(() => window.__p3CallbackEvents.length === 1); + const first = await page.evaluate(() => ({ callbacks: window.__p3CallbackEvents, openDialogs: document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('dialog[open]').length })); + check(first.callbacks.length === 1 && first.openDialogs === 0 && first.callbacks[0].triggerId === first.callbacks[0].employeeId && first.callbacks[0].organizationId && first.callbacks[0].affiliationId, `${transport}: custom callback fires once with fixture/context and replaces default dialog`, first); + await card.press('Enter'); await card.press('Space'); + const keyboard = await page.evaluate(() => window.__p3CallbackEvents); + check(keyboard.length === 3 && keyboard.slice(1).every(event => event.eventType === 'keydown'), `${transport}: Enter/Space each activate callback once`, keyboard); + await page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.blur()); + let reached = false; + for (let step = 0; step < 60 && !reached; step++) { + await page.keyboard.press('Tab'); + reached = await page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.classList.contains('hm-orgchart-organization-link') ?? false); + } + const linkFocus = await page.locator('.hm-orgchart-organization-link').evaluate(element => ({ matches: element.matches(':focus-visible'), outlineStyle: getComputedStyle(element).outlineStyle, outlineWidth: getComputedStyle(element).outlineWidth })); + check(reached && linkFocus.matches && linkFocus.outlineStyle === 'solid' && parseFloat(linkFocus.outlineWidth) >= 2, `${transport}: real Tab reaches title link with visible focus`, linkFocus); + check((await page.evaluate(() => window.__p3CallbackEvents)).length === 3, `${transport}: title focus does not activate employee callback`); + await Promise.all([page.waitForURL(targetUrl), page.keyboard.press('Enter')]); + check(await page.locator('#proof').textContent() === 'Configured organization link reached', `${transport}: native keyboard link reaches configured verifier URL`); + await page.goto(routeUrl); await ready(page); + await page.evaluate(() => { window.__p3CallbackEvents = []; }); + await configure(page, transport, { links: {}, callback: false }); + check(await page.locator('.hm-orgchart-organization-link').count() === 0, `${transport}: removing configured links restores ordinary titles`); + await page.locator('.hm-orgchart-top-leaders .hm-orgchart-employee').click(); + await page.locator('dialog[open]').waitFor({ state: 'visible' }); + check(true, `${transport}: clearing custom callback restores default Core dialog`); + await page.keyboard.press('Escape'); +} +function sameGeometry(a, b) { + if (!a || !b || a.cards.length !== b.cards.length || a.headers.length !== b.headers.length) return false; + const boxesEqual = (first, second) => ['x', 'y', 'width', 'height'].every(key => near(first[key], second[key], 0.4)); + return a.cards.every((card, index) => ['id', 'kind', 'size', 'asset', 'name', 'role', 'badge'].every(key => card[key] === b.cards[index][key]) && boxesEqual(card.box, b.cards[index].box)) + && a.headers.every((header, index) => header.id === b.headers[index].id && boxesEqual(header.box, b.headers[index].box)) + && JSON.stringify(a.connectors) === JSON.stringify(b.connectors) + && JSON.stringify(a.table && { ...a.table, box: undefined }) === JSON.stringify(b.table && { ...b.table, box: undefined }) + && a.table && b.table && boxesEqual(a.table.box, b.table.box); +} + +let browser; +try { + const [htmlOrigin, reactOrigin] = await Promise.all([serve(path.join(root, 'examples/org-chart/dist')), serve(path.join(root, 'examples/org-chart-react/dist'))]); + // The user identified the live 6041 Storybook; never silently verify a stale + // storybook-static build from P2 instead of that requested running server. + const storybookOrigin = process.env.ORGCHART_STORYBOOK_URL?.replace(/\/$/, '') ?? 'http://127.0.0.1:6041'; + browser = await launchChromium({ headless: true }); + const context = await browser.newContext({ deviceScaleFactor: 1, reducedMotion: 'reduce' }); + const routes = [ + { label: 'html', transport: 'html', url: `${htmlOrigin}/p1.html?mode=readOnly` }, + { label: 'file-classic', transport: 'html', url: `${pathToFileURL(path.join(root, 'examples/org-chart/dist/p1-classic.html')).href}?mode=readOnly` }, + { label: 'react', transport: 'react', url: `${reactOrigin}/p1.html?mode=readOnly` }, + { label: 'storybook', transport: null, url: `${storybookOrigin}/iframe.html?id=orgchart--full-composition&viewMode=story` }, + ]; + for (const route of routes) { + const page = await context.newPage(); captureErrors(page, route.label); + try { + await page.setViewportSize(viewports[0]); await page.goto(route.url); await ready(page); + for (const viewport of viewports) { + await page.setViewportSize(viewport); await ready(page); + const value = await measure(page); measurements.push({ route: route.label, ...viewport, ...value }); + verify(value, `${route.label}/${viewport.width}×${viewport.height}`, viewport); + if (viewport.width === 1920 || viewport.width === 390) { + const filename = `${route.label}-${viewport.width}x${viewport.height}.png`; + await page.screenshot({ path: path.join(output, filename), fullPage: true }); screenshots.push(filename); + } + } + await dialogs(page, route.label, viewports[0]); + await dialogs(page, route.label, viewports.at(-1)); + if (route.label === 'html' || route.label === 'react') { + await page.setViewportSize(viewports[0]); + await linksAndCallbacks(page, route.transport, route.url, `${route.label === 'html' ? htmlOrigin : reactOrigin}/__p3_link_proof`); + } + } catch (error) { check(false, `${route.label}: P3 browser verification completed`, { message: error.message, stack: error.stack }); } + finally { await page.close(); } + } + for (const viewport of viewports) { + const values = ['html', 'file-classic', 'react', 'storybook'].map(route => measurements.find(item => item.route === route && item.width === viewport.width)); + check(values.every(Boolean) && values.slice(1).every(value => sameGeometry(values[0], value)), `HTML/classic/React/Story shared Core geometry and matrix/${viewport.width}`, { routes: values.map(value => value?.route ?? 'missing'), basePixelTolerance: 0.4 }); + } + const fitPage = await context.newPage(); captureErrors(fitPage, 'host-fit'); + await fitPage.setViewportSize({ width: 1920, height: 650 }); await fitPage.goto(routes[0].url); await ready(fitPage); + const shortValue = await measure(fitPage); measurements.push({ route: 'short-height', width: 1920, height: 650, ...shortValue }); + check(shortValue.document.scrollHeight <= shortValue.document.clientHeight && shortValue.screen.stage.bottom <= 650 && shortValue.scale <= (650 - shortValue.screen.composition.y) / shortValue.baseHeight + 0.002, 'short1920×650: fit respects actual visible height without assuming host header', shortValue.screen); + await fitPage.setViewportSize(viewports[0]); + await fitPage.evaluate(() => { document.getElementById('chart-host').style.height = '400px'; }); await ready(fitPage); + const fixed = await measure(fitPage); measurements.push({ route: 'fixed400-host', width: 1920, height: 1080, ...fixed }); + check(fixed.screen.stage.height <= 400.5 && fixed.screen.composition.height <= 400.5 && fixed.scale <= 400 / fixed.baseHeight + 0.002 && fixed.screen.stage.bottom <= fixed.screen.host.y + 400.5, 'explicit400px container: fit uses host height without clipping whole composition', { scale: fixed.scale, screen: fixed.screen }); + await fitPage.evaluate(() => { + const host = document.getElementById('chart-host'); host.style.height = ''; host.style.maxHeight = '400px'; + }); await ready(fitPage); + const maxHeight = await measure(fitPage); measurements.push({ route: 'max-height400-host', width: 1920, height: 1080, ...maxHeight }); + check(maxHeight.screen.stage.height <= 400.5 && maxHeight.screen.composition.height <= 400.5 && maxHeight.scale <= 400 / maxHeight.baseHeight + 0.002, 'max-height400px container: fit respects ancestor max-height', { scale: maxHeight.scale, screen: maxHeight.screen }); + await fitPage.evaluate(() => { + const host = document.getElementById('chart-host'); host.style.maxHeight = ''; host.style.height = '400px'; host.style.boxSizing = 'border-box'; host.style.padding = '20px'; + const chartRoot = host.querySelector('.hm-orgchart-host'); const inner = document.createElement('div'); inner.style.padding = '12px'; inner.style.boxSizing = 'border-box'; + host.append(inner); inner.append(chartRoot); + }); await ready(fitPage); + const padded = await measure(fitPage); measurements.push({ route: 'nested-padding400-host', width: 1920, height: 1080, ...padded }); + check(padded.screen.stage.height <= 336.5 && padded.scale <= 336 / padded.baseHeight + 0.002 && padded.screen.stage.bottom <= padded.screen.host.y + 336.5, '400px border-box ancestor: fit subtracts20px outer and12px inner vertical padding', { scale: padded.scale, screen: padded.screen }); + await fitPage.close(); + + const index = await (await fetch(`${storybookOrigin}/index.json`)).json(); + const entries = Object.values(index.entries ?? {}); + const visible = entries.filter(entry => entry.title === 'OrgChart' && entry.type === 'story' && !entry.tags?.includes('internal')); + check(JSON.stringify(visible.map(entry => entry.name).sort()) === JSON.stringify(['Logo · Personnel Summary', 'Department Colors', 'Full Composition'].sort()), 'Storybook index: exactly three user-facing OrgChart pages', visible.map(({ id, name, tags }) => ({ id, name, tags }))); + check(['orgchart--logo', 'orgchart--position-summary', 'orgchart--affiliation-summary'].every(id => entries.some(entry => entry.id === id && entry.tags?.includes('internal'))), 'Storybook index: independent component verification Stories retained'); + const combined = await context.newPage(); captureErrors(combined, 'combined-summary'); + await combined.goto(`${storybookOrigin}/iframe.html?id=orgchart--logo-personnel-summary&viewMode=story`); + await combined.waitForFunction(() => [...document.querySelectorAll('.hm-orgchart-component-host')].filter(host => host.shadowRoot?.querySelector('[data-component]')).length >= 3); + const combinedContent = await combined.evaluate(() => [...document.querySelectorAll('.hm-orgchart-component-host')].map(host => [...host.shadowRoot.querySelectorAll('[data-component]')].map(element => element.dataset.component)).flat()); + check(['Logo', 'PositionSummary', 'AffiliationSummary'].every(name => combinedContent.includes(name)), 'Storybook combined page renders all three existing Core components', combinedContent); + await combined.close(); + const manager = await context.newPage(); captureErrors(manager, 'storybook-manager'); + await manager.goto(`${storybookOrigin}/?path=/story/orgchart--full-composition`); + await manager.locator('a[href*="orgchart--full-composition"]').first().waitFor({ state: 'visible', timeout: 20000 }); + const sidebar = await manager.evaluate(() => [...document.querySelectorAll('a[href*="/story/orgchart--"]')].filter(element => element.getBoundingClientRect().width && element.getBoundingClientRect().height).map(element => ({ name: element.textContent.trim(), href: element.getAttribute('href') }))); + check(sidebar.length === 3 && ['logo-personnel-summary', 'department-colors', 'full-composition'].every(id => sidebar.some(item => item.href.includes(`orgchart--${id}`))), 'Storybook manager: actual visible OrgChart sidebar contains only three pages', sidebar); + await manager.close(); + + const interactivePage = await context.newPage(); captureErrors(interactivePage, 'interactive'); + await interactivePage.goto(`${htmlOrigin}/p1.html`); await ready(interactivePage, 'interactive'); + const preserved = await interactivePage.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + return { toggles: shadow.querySelectorAll('.hm-orgchart-toggle').length, visibleRoutes: [...shadow.querySelectorAll('.hm-orgchart-engine .hierarchy')].some(element => !['none', 'normal'].includes(getComputedStyle(element, '::before').content) && getComputedStyle(element, '::before').display !== 'none'), statusLabels: shadow.querySelector('.hm-orgchart-status-group .hm-orgchart-status-label')?.textContent }; + }); + check(preserved.toggles > 0 && preserved.visibleRoutes && ['파견', '휴직'].includes(preserved.statusLabels), 'interactive view preserves existing collapse, hierarchy routes and status labels', preserved); + await interactivePage.close(); + check(createHash('sha256').update(await readFile(fixturePath)).digest('hex') === fixtureHash, 'P3 verifier preserves original 41-person fixture bytes', { sha256: fixtureHash }); + check(errors.length === 0, 'P3 routes have no browser runtime/console errors', errors); +} catch (error) { check(false, 'P3 verifier setup/completion', { message: error.message, stack: error.stack }); } +finally { + await writeFile(path.join(output, 'browser.json'), JSON.stringify({ + browser: browser?.version(), scope: 'P3 impacts only; P2/old suites and expectations are unchanged', fixtureSha256: fixtureHash, + transportPolicy: 'HTTP ESM/React/Storybook and true file:// classic without file-access Chrome flags', + testLinkPolicy: '/__p3_link_proof URLs exist only in the verifier-owned local HTTP servers; no actual product URL/data was invented', + connectorRoutingInk: 'NOT VERIFIED automatically: inspect representative screenshots for curved route/ink intersections', + textInkCollision: 'NOT VERIFIED automatically: confirmed gap0 may overlap font envelopes; measured element boxes and horizontal text limits are checked', + originalPhotoPolicy: 'Workdesk has rectangular photo slots and no explicit missing-photo asset. Fictitious cards use transparent empty slots, no real employee photo copied.', + results, errors, measurements, interactions, screenshots, + }, null, 2)); + await browser?.close(); + for (const server of servers) await new Promise(resolve => server.close(resolve)); +} +const failures = results.filter(result => result.status === 'FAIL'); +console.log(JSON.stringify({ output, checks: results.length, failureCount: failures.length, failures: failures.slice(0, 30).map(result => result.name), ...(failures.length > 30 ? { additionalFailures: failures.length - 30 } : {}) }, null, 2)); +if (failures.length) process.exitCode = 1; diff --git a/examples/org-chart/verify-p5-1a.mjs b/examples/org-chart/verify-p5-1a.mjs new file mode 100644 index 0000000..c6b22a6 --- /dev/null +++ b/examples/org-chart/verify-p5-1a.mjs @@ -0,0 +1,372 @@ +import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +import http from 'node:http'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +// P5-1A only: preserve previous suites and the baseline-files.json snapshot. +// 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-1a'); +const storyOrigin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6052'; +const { chromium } = await loadPlaywright(); +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 === '/__p51a') 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 launchChromium({ 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}/__p51a`); + 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-1a/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`); +} +function assertExpanded(value, name) { + const representative = value.cards.filter(card => card.kind === 'topLeader'); + check(value.layoutStatus === 'preview' && value.phaseText.includes('P5-1A') && value.phaseText.includes('후속'), `${name}: deferred work is explicitly marked preview`); + check(representative.length === 1 && near(representative[0].box.width, 146) && near(representative[0].box.height, 74) + && representative[0].name === '홍 길 동' && representative[0].role === '대표이사' && representative[0].badgeCount === 0, `${name}: representative 146x74/name/position/zero badges`); + check(value.cards.filter(card => card.kind === 'leader').length === 3 + && value.cards.filter(card => card.kind === 'leader').every(card => near(card.box.width, 146) && near(card.box.height, 68)), `${name}: reused 1Depth leader 146x68 cards`); + check(value.cards.filter(card => card.kind === 'employee').length === 6 + && value.cards.filter(card => card.kind === 'employee').every(card => near(card.box.width, 108) && near(card.box.height, 52)), `${name}: reused small 108x52 employee cards`); + check(value.cards.every(card => card.font.includes('Noto Sans KR') && card.placeholder === 'placeholder' && card.asset), `${name}: approved assets/common photo fallback/Noto font reused`); + check(value.nodes.length === 3 && value.nodes.every(node => node.depth === 1) && value.controls === 0, `${name}: only initial 1Depth groups without collapse/zoom/pan controls`); + check(value.cards.length === 10 && value.collisions.length === 0 && value.hostCount === 1 && value.compositionCount === 1, `${name}: no duplicate or overlapping cards`, value.collisions); +} +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 }); } +} + +try { + let baselineFixed, defaultFixed, autoVanilla; + let baselinePopup; + await scenario('Fixed baseline', async () => { + const page = await newPage('baseline-fixed'); + try { + const state = await mount(page, { baseline: true, fixture: fixedFixture, options: { displayMode: 'readOnly' } }); + check(state.status === 'ready', 'baseline Fixed: existing public Core ready'); + baselineFixed = await measure(page); + measurements.push({ name: 'baseline-fixed', ...baselineFixed }); + baselinePopup = await popup(page, 'baseline Fixed', 'mock-leader-00'); + await page.screenshot({ path: path.join(output, 'baseline-fixed-1440.png'), fullPage: true }); + } finally { await page.close(); } + }); + for (const explicit of [false, true]) await scenario(`Fixed ${explicit ? 'explicit' : 'default'}`, async () => { + const name = explicit ? 'explicit-fixed' : 'default-fixed'; + const page = await newPage(name); + try { + const state = await mount(page, { fixture: fixedFixture, options: { displayMode: 'readOnly', ...(explicit ? { layoutMode: 'fixed', companyId: 'unknown-is-ignored-for-fixed' } : {}) } }); + const value = await measure(page); + measurements.push({ name, ...value }); + check(state.status === 'ready' && state.summary.raw.total === fixedFixture.employees.length, `${name}: ready with preserved employee count`); + if (baselineFixed) compareFixed(baselineFixed, value, name); + else check(false, `${name}: baseline required for regression comparison`); + if (!explicit) { + defaultFixed = value; + check(await popup(page, name, 'mock-leader-00') === baselinePopup, `${name}: existing popup field content unchanged`); + await page.screenshot({ path: path.join(output, 'current-fixed-1440.png'), fullPage: true }); + } + } finally { await page.close(); } + }); + await scenario('Auto public Core', async () => { + const page = await newPage('vanilla-auto'); + try { + const state = await mount(page, { options: { layoutMode: 'auto', displayMode: 'readOnly' } }); + check(state.status === 'ready' && state.summary.raw.total === expandedFixture.employees.length, 'Auto: JsonOrgChartDataSource and full data summary retained'); + autoVanilla = await measure(page); + measurements.push({ name: 'vanilla-auto', ...autoVanilla }); + assertExpanded(autoVanilla, 'Vanilla Auto'); + const popupText = await popup(page, 'Auto representative', 'expanded-representative'); + check(popupText.includes('대표이사') && popupText.includes('(주)한맥기술'), 'Auto popup: original employee/affiliation information'); + await popup(page, 'Auto employee', 'expanded-employee-01'); + const presetData = await page.evaluate(ids => ids.map(id => window.hmModule.getCompanyColorPreset(id)), expectedCompanyIds); + check(presets.every((preset, index) => presetData[index]?.companyId === preset.id && presetData[index].primary === preset.primary && JSON.stringify(presetData[index].secondary) === JSON.stringify(preset.secondary)), 'Public lookup: all 7 P5-0 primary/secondary presets unchanged', presetData); + const invalid = await page.evaluate(() => { + const base = { dataSource: new window.hmModule.JsonOrgChartDataSource(window.fixture), stylesheetUrls: { core: 'core.css', engine: 'engine.css' } }; + return [{ layoutMode: 'invalid' }, { layoutMode: 'auto', companyId: 'unknown-company' }].map(options => { + try { window.hmModule.createOrgChart({ ...base, ...options }); return false; } + catch (error) { return error instanceof TypeError; } + }); + }); + check(invalid.every(Boolean), 'New options: invalid layout mode and unknown Auto preset rejected', invalid); + const lifecycle = await page.evaluate(async () => { + const changed = structuredClone(window.fixture); + changed.metadata.dataVersion = 'p5-1a-update'; + const removed = 'talent-growth'; + changed.organizations = changed.organizations.filter(organization => organization.id !== removed); + changed.employees = changed.employees.filter(employee => employee.organizationId !== removed); + changed.presentation.organizationOrder = changed.presentation.organizationOrder.filter(id => id !== removed); + changed.organizations.find(organization => organization.id === 'business-management').name = '갱신된 사업관리'; + await window.handle.update(changed); + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + const updated = { state: window.handle.getState().status, version: window.handle.getState().dataVersion, + compositions: shadow.querySelectorAll('.hm-orgchart-expanded').length, nodes: shadow.querySelectorAll('.node[data-organization-id]').length, + cards: shadow.querySelectorAll('.hm-orgchart-employee').length, stale: !!shadow.querySelector('[data-organization-id="talent-growth"]'), text: shadow.textContent.includes('갱신된 사업관리') }; + await window.handle.reload(); + const reloaded = { state: window.handle.getState().status, version: window.handle.getState().dataVersion, + compositions: shadow.querySelectorAll('.hm-orgchart-expanded').length, nodes: shadow.querySelectorAll('.node[data-organization-id]').length, + cards: shadow.querySelectorAll('.hm-orgchart-employee').length, stale: shadow.textContent.includes('갱신된 사업관리') }; + return { updated, reloaded }; + }); + check(lifecycle.updated.state === 'ready' && lifecycle.updated.version === 'p5-1a-update' && lifecycle.updated.compositions === 1 + && lifecycle.updated.nodes === 2 && lifecycle.updated.cards === 7 && !lifecycle.updated.stale && lifecycle.updated.text, 'Auto update: replaces groups/cards without duplicates or stale DOM', lifecycle.updated); + check(lifecycle.reloaded.state === 'ready' && lifecycle.reloaded.version === expandedFixture.metadata.dataVersion && lifecycle.reloaded.compositions === 1 + && lifecycle.reloaded.nodes === 3 && lifecycle.reloaded.cards === 10 && !lifecycle.reloaded.stale, 'Auto reload: restores JSON source without stale DOM', lifecycle.reloaded); + await ready(page, 'auto'); + await page.screenshot({ path: path.join(output, 'vanilla-auto-1440.png'), fullPage: true }); + const deep = await page.evaluate(async () => { + const deep = structuredClone(window.fixture); + deep.organizations.push({ id: 'deferred-depth-2', parentId: 'business-management', name: '후속 2Depth', type: 'team', leaderId: null, displayOrder: 0 }, + { id: 'deferred-depth-3', parentId: 'deferred-depth-2', name: '후속 3Depth', type: 'cell', leaderId: null, displayOrder: 0 }); + for (const [index, id] of ['deferred-depth-2', 'deferred-depth-3'].entries()) deep.employees.push({ id: `deferred-employee-${index}`, organizationId: id, affiliationId: 'hanmac', name: '가상 직원', position: '연구', jobTitle: null, profileImage: null, workStatus: 'active' }); + deep.presentation.organizationOrder.push('deferred-depth-2', 'deferred-depth-3'); + deep.metadata.dataVersion = 'p5-1b-deferred-fixture'; + const validated = window.hmModule.validateOrgChartDataset(deep); + const normalized = validated.valid ? window.hmModule.normalizeOrgChartDataset(validated.data) : null; + await window.handle.update(deep); + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + return { valid: validated.valid, state: window.handle.getState().status, total: window.handle.getState().summary?.raw.total, + deepest: normalized?.roots[0].children[0].children[0].children[0].organization.id, + rendered: [...shadow.querySelectorAll('.node[data-organization-id]')].map(node => node.dataset.organizationId), + deferredCards: shadow.querySelectorAll('[data-employee-id^="deferred-employee-"]').length, + retained: deep.organizations.length === window.fixture.organizations.length + 2 }; + }); + check(deep.valid && deep.state === 'ready' && deep.deepest === 'deferred-depth-3' && deep.total === expandedFixture.employees.length + 2 + && deep.rendered.length === 3 && !deep.rendered.some(id => id.startsWith('deferred-')) && deep.deferredCards === 0 && deep.retained, + 'P5-1B boundary: 3Depth data retained and validated; descendants intentionally unplaced', deep); + const destroyed = await page.evaluate(() => { + window.handle.destroy(); window.handle.destroy(); + return { status: window.handle.getState().status, children: document.getElementById('chart').children.length }; + }); + check(destroyed.status === 'destroyed' && destroyed.children === 0, 'Auto destroy: terminal/idempotent cleanup', destroyed); + } finally { await page.close(); } + }); + await scenario('Auto default displayMode', async () => { + const page = await newPage('auto-default-display'); + try { + await mount(page, { options: { layoutMode: 'auto' } }); + const initial = await measure(page); + check(initial.controls === 0 && initial.nodes.length === 3 && initial.layoutStatus === 'preview', 'Auto without displayMode: initial preview, no collapse controls'); + const transforms = await page.evaluate(() => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-engine')].map(element => element.style.transform)); + const viewport = page.locator('.hm-orgchart-viewport'); + await viewport.hover(); await page.mouse.wheel(0, -100); + const bounds = await viewport.boundingBox(); + await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + 10); await page.mouse.down(); + await page.mouse.move(bounds.x + bounds.width / 2 + 40, bounds.y + 10, { steps: 4 }); await page.mouse.up(); + const after = await page.evaluate(() => [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('.hm-orgchart-engine')].map(element => element.style.transform)); + check(JSON.stringify(transforms) === JSON.stringify(after), 'Auto: wheel/drag do not activate engine zoom or pan'); + } finally { await page.close(); } + }); + await scenario('Actual Storybook', async () => { + const page = await newPage('storybook-auto'); + try { + const response = await page.request.get(`${storyOrigin}/index.json`); + const index = await response.json(); + const visible = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title === 'OrgChart' && !entry.tags?.includes('internal')).map(entry => entry.id).sort(); + const expected = ['orgchart--logo-personnel-summary', 'orgchart--department-colors', 'orgchart--full-composition', 'orgchart--expanded-org-chart'].sort(); + check(response.ok() && JSON.stringify(visible) === JSON.stringify(expected), 'Storybook: existing 3 public menus plus Expanded OrgChart only', visible); + await page.goto(`${storyOrigin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); + await ready(page, 'auto'); + const vanillaStyles = autoVanilla?.cards.map(card => ({ id: card.id, kind: card.kind, width: card.box.width, height: card.box.height, asset: card.asset, font: card.font })); + const reactValue = await measure(page); + check(Boolean(vanillaStyles) && reactValue.cards.length === vanillaStyles.length && reactValue.cards.every((card, index) => { + const vanilla = vanillaStyles[index]; + return card.id === vanilla.id && card.kind === vanilla.kind && near(card.box.width, vanilla.width) && near(card.box.height, vanilla.height) && card.asset === vanilla.asset && card.font === vanilla.font; + }), 'React Story / Vanilla: same Core fixture/cards/assets/font'); + for (const preset of presets) { + const select = page.getByRole('combobox', { name: '회사별 컬러 테마' }); + await select.click(); + await page.getByRole('option', { name: preset.name, exact: true }).click(); + await page.waitForFunction(companyId => { + const nodes = [...document.querySelector('.hm-orgchart-host')?.shadowRoot.querySelectorAll('.node[data-organization-id]') ?? []]; + return nodes.length === 3 && nodes.every(node => node.dataset.companyId === companyId); + }, preset.id); + await ready(page, 'auto'); + const value = await measure(page); + measurements.push({ name: `storybook-company-${preset.id}`, nodes: value.nodes }); + check(value.nodes.every(node => node.companyId === preset.id && node.primary === preset.primary && node.backgroundColor === rgb(preset.primary) + && JSON.stringify(node.secondary) === JSON.stringify(preset.secondary)), `HM Select: ${preset.name} primary and all secondary colors connected`, value.nodes); + } + await page.getByRole('combobox', { name: '회사별 컬러 테마' }).click(); + await page.getByRole('option', { name: '한맥기술', exact: true }).click(); + await page.waitForFunction(() => document.querySelector('.hm-orgchart-host')?.shadowRoot.querySelector('.node[data-company-id="hanmac"]')); + for (const width of [390, 768, 1280, 1440]) { + await page.setViewportSize({ width, height: width < 1000 ? 1024 : 900 }); + await ready(page, 'auto'); + const value = await measure(page); + measurements.push({ name: `storybook-${width}`, ...value }); + assertExpanded(value, `Storybook ${width}`); + check(value.document.scrollWidth <= value.document.clientWidth + 1 + && value.viewport && ['auto', 'scroll'].includes(value.viewport.overflowX), `Storybook ${width}: horizontal overflow contained by Core viewport`, { document: value.document, viewport: value.viewport }); + await page.screenshot({ path: path.join(output, `storybook-expanded-${width}.png`), fullPage: true }); + } + await popup(page, 'React Auto employee', 'expanded-employee-01'); + await page.goto(`${storyOrigin}/iframe.html?id=orgchart--full-composition&viewMode=story`); + await ready(page, 'fixed'); + const value = await measure(page); + check(value.cards.length === defaultFixed?.cards.length && value.controls === 0 && value.layoutStatus === null && value.compositionCount === 1, + 'Existing Full Composition Story: fixed read-only route retained'); + } finally { await page.close(); } + }); +} finally { + await browser.close(); + await new Promise(resolve => server.close(resolve)); + check(errors.length === 0, 'Focused browser scenarios: no new console/runtime errors', errors); + const summary = { total: results.length, pass: results.filter(result => result.status === 'PASS').length, fail: results.filter(result => result.status === 'FAIL').length }; + await fs.writeFile(path.join(output, 'browser.json'), JSON.stringify({ scope: 'P5-1A only', browser: chromium.name(), summary, results, measurements, errors, warnings, + notVerified: ['P5-1B recursive automatic layout/recalculation/collision solver', 'Collapse/expand and Zoom/Pan implementation', 'Arbitrary data/themes/browsers and real touch/screen reader speech', 'Entire historical P2/P3 suites and complete Storybook production build'] }, null, 2)); + console.log(JSON.stringify(summary)); + if (summary.fail) process.exitCode = 1; +} diff --git a/examples/org-chart/verify-p5-1b.mjs b/examples/org-chart/verify-p5-1b.mjs new file mode 100644 index 0000000..941b406 --- /dev/null +++ b/examples/org-chart/verify-p5-1b.mjs @@ -0,0 +1,377 @@ +import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +import http from 'node:http'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { fileURLToPath } 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'; +await loadPlaywright(); +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 launchChromium({ 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..747bea5 --- /dev/null +++ b/examples/org-chart/verify-p5-23.mjs @@ -0,0 +1,224 @@ +import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-p5-23'); +await loadPlaywright(); +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 launchChromium({ 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..317bf22 --- /dev/null +++ b/examples/org-chart/verify-p5-4.mjs @@ -0,0 +1,221 @@ +import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +// 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 } from 'node:url'; +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-p5-4'); +await loadPlaywright(); +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 launchChromium({ 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/examples/org-chart/verify-p7.mjs b/examples/org-chart/verify-p7.mjs new file mode 100644 index 0000000..b4c4ee7 --- /dev/null +++ b/examples/org-chart/verify-p7.mjs @@ -0,0 +1,545 @@ +// P7 change-only integration verification. Historical suites are not replayed. +import fs from 'node:fs/promises'; +import { writeFileSync } from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-p7'); +await fs.mkdir(output, { recursive: true }); +const template = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-expanded-large.json'), 'utf8')); +const small = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-expanded-small.json'), 'utf8')); +const fixed = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8')); +const results = [], measurements = [], errors = []; +const only = process.argv.slice(2); +const run = section => (!only.length && !['repair', 'react-repair', 'options-tail', 'story-menu', 'lr-reference', 'lr-residue', 'performance', 'dialog-narrow'].includes(section)) || only.includes(section); +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('
'); + const file = path.resolve(root, pathname.startsWith('/assets/') ? 'test-results/org-chart-p7/react-build/dist' + pathname : '.' + pathname); + const relative = path.relative(root, file); + if (relative.startsWith('..') || path.isAbsolute(relative)) 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}`; +const browser = await launchChromium({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } }); +page.on('pageerror', e => errors.push(e.message)); +page.on('console', m => { if (m.type() === 'error') errors.push({ message: m.text(), location: m.location() }); }); +const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 5; i++) await new Promise(resolve => requestAnimationFrame(resolve)); }); +async function ready(count) { + await page.waitForFunction(count => { + const s = document.querySelector('.hm-orgchart-host')?.shadowRoot; + return s && (count === undefined || s.querySelectorAll('.hm-orgchart-employee').length === count) && [...s.querySelectorAll('link')].every(link => link.sheet) && !s.querySelector('.hm-orgchart-shell[aria-busy="true"]'); + }, count); + await settle(); +} +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-p7/baseline-dist' : '/packages/org-chart/dist'); + window.mod = await import(base + '/index.js'); + window.handle = window.mod.createOrgChart({ container: document.getElementById('chart'), dataSource: new window.mod.JsonOrgChartDataSource(data), stylesheetUrls: { engine: base + '/engine.css', core: base + '/style.css' }, ...options }); + const started = performance.now(); + await window.handle.mount(); + window.mountMs = performance.now() - started; + }, { origin, data, options, baseline }); + await ready(); +} +async function example(scope, employeeCount, companyIds) { + return page.evaluate(({ template, scope, employeeCount, companyIds }) => window.mod.createOrgChartExampleDataset(template, { scope, employeeCount, companyIds }), { template, scope, employeeCount, companyIds }); +} +async function measure() { + return page.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), a = c.closest('.hm-orgchart-organization'), p = a?.querySelector(`path[data-employee-id="${c.dataset.employeeId}"]`); + let x = 0, y = 0, cur = c; + while (a && cur && cur !== a) { x += cur.offsetLeft; y += cur.offsetTop; cur = cur.offsetParent; } + const d = c.dataset.connectionDirection; + const target = d === 'left' ? [x, y + c.offsetHeight / 2] : d === 'right' ? [x + c.offsetWidth, y + c.offsetHeight / 2] : [x + c.offsetWidth / 2, y]; + return { id: c.dataset.employeeId, company: c.dataset.companyId, kind: c.dataset.cardKind, size: [c.offsetWidth, c.offsetHeight], box: box(c), name: c.querySelector('.hm-orgchart-person-name')?.textContent, role: c.querySelector('.hm-orgchart-person-role')?.textContent, asset: c.dataset.cardAsset, direction: d, border: [cs.borderTopColor, cs.borderRightColor, cs.borderBottomColor, cs.borderLeftColor], background: cs.backgroundImage, endpointError: p ? Math.hypot(Number(p.dataset.endX) - target[0], Number(p.dataset.endY) - target[1]) : null }; + }); + 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, depth: Number(n.dataset.depth), company: n.dataset.companyId, box: box(n), article: box(n.querySelector('.hm-orgchart-organization')), collapsed: n.dataset.collapsed })), titles: [...s.querySelectorAll('.hm-orgchart-organization-name')].map(t => ({ text: t.textContent, background: getComputedStyle(t).backgroundColor, color: getComputedStyle(t).color, company: t.closest('[data-company-id]')?.dataset.companyId })), 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], layout: s.querySelector('.hm-orgchart-expanded')?.dataset.autoLayout, paths: [...s.querySelectorAll('.hm-orgchart-organization-connectors path')].map(p => ({ ...p.dataset, d: p.getAttribute('d') })), heap: performance.memory?.usedJSHeapSize, domCount: s.querySelectorAll('*').length }; + }); +} +const overlap = boxes => boxes.some((a, i) => boxes.slice(i + 1).some(b => 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)); +const fit = m => 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; +const endpoint = m => m.cards.every(c => c.endpointError !== null && c.endpointError < 1); +const border = m => m.cards.every(c => { const i = { top: 0, right: 1, left: 3 }[c.direction]; return i === undefined ? c.border.every(b => b === c.border[2]) : c.border[i] !== c.border[2] && c.border.every((b, j) => j === i || b === c.border[2]); }); +async function screenshot(name) { await page.screenshot({ path: path.join(output, name + '.png'), fullPage: true }); } +async function setLayout(options) { await page.evaluate(options => window.handle.setAutoLayout(options), options); await ready(); } +async function select(label, option) { + await page.getByRole('combobox', { name: label, exact: true }).click(); + await page.getByRole('option', { name: option, exact: true }).click(); + await settle(); +} +async function hmPopup(name) { + const first = page.locator('.hm-orgchart-employee').first(); + const id = await first.getAttribute('data-employee-id'); + await first.focus(); await first.press('Enter'); + const d = page.locator('[data-component="HMOrgChartEmployeeDialog"]'); + await d.waitFor(); + const details = await d.evaluate(d => ({ role: d.getAttribute('role'), employee: d.dataset.employeeId, focus: d.contains(document.activeElement), unscaled: !d.closest('.hm-orgchart-forest'), bodyPortal: d.parentElement === document.body, text: d.textContent, transform: getComputedStyle(d).transform })); + check(details.employee === id && details.role === 'dialog' && details.focus && details.unscaled, name + ': actual HM Dialog, exact employee, Portal and focus', details); + check(await page.locator('dialog[open]').count() === 0 && await page.getByRole('dialog').count() === 1, name + ': only one popup'); + await screenshot(name.replaceAll(' ', '-') + '-dialog'); + await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab'); + check(await d.evaluate(d => d.contains(document.activeElement)), name + ': keyboard focus remains in Dialog'); + await page.keyboard.press('Escape'); await d.waitFor({ state: 'hidden' }); await settle(); + check(await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId === id, id), name + ': ESC returns focus to employee'); + await first.press('Space'); await d.waitFor(); + await d.getByRole('button', { name: '닫기', exact: true }).last().click(); await d.waitFor({ state: 'hidden' }); await settle(); + check(await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId === id, id), name + ': close button returns focus'); +} + +try { + if (run('core')) { + // Exact old fixture checks compare only unchanged layout/card geometry, since white titles predate P7. + await mount(small, { layoutMode: 'auto', displayMode: 'readOnly' }, true); const oldTB = await measure(); + await mount(small, { layoutMode: 'auto', displayMode: 'readOnly' }); const newTB = await measure(); + const signature = m => ({ cards: m.cards.map(c => [c.id, c.box, c.size, c.role, c.name]), nodes: m.nodes.map(n => [n.id, n.box, n.article]) }); + check(JSON.stringify(signature(oldTB)) === JSON.stringify(signature(newTB)), 'TB existing fixture geometry remains unchanged'); + for (const count of [20, 100, 300]) { + const data = await example('total', count); + await page.evaluate(data => window.handle.update(data), data); await ready(count); + const m = await measure(); + check(m.cards.length === count && new Set(m.cards.map(c => c.id)).size === count && new Set(m.nodes.map(n => n.company)).size === 7, `Total ${count}: all seven companies and unique employees`); + check(!overlap(m.cards.map(c => c.box)) && !overlap(m.nodes.map(n => n.article)), `Total ${count}: no card or organization overlap`); + check(fit(m), `Total ${count}: data update refreshes Fit bounds`, { forest: m.forest, viewport: m.viewport }); + } + for (const count of [20, 100, 300]) { + const data = await example('per-company', count, ['saman']); + await page.evaluate(data => window.handle.update(data), data); await ready(count); + check((await measure()).cards.every(c => c.company === 'saman'), `Single company ${count}: exact selected company`); + } + const data = await example('per-company', 100, ['hanmac', 'saman']); + await page.evaluate(data => window.handle.update(data), data); await ready(200); + check(new Set((await measure()).cards.map(c => c.company)).size === 2, 'Two companies ×100 render exactly 200'); + const all = await example('total', 300); + await page.evaluate(data => window.handle.update(data), all); await ready(300); + const dims = (await measure()).cards.map(c => c.size); + for (const options of [{ type: 'lr' }, { type: 'tb' }, { type: 'hybrid', hybridStartDepth: 2 }, { type: 'tb' }, { type: 'lr' }, { type: 'hybrid', hybridStartDepth: 3 }, { type: 'hybrid', hybridStartDepth: 4 }]) { + const started = Date.now(); await setLayout(options); const ms = Date.now() - started; const m = await measure(); + check(m.cards.length === 300 && new Set(m.cards.map(c => c.id)).size === 300, `${JSON.stringify(options)}: preserves complete data`); + check(!overlap(m.cards.map(c => c.box)) && !overlap(m.nodes.map(n => n.article)), `${JSON.stringify(options)}: no card or group overlap`); + check(fit(m), `${JSON.stringify(options)}: complete Fit`, { forest: m.forest, viewport: m.viewport }); + check(endpoint(m) && border(m), `${JSON.stringify(options)}: connector endpoints and single accent side agree`); + check(JSON.stringify(m.cards.map(c => c.size)) === JSON.stringify(dims), `${JSON.stringify(options)}: card dimensions preserved`); + if (options.type === 'lr') { + check(m.nodes.every(n => !n.parent || m.nodes.some(p => p.id === n.parent && p.article.x + p.article.width < n.article.x)), 'LR: every child to the right of its parent'); + const parents = new Set(m.nodes.map(n => n.parent).filter(Boolean)); + check([...parents].every(id => { const siblings = m.nodes.filter(n => n.parent === id); return siblings.every((n, i) => i === 0 || n.article.y >= siblings[i - 1].article.y + siblings[i - 1].article.height); }), 'LR: siblings stack vertically'); + check(m.cards.every(c => c.direction === 'left'), 'LR: cards use left incoming accent'); + } else { + check(m.nodes.every(n => !n.parent || m.nodes.some(p => p.id === n.parent && p.article.y + p.article.height < n.article.y)), `${JSON.stringify(options)}: each child remains under its own parent`); + if (options.type === 'hybrid') { + const parents = new Set(m.nodes.filter(n => n.depth + 1 >= options.hybridStartDepth).map(n => n.parent).filter(Boolean)); + check([...parents].every(id => { const siblings = m.nodes.filter(n => n.parent === id); return siblings.every((n, i) => i === 0 || n.article.y >= siblings[i - 1].article.y + siblings[i - 1].article.height); }), `Hybrid Depth ${options.hybridStartDepth}: siblings below own parent form a vertical list`); + } + } + measurements.push({ name: JSON.stringify(options), switchAndSettleMs: ms, value: m }); + if (options.type === 'lr' || options.type === 'hybrid') await screenshot(options.type + '-' + (options.hybridStartDepth ?? '300')); + } + await setLayout({ type: 'tb' }); + const rootId = (await measure()).nodes.find(n => !n.parent).id; + let toggle = page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${rootId}"]`); + await toggle.focus(); await toggle.press('Enter'); await ready(); + const collapsedCount = (await measure()).cards.length; + for (const type of ['lr', 'hybrid', 'tb']) { await setLayout({ type }); check((await measure()).cards.length === collapsedCount, `Collapse state preserved switching to ${type}`); } + toggle = page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${rootId}"]`); await toggle.focus(); await toggle.press('Space'); await ready(300); + check(await toggle.getAttribute('aria-expanded') === 'true', 'Keyboard expands group after mode transitions'); + for (const depth of [1, 2, 3]) { + await setLayout({ type: 'hybrid', initialVisibleDepth: depth }); const m = await measure(); + check(m.nodes.every(n => n.depth + 1 <= depth), `Initial visible Depth ${depth}: no deeper group displayed`); + } + await setLayout({ type: 'lr', initialVisibleDepth: 'all' }); await ready(300); + await page.evaluate(() => window.handle.setSelectedCompanies(['hanmac', 'saman'])); await ready(); + check(new Set((await measure()).nodes.map(n => n.company)).size === 2, 'Company selection independent of LR'); + await setLayout({ type: 'hybrid', hybridStartDepth: 2 }); + check(new Set((await measure()).nodes.map(n => n.company)).size === 2, 'Selected companies preserved changing to Hybrid'); + await page.evaluate(() => window.handle.setSelectedCompanies([])); await ready(0); + check(await page.locator('.hm-orgchart-expanded-empty').count() === 1, 'Empty company selection safe'); + await page.evaluate(() => window.handle.setSelectedCompanies()); await ready(300); + for (const type of ['tb', 'lr', 'hybrid']) { + await setLayout({ type }); const m = await measure(); + check(m.titles.every(t => t.color === 'rgb(255, 255, 255)'), `${type}: all company title text remains white`); + const v = page.locator('.hm-orgchart-viewport'), vb = await v.boundingBox(); + const before = await page.evaluate(() => window.handle.getViewportState()); + await page.mouse.move(vb.x + 10, vb.y + 10); await page.mouse.down(); await page.mouse.move(vb.x + 50, vb.y + 30); await page.mouse.up(); + const after = await page.evaluate(() => window.handle.getViewportState()); + check(after.x !== before.x && after.y !== before.y, `${type}: trusted pointer Pan`); + await page.getByRole('button', { name: '100% 복원', exact: true }).click(); + await page.getByRole('button', { name: '확대', exact: true }).click(); + check((await page.evaluate(() => window.handle.getViewportState())).scale > 1, `${type}: Zoom independent`); + await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); + check(fit(await measure()), `${type}: toolbar Fit`); + } + // Vanilla popup remains outside the scaled forest, closes with ESC and restores card. + const first = page.locator('.hm-orgchart-employee').first(), id = await first.getAttribute('data-employee-id'); + await first.focus(); await first.press('Enter'); await page.locator('dialog[open]').waitFor(); + check(await page.locator('dialog[open]').evaluate(d => !d.closest('.hm-orgchart-forest') && getComputedStyle(d).transform === 'none' && d.contains(d.getRootNode().activeElement)), 'Vanilla default popup focus and unscaled layer'); + await page.keyboard.press('Escape'); await settle(); + check(await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId === id, id), 'Vanilla ESC restores employee focus'); + // Higher scale has its own measurement; P5's 300 benchmark is not used. + const huge = await example('per-company', 300); + await mount(huge, { layoutMode: 'auto', displayMode: 'readOnly', autoLayout: { type: 'lr' } }); await ready(2100); + const hugeLR = await measure(); + check(!overlap(hugeLR.cards.map(c => c.box)) && fit(hugeLR), '2100 LR: full rendering, no card overlap and Fit'); + measurements.push({ name: '2100 LR initial full', mountMs: await page.evaluate(() => window.mountMs), value: hugeLR }); await screenshot('2100-lr'); + const largeStart = Date.now(); await setLayout({ type: 'hybrid', hybridStartDepth: 3 }); await ready(2100); const hugeHybrid = await measure(); + check(!overlap(hugeHybrid.cards.map(c => c.box)) && fit(hugeHybrid), '2100 Hybrid: full rendering, no card overlap and Fit'); + measurements.push({ name: '2100 Hybrid full', switchAndSettleMs: Date.now() - largeStart, value: hugeHybrid }); + await setLayout({ type: 'hybrid', initialVisibleDepth: 2 }); const limited = await measure(); + check(limited.cards.length < 2100 && limited.nodes.every(n => n.depth < 2), '2100 independent visible depth reduces initial rendering'); + measurements.push({ name: '2100 Depth2', value: limited }); + // Minimum Fixed regression from captured pre-P7 public distribution. + await mount(fixed, { displayMode: 'readOnly' }, true); const beforeFixed = await measure(); + await mount(fixed, { displayMode: 'readOnly' }); const afterFixed = await measure(); + const fixedSig = m => m.cards.map(c => [c.id, c.box, c.size, c.name, c.role, c.asset, c.border, c.background]); + check(JSON.stringify(fixedSig(beforeFixed)) === JSON.stringify(fixedSig(afterFixed)), 'Fixed card geometry/SVG/text/gradient/border unchanged'); + check(await page.locator('.hm-orgchart-expanded-toggle').count() === 0, 'Fixed contains no new Auto controls'); + await page.locator('.hm-orgchart-employee').first().press('Enter'); await page.locator('dialog[open]').waitFor(); await page.keyboard.press('Escape'); + check(await page.locator('dialog[open]').count() === 0, 'Fixed original popup retains open/ESC'); await screenshot('fixed-1440'); + } + if (run('repair')) { + await mount(small, { layoutMode: 'auto', displayMode: 'readOnly' }); + for (const count of [20, 100, 300]) { + const data = await example('total', count); + await page.evaluate(data => window.handle.update(data), data); await ready(count); + check(fit(await measure()), `Total ${count}: data update refreshes Fit bounds`); + } + for (const options of [{ type: 'lr' }, { type: 'hybrid', hybridStartDepth: 2 }, { type: 'hybrid', hybridStartDepth: 3 }, { type: 'hybrid', hybridStartDepth: 4 }, { type: 'tb', hybridOrganizationIds: ['hanmac-root'] }]) { + await setLayout(options); const m = await measure(); + check(!overlap(m.cards.map(c => c.box)) && !overlap(m.nodes.map(n => n.article)), `Corrected ${JSON.stringify(options)}: no group/card overlap`); + check(fit(m) && endpoint(m) && border(m), `Corrected ${JSON.stringify(options)}: Fit, card endpoints and accents`); + if (options.type === 'lr') { + check(m.nodes.every(n => !n.parent || m.nodes.some(p => p.id === n.parent && p.article.x + p.article.width < n.article.x)), 'Corrected LR: every child right of its parent'); + const parents = new Set(m.nodes.map(n => n.parent).filter(Boolean)); + check([...parents].every(id => { const siblings = m.nodes.filter(n => n.parent === id); return siblings.every((n, i) => i === 0 || n.article.y >= siblings[i - 1].article.y + siblings[i - 1].article.height); }), 'LR: siblings stack vertically'); + } else { + check(m.nodes.every(n => !n.parent || m.nodes.some(p => p.id === n.parent && p.article.y + p.article.height < n.article.y)), `${JSON.stringify(options)}: each child remains under its own parent`); + const parents = new Set(m.nodes.filter(n => options.type === 'hybrid' ? n.depth + 1 >= options.hybridStartDepth : n.company === 'hanmac').map(n => n.parent).filter(Boolean)); + check([...parents].every(id => { const siblings = m.nodes.filter(n => n.parent === id); return siblings.every((n, i) => i === 0 || n.article.y >= siblings[i - 1].article.y + siblings[i - 1].article.height); }), `Hybrid Depth ${options.hybridStartDepth ?? 'group'}: siblings below own parent form a vertical list`); + } + const pathValid = await page.evaluate(() => { + const s = document.querySelector('.hm-orgchart-host').shadowRoot; + return [...s.querySelectorAll('.hm-orgchart-tree')].every(tree => { + const nodes = [...tree.querySelectorAll('.node[data-organization-id]')]; + const paths = [...tree.querySelectorAll('.hm-orgchart-organization-connectors path')]; + const scale = Number(s.querySelector('.hm-orgchart-viewport').dataset.scale); + return paths.length === nodes.filter(n => n.dataset.parentId).length && paths.every(p => { + const node = nodes.find(n => n.dataset.organizationId === p.dataset.organizationId); + if (!node || node.dataset.parentId !== p.dataset.parentId) return false; + const r = node.querySelector('.hm-orgchart-organization-name').getBoundingClientRect(), t = tree.getBoundingClientRect(); + const nums = p.getAttribute('d').match(/-?\d+(?:\.\d+)?/g).map(Number); + const direction = p.dataset.direction; + const x = direction === 'left' ? nums.at(-1) : nums.at(-2); + const y = direction === 'left' ? nums.at(-2) : nums.at(-1); + return Math.abs(x - ((r.left - t.left) / scale + (direction === 'left' ? 0 : node.querySelector('.hm-orgchart-organization-name').offsetWidth / 2))) < 1 + && Math.abs(y - ((r.top - t.top) / scale + (direction === 'left' ? node.querySelector('.hm-orgchart-organization-name').offsetHeight / 2 : 0))) < 1; + }); + }); + }); + check(pathValid, `Corrected ${JSON.stringify(options)}: actual organization paths follow own parent and heading endpoints`); + measurements.push({ name: 'Corrected ' + JSON.stringify(options), value: m }); + await screenshot('corrected-' + options.type + '-' + (options.hybridStartDepth ?? 'group')); + } + const huge = await example('per-company', 300); + await mount(huge, { layoutMode: 'auto', displayMode: 'readOnly', autoLayout: { type: 'lr' } }); await ready(2100); + const m = await measure(); + check(!overlap(m.cards.map(c => c.box)) && fit(m), 'Corrected 2100 LR: full rendering/no overlap/Fit'); + measurements.push({ name: 'Corrected 2100 LR full', mountMs: await page.evaluate(() => window.mountMs), value: m }); + await screenshot('2100-lr'); + const started = Date.now(); await setLayout({ type: 'hybrid', hybridStartDepth: 3 }); await ready(2100); + const hybrid = await measure(); + check(!overlap(hybrid.cards.map(c => c.box)) && fit(hybrid), 'Corrected 2100 Hybrid: full rendering/no overlap/Fit'); + measurements.push({ name: 'Corrected 2100 Hybrid full', switchAndSettleMs: Date.now() - started, value: hybrid }); + await screenshot('2100-hybrid'); + } + if (run('story')) { + await page.goto((process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6056') + '/iframe.html?id=orgchart--expanded-org-chart&viewMode=story'); + await ready(20); + check(await page.locator('.hm-orgchart-story-companies').first().getByRole('checkbox').count() === 7 && new Set((await measure()).nodes.map(n => n.company)).size === 7, 'Story total20: all seven company controls and rendered trees'); + for (const count of [100, 300]) { + await select('전체 직원 수', `${count}명`); + check(await page.locator('.hm-orgchart-employee').count() !== count, `Story total${count}: draft choice does not render before Apply`); + await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready(count); + check((await measure()).cards.length === count, `Story total${count}: actual selector applies count`); + } + await page.getByRole('button', { name: '선택 해제', exact: true }).click(); + await page.getByRole('checkbox', { name: '한맥기술', exact: true }).check(); + await select('데이터 범위', '회사별'); + for (const count of [20, 100, 300]) { + await select('회사당 직원 수', `${count}명`); + await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready(count); + check((await measure()).cards.every(c => c.company === 'hanmac'), `Story per-company${count}: exact chosen roster`); + } + await page.getByRole('checkbox', { name: '삼안', exact: true }).check(); + await select('회사당 직원 수', '100명'); + await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready(200); + check(new Set((await measure()).cards.map(c => c.company)).size === 2, 'Story two companies100: actual 200'); + for (const [label, type] of [['가로형 LR', 'lr'], ['혼합형 Hybrid', 'hybrid'], ['세로형 TB', 'tb']]) { + await select('조직도 표시 유형', label); await ready(200); + check((await measure()).layout === type, `Story layout ${type}: real screen switched`); + await screenshot('story-' + type + '-200'); + } + check(await page.getByRole('combobox', { name: '혼합형 시작 Depth', exact: true }).isDisabled(), 'Story Hybrid depth limited outside Hybrid'); + await select('조직도 표시 유형', '혼합형 Hybrid'); + for (const depth of [2, 3, 4]) { await select('혼합형 시작 Depth', `Depth ${depth}부터 세로 배치`); await ready(200); check(!overlap((await measure()).cards.map(c => c.box)), `Story HybridDepth${depth}: actual control applied safely`); } + await hmPopup('Story HM'); + // Source/company disappearance closes the external popup safely. + await page.locator('.hm-orgchart-employee').first().press('Enter'); await page.locator('[data-component="HMOrgChartEmployeeDialog"]').waitFor(); + await page.evaluate(() => { + const overlay = document.querySelector('[data-component="HMOrgChartEmployeeDialog"]'); + // Escape to dismiss first; normal UI then commits an empty company view. + overlay.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + }); + await page.keyboard.press('Escape'); + await page.getByRole('button', { name: '선택 해제', exact: true }).click(); + await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready(0); + check(await page.locator('[data-component="HMOrgChartEmployeeDialog"]').count() === 0 && await page.locator('.hm-orgchart-expanded-empty').count() === 1, 'Story empty selection safely clears view/popup'); + await page.getByRole('button', { name: '초기화', exact: true }).click(); await ready(20); + await select('조직도 표시 유형', '세로형 TB'); + const rootId = (await measure()).nodes.find(n => !n.parent).id; + const toggle = page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${rootId}"]`); + await toggle.focus(); await toggle.press('Enter'); await ready(); const collapsed = await page.locator('.hm-orgchart-employee').count(); + await select('직원 정보 팝업', 'Core 기본 팝업'); await ready(); + check(await page.locator('.hm-orgchart-employee').count() === collapsed, 'Story popup ownership switch retains independent collapse'); + await page.locator('.hm-orgchart-employee').first().press('Enter'); await page.locator('dialog[open]').waitFor(); await page.keyboard.press('Escape'); + await select('직원 정보 팝업', '사용 안 함'); await ready(); + await page.locator('.hm-orgchart-employee').first().press('Enter'); await settle(); + check(await page.getByRole('dialog').count() === 0, 'Story popup none: no default or HM popup'); + await select('직원 정보 팝업', 'HMDesign Dialog'); await ready(); + await toggle.focus(); await toggle.press('Enter'); await ready(20); + for (const width of [390, 768, 1280, 1440]) { + await page.setViewportSize({ width, height: width === 390 ? 844 : 1100 }); await settle(); + await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); + const m = await measure(); + check(m.document[0] <= width && m.scroll[0] === 0 && m.scroll[1] === 0, `Story ${width}px: no document overflow/hidden viewport scroll`, { document: m.document, scroll: m.scroll }); + measurements.push({ name: `Story ${width}px`, value: m }); + await screenshot('story-' + width); + } + await select('조직도 표시 유형', '가로형 LR'); + await select('초기 표시 Depth', '2Depth까지'); + await page.getByRole('button', { name: '전체 회사', exact: true }).click(); + await select('데이터 범위', '회사별'); await select('회사당 직원 수', '300명'); + check(await page.getByText(/대용량 2,100명 전환 예정/).count() === 1, 'Story2100: scale disclosed before data commit'); + await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready(); + check(await page.getByText(/실제 직원 합계 2,100명/).count() === 1 && (await measure()).nodes.every(n => n.depth < 2), 'Story2100: source count retained with independent initial-depth reduction'); + await screenshot('story-2100-depth2'); + await page.getByRole('button', { name: '초기화', exact: true }).click(); + await select('초기 표시 Depth', '전체 펼침'); await select('조직도 표시 유형', '세로형 TB'); await ready(20); + check(await page.getByRole('alert').count() === 0, 'Story has no lifecycle error alert'); + } + if (run('react')) { + await page.goto(origin + '/test-results/org-chart-p7/react-build/dist/index.html'); await ready(20); + await hmPopup('React production'); + for (const type of ['lr', 'hybrid']) { + await page.evaluate(type => window.p7.setAutoLayout({ type }), type); await ready(20); + await hmPopup('React ' + type); + } + // Layout replacement while Dialog is open preserves selected identity and restores replacement card. + const id = await page.locator('.hm-orgchart-employee').first().getAttribute('data-employee-id'); + await page.locator('.hm-orgchart-employee').first().press('Enter'); await page.locator('[data-component="HMOrgChartEmployeeDialog"]').waitFor(); + await page.evaluate(() => window.p7.setAutoLayout({ type: 'tb' })); await settle(); + check(await page.locator('[data-component="HMOrgChartEmployeeDialog"]').getAttribute('data-employee-id') === id, 'React layout change retains popup employee identity'); + await page.keyboard.press('Escape'); await page.locator('[data-component="HMOrgChartEmployeeDialog"]').waitFor({ state: 'hidden' }); await settle(); + check(await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId === id, id), 'React mode rerender returns focus to replacement card'); + await page.locator('.hm-orgchart-employee').first().press('Enter'); await page.locator('[data-component="HMOrgChartEmployeeDialog"]').waitFor(); + await page.evaluate(() => window.p7.setCompanies(['saman'])); await settle(); + check(await page.locator('[data-component="HMOrgChartEmployeeDialog"]').count() === 0, 'React company disappearance clears external popup'); + await page.evaluate(() => window.p7.setCompanies(undefined)); await ready(20); + await page.locator('.hm-orgchart-employee').first().press('Enter'); await page.locator('[data-component="HMOrgChartEmployeeDialog"]').waitFor(); + await page.evaluate(() => window.p7.setInput({ scope: 'per-company', employeeCount: 100, companyIds: ['saman'] })); await ready(100); + await page.locator('[data-component="HMOrgChartEmployeeDialog"]').waitFor({ state: 'hidden' }); + check(await page.locator('[data-component="HMOrgChartEmployeeDialog"]').count() === 0, 'React source change clears stale popup'); + const m = await measure(); check(m.cards.every(c => c.company === 'saman'), 'React generated100: source identity matches shared Vanilla contract'); + measurements.push({ name: 'React generated100', value: m }); + } + if (run('react-repair')) { + await page.goto(origin + '/test-results/org-chart-p7/react-build/dist/index.html'); await ready(20); + const id = await page.locator('.hm-orgchart-employee').first().getAttribute('data-employee-id'); + await page.locator('.hm-orgchart-employee').first().press('Enter'); const d = page.locator('[data-component="HMOrgChartEmployeeDialog"]'); await d.waitFor(); + await page.evaluate(() => window.p7.setAutoLayout({ type: 'lr' })); await settle(); + await page.keyboard.press('Escape'); await d.waitFor({ state: 'hidden' }); await settle(); + check(await page.evaluate(id => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId === id, id), 'React mode rerender returns focus to replacement card'); + await page.locator('.hm-orgchart-employee').first().press('Enter'); await d.waitFor(); + await page.evaluate(() => window.p7.setInput({ scope: 'per-company', employeeCount: 100, companyIds: ['saman'] })); await ready(100); await d.waitFor({ state: 'hidden' }); + check(await d.count() === 0, 'React source change clears stale popup'); + } + if (run('options')) { + await mount(small, { layoutMode: 'auto', displayMode: 'readOnly' }); + for (const type of ['tb', 'lr', 'hybrid']) { + await setLayout({ type }); + for (const config of [{ collapse: false, zoom: true, pan: true }, { collapse: true, zoom: false, pan: true }, { collapse: true, zoom: true, pan: false }, { collapse: false, zoom: false, pan: false }]) { + const before = await page.evaluate(() => window.handle.getViewportState()); + await page.evaluate(config => window.handle.setInteractions(config), config); await ready(20); + const after = await page.evaluate(() => window.handle.getViewportState()); + const v = page.locator('.hm-orgchart-viewport'); + const root = page.locator('.hm-orgchart-expanded-toggle').first(); + check(await root.isDisabled() === !config.collapse && await page.getByRole('button', { name: '확대', exact: true }).isDisabled() === !config.zoom && await v.getAttribute('data-pan-enabled') === String(config.pan), `${type} independent ${JSON.stringify(config)}: only requested capabilities disabled`); + check(JSON.stringify(before) === JSON.stringify(after), `${type} independent ${JSON.stringify(config)}: setter retains viewport state`); + const box = await v.boundingBox(); const panBefore = await page.evaluate(() => window.handle.getViewportState()); + await page.mouse.move(box.x + 8, box.y + 8); await page.mouse.down(); await page.mouse.move(box.x + 60, box.y + 30); await page.mouse.up(); + const panAfter = await page.evaluate(() => window.handle.getViewportState()); + check((panAfter.x !== panBefore.x || panAfter.y !== panBefore.y) === config.pan, `${type} independent ${JSON.stringify(config)}: trusted Pan respects flag`); + if (!config.zoom) { + await page.keyboard.down('Control'); await page.mouse.wheel(0, -80); await page.keyboard.up('Control'); await settle(); + check((await page.evaluate(() => window.handle.getViewportState())).scale === panAfter.scale, `${type} disabled Zoom ignores Ctrl-wheel`); + } + await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); + check(fit(await measure()), `${type} independent ${JSON.stringify(config)}: Fit remains usable`); + } + await page.evaluate(() => window.handle.setInteractions({ collapse: true, zoom: true, pan: true })); await ready(20); + await page.locator('.hm-orgchart-employee').first().press('Enter'); await page.locator('dialog[open]').waitFor(); await page.keyboard.press('Escape'); + check(await page.locator('dialog[open]').count() === 0, `${type}: popup works independently of feature flags`); + } + await page.evaluate(() => { window.handle.zoomTo(1); }); + await page.evaluate(() => window.handle.setInteractions({ pan: false, zoom: true, collapse: true })); await ready(20); + await page.locator('.hm-orgchart-employee').last().focus(); await settle(); + const focus = await page.evaluate(() => { + const s = document.querySelector('.hm-orgchart-host').shadowRoot, v = s.querySelector('.hm-orgchart-viewport'), c = s.activeElement; + const r = c.getBoundingClientRect(), b = v.getBoundingClientRect(); + return { visible: r.left >= b.left && r.right <= b.right && r.top >= b.top && r.bottom <= b.bottom, scale: v.dataset.scale, scroll: [v.scrollLeft, v.scrollTop] }; + }); + check(focus.visible && focus.scale === '1' && focus.scroll.every(n => n === 0), 'Pan disabled: keyboard reveals offscreen card at unchanged Zoom with no hidden scroll', focus); + // Actual new React/Story settings and changed content layout, without replaying all data scenarios. + await page.goto((process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6056') + '/iframe.html?id=orgchart--expanded-org-chart&viewMode=story'); await ready(20); + for (const [label, type] of [['세로형 TB', 'tb'], ['가로형 LR', 'lr'], ['혼합형 Hybrid', 'hybrid']]) { + await select('조직도 표시 유형', label); await ready(20); + await page.getByRole('checkbox', { name: '접기·펼치기 사용', exact: true }).uncheck(); + await page.getByRole('checkbox', { name: 'Zoom 사용', exact: true }).uncheck(); + await page.getByRole('checkbox', { name: 'Pan 사용', exact: true }).uncheck(); await ready(20); + check(await page.locator('.hm-orgchart-expanded-toggle').first().isDisabled() && await page.getByRole('button', { name: '확대', exact: true }).isDisabled() && await page.locator('.hm-orgchart-viewport').getAttribute('data-pan-enabled') === 'false', `Story ${type}: all independent checkboxes control actual Core`); + await hmPopup('Final ' + type); + await page.getByRole('checkbox', { name: '접기·펼치기 사용', exact: true }).check(); + await page.getByRole('checkbox', { name: 'Zoom 사용', exact: true }).check(); + await page.getByRole('checkbox', { name: 'Pan 사용', exact: true }).check(); await ready(20); + } + await select('직원 정보 팝업', '사용 안 함'); await ready(20); + check(await page.locator('.hm-orgchart-employee[role="button"]').count() === 0 && await page.locator('.hm-orgchart-employee[tabindex="0"]').count() === 0, 'Story popup none: no false actionable card semantics'); + await select('직원 정보 팝업', 'HMDesign Dialog'); await ready(20); + } + if (run('options') || run('options-tail')) { + if (run('options-tail')) { + await page.goto((process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6056') + '/iframe.html?id=orgchart--expanded-org-chart&viewMode=story'); await ready(20); + } + await select('조직도 표시 유형', '세로형 TB'); + await select('특정 그룹 세로 배치', '한맥기술 · 가상 조직'); await ready(20); + check(await page.locator('[data-group-hybrid="true"]').count() === 1, 'Story per-group Hybrid: real applied local branch'); + await select('조직도 표시 유형', '가로형 LR'); await ready(20); + check(await page.getByRole('combobox', { name: '특정 그룹 세로 배치', exact: true }).isDisabled(), 'Story per-group Hybrid: incompatible LR selection disabled and cleared'); + await select('조직도 표시 유형', '세로형 TB'); + for (const width of [390, 768, 1280, 1440]) { + await page.setViewportSize({ width, height: width === 390 ? 844 : 1100 }); await settle(); + await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); const m = await measure(); + check(m.document[0] <= width && m.scroll[0] === 0 && m.scroll[1] === 0 && fit(m), `Final Story ${width}px: added options fit and no overflow`, { document: m.document, scroll: m.scroll, forest: m.forest, viewport: m.viewport }); + measurements.push({ name: `Final Story ${width}px`, value: m }); await screenshot('story-' + width); + } + const presetColors = { hanmac: 'rgb(203, 46, 40)', saman: 'rgb(240, 131, 0)', 'jangheon-industry': 'rgb(69, 128, 166)', jangheon: 'rgb(29, 65, 141)', ptc: 'rgb(132, 51, 142)', halla: 'rgb(0, 104, 183)', baron: 'rgb(12, 54, 127)' }; + const colors = (await measure()).titles; + check(colors.every(t => t.color === 'rgb(255, 255, 255)' && t.background === presetColors[t.company]), 'Seven company titles preserve exact preset colors and requested white text', colors); + const title = page.locator('.hm-orgchart-organization-name').first(); + await page.locator('.hm-orgchart-expanded-toggle').first().hover(); + check(await title.evaluate(t => getComputedStyle(t).color) === 'rgb(255, 255, 255)', 'White company title remains on hover'); + await page.locator('.hm-orgchart-expanded-toggle').first().focus(); + check(await title.evaluate(t => getComputedStyle(t).color) === 'rgb(255, 255, 255)', 'White company title remains on keyboard focus'); + await page.locator('.hm-orgchart-expanded-toggle').first().press('Enter'); await settle(); + check(await title.evaluate(t => getComputedStyle(t).color) === 'rgb(255, 255, 255)', 'White company title remains collapsed'); + await page.locator('.hm-orgchart-expanded-toggle').first().press('Enter'); await settle(); + check(await title.evaluate(t => getComputedStyle(t).color) === 'rgb(255, 255, 255)', 'White company title remains expanded'); + } + if (run('options-tail') || run('story-menu')) { + const index = await (await page.request.get((process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6056') + '/index.json')).json(); + const names = Object.values(index.entries).filter(e => e.title === 'OrgChart' && e.type === 'story' && !e.tags.includes('internal') && !e.tags.includes('verification')).map(e => e.name).sort(); + check(JSON.stringify(names) === JSON.stringify(['Department Colors', 'Expanded OrgChart', 'Full Composition', 'Logo · Personnel Summary'].sort()), 'Story menu retains exactly the original four user entries', names); + } + if (run('lr-reference') || run('lr-residue')) { + await mount(template, { layoutMode: 'auto', displayMode: 'readOnly', autoLayout: { type: 'lr' } }); await ready(300); + if (run('lr-reference')) { + const upright = await page.evaluate(() => { + const s = document.querySelector('.hm-orgchart-host').shadowRoot; + const elements = [...s.querySelectorAll('.orgchart, .node, .hm-orgchart-organization, .hm-orgchart-employee, .hm-orgchart-avatar, .hm-orgchart-person-name, .hm-orgchart-person-role')]; + return { l2r: s.querySelectorAll('.orgchart.l2r').length, rotated: elements.filter(e => getComputedStyle(e).transform !== 'none').map(e => [e.className, getComputedStyle(e).transform]), photos: [...s.querySelectorAll('.hm-orgchart-avatar')].every(e => e.offsetWidth === 36 && e.offsetHeight === 36) }; + }); + check(upright.l2r === 7 && upright.rotated.length === 0 && upright.photos, 'LR reference: actual l2r engine class on all seven trees; photos/text/card remain upright', upright); + const route = async () => page.evaluate(() => { + const s = document.querySelector('.hm-orgchart-host').shadowRoot, scale = Number(s.querySelector('.hm-orgchart-viewport').dataset.scale); + return [...s.querySelectorAll('.hm-orgchart-tree')].every(tree => { + const nodes = [...tree.querySelectorAll('.node[data-organization-id]')], paths = [...tree.querySelectorAll('.hm-orgchart-organization-connectors path')], t = tree.getBoundingClientRect(); + return paths.length === nodes.filter(n => n.dataset.parentId).length && paths.every(p => { + const node = nodes.find(n => n.dataset.organizationId === p.dataset.organizationId), parent = nodes.find(n => n.dataset.organizationId === p.dataset.parentId); + if (!node || !parent || node.dataset.parentId !== parent.dataset.organizationId) return false; + const start = parent.querySelector('.hm-orgchart-organization-name').getBoundingClientRect(), end = node.querySelector('.hm-orgchart-organization-name').getBoundingClientRect(); + const values = p.getAttribute('d').match(/-?\d+(?:\.\d+)?/g).map(Number); + return Math.abs(values[0] - (start.right - t.left) / scale) < 1 && Math.abs(values[1] - (start.top + start.height / 2 - t.top) / scale) < 1 + && Math.abs(values.at(-1) - (end.left - t.left) / scale) < 1 && Math.abs(values.at(-2) - (end.top + end.height / 2 - t.top) / scale) < 1; + }); + }); + }); + const branch = page.locator('.node[data-organization-id="hanmac-division-0"] > .hm-orgchart-organization .hm-orgchart-expanded-toggle').first(); + const before = await measure(); await branch.press('Enter'); await ready(); const closed = await measure(); + check(closed.cards.length < before.cards.length && closed.nodes.length < before.nodes.length && await route() && !overlap(closed.nodes.map(n => n.article)) && endpoint(closed) && border(closed), 'LR reference: collapsed branch removes descendants and recalculates both connector endpoints and layout'); + await branch.press('Enter'); await ready(300); const opened = await measure(); + check(await route() && !overlap(opened.nodes.map(n => n.article)) && endpoint(opened) && border(opened), 'LR reference: reopened branch updates both parent/child endpoints without overlap'); + } + await setLayout({ type: 'tb' }); await setLayout({ type: 'hybrid', hybridStartDepth: 3 }); await setLayout({ type: 'lr' }); + const clean = await page.evaluate(() => { + const s = document.querySelector('.hm-orgchart-host').shadowRoot; + return { trees: s.querySelectorAll('.orgchart').length, l2r: s.querySelectorAll('.orgchart.l2r').length, vertical: s.querySelectorAll('.nodes.vertical').length, svgs: s.querySelectorAll('.hm-orgchart-organization-connectors').length, paths: s.querySelectorAll('.hm-orgchart-organization-connectors path').length, nodes: [...s.querySelectorAll('.node[data-parent-id]')].filter(n => n.dataset.parentId).length }; + }); + check(clean.trees === 7 && clean.l2r === 7 && clean.vertical === 0 && clean.svgs === 7 && clean.paths === clean.nodes, 'LR reference: TB/Hybrid/LR return has one fresh engine and SVG per company, no stale vertical lists', clean); + await page.evaluate(() => window.handle.zoomTo(1)); await page.locator('.hm-orgchart-employee').first().focus(); await settle(); await screenshot('lr-card-upright'); + measurements.push({ name: 'LR reference upright and refreshed paths', value: await measure() }); + } + if (run('performance')) { + for (const options of [{ type: 'lr' }, { type: 'hybrid', hybridStartDepth: 2 }, { type: 'hybrid', hybridStartDepth: 3 }, { type: 'hybrid', hybridStartDepth: 4 }]) { + const started = Date.now(); await mount(template, { layoutMode: 'auto', displayMode: 'readOnly', autoLayout: options }); await ready(300); + const readyMs = Date.now() - started, mountMs = await page.evaluate(() => window.mountMs); + const toggle = page.locator('.node[data-organization-id="hanmac-division-0"] > .hm-orgchart-organization .hm-orgchart-expanded-toggle').first(); + const collapseStarted = Date.now(); await toggle.press('Enter'); await ready(); const collapseMs = Date.now() - collapseStarted; + await toggle.press('Enter'); await ready(300); + const zoomStarted = Date.now(); await page.getByRole('button', { name: '확대', exact: true }).click(); await settle(); const zoomMs = Date.now() - zoomStarted; + const fitStarted = Date.now(); await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); const fitMs = Date.now() - fitStarted; + const value = await measure(); + measurements.push({ name: '300 performance ' + JSON.stringify(options), mountMs, fullReadyMs: readyMs, collapseMs, zoomMs, fitMs, value }); + check(value.cards.length === 300 && fit(value), '300 performance sample completes at full data: ' + JSON.stringify(options), { mountMs, fullReadyMs: readyMs, collapseMs, zoomMs, fitMs, heap: value.heap, domCount: value.domCount }); + } + } + if (run('dialog-narrow')) { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto((process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6056') + '/iframe.html?id=orgchart--expanded-org-chart&viewMode=story'); await ready(20); + await page.locator('.hm-orgchart-employee').first().press('Enter'); const d = page.locator('[data-component="HMOrgChartEmployeeDialog"]'); await d.waitFor(); await settle(); + const bounds = await d.evaluate(d => { + const r = d.getBoundingClientRect(); + return { x: r.left, y: r.top, right: r.right, bottom: r.bottom, width: r.width, height: r.height, scrollWidth: d.scrollWidth, clientWidth: d.clientWidth, fields: [...d.querySelectorAll('dd')].map(e => ({ text: e.textContent, scroll: e.scrollWidth, client: e.clientWidth })), active: d.contains(document.activeElement) }; + }); + check(bounds.x >= 0 && bounds.y >= 0 && bounds.right <= 390 && bounds.bottom <= 844 && bounds.scrollWidth <= bounds.clientWidth && bounds.fields.every(f => f.scroll <= f.client) && bounds.active, 'HM Dialog 390px: content, fields, focus fit viewport without horizontal overflow', bounds); + await screenshot('hm-dialog-390'); await page.keyboard.press('Escape'); await d.waitFor({ state: 'hidden' }); + } + check(errors.length === 0, 'No new console or runtime errors', errors); +} catch (error) { + check(false, 'Verification runner completed', { message: error.message, stack: error.stack }); +} finally { + const pass = results.filter(r => r.status === 'PASS').length; + const fail = results.filter(r => r.status === 'FAIL').length; + await fs.writeFile(path.join(output, only.length ? `browser-${only.join('-')}.json` : 'browser.json'), JSON.stringify({ pass, fail, results, errors, measurements, chrome: await browser.version() }, null, 2)); + console.log(JSON.stringify({ pass, fail, errors: errors.length })); + await browser.close(); await new Promise(resolve => server.close(resolve)); + process.exitCode = fail ? 1 : 0; +} diff --git a/examples/org-chart/verify-p8.mjs b/examples/org-chart/verify-p8.mjs new file mode 100644 index 0000000..c5b807d --- /dev/null +++ b/examples/org-chart/verify-p8.mjs @@ -0,0 +1,376 @@ +// P8 Storybook presentation-only browser checks. P7 units/full suites stay unrun. +import fs from 'node:fs/promises'; +import { writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-p8'); +const origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6057').replace(/\/$/, ''); +const sections = process.argv.slice(2); +const run = section => (!sections.length && !['full-repair', 'route-final'].includes(section)) || sections.includes(section); +const results = [], measurements = [], errors = []; +const startedAt = new Date().toISOString(); +await fs.mkdir(output, { recursive: true }); +const persist = () => writeFileSync(path.join(output, 'browser-progress.json'), JSON.stringify({ startedAt, origin, sections, results, measurements, errors }, null, 2)); +function check(pass, name, detail) { + results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); + persist(); + if (!pass) console.log('FAIL', name, JSON.stringify(detail)); +} +const stories = [ + { id: 'orgchart--logo-personnel-summary', name: 'Overview', file: 'overview' }, + { id: 'orgchart--department-colors', name: 'Department Colors', file: 'department-colors' }, + { id: 'orgchart--full-composition', name: 'Full Composition', file: 'full-composition' }, + { id: 'orgchart--expanded-org-chart', name: 'Expanded OrgChart', file: 'expanded-org-chart' }, +]; +const patterns = ['patterns-data-filter-bar--all', 'patterns-layout-page-header--all', 'patterns-layout-loading-screen--all']; +const internalIds = [ + 'orgchart--logo', 'orgchart--position-summary', 'orgchart--affiliation-summary', + ...['business-management', 'business-planning', 'talent-growth', 'integrated-system', 'design-planning', 'solution-sales', 'top-leader', 'organization-leader', 'employee'].map(name => 'orgchart-components-cards--' + name), + ...['position-summary', 'affiliation-summary', 'status-summary'].map(name => 'orgchart-components-summaries--' + name), + ...['basic-organization', 'organization-hierarchy', 'employee-cards', 'organization-summary', 'json-data-update', 'loading-empty-error', 'responsive-container', 'multiple-instances'].map(name => 'orgchart-content--' + name), +]; +const browser = await launchChromium({ headless: true }); +const context = await browser.newContext({ viewport: { width: 1440, height: 1100 } }); +const page = await context.newPage(); +context.on('page', p => { + p.on('pageerror', error => errors.push({ url: p.url(), type: 'pageerror', message: error.message })); + p.on('console', message => { if (message.type() === 'error') errors.push({ url: p.url(), type: 'console', message: message.text(), location: message.location() }); }); +}); +// The initial page predates the context event listener. +page.on('pageerror', error => errors.push({ url: page.url(), type: 'pageerror', message: error.message })); +page.on('console', message => { if (message.type() === 'error') errors.push({ url: page.url(), type: 'console', message: message.text(), location: message.location() }); }); +page.setDefaultTimeout(20000); +async function settle(target = page) { + await target.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 5; i++) await new Promise(resolve => requestAnimationFrame(resolve)); }); +} +async function ready(expected) { + await page.locator('.sb-page-title').waitFor(); + await page.waitForFunction(expected => { + const hosts = [...document.querySelectorAll('.hm-orgchart-host,.hm-orgchart-component-host')]; + if (!hosts.length) return false; + return hosts.every(host => { + const shadow = host.shadowRoot; + return shadow && [...shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet) + && !shadow.querySelector('[aria-busy="true"]') + && (expected === undefined || shadow.querySelectorAll('.hm-orgchart-employee').length === expected); + }); + }, expected); + await settle(); +} +async function gotoStory(story, width = 1440) { + await page.setViewportSize({ width, height: 1100 }); + await page.goto(`${origin}/iframe.html?id=${story.id}&viewMode=story`); + await ready(story.file === 'expanded-org-chart' ? 20 : undefined); +} +async function capture(file, fullPage = true) { await page.screenshot({ path: path.join(output, file + '.png'), fullPage }); } +async function section(name, work) { + if (!run(name)) return; + try { await work(); } + catch (error) { check(false, `${name}: runner completed`, { message: error.message, stack: error.stack }); } +} +async function commonStyle() { + return page.evaluate(() => { + const properties = { + '.sb-page': ['fontFamily', 'color', 'paddingTop', 'paddingBottom', 'boxSizing', 'width'], + '.sb-page-inner': ['width', 'boxSizing'], + '.sb-page-header': ['paddingBottom', 'borderBottomWidth', 'borderBottomColor', 'fontSize', 'lineHeight'], + '.sb-page-kicker': ['fontSize', 'fontWeight', 'color', 'marginBottom'], + '.sb-page-title': ['fontSize', 'fontWeight', 'lineHeight', 'marginTop', 'marginBottom'], + '.sb-page-description': ['fontSize', 'fontWeight', 'lineHeight', 'marginBottom'], + '.sb-page-section': ['marginTop', 'scrollMarginTop'], + '.sb-page-section-header': ['paddingBottom', 'marginBottom', 'borderBottomWidth', 'borderBottomColor'], + '.sb-page-section-title': ['fontSize', 'lineHeight', 'fontWeight', 'marginTop', 'marginBottom'], + '.sb-page-section-description': ['fontSize', 'lineHeight', 'fontWeight', 'marginTop', 'color'], + }; + return Object.fromEntries(Object.entries(properties).map(([selector, keys]) => { + const element = document.querySelector(selector); + if (!element) return [selector, null]; + const css = getComputedStyle(element); + const style = Object.fromEntries(keys.map(key => [key, css[key]])); + if (selector === '.sb-page-inner') { + // Chrome can expose auto margins as 0px after the Core's Typed OM + // ancestor inspection. Actual symmetric gutters are the user contract. + const outer = element.parentElement.getBoundingClientRect(), inner = element.getBoundingClientRect(); + style.leftGutter = inner.left - outer.left; + style.rightGutter = outer.right - inner.right; + } + return [selector, style]; + })); + }); +} +async function pageGeometry() { + return page.evaluate(() => { + const rect = element => { if (!element) return null; const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; }; + const documentGeometry = { scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, innerWidth, scrollHeight: document.documentElement.scrollHeight, clientHeight: document.documentElement.clientHeight }; + const frame = { page: rect(document.querySelector('.sb-page')), inner: rect(document.querySelector('.sb-page-inner')), title: document.querySelector('.sb-page-title')?.textContent }; + const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; + const wrapper = shadow?.querySelector('.hm-orgchart-composition'); + const stage = shadow?.querySelector('.hm-orgchart-stage'); + const fixed = wrapper && stage ? { + wrapper: rect(wrapper), stage: rect(stage), baseWidth: stage.offsetWidth, baseHeight: stage.offsetHeight, + scale: Number(wrapper.dataset.scale), matrix: getComputedStyle(stage).transform, + scroll: ['.hm-orgchart-shell', '.hm-orgchart-content', '.hm-orgchart-composition', '.hm-orgchart-viewport'].map(selector => { + const element = shadow.querySelector(selector); + if (!element) return { selector, missing: true }; + const css = getComputedStyle(element); + return { selector, clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, scrollLeft: element.scrollLeft, scrollTop: element.scrollTop, overflowX: css.overflowX, overflowY: css.overflowY }; + }), + visibleBounds: [...stage.querySelectorAll('.hm-orgchart-employee,.hm-orgchart-organization,.hm-orgchart-summary,.hm-orgchart-logo,.hm-orgchart-workdesk-overlay')].map(element => ({ className: element.className, box: rect(element) })), + } : null; + return { document: documentGeometry, frame, fixed }; + }); +} +async function autoState() { + return page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + const viewport = shadow.querySelector('.hm-orgchart-viewport'), forest = shadow.querySelector('.hm-orgchart-forest'); + 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 }; }; + return { type: shadow.querySelector('.hm-orgchart-expanded')?.dataset.autoLayout, employees: shadow.querySelectorAll('.hm-orgchart-employee').length, + organizations: shadow.querySelectorAll('.node[data-depth]').length, scale: Number(viewport.dataset.scale), x: Number(viewport.dataset.panX), y: Number(viewport.dataset.panY), + viewport: rect(viewport), forest: rect(forest), zoomEnabled: viewport.dataset.zoomEnabled, panEnabled: viewport.dataset.panEnabled, + scroll: { left: viewport.scrollLeft, top: viewport.scrollTop, width: viewport.scrollWidth, clientWidth: viewport.clientWidth, height: viewport.scrollHeight, clientHeight: viewport.clientHeight } }; + }); +} +const autoFits = state => state.forest.x >= state.viewport.x - 1 && state.forest.y >= state.viewport.y - 1 && state.forest.right <= state.viewport.right + 1 && state.forest.bottom <= state.viewport.bottom + 1; +const fixedHasNoInternalScroll = fixed => fixed.scroll.filter(item => !item.missing).every(item => item.scrollLeft === 0 && item.scrollTop === 0 && ( + item.selector === '.hm-orgchart-composition' + ? item.overflowX === 'hidden' && item.overflowY === 'hidden' + : item.scrollWidth <= item.clientWidth + 1 && item.scrollHeight <= item.clientHeight + 1 +)); +async function select(label, option) { + await page.getByRole('combobox', { name: label, exact: true }).click(); + await page.getByRole('option', { name: option, exact: true }).click(); + await settle(); +} + +try { + await section('manager', async () => { + const response = await page.request.get(origin + '/index.json'); + check(response.ok(), 'Storybook index responds', { status: response.status() }); + const index = await response.json(); + await fs.writeFile(path.join(output, 'story-index.json'), JSON.stringify(index, null, 2)); + const entries = Object.values(index.entries); + const publicEntries = entries.filter(entry => (entry.title === 'OrgChart' || entry.title.startsWith('OrgChart/')) && entry.type === 'story' && !entry.tags?.some(tag => ['internal', 'verification'].includes(tag))); + check(publicEntries.length === 4 && stories.every(story => publicEntries.some(entry => entry.id === story.id && entry.name === story.name)), 'Exactly four public names and original Story IDs', publicEntries.map(({ id, name, title, tags }) => ({ id, name, title, tags }))); + check(internalIds.every(id => index.entries[id]?.tags?.includes('internal')), 'Existing internal Story IDs retained', { expected: internalIds, missingOrPublic: internalIds.filter(id => !index.entries[id]?.tags?.includes('internal')) }); + check(new Set(entries.map(entry => entry.id)).size === entries.length, 'Story index contains no duplicate IDs'); + const manager = await context.newPage(); + await manager.goto(`${origin}/?path=/story/${stories[0].id}`); + await manager.locator('#storybook-explorer-tree').waitFor(); + await manager.locator(`#storybook-explorer-tree a[id="${stories[0].id}"]`).waitFor(); + await manager.waitForFunction(() => [...document.querySelectorAll('#storybook-explorer-tree [data-item-id]')].some(row => row.dataset.itemId === 'orgchart' && row.dataset.hmDepth === '0')); + await manager.waitForFunction(() => { + const preview = document.querySelector('#storybook-preview-iframe')?.contentDocument; + const hosts = [...preview?.querySelectorAll('.hm-orgchart-component-host') ?? []]; + return preview?.querySelector('.sb-page-title')?.textContent === 'Overview' && hosts.length > 0 + && hosts.every(host => host.shadowRoot && [...host.shadowRoot.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet)); + }); + await manager.frameLocator('#storybook-preview-iframe').locator('.sb-page-title').evaluate(async element => { + const document = element.ownerDocument, view = document.defaultView; + await document.fonts.ready; + for (let i = 0; i < 5; i++) await new Promise(resolve => view.requestAnimationFrame(resolve)); + }); + await settle(manager); + const sidebar = await manager.evaluate(() => [...document.querySelectorAll('#storybook-explorer-tree [data-item-id]')].map(row => { + const primary = row.querySelector(':scope > a[id],:scope > button[id]'); + const label = primary?.querySelector('.hm-manager-label')?.textContent ?? primary?.textContent ?? row.querySelector(':scope > button')?.textContent ?? row.textContent; + return { id: row.dataset.itemId, text: label?.trim(), depth: Number(row.dataset.hmDepth), section: row.dataset.hmSection, type: row.dataset.nodetype, href: primary?.getAttribute('href'), visible: row.getBoundingClientRect().height > 0 }; + })); + await fs.writeFile(path.join(output, 'manager-sidebar.json'), JSON.stringify(sidebar, null, 2)); + const roots = sidebar.filter(row => row.depth === 0 && row.visible); + const patternsRoot = roots.findIndex(row => row.id === 'patterns'), orgRoot = roots.findIndex(row => row.id === 'orgchart'), centerRoot = roots.findIndex(row => row.id === 'center-graphic'); + check(patternsRoot >= 0 && orgRoot === patternsRoot + 1 && centerRoot === orgRoot + 1, 'OrgChart is between Patterns and Center Graphic at manager depth 0', roots); + const publicRows = sidebar.filter(row => stories.some(story => story.id === row.id)); + check(JSON.stringify(publicRows.map(row => row.text)) === JSON.stringify(stories.map(story => story.name)) && publicRows.every(row => row.depth === 1), 'Four manager pages follow requested order at one child depth', publicRows); + check(sidebar.filter(row => row.id.startsWith('orgchart--')).length === 4 && internalIds.every(id => !sidebar.some(row => row.id === id)), 'Internal pages remain default hidden with no duplicate public row'); + const sidebarHeight = await manager.locator('#storybook-sidebar-region').evaluate(element => Math.max(element.scrollHeight, ...[...element.querySelectorAll('[data-radix-scroll-area-content]')].map(item => item.scrollHeight))); + await manager.setViewportSize({ width: 1440, height: Math.max(1100, Math.min(2400, sidebarHeight + 100)) }); + await settle(manager); + await manager.screenshot({ path: path.join(output, 'manager-full-sidebar.png'), fullPage: true }); + await manager.locator('#storybook-sidebar-region').screenshot({ path: path.join(output, 'manager-sidebar.png') }); + await manager.close(); + }); + await section('route-final', async () => { + const expected = { + overview: { hosts: 3, selectors: ['[data-component="Logo"]', '[data-component="PositionSummary"]', '[data-component="AffiliationSummary"]'] }, + 'department-colors': { hosts: 1, selectors: ['[data-component="DepartmentColors"]', '.hm-orgchart-employee'] }, + 'full-composition': { hosts: 1, selectors: ['.hm-orgchart-composition', '.hm-orgchart-stage', '.hm-orgchart-employee'] }, + 'expanded-org-chart': { hosts: 1, selectors: ['.hm-orgchart-expanded', '.hm-orgchart-viewport', '.hm-orgchart-employee'] }, + }; + for (const story of stories) { + await gotoStory(story); + const rendered = await page.evaluate(({ selectors }) => { + const hosts = [...document.querySelectorAll('.hm-orgchart-host,.hm-orgchart-component-host')]; + return { title: document.querySelector('.sb-page-title')?.textContent, hosts: hosts.length, + specimens: Object.fromEntries(selectors.map(selector => [selector, hosts.reduce((total, host) => total + host.shadowRoot.querySelectorAll(selector).length, 0)])), + employees: hosts.reduce((total, host) => total + host.shadowRoot.querySelectorAll('.hm-orgchart-employee').length, 0), + autoLayout: hosts.find(host => host.classList.contains('hm-orgchart-host'))?.shadowRoot.querySelector('.hm-orgchart-expanded')?.dataset.autoLayout }; + }, expected[story.file]); + check(rendered.title === story.name && rendered.hosts === expected[story.file].hosts && Object.values(rendered.specimens).every(count => count > 0) + && (story.file !== 'expanded-org-chart' || (rendered.employees === 20 && rendered.autoLayout === 'tb')), `${story.name}: final CSF old URL loads expected Core specimens`, { url: page.url(), ...rendered }); + measurements.push({ name: story.name + ' final retained route', rendered }); + await capture(story.file + '-1440'); + } + }); + await section('styles', async () => { + const references = []; + for (const id of patterns) { + await page.setViewportSize({ width: 1440, height: 1100 }); + await page.goto(`${origin}/iframe.html?id=${id}&viewMode=story`); + await page.locator('.sb-page-title').waitFor(); await settle(); + references.push({ id, style: await commonStyle() }); + } + const reference = references[0].style; + check(references.every(item => JSON.stringify(item.style) === JSON.stringify(reference)), 'Three existing Pattern pages share the measured StoryPage frame', references); + for (const story of stories) { + await gotoStory(story); + const style = await commonStyle(); + measurements.push({ name: story.name + ' common StoryPage computed styles', style }); + check(JSON.stringify(style) === JSON.stringify(reference), story.name + ': StoryPage title, spacing, section and inner styles match Pattern pages', { reference: patterns[0], difference: Object.keys(reference).filter(key => JSON.stringify(reference[key]) !== JSON.stringify(style[key])), style }); + await capture(story.file + '-1440'); + if (['full-composition', 'expanded-org-chart'].includes(story.file)) { + const shielding = await page.evaluate(() => { + const pageFrame = document.querySelector('.sb-page'); + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + const keys = ['fontFamily', 'fontSize', 'lineHeight', 'color', 'backgroundColor', 'backgroundImage', 'borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderRadius', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'width', 'height']; + const snapshot = () => ['.hm-orgchart-employee', '.hm-orgchart-organization', '.hm-orgchart-organization-name', '.hm-orgchart-person-name', '.hm-orgchart-summary'].flatMap(selector => [...shadow.querySelectorAll(selector)].map(element => ({ selector, id: element.dataset.employeeId ?? element.dataset.organizationId, style: Object.fromEntries(keys.map(key => [key, getComputedStyle(element)[key]])) }))); + const withFrame = snapshot(), previous = pageFrame.className; + try { pageFrame.className = ''; return { withFrame, withoutFrame: snapshot() }; } + finally { pageFrame.className = previous; } + }); + check(JSON.stringify(shielding.withFrame) === JSON.stringify(shielding.withoutFrame), story.name + ': StoryPage frame does not override Core shadow typography, colors, borders or dimensions', { elements: shielding.withFrame.length, changed: shielding.withFrame.filter((value, index) => JSON.stringify(value) !== JSON.stringify(shielding.withoutFrame[index])) }); + await settle(); + } + } + }); + await section('widths', async () => { + for (const story of stories) for (const width of [390, 768, 1280, 1440]) { + await gotoStory(story, width); + const geometry = await pageGeometry(); + measurements.push({ name: `${story.name} ${width}px`, geometry }); + check(geometry.document.scrollWidth <= geometry.document.clientWidth + 1 && geometry.document.scrollWidth <= width + 1, `${story.name} ${width}px: no document horizontal overflow`, geometry.document); + check(geometry.frame.title === story.name, `${story.name} ${width}px: direct URL renders requested page`, { url: page.url(), title: geometry.frame.title }); + if (geometry.fixed) { + const fixed = geometry.fixed; + const matrix = fixed.matrix === 'none' ? [1, 0, 0, 1] : fixed.matrix.slice(7, -1).split(',').map(Number); + const uniform = Math.abs(matrix[0] - matrix[3]) < 0.0001 && Math.abs(matrix[1]) < 0.0001 && Math.abs(matrix[2]) < 0.0001; + const bounds = fixed.stage.x >= fixed.wrapper.x - 1 && fixed.stage.y >= fixed.wrapper.y - 1 && fixed.stage.right <= fixed.wrapper.right + 1 && fixed.stage.bottom <= fixed.wrapper.bottom + 1; + const noScroll = fixedHasNoInternalScroll(fixed); + check(fixed.scale > 0 && fixed.scale <= 1 && uniform && Math.abs(fixed.stage.width / fixed.baseWidth - matrix[0]) < 0.001 && Math.abs(fixed.stage.height / fixed.baseHeight - matrix[3]) < 0.001 && bounds, `Full Composition ${width}px: uniform scale and entire stage fit content wrapper`, fixed); + check(noScroll, `Full Composition ${width}px: no internal horizontal or vertical scroll`, { note: 'The overflow-hidden composition retains unscaled transformed extents in CSSOM; complete visible stage bounds are checked separately.', scroll: fixed.scroll }); + check(fixed.visibleBounds.every(({ box }) => box.x >= fixed.stage.x - 1 && box.y >= fixed.stage.y - 1 && box.right <= fixed.stage.right + 1 && box.bottom <= fixed.stage.bottom + 1), `Full Composition ${width}px: cards and summaries remain inside complete stage`, fixed.visibleBounds); + } + if (width === 390) await capture(story.file + '-390'); + } + }); + await section('full-repair', async () => { + await page.setViewportSize({ width: 1440, height: 1100 }); + await page.goto(`${origin}/iframe.html?id=${patterns[0]}&viewMode=story`); + await page.locator('.sb-page-title').waitFor(); await settle(); + const reference = await commonStyle(); + await gotoStory(stories[2]); + const style = await commonStyle(); + const inspection = await page.evaluate(() => ['.sb-page', '.sb-page-inner'].map(selector => { + const element = document.querySelector(selector), css = getComputedStyle(element), rect = element.getBoundingClientRect(); + return { selector, display: css.display, marginLeft: css.marginLeft, marginRight: css.marginRight, marginInline: css.marginInline, rect: { x: rect.x, y: rect.y, width: rect.width, right: rect.right }, inline: element.getAttribute('style') }; + })); + check(JSON.stringify(style) === JSON.stringify(reference), 'Full Composition repair: common styles and actual centered gutters match existing Pattern', { style, inspection, reference }); + await capture('full-composition-1440'); + for (const width of [390, 768, 1280, 1440]) { + await gotoStory(stories[2], width); + const before = await pageGeometry(); + const wrapper = await page.locator('.hm-orgchart-composition').boundingBox(); + await page.mouse.move(wrapper.x + wrapper.width / 2, wrapper.y + Math.min(30, wrapper.height / 2)); + await page.mouse.wheel(80, 80); await settle(); + const after = await pageGeometry(); + const fixed = after.fixed; + const bounds = fixed.stage.x >= fixed.wrapper.x - 1 && fixed.stage.y >= fixed.wrapper.y - 1 && fixed.stage.right <= fixed.wrapper.right + 1 && fixed.stage.bottom <= fixed.wrapper.bottom + 1; + check(fixedHasNoInternalScroll(fixed) && bounds && fixed.visibleBounds.every(({ box }) => box.x >= fixed.stage.x - 1 && box.y >= fixed.stage.y - 1 && box.right <= fixed.stage.right + 1 && box.bottom <= fixed.stage.bottom + 1), `Full Composition repair ${width}px: no wheel-scroll area or clipped transformed contents`, { before: before.fixed.scroll, after: fixed.scroll, wrapper: fixed.wrapper, stage: fixed.stage, document: after.document }); + measurements.push({ name: `Full Composition repair ${width}px`, geometry: after }); + } + }); + await section('smoke', async () => { + await gotoStory(stories[3]); + for (const label of ['데이터 범위', '전체 직원 수', '조직도 표시 유형', '혼합형 시작 Depth', '특정 그룹 세로 배치', '초기 표시 Depth', '직원 정보 팝업']) check(await page.getByRole('combobox', { name: label, exact: true }).count() === 1, `Expanded: ${label} control preserved`); + for (const label of ['접기·펼치기 사용', 'Zoom 사용', 'Pan 사용']) check(await page.getByRole('checkbox', { name: label, exact: true }).isChecked(), `Expanded: independent ${label} control enabled`); + check(await page.getByRole('checkbox').count() === 10 && await page.getByRole('button', { name: '데이터 적용', exact: true }).count() === 1 && await page.getByRole('button', { name: '초기화', exact: true }).count() === 1, 'Expanded: seven company selections and apply/reset actions preserved'); + await select('전체 직원 수', '100명'); + check((await autoState()).employees === 20, 'Expanded: draft count waits for data apply'); + await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready(100); + check((await autoState()).employees === 100 && (await page.locator('[data-component="OrgChartExampleCounts"]').textContent()).includes('100'), 'Expanded: data apply renders 100 employee records'); + for (const [option, type] of [['세로형 TB', 'tb'], ['가로형 LR', 'lr'], ['혼합형 Hybrid', 'hybrid']]) { + await select('조직도 표시 유형', option); await ready(100); + const state = await autoState(); + check(state.type === type && state.employees === 100 && autoFits(state), `Expanded: ${type} switch preserves data and Fit`, state); + } + await select('초기 표시 Depth', '2Depth까지'); await ready(); + const depths = await page.locator('.node[data-depth]').evaluateAll(nodes => nodes.map(node => Number(node.dataset.depth))); + check(depths.every(depth => depth < 2), 'Expanded: initial Depth control still limits visible groups', depths); + await select('초기 표시 Depth', '전체 펼침'); await ready(100); + const beforeCollapse = await autoState(); + const toggle = page.locator('.hm-orgchart-expanded-toggle').first(); + const organizationId = await toggle.getAttribute('data-organization-id'); + await toggle.focus(); await toggle.press('Enter'); await ready(); + const collapsed = await autoState(); + check(await toggle.getAttribute('aria-expanded') === 'false' && collapsed.organizations < beforeCollapse.organizations && collapsed.employees < beforeCollapse.employees, 'Expanded: Enter collapses a group and its descendants', { organizationId, before: beforeCollapse, after: collapsed }); + await page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${organizationId}"]`).press('Space'); await ready(100); + check(await page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${organizationId}"]`).getAttribute('aria-expanded') === 'true', 'Expanded: Space expands the same group'); + await page.getByRole('checkbox', { name: 'Zoom 사용', exact: true }).uncheck(); await ready(); + check((await autoState()).zoomEnabled === 'false' && await page.getByRole('button', { name: '확대', exact: true }).isDisabled(), 'Expanded: independent Zoom switch disables only Zoom action'); + await page.getByRole('checkbox', { name: 'Zoom 사용', exact: true }).check(); await ready(); + await page.getByRole('button', { name: '100% 복원', exact: true }).click(); + const beforeZoom = await autoState(); + await page.getByRole('button', { name: '확대', exact: true }).click(); await settle(); + check((await autoState()).scale > beforeZoom.scale, 'Expanded: Zoom changes actual forest scale'); + await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); + const beforePan = await autoState(); + await page.locator('.hm-orgchart-viewport').scrollIntoViewIfNeeded(); + const viewport = await page.locator('.hm-orgchart-viewport').boundingBox(); + await page.mouse.move(viewport.x + 10, viewport.y + 10); await page.mouse.down(); await page.mouse.move(viewport.x + 50, viewport.y + 35); await page.mouse.up(); await settle(); + const afterPan = await autoState(); + check(afterPan.x !== beforePan.x && afterPan.y !== beforePan.y, 'Expanded: pointer Pan changes both forest coordinates', { before: beforePan, after: afterPan }); + await page.getByRole('checkbox', { name: 'Pan 사용', exact: true }).uncheck(); await ready(); + check((await autoState()).panEnabled === 'false' && (await autoState()).zoomEnabled === 'true', 'Expanded: independent Pan switch leaves Zoom enabled'); + await page.getByRole('checkbox', { name: 'Pan 사용', exact: true }).check(); await ready(); + await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); + check(autoFits(await autoState()), 'Expanded: toolbar Fit contains complete forest'); + const employee = page.locator('.hm-orgchart-employee').first(), employeeId = await employee.getAttribute('data-employee-id'); + await employee.focus(); await employee.press('Enter'); + const dialog = page.locator('[data-component="HMOrgChartEmployeeDialog"]'); + await dialog.waitFor({ state: 'visible' }); await settle(); + const popup = await dialog.evaluate(element => ({ role: element.getAttribute('role'), employeeId: element.dataset.employeeId, portal: element.parentElement === document.body, focusInside: element.contains(document.activeElement), text: element.textContent, box: (() => { const r = element.getBoundingClientRect(); return { x: r.x, right: r.right, width: r.width }; })() })); + check(popup.role === 'dialog' && popup.employeeId === employeeId && popup.portal && popup.focusInside && await page.getByRole('dialog').count() === 1 && await page.locator('dialog[open]').count() === 0, 'Expanded: actual single HM Dialog with exact employee and Portal focus', popup); + await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab'); + check(await dialog.evaluate(element => element.contains(document.activeElement)), 'Expanded: HM Dialog traps Tab focus'); + await capture('expanded-hm-dialog', false); + await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' }); await settle(); + const focusedEmployee = () => page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId); + check(await focusedEmployee() === employeeId, 'Expanded: ESC closes HM Dialog and returns employee focus'); + await employee.press('Space'); await dialog.waitFor({ state: 'visible' }); + await dialog.getByRole('button', { name: '닫기', exact: true }).click(); await dialog.waitFor({ state: 'hidden' }); await settle(); + check(await focusedEmployee() === employeeId, 'Expanded: HM close button returns employee focus'); + await page.setViewportSize({ width: 390, height: 844 }); await settle(); + await employee.focus(); await employee.press('Enter'); await dialog.waitFor({ state: 'visible' }); await settle(); + const narrow = await dialog.evaluate(element => { const r = element.getBoundingClientRect(); return { x: r.x, right: r.right, width: r.width, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, documentScrollWidth: document.documentElement.scrollWidth, innerWidth }; }); + check(narrow.x >= -1 && narrow.right <= 391 && narrow.scrollWidth <= narrow.clientWidth + 1 && narrow.documentScrollWidth <= 390, 'Expanded: actual HM Dialog fits 390px', narrow); + await capture('expanded-hm-dialog-390', false); + await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' }); + // Department Colors remains a live Core specimen with its original option. + await gotoStory(stories[1]); + const longNameButton = page.getByRole('button', { name: '긴 이름 검증 시작', exact: true }); + await longNameButton.click(); await ready(); + check(await page.getByRole('button', { name: '긴 이름 검증 종료', exact: true }).getAttribute('aria-pressed') === 'true' && await page.locator('.hm-orgchart-person-name').filter({ hasText: '아주긴가상이름표시검증사례' }).count() > 0, 'Department Colors: original long-name specimen option remains functional'); + }); +} finally { + check(errors.length === 0, 'No new browser page or console errors', errors); + const result = { startedAt, completedAt: new Date().toISOString(), origin, sections, browser: browser.version(), results, measurements, errors, pass: results.filter(item => item.status === 'PASS').length, fail: results.filter(item => item.status === 'FAIL').length, + limitations: ['Historical P7 units/full suites were not repeated.', 'Common computed styles prove runtime parity; design intent approval remains separate.', 'Document vertical scrolling caused by StoryPage text is reported separately from internal chart scrolling.', 'The smoke checks are a narrow regression sample, not full option-combination or accessibility certification.'] }; + const suffix = sections.length ? '-' + sections.join('-') : ''; + await fs.writeFile(path.join(output, `browser${suffix}.json`), JSON.stringify(result, null, 2)); + console.log(JSON.stringify({ pass: result.pass, fail: result.fail, errors: errors.length, output: `test-results/org-chart-p8/browser${suffix}.json` })); + await context.close(); await browser.close(); + if (result.fail) process.exitCode = 1; +} diff --git a/examples/org-chart/verify-p9-1.mjs b/examples/org-chart/verify-p9-1.mjs new file mode 100644 index 0000000..8ab6d8a --- /dev/null +++ b/examples/org-chart/verify-p9-1.mjs @@ -0,0 +1,280 @@ +// Focused P9.1 before/after evidence. Does not repeat P7-P9 or interact with Expanded. +import fs from 'node:fs/promises'; +import { writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-p9-1'); +const origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6061').replace(/\/$/, ''); +const baselineMode = process.argv.includes('--baseline'); +const resizeOnly = process.argv.includes('--resize-only'); +const sizes = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 390, height: 844 }]; +const stories = { department: 'orgchart--department-colors', fixed: 'orgchart--full-composition' }; +const publicStories = ['orgchart--logo-personnel-summary', ...Object.values(stories), 'orgchart--expanded-org-chart']; +const startedAt = new Date().toISOString(), results = [], snapshots = {}, errors = []; +await fs.mkdir(output, { recursive: true }); +const progressFile = path.join(output, resizeOnly ? 'browser-resize-progress.json' : baselineMode ? 'browser-baseline-progress.json' : 'browser-progress.json'); +const persist = () => writeFileSync(progressFile, JSON.stringify({ startedAt, origin, baselineMode, resizeOnly, results, snapshots, errors }, null, 2)); +const check = (pass, name, detail) => { + results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); + persist(); + if (!pass) console.log('FAIL', name, JSON.stringify(detail)); +}; +const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b); +const near = (a, b, tolerance = 0.15) => a && b && Object.keys(a).every(key => Math.abs(a[key] - b[key]) <= tolerance); +const contains = (outer, inner, tolerance = 1) => outer && inner && inner.x >= outer.x - tolerance && inner.y >= outer.y - tolerance && inner.right <= outer.right + tolerance && inner.bottom <= outer.bottom + tolerance; +const browser = await launchChromium({ headless: true }); +const context = await browser.newContext({ viewport: sizes[0] }); +context.on('page', target => { + target.on('pageerror', error => { errors.push({ type: 'pageerror', url: target.url(), message: error.message }); persist(); }); + target.on('console', message => { if (message.type() === 'error') { errors.push({ type: 'console', url: target.url(), message: message.text(), location: message.location() }); persist(); } }); +}); +const page = await context.newPage(); +page.setDefaultTimeout(20000); +async function settle() { + await page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 8; i++) await new Promise(resolve => requestAnimationFrame(resolve)); }); +} +async function goto(name, size) { + await page.setViewportSize(size); + await page.goto(`${origin}/iframe.html?id=${stories[name]}&viewMode=story`); + await page.locator('.sb-page-title').waitFor(); + await page.waitForFunction(() => { + const hosts = [...document.querySelectorAll('.hm-orgchart-host,.hm-orgchart-component-host')]; + return hosts.length === 1 && hosts.every(host => host.shadowRoot && !host.shadowRoot.querySelector('[aria-busy="true"]') && [...host.shadowRoot.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet)); + }); + // Force offscreen lazy placeholders to load in verification only. The same + // browser-only attribute is applied to both baseline and final DOM hashes. + await page.evaluate(async () => { + const shadow = document.querySelector('.hm-orgchart-host,.hm-orgchart-component-host').shadowRoot; + await Promise.all([...shadow.querySelectorAll('img')].map(async image => { image.loading = 'eager'; await image.decode().catch(() => {}); })); + }); + await page.waitForFunction(() => [...document.querySelector('.hm-orgchart-host,.hm-orgchart-component-host').shadowRoot.querySelectorAll('img')].every(image => image.complete && image.naturalWidth > 0)); + await settle(); +} +async function snapshot() { + return page.evaluate(async () => { + const host = document.querySelector('.hm-orgchart-host,.hm-orgchart-component-host'); + const shadow = host?.shadowRoot; + const all = selector => [...(shadow?.querySelectorAll(selector) ?? [])]; + const first = selector => shadow?.querySelector(selector); + const rect = element => { + if (!element) return null; + const r = element.getBoundingClientRect(); + return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; + }; + const union = elements => { + const boxes = elements.map(rect).filter(box => box && box.width > 0 && box.height > 0); + if (!boxes.length) return null; + const x = Math.min(...boxes.map(box => box.x)), y = Math.min(...boxes.map(box => box.y)); + const right = Math.max(...boxes.map(box => box.right)), bottom = Math.max(...boxes.map(box => box.bottom)); + return { x, y, width: right - x, height: bottom - y, right, bottom }; + }; + const styleKeys = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'color', 'backgroundColor', 'backgroundImage', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor', 'borderRadius', 'boxShadow', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'width', 'height', 'minWidth', 'minHeight', 'textAlign', 'whiteSpace', 'overflowWrap', 'verticalAlign', 'objectFit', 'objectPosition', 'gap', 'rowGap', 'columnGap']; + const style = element => element ? Object.fromEntries(styleKeys.map(key => [key, getComputedStyle(element)[key]])) : null; + const hash = async input => [...new Uint8Array(await crypto.subtle.digest('SHA-256', typeof input === 'string' ? new TextEncoder().encode(input) : input))].map(byte => byte.toString(16).padStart(2, '0')).join(''); + const assetHashes = new Map(); + const assets = async element => Promise.all([...element.querySelectorAll('img')].map(async image => { + const url = image.currentSrc || image.src; + if (!assetHashes.has(url)) assetHashes.set(url, fetch(url).then(response => { if (!response.ok) throw new Error(`Asset HTTP ${response.status}: ${url.slice(0, 120)}`); return response.arrayBuffer(); }).then(hash)); + return { hash: await assetHashes.get(url), naturalWidth: image.naturalWidth, naturalHeight: image.naturalHeight, loaded: image.complete && image.naturalWidth > 0 }; + })); + const visible = element => { const r = rect(element); return r?.width > 0 && r.height > 0 && getComputedStyle(element).display !== 'none'; }; + const scroll = element => { + if (!element) return null; + const css = getComputedStyle(element); + return { scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, scrollHeight: element.scrollHeight, clientHeight: element.clientHeight, left: element.scrollLeft, top: element.scrollTop, overflowX: css.overflowX, overflowY: css.overflowY }; + }; + const common = Object.fromEntries(['.sb-page', '.sb-page-inner', '.sb-page-title', '.sb-page-section-title', '.sb-page-section-header', '.sb-page-section-body'].map(selector => [selector, style(document.querySelector(selector))])); + // Page/section height is content-dependent; typography, borders and gutters are invariant. + for (const value of Object.values(common)) if (value) for (const key of ['height', 'minHeight']) delete value[key]; + const documentScroll = { viewportWidth: innerWidth, viewportHeight: innerHeight, root: scroll(document.documentElement), body: scroll(document.body), scrollingElement: scroll(document.scrollingElement) }; + const departmentRoot = first('.hm-orgchart-department-colors'); + const departments = departmentRoot ? await Promise.all([...departmentRoot.querySelectorAll('.hm-orgchart-color-preview')].map(async row => { + const heading = row.querySelector('.hm-orgchart-color-department-name'); + const preview = row.querySelector('.hm-orgchart-preview-grid'); + const cards = [...row.querySelectorAll('.hm-orgchart-employee')]; + const headingBox = rect(heading), cardBox = union(cards); + return { + name: heading?.textContent, box: rect(row), heading: headingBox, headingStyle: style(heading), preview: rect(preview), previewStyle: style(preview), + gap: headingBox && cardBox ? cardBox.y - headingBox.bottom : null, + visibleChildren: [...row.children].filter(visible).map(child => ({ tag: child.tagName, className: child.className })), + cards: await Promise.all(cards.map(async card => ({ + kind: card.dataset.cardKind, width: card.offsetWidth, height: card.offsetHeight, box: rect(card), hash: await hash(card.outerHTML), assets: await assets(card), + subtree: [card, ...card.querySelectorAll('*')].map(child => ({ tag: child.tagName, className: typeof child.className === 'string' ? child.className : child.className?.baseVal, style: style(child) })), + }))), + }; + })) : null; + const unwantedSelectors = '.hm-orgchart-color-legend,.hm-orgchart-contrast-note,.hm-orgchart-contrast-comparison,.hm-orgchart-card-caption,.hm-orgchart-representative-preview,.hm-orgchart-department-colors > p,.hm-orgchart-color-preview > p'; + const unwanted = departmentRoot ? [...departmentRoot.querySelectorAll(unwantedSelectors)].filter(visible).map(element => ({ className: element.className, text: element.textContent.slice(0, 150) })) : []; + const wrapper = first('.hm-orgchart-composition'), stage = first('.hm-orgchart-stage'); + let fixed = null; + if (stage) { + const stageBox = rect(stage), scale = stageBox.width / stage.offsetWidth; + const available = rect(document.querySelector('.hm-orgchart-story-full')); + const trees = all('.hm-orgchart-forest[data-layout="workdesk"] > .hm-orgchart-tree'); + // Measure rendered visible six-column content, never the full-width tree viewport. + const bodyElements = trees.flatMap(tree => [...tree.querySelectorAll('.hm-orgchart-organization-name,.hm-orgchart-employee')]); + const body = union(bodyElements); + const normalized = box => box ? { x: (box.x - stageBox.x) / scale, y: (box.y - stageBox.y) / scale, width: box.width / scale, height: box.height / scale } : null; + const selectors = '.hm-orgchart-employee,.hm-orgchart-organization-name,.hm-orgchart-organization-totals,.hm-orgchart-organization-totals-title,.hm-orgchart-workdesk-summary-frame,.hm-orgchart-workdesk-position-table,.hm-orgchart-workdesk-position-table th,.hm-orgchart-workdesk-position-table td,.hm-orgchart-slot-side,.hm-orgchart-slot-belowTree,.hm-orgchart-status-group,.hm-orgchart-summary-title,.hm-orgchart-slot-footer,.hm-orgchart-affiliation-bar,.hm-orgchart-affiliation-bar-item,.hm-orgchart-axis,.hm-orgchart-top-leaders,.hm-orgchart-hub,.hm-orgchart-workdesk-overlay,.hm-orgchart-workdesk-decoration'; + const elements = [...stage.querySelectorAll(selectors)]; + const invariants = await Promise.all(elements.map(async (element, index) => ({ + key: `${element.tagName}:${typeof element.className === 'string' ? element.className : element.className.baseVal}:${element.dataset.employeeId ?? element.dataset.organizationId ?? element.dataset.contentSlot ?? element.dataset.workdeskDecoration ?? ''}:${index}`, + box: normalized(rect(element)), style: style(element), htmlHash: await hash(element.outerHTML), + }))); + const collisionElements = [...stage.querySelectorAll('.hm-orgchart-employee,.hm-orgchart-organization-name,.hm-orgchart-organization-totals,.hm-orgchart-affiliation-bar,.hm-orgchart-status-group > .hm-orgchart-summary-title')].filter(visible); + const collisions = []; + for (let i = 0; i < collisionElements.length; i++) for (let j = i + 1; j < collisionElements.length; j++) { + const a = collisionElements[i], b = collisionElements[j]; + if (a.contains(b) || b.contains(a)) continue; + const ra = normalized(rect(a)), rb = normalized(rect(b)); + const width = Math.min(ra.x + ra.width, rb.x + rb.width) - Math.max(ra.x, rb.x); + const height = Math.min(ra.y + ra.height, rb.y + rb.height) - Math.max(ra.y, rb.y); + if (width > 0.5 && height > 0.5) collisions.push({ pair: `${i}:${j}`, a: a.className, b: b.className, width, height }); + } + const matrix = getComputedStyle(stage).transform.match(/matrix\(([^)]+)\)/)?.[1].split(',').map(Number); + fixed = { + scale, verticalScale: stageBox.height / parseFloat(getComputedStyle(stage).height), matrix, stage: stageBox, available, wrapper: rect(wrapper), host: rect(host), baseWidth: stage.offsetWidth, baseHeight: stage.offsetHeight, computedBaseWidth: parseFloat(getComputedStyle(stage).width), computedBaseHeight: parseFloat(getComputedStyle(stage).height), + treeCount: trees.length, body, bodyStage: normalized(body), bodyElementCount: bodyElements.length, bodyCenter: body?.x + body?.width / 2, availableCenter: available?.x + available?.width / 2, + centerDelta: body && available ? body.x + body.width / 2 - available.x - available.width / 2 : null, + htmlHash: await hash(stage.innerHTML), invariants, assets: await assets(stage), collisions, + scroll: ['.hm-orgchart-shell', '.hm-orgchart-content', '.hm-orgchart-composition', '.hm-orgchart-viewport'].map(selector => ({ selector, ...scroll(first(selector)) })), + allVisibleContained: bodyElements.every(element => { const b = rect(element); return b.x >= stageBox.x - 1 && b.y >= stageBox.y - 1 && b.right <= stageBox.right + 1 && b.bottom <= stageBox.bottom + 1; }), + }; + } + return { + title: document.querySelector('.sb-page-title')?.textContent, common, documentScroll, departments, unwanted, + optionsPresent: [...document.querySelectorAll('button')].filter(visible).some(button => /긴 이름 검증/.test(button.textContent)), + sectionTitles: [...document.querySelectorAll('.sb-page-section-title')].map(element => element.textContent), fixed, + }; + }); +} +function documentFits(state, bothAxes) { + const d = state.documentScroll; + return [d.root, d.body, d.scrollingElement].filter(Boolean).every(value => value.scrollWidth <= d.viewportWidth + 1 && (!bothAxes || value.scrollHeight <= d.viewportHeight + 1)); +} +function preserveDepartment(before, after) { + return before.departments?.length === after.departments?.length && after.departments.every((row, index) => { + const old = before.departments[index]; + return row.name === old.name && equal(row.headingStyle, old.headingStyle) && row.cards.length === old.cards.length && row.cards.every((card, i) => equal({ kind: card.kind, width: card.width, height: card.height, hash: card.hash, assets: card.assets, subtree: card.subtree }, { kind: old.cards[i].kind, width: old.cards[i].width, height: old.cards[i].height, hash: old.cards[i].hash, assets: old.cards[i].assets, subtree: old.cards[i].subtree })); + }); +} +function preserveFixed(before, after) { + return before.htmlHash === after.htmlHash && equal(before.assets, after.assets) && before.invariants.length === after.invariants.length && after.invariants.every((item, index) => { + const old = before.invariants[index]; + return old.key === item.key && old.htmlHash === item.htmlHash && equal(old.style, item.style) && near(old.box, item.box); + }); +} + +let fatal; +try { + const baseline = baselineMode ? null : JSON.parse(await fs.readFile(path.join(output, 'visual-baseline.json'), 'utf8')); + if (resizeOnly) { + // Mount once; every following measurement reuses this exact document and + // proves ResizeObserver handles both viewport axes without a page reload. + await goto('fixed', sizes[1]); + snapshots.fixed = {}; + for (const size of [sizes[0], sizes[2], { width: 1440, height: 640 }, sizes[1]]) { + await page.setViewportSize(size); + await page.waitForFunction(() => { + const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; + const available = document.querySelector('.hm-orgchart-story-full')?.getBoundingClientRect(); + const elements = [...(shadow?.querySelectorAll('.hm-orgchart-forest[data-layout="workdesk"] > .hm-orgchart-tree .hm-orgchart-organization-name,.hm-orgchart-forest[data-layout="workdesk"] > .hm-orgchart-tree .hm-orgchart-employee') ?? [])]; + if (!available || !elements.length) return false; + const boxes = elements.map(element => element.getBoundingClientRect()); + const left = Math.min(...boxes.map(box => box.left)), right = Math.max(...boxes.map(box => box.right)); + return Math.abs((left + right) / 2 - available.x - available.width / 2) <= 1; + }); + await settle(); + const state = await snapshot(), fixed = state.fixed, key = `${size.width}x${size.height}`; + const before = baseline.snapshots.fixed[size.width].fixed; + const stability = await page.evaluate(async () => { + const stage = document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-stage'); + let styleMutations = 0; + const observer = new MutationObserver(records => { styleMutations += records.length; }); + observer.observe(stage, { attributes: true, attributeFilter: ['style'] }); + const signatures = [stage.getAttribute('style')]; + for (let frame = 0; frame < 10; frame++) { + await new Promise(resolve => requestAnimationFrame(resolve)); + signatures.push(stage.getAttribute('style')); + } + styleMutations += observer.takeRecords().length; + observer.disconnect(); + return { frames: 10, styleMutations, stable: signatures.every(signature => signature === signatures[0]), signatures: [...new Set(signatures)] }; + }); + snapshots.fixed[key] = { ...state, stability }; + check(fixed.treeCount === 6 && Math.abs(fixed.centerDelta) <= 1 && contains(fixed.available, fixed.stage) && contains(fixed.host, fixed.stage) && fixed.allVisibleContained && Math.abs(fixed.scale - fixed.verticalScale) <= 0.0001, + `Resize ${key}: six-group body stays centered and full stage fits with uniform scale`, { bodyCenter: fixed.bodyCenter, availableCenter: fixed.availableCenter, delta: fixed.centerDelta, scale: fixed.scale, verticalScale: fixed.verticalScale, stage: fixed.stage, available: fixed.available }); + const offsets = await page.evaluate(() => ({ x: scrollX, y: scrollY })); + check(documentFits(state, true) && offsets.x === 0 && offsets.y === 0 && fixed.scroll.every(item => item.left === undefined || (item.left === 0 && item.top === 0)), + `Resize ${key}: no document overflow or document/internal scroll offsets`, { document: state.documentScroll, offsets, internal: fixed.scroll }); + check(preserveFixed(before, fixed), `Resize ${key}: cards, connectors and statistics keep baseline stage-relative geometry and styles`, { beforeHTMLHash: before.htmlHash, afterHTMLHash: fixed.htmlHash, elementCount: fixed.invariants.length }); + check(stability.stable && stability.styleMutations === 0, `Resize ${key}: stage style stops mutating over 10 animation frames`, stability); + await page.screenshot({ path: path.join(output, `after-resize-fixed-${key}.png`), fullPage: true }); + } + } else { + for (const name of Object.keys(stories)) { + snapshots[name] = {}; + for (const size of sizes) { + await goto(name, size); + const state = await snapshot(); + snapshots[name][size.width] = state; + await page.screenshot({ path: path.join(output, `${baselineMode ? 'before' : 'after'}-${name}-${size.width}.png`), fullPage: true }); + if (baselineMode) { + check(name === 'department' ? state.departments?.length === 6 : state.fixed?.treeCount === 6, `Baseline ${name} ${size.width}: six groups and actual browser geometry captured`); + continue; + } + const before = baseline.snapshots[name][size.width]; + check(state.title === before.title && equal(state.common, before.common), `${name} ${size.width}: common StoryPage title, typography and gutters preserved`); + if (name === 'department') { + check(state.departments?.length === 6 && state.departments.every(row => row.name && row.cards.length === 2 && row.cards.some(card => card.kind === 'leader') && row.cards.some(card => card.kind === 'employee')), `Department ${size.width}: six titles, 1Depth large leaders and 2Depth small employee cards`, state.departments?.map(row => ({ name: row.name, cards: row.cards.map(card => ({ kind: card.kind, width: card.width, height: card.height })) }))); + check(state.unwanted.length === 0 && !state.optionsPresent && !state.sectionTitles.includes('표시 옵션') && state.departments.every(row => row.visibleChildren.length === 2), `Department ${size.width}: descriptions, provenance, legends, contrast and captions removed`, { unwanted: state.unwanted, optionsPresent: state.optionsPresent, sections: state.sectionTitles, children: state.departments.map(row => row.visibleChildren) }); + check(preserveDepartment(before, state), `Department ${size.width}: exact existing header colors/gradient, card DOM, assets, typography and computed styles preserved`); + check(state.departments.every(row => row.gap >= 1 && row.cards.every(card => contains(row.box, card.box)) && contains(row.box, row.heading)), `Department ${size.width}: header/card separation and complete card containment`, state.departments.map(row => ({ name: row.name, beforeGap: before.departments.find(old => old.name === row.name)?.gap, afterGap: row.gap }))); + check(state.departments.every((row, i, rows) => rows.every((other, j) => i === j || Math.abs(row.box.y - other.box.y) > 1 || Math.abs(row.preview.y - other.preview.y) <= 1)), `Department ${size.width}: card starts align across each shared desktop row`); + check(documentFits(state, false), `Department ${size.width}: no document horizontal overflow`, state.documentScroll); + } else { + const fixed = state.fixed, old = before.fixed; + check(fixed.treeCount === 6 && Math.abs(fixed.centerDelta) <= 1, `Full Composition ${size.width}: actual six-group body centered in available Storybook region`, { before: { bodyCenter: old.bodyCenter, availableCenter: old.availableCenter, delta: old.centerDelta, bounds: old.body }, after: { bodyCenter: fixed.bodyCenter, availableCenter: fixed.availableCenter, delta: fixed.centerDelta, bounds: fixed.body } }); + check(fixed.baseWidth === 1920 && fixed.baseHeight === 915 && Math.abs(fixed.computedBaseHeight - 914.5) <= 0.02 && fixed.scale > 0 && fixed.scale <= 1 && Math.abs(fixed.scale - fixed.verticalScale) <= 0.0001 && fixed.matrix && Math.abs(fixed.matrix[0] - fixed.matrix[3]) <= 0.0001 && contains(fixed.available, fixed.stage) && contains(fixed.host, fixed.stage) && fixed.allVisibleContained, `Full Composition ${size.width}: original 1920 x 914.5 canvas fully fits with uniform scale`, { scale: fixed.scale, verticalScale: fixed.verticalScale, matrix: fixed.matrix, stage: fixed.stage, available: fixed.available, host: fixed.host, naturalSize: [fixed.baseWidth, fixed.baseHeight], computedBaseHeight: fixed.computedBaseHeight }); + check(preserveFixed(old, fixed), `Full Composition ${size.width}: all stage-relative cards, headers, axis, connectors, decorations, side/status/footer geometry and styles preserved`, { beforeHTMLHash: old.htmlHash, afterHTMLHash: fixed.htmlHash, elementCount: fixed.invariants.length, assetCount: fixed.assets.length }); + const newCollisions = fixed.collisions.filter(collision => !old.collisions.some(previous => previous.pair === collision.pair)); + check(newCollisions.length === 0, `Full Composition ${size.width}: no new card/header/statistics overlaps`, { baselineOverlapCount: old.collisions.length, afterOverlapCount: fixed.collisions.length, newCollisions }); + // Transformed fixed-size child layout can retain an unscaled scrollWidth + // in the existing hidden fit shell. Full painted-stage containment is + // separately mandatory; only unchanged hidden-shell overflow is exempt. + const internalFits = fixed.scroll.every((item, index) => item.scrollWidth === undefined || (item.left === 0 && item.top === 0 + && (item.scrollWidth <= item.clientWidth + 1 || (item.overflowX === 'hidden' && old.scroll[index].overflowX === 'hidden')) + && (item.scrollHeight <= item.clientHeight + 1 || (item.overflowY === 'hidden' && old.scroll[index].overflowY === 'hidden')))); + check(documentFits(state, true) && internalFits, `Full Composition ${size.width}: no document or internal horizontal/vertical scroll`, { document: state.documentScroll, internal: fixed.scroll }); + const wheelBefore = await page.evaluate(() => ({ x: scrollX, y: scrollY })); + await page.mouse.move(fixed.available.x + fixed.available.width / 2, fixed.available.y + Math.min(40, fixed.available.height / 2)); + await page.mouse.wheel(120, 120); + await settle(); + const wheelAfter = await page.evaluate(() => ({ x: scrollX, y: scrollY })); + check(equal(wheelBefore, wheelAfter), `Full Composition ${size.width}: wheel does not scroll document`, { before: wheelBefore, after: wheelAfter }); + } + } + } + if (!baselineMode) { + const response = await page.request.get(`${origin}/index.json`), index = await response.json(); + const entries = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title.startsWith('OrgChart/') && !(entry.tags ?? []).includes('internal')); + check(entries.length === 4 && equal(entries.map(entry => entry.id).sort(), publicStories.sort()), 'Storybook: all four public OrgChart menu entries retained', entries.map(entry => ({ id: entry.id, title: entry.title, name: entry.name }))); + } + } +} catch (error) { + fatal = { message: error.message, stack: error.stack }; + check(false, 'Focused P9.1 runner completed', fatal); +} finally { + check(errors.length === 0, 'No browser console or page errors', errors); + const result = { startedAt, completedAt: new Date().toISOString(), origin, browser: browser.version(), baselineMode, resizeOnly, sizes, results, snapshots, errors, fatal, pass: results.filter(result => result.status === 'PASS').length, fail: results.filter(result => result.status === 'FAIL').length, + limitations: ['Only the two P9.1 pages and public Storybook index checked; Expanded interactions and historical P7-P9 suites were not repeated.', 'Browser rendering and computed style preservation do not replace user design approval.', 'Other browsers, real devices, screen readers and production deployment remain NOT VERIFIED.'] }; + const resultName = resizeOnly ? 'browser-resize.json' : baselineMode ? 'visual-baseline.json' : 'browser.json'; + await fs.writeFile(path.join(output, resultName), JSON.stringify(result, null, 2)); + console.log(JSON.stringify({ pass: result.pass, fail: result.fail, errors: errors.length, output: `test-results/org-chart-p9-1/${resultName}`, centers: Object.entries(snapshots.fixed ?? {}).map(([viewport, state]) => ({ viewport, bodyCenter: state.fixed?.bodyCenter, availableCenter: state.fixed?.availableCenter, delta: state.fixed?.centerDelta })) })); + await context.close(); + await browser.close(); + if (result.fail) process.exitCode = 1; +} diff --git a/examples/org-chart/verify-p9-2-composition.mjs b/examples/org-chart/verify-p9-2-composition.mjs new file mode 100644 index 0000000..62cc3b8 --- /dev/null +++ b/examples/org-chart/verify-p9-2-composition.mjs @@ -0,0 +1,150 @@ +// P9.2 only: measure the actual visible Content Wrapper and the entire scaled Stage. +// --baseline captures P9.1 without asserting its centering; the final run compares invariants. +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-p9-2'); +const origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6062').replace(/\/$/, ''); +const baselineMode = process.argv.includes('--baseline'); +const prefix = baselineMode ? 'before' : 'after'; +const sizes = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 390, height: 844 }]; +const startedAt = new Date().toISOString(), results = [], snapshots = {}, errors = []; +await fs.mkdir(output, { recursive: true }); +const browser = await launchChromium({ headless: true }); +const page = await browser.newPage({ viewport: sizes[0] }); +page.on('pageerror', error => errors.push({ type: 'pageerror', message: error.message })); +page.on('console', message => { if (message.type() === 'error') errors.push({ type: 'console', message: message.text() }); }); +const check = (pass, name, detail) => { + results.push({ name, status: pass ? 'PASS' : 'FAIL', detail }); + if (!pass) console.log('FAIL', name, JSON.stringify(detail)); +}; +const contains = (a, b, tolerance = 1) => b.left >= a.left - tolerance && b.right <= a.right + tolerance && b.top >= a.top - tolerance && b.bottom <= a.bottom + tolerance; +const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); +const near = (a, b) => Object.keys(a).every(key => Math.abs(a[key] - b[key]) <= .15); +const preserved = (a, b) => a && a.htmlHash === b.htmlHash && same(a.assets, b.assets) && a.elements.length === b.elements.length && b.elements.every((item, i) => { + const old = a.elements[i]; return old.key === item.key && old.hash === item.hash && same(old.style, item.style) && near(old.box, item.box); +}); +async function settle() { + await page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 12; i++) await new Promise(resolve => requestAnimationFrame(resolve)); }); +} +async function open(size) { + await page.setViewportSize(size); + await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`); + await page.locator('.sb-page-title').waitFor(); + await page.waitForFunction(() => { + const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; + return shadow?.querySelector('.hm-orgchart-stage[data-workdesk-artwork]') && !shadow.querySelector('[aria-busy="true"]') && [...shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet); + }); + await page.evaluate(async () => { + await Promise.all([...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('img')].map(async image => { image.loading = 'eager'; await image.decode(); })); + }); + await settle(); +} +async function snapshot() { + return page.evaluate(async () => { + const wrapper = document.querySelector('.hm-orgchart-story-full'); + const shadowHost = wrapper.querySelector('.hm-orgchart-host'); + const shadow = shadowHost.shadowRoot; + const scaleWrapper = shadow.querySelector('.hm-orgchart-composition'); + const stage = shadow.querySelector('.hm-orgchart-stage[data-workdesk-artwork]'); + const rect = element => { const r = element.getBoundingClientRect(); return Object.fromEntries(['left', 'right', 'top', 'bottom', 'width', 'height'].map(key => [key, r[key]])); }; + const css = getComputedStyle(wrapper), wb = rect(wrapper), sb = rect(stage), sc = getComputedStyle(stage); + const inset = Object.fromEntries(['paddingLeft', 'paddingRight', 'paddingTop', 'paddingBottom', 'borderLeftWidth', 'borderRightWidth', 'borderTopWidth', 'borderBottomWidth'].map(key => [key, parseFloat(css[key]) || 0])); + const content = { + left: wb.left + inset.borderLeftWidth + inset.paddingLeft, + right: wb.right - inset.borderRightWidth - inset.paddingRight, + top: wb.top + inset.borderTopWidth + inset.paddingTop, + bottom: wb.bottom - inset.borderBottomWidth - inset.paddingBottom, + }; + content.width = content.right - content.left; content.height = content.bottom - content.top; + const scale = sb.width / stage.offsetWidth; + const normalized = box => ({ x: (box.left - sb.left) / scale, y: (box.top - sb.top) / scale, width: box.width / scale, height: box.height / scale }); + const hash = async value => [...new Uint8Array(await crypto.subtle.digest('SHA-256', typeof value === 'string' ? new TextEncoder().encode(value) : value))].map(byte => byte.toString(16).padStart(2, '0')).join(''); + const selectors = '.hm-orgchart-employee,.hm-orgchart-organization-name,.hm-orgchart-organization-totals,.hm-orgchart-organization-totals-title,.hm-orgchart-workdesk-summary-frame,.hm-orgchart-workdesk-position-table,.hm-orgchart-workdesk-position-table th,.hm-orgchart-workdesk-position-table td,.hm-orgchart-slot-side,.hm-orgchart-slot-belowTree,.hm-orgchart-status-group,.hm-orgchart-summary-title,.hm-orgchart-slot-footer,.hm-orgchart-affiliation-bar,.hm-orgchart-affiliation-bar-item,.hm-orgchart-axis,.hm-orgchart-top-leaders,.hm-orgchart-hub,.hm-orgchart-workdesk-overlay,.hm-orgchart-workdesk-decoration'; + const styleKeys = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'color', 'backgroundColor', 'backgroundImage', 'border', 'borderRadius', 'boxShadow', 'padding', 'margin', 'width', 'height', 'gap', 'objectFit']; + const elements = await Promise.all([...stage.querySelectorAll(selectors)].map(async (element, index) => ({ + key: `${element.tagName}:${typeof element.className === 'string' ? element.className : element.className.baseVal}:${element.dataset.employeeId ?? element.dataset.organizationId ?? element.dataset.contentSlot ?? element.dataset.workdeskDecoration ?? ''}:${index}`, + box: normalized(rect(element)), hash: await hash(element.outerHTML), + style: Object.fromEntries(styleKeys.map(key => [key, getComputedStyle(element)[key]])), + }))); + const assetHashes = new Map(); + const assets = await Promise.all([...stage.querySelectorAll('img')].map(async image => { + const url = image.currentSrc || image.src; + if (!assetHashes.has(url)) assetHashes.set(url, fetch(url).then(r => r.arrayBuffer()).then(hash)); + return { hash: await assetHashes.get(url), width: image.naturalWidth, height: image.naturalHeight, loaded: image.complete && image.naturalWidth > 0 }; + })); + const visible = element => { const r = rect(element); return r.width > 0 && r.height > 0 && getComputedStyle(element).display !== 'none'; }; + const painted = [...stage.querySelectorAll(selectors)].filter(visible).map(element => ({ key: typeof element.className === 'string' ? element.className : element.className.baseVal, bounds: rect(element) })); + const collisionElements = [...stage.querySelectorAll('.hm-orgchart-employee,.hm-orgchart-organization-name,.hm-orgchart-organization-totals,.hm-orgchart-affiliation-bar,.hm-orgchart-status-group > .hm-orgchart-summary-title')].filter(visible); + const collisions = []; + for (let i = 0; i < collisionElements.length; i++) for (let j = i + 1; j < collisionElements.length; j++) { + const a = collisionElements[i], b = collisionElements[j]; if (a.contains(b) || b.contains(a)) continue; + const ra = normalized(rect(a)), rb = normalized(rect(b)); + const width = Math.min(ra.x + ra.width, rb.x + rb.width) - Math.max(ra.x, rb.x); + const height = Math.min(ra.y + ra.height, rb.y + rb.height) - Math.max(ra.y, rb.y); + if (width > .5 && height > .5) collisions.push({ pair: `${i}:${j}`, a: a.className, b: b.className, width, height }); + } + const scroll = element => { const cs = getComputedStyle(element); return { scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, scrollHeight: element.scrollHeight, clientHeight: element.clientHeight, overflowX: cs.overflowX, overflowY: cs.overflowY }; }; + return { + wrapper: { selector: '.hm-orgchart-story-full', tag: wrapper.tagName, ariaLabel: wrapper.getAttribute('aria-label'), background: css.backgroundColor, bounds: wb, inset, content }, + sectionBody: rect(wrapper.closest('.sb-page-section-body')), shadowHost: rect(shadowHost), scaleWrapper: rect(scaleWrapper), + stage: sb, contentCenter: (content.left + content.right) / 2, stageCenter: (sb.left + sb.right) / 2, + centerDifference: (sb.left + sb.right - content.left - content.right) / 2, + viewportCenter: innerWidth / 2, baseWidth: parseFloat(sc.width), baseHeight: parseFloat(sc.height), scale, verticalScale: sb.height / parseFloat(sc.height), + coreTransform: sc.transform, additionalScale: sc.scale, translate: sc.translate, htmlHash: await hash(stage.innerHTML), elements, assets, painted, collisions, + counts: { trees: stage.querySelectorAll('.hm-orgchart-forest[data-layout="workdesk"] > .hm-orgchart-tree').length, cards: stage.querySelectorAll('.hm-orgchart-employee').length, overlays: stage.querySelectorAll('.hm-orgchart-workdesk-overlay').length, paths: stage.querySelectorAll('.hm-orgchart-workdesk-overlay path').length, side: stage.querySelectorAll('.hm-orgchart-slot-side').length, status: stage.querySelectorAll('.hm-orgchart-slot-belowTree').length, footer: stage.querySelectorAll('.hm-orgchart-slot-footer').length }, + document: { width: innerWidth, height: innerHeight, root: scroll(document.documentElement), body: scroll(document.body), scrolling: scroll(document.scrollingElement) }, + scroll: [wrapper, shadowHost, ...['.hm-orgchart-shell', '.hm-orgchart-content', '.hm-orgchart-composition', '.hm-orgchart-viewport'].map(selector => shadow.querySelector(selector)).filter(Boolean)].map(element => ({ selector: element.className, ...scroll(element) })), + }; + }); +} +async function stability() { + return page.evaluate(async () => { + const stage = document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-stage'); + let changes = 0; const observer = new MutationObserver(records => { changes += records.length; }); + observer.observe(stage, { attributes: true, attributeFilter: ['style'] }); + for (let i = 0; i < 12; i++) await new Promise(resolve => requestAnimationFrame(resolve)); + observer.disconnect(); return { styleMutations: changes }; + }); +} +const baseline = baselineMode ? null : JSON.parse(await fs.readFile(path.join(output, 'before-composition.json'), 'utf8')); +async function record(key) { + const state = await snapshot(); snapshots[key] = state; + await page.screenshot({ path: path.join(output, `${prefix}-full-composition-${key}.png`), fullPage: true }); + if (baselineMode) return; + check(Math.abs(state.centerDifference) <= 1, `${key}: whole Stage centered in visible wrapper content box`, { wrapper: state.wrapper, stage: state.stage, contentCenter: state.contentCenter, stageCenter: state.stageCenter, centerDifference: state.centerDifference, viewportCenter: state.viewportCenter }); + check(state.baseWidth === 1920 && state.baseHeight === 914.5 && state.scale > 0 && state.scale <= 1 && Math.abs(state.scale - state.verticalScale) <= .0001 && contains(state.wrapper.content, state.stage), `${key}: original canvas fully contained with uniform proportional fit`, { baseWidth: state.baseWidth, baseHeight: state.baseHeight, scale: state.scale, verticalScale: state.verticalScale, stage: state.stage, content: state.wrapper.content }); + const outside = state.painted.filter(item => !contains(state.stage, item.bounds) || !contains(state.wrapper.content, item.bounds)); + check(outside.length === 0 && state.collisions.length === 0, `${key}: painted cards, lines and statistics remain visible without collisions`, { outside, collisions: state.collisions, counts: state.counts }); + check(preserved(baseline.snapshots[key], state), `${key}: P9.1 inner DOM, assets, styles and normalized geometry preserved`, { beforeHash: baseline.snapshots[key]?.htmlHash, afterHash: state.htmlHash, elements: state.elements.length, assets: state.assets.length }); + const d = state.document; + const documentFits = [d.root, d.body, d.scrolling].every(item => item.scrollWidth <= d.width + 1 && item.scrollHeight <= d.height + 1); + const scrollable = state.scroll.filter(item => ['auto', 'scroll'].includes(item.overflowX) && item.scrollWidth > item.clientWidth + 1 || ['auto', 'scroll'].includes(item.overflowY) && item.scrollHeight > item.clientHeight + 1); + check(documentFits && scrollable.length === 0, `${key}: no document or interactive chart scrollbars`, { document: d, scrollable, scroll: state.scroll }); + check((await stability()).styleMutations === 0, `${key}: alignment settles without observer feedback loop`); +} +let fatal; +try { + for (const size of sizes) { await open(size); await record(String(size.width)); } + await open(sizes[1]); + await page.evaluate(() => { + const wrapper = document.querySelector('.hm-orgchart-story-full'); + // Perturb the actual visible wrapper, not a proxy box; keep standard document flow. + Object.assign(wrapper.style, { boxSizing: 'border-box', width: 'calc(100% - 80px)', marginLeft: '23px', padding: '17px 53px 19px 11px', borderStyle: 'solid', borderColor: '#789', borderWidth: '2px 7px 5px 3px' }); + }); + await settle(); await record('asymmetric-1440'); + await page.evaluate(() => { document.querySelector('.hm-orgchart-story-full').style.padding = '17px 19px 19px 45px'; }); + await settle(); await record('padding-shift-1440'); + await page.setViewportSize({ width: 1010, height: 840 }); await settle(); await record('asymmetric-resize-1010'); + await page.setViewportSize(sizes[2]); await settle(); await record('asymmetric-resize-390'); + if (!baselineMode) check(errors.length === 0, 'No new Full Composition console or page errors', errors); +} catch (error) { fatal = error.stack; check(false, 'Runner completed', fatal); } +finally { + const result = { startedAt, completedAt: new Date().toISOString(), origin, browser: browser.version(), baselineMode, sizes, results, snapshots, errors, fatal, pass: results.filter(item => item.status === 'PASS').length, fail: results.filter(item => item.status === 'FAIL').length }; + await fs.writeFile(path.join(output, baselineMode ? 'before-composition.json' : 'browser-composition.json'), JSON.stringify(result, null, 2)); + await browser.close(); console.log(JSON.stringify({ baselineMode, pass: result.pass, fail: result.fail, output })); + if (result.fail) process.exitCode = 1; +} diff --git a/examples/org-chart/verify-p9-2.mjs b/examples/org-chart/verify-p9-2.mjs new file mode 100644 index 0000000..3a278c6 --- /dev/null +++ b/examples/org-chart/verify-p9-2.mjs @@ -0,0 +1,183 @@ +// P9.2 Expanded selection integration only. Historical suites are preserved. +import fs from 'node:fs/promises'; +import { writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +import { companyColorPresets, createOrgChartExampleDataset } from '../../packages/org-chart/dist/index.js'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-p9-2'); +const origin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6062'; +const only = process.argv.slice(2), run = name => !only.length || only.includes(name); +const suffix = only.length ? '-' + only.join('-') : ''; +const template = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-expanded-large.json'), 'utf8')); +const companies = Object.values(companyColorPresets), ids = companies.map(company => company.companyId); +const results = [], measurements = [], errors = [], startedAt = new Date().toISOString(); +await fs.mkdir(output, { recursive: true }); +const persist = () => writeFileSync(path.join(output, `expanded-progress${suffix}.json`), JSON.stringify({ startedAt, results, measurements, errors }, null, 2)); +const check = (pass, name, detail) => { results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); persist(); if (!pass) console.log('FAIL', name, JSON.stringify(detail)); }; +const browser = await launchChromium({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } }); +page.setDefaultTimeout(15000); +page.on('pageerror', error => { errors.push({ type: 'pageerror', message: error.message }); persist(); }); +page.on('console', message => { if (message.type() === 'error') { errors.push({ type: 'console', message: message.text(), location: message.location() }); persist(); } }); +const trigger = () => page.getByRole('button', { name: /^회사 선택/ }); +const panel = () => page.getByRole('dialog', { name: '회사 선택', exact: true }); +const checkbox = name => panel().getByRole('checkbox', { name, exact: true }); +const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 8; i++) await new Promise(requestAnimationFrame); }); +async function ready(count) { + await page.waitForFunction(count => { + const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; + return shadow && !shadow.querySelector('[aria-busy="true"]') && [...shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet) + && (count === undefined || shadow.querySelectorAll('.hm-orgchart-employee').length === count); + }, count); + await settle(); +} +async function goto(size = { width: 1440, height: 1100 }) { + await page.setViewportSize(size); + await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); + await trigger().waitFor(); await ready(20); +} +async function open() { await trigger().click(); await panel().waitFor(); await settle(); } +async function close() { await page.keyboard.press('Escape'); await panel().waitFor({ state: 'hidden' }); await settle(); } +async function select(label, option) { await page.getByRole('combobox', { name: label, exact: true }).click(); await page.getByRole('option', { name: option, exact: true }).click(); await ready(); } +async function apply(count) { await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready(count); } +const expectedTotal = (count, selected) => selected.length ? createOrgChartExampleDataset(template, { scope: 'total', employeeCount: count, companyIds: selected }).employees.length : 0; +async function state() { + return page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot, expanded = shadow.querySelector('.hm-orgchart-expanded'); + const viewport = shadow.querySelector('.hm-orgchart-viewport'), forest = shadow.querySelector('.hm-orgchart-forest'); + const rect = element => { if (!element) return null; const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; }; + return { countText: document.querySelector('[data-component="OrgChartExampleCounts"]').textContent.trim(), employees: shadow.querySelectorAll('.hm-orgchart-employee').length, + roots: [...shadow.querySelectorAll('.hm-orgchart-company-tree')].map(element => element.dataset.companyId), type: expanded?.dataset.autoLayout, + empty: forest?.children.length === 0, scale: Number(viewport?.dataset.scale), panX: Number(viewport?.dataset.panX), panY: Number(viewport?.dataset.panY), viewport: rect(viewport), forest: rect(forest), + documentWidth: document.documentElement.scrollWidth, width: innerWidth, + cards: [...shadow.querySelectorAll('.hm-orgchart-employee')].map(element => ({ id: element.dataset.employeeId, companyId: element.dataset.companyId, direction: element.dataset.connectionDirection })), + titles: [...shadow.querySelectorAll('.node[data-company-id] .hm-orgchart-organization-name')].map(element => ({ companyId: element.closest('.node').dataset.companyId, color: getComputedStyle(element).color })), + autoControlsInFixed: Boolean(shadow.querySelector('.hm-orgchart-stage .hm-orgchart-expanded-toggle')) }; + }); +} +async function snap(name, fullPage = false) { await page.screenshot({ path: path.join(output, `${name}.png`), fullPage }); } +async function section(name, action) { if (!run(name)) return; try { await action(); } catch (error) { check(false, `${name}: execution completed`, { message: error.message, stack: error.stack }); await snap(`failure-${name}`, true).catch(() => {}); } } + +await section('dropdown', async () => { + await goto(); await snap('expanded-closed', true); + check((await trigger().innerText()).includes('전체 회사 (7개)') && await page.locator('.hm-orgchart-story-settings').first().getByRole('checkbox').count() === 0, 'Closed: summary and no external company checkboxes'); + await trigger().focus(); await trigger().press('Enter'); await panel().waitFor(); await settle(); + const structure = await panel().evaluate(element => ({ portal: element.closest('.sb-page') === null, count: element.querySelectorAll('[role="checkbox"]').length, focusedFirst: element.querySelector('[role="checkbox"]') === document.activeElement })); + check(structure.portal && structure.count === 8 && structure.focusedFirst && await checkbox('전체 선택').getAttribute('aria-checked') === 'true', 'Open via keyboard: HM Portal and focus on select-all', structure); + await snap('expanded-open-all'); + await page.keyboard.press('Space'); await ready(0); + let s = await state(); measurements.push({ name: 'empty', state: s }); + check(await checkbox('전체 선택').getAttribute('aria-checked') === 'false' && (await trigger().innerText()).includes('회사를 선택하세요') && s.employees === 0 && s.countText.includes('0개 회사') && Boolean(s.empty), 'Space select-all off: empty state without error', s); + await page.keyboard.press('Tab'); + check(await checkbox('한맥기술').evaluate(element => element === document.activeElement), 'Tab reaches first individual checkbox'); + await page.keyboard.press('Space'); await ready(expectedTotal(20, ['hanmac'])); + check(await checkbox('한맥기술').getAttribute('aria-checked') === 'true' && await checkbox('전체 선택').getAttribute('aria-checked') === 'mixed' && (await trigger().innerText()).includes('한맥기술'), 'Individual Space: one company summary and indeterminate'); + await page.keyboard.press('Tab'); await page.keyboard.press('Space'); await ready(expectedTotal(20, ['hanmac', 'saman'])); + check(await checkbox('삼안').getAttribute('aria-checked') === 'true' && (await trigger().innerText()).includes('2개 회사 선택'), 'Multiple keyboard selection: two company summary'); + await snap('expanded-open-partial'); await close(); + check(await trigger().evaluate(element => element === document.activeElement) && await trigger().getAttribute('aria-expanded') === 'false', 'ESC closes and restores focus'); + await open(); + check(await checkbox('한맥기술').getAttribute('aria-checked') === 'true' && await checkbox('삼안').getAttribute('aria-checked') === 'true', 'Selection persists after reopen'); + await checkbox('한맥기술').click(); await ready(expectedTotal(20, ['saman'])); + check((await trigger().innerText()).includes('삼안'), 'Individual deselect removes only that company'); + await checkbox('전체 선택').click(); await ready(20); + check(await checkbox('전체 선택').getAttribute('aria-checked') === 'true' && await panel().getByRole('checkbox', { checked: true }).count() === 8, 'Indeterminate select-all selects seven companies'); + await snap('expanded-all-selected'); + await trigger().click(); await panel().waitFor({ state: 'hidden' }); check(await trigger().getAttribute('aria-expanded') === 'false', 'Trigger click closes'); + await open(); await page.locator('.sb-page-title').click(); await panel().waitFor({ state: 'hidden' }); check(true, 'Outside click closes'); +}); + +await section('data', async () => { + await goto(); await open(); await checkbox('전체 선택').click(); await ready(0); await checkbox('한맥기술').click(); await ready(expectedTotal(20, ['hanmac'])); await close(); + await select('데이터 범위', '회사별'); await select('회사당 직원 수', '100명'); await apply(100); + let s = await state(); check(s.employees === 100 && s.countText.includes('회사당 100명') && s.countText.includes('합계 100명'), 'Per-company 100: one company produces 100', s.countText); + await open(); await checkbox('삼안').click(); await ready(200); s = await state(); measurements.push({ name: 'per-company-two', state: s }); + check(s.employees === 200 && s.countText.includes('2개 회사') && s.countText.includes('합계 200명'), 'Second company immediately produces 200 without Apply', s.countText); + await close(); await snap('expanded-closed-partial', true); + await select('데이터 범위', '전체 회사 합산'); await select('전체 직원 수', '20명'); + await open(); await checkbox('삼안').click(); await ready(100); s = await state(); + check(s.employees === 100 && s.countText.includes('회사당 100명'), 'Company change preserves pending scope/count Apply boundary', s.countText); + await close(); await apply(expectedTotal(20, ['hanmac'])); s = await state(); + check(s.employees === expectedTotal(20, ['hanmac']) && !s.countText.includes('회사당'), 'Total20 preserves P7 weighted partition without redistribution', s.countText); + for (const count of [100, 300]) { + await select('전체 직원 수', `${count}명`); await apply(expectedTotal(count, ['hanmac'])); s = await state(); + check(s.employees === expectedTotal(count, ['hanmac']), `Total${count} selected-company partition`, { count: s.employees, expected: expectedTotal(count, ['hanmac']) }); + } + await select('데이터 범위', '회사별'); await select('회사당 직원 수', '20명'); await apply(20); check((await state()).employees === 20, 'Per-company20 selection retained'); + await select('회사당 직원 수', '300명'); await apply(300); check((await state()).employees === 300, 'Per-company300 selection retained'); + await page.getByRole('button', { name: '초기화', exact: true }).click(); await ready(20); check((await trigger().innerText()).includes('전체 회사 (7개)'), 'Reset restores original total20 and all seven'); +}); + +await section('modes', async () => { + await goto(); await open(); await checkbox('바론컨설턴트').click(); await ready(expectedTotal(20, ids.filter(id => id !== 'baron'))); await close(); + const selected = ids.filter(id => id !== 'baron'), count = expectedTotal(20, selected); + for (const [option, type] of [['세로형 TB', 'tb'], ['가로형 LR', 'lr'], ['혼합형 Hybrid', 'hybrid']]) { + if (type !== 'tb') await select('조직도 표시 유형', option); + await ready(count); const s = await state(); measurements.push({ name: type, state: s }); + check(s.type === type && s.employees === count && s.cards.every(card => selected.includes(card.companyId)) && new Set(s.cards.map(card => card.companyId)).size === 6, `${type}: chosen companies render and excluded company absent`, s.countText); + check(s.titles.length > 0 && s.titles.every(title => title.color === 'rgb(255, 255, 255)') && s.cards.every(card => ['top', 'left', 'right'].includes(card.direction)), `${type}: existing white titles and directional cards`); + } + const toggle = page.locator('.hm-orgchart-expanded-toggle:not(:disabled)').first(); + const id = await toggle.getAttribute('data-organization-id'); await toggle.click(); await ready(); + const current = () => page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`); + check(await current().getAttribute('aria-expanded') === 'false', 'Group collapse after selection'); await current().press('Space'); await ready(); check(await current().getAttribute('aria-expanded') === 'true', 'Group expands with Space'); + const employee = page.locator('.hm-orgchart-employee').first(), employeeId = await employee.getAttribute('data-employee-id'); await employee.click(); + const dialog = page.locator('[data-component="HMOrgChartEmployeeDialog"]'); await dialog.waitFor(); + check(await dialog.getAttribute('data-employee-id') === employeeId && await page.getByRole('dialog').count() === 1, 'Employee HM Dialog identity after company changes'); + await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' }); await settle(); + check(await page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId) === employeeId, 'Employee Dialog ESC returns card focus'); + await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); const before = await state(); + await page.getByRole('button', { name: '확대', exact: true }).click(); await settle(); const zoom = await state(); check(zoom.scale > before.scale, 'Zoom remains functional'); + const viewport = page.locator('.hm-orgchart-viewport'); await viewport.scrollIntoViewIfNeeded(); const box = await viewport.boundingBox(); const panBefore = await state(); + await page.mouse.move(box.x + 8, box.y + box.height - 8); await page.mouse.down(); await page.mouse.move(box.x + 58, box.y + box.height - 38, { steps: 5 }); await page.mouse.up(); await settle(); const pan = await state(); + check(Math.abs(pan.panX - panBefore.panX) >= 40 && Math.abs(pan.panY - panBefore.panY) >= 20, 'Pointer Pan remains functional', { before: [panBefore.panX, panBefore.panY], after: [pan.panX, pan.panY] }); + await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); const fit = await state(); + check(fit.forest.x >= fit.viewport.x - 1 && fit.forest.right <= fit.viewport.right + 1 && fit.forest.y >= fit.viewport.y - 1 && fit.forest.bottom <= fit.viewport.bottom + 1, 'Fit contains complete selected forest', fit); +}); + +await section('responsive', async () => { + for (const size of [{ width: 1920, height: 1080 }, { width: 768, height: 1024 }, { width: 390, height: 844 }]) { + await goto(size); await open(); + const bounds = await panel().evaluate(element => { const r = element.getBoundingClientRect(); return { x: r.x, right: r.right, y: r.y, bottom: r.bottom, width: r.width, height: r.height, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, portal: !element.closest('.sb-page'), checkboxesVisible: [...element.querySelectorAll('[role="checkbox"]')].every(child => { const b = child.getBoundingClientRect(); return b.left >= r.left && b.right <= r.right && b.top >= r.top && b.bottom <= r.bottom; }) }; }); + check(bounds.portal && bounds.x >= -1 && bounds.right <= size.width + 1 && bounds.y >= -1 && bounds.bottom <= size.height + 1 && bounds.scrollWidth <= bounds.clientWidth + 1 && bounds.checkboxesVisible, `${size.width}: Portal panel and all options fit without clipping`, bounds); + check((await state()).documentWidth <= size.width + 1, `${size.width}: no horizontal page overflow`); + await snap(`expanded-open-${size.width}`); await close(); + const summary = await trigger().evaluate(element => { const r = element.getBoundingClientRect(); return { width: r.width, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, label: element.textContent }; }); + check(summary.scrollWidth <= summary.clientWidth + 1, `${size.width}: concise trigger fits control`, summary); + await snap(`expanded-closed-${size.width}`, true); + } +}); + +// Isolate a corrected empty-forest probe without replaying successful sections. +if (only.includes('empty')) await section('empty', async () => { + await goto(); await trigger().focus(); await trigger().press('Enter'); await panel().waitFor(); + await page.keyboard.press('Space'); await ready(0); const s = await state(); + check(await checkbox('전체 선택').getAttribute('aria-checked') === 'false' && (await trigger().innerText()).includes('회사를 선택하세요') && s.employees === 0 && s.countText.includes('0개 회사') && s.empty, 'Space select-all off: empty forest without error', s); + await snap('expanded-empty'); +}); + +// Runtime evidence for Radix's public positioning variable, opaque to Guard. +if (only.includes('token')) await section('token', async () => { + await goto(); await open(); + const evidence = await panel().evaluate(element => { + const css = getComputedStyle(element); + return { variable: '--radix-popover-content-available-height', actual: css.getPropertyValue('--radix-popover-content-available-height').trim(), maxHeight: css.maxHeight, component: element.dataset.component, width: innerWidth, height: innerHeight }; + }); + measurements.push({ name: 'Radix runtime variable', evidence }); + check(parseFloat(evidence.actual) > 0 && evidence.maxHeight === evidence.actual, 'HM Popover declares Radix available-height variable at runtime', evidence); +}); + +await section('menu', async () => { + const index = await (await page.request.get(`${origin}/index.json`)).json(); + const entries = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title.startsWith('OrgChart/') && !(entry.tags ?? []).includes('internal')); + const expected = ['orgchart--logo-personnel-summary', 'orgchart--department-colors', 'orgchart--full-composition', 'orgchart--expanded-org-chart']; + check(entries.length === 4 && entries.every(entry => expected.includes(entry.id)), 'Existing four public Story IDs retained', entries.map(entry => ({ id: entry.id, title: entry.title }))); +}); +check(errors.length === 0, 'New console/page errors: zero', errors); +const result = { startedAt, completedAt: new Date().toISOString(), browser: browser.version(), origin, sections: only, results, measurements, errors, pass: results.filter(item => item.status === 'PASS').length, fail: results.filter(item => item.status === 'FAIL').length }; +await fs.writeFile(path.join(output, `browser-expanded${suffix}.json`), JSON.stringify(result, null, 2)); +console.log(JSON.stringify({ pass: result.pass, fail: result.fail, errors: errors.length, output: `test-results/org-chart-p9-2/browser-expanded${suffix}.json` })); +await browser.close(); if (result.fail) process.exitCode = 1; diff --git a/examples/org-chart/verify-p9-3-core.mjs b/examples/org-chart/verify-p9-3-core.mjs new file mode 100644 index 0000000..71e341c --- /dev/null +++ b/examples/org-chart/verify-p9-3-core.mjs @@ -0,0 +1,371 @@ +// P9.3 public Vanilla Core API and engine bridge checks only. +// Requires the parent-controlled static server and an already built Core. +import fs from 'node:fs/promises'; +import { writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-p9-3'); +const origin = process.env.ORGCHART_CORE_ORIGIN ?? 'http://127.0.0.1:6064'; +const only = process.argv.slice(2), run = section => !only.length || only.includes(section); +const suffix = only.length ? '-' + only.join('-') : ''; +const startedAt = new Date().toISOString(), results = [], measurements = [], errors = []; +await fs.mkdir(output, { recursive: true }); +const persist = () => writeFileSync(path.join(output, `core-progress${suffix}.json`), JSON.stringify({ startedAt, results, measurements, errors }, null, 2)); +const check = (pass, name, detail) => { + results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); + persist(); if (!pass) console.log('FAIL', name, JSON.stringify(detail)); +}; +const browser = await launchChromium({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); +page.setDefaultTimeout(15000); +page.on('pageerror', error => { errors.push({ type: 'pageerror', message: error.message }); persist(); }); +page.on('console', message => { if (message.type() === 'error') { errors.push({ type: 'console', message: message.text(), location: message.location() }); persist(); } }); +const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 6; i++) await new Promise(requestAnimationFrame); }); +async function ready() { + await page.waitForFunction(() => window.fixtureReady && window.chart?.getState().status === 'ready' + && [...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet)); + await settle(); +} +async function fresh(extra) { + await page.goto(`${origin}/vanilla.html`); await ready(); + if (extra) { await page.evaluate(extra => window.mountChart('auto', extra), extra); await ready(); } +} +const employee = id => page.locator(`.hm-orgchart-employee[data-employee-id="${id}"]`); +const title = id => page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`); +async function pointerMove(source, target) { + const from = await source.boundingBox(), to = await target.boundingBox(); + if (!from || !to) throw new Error('Pointer move source/target has no rendered bounds'); + const start = { x: from.x + from.width / 2, y: from.y + from.height / 2 }, end = { x: to.x + to.width / 2, y: to.y + to.height / 2 }; + await page.mouse.move(start.x, start.y); await page.mouse.down(); + await page.mouse.move(start.x + 8, start.y + 8, { steps: 2 }); + await page.mouse.move(end.x, end.y, { steps: 10 }); + const feedback = await page.evaluate(() => [...window.shadow.querySelectorAll('.node[data-drop-state]')].map(node => ({ id: node.dataset.organizationId, state: node.dataset.dropState, hover: node.dataset.dropHover }))); + await page.mouse.up(); await ready(); + return feedback; +} +async function snapshot() { + return page.evaluate(() => { + const data = window.chart.getData(), state = window.chart.getState(), shadow = window.shadow; + return { data, summary: state.status === 'ready' ? state.summary.raw : undefined, + viewport: window.chart.getViewportState(), moves: window.moves, events: window.bridgeEvents, + cards: [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({ id: card.dataset.employeeId, organization: card.closest('.node').dataset.organizationId, direction: card.dataset.connectionDirection })), + groups: [...shadow.querySelectorAll('.node[data-organization-id]')].map(node => ({ id: node.dataset.organizationId, parent: node.dataset.parentId, collapsed: node.dataset.collapsed })), + organizationPaths: [...shadow.querySelectorAll('.hm-orgchart-organization-connectors path')].map(node => ({ id: node.dataset.organizationId, parent: node.dataset.parentId })), + dialogCount: shadow.querySelectorAll('dialog[open]').length, + feedback: shadow.querySelector('.hm-orgchart-move-feedback')?.textContent }; + }); +} + +try { + if (run('api')) { + await fresh(); + const initial = await snapshot(); + check(initial.data.employees.length === 20 && initial.data.organizations.length === 15, 'Vanilla public Core mounts raw multi-company fixture'); + const frozen = await page.evaluate(() => { + const data = window.chart.getData(), before = JSON.stringify(data); + const deep = value => value === null || typeof value !== 'object' || (Object.isFrozen(value) && Object.values(value).every(deep)); + try { data.employees[0].name = 'consumer mutation'; } catch { /* Frozen snapshots may throw in strict mode. */ } + return { deepFrozen: deep(data), unchanged: JSON.stringify(data) === before, copied: data !== window.fixture, fixtureUnchanged: JSON.stringify(window.fixture) === window.fixtureOriginal }; + }); + check(frozen.deepFrozen && frozen.unchanged && frozen.copied && frozen.fixtureUnchanged, 'getData is a deeply frozen owned snapshot; consumer input preserved', frozen); + const moved = await page.evaluate(() => window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-division-1' })); + await ready(); const afterEmployee = await snapshot(); + check(moved.moved && moved.previousOrganizationId === 'hanmac-office-0' && moved.request.sourceId === 'virtual-006', 'Public employee move reports original relation and request', moved.moved ? { previousOrganizationId: moved.previousOrganizationId, request: moved.request } : moved); + const beforePerson = initial.data.employees.find(person => person.id === 'virtual-006'), afterPerson = afterEmployee.data.employees.find(person => person.id === 'virtual-006'); + check(afterPerson.organizationId === 'hanmac-division-1' && JSON.stringify({ ...afterPerson, organizationId: beforePerson.organizationId }) === JSON.stringify(beforePerson), 'Employee identity, affiliation and personal fields survive new organization'); + check(afterEmployee.data.employees.length === initial.data.employees.length && new Set(afterEmployee.cards.map(card => card.id)).size === 20 + && afterEmployee.cards.filter(card => card.id === 'virtual-006').length === 1 && afterEmployee.cards.find(card => card.id === 'virtual-006').organization === 'hanmac-division-1', 'Employee count unchanged and one rendered card under new group'); + check(afterEmployee.summary.total === initial.summary.total + && afterEmployee.summary.byOrganization['hanmac-office-0'].direct === initial.summary.byOrganization['hanmac-office-0'].direct - 1 + && afterEmployee.summary.byOrganization['hanmac-division-1'].direct === initial.summary.byOrganization['hanmac-division-1'].direct + 1, 'Public summary direct totals update without changing overall count'); + const group = await page.evaluate(() => window.chart.move({ kind: 'organization', sourceId: 'hanmac-division-0', targetOrganizationId: 'hanmac-division-1' })); + await ready(); const afterGroup = await snapshot(); + check(group.moved && group.previousOrganizationId === 'hanmac-root' + && afterGroup.data.organizations.find(org => org.id === 'hanmac-division-0').parentId === 'hanmac-division-1' + && afterGroup.data.organizations.find(org => org.id === 'hanmac-office-0').parentId === 'hanmac-division-0' + && JSON.stringify(afterGroup.data.employees) === JSON.stringify(afterEmployee.data.employees), 'Public organization move updates parent only and retains descendants/staff', group.moved ? group.request : group); + const unchanged = JSON.stringify(afterGroup.data); + const invalid = [ + [{ kind: 'organization', sourceId: 'hanmac-division-0', targetOrganizationId: 'hanmac-office-0' }, 'DESCENDANT_TARGET'], + [{ kind: 'organization', sourceId: 'hanmac-division-0', targetOrganizationId: 'hanmac-division-0' }, 'SELF_TARGET'], + [{ kind: 'organization', sourceId: 'hanmac-root', targetOrganizationId: 'hanmac-division-1' }, 'ROOT_PROTECTED'], + [{ kind: 'employee', sourceId: 'virtual-001', targetOrganizationId: 'hanmac-division-1' }, 'LEADER_PROTECTED'], + [{ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-division-1' }, 'SAME_ORGANIZATION'], + [{ kind: 'employee', sourceId: 'missing-employee', targetOrganizationId: 'hanmac-division-1' }, 'SOURCE_NOT_FOUND'], + [{ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'missing-group' }, 'TARGET_NOT_FOUND'], + ]; + for (const [request, code] of invalid) { + const result = await page.evaluate(request => window.chart.move(request), request); + check(!result.moved && result.code === code && await page.evaluate(expected => JSON.stringify(window.chart.getData()) === expected, unchanged), `Public move rejects ${code} and preserves complete dataset`, result); + } + check(await page.evaluate(expected => window.moves.length === expected && window.moves.filter(move => move.moved).length === 2, 2 + invalid.length), 'onMove receives successful moves and every policy rejection exactly once'); + const callback = await page.evaluate(async () => { window.throwCallback = true; const result = await window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-office-1' }); window.throwCallback = false; return { moved: result.moved, status: window.chart.getState().status }; }); + await ready(); check(callback.moved && callback.status === 'ready', 'Consumer onMove exception cannot fail Core transaction', callback); + check(await page.evaluate(() => JSON.stringify(window.fixture) === window.fixtureOriginal), 'All public edits leave original JSON input unchanged'); + measurements.push({ section: 'api', state: await snapshot() }); + } + + if (run('cross')) { + // User's later instruction permits cross-company moves. The earlier + // CROSS_COMPANY rejection evidence remains preserved as superseded history. + await fresh(); const before = await snapshot(); + const employeeResult = await page.evaluate(() => window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'saman-division-1' })); + await ready(); const afterEmployee = await snapshot(); + const oldEmployee = before.data.employees.find(person => person.id === 'virtual-006'), newEmployee = afterEmployee.data.employees.find(person => person.id === 'virtual-006'); + check(employeeResult.moved && employeeResult.previousOrganizationId === 'hanmac-office-0' + && newEmployee.organizationId === 'saman-division-1' && newEmployee.affiliationId === 'saman' + && JSON.stringify({ ...newEmployee, organizationId: oldEmployee.organizationId, affiliationId: oldEmployee.affiliationId }) === JSON.stringify(oldEmployee), + 'Cross-company public employee move updates actual organizationId/affiliationId and preserves identity', employeeResult.moved ? { request: employeeResult.request, person: newEmployee } : employeeResult); + check(afterEmployee.data.employees.length === 20 && afterEmployee.cards.length === 20 && new Set(afterEmployee.cards.map(card => card.id)).size === 20 + && afterEmployee.summary.total === before.summary.total + && afterEmployee.summary.byAffiliation.hanmac === before.summary.byAffiliation.hanmac - 1 + && afterEmployee.summary.byAffiliation.saman === before.summary.byAffiliation.saman + 1 + && afterEmployee.summary.byOrganization['hanmac-root'].inclusive === before.summary.byOrganization['hanmac-root'].inclusive - 1 + && afterEmployee.summary.byOrganization['saman-root'].inclusive === before.summary.byOrganization['saman-root'].inclusive + 1, + 'Cross-company employee move retains total and updates source/target company plus affiliation counts'); + const employeeColor = await employee('virtual-006').evaluate(card => { + const probe = document.createElement('span'); probe.style.color = window.mod.companyColorPresets.saman.primary; window.shadow.append(probe); + const expected = getComputedStyle(probe).color; probe.remove(); + return { company: card.dataset.companyId, organization: card.closest('.node').dataset.organizationId, border: getComputedStyle(card).borderTopColor, expected }; + }); + check(employeeColor.company === 'saman' && employeeColor.organization === 'saman-division-1' && employeeColor.border === employeeColor.expected, + 'Cross-company employee card uses destination existing company preset', employeeColor); + await employee('virtual-006').click(); const popup = page.locator('.hm-orgchart-employee-dialog[open]'); await popup.waitFor(); + const popupState = await popup.evaluate(dialog => { + const field = name => [...dialog.querySelectorAll('dt')].find(term => term.textContent === name)?.nextElementSibling?.textContent; + return { id: dialog.dataset.employeeId, organization: field('소속'), affiliation: field('관계사') }; + }); + check(popupState.id === 'virtual-006' && popupState.organization === afterEmployee.data.organizations.find(org => org.id === 'saman-division-1').name + && popupState.affiliation === afterEmployee.data.affiliations.find(affiliation => affiliation.id === 'saman').name, + 'Vanilla employee Dialog reflects destination company and organization after cross move', popupState); + await page.keyboard.press('Escape'); await popup.waitFor({ state: 'hidden' }); await settle(); + const groupResult = await page.evaluate(() => window.chart.move({ kind: 'organization', sourceId: 'hanmac-division-0', targetOrganizationId: 'saman-division-1' })); + await ready(); const afterGroup = await snapshot(); + const subtree = new Set(['hanmac-division-0', 'hanmac-office-0']); + const subtreePeople = afterEmployee.data.employees.filter(person => subtree.has(person.organizationId)); + check(groupResult.moved && groupResult.previousOrganizationId === 'hanmac-root' + && afterGroup.data.organizations.find(org => org.id === 'hanmac-division-0').parentId === 'saman-division-1' + && afterGroup.data.organizations.find(org => org.id === 'hanmac-office-0').parentId === 'hanmac-division-0' + && afterGroup.data.organizations.filter(org => subtree.has(org.id)).every(org => org.companyId === 'saman') + && afterGroup.data.employees.filter(person => subtree.has(person.organizationId)).every(person => person.affiliationId === 'saman') + && subtreePeople.every(old => JSON.stringify(afterGroup.data.employees.find(person => person.id === old.id)) === JSON.stringify({ ...old, affiliationId: 'saman' })), + 'Cross-company public group move retargets subtree companyId and staff affiliationId while retaining hierarchy and identities', groupResult.moved ? { request: groupResult.request, subtreePeople } : groupResult); + check(afterGroup.data.employees.length === 20 && afterGroup.cards.length === 20 && new Set(afterGroup.cards.map(card => card.id)).size === 20 + && afterGroup.summary.byAffiliation.hanmac === afterEmployee.summary.byAffiliation.hanmac - subtreePeople.length + && afterGroup.summary.byAffiliation.saman === afterEmployee.summary.byAffiliation.saman + subtreePeople.length + && afterGroup.summary.byOrganization['hanmac-root'].inclusive === afterEmployee.summary.byOrganization['hanmac-root'].inclusive - subtreePeople.length + && afterGroup.summary.byOrganization['saman-root'].inclusive === afterEmployee.summary.byOrganization['saman-root'].inclusive + subtreePeople.length, + 'Cross-company group transfer updates company/affiliation aggregate totals without duplication'); + const groupColors = await page.evaluate(() => { + const probe = document.createElement('span'); probe.style.color = window.mod.companyColorPresets.saman.primary; window.shadow.append(probe); + const expected = getComputedStyle(probe).color; probe.remove(); + return [...window.shadow.querySelectorAll('.node')].filter(node => ['hanmac-division-0', 'hanmac-office-0'].includes(node.dataset.organizationId)) + .map(node => ({ id: node.dataset.organizationId, company: node.dataset.companyId, title: getComputedStyle(node.querySelector('.hm-orgchart-organization-name')).backgroundColor, expected, + cards: [...node.querySelectorAll('.hm-orgchart-employee')].map(card => ({ company: card.dataset.companyId, border: getComputedStyle(card).borderTopColor })) })); + }); + check(groupColors.length === 2 && groupColors.every(node => node.company === 'saman' && node.title === node.expected + && node.cards.every(card => card.company === 'saman' && card.border === node.expected)), 'Transferred subtree uses destination presets on group headings and employee borders', groupColors); + check(await page.evaluate(() => window.moves.length === 2 && window.moves.every(result => result.moved) + && JSON.stringify(window.fixture) === window.fixtureOriginal && window.mod.validateOrgChartDataset(window.chart.getData()).valid + && Object.isFrozen(window.chart.getData()) && Object.isFrozen(window.chart.getData().employees[0])), + 'Cross-company API callbacks occur once per success; data remains valid/frozen and original fixture unchanged'); + await page.evaluate(() => window.chart.setSelectedCompanies(['hanmac'])); await ready(); + check(await employee('virtual-006').count() === 0 && await title('hanmac-division-0').count() === 0, 'Destination-company filter removes transferred employee/subtree from source-company view'); + await page.evaluate(() => window.chart.setSelectedCompanies(['saman'])); await ready(); + check(await employee('virtual-006').count() === 1 && await title('hanmac-division-0').count() === 1 && await title('hanmac-office-0').count() === 1, + 'Destination-company filter restores transferred employee/subtree exactly once'); + measurements.push({ section: 'cross-api', state: await snapshot() }); + + await fresh(); + const employeeMarks = await pointerMove(employee('virtual-006'), title('saman-division-1')); + const employeePointer = await snapshot(); + check(employeeMarks.some(node => node.id === 'saman-division-1' && node.state === 'allowed' && node.hover === 'allowed') + && employeePointer.events.some(event => event.type === 'otherdropped.orgchart' && event.defaultPrevented && event.targetId === 'saman-division-1') + && employeePointer.data.employees.find(person => person.id === 'virtual-006').organizationId === 'saman-division-1' + && employeePointer.data.employees.find(person => person.id === 'virtual-006').affiliationId === 'saman', + 'Real cross-tree employee pointer marks destination allowed and reuses canceled upstream external-drop bridge', { marks: employeeMarks, events: employeePointer.events }); + await page.evaluate(() => window.chart.fitToView()); await settle(); + const groupMarks = await pointerMove(title('hanmac-division-0'), title('saman-division-1')); + const groupPointer = await snapshot(); + check(groupMarks.some(node => node.id === 'saman-division-1' && node.state === 'allowed' && node.hover === 'allowed') + && groupPointer.events.some(event => event.type === 'nodedrop.orgchart' && event.defaultPrevented && event.targetId === 'saman-division-1') + && groupPointer.data.organizations.find(org => org.id === 'hanmac-division-0').parentId === 'saman-division-1' + && groupPointer.data.organizations.find(org => org.id === 'hanmac-office-0').companyId === 'saman', + 'Real cross-tree group pointer reuses canceled upstream node-drop bridge and retargets subtree', { marks: groupMarks, events: groupPointer.events }); + check(groupPointer.moves.length === 2 && groupPointer.moves.every(move => move.moved) && groupPointer.dialogCount === 0 + && groupPointer.data.employees.length === 20 && new Set(groupPointer.cards.map(card => card.id)).size === 20 + && await page.evaluate(() => JSON.stringify(window.fixture) === window.fixtureOriginal), + 'Cross-tree pointer transactions have one callback each, no popup/duplicate and immutable original fixture'); + measurements.push({ section: 'cross-pointer', state: groupPointer }); + await page.screenshot({ path: path.join(output, 'vanilla-cross-company.png'), fullPage: true }); + } + + if (run('bridge')) { + await fresh(); await page.evaluate(() => window.chart.setSelectedCompanies(['hanmac'])); await ready(); + const before = await snapshot(); + const marks = await pointerMove(employee('virtual-006'), title('hanmac-division-1')); + const staff = await snapshot(); + check(marks.some(group => group.id === 'hanmac-division-1' && group.state === 'allowed' && group.hover === 'allowed') + && marks.some(group => group.id === 'hanmac-office-0' && group.state === 'blocked'), 'Vanilla pointer card gesture exposes valid/invalid/hover targets', marks); + check(staff.events.some(event => event.type === 'otherdropped.orgchart' && event.defaultPrevented && event.sourceId === 'virtual-006' && event.targetId === 'hanmac-division-1') + && staff.data.employees.find(person => person.id === 'virtual-006').organizationId === 'hanmac-division-1', 'Real employee pointer uses canceled upstream otherdropped bridge and updates data', staff.events); + check(staff.dialogCount === 0 && JSON.stringify(staff.viewport) === JSON.stringify(before.viewport), 'Employee drag opens no Dialog and retains Pan/Zoom transform', { before: before.viewport, after: staff.viewport, dialogs: staff.dialogCount }); + await page.evaluate(() => window.chart.fitToView()); await settle(); + const groupMarks = await pointerMove(title('hanmac-division-0'), title('hanmac-division-1')); + const groups = await snapshot(); + check(groupMarks.some(group => group.id === 'hanmac-office-0' && group.state === 'blocked') + && groups.events.some(event => event.type === 'nodedrop.orgchart' && event.defaultPrevented && event.sourceId === 'hanmac-division-0' && event.targetId === 'hanmac-division-1') + && groups.data.organizations.find(org => org.id === 'hanmac-division-0').parentId === 'hanmac-division-1', 'Real organization pointer uses canceled upstream nodedrop bridge and protects descendant', { marks: groupMarks, events: groups.events }); + check(groups.data.employees.length === 20 && new Set(groups.groups.map(group => group.id)).size === groups.groups.length + && groups.groups.find(group => group.id === 'hanmac-office-0').parent === 'hanmac-division-0' + && groups.groups.find(group => group.id === 'hanmac-division-0').collapsed === 'false', 'Group pointer move keeps complete subtree and avoids post-drag collapse'); + await page.evaluate(() => window.chart.setAutoLayout({ type: 'lr' })); await ready(); + const routed = await snapshot(); + check(routed.organizationPaths.some(route => route.id === 'hanmac-division-0' && route.parent === 'hanmac-division-1') + && !routed.organizationPaths.some(route => route.id === 'hanmac-division-0' && route.parent === 'hanmac-root') + && routed.cards.every(card => card.direction === 'left'), 'LR regenerated connector follows changed parent and card incoming borders'); + await page.screenshot({ path: path.join(output, 'vanilla-core-lr.png'), fullPage: true }); + measurements.push({ section: 'bridge', state: routed }); + } + + if (run('lifecycle')) { + await fresh(); + await page.evaluate(() => window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-division-1' })); await ready(); + await page.evaluate(() => window.chart.setSelectedCompanies(['hanmac'])); await ready(); + await title('hanmac-division-1').click(); await ready(); + const collapsed = await snapshot(); + check(collapsed.groups.find(group => group.id === 'hanmac-division-1').collapsed === 'true', 'Moved employee target remains ordinarily collapsible'); + for (const type of ['lr', 'hybrid', 'tb']) { + await page.evaluate(type => window.chart.setAutoLayout({ type }), type); await ready(); + check(await title('hanmac-division-1').getAttribute('aria-expanded') === 'false' + && await page.evaluate(() => window.chart.getData().employees.find(person => person.id === 'virtual-006').organizationId === 'hanmac-division-1'), `Local relation and collapse survive layout ${type}`); + } + const hiddenState = await page.evaluate(() => JSON.stringify(window.chart.getData())); + await page.evaluate(() => window.chart.setSelectedCompanies(['saman'])); await ready(); + check(await page.locator('.node[data-company-id="hanmac"]').count() === 0 + && await page.evaluate(expected => JSON.stringify(window.chart.getData()) === expected, hiddenState), 'Hiding a company preserves all browser-state relations'); + await page.evaluate(() => window.chart.setSelectedCompanies(['hanmac'])); await ready(); + check(await title('hanmac-division-1').getAttribute('aria-expanded') === 'false', 'Company restore keeps saved collapsed target'); + await title('hanmac-division-1').click(); await ready(); + check(await employee('virtual-006').count() === 1 && await employee('virtual-006').evaluate(card => card.closest('.node').dataset.organizationId) === 'hanmac-division-1', 'Company restore shows moved employee exactly once in new group'); + const raced = await page.evaluate(async () => { + let resolveSource; + const delayed = { load: () => new Promise(resolve => { resolveSource = resolve; }) }; + const first = window.chart.reload(delayed); + const rejected = await window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-office-1' }); + const fresh = structuredClone(window.fixture); fresh.metadata.dataVersion = 'p9.3-newer-request'; + await window.chart.update(fresh); + resolveSource(window.fixture); await first; + return { rejected, version: window.chart.getData().metadata.dataVersion, organization: window.chart.getData().employees.find(person => person.id === 'virtual-006').organizationId, status: window.chart.getState().status }; + }); + await ready(); + check(!raced.rejected.moved && raced.rejected.code === 'INVALID_DATA', 'Move during pending source request is safely rejected', raced.rejected); + check(raced.version === 'p9.3-newer-request' && raced.organization === 'hanmac-office-0' && raced.status === 'ready', 'Stale asynchronous reload cannot overwrite newer dataset request', raced); + const simultaneous = await page.evaluate(async () => { + const one = window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-division-1' }); + const two = window.chart.move({ kind: 'employee', sourceId: 'virtual-007', targetOrganizationId: 'hanmac-office-1' }); + const outcomes = await Promise.all([one, two]); + return { outcomes: outcomes.map(result => ({ moved: result.moved, code: result.code })), data: window.chart.getData(), valid: window.mod.validateOrgChartDataset(window.chart.getData()).valid, status: window.chart.getState().status }; + }); + await ready(); + check(simultaneous.outcomes[0].moved && !simultaneous.outcomes[1].moved && simultaneous.outcomes[1].code === 'INVALID_DATA' + && simultaneous.valid && simultaneous.status === 'ready' && simultaneous.data.employees.length === 20 + && simultaneous.data.employees.find(person => person.id === 'virtual-006').organizationId === 'hanmac-division-1' + && simultaneous.data.employees.find(person => person.id === 'virtual-007').organizationId === 'hanmac-division-1', 'Concurrent public moves serialize safely; loading request rejects without losing data', { outcomes: simultaneous.outcomes, status: simultaneous.status, valid: simultaneous.valid }); + measurements.push({ section: 'lifecycle', state: await snapshot() }); + } + + if (run('touch')) { + await fresh(); await page.evaluate(() => window.chart.setSelectedCompanies(['hanmac'])); await ready(); + await page.evaluate(() => { + window.touchPointers = []; + for (const type of ['pointerdown', 'pointermove', 'pointerup', 'pointercancel', 'gotpointercapture', 'lostpointercapture']) { + window.addEventListener(type, event => window.touchPointers.push({ type, pointerType: event.pointerType, isPrimary: event.isPrimary, + x: event.clientX, y: event.clientY, editing: window.shadow.querySelector('.hm-orgchart-viewport').dataset.editDragging, + feedback: window.shadow.querySelector('.hm-orgchart-move-feedback').textContent })); + } + }); + const from = await employee('virtual-006').boundingBox(), to = await title('hanmac-division-1').boundingBox(); + const start = { x: from.x + from.width / 2, y: from.y + from.height / 2 }, end = { x: to.x + to.width / 2, y: to.y + to.height / 2 }; + const session = await page.context().newCDPSession(page); + await session.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 }); + await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [start] }); + for (let step = 1; step <= 8; step++) { + await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: start.x + (end.x - start.x) * step / 8, y: start.y + (end.y - start.y) * step / 8 }] }); + } + await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + await ready(); + const touch = await page.evaluate(() => ({ pointers: window.touchPointers, + organization: window.chart.getData().employees.find(person => person.id === 'virtual-006').organizationId, + events: window.bridgeEvents, dialogs: window.shadow.querySelectorAll('dialog[open]').length })); + check(touch.pointers.some(event => event.pointerType === 'touch' && event.isPrimary) + && touch.organization === 'hanmac-division-1' && touch.events.some(event => event.type === 'otherdropped.orgchart' && event.defaultPrevented) && touch.dialogs === 0, + 'Synthetic browser touch input generates primary touch pointer, moves card and suppresses Dialog', touch); + measurements.push({ section: 'touch', state: touch, realDevice: 'NOT VERIFIED' }); + await session.send('Emulation.setTouchEmulationEnabled', { enabled: false }); await session.detach(); + } + + if (run('touch-pan')) { + await fresh(); await page.evaluate(() => window.chart.setSelectedCompanies(['hanmac'])); await ready(); + const setup = await page.evaluate(() => { + const viewport = window.shadow.querySelector('.hm-orgchart-viewport'), box = viewport.getBoundingClientRect(); + const left = box.left + 24, right = Math.min(box.right - 72, innerWidth - 72), top = box.top + 24, bottom = Math.min(box.bottom - 72, innerHeight - 72); + for (let y = top; y <= bottom; y += 40) { + for (let x = left; x <= right; x += 40) { + const hit = window.shadow.elementFromPoint(x, y); + if (hit && viewport.contains(hit) && !hit.closest('button,a,.hm-orgchart-employee,.node')) { + window.touchPanPointers = []; + viewport.addEventListener('pointerdown', event => window.touchPanPointers.push({ pointerType: event.pointerType, isPrimary: event.isPrimary })); + return { point: { x, y }, sourceClass: String(hit.className), before: window.chart.getViewportState(), moves: window.moves.length }; + } + } + } + throw new Error('No safe visible blank point for touch Pan'); + }); + const session = await page.context().newCDPSession(page); + await session.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 }); + await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [setup.point] }); + for (let step = 1; step <= 8; step++) { + await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: setup.point.x + 40 * step / 8, y: setup.point.y + 30 * step / 8 }] }); + } + await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); await settle(); + const after = await page.evaluate(() => ({ viewport: window.chart.getViewportState(), moves: window.moves.length, + pointers: window.touchPanPointers, editing: window.shadow.querySelector('.hm-orgchart-viewport').dataset.editDragging, + panDragging: window.shadow.querySelector('.hm-orgchart-viewport').dataset.dragging })); + check(after.pointers.some(event => event.pointerType === 'touch' && event.isPrimary) + && Math.abs(after.viewport.x - setup.before.x - 40) < 1 && Math.abs(after.viewport.y - setup.before.y - 30) < 1 + && after.viewport.scale === setup.before.scale && after.moves === setup.moves && !after.editing && !after.panDragging, + 'Synthetic touch blank-space Pan translates forest, retains scale and triggers no card move', { setup, after }); + measurements.push({ section: 'touch-pan', setup, after, realDevice: 'NOT VERIFIED' }); + await session.send('Emulation.setTouchEmulationEnabled', { enabled: false }); await session.detach(); + } + + if (run('fixed')) { + await fresh(); await page.evaluate(() => window.mountChart('fixed')); await ready(); + const fixed = await page.evaluate(async () => { + const before = window.shadow.querySelector('.hm-orgchart-stage')?.outerHTML; + let rejection; + try { await window.chart.move({ kind: 'employee', sourceId: 'virtual-006', targetOrganizationId: 'hanmac-division-1' }); } + catch (error) { rejection = error.message; } + return { rejection, unchanged: before === window.shadow.querySelector('.hm-orgchart-stage')?.outerHTML, + expanded: window.shadow.querySelectorAll('.hm-orgchart-expanded-toggle,.hm-orgchart-move-feedback,[data-edit-source],[data-drop-state]').length, + draggable: window.shadow.querySelectorAll('[draggable="true"]').length, status: window.chart.getState().status, data: window.chart.getData(), events: window.moves.length }; + }); + check(fixed.rejection?.includes('Auto mode') && fixed.unchanged && fixed.status === 'ready' && fixed.events === 0, 'Fixed public handle rejects editing and retains rendered stage', fixed); + check(fixed.expanded === 0 && fixed.draggable === 0 && fixed.data === undefined, 'Fixed has no editing controls, native drag or Auto browser-state API'); + } + check(errors.length === 0, 'Vanilla Core change-only verification has no console/page errors', errors); +} catch (error) { + check(false, 'Unexpected verification error', { message: error.message, stack: error.stack }); + await page.screenshot({ path: path.join(output, `vanilla-error${suffix}.png`), fullPage: true }).catch(() => {}); +} finally { + await browser.close(); + const completedAt = new Date().toISOString(); + const counts = { PASS: results.filter(result => result.status === 'PASS').length, FAIL: results.filter(result => result.status === 'FAIL').length }; + await fs.writeFile(path.join(output, `core-browser${suffix}.json`), JSON.stringify({ startedAt, completedAt, origin, counts, results, measurements, errors }, null, 2)); + console.log(JSON.stringify({ counts, origin, output: `test-results/org-chart-p9-3/core-browser${suffix}.json` })); + if (counts.FAIL || errors.length) process.exitCode = 1; +} diff --git a/examples/org-chart/verify-p9-3-preservation.mjs b/examples/org-chart/verify-p9-3-preservation.mjs new file mode 100644 index 0000000..d71264c --- /dev/null +++ b/examples/org-chart/verify-p9-3-preservation.mjs @@ -0,0 +1,40 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +const output = path.resolve('test-results/org-chart-p9-3'); +const origin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6064'; +const browser = await launchChromium({headless:true}); +const page = await browser.newPage({viewport:{width:1440,height:900}}); +const results=[],errors=[]; +page.on('pageerror',e=>errors.push(e.message)); +page.on('console',m=>{if(m.type()==='error')errors.push(m.text());}); +const check=(ok,name,detail)=>{results.push({name,status:ok?'PASS':'FAIL',detail});if(!ok)console.log('FAIL',name,detail);}; +const ready=async()=>{await page.waitForFunction(()=>{const s=document.querySelector('.hm-orgchart-host')?.shadowRoot;return s&&!s.querySelector('[aria-busy="true"]')&&s.querySelector('.hm-orgchart-employee')&&[...s.querySelectorAll('link')].every(l=>l.sheet);});await page.evaluate(async()=>{await document.fonts.ready;for(let i=0;i<12;i++)await new Promise(requestAnimationFrame);});}; +await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`);await ready(); +await page.evaluate(async()=>{await Promise.all([...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('img')].map(async i=>{i.loading='eager';await i.decode();}));}); +// Reuse the exact original measurement function; do not rerun P9.2's suite. +const priorSource=await fs.readFile('examples/org-chart/verify-p9-2-composition.mjs','utf8'); +const from=priorSource.indexOf('async function snapshot()'),to=priorSource.indexOf('async function stability()',from); +const state=await new Function('page',priorSource.slice(from,to)+'; return snapshot();')(page); +const previous=JSON.parse(await fs.readFile('test-results/org-chart-p9-2/browser-composition.json','utf8')).snapshots['1440']; +const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b); +const normalizedEqual=state.elements.every((e,i)=>{const old=previous.elements[i];return old?.key===e.key&&old.hash===e.hash&&same(old.style,e.style)&&Object.keys(e.box).every(k=>Math.abs(e.box[k]-old.box[k])<.15);}); +check(previous.htmlHash===state.htmlHash&&same(previous.assets,state.assets)&&normalizedEqual,'Fixed P9.2 DOM, 139 image hashes, 152 element styles/geometry preserved',{before:previous.htmlHash,after:state.htmlHash,elements:state.elements.length,assets:state.assets.length}); +check(Math.abs(state.centerDifference)<1&&state.collisions.length===0&&state.counts.cards===43&&state.counts.paths===5,'Fixed center/card/connector minimal regression',{centerDifference:state.centerDifference,counts:state.counts,collisions:state.collisions}); +check(await page.evaluate(()=>!document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('[data-edit-dragging],[data-drop-state],.hm-orgchart-move-feedback')),'Fixed has no editing UI'); +await page.screenshot({path:path.join(output,'fixed-preserved-1440.png'),fullPage:true}); +await page.goto(`${origin}/`); +await page.waitForFunction(()=>Boolean(document.querySelector('a[href*="orgchart--expanded-org-chart"]'))); +const menus=await page.locator('a[href*="orgchart--"]').allTextContents(); +check(menus.includes('Overview')&&menus.includes('Department Colors')&&menus.includes('Full Composition')&&menus.includes('Expanded OrgChart'),'Four existing Storybook menu entries preserved',menus); +for(const width of [390,768,1280,1440]){ + await page.setViewportSize({width,height:1100}); + await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`);await ready(); + const metrics=await page.evaluate(()=>{const s=document.querySelector('.hm-orgchart-host').shadowRoot,v=s.querySelector('.hm-orgchart-viewport'),f=s.querySelector('.hm-orgchart-move-feedback'),c=getComputedStyle(f);return {width:innerWidth,scrollWidth:document.documentElement.scrollWidth,viewportWidth:v.clientWidth,rawInnerScrollWidth:v.scrollWidth,feedbackHeight:f.clientHeight,feedbackScrollHeight:f.scrollHeight,feedbackColor:c.color,feedbackFontWeight:c.fontWeight,unresolvedTokens:[...['--hm-orgchart-resolved-fg-secondary','--hm-orgchart-resolved-action-primary','--hm-orgchart-resolved-stroke-default','--hm-orgchart-resolved-stroke-input-focus']].filter(k=>!getComputedStyle(f).getPropertyValue(k).trim())};}); + check(metrics.scrollWidth<=width+1&&metrics.viewportWidth>0&&metrics.feedbackScrollHeight<=metrics.feedbackHeight+1&&metrics.unresolvedTokens.length===0,`${width}: responsive move instructions, viewport and token declarations`,metrics); + await page.screenshot({path:path.join(output,`expanded-${width}.png`),fullPage:true}); +} +check(errors.length===0,'No new console/page errors',errors); +await fs.writeFile(path.join(output,'preservation.json'),JSON.stringify({origin,results,errors,fixedSnapshot:state},null,2)); +await browser.close();console.log(JSON.stringify({pass:results.filter(x=>x.status==='PASS').length,fail:results.filter(x=>x.status==='FAIL').length})); +if(results.some(x=>x.status==='FAIL'))process.exitCode=1; diff --git a/examples/org-chart/verify-p9-3.mjs b/examples/org-chart/verify-p9-3.mjs new file mode 100644 index 0000000..5d1c557 --- /dev/null +++ b/examples/org-chart/verify-p9-3.mjs @@ -0,0 +1,400 @@ +// P9.3 browser-state editing only. Exact section names allow failed probes to +// rerun without replaying previously successful P7-P9.2 or P9.3 checks. +import fs from 'node:fs/promises'; +import { writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +import { companyColorPresets } from '../../packages/org-chart/dist/index.js'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-p9-3'); +const origin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6064'; +const only = process.argv.slice(2), suffix = only.length ? `-${only.join('-')}` : ''; +const results = [], measurements = [], errors = [], startedAt = new Date().toISOString(); +const colors = Object.fromEntries(Object.values(companyColorPresets).map(preset => [preset.companyId, + `rgb(${[1, 3, 5].map(index => parseInt(preset.primary.slice(index, index + 2), 16)).join(', ')})`])); +await fs.mkdir(output, { recursive: true }); +const persist = () => writeFileSync(path.join(output, `browser-progress${suffix}.json`), JSON.stringify({ startedAt, results, measurements, errors }, null, 2)); +const check = (pass, name, detail) => { + results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); persist(); + if (!pass) console.log('FAIL', name, JSON.stringify(detail)); +}; +const browser = await launchChromium({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1920, height: 1700 } }); +page.setDefaultTimeout(15000); +page.on('pageerror', error => { errors.push({ type: 'pageerror', message: error.message }); persist(); }); +page.on('console', message => { if (message.type() === 'error') { errors.push({ type: 'console', message: message.text(), location: message.location() }); persist(); } }); +const viewport = () => page.locator('.hm-orgchart-viewport'); +const employee = id => page.locator(`.hm-orgchart-employee[data-employee-id="${id}"]`); +const node = id => page.locator(`.node[data-organization-id="${id}"]`); +const title = id => page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`); +const feedback = () => page.locator('.hm-orgchart-move-feedback'); +const companyTrigger = () => page.getByRole('button', { name: /^회사 선택/ }); +const companyPanel = () => page.getByRole('dialog', { name: '회사 선택', exact: true }); +const companyCheckbox = name => companyPanel().getByRole('checkbox', { name, exact: true }); +const dialog = () => page.locator('[data-component="HMOrgChartEmployeeDialog"]'); +const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 8; i++) await new Promise(requestAnimationFrame); }); +async function ready(count) { + await page.waitForFunction(count => { + const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; + return shadow && !shadow.querySelector('[aria-busy="true"]') && [...shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet) + && (count === undefined || shadow.querySelectorAll('.hm-orgchart-employee').length === count); + }, count); + await settle(); +} +async function select(label, option) { + await page.getByRole('combobox', { name: label, exact: true }).click(); + await page.getByRole('option', { name: option, exact: true }).click(); await ready(); +} +async function chooseCompanies(names) { + await companyTrigger().click(); await companyPanel().waitFor(); + const all = companyCheckbox('전체 선택'); + // The established mixed select-all action selects all first, then none. + for (let i = 0; i < 2 && await all.getAttribute('aria-checked') !== 'false'; i++) { await all.click(); await ready(); } + await ready(0); + for (const name of names) { await companyCheckbox(name).click(); await ready(); } + await page.keyboard.press('Escape'); await companyPanel().waitFor({ state: 'hidden' }); await ready(); +} +async function setup({ companies = ['한맥기술'], layout = 'tb' } = {}) { + await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); + await companyTrigger().waitFor(); await ready(20); + await chooseCompanies(companies); + await select('데이터 범위', '회사별'); + await page.getByRole('button', { name: '데이터 적용', exact: true }).click(); await ready(companies.length * 20); + if (layout !== 'tb') await select('조직도 표시 유형', layout === 'lr' ? '가로형 LR' : '혼합형 Hybrid'); + await viewport().scrollIntoViewIfNeeded(); await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); +} +async function state() { + return page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + const viewport = shadow.querySelector('.hm-orgchart-viewport'), expanded = shadow.querySelector('.hm-orgchart-expanded'); + 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 }; }; + const nodes = [...shadow.querySelectorAll('.node[data-organization-id]')].map(element => ({ + id: element.dataset.organizationId, parentId: element.dataset.parentId, companyId: element.dataset.companyId, + domParentId: element.closest('.hierarchy')?.parentElement?.closest('.hierarchy')?.querySelector(':scope > .node')?.dataset.organizationId ?? '', + direct: Number(element.dataset.directEmployeeCount), inclusive: Number(element.dataset.inclusiveEmployeeCount), collapsed: element.dataset.collapsed, + })); + const cards = [...shadow.querySelectorAll('.hm-orgchart-employee')].map(element => ({ + id: element.dataset.employeeId, organizationId: element.closest('.node').dataset.organizationId, companyId: element.dataset.companyId, + direction: element.dataset.connectionDirection, kind: element.dataset.cardKind, + border: { top: getComputedStyle(element).borderTopColor, left: getComputedStyle(element).borderLeftColor, right: getComputedStyle(element).borderRightColor }, + })); + const paths = [...shadow.querySelectorAll('.hm-orgchart-organization-connectors path')].map(path => ({ id: path.dataset.organizationId, parentId: path.dataset.parentId, d: path.getAttribute('d') })); + const employeePaths = [...shadow.querySelectorAll('.hm-orgchart-employee-connectors path')].map(path => ({ id: path.dataset.employeeId, organizationId: path.closest('.node').dataset.organizationId, direction: path.dataset.direction, d: path.getAttribute('d') })); + const feedback = shadow.querySelector('.hm-orgchart-move-feedback'); + return { layout: expanded?.dataset.autoLayout, scale: Number(viewport.dataset.scale), panX: Number(viewport.dataset.panX), panY: Number(viewport.dataset.panY), + viewport: rect(viewport), forest: rect(shadow.querySelector('.hm-orgchart-forest')), nodes, cards, paths, employeePaths, feedback: { status: feedback?.dataset.moveStatus, text: feedback?.textContent }, + drop: [...shadow.querySelectorAll('[data-drop-state]')].map(element => ({ id: element.dataset.organizationId, state: element.dataset.dropState, hover: element.dataset.dropHover })), + dragging: viewport.dataset.editDragging, panDragging: viewport.dataset.dragging, activeEmployee: shadow.activeElement?.dataset.employeeId, + activeOrganization: shadow.activeElement?.dataset.organizationId, + countText: document.querySelector('[data-component="OrgChartExampleCounts"]')?.textContent.trim(), dialogs: document.querySelectorAll('[data-component="HMOrgChartEmployeeDialog"]').length, + employeesVisible: cards.length, employeeCount: cards.length, organizationCount: nodes.length, + companyRoots: nodes.filter(org => org.parentId === '').map(org => org.companyId), + }; + }); +} +const relationState = s => JSON.stringify({ nodes: s.nodes, cards: s.cards.map(({ id, organizationId, direction }) => ({ id, organizationId, direction })) }); +async function blankPoint() { + return page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot, viewport = shadow.querySelector('.hm-orgchart-viewport'), box = viewport.getBoundingClientRect(); + for (const [dx, dy] of [[8, 8], [8, box.height - 8], [box.width - 8, 8], [box.width - 8, box.height - 8], [box.width / 2, box.height - 8]]) { + const point = { x: box.left + dx, y: box.top + dy }, hit = shadow.elementFromPoint(point.x, point.y); + if (hit && !hit.closest('button,a,.hm-orgchart-employee,.node')) return point; + } + throw new Error('No visible blank Pan point found'); + }); +} +async function pan(dx, dy) { + const p = await blankPoint(); await page.mouse.move(p.x, p.y); await page.mouse.down(); + const v = await viewport().boundingBox(); + // Cross the six-pixel Pan threshold inside the viewport before the requested + // motion. A large first upward step from its top edge never reaches the + // viewport's pointermove listener and therefore cannot establish capture. + await page.mouse.move(p.x + Math.sign(v.x + v.width / 2 - p.x) * 8, p.y, { steps: 2 }); + await page.mouse.move(p.x + dx, p.y + dy, { steps: 8 }); await page.mouse.up(); await settle(); +} +async function centerPair(source, target) { + await viewport().scrollIntoViewIfNeeded(); await source.focus(); await settle(); + for (let pass = 0; pass < 3; pass++) { + const a = await source.boundingBox(), b = await target.boundingBox(), v = await viewport().boundingBox(); + if (!a || !b || !v) throw new Error('Drag source or target has no geometry'); + const left = Math.min(a.x, b.x), right = Math.max(a.x + a.width, b.x + b.width); + const top = Math.min(a.y, b.y), bottom = Math.max(a.y + a.height, b.y + b.height); + if (right - left > v.width - 24 || bottom - top > v.height - 24) throw new Error(`Drag pair exceeds viewport at current scale: ${JSON.stringify({ a, b, v })}`); + const dx = v.x + v.width / 2 - (left + right) / 2, dy = v.y + v.height / 2 - (top + bottom) / 2; + if (Math.abs(dx) < 1 && Math.abs(dy) < 1) break; + await pan(dx, dy); + } +} +async function drag(source, target, { inspect = false, screenshot } = {}) { + await centerPair(source, target); + const a = await source.boundingBox(), b = await target.boundingBox(); + const before = await state(); + await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2); await page.mouse.down(); + await page.mouse.move(a.x + a.width / 2 + 8, a.y + a.height / 2, { steps: 2 }); + const started = inspect ? await state() : undefined; + await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 14 }); + const hover = inspect ? await state() : undefined; + if (screenshot) await snap(screenshot); + await page.mouse.up(); await ready(); + return { before, started, hover, pointer: { source: a, target: b }, after: await state() }; +} +async function snap(name) { await page.screenshot({ path: path.join(output, `${name}.png`), fullPage: false }); } +async function section(name, action) { + if (only.length && !only.includes(name) && !(name.startsWith('mode-') && only.includes('modes'))) return; + try { await action(); } catch (error) { + check(false, `${name}: execution completed`, { message: error.message, stack: error.stack }); + await page.mouse.up().catch(() => {}); await snap(`failure-${name}`).catch(() => {}); + } +} +function checkConsistency(s, name, total = 20) { + const uniqueEmployees = new Set(s.cards.map(card => card.id)), uniqueOrganizations = new Set(s.nodes.map(org => org.id)); + check(uniqueEmployees.size === s.cards.length && uniqueOrganizations.size === s.nodes.length && s.cards.length === total, `${name}: unique cards/groups and total preserved`, { cards: s.cards.length, groups: s.nodes.length, total }); + check(s.nodes.every(org => org.parentId === org.domParentId), `${name}: dabeng DOM hierarchy matches current parentId`); + check(s.cards.every(card => card.border[card.direction] === colors[card.companyId]), `${name}: company accent border matches each connector direction`); + check(s.employeePaths.length === s.cards.length && new Set(s.employeePaths.map(path => path.id)).size === s.cards.length + && s.employeePaths.every(path => path.d && !/NaN|Infinity/.test(path.d) && s.cards.some(card => card.id === path.id && card.organizationId === path.organizationId && card.direction === path.direction)), `${name}: employee connectors refreshed once per card`); + check(s.paths.every(path => path.d && !/NaN|Infinity/.test(path.d) && s.nodes.some(org => org.id === path.id && org.parentId === path.parentId)) + && new Set(s.paths.map(path => path.id)).size === s.paths.length, `${name}: organization connectors use current parents without duplicate paths`); +} + +await section('staff', async () => { + await setup(); + const result = await drag(employee('virtual-022'), title('hanmac-office-sibling'), { inspect: true, screenshot: 'tb-staff-drop-hover' }); + measurements.push({ name: 'TB employee move', ...result }); + check(result.started.dragging === 'true' && result.started.drop.some(target => target.state === 'allowed') && result.started.drop.some(target => target.state === 'blocked'), 'Employee threshold starts editing and marks allowed/blocked targets'); + check(result.hover.drop.find(target => target.id === 'hanmac-office-sibling')?.hover === 'allowed', 'Employee valid target hover is identified'); + check(result.after.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-sibling' && !result.after.cards.some(card => card.id === 'virtual-022' && card.organizationId === 'hanmac-office-0'), 'Employee membership moves to new group once'); + check(result.after.feedback.status === 'success' && result.after.dialogs === 0 && result.after.drop.length === 0, 'Employee success announces completion and suppresses click/Dialog'); + check(Math.abs(result.before.scale - result.after.scale) < .0001 && Math.abs(result.before.panX - result.hover.panX) < .1 && Math.abs(result.before.panY - result.hover.panY) < .1 && !result.hover.panDragging, 'Card Drag preserves Zoom and does not execute Pan'); + const sourceBefore = result.before.nodes.find(org => org.id === 'hanmac-office-0'), sourceAfter = result.after.nodes.find(org => org.id === 'hanmac-office-0'); + const targetBefore = result.before.nodes.find(org => org.id === 'hanmac-office-sibling'), targetAfter = result.after.nodes.find(org => org.id === 'hanmac-office-sibling'); + check(sourceAfter.direct === sourceBefore.direct - 1 && sourceAfter.inclusive === sourceBefore.inclusive - 1 && targetAfter.direct === targetBefore.direct + 1 && targetAfter.inclusive === targetBefore.inclusive + 1, 'Employee source/destination direct and inclusive counts update'); + checkConsistency(result.after, 'TB employee'); + await employee('virtual-022').click(); await dialog().waitFor(); + const organization = await dialog().locator('dl > div').filter({ has: page.locator('dt', { hasText: '소속 조직' }) }).locator('dd').innerText(); + check(organization === '가상 계획실' && await dialog().getAttribute('data-employee-id') === 'virtual-022', 'HM Dialog shows new organization and same employee ID', organization); + await page.keyboard.press('Escape'); await dialog().waitFor({ state: 'hidden' }); await settle(); + check((await state()).activeEmployee === 'virtual-022', 'HM Dialog returns focus to moved card'); + await snap('tb-staff-moved'); +}); + +await section('groups', async () => { + await setup(); + const result = await drag(title('hanmac-office-0'), title('hanmac-office-1'), { inspect: true, screenshot: 'tb-group-drop-hover' }); + measurements.push({ name: 'TB group move', ...result }); + check(result.after.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-office-1', 'Group parentId changes to dropped organization'); + const descendants = ['hanmac-cell-0', 'hanmac-team-0', 'hanmac-team-sibling']; + check(descendants.every(id => result.after.nodes.find(org => org.id === id)?.parentId === result.before.nodes.find(org => org.id === id)?.parentId) + && result.after.cards.every(card => result.before.cards.find(before => before.id === card.id)?.organizationId === card.organizationId), 'Group move retains subtree and every employee membership'); + const inclusive = result.before.nodes.find(org => org.id === 'hanmac-office-0').inclusive; + check(result.after.nodes.find(org => org.id === 'hanmac-division-0').inclusive === result.before.nodes.find(org => org.id === 'hanmac-division-0').inclusive - inclusive + && result.after.nodes.find(org => org.id === 'hanmac-division-1').inclusive === result.before.nodes.find(org => org.id === 'hanmac-division-1').inclusive + inclusive, 'Group move updates ancestor inclusive counts'); + check(result.after.feedback.status === 'success' && result.after.nodes.find(org => org.id === 'hanmac-office-0').collapsed === 'false' && result.after.dialogs === 0, 'Title Drag suppresses collapse and Dialog'); + checkConsistency(result.after, 'TB group'); await snap('tb-group-moved'); + await title('hanmac-office-0').click(); await ready(); check(await title('hanmac-office-0').getAttribute('aria-expanded') === 'false', 'Moved group title click still collapses'); + await title('hanmac-office-0').press('Space'); await ready(20); check(await title('hanmac-office-0').getAttribute('aria-expanded') === 'true', 'Moved group Space still expands'); +}); + +await section('invalid', async () => { + await setup({ companies: ['한맥기술', '삼안'] }); + for (const [name, source, target, reason] of [ + ['protected leader', employee('virtual-002'), title('hanmac-office-sibling'), '조직장'], + ['group descendant cycle', title('hanmac-office-0'), title('hanmac-cell-0'), '순환'], + ['group self', title('hanmac-office-0'), title('hanmac-office-0'), '자기 자신'], + ['company root', title('hanmac-root'), title('hanmac-office-sibling'), '최상위'], + ]) { + const result = await drag(source, target, { inspect: true }); + check(relationState(result.before) === relationState(result.after) && result.after.feedback.status === 'failure' && result.after.feedback.text.includes(reason), `${name}: rejects with reason and retains data`, result.after.feedback); + const targetId = await target.getAttribute('data-organization-id'); + check(result.hover.drop.find(item => item.id === targetId)?.hover === 'blocked' && result.after.dialogs === 0, `${name}: blocked hover and no Dialog`); + } + await centerPair(employee('virtual-022'), title('hanmac-office-sibling')); + const before = await state(), source = await employee('virtual-022').boundingBox(), box = await viewport().boundingBox(); + await page.mouse.move(source.x + source.width / 2, source.y + source.height / 2); await page.mouse.down(); + await page.mouse.move(source.x + source.width / 2 + 8, source.y + source.height / 2); + await page.mouse.move(box.x + box.width + 12, box.y + 50, { steps: 12 }); await page.mouse.up(); await ready(); + const after = await state(); check(relationState(before) === relationState(after) && after.feedback.status === 'failure' && !after.dragging && after.dialogs === 0, 'Outside viewport release cancels edit without changing data', after.feedback); + await snap('invalid-drop-rejected'); +}); + +await section('pan-click', async () => { + await setup(); await centerPair(employee('virtual-022'), title('hanmac-office-sibling')); + const before = await state(); await pan(85, -45); const after = await state(); + check(Math.abs(after.panX - before.panX - 85) < 1 && Math.abs(after.panY - before.panY + 45) < 1 && after.scale === before.scale && relationState(after) === relationState(before), 'Blank-space pointer Pan preserves Zoom and every relationship'); + await centerPair(employee('virtual-022'), title('hanmac-office-sibling')); + const box = await employee('virtual-022').boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2 + 2, box.y + box.height / 2 + 1); const small = await state(); await page.mouse.up(); + await dialog().waitFor(); check(!small.dragging && await dialog().getAttribute('data-employee-id') === 'virtual-022', 'Movement below six-pixel threshold remains employee click'); + await page.keyboard.press('Escape'); await dialog().waitFor({ state: 'hidden' }); + await title('hanmac-office-sibling').click(); await ready(); check(await title('hanmac-office-sibling').getAttribute('aria-expanded') === 'false', 'Simple group click collapses'); + await title('hanmac-office-sibling').click(); await ready(20); check(await title('hanmac-office-sibling').getAttribute('aria-expanded') === 'true', 'Simple group click expands'); +}); + +await section('zoom', async () => { + await setup(); + let current = 'hanmac-office-0'; + for (const scale of [1, .65, 1.4]) { + await page.getByRole('button', { name: '100% 복원', exact: true }).click(); await settle(); + if (scale !== 1) { const p = await blankPoint(); await page.mouse.move(p.x, p.y); await page.keyboard.down('Control'); await page.mouse.wheel(0, -Math.log(scale) / .002); await page.keyboard.up('Control'); await settle(); } + check(Math.abs((await state()).scale - scale) < .002, `${scale}: actual Ctrl+wheel Zoom reaches requested scale`); + const target = current === 'hanmac-office-0' ? 'hanmac-office-sibling' : 'hanmac-office-0'; + const result = await drag(employee('virtual-022'), title(target)); + check(result.after.cards.find(card => card.id === 'virtual-022')?.organizationId === target && Math.abs(result.after.scale - scale) < .002 && result.after.dialogs === 0, `${scale}: actual pointer Drop uses transformed screen coordinates`); + measurements.push({ name: `zoom-${scale}`, before: result.before, after: result.after }); current = target; + } + await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); + await pan(48, 24); + const target = current === 'hanmac-office-0' ? 'hanmac-office-sibling' : 'hanmac-office-0'; + const result = await drag(employee('virtual-022'), title(target)); + check(result.after.cards.find(card => card.id === 'virtual-022')?.organizationId === target && result.after.scale === result.before.scale, 'Drop after Fit and Pan uses current transformed target'); + await snap('zoom-pan-drop-complete'); +}); + +for (const layout of ['lr', 'hybrid']) await section(`mode-${layout}`, async () => { + await setup({ layout }); + const staff = await drag(employee('virtual-022'), title('hanmac-office-sibling')); + check(staff.after.layout === layout && staff.after.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-sibling' && staff.after.feedback.status === 'success', `${layout}: actual employee Drag supported`); + const group = await drag(title('hanmac-office-0'), title('hanmac-office-1'), { inspect: true }); + check(group.after.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-office-1' && group.after.feedback.status === 'success', `${layout}: actual group Drag supported`); + check(group.after.paths.some(path => path.id === 'hanmac-office-0' && path.parentId === 'hanmac-office-1'), `${layout}: moved group connector uses new parent`); + checkConsistency(group.after, layout); measurements.push({ name: `${layout}-moves`, staff: staff.after, group: group.after, groupDrag: group }); + await snap(`${layout}-group-moved`); +}); + +await section('retained', async () => { + await setup(); + await title('hanmac-office-sibling').click(); await ready(); + const moved = await drag(employee('virtual-022'), title('hanmac-office-sibling')); + check(moved.after.feedback.status === 'success' && await title('hanmac-office-sibling').getAttribute('aria-expanded') === 'false' && await employee('virtual-022').count() === 0, 'Drop onto collapsed target updates relationship and retains collapsed view'); + await title('hanmac-office-sibling').click(); await ready(20); + check((await state()).cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-sibling', 'Expanding target reveals moved employee once'); + await title('hanmac-office-0').click(); await ready(); + const group = await drag(title('hanmac-office-0'), title('hanmac-office-1')); + check(group.after.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-office-1' && await title('hanmac-office-0').getAttribute('aria-expanded') === 'false', 'Moving collapsed source group retains its collapsed state'); + await title('hanmac-office-0').click(); await ready(20); + check((await state()).nodes.find(org => org.id === 'hanmac-cell-0')?.parentId === 'hanmac-office-0', 'Moved collapsed subtree is restored when source expands'); + await chooseCompanies([]); await ready(0); await chooseCompanies(['한맥기술']); await ready(20); + const shown = await state(); check(shown.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-sibling' && shown.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-office-1', 'Company hide/re-show retains employee and group edits without duplicate data'); + checkConsistency(shown, 'Re-shown company'); + await page.getByRole('button', { name: '초기화', exact: true }).click(); await ready(20); + const reset = await state(); check(reset.companyRoots.length === 7 && reset.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-division-0' && reset.feedback.status === 'idle', 'Reset regenerates original virtual data/settings and clears move feedback'); +}); + +// A failed reset assertion can be checked without repeating successful +// collapsed-target/company-selection probes in the retained section. +await section('reset', async () => { + await setup(); await drag(title('hanmac-office-0'), title('hanmac-office-1')); + const edited = await state(); + await page.getByRole('button', { name: '초기화', exact: true }).click(); await ready(20); + const reset = await state(); + check(edited.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-office-1' + && reset.companyRoots.length === 7 && reset.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-division-0' && reset.feedback.status === 'idle', + 'Reset clears actual group edit, restores seven company roots and original membership, and clears feedback', { before: edited.feedback, after: reset.feedback, roots: reset.companyRoots }); +}); + +await section('hybrid-cross-staff', async () => { + await setup({ companies: ['한맥기술', '삼안'], layout: 'hybrid' }); + const staff = await drag(employee('virtual-022'), title('saman-office-0'), { inspect: true, screenshot: 'hybrid-cross-company-staff-hover' }); + const card = staff.after.cards.find(card => card.id === 'virtual-022'); + check(staff.hover.drop.find(target => target.id === 'saman-office-0')?.hover === 'allowed' + && card?.organizationId === 'saman-office-0' && card.companyId === 'saman' && card.border[card.direction] === colors.saman && staff.after.feedback.status === 'success', + 'hybrid: cross-company staff Drag updates organization, company and color', staff.after.feedback); + check(staff.after.nodes.find(org => org.id === 'hanmac-root').inclusive === 19 && staff.after.nodes.find(org => org.id === 'saman-root').inclusive === 21 + && staff.after.cards.length === 40 && new Set(staff.after.cards.map(card => card.id)).size === 40 && staff.after.dialogs === 0, + 'hybrid: cross-company staff updates both company totals without duplication/click'); + await employee('virtual-022').click(); await dialog().waitFor(); + const company = await dialog().locator('dl > div').filter({ has: page.locator('dt', { hasText: '소속 회사' }) }).locator('dd').innerText(); + const organization = await dialog().locator('dl > div').filter({ has: page.locator('dt', { hasText: '소속 조직' }) }).locator('dd').innerText(); + check(company === '삼안' && organization === '가상 1실', 'Hybrid cross-company HM Dialog shows changed company and organization', { company, organization }); +}); + +await section('cross-company', async () => { + for (const layout of ['tb', 'lr', 'hybrid']) { + await setup({ companies: ['한맥기술', '삼안'], layout }); + let initial = await state(); + if (layout !== 'hybrid') { + const staff = await drag(employee('virtual-022'), title('saman-office-0'), { inspect: true, screenshot: `${layout}-cross-company-staff-hover` }); + const card = staff.after.cards.find(card => card.id === 'virtual-022'); + check(staff.hover.drop.find(target => target.id === 'saman-office-0')?.hover === 'allowed' + && card?.organizationId === 'saman-office-0' && card.companyId === 'saman' && card.border[card.direction] === colors.saman && staff.after.feedback.status === 'success', `${layout}: cross-company staff Drag updates organization, company and color`, staff.after.feedback); + check(staff.after.nodes.find(org => org.id === 'hanmac-root').inclusive === 19 && staff.after.nodes.find(org => org.id === 'saman-root').inclusive === 21 + && staff.after.cards.length === 40 && staff.after.dialogs === 0, `${layout}: cross-company staff updates both company totals without duplication/click`); + if (layout === 'tb') { + await employee('virtual-022').click(); await dialog().waitFor(); + const company = await dialog().locator('dl > div').filter({ has: page.locator('dt', { hasText: '소속 회사' }) }).locator('dd').innerText(); + const organization = await dialog().locator('dl > div').filter({ has: page.locator('dt', { hasText: '소속 조직' }) }).locator('dd').innerText(); + check(company === '삼안' && organization === '가상 1실', 'Cross-company HM Dialog shows changed company and organization', { company, organization }); + await page.keyboard.press('Escape'); await dialog().waitFor({ state: 'hidden' }); await settle(); + } + initial = staff.after; + } + const subtree = new Set(['hanmac-office-0', 'hanmac-cell-0', 'hanmac-team-0', 'hanmac-team-sibling']); + const transferred = initial.nodes.find(org => org.id === 'hanmac-office-0').inclusive; + const subtreeEmployees = initial.cards.filter(card => subtree.has(card.organizationId)).map(card => card.id); + const group = await drag(title('hanmac-office-0'), title('saman-office-1'), { inspect: true, screenshot: `${layout}-cross-company-group-hover` }); + check(group.hover.drop.find(target => target.id === 'saman-office-1')?.hover === 'allowed' + && group.after.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'saman-office-1' && group.after.feedback.status === 'success', `${layout}: cross-company group Drag changes real parent`, group.after.feedback); + check(group.after.nodes.filter(org => subtree.has(org.id)).every(org => org.companyId === 'saman') + && subtreeEmployees.every(id => { const card = group.after.cards.find(card => card.id === id); return card?.companyId === 'saman' && subtree.has(card.organizationId) && card.border[card.direction] === colors.saman; }) + && group.after.nodes.find(org => org.id === 'hanmac-cell-0')?.parentId === 'hanmac-office-0', `${layout}: entire transferred subtree retains IDs/hierarchy and receives target company colors`); + check(group.after.nodes.find(org => org.id === 'hanmac-root').inclusive === initial.nodes.find(org => org.id === 'hanmac-root').inclusive - transferred + && group.after.nodes.find(org => org.id === 'saman-root').inclusive === initial.nodes.find(org => org.id === 'saman-root').inclusive + transferred, `${layout}: cross-company group updates both company headcounts`); + checkConsistency(group.after, `${layout} cross-company`, 40); + measurements.push({ name: `${layout} cross-company transfers`, initial, groupDrag: group }); await snap(`${layout}-cross-company-complete`); + if (layout === 'tb') { + const samanCount = group.after.nodes.find(org => org.id === 'saman-root').inclusive; + const hanmacCount = group.after.nodes.find(org => org.id === 'hanmac-root').inclusive; + await chooseCompanies(['삼안']); await ready(samanCount); let shown = await state(); + check(shown.cards.length === samanCount && shown.countText.includes(`합계 ${samanCount}명`) && shown.cards.some(card => card.id === 'virtual-022') + && shown.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'saman-office-1', 'Destination-only selection retains transferred employee/subtree and shows current destination headcount', shown.countText); + await chooseCompanies(['한맥기술']); await ready(hanmacCount); shown = await state(); + check(shown.cards.length === hanmacCount && shown.countText.includes(`합계 ${hanmacCount}명`) && !shown.cards.some(card => card.id === 'virtual-022') + && !shown.nodes.some(org => org.id === 'hanmac-office-0'), 'Source-only selection excludes transferred data and shows current source headcount', shown.countText); + await chooseCompanies(['한맥기술', '삼안']); await ready(40); shown = await state(); + check(new Set(shown.cards.map(card => card.id)).size === 40 && shown.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'saman-office-1', 'Both companies re-show preserves cross-company edits without duplicate cards'); + } + } +}); + +await section('keyboard', async () => { + await setup(); await employee('virtual-022').focus(); await settle(); + await page.keyboard.press('Alt+m'); await settle(); const opened = await state(); + check(opened.dragging === 'true' && opened.drop.some(target => target.hover === 'allowed'), 'Alt+M starts keyboard movement with an allowed target'); + const initialHover = opened.drop.find(target => target.hover)?.id; + await page.keyboard.press('ArrowRight'); await settle(); check((await state()).drop.find(target => target.hover)?.id !== initialHover, 'Keyboard Arrow changes target'); + await page.keyboard.press('Escape'); await settle(); const cancelled = await state(); + check(!cancelled.dragging && cancelled.feedback.status === 'cancelled' && cancelled.activeEmployee === 'virtual-022' && cancelled.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-0', 'Escape cancels keyboard edit, retains data and returns source focus'); + await page.keyboard.press('Alt+m'); await settle(); + for (let i = 0; i < 30 && (await state()).drop.find(target => target.hover)?.id !== 'hanmac-office-sibling'; i++) { await page.keyboard.press('ArrowRight'); await settle(); } + check((await state()).drop.find(target => target.hover)?.id === 'hanmac-office-sibling', 'Keyboard selects intended same-company destination'); + await page.keyboard.press('Enter'); await ready(20); const completed = await state(); + check(completed.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-sibling' && completed.feedback.status === 'success' && completed.dialogs === 0, 'Keyboard Enter commits movement without employee Dialog'); + await title('hanmac-office-0').focus(); await settle(); await page.keyboard.press('Alt+m'); await settle(); + for (let i = 0; i < 30 && (await state()).drop.find(target => target.hover)?.id !== 'hanmac-office-1'; i++) { await page.keyboard.press('ArrowDown'); await settle(); } + await page.keyboard.press('Enter'); await ready(20); + check((await state()).nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-office-1', 'Keyboard group move changes parent through same policy'); + await snap('keyboard-move-complete'); +}); + +await section('fit-cross', async () => { + for (const layout of ['tb', 'lr', 'hybrid']) { + await setup({ companies: ['한맥기술', '삼안'], layout }); + await drag(title('hanmac-office-0'), title('saman-office-1')); + await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); + const fitted = await state(); + check(fitted.feedback.status === 'success' && fitted.forest.x >= fitted.viewport.x - 1 && fitted.forest.right <= fitted.viewport.right + 1 + && fitted.forest.y >= fitted.viewport.y - 1 && fitted.forest.bottom <= fitted.viewport.bottom + 1, + `${layout}: Fit contains recalculated forest bounds after cross-company subtree transfer`, { viewport: fitted.viewport, forest: fitted.forest, scale: fitted.scale }); + measurements.push({ name: `${layout} post-transfer Fit`, state: fitted }); await snap(`${layout}-cross-company-fit`); + } +}); + +check(errors.length === 0, 'New console/page errors: zero', errors); +const result = { startedAt, completedAt: new Date().toISOString(), browser: browser.version(), origin, sections: only, results, measurements, errors, + notVerified: ['Physical touch device', 'Actual screen reader'], pass: results.filter(item => item.status === 'PASS').length, fail: results.filter(item => item.status === 'FAIL').length }; +await fs.writeFile(path.join(output, `browser-editing${suffix}.json`), JSON.stringify(result, null, 2)); +console.log(JSON.stringify({ pass: result.pass, fail: result.fail, errors: errors.length, output: `test-results/org-chart-p9-3/browser-editing${suffix}.json` })); +await browser.close(); if (result.fail) process.exitCode = 1; diff --git a/examples/org-chart/verify-p9-4-core.mjs b/examples/org-chart/verify-p9-4-core.mjs new file mode 100644 index 0000000..dde4d0a --- /dev/null +++ b/examples/org-chart/verify-p9-4-core.mjs @@ -0,0 +1,100 @@ +// Focused public Vanilla API and dynamic-option parity with React Story evidence. +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +const output = path.resolve('test-results/org-chart-p9-4'); +const origin = (process.env.ORGCHART_CORE_ORIGIN ?? 'http://127.0.0.1:6074').replace(/\/$/, ''); +const only = process.argv.slice(2), run = name => !only.length || only.includes(name), suffix = only.length ? `-${only.join('-')}` : ''; +const results = [], errors = [], measurements = [], startedAt = new Date().toISOString(); +await fs.mkdir(output, { recursive:true }); +const check = (pass, name, detail) => { results.push({ name, status:pass ? 'PASS':'FAIL', ...(detail === undefined ? {} : { detail }) }); if (!pass) console.log('FAIL', name, JSON.stringify(detail)); }; +const browser = await launchChromium({ headless:true }), page = await browser.newPage({ viewport:{ width:1440,height:1000 } }); +page.setDefaultTimeout(15000); +page.on('pageerror', error => errors.push({ type:'pageerror', message:error.message })); +page.on('console', message => { if (message.type() === 'error') errors.push({ type:'console', message:message.text() }); }); +const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 8; i++) await new Promise(requestAnimationFrame); }); +async function ready() { await page.waitForFunction(() => window.fixtureReady && window.chart?.getState().status === 'ready' && [...window.shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet)); await settle(); } +async function fresh(extra, mode = 'auto') { + await page.goto(`${origin}/test-results/org-chart-p9-4/vanilla.html`); await ready(); + if (extra || mode !== 'auto') { await page.evaluate(({ extra, mode }) => window.mountChart(mode, extra ?? {}), { extra,mode }); await ready(); } +} +async function section(name, action) { if (!run(name)) return; try { await action(); } catch (error) { check(false, `${name}: execution completed`, { message:error.message,stack:error.stack }); await page.mouse.up().catch(() => {}); await page.screenshot({ path:path.join(output,`core-failure-${name}.png`) }).catch(() => {}); } } +const employee = id => page.locator(`.hm-orgchart-employee[data-employee-id="${id}"]`); +const requests = [ + { kind:'employee',sourceId:'virtual-022',targetOrganizationId:'hanmac-office-sibling' }, + { kind:'organization',sourceId:'hanmac-office-0',targetOrganizationId:'hanmac-office-1' }, + { kind:'employee',sourceId:'virtual-022',targetOrganizationId:'saman-office-0' }, + { kind:'organization',sourceId:'hanmac-office-0',targetOrganizationId:'saman-office-1' }, +]; +await section('api', async () => { + await fresh(); + const before = await page.evaluate(() => ({ enabled:window.chart.getDragDropEnabled(), data:JSON.stringify(window.chart.getData()), loads:window.loads })); + check(before.enabled === false && before.loads === 1, 'Vanilla omitted option defaults OFF and loads source once', before.loads); + for (const request of requests) { + const rejection = await page.evaluate(request => window.chart.move(request), request); + check(!rejection.moved && rejection.code === 'EDITING_DISABLED' && await page.evaluate(data => JSON.stringify(window.chart.getData()) === data, before.data), `OFF public move rejects ${request.kind} to ${request.targetOrganizationId} without changing data`, rejection); + } + const pure = await page.evaluate(request => window.mod.applyOrgChartMove(window.fixture, request), requests[0]); + check(pure.moved, 'Pure data transformation remains compatible outside interactive OFF policy', pure.moved ? pure.request : pure); + const legacy = await page.evaluate(() => { + const original = JSON.stringify(window.template); + const total = window.mod.createOrgChartExampleDataset(window.template, { scope:'total',employeeCount:100 }); + const perCompany = window.mod.createOrgChartExampleDataset(window.template, { scope:'per-company',employeeCount:20,companyIds:['hanmac','saman'] }); + return { total:total.employees.length,totalValid:window.mod.validateOrgChartDataset(total).valid,perCompany:perCompany.employees.length,sourceUnchanged:JSON.stringify(window.template) === original }; + }); + check(legacy.total === 100 && legacy.totalValid && legacy.perCompany === 40 && legacy.sourceUnchanged, 'Public total scope remains compatible while Expanded uses company counts', legacy); + const toggle = await page.evaluate(() => { + const data = window.chart.getData(), forest = window.shadow.querySelector('.hm-orgchart-forest'), viewport = window.chart.getViewportState(); + window.chart.setDragDropEnabled(true); const on = window.chart.getDragDropEnabled(); window.chart.setDragDropEnabled(false); const off = window.chart.getDragDropEnabled(); + return { on,off,dataSame:data === window.chart.getData(),forestSame:forest === window.shadow.querySelector('.hm-orgchart-forest'),viewportSame:JSON.stringify(viewport) === JSON.stringify(window.chart.getViewportState()),loads:window.loads }; + }); + check(toggle.on && !toggle.off && toggle.dataSame && toggle.forestSame && toggle.viewportSame && toggle.loads === before.loads, 'Vanilla setter changes policy without data reload, DOM remount or viewport reset', toggle); + await page.evaluate(() => window.chart.setDragDropEnabled(true)); + for (const request of requests) { + const moved = await page.evaluate(request => window.chart.move(request), request); await ready(); + check(moved.moved, `ON public move permits ${request.kind} to ${request.targetOrganizationId}`, moved.moved ? moved.request : moved); + } + const after = await page.evaluate(() => { + const data = window.chart.getData(), before = JSON.stringify(data), forest = window.shadow.querySelector('.hm-orgchart-forest'); window.chart.setDragDropEnabled(false); + return { same:JSON.stringify(window.chart.getData()) === before,owned:data === window.chart.getData(),forestSame:forest === window.shadow.querySelector('.hm-orgchart-forest'), + unique:new Set(data.employees.map(employee => employee.id)).size === data.employees.length,total:data.employees.length,valid:window.mod.validateOrgChartDataset(data).valid, + employee:data.employees.find(employee => employee.id === 'virtual-022'),group:data.organizations.find(group => group.id === 'hanmac-office-0'),fixturePreserved:JSON.stringify(window.fixture) === window.fixtureOriginal,loads:window.loads }; + }); + check(after.same && after.owned && after.forestSame && after.unique && after.total === 40 && after.valid && after.fixturePreserved && after.loads === 1 + && after.employee.organizationId === 'saman-office-0' && after.group.parentId === 'saman-office-1', 'Vanilla OFF preserves completed cross-company moves, unique IDs and original fixture without reload', after); + await page.screenshot({ path:path.join(output,'vanilla-on-off-preserved.png'),fullPage:true }); measurements.push({ name:'public API',before,toggle,after }); +}); +await section('cancel', async () => { + await fresh({ dragDropEnabled:true }); await page.evaluate(() => { window.pointerId = undefined; window.shadow.querySelector('.hm-orgchart-viewport').addEventListener('pointerdown', event => { window.pointerId = event.pointerId; }, { capture:true }); }); + const box = await employee('virtual-022').boundingBox(); + await page.mouse.move(box.x + box.width/2,box.y + box.height/2); await page.mouse.down(); await page.mouse.move(box.x + box.width/2 + 12,box.y + box.height/2,{ steps:2 }); await settle(); + const before = await page.evaluate(() => { const v = window.shadow.querySelector('.hm-orgchart-viewport'); return { dragging:v.dataset.editDragging,captured:v.hasPointerCapture(window.pointerId),data:JSON.stringify(window.chart.getData()) }; }); + const after = await page.evaluate(() => { window.chart.setDragDropEnabled(false); const v = window.shadow.querySelector('.hm-orgchart-viewport'); return { dragging:v.dataset.editDragging,captured:v.hasPointerCapture(window.pointerId),highlight:window.shadow.querySelectorAll('[data-drop-state],[data-drop-hover],[data-edit-source]').length,data:JSON.stringify(window.chart.getData()) }; }); + await page.mouse.up(); await settle(); + check(before.dragging === 'true' && before.captured && !after.dragging && !after.captured && after.highlight === 0 && before.data === after.data, 'Vanilla live pointer OFF releases capture and clears all pending/highlight state', { before:{ ...before,data:undefined },after:{ ...after,data:undefined } }); + check(await page.locator('.hm-orgchart-employee-dialog[open]').count() === 0 && await page.evaluate(data => JSON.stringify(window.chart.getData()) === data,before.data), 'Vanilla canceled pointer release neither commits nor opens Dialog'); + await page.evaluate(() => window.chart.setDragDropEnabled(true)); await employee('virtual-022').focus(); await page.keyboard.press('Alt+m'); await settle(); + const keyboardBefore = await page.evaluate(() => window.shadow.querySelector('.hm-orgchart-viewport').dataset.editDragging); + await page.evaluate(() => window.chart.setDragDropEnabled(false)); await page.keyboard.press('Enter'); await settle(); + check(keyboardBefore === 'true' && await page.evaluate(data => !window.shadow.querySelector('[data-edit-dragging],[data-drop-state],[data-drop-hover]') && JSON.stringify(window.chart.getData()) === data,before.data), 'Vanilla active keyboard move is canceled and stale Enter cannot commit'); +}); +await section('default', async () => { + for (const [flag,expected] of [[undefined,false],[false,false],[true,true]]) { + await fresh(flag === undefined ? undefined : { dragDropEnabled:flag }); + const actual = await page.evaluate(() => ({ value:window.chart.getDragDropEnabled(),attribute:window.shadow.querySelector('.hm-orgchart-viewport').dataset.dragDropEnabled,shortcuts:[...window.shadow.querySelectorAll('.hm-orgchart-employee,.hm-orgchart-expanded-toggle')].map(element => element.getAttribute('aria-keyshortcuts')) })); + check(actual.value === expected && actual.attribute === String(expected) && actual.shortcuts.every(value => expected ? value === 'Alt+M' : value === null), `Vanilla option ${String(flag)} matches React default/setter policy and keyboard affordances`, actual.value); + } +}); +await section('fixed', async () => { + await fresh({ dragDropEnabled:true },'fixed'); + const fixed = await page.evaluate(async () => { + let setterRejected = false; try { window.chart.setDragDropEnabled(true); } catch (error) { setterRejected = /Auto/.test(error.message); } + let moveRejected = false; try { await window.chart.move({ kind:'employee',sourceId:'emp-001',targetOrganizationId:'management-plan' }); } catch (error) { moveRejected = /Auto/.test(error.message); } + return { enabled:window.chart.getDragDropEnabled(),setterRejected,moveRejected,editingUI:window.shadow.querySelectorAll('[data-drag-drop-enabled],[data-edit-dragging],[data-drop-state],.hm-orgchart-move-feedback').length,loads:window.loads }; + }); + check(!fixed.enabled && fixed.setterRejected && fixed.moveRejected && fixed.editingUI === 0, 'Fixed ignores dragDropEnabled option, rejects Auto setter/move and adds no editing UI', fixed); +}); +check(errors.length === 0,'Vanilla new console/page errors: zero',errors); +const result = { startedAt,completedAt:new Date().toISOString(),browser:browser.version(),origin,sections:only,results,measurements,errors,pass:results.filter(item => item.status === 'PASS').length,fail:results.filter(item => item.status === 'FAIL').length }; +await fs.writeFile(path.join(output,`core${suffix}.json`),JSON.stringify(result,null,2)); await browser.close(); +console.log(JSON.stringify({ pass:result.pass,fail:result.fail,output:`test-results/org-chart-p9-4/core${suffix}.json` })); if (result.fail) process.exitCode = 1; diff --git a/examples/org-chart/verify-p9-4-preservation.mjs b/examples/org-chart/verify-p9-4-preservation.mjs new file mode 100644 index 0000000..9909369 --- /dev/null +++ b/examples/org-chart/verify-p9-4-preservation.mjs @@ -0,0 +1,38 @@ +// Compare protected source hashes to the captured pre-P9.4 state and one Fixed +// screen to existing P9.2 evidence. No full historical regression suite replay. +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +const output = path.resolve('test-results/org-chart-p9-4'), origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6064').replace(/\/$/, ''); +const results = [],errors = [],startedAt = new Date().toISOString(); +const check = (pass,name,detail) => { results.push({ name,status:pass ? 'PASS':'FAIL', ...(detail === undefined ? {} : { detail }) }); if (!pass) console.log('FAIL',name,JSON.stringify(detail)); }; +const fixedOnly = process.argv.includes('--fixed-only'); +const baseline = fixedOnly ? { files:[] } : JSON.parse(await fs.readFile(path.join(output,'baseline.json'),'utf8')); +const protectedPath = name => /^examples\/org-chart\/data.*\.json$/.test(name) || /^packages\/org-chart\/src\/assets\//.test(name) + || ['packages/org-chart/src/company-color-presets.ts','packages/org-chart/src/core/style-presets.ts','packages/org-chart/src/core/employee-card.ts', + 'packages/org-chart/src/styles/employee-card.css','packages/org-chart/src/core/workdesk-artwork.ts','packages/org-chart/src/core/workdesk-layout.ts', + 'src/org-chart/center-composition-preview.ts','src/org-chart/org-chart-full-composition.stories.tsx','src/org-chart/org-chart-department-colors.stories.tsx', + 'src/org-chart/org-chart-overview-internal.stories.tsx'].includes(name); +const protectedFiles = baseline.files.filter(file => protectedPath(file.path)); +const changed = []; +for (const file of protectedFiles) { const actual = createHash('sha256').update(await fs.readFile(path.resolve(file.path))).digest('hex').toUpperCase(); if (actual !== file.sha256.toUpperCase()) changed.push({ path:file.path,before:file.sha256,after:actual }); } +if (!fixedOnly) check(protectedFiles.length > 20 && changed.length === 0,'Pre-P9.4 fixtures, approved SVG/images, card renderer/styles, company/department presets and Fixed source hashes preserved',{ checked:protectedFiles.length,changed }); +const browser = await launchChromium({ headless:true }), page = await browser.newPage({ viewport:{ width:1440,height:900 } }); +page.on('pageerror',error => errors.push(error.message)); page.on('console',message => { if (message.type() === 'error') errors.push(message.text()); }); +await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`); +await page.waitForFunction(() => { const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; return shadow?.querySelector('.hm-orgchart-stage[data-workdesk-artwork]') && !shadow.querySelector('[aria-busy="true"]') && [...shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet); }); +await page.evaluate(async () => { await document.fonts.ready; await Promise.all([...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('img')].map(async image => { image.loading = 'eager'; await image.decode(); })); for (let i = 0; i < 12; i++) await new Promise(requestAnimationFrame); }); +const priorSource = await fs.readFile('examples/org-chart/verify-p9-2-composition.mjs','utf8'); +const from = priorSource.indexOf('async function snapshot()'),to = priorSource.indexOf('async function stability()',from); +const snapshot = await new Function('page',priorSource.slice(from,to) + '; return snapshot();')(page); +const previous = JSON.parse(await fs.readFile('test-results/org-chart-p9-2/browser-composition.json','utf8')).snapshots['1440']; +const same = (a,b) => JSON.stringify(a) === JSON.stringify(b); +const normalizedEqual = snapshot.elements.every((element,index) => { const old = previous.elements[index]; return old?.key === element.key && old.hash === element.hash && same(old.style,element.style) && Object.keys(element.box).every(key => Math.abs(element.box[key] - old.box[key]) < .15); }); +check(previous.htmlHash === snapshot.htmlHash && same(previous.assets,snapshot.assets) && normalizedEqual,'Fixed DOM, 139 asset hashes and 152 styles/geometry match preserved P9.2 evidence',{ previous:previous.htmlHash,current:snapshot.htmlHash,elements:snapshot.elements.length,assets:snapshot.assets.length }); +check(Math.abs(snapshot.centerDifference) < 1 && snapshot.collisions.length === 0 && snapshot.counts.cards === 43 && snapshot.counts.paths === 5,'Fixed minimal center/cards/connectors regression',{ centerDifference:snapshot.centerDifference,counts:snapshot.counts,collisions:snapshot.collisions }); +check(await page.evaluate(() => !document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('[data-drag-drop-enabled],[data-edit-dragging],[data-drop-state],.hm-orgchart-move-feedback')),'Fixed has no ON/OFF or editing UI'); +await page.screenshot({ path:path.join(output,'fixed-preserved-1440.png'),fullPage:true }); +check(errors.length === 0,'Preservation browser console/page errors: zero',errors); +const result = { startedAt,completedAt:new Date().toISOString(),origin,results,errors,protectedFiles:protectedFiles.map(file => file.path),fixedSnapshot:snapshot,pass:results.filter(item => item.status === 'PASS').length,fail:results.filter(item => item.status === 'FAIL').length }; +await fs.writeFile(path.join(output,'fixed-preservation.json'),JSON.stringify(result,null,2)); await browser.close(); console.log(JSON.stringify({ pass:result.pass,fail:result.fail })); if (result.fail) process.exitCode = 1; diff --git a/examples/org-chart/verify-p9-4.mjs b/examples/org-chart/verify-p9-4.mjs new file mode 100644 index 0000000..f73ef34 --- /dev/null +++ b/examples/org-chart/verify-p9-4.mjs @@ -0,0 +1,447 @@ +// Focused P9.4 Storybook deltas. Run named sections to repair only failed probes. +// Existing P7-P9.3 evidence is preserved and is not replayed by this harness. +import fs from 'node:fs/promises'; +import { writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-p9-4'); +const origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6064').replace(/\/$/, ''); +const only = process.argv.slice(2), suffix = only.length ? `-${only.join('-')}` : ''; +const results = [], measurements = [], errors = [], startedAt = new Date().toISOString(); +await fs.mkdir(output, { recursive: true }); +const persist = () => writeFileSync(path.join(output, `browser-progress${suffix}.json`), JSON.stringify({ startedAt, results, measurements, errors }, null, 2)); +const check = (pass, name, detail) => { + results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); persist(); + if (!pass) console.log('FAIL', name, JSON.stringify(detail)); +}; +const browser = await launchChromium({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1920, height: 1700 } }); +page.setDefaultTimeout(20000); +page.on('pageerror', error => { errors.push({ type: 'pageerror', message: error.message }); persist(); }); +page.on('console', message => { if (message.type() === 'error') { errors.push({ type: 'console', message: message.text(), location: message.location() }); persist(); } }); +const viewport = () => page.locator('.hm-orgchart-viewport'); +const employee = id => page.locator(`.hm-orgchart-employee[data-employee-id="${id}"]`); +const title = id => page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`); +const companyTrigger = () => page.getByRole('button', { name: /^회사 선택/ }); +const companyPanel = () => page.getByRole('dialog', { name: '회사 선택', exact: true }); +const companyCheckbox = name => companyPanel().getByRole('checkbox', { name, exact: true }); +const dialog = () => page.locator('[data-component="HMOrgChartEmployeeDialog"]'); +const editSwitch = () => page.getByRole('switch', { name: /Drag & Drop|조직 이동/ }); +const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 8; i++) await new Promise(requestAnimationFrame); }); +async function ready(count) { + await page.waitForFunction(count => { + const shadow = document.querySelector('.hm-orgchart-host,.hm-orgchart-component-host')?.shadowRoot; + return shadow && !shadow.querySelector('[aria-busy="true"]') && [...shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet) + && (count === undefined || shadow.querySelectorAll('.hm-orgchart-employee').length === count); + }, count, { timeout: 45000 }); await settle(); +} +async function select(label, option) { + await page.getByRole('combobox', { name: label, exact: true }).click(); + await page.getByRole('option', { name: option, exact: true }).click(); await ready(); +} +async function countPerCompany(count, expected) { + await select('회사당 직원 수', `${count}명`); + const apply = page.getByRole('button', { name: '데이터 적용', exact: true }); + if (await apply.count() && await apply.isEnabled()) await apply.click(); + await ready(expected); +} +async function chooseCompanies(names) { + await companyTrigger().click(); await companyPanel().waitFor(); + const all = companyCheckbox('전체 선택'); + for (let i = 0; i < 2 && await all.getAttribute('aria-checked') !== 'false'; i++) { await all.click(); await ready(); } + await ready(0); + for (const name of names) { await companyCheckbox(name).click(); await ready(); } + await page.keyboard.press('Escape'); await companyPanel().waitFor({ state: 'hidden' }); await ready(); +} +async function toggle(enabled, { programmatic = false } = {}) { + const control = editSwitch(); + if (await control.getAttribute('aria-checked') !== String(enabled)) { + // A live pointer must remain down while an external settings change cancels + // it. DOM click is the same React handler and does not synthesize pointerup. + if (programmatic) await control.evaluate(element => element.click()); else await control.click(); + } + await settle(); +} +async function setup({ companies = ['한맥기술'], layout = 'tb', enabled = false } = {}) { + await page.setViewportSize({ width: 1920, height: 1700 }); + await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); + await companyTrigger().waitFor(); await ready(140); + await chooseCompanies(companies); await ready(companies.length * 20); + if (layout !== 'tb') await select('조직도 표시 유형', layout === 'lr' ? '가로형 LR' : '혼합형 Hybrid'); + await toggle(enabled); await viewport().scrollIntoViewIfNeeded(); + await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); +} +async function state() { + return page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + const viewport = shadow.querySelector('.hm-orgchart-viewport'), expanded = shadow.querySelector('.hm-orgchart-expanded'); + 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 }; }; + const nodes = [...shadow.querySelectorAll('.node[data-organization-id]')].map(element => ({ + id: element.dataset.organizationId, parentId: element.dataset.parentId, companyId: element.dataset.companyId, + direct: Number(element.dataset.directEmployeeCount), inclusive: Number(element.dataset.inclusiveEmployeeCount), collapsed: element.dataset.collapsed, + })); + const cards = [...shadow.querySelectorAll('.hm-orgchart-employee')].map(element => ({ + id: element.dataset.employeeId, organizationId: element.closest('.node').dataset.organizationId, companyId: element.dataset.companyId, + direction: element.dataset.connectionDirection, kind: element.dataset.cardKind, + border: { top: getComputedStyle(element).borderTopColor, left: getComputedStyle(element).borderLeftColor, right: getComputedStyle(element).borderRightColor }, + })); + const feedback = shadow.querySelector('.hm-orgchart-move-feedback'); + return { layout: expanded?.dataset.autoLayout, scale: Number(viewport.dataset.scale), panX: Number(viewport.dataset.panX), panY: Number(viewport.dataset.panY), + viewport: rect(viewport), forest: rect(shadow.querySelector('.hm-orgchart-forest')), nodes, cards, + feedback: { status: feedback?.dataset.moveStatus, text: feedback?.textContent }, + drop: [...shadow.querySelectorAll('[data-drop-state]')].map(element => ({ id: element.dataset.organizationId, state: element.dataset.dropState, hover: element.dataset.dropHover })), + dragging: viewport.dataset.editDragging, panDragging: viewport.dataset.dragging, activeEmployee: shadow.activeElement?.dataset.employeeId, + activeOrganization: shadow.activeElement?.dataset.organizationId, dragDropEnabled: viewport.dataset.dragDropEnabled, + countText: document.querySelector('[data-component="OrgChartExampleCounts"]')?.textContent.trim(), dialogs: document.querySelectorAll('[data-component="HMOrgChartEmployeeDialog"]').length, + companyRoots: nodes.filter(org => org.parentId === '').map(org => org.companyId), documentWidth: document.documentElement.scrollWidth, + }; + }); +} +const relations = s => JSON.stringify({ nodes: s.nodes.map(({ id, parentId, companyId }) => ({ id, parentId, companyId })), cards: s.cards.map(({ id, organizationId, companyId }) => ({ id, organizationId, companyId })) }); +async function blankPoint() { + return page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot, viewport = shadow.querySelector('.hm-orgchart-viewport'), box = viewport.getBoundingClientRect(); + for (const [dx, dy] of [[8, 8], [8, box.height - 8], [box.width - 8, 8], [box.width - 8, box.height - 8], [box.width / 2, box.height - 8]]) { + const point = { x: box.left + dx, y: box.top + dy }, hit = shadow.elementFromPoint(point.x, point.y); + if (hit && !hit.closest('button,a,.hm-orgchart-employee,.node')) return point; + } throw new Error('No visible blank Pan point found'); + }); +} +async function pan(dx, dy) { + const p = await blankPoint(); await page.mouse.move(p.x, p.y); await page.mouse.down(); const v = await viewport().boundingBox(); + await page.mouse.move(p.x + Math.sign(v.x + v.width / 2 - p.x) * 8, p.y, { steps: 2 }); + await page.mouse.move(p.x + dx, p.y + dy, { steps: 8 }); await page.mouse.up(); await settle(); +} +async function centerPair(source, target) { + await viewport().scrollIntoViewIfNeeded(); await source.focus(); await settle(); + for (let pass = 0; pass < 3; pass++) { + const a = await source.boundingBox(), b = await target.boundingBox(), v = await viewport().boundingBox(); + if (!a || !b || !v) throw new Error('Drag source/target has no geometry'); + const left = Math.min(a.x, b.x), right = Math.max(a.x + a.width, b.x + b.width), top = Math.min(a.y, b.y), bottom = Math.max(a.y + a.height, b.y + b.height); + if (right - left > v.width - 24 || bottom - top > v.height - 24) throw new Error('Drag pair exceeds viewport at current scale'); + const dx = v.x + v.width / 2 - (left + right) / 2, dy = v.y + v.height / 2 - (top + bottom) / 2; + if (Math.abs(dx) < 1 && Math.abs(dy) < 1) break; await pan(dx, dy); + } +} +async function drag(source, target, beforeRelease) { + await centerPair(source, target); const a = await source.boundingBox(), b = await target.boundingBox(), before = await state(); + await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2); await page.mouse.down(); + await page.mouse.move(a.x + a.width / 2 + 8, a.y + a.height / 2, { steps: 2 }); + await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 14 }); const hover = await state(); + if (beforeRelease) await beforeRelease(); + await page.mouse.up(); await ready(); return { before, hover, after: await state() }; +} +async function snap(name, fullPage = false) { await page.screenshot({ path: path.join(output, `${name}.png`), fullPage }); } +async function section(name, action) { + if (only.length && !only.includes(name)) return; + try { await action(); } catch (error) { check(false, `${name}: execution completed`, { message: error.message, stack: error.stack }); await page.mouse.up().catch(() => {}); await snap(`failure-${name}`, true).catch(() => {}); } +} +function counts(s, companies, perCompany, name) { + const grouped = Object.fromEntries(companies.map(id => [id, s.cards.filter(card => card.companyId === id).length])); + check(s.cards.length === companies.length * perCompany && new Set(s.cards.map(card => card.id)).size === s.cards.length + && new Set(s.nodes.map(org => org.id)).size === s.nodes.length && Object.values(grouped).every(count => count === perCompany), `${name}: unique IDs and exact per-company employee counts`, grouped); + check(s.companyRoots.length === companies.length && s.nodes.filter(org => org.parentId === '').every(org => org.inclusive === perCompany), `${name}: root totals count organizational leaders exactly once`); + check(s.countText?.includes(`합계 ${(companies.length * perCompany).toLocaleString('ko-KR')}명`), `${name}: displayed total matches Employee IDs`, s.countText); +} + +await section('department', async () => { + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.goto(`${origin}/iframe.html?id=orgchart--department-colors&viewMode=story`); await ready(); + await page.evaluate(async () => { await Promise.all([...document.querySelector('.hm-orgchart-component-host').shadowRoot.querySelectorAll('img')].map(async image => { image.loading = 'eager'; await image.decode(); })); }); + const department = await page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-component-host').shadowRoot, root = shadow.querySelector('.hm-orgchart-department-colors'); + const cards = [...root.querySelectorAll('[data-card-asset-size="representative"]')], card = cards[0]; + const styles = element => Object.fromEntries(['fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color'].map(key => [key, getComputedStyle(element)[key]])); + const renderedCard = element => ({ id: element?.dataset.employeeId, width: element?.offsetWidth, height: element?.offsetHeight, + role: element?.querySelector('.hm-orgchart-person-role')?.textContent, name: element?.querySelector('.hm-orgchart-person-name')?.textContent, + badgeCount: element?.querySelectorAll('.hm-orgchart-job-title,.hm-orgchart-status-label').length, + background: element?.querySelector('.hm-orgchart-card-background')?.src, photo: element?.querySelector('.hm-orgchart-avatar img')?.src, + roleStyle: element ? styles(element.querySelector('.hm-orgchart-person-role')) : {}, nameStyle: element ? styles(element.querySelector('.hm-orgchart-person-name')) : {} }); + const rows = [...root.querySelectorAll('.hm-orgchart-color-preview')].map(row => ({ title: row.querySelector('.hm-orgchart-color-department-name')?.textContent, + gradient: getComputedStyle(row.querySelector('.hm-orgchart-color-department-name')).backgroundImage, + color: getComputedStyle(row.querySelector('.hm-orgchart-color-department-name')).color, + cards: [...row.querySelectorAll('.hm-orgchart-employee')].map(card => ({ kind: card.dataset.cardKind, width: card.offsetWidth, height: card.offsetHeight, asset: card.dataset.cardAsset })) })); + const unwanted = root.querySelectorAll('.hm-orgchart-color-legend,.hm-orgchart-contrast-note,.hm-orgchart-contrast-comparison,.hm-orgchart-card-caption,.hm-orgchart-color-preview > p').length; + const frame = root.querySelector('.tunnel-reference__sample'), stage = frame?.querySelector('.tunnel-reference__stage'); + return { representativeCount: cards.length, card: renderedCard(card), rows, unwanted, + frameStyle: frame ? Object.fromEntries(['backgroundColor','border','borderRadius','boxShadow','padding','overflow'].map(key => [key, getComputedStyle(frame)[key]])) : {}, + stageStyle: stage ? Object.fromEntries(['padding','overflowX','maxWidth'].map(key => [key, getComputedStyle(stage)[key]])) : {}, + representativeFirst: Boolean(card && root.firstElementChild?.contains(card)), documentWidth: document.documentElement.scrollWidth }; + }); + check(department.representativeCount === 1 && department.card.width === 146 && department.card.height === 74, 'Department representative: one shared 146x74 card', department.card); + check(department.card.role === '대표이사' && department.card.name === '홍 길 동' && department.card.badgeCount === 0 && /Noto Sans KR/.test(department.card.nameStyle.fontFamily), 'Department representative: approved text, font and zero badges'); + check(department.representativeFirst && department.rows.length === 6 && department.rows.every(row => row.cards.length === 2 && row.cards[0].kind === 'leader' && row.cards[0].width === 146 && row.cards[0].height === 68 && row.cards[1].width === 108 && row.cards[1].height === 52), 'Department order: representative, six group titles, large leaders and small employees'); + check(department.unwanted === 0, 'Department compact preview retains no legends, contrast/debug prose or duplicate card captions'); + await snap('department-representative'); await snap('department-full', true); + await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`); await ready(); + const fixedCard = await page.locator('[data-card-asset-size="representative"]').evaluate(element => { + const styles = target => Object.fromEntries(['fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color'].map(key => [key, getComputedStyle(target)[key]])); + return { id: element.dataset.employeeId, background: element.querySelector('.hm-orgchart-card-background')?.src, photo: element.querySelector('.hm-orgchart-avatar img')?.src, + roleStyle: styles(element.querySelector('.hm-orgchart-person-role')), nameStyle: styles(element.querySelector('.hm-orgchart-person-name')) }; + }); + check(['id','background','photo','roleStyle','nameStyle'].every(key => JSON.stringify(fixedCard[key]) === JSON.stringify(department.card[key])), 'Department and Fixed representative reuse identical SVG, avatar and typography', { department: department.card, fixed: fixedCard }); + const prior = JSON.parse(await fs.readFile(path.join(root, 'test-results/org-chart-p9-1/browser.json'), 'utf8')).snapshots?.department?.['1440']; + if (prior?.departments) check(department.rows.every((row, i) => row.gradient === prior.departments[i].headingStyle.backgroundImage && row.color === prior.departments[i].headingStyle.color), 'Department approved group gradients and white titles preserve P9.1 baseline'); + else check(false, 'Department historical approved gradient evidence exists', 'Check P9.1 browser.json snapshot structure'); + await page.goto(`${origin}/iframe.html?id=center-graphic-facility--tunnel&viewMode=story`); await page.locator('.tunnel-reference__sample').first().waitFor(); await settle(); + const reference = await page.locator('.tunnel-reference__sample').first().evaluate(frame => { + const stage = frame.querySelector('.tunnel-reference__stage'); + return { frameStyle: Object.fromEntries(['backgroundColor','border','borderRadius','boxShadow','padding','overflow'].map(key => [key, getComputedStyle(frame)[key]])), + stageStyle: Object.fromEntries(['padding','overflowX','maxWidth'].map(key => [key, getComputedStyle(stage)[key]])) }; + }); + check(JSON.stringify(reference.frameStyle) === JSON.stringify(department.frameStyle) && JSON.stringify(reference.stageStyle) === JSON.stringify(department.stageStyle), 'Department shares actual Tunnel preview frame, background, border, radius, shadow, padding and overflow', { department: { frameStyle: department.frameStyle, stageStyle: department.stageStyle }, reference }); + measurements.push({ name: 'Department', department, fixedCard, reference }); +}); + +await section('department-names', async () => { + // User's later naming correction only; keep every prior P9.4 PASS as evidence. + const previous = JSON.parse(await fs.readFile(path.join(output, 'browser.json'), 'utf8')).measurements.find(item => item.name === 'Department').department; + const oldStyles = JSON.parse(await fs.readFile(path.join(root, 'test-results/org-chart-p9-1/browser.json'), 'utf8')).snapshots.department['1440'].departments; + const fixture = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8')); + await page.setViewportSize({ width:1440, height:1000 }); + await page.goto(`${origin}/iframe.html?id=orgchart--department-colors&viewMode=story`); await ready(); + await page.evaluate(async () => { await Promise.all([...document.querySelector('.hm-orgchart-component-host').shadowRoot.querySelectorAll('img')].map(async image => { image.loading = 'eager'; await image.decode(); })); }); + const current = await page.evaluate(async () => { + const shadow = document.querySelector('.hm-orgchart-component-host').shadowRoot, root = shadow.querySelector('.hm-orgchart-department-colors'); + const hash = async data => [...new Uint8Array(await crypto.subtle.digest('SHA-256', data))].map(byte => byte.toString(16).padStart(2,'0')).join(''); + const keys = ['fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color']; + const styles = element => Object.fromEntries(keys.map(key => [key,getComputedStyle(element)[key]])); + const card = async element => ({ id:element.dataset.employeeId,kind:element.dataset.cardKind,asset:element.dataset.cardAsset,width:element.offsetWidth,height:element.offsetHeight, + name:element.querySelector('.hm-orgchart-person-name')?.textContent,role:element.querySelector('.hm-orgchart-person-role')?.textContent, + badges:[...element.querySelectorAll('.hm-orgchart-job-title')].map(badge => badge.textContent), + roleStyle:styles(element.querySelector('.hm-orgchart-person-role')),nameStyle:styles(element.querySelector('.hm-orgchart-person-name')), + assets:await Promise.all([...element.querySelectorAll('img')].map(async image => ({ hash:await hash(await (await fetch(image.currentSrc || image.src)).arrayBuffer()),naturalWidth:image.naturalWidth,naturalHeight:image.naturalHeight,loaded:image.complete && image.naturalWidth > 0 }))) }); + return { cards:await Promise.all([...root.querySelectorAll('.hm-orgchart-employee')].map(card)), + rows:await Promise.all([...root.querySelectorAll('.hm-orgchart-color-preview')].map(async row => ({ title:row.querySelector('.hm-orgchart-color-department-name').textContent, + gradient:getComputedStyle(row.querySelector('.hm-orgchart-color-department-name')).backgroundImage,color:getComputedStyle(row.querySelector('.hm-orgchart-color-department-name')).color, + cards:await Promise.all([...row.querySelectorAll('.hm-orgchart-employee')].map(card)) }))) }; + }); + check(current.cards.length === 13 && current.cards.every(card => card.name === '홍 길 동'), 'Department naming correction: all thirteen displayed names are 홍 길 동', current.cards.map(({ id,name }) => ({ id,name }))); + const rolePreserved = current.cards.every(card => { + const person = fixture.employees.find(employee => employee.id === card.id); + const expectedRole = card.kind === 'topLeader' ? '대표이사' : person?.position.replace(/\s+/gu,''); + const expectedBadges = card.kind === 'topLeader' ? [] : person?.jobTitle ? [person.jobTitle] : []; + return Boolean(person) && card.role === expectedRole && JSON.stringify(card.badges) === JSON.stringify(expectedBadges); + }); + check(rolePreserved && new Set(current.cards.map(card => card.id)).size === 13, 'Department naming correction preserves fixture Employee IDs, positions and organizational badges'); + check(current.rows.length === previous.rows.length && current.rows.every((row,index) => { + const old = previous.rows[index]; return row.title === old.title && row.gradient === old.gradient && row.color === old.color + && row.cards.length === old.cards.length && row.cards.every((card,i) => ['kind','width','height','asset'].every(key => card[key] === old.cards[i][key])); + }), 'Department naming correction preserves P9.4 group titles, colors, gradients, card sizes and artwork mappings'); + const styleKeys = ['fontFamily','fontSize','fontWeight','lineHeight','letterSpacing','color']; + check(current.rows.every((row,index) => row.cards.every((card,i) => { + const old = oldStyles[index].cards[i]; + const role = old.subtree.find(item => item.className === 'hm-orgchart-person-role')?.style, name = old.subtree.find(item => item.className === 'hm-orgchart-person-name')?.style; + return styleKeys.every(key => card.roleStyle[key] === role?.[key] && card.nameStyle[key] === name?.[key]) && JSON.stringify(card.assets) === JSON.stringify(old.assets); + })), 'Department naming correction preserves approved card SVG/photo hashes and typography from P9.1'); + const baseline = JSON.parse(await fs.readFile(path.join(output, 'baseline.json'), 'utf8')); + const safe = baseline.files.filter(file => /^examples\/org-chart\/data.*\.json$/.test(file.path) || /^packages\/org-chart\/src\/assets\//.test(file.path) + || ['packages/org-chart/src/core/employee-card.ts','packages/org-chart/src/styles/employee-card.css','packages/org-chart/src/core/org-chart-content.ts','packages/org-chart/src/core/render.ts','src/org-chart/org-chart-full-composition.stories.tsx'].includes(file.path)); + const changed = []; + for (const file of safe) { const hash = createHash('sha256').update(await fs.readFile(path.join(root,file.path))).digest('hex'); if (hash.toUpperCase() !== file.sha256.toUpperCase()) changed.push(file.path); } + check(changed.length === 0 && safe.length > 20, 'Department naming correction leaves original fixtures, shared card renderer/styles, Fixed presenter and approved assets unchanged', { checked:safe.length,changed }); + const representative = current.cards.find(card => card.kind === 'topLeader'); + check(representative.width === previous.card.width && representative.height === previous.card.height && representative.badges.length === 0 + && JSON.stringify(representative.nameStyle) === JSON.stringify(previous.card.nameStyle) && JSON.stringify(representative.roleStyle) === JSON.stringify(previous.card.roleStyle), 'Department representative retains 146x74, zero badges and P9.4 typography'); + await snap('department-names',true); measurements.push({ name:'Department naming correction',current }); +}); + +await section('data', async () => { + await setup(); + check(await page.getByRole('combobox', { name: '데이터 범위', exact: true }).count() === 0 && !await page.getByText('전체 회사 합산', { exact: true }).count(), 'Expanded removes both data-scope choices'); + counts(await state(), ['hanmac'], 20, 'one company x20'); + for (const count of [100, 300]) { await countPerCompany(count, count); counts(await state(), ['hanmac'], count, `one company x${count}`); } + await countPerCompany(100, 100); await chooseCompanies(['한맥기술', '삼안']); await ready(200); + counts(await state(), ['hanmac', 'saman'], 100, 'two companies x100'); await snap('expanded-scope-removed', true); + await companyTrigger().click(); await companyPanel().waitFor(); await companyCheckbox('전체 선택').click(); await ready(700); + await page.keyboard.press('Escape'); await companyPanel().waitFor({ state: 'hidden' }); await countPerCompany(300, 2100); + const allIds = ['hanmac','saman','jangheon-industry','jangheon','ptc','halla','baron']; + counts(await state(), allIds, 300, 'seven companies x300'); measurements.push({ name: 'seven x300', state: await state() }); + await chooseCompanies([]); await ready(0); const empty = await state(); + check(empty.cards.length === 0 && empty.companyRoots.length === 0 && empty.countText.includes('0개 회사') && await page.locator('.hm-orgchart-expanded-empty').count() === 1, 'Zero selected companies renders explicit empty state'); + await snap('expanded-empty'); await chooseCompanies(['한맥기술']); await ready(300); counts(await state(), ['hanmac'], 300, 'selection after empty'); +}); + +await section('dropdown', async () => { + await setup(); await chooseCompanies(['한맥기술']); await companyTrigger().focus(); await page.keyboard.press('Enter'); await companyPanel().waitFor(); + check(await companyCheckbox('전체 선택').evaluate(element => element === document.activeElement), 'Company dropdown keyboard opening focuses select-all'); + await page.keyboard.press('Tab'); await page.keyboard.press('Tab'); await page.keyboard.press('Space'); await ready(40); + check(await companyCheckbox('삼안').getAttribute('aria-checked') === 'true' && await companyCheckbox('전체 선택').getAttribute('aria-checked') === 'mixed', 'Company keyboard Space selects second company and mixed state'); + await snap('company-dropdown'); await page.keyboard.press('Escape'); await companyPanel().waitFor({ state: 'hidden' }); + check(await companyTrigger().evaluate(element => element === document.activeElement), 'Company ESC closes and restores trigger focus'); + await companyTrigger().click(); await companyPanel().waitFor(); await page.locator('.sb-page-title').click(); await companyPanel().waitFor({ state: 'hidden' }); + check(true, 'Company outside click closes existing HM Popover'); +}); + +await section('off', async () => { + await setup({ companies: ['한맥기술', '삼안'] }); + check(await editSwitch().getAttribute('aria-checked') === 'false', 'Drag & Drop defaults OFF'); await snap('drag-drop-off', true); + for (const [name, source, target] of [ + ['employee within company', employee('virtual-022'), title('hanmac-office-sibling')], + ['group within company', title('hanmac-office-0'), title('hanmac-office-1')], + ['employee across companies', employee('virtual-022'), title('saman-office-0')], + ['group across companies', title('hanmac-office-0'), title('saman-office-1')], + ]) { + const result = await drag(source, target); + check(relations(result.before) === relations(result.after) && !result.hover.dragging && result.hover.drop.length === 0 && result.after.drop.length === 0, `OFF blocks ${name} and all Drop highlights`); + } + for (const source of [employee('virtual-022'), title('hanmac-office-0')]) { + await source.focus(); const before = await state(); await page.keyboard.press('Alt+m'); await settle(); const after = await state(); + check(!after.dragging && after.drop.length === 0 && relations(after) === relations(before), 'OFF blocks Alt+M on employee/group without highlighting'); + } + await viewport().scrollIntoViewIfNeeded(); const before = await state(); await pan(70, -35); const after = await state(); + check(Math.abs(after.panX - before.panX - 70) < 1 && Math.abs(after.panY - before.panY + 35) < 1 && relations(before) === relations(after), 'OFF preserves blank-space Pan'); + await title('hanmac-office-sibling').click(); await ready(); check(await title('hanmac-office-sibling').getAttribute('aria-expanded') === 'false', 'OFF group click collapses'); + await title('hanmac-office-sibling').press('Space'); await ready(40); check(await title('hanmac-office-sibling').getAttribute('aria-expanded') === 'true', 'OFF group Space expands'); + await employee('virtual-022').click(); await dialog().waitFor(); check(await dialog().getAttribute('data-employee-id') === 'virtual-022', 'OFF employee click opens HM Dialog'); + await page.keyboard.press('Escape'); await dialog().waitFor({ state: 'hidden' }); await settle(); + check((await state()).activeEmployee === 'virtual-022', 'OFF Dialog closes and restores employee focus'); + const zoomBefore = await state(); await page.getByRole('button', { name: '확대', exact: true }).click(); await settle(); check((await state()).scale > zoomBefore.scale, 'OFF Zoom remains available'); + await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); const fit = await state(); + check(fit.forest.x >= fit.viewport.x - 1 && fit.forest.right <= fit.viewport.right + 1 && fit.forest.y >= fit.viewport.y - 1 && fit.forest.bottom <= fit.viewport.bottom + 1, 'OFF Fit contains selected forest'); + measurements.push({ name: 'OFF browsing', state: fit }); +}); + +await section('on', async () => { + await setup({ companies: ['한맥기술', '삼안'], enabled: true }); await snap('drag-drop-on', true); + const inside = await drag(employee('virtual-022'), title('hanmac-office-sibling')); + check(inside.after.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-sibling' && inside.after.feedback.status === 'success', 'ON employee move within company'); + const groupInside = await drag(title('hanmac-office-0'), title('hanmac-office-1')); + check(groupInside.after.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-office-1' && groupInside.after.feedback.status === 'success', 'ON group move within company'); + const cross = await drag(employee('virtual-022'), title('saman-office-0')); + const moved = cross.after.cards.find(card => card.id === 'virtual-022'); + check(moved?.companyId === 'saman' && moved.organizationId === 'saman-office-0' && moved.border[moved.direction] === 'rgb(240, 131, 0)' && cross.after.feedback.status === 'success', 'ON cross-company employee move updates actual company, organization and approved accent'); + const crossGroup = await drag(title('hanmac-office-0'), title('saman-office-1')); + check(crossGroup.after.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'saman-office-1' + && ['hanmac-office-0','hanmac-cell-0','hanmac-team-0','hanmac-team-sibling'].every(id => crossGroup.after.nodes.find(org => org.id === id)?.companyId === 'saman') && crossGroup.after.feedback.status === 'success', 'ON cross-company group move preserves and recolors complete subtree'); + check(crossGroup.after.cards.length === 40 && new Set(crossGroup.after.cards.map(card => card.id)).size === 40 && crossGroup.after.nodes.filter(org => org.parentId === '').reduce((sum, org) => sum + org.inclusive, 0) === 40, 'ON cross-company moves preserve unique Employee IDs and aggregate total'); + await snap('cross-company-moved', true); + const beforeOff = await state(); await toggle(false); const afterOff = await state(); + check(relations(beforeOff) === relations(afterOff) && afterOff.drop.length === 0 && !afterOff.dragging, 'Turning OFF retains completed employee and group moves'); + await toggle(true); check(relations(beforeOff) === relations(await state()), 'Turning ON again preserves completed moves without reloading data'); + await employee('virtual-022').click(); await dialog().waitFor(); check(await dialog().getAttribute('data-employee-id') === 'virtual-022', 'ON moved employee remains clickable'); + await page.keyboard.press('Escape'); await dialog().waitFor({ state: 'hidden' }); + await title('hanmac-office-0').click(); await ready(); check(await title('hanmac-office-0').getAttribute('aria-expanded') === 'false', 'ON group click still collapses'); + await title('hanmac-office-0').press('Space'); await ready(40); check(await title('hanmac-office-0').getAttribute('aria-expanded') === 'true', 'ON group Space still expands'); + await countPerCompany(100, 200); const fresh = await state(); + check(fresh.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-0' && fresh.nodes.find(org => org.id === 'hanmac-office-0')?.parentId === 'hanmac-division-0' && fresh.drop.length === 0 && !fresh.dragging, 'Data Apply rebuilds fixture without mixing completed session moves or drag state'); + measurements.push({ name: 'ON transfers', inside, groupInside, cross, crossGroup, afterOff }); +}); + +await section('cancel', async () => { + await setup({ enabled: true }); + let cancelledDuringPointer; + const pointer = await drag(employee('virtual-022'), title('hanmac-office-sibling'), async () => { await toggle(false, { programmatic: true }); cancelledDuringPointer = await state(); }); + check(pointer.hover.dragging === 'true' && pointer.hover.drop.some(target => target.hover === 'allowed'), 'ON pointer edit is active before disable'); + check(!cancelledDuringPointer.dragging && cancelledDuringPointer.drop.length === 0 && relations(pointer.before) === relations(pointer.after) && pointer.after.dialogs === 0, 'OFF during pointer move cancels edit/highlights and suppresses release click'); + await toggle(true); await employee('virtual-022').focus(); await page.keyboard.press('Alt+m'); await settle(); const opened = await state(); + check(opened.dragging === 'true' && opened.drop.some(target => target.hover === 'allowed'), 'ON Alt+M starts keyboard edit'); + await toggle(false, { programmatic: true }); await page.keyboard.press('Enter'); await settle(); const cancelled = await state(); + check(!cancelled.dragging && cancelled.drop.length === 0 && cancelled.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-0' && cancelled.dialogs === 0, 'OFF cancels active keyboard edit and blocks stale Enter commit'); + await toggle(true); await employee('virtual-022').focus(); await page.keyboard.press('Alt+m'); await settle(); + for (let i = 0; i < 40 && (await state()).drop.find(target => target.hover)?.id !== 'hanmac-office-sibling'; i++) { await page.keyboard.press('ArrowRight'); await settle(); } + await page.keyboard.press('Enter'); await ready(20); const keyboard = await state(); + check(keyboard.cards.find(card => card.id === 'virtual-022')?.organizationId === 'hanmac-office-sibling' && keyboard.feedback.status === 'success' && keyboard.dialogs === 0, 'Re-enabled keyboard move commits through common ON policy'); + measurements.push({ name: 'disable cancellation', pointer, cancelledDuringPointer, opened, cancelled, keyboard }); +}); + +await section('modes', async () => { + await setup({ companies: ['한맥기술', '삼안'] }); + for (const [layout, option] of [['tb','세로형 TB'],['lr','가로형 LR'],['hybrid','혼합형 Hybrid']]) { + if (layout !== 'tb') await select('조직도 표시 유형', option); await ready(40); const s = await state(); + check(s.layout === layout && s.cards.length === 40 && s.cards.every(card => ['top','left','right'].includes(card.direction)), `${layout}: layout and existing directional cards work with default OFF`); + } +}); + +await section('responsive', async () => { + for (const width of [390,768,1280,1440]) { + await page.setViewportSize({ width, height: 1100 }); await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); await companyTrigger().waitFor(); await ready(140); + const metric = await editSwitch().evaluate(element => ({ label: element.getAttribute('aria-label') || element.getAttribute('aria-labelledby') || document.querySelector(`label[for="${element.id}"]`)?.textContent, checked: element.getAttribute('aria-checked'), scrollWidth: document.documentElement.scrollWidth, switchVisible: element.getBoundingClientRect().width > 0 })); + check(metric.scrollWidth <= width + 1 && metric.switchVisible && metric.checked === 'false' && Boolean(metric.label), `${width}: OFF switch is named and settings have no horizontal overflow`, metric); + await snap(`expanded-${width}`, true); await companyTrigger().click(); await companyPanel().waitFor(); + const bounds = await companyPanel().evaluate(element => { const r = element.getBoundingClientRect(); return { x: r.left, right: r.right, y: r.top, bottom: r.bottom, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth }; }); + check(bounds.x >= -1 && bounds.right <= width + 1 && bounds.y >= -1 && bounds.bottom <= 1101 && bounds.scrollWidth <= bounds.clientWidth + 1, `${width}: retained company dropdown fits viewport`, bounds); + await page.keyboard.press('Escape'); await companyPanel().waitFor({ state: 'hidden' }); + await page.goto(`${origin}/iframe.html?id=orgchart--department-colors&viewMode=story`); await ready(); + const departmentOverflow = await page.evaluate(() => ({ width: innerWidth, scrollWidth: document.documentElement.scrollWidth })); + check(departmentOverflow.scrollWidth <= width + 1, `${width}: Department preview containers fit viewport`, departmentOverflow); + await snap(`department-${width}`, true); + } +}); + +await section('expanded-frame', async () => { + // Later outer-frame style correction only. Existing layout/move suites remain + // the evidence for those features; moving a sample below is for fresh capture. + await page.setViewportSize({ width:1440,height:1100 }); + await page.goto(`${origin}/iframe.html?id=center-graphic-facility--tunnel&viewMode=story`); await page.locator('.tunnel-reference__sample').first().waitFor(); await settle(); + const reference = await page.locator('.tunnel-reference__sample').first().evaluate(element => { + const css = getComputedStyle(element); return { backgroundColor:css.backgroundColor,border:css.border,borderRadius:css.borderRadius }; + }); + await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); await companyTrigger().waitFor(); await ready(140); + const frameSnapshot = () => page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot, root = shadow.querySelector('.hm-orgchart-expanded'), css = getComputedStyle(root); + return { frame:{ backgroundColor:css.backgroundColor,border:css.border,borderRadius:css.borderRadius }, + inputs:Object.fromEntries(['--hm-orgchart-expanded-preview-background','--hm-orgchart-expanded-preview-border','--hm-orgchart-expanded-preview-radius'].map(name => [name,css.getPropertyValue(name).trim()])), + cards:[...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({ id:card.dataset.employeeId,background:getComputedStyle(card).backgroundColor,width:card.offsetWidth,height:card.offsetHeight,kind:card.dataset.cardKind })), + titles:[...shadow.querySelectorAll('.node[data-company-id] .hm-orgchart-organization-name')].map(title => ({ company:title.closest('.node').dataset.companyId,color:getComputedStyle(title).color,background:getComputedStyle(title).backgroundColor })), + layout:root.dataset.autoLayout,width:innerWidth,documentWidth:document.documentElement.scrollWidth, + pan:shadow.querySelector('.hm-orgchart-viewport').dataset.panEnabled,zoom:shadow.querySelector('.hm-orgchart-viewport').dataset.zoomEnabled, + editing:shadow.querySelector('.hm-orgchart-viewport').dataset.dragDropEnabled }; + }); + const before = await frameSnapshot(); + check(JSON.stringify(before.frame) === JSON.stringify(reference) && Object.values(before.inputs).every(value => value.length > 0), 'Expanded outer frame matches actual Tunnel shared background, border and radius', { expanded:before.frame,reference,inputs:before.inputs }); + const expectedColors = { hanmac:'rgb(203, 46, 40)',saman:'rgb(240, 131, 0)','jangheon-industry':'rgb(69, 128, 166)',jangheon:'rgb(29, 65, 141)',ptc:'rgb(132, 51, 142)',halla:'rgb(0, 104, 183)',baron:'rgb(12, 54, 127)' }; + check(before.cards.length === 140 && before.cards.every(card => card.background === 'rgb(255, 255, 255)') && before.titles.every(title => title.color === 'rgb(255, 255, 255)' && title.background === expectedColors[title.company]), 'Expanded outer-frame style preserves white employee backgrounds and approved company/white group titles'); + check(before.layout === 'tb' && before.editing === 'false' && await editSwitch().getAttribute('aria-checked') === 'false' + && await page.getByRole('checkbox',{ name:'Pan 사용',exact:true }).getAttribute('aria-checked') === 'true' + && await page.getByRole('checkbox',{ name:'Zoom 사용',exact:true }).getAttribute('aria-checked') === 'true' + && await page.getByRole('checkbox',{ name:'접기·펼치기 사용',exact:true }).getAttribute('aria-checked') === 'true', 'Expanded frame correction preserves TB, default OFF and enabled browsing controls'); + await snap('expanded-frame-off',true); await snap('expanded-frame-scope-removed',true); + await companyTrigger().click(); await companyPanel().waitFor(); await snap('expanded-frame-company-dropdown'); + await page.keyboard.press('Escape'); await companyPanel().waitFor({ state:'hidden' }); + await toggle(true); const on = await frameSnapshot(); + check(JSON.stringify(on.frame) === JSON.stringify(before.frame) && JSON.stringify(on.cards) === JSON.stringify(before.cards) && JSON.stringify(on.titles) === JSON.stringify(before.titles), 'ON toggle leaves corrected outer frame and all card/title styling unchanged'); + await snap('expanded-frame-on',true); + await page.setViewportSize({ width:390,height:1100 }); await settle(); const narrow = await frameSnapshot(); + check(narrow.documentWidth <= 391 && JSON.stringify(narrow.frame) === JSON.stringify(reference), '390px corrected Expanded frame fits page and retains shared style', { width:narrow.width,scrollWidth:narrow.documentWidth,frame:narrow.frame }); + await snap('expanded-frame-390',true); + await setup({ companies:['한맥기술','삼안'],enabled:true }); + const captureMove = await drag(employee('virtual-022'),title('saman-office-0')); + await page.getByRole('button',{ name:'Fit to View',exact:true }).click(); await settle(); await snap('expanded-frame-company-moved',true); + measurements.push({ name:'Expanded outer-frame correction',reference,before,on,narrow, + captureOnlyMove:{ status:captureMove.after.feedback.status,employee:captureMove.after.cards.find(card => card.id === 'virtual-022'),countText:captureMove.after.countText,note:'Fresh moved sample screenshot; previous P9.4 movement PASS is retained without rerunning its assertions.' } }); +}); + +await section('names-mobile', async () => { + // Only the narrow viewport and current captures are new; all thirteen names, + // roles/artwork/typography already passed the department-names section. + await page.setViewportSize({ width:1440,height:1000 }); await page.goto(`${origin}/iframe.html?id=orgchart--department-colors&viewMode=story`); await ready(); + await page.evaluate(async () => { await Promise.all([...document.querySelector('.hm-orgchart-component-host').shadowRoot.querySelectorAll('img')].map(async image => { image.loading = 'eager'; await image.decode(); })); }); + await snap('department-full-current',true); await page.locator('.hm-orgchart-representative-preview').screenshot({ path:path.join(output,'department-representative-current.png') }); + await page.setViewportSize({ width:390,height:1100 }); await settle(); + const narrow = await page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-component-host').shadowRoot; + const cards = [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => { const box = card.getBoundingClientRect(); return { id:card.dataset.employeeId,left:box.left,right:box.right,name:card.querySelector('.hm-orgchart-person-name').textContent }; }); + return { width:innerWidth,documentWidth:document.documentElement.scrollWidth,cards }; + }); + check(narrow.documentWidth <= 391 && narrow.cards.every(card => card.left >= -1 && card.right <= 391), '390px corrected Department names/cards stay within page without horizontal overflow', narrow); + await snap('department-names-390',true); measurements.push({ name:'Department narrow capture',narrow }); +}); + +await section('menu', async () => { + const index = await (await page.request.get(`${origin}/index.json`)).json(); + const entries = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title.startsWith('OrgChart/') && !(entry.tags ?? []).includes('internal')); + const expected = ['orgchart--logo-personnel-summary','orgchart--department-colors','orgchart--full-composition','orgchart--expanded-org-chart']; + check(entries.length === 4 && entries.every(entry => expected.includes(entry.id)), 'Four existing public OrgChart Story IDs retained', entries.map(entry => ({ id: entry.id, title: entry.title }))); +}); + +check(errors.length === 0, 'New console/page errors: zero', errors); +const result = { startedAt, completedAt: new Date().toISOString(), browser: browser.version(), origin, sections: only, results, measurements, errors, + notVerified: ['Physical touch device','Actual screen reader','Design intent approval'], pass: results.filter(item => item.status === 'PASS').length, fail: results.filter(item => item.status === 'FAIL').length }; +await fs.writeFile(path.join(output, `browser${suffix}.json`), JSON.stringify(result, null, 2)); +console.log(JSON.stringify({ pass: result.pass, fail: result.fail, errors: errors.length, output: `test-results/org-chart-p9-4/browser${suffix}.json` })); +await browser.close(); if (result.fail) process.exitCode = 1; diff --git a/examples/org-chart/verify-p9-5-arrowhead.mjs b/examples/org-chart/verify-p9-5-arrowhead.mjs new file mode 100644 index 0000000..2177bbb --- /dev/null +++ b/examples/org-chart/verify-p9-5-arrowhead.mjs @@ -0,0 +1,54 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +const root=fileURLToPath(new URL('../../',import.meta.url)); +const output=path.join(root,process.env.ORGCHART_ARROW_OUTPUT??'test-results/org-chart-p9-5-arrowhead'); +const phase=process.argv[2]??'after', origin=process.env.ORGCHART_STORY_ORIGIN??'http://127.0.0.1:6064'; +const browser=await launchChromium({headless:true}),page=await browser.newPage({viewport:{width:1920,height:1080}}); +const checks=[],measurements=[],errors=[]; +page.on('pageerror',e=>errors.push(e.message));page.on('console',m=>{if(m.type()==='error')errors.push(m.text());}); +await fs.mkdir(output,{recursive:true}); +const check=(pass,name,detail)=>{checks.push({name,status:pass?'PASS':'FAIL',detail});if(!pass)console.log('FAIL',name,JSON.stringify(detail));}; +const settle=()=>page.evaluate(async()=>{await document.fonts.ready;for(let i=0;i<20;i++)await new Promise(requestAnimationFrame);}); +const before=phase==='after'?JSON.parse(await fs.readFile(path.join(output,'before.json'),'utf8')):null; +const reference=await fs.readFile('D:/디자인시스템/gpd-workdesk/sample/조직도_v1.3/org-dashboard-data.js','utf8'); +const originalD=reference.match(/id:\s*"jang-arrow-head",[\s\S]*?\bd:\s*"([^"]+)"/)[1]; +try{ + for(const size of [{width:1920,height:1080},{width:1920,height:1440},{width:390,height:844}]){ + await page.setViewportSize(size);await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`); + await page.waitForFunction(()=>{const s=document.querySelector('.hm-orgchart-host')?.shadowRoot;return s?.querySelector('.hm-orgchart-stage[data-workdesk-artwork]')&&!s.querySelector('[aria-busy="true"]')&&[...s.querySelectorAll('link[rel="stylesheet"]')].every(l=>l.sheet);});await settle(); + const snapshot=await page.evaluate(()=>{ + const s=document.querySelector('.hm-orgchart-host').shadowRoot,stage=s.querySelector('.hm-orgchart-stage'),overlay=s.querySelector('.hm-orgchart-workdesk-overlay'); + const r=e=>{const b=e.getBoundingClientRect();return{x:b.left,y:b.top,width:b.width,height:b.height,right:b.right,bottom:b.bottom};}; + const o=r(overlay),scale=o.width/1920,local=e=>{const b=r(e);return{x:(b.x-o.x)/scale,y:(b.y-o.y)/scale,width:b.width/scale,height:b.height/scale};}; + const card=s.querySelector('.hm-orgchart-top-leaders .hm-orgchart-employee'),hub=s.querySelector('.hm-orgchart-hub'),head=s.querySelector('[data-arrow-head="representative"]'),line=s.querySelector('[data-connector="representative-to-hub"]'); + return{scale,offset:Number(s.querySelector('.hm-orgchart-composition').dataset.centerOffset),card:local(card),hub:local(hub),crop:{card:r(card),hub:r(hub)},head:head?{d:head.getAttribute('d'),fill:getComputedStyle(head).fill,stroke:getComputedStyle(head).stroke,transform:head.getAttribute('transform'),box:local(head),screen:r(head)}:null,headCount:s.querySelectorAll('[data-arrow-head="representative"]').length, + line:{d:line.getAttribute('d'),transform:line.getAttribute('transform'),start:line.getPointAtLength(0),end:line.getPointAtLength(line.getTotalLength())},team:[...s.querySelectorAll('image[data-arrow]')].map(e=>({id:e.dataset.arrow,box:local(e),href:e.getAttribute('href')})), + connections:s.querySelectorAll('[data-connector]').length,cards:s.querySelectorAll('.hm-orgchart-employee').length,scroll:[document.documentElement.scrollWidth,document.documentElement.scrollHeight]}; + }); + const name=`${size.width}x${size.height}`; measurements.push({name,...snapshot}); + const clip={x:Math.max(0,Math.floor(snapshot.crop.card.x-30)),y:Math.max(0,Math.floor(snapshot.crop.card.y-10)),width:Math.ceil(snapshot.crop.card.width+60),height:Math.ceil(snapshot.crop.hub.bottom-snapshot.crop.card.y+25)}; + await page.screenshot({path:path.join(output,`${phase}-${name}-detail.png`),clip,animations:'disabled'}); + if(phase==='after'){ + const old=before.measurements.find(m=>m.name===name),h=snapshot.head; + check(snapshot.headCount===1&&h?.d===originalD&&h.fill==='rgb(69, 99, 159)'&&h.stroke==='none',`${name}: exact original solid chevron restored once`,h); + if(!h)continue; + const gap=h.box.y-(snapshot.card.y+snapshot.card.height); + check(Math.abs(gap)<.01,`${name}: upward tip attaches to representative bottom edge`,gap); + check(Math.abs(h.box.x+h.box.width/2-(snapshot.card.x+snapshot.card.width/2))<.1,`${name}: arrow aligned to card/connector axis`,h.box); + const headBottom=h.box.y+h.box.height,lineStart=Number(snapshot.line.d.match(/M[^ ]+ ([^V]+)/)[1])-17; + check(Math.abs(lineStart-headBottom)<.2,`${name}: chevron meets existing dashed stem`,{headBottom,lineStart}); + check(snapshot.connections===5&&snapshot.cards===old.cards&&snapshot.team.length===5&&snapshot.team.every((a,i)=>a.id===old.team[i].id&&a.href===old.team[i].href&&['x','y','width','height'].every(k=>Math.abs(a.box[k]-old.team[i].box[k])<.05)),`${name}: five team arrows/relations and cards unchanged`); + check(['card','hub'].every(key=>['x','y','width','height'].every(k=>Math.abs(snapshot[key][k]-old[key][k])<.05)),`${name}: representative/hub placement preserved`); + const target=page.locator('[data-arrow-head="representative"]'); + const shown=await page.screenshot({clip,animations:'disabled'});await target.evaluate(e=>e.style.visibility='hidden'); + const hidden=await page.screenshot({clip,animations:'disabled'});await target.evaluate(e=>e.style.removeProperty('visibility')); + check(!shown.equals(hidden),`${name}: restored arrow actually paints visible pixels`,{shownBytes:shown.length,hiddenBytes:hidden.length}); + check(snapshot.scroll[0]<=size.width+1&&snapshot.scroll[1]<=size.height+1,`${name}: no new document scroll`,snapshot.scroll); + } + } +}catch(e){check(false,'Targeted arrow check completed',{message:e.message,stack:e.stack});} +check(!errors.length,'Console/page errors zero',errors); +const result={phase,origin,measurements,checks,errors,pass:checks.filter(c=>c.status==='PASS').length,fail:checks.filter(c=>c.status==='FAIL').length}; +await fs.writeFile(path.join(output,`${phase}.json`),JSON.stringify(result,null,2));await browser.close();console.log(JSON.stringify({phase,pass:result.pass,fail:result.fail,measurements:measurements.map(m=>({name:m.name,head:m.head?.box,card:m.card,connections:m.connections}))}));if(result.fail)process.exitCode=1; diff --git a/examples/org-chart/verify-p9-5-core-dialog-close.mjs b/examples/org-chart/verify-p9-5-core-dialog-close.mjs new file mode 100644 index 0000000..b2c4c1e --- /dev/null +++ b/examples/org-chart/verify-p9-5-core-dialog-close.mjs @@ -0,0 +1,101 @@ +// Focused comparison with the real HM Dialog; no P9.4/P9.5 layout regression replay. +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +const output = path.resolve('test-results/org-chart-p9-5-core-dialog-close'); +const origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6064').replace(/\/$/, ''); +const oldCss = await fs.readFile(path.join(output, 'before-employee-info-dialog.css'), 'utf8'); +const data = JSON.parse(await fs.readFile('examples/org-chart/data-p1.json', 'utf8')); +const checks = [], measurements = [], errors = [], startedAt = new Date().toISOString(); +const check = (pass, name, detail) => { checks.push({ name, status:pass ? 'PASS':'FAIL', detail }); if (!pass) console.log('FAIL', name, JSON.stringify(detail)); }; +const browser = await launchChromium({ headless:true }); +const page = await browser.newPage({ viewport:{ width:1440,height:900 } }); +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; for (let i = 0; i < 12; i++) await new Promise(requestAnimationFrame); }); +const coreClose = '.hm-orgchart-employee-dialog-close', coreDialog = '.hm-orgchart-employee-dialog'; +const refClose = '[data-slot="dialog-content"] button[data-variant="ghost"]'; +async function measure(selector) { + return page.locator(selector).evaluate(button => { + const rect = el => { const r=el.getBoundingClientRect(); return { x:r.x,y:r.y,width:r.width,height:r.height }; }; + const css=getComputedStyle(button), parent=button.closest('dialog,[data-slot="dialog-content"]'), box=rect(button), pbox=rect(parent), svg=button.querySelector('svg'); + const fields=parent.querySelector('dl'), title=parent.querySelector('h2'); + return { box,dialog:pbox,inset:{ top:box.y-pbox.y,right:pbox.x+pbox.width-box.x-box.width }, + css:Object.fromEntries(['padding','border','borderRadius','color','backgroundColor','outlineStyle','outlineOffset','boxShadow','transition','cursor','fontSize','lineHeight','position','top','right','boxSizing','backgroundClip','userSelect'].map(k=>[k,css[k]])), + svg:svg && { box:rect(svg),attrs:Object.fromEntries(['viewBox','fill','stroke','stroke-width','stroke-linecap','stroke-linejoin','aria-hidden'].map(k=>[k,svg.getAttribute(k)])),paths:[...svg.querySelectorAll('path')].map(p=>p.getAttribute('d')) }, + title:title && { box:rect(title),text:title.textContent },fields:fields && { box:rect(fields),html:fields.innerHTML }, + container:Object.fromEntries(['padding','backgroundColor','color','border','borderRadius','fontFamily','fontSize','lineHeight','maxHeight'].map(k=>[k,getComputedStyle(parent)[k]])), + accessibleName:button.getAttribute('aria-label'),focusVisible:button.matches(':focus-visible'),open:parent.open + }; + }); +} +const equivalent=(a,b)=>JSON.stringify(a)===JSON.stringify(b); +const rectNear=(a,b)=>Object.keys(a).every(k=>Math.abs(a[k]-b[k])<.02); +await page.goto(`${origin}/iframe.html?id=components-overlay-dialog--all&viewMode=story`); +await page.getByRole('button',{ name:'기본',exact:true }).click(); await page.locator(refClose).waitFor(); await settle(); +await page.locator(refClose).evaluate(el=>el.blur()); await page.mouse.move(0,0); +const reference={ normal:await measure(refClose) }; +await page.locator(refClose).hover(); await settle(); reference.hover=await measure(refClose); +await page.mouse.move(0,0); await page.keyboard.press('Tab'); await page.locator(refClose).focus(); await settle(); reference.focus=await measure(refClose); +check(reference.focus.focusVisible, 'Actual HM Dialog keyboard focus reference captured', reference.focus.css); +await page.locator('[data-slot="dialog-content"]').screenshot({path:path.join(output,'hm-reference.png')}); +measurements.push({name:'actual HM Dialog',...reference}); +for (const mode of ['React Story / Core popup','Vanilla / no React']) { + for (const width of [1440,390]) { + await page.setViewportSize({width,height:900}); + if (mode.startsWith('React')) { + await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`); + } else { + await page.route('**/p95-native-dialog-fixture', route=>route.fulfill({contentType:'text/html',body:'Core close verification
'})); + await page.goto(`${origin}/p95-native-dialog-fixture`); + await page.evaluate(async data=>{ + const api=await import('/core/index.js'); + window.chart=api.createOrgChart({container:document.querySelector('#fixture'),dataSource:new api.JsonOrgChartDataSource(data),displayMode:'readOnly',stylesheetUrls:{engine:'/core/engine.css',core:'/core/style.css'}}); + await window.chart.mount(); + },data); + } + await page.locator('.hm-orgchart-top-leaders .hm-orgchart-employee').waitFor(); + await page.waitForFunction(()=>[...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('link[rel="stylesheet"]')].every(link=>link.sheet)); await settle(); + const trigger=page.locator('.hm-orgchart-top-leaders .hm-orgchart-employee'); + const label=`${mode} ${width}`; + await trigger.click(); await page.locator(`${coreDialog}[open]`).waitFor(); await settle(); + // Reapply the captured pre-change stylesheet/markup on this same popup to + // compare body geometry at each width/runtime without a second application. + await page.locator(coreClose).evaluate((button,css)=>{ + window.savedCloseNodes=[...button.childNodes]; button.textContent='닫기'; + window.oldCloseStyle=document.createElement('style'); window.oldCloseStyle.textContent=`${css}\n.hm-orgchart-employee-dialog-close { all: revert; padding:var(--hm-orgchart-resolved-spacing-xs) var(--hm-orgchart-resolved-spacing-sm);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-content);color:inherit;font:inherit;cursor:pointer;box-sizing:border-box; } .hm-orgchart-employee-dialog-header { min-height:0; }`; + button.getRootNode().append(window.oldCloseStyle); + },oldCss); + const before=await measure(coreClose); + await page.locator(coreClose).evaluate(button=>{button.replaceChildren(...window.savedCloseNodes);window.oldCloseStyle.remove();}); + await page.locator(coreClose).evaluate(el=>el.blur()); await page.mouse.move(0,0); await settle(); + const normal=await measure(coreClose); + const compareKeys=['padding','border','borderRadius','color','backgroundColor','outlineStyle','outlineOffset','transition','cursor','fontSize','lineHeight','position','top','right','boxSizing','backgroundClip','userSelect']; + check(compareKeys.every(k=>normal.css[k]===reference.normal.css[k]),`${label}: HM normal button styles match`,{actual:normal.css,reference:reference.normal.css}); + check(normal.box.width===reference.normal.box.width && normal.box.height===reference.normal.box.height && equivalent(normal.inset,reference.normal.inset),`${label}: 28px button and top/right inset match`,{box:normal.box,inset:normal.inset}); + check(normal.svg.box.width===16 && normal.svg.box.height===16 && equivalent(normal.svg.attrs,reference.normal.svg.attrs) && equivalent(normal.svg.paths,reference.normal.svg.paths),`${label}: actual Lucide X geometry/stroke matches`,normal.svg); + check(rectNear(before.dialog,normal.dialog) && rectNear(before.fields.box,normal.fields.box) && rectNear(before.title.box,normal.title.box) && equivalent(before.container,normal.container) && before.fields.html===normal.fields.html,`${label}: popup size, title, body geometry, background and data preserved`,{before,after:normal}); + check(normal.accessibleName==='직원 정보 닫기',`${label}: accessible close name preserved`); + await page.locator(coreClose).hover(); await settle(); const hover=await measure(coreClose); + check(hover.css.backgroundColor===reference.hover.css.backgroundColor && hover.css.color===reference.hover.css.color,`${label}: HM hover matches`,hover.css); + await page.mouse.move(0,0); await page.keyboard.press('Tab'); await page.locator(coreClose).focus(); await settle(); const focus=await measure(coreClose); + check(focus.focusVisible && focus.css.border===reference.focus.css.border && focus.css.outlineStyle==='none' && focus.css.boxShadow.includes('oklab(0.462622 -0.0726663 0.0150061 / 0.5) 0px 0px 0px 3px'),`${label}: HM focus border and 3px 50% ring match`,focus.css); + await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab'); + check(await page.locator(coreClose).evaluate(el=>el.getRootNode().activeElement===el),`${label}: Tab/Shift+Tab focus trap retained`); + await page.mouse.click(5,5); await settle(); + check(await page.locator(`${coreDialog}[open]`).count()===1,`${label}: existing outside-click stays open policy retained`); + await page.locator(coreClose).click(); await settle(); + check(await page.locator(`${coreDialog}[open]`).count()===0 && await trigger.evaluate(el=>el.getRootNode().activeElement===el),`${label}: X click closes and restores employee focus`); + await trigger.click(); await page.keyboard.press('Escape'); await settle(); + check(await page.locator(`${coreDialog}[open]`).count()===0 && await trigger.evaluate(el=>el.getRootNode().activeElement===el),`${label}: ESC closes and restores employee focus`); + await trigger.focus(); await page.keyboard.press('Enter'); await settle(); await page.keyboard.press('Tab'); + const screen=mode.startsWith('React')?'react':'vanilla'; + await page.locator(coreDialog).screenshot({path:path.join(output,`${screen}-${width}-popup.png`)}); + if (mode.startsWith('Vanilla')) check(await page.evaluate(()=>!window.React && ![...document.querySelectorAll('script')].some(el=>/react/i.test(el.src))),`${label}: native Core mounting has no React runtime`); + measurements.push({name:label,before,normal,hover,focus}); + } +} +check(errors.length===0,'No new console/page errors',errors); +const result={startedAt,completedAt:new Date().toISOString(),origin,browser:browser.version(),checks,measurements,errors,pass:checks.filter(c=>c.status==='PASS').length,fail:checks.filter(c=>c.status==='FAIL').length}; +await fs.writeFile(path.join(output,'result.json'),JSON.stringify(result,null,2)); await browser.close(); +console.log(JSON.stringify({pass:result.pass,fail:result.fail,output})); if(result.fail)process.exitCode=1; diff --git a/examples/org-chart/verify-p9-5-full-interactions.mjs b/examples/org-chart/verify-p9-5-full-interactions.mjs new file mode 100644 index 0000000..d479380 --- /dev/null +++ b/examples/org-chart/verify-p9-5-full-interactions.mjs @@ -0,0 +1,246 @@ +// P9.5 Full Composition only: same-mount resize, artwork coordinates and native interactions. +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-p9-5-full'); +const origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6064').replace(/\/$/, ''); +const baseline = JSON.parse(await fs.readFile(path.join(output, 'before.json'), 'utf8')); +const fixture = JSON.parse(await fs.readFile(path.join(root, 'examples/org-chart/data-p1.json'), 'utf8')); +const names = ['team-arrow-blue-short.png', 'team-arrow-blue-purple.png', 'team-arrow-purple-teal.png', 'team-arrow-teal-green.png']; +const assetHashes = Object.fromEntries(await Promise.all(names.map(async name => [name, createHash('sha256').update(await fs.readFile(path.join(root, 'packages/org-chart/src/assets/workdesk', name))).digest('hex')]))); +const arrowAssets = { 'blue-short-1': names[0], 'blue-short-2': names[0], 'blue-purple': names[1], 'purple-teal': names[2], 'teal-green': names[3] }; +const checks = [], measurements = [], errors = [], arrowPaint = []; +const startedAt = new Date().toISOString(); +const browser = await launchChromium({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } }); +page.setDefaultTimeout(30000); +page.on('pageerror', error => errors.push({ type: 'pageerror', message: error.message })); +page.on('console', message => { if (message.type() === 'error') errors.push({ type: 'console', message: message.text() }); }); +const check = (pass, name, detail) => { checks.push({ name, status: pass ? 'PASS' : 'FAIL', detail }); if (!pass) console.log('FAIL', name, JSON.stringify(detail)); }; +const notVerified = (name, detail) => checks.push({ name, status: 'NOT VERIFIED', detail }); +const near = (a, b, tolerance = .15) => Object.keys(a).every(key => Math.abs(a[key] - b[key]) <= tolerance); +const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 20; i++) await new Promise(requestAnimationFrame); }); +async function ready() { + await page.waitForFunction(() => { + const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; + return shadow?.querySelector('.hm-orgchart-stage[data-workdesk-artwork]') && !shadow.querySelector('[aria-busy="true"]') && [...shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet); + }); + await page.evaluate(async () => { await Promise.all([...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('img')].map(image => image.decode())); }); + await settle(); +} +async function snapshot(name) { + const value = await page.evaluate(async () => { + const host = document.querySelector('.hm-orgchart-story-full'), shadow = host.querySelector('.hm-orgchart-host').shadowRoot; + const stage = shadow.querySelector('.hm-orgchart-stage'), wrapper = shadow.querySelector('.hm-orgchart-composition'); + const rect = element => { const r = element.getBoundingClientRect(); return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height }; }; + const origin = rect(stage), scale = origin.width / stage.offsetWidth, offset = Number(wrapper.dataset.centerOffset) || 0; + const normal = element => { const r = rect(element); return { x: (r.left - origin.left) / scale, y: (r.top - origin.top) / scale - offset, width: r.width / scale, height: r.height / scale }; }; + const hash = async bytes => [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))].map(byte => byte.toString(16).padStart(2, '0')).join(''); + const central = [...stage.querySelectorAll('.hm-orgchart-axis .hm-orgchart-employee,.hm-orgchart-hub,.hm-orgchart-viewport .hm-orgchart-employee,.hm-orgchart-viewport .hm-orgchart-organization-name')].map((element, index) => ({ key: `${element.className}:${element.dataset.employeeId ?? element.closest('.node')?.dataset.organizationId ?? ''}:${index}`, box: normal(element) })); + const arrows = await Promise.all([...stage.querySelectorAll('image[data-arrow]')].map(async element => { + const href = element.getAttribute('href'), image = new Image(); image.src = href; await image.decode(); + return { id: element.dataset.arrow, box: normal(element), rect: rect(element), hash: await hash(await (await fetch(href)).arrayBuffer()), loaded: image.naturalWidth > 0, attributes: Object.fromEntries(['x', 'y', 'width', 'height', 'preserveAspectRatio'].map(key => [key, element.getAttribute(key)])) }; + })); + const paths = [...stage.querySelectorAll('.hm-orgchart-workdesk-overlay path')].map(element => { + const point = element.getPointAtLength(element.getTotalLength()), screen = new DOMPoint(point.x, point.y).matrixTransform(element.getScreenCTM()); + return { id: element.dataset.connector, d: element.getAttribute('d'), box: normal(element), endpoint: { x: screen.x, y: screen.y } }; + }); + const management = rect(stage.querySelector('.node[data-organization-id="business-management"] .hm-orgchart-organization-name')); + const hostRect = rect(host), css = getComputedStyle(host); + const left = hostRect.left + (parseFloat(css.borderLeftWidth) || 0) + (parseFloat(css.paddingLeft) || 0); + const right = hostRect.right - (parseFloat(css.borderRightWidth) || 0) - (parseFloat(css.paddingRight) || 0); + const line = paths.find(item => item.id === 'hub-to-business-management'); + return { size: { width: innerWidth, height: innerHeight }, stage: origin, scale, offset, canvasHeight: Number(wrapper.dataset.canvasHeight), sameMount: stage === window.__p95MountedStage, + horizontalError: (origin.left + origin.right - left - right) / 2, central, arrows, paths, + managementEndpointError: { x: line.endpoint.x - (management.left + management.right) / 2, y: line.endpoint.y - management.top - 2 * scale }, + links: [...stage.querySelectorAll('.hm-orgchart-organization-link')].map(element => ({ href: element.href, text: element.textContent })) }; + }); + measurements.push({ name, ...value }); + return value; +} +async function stability() { + return page.evaluate(async () => { + const stage = document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-stage'); + const records = []; + const observer = new MutationObserver(items => { for (const item of items) records.push({ oldStyle: item.oldValue, style: stage.getAttribute('style'), at: performance.now() }); }); + observer.observe(stage, { attributes: true, attributeFilter: ['style'], attributeOldValue: true }); + for (let i = 0; i < 20; i++) await new Promise(requestAnimationFrame); + observer.disconnect(); return { count: records.length, records }; + }); +} +// Directly affected by the adaptive-anchor notification: a bounded parent +// grows while viewport and scale remain unchanged. Do not replay other checks. +if (process.argv.includes('--anchor-notify-only')) { + const probes = []; + try { + await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`); await ready(); + const value = await page.evaluate(async data => { + const api = await import('/core/index.js'), host = document.createElement('section'); + Object.assign(host.style, { position: 'fixed', left: '0', top: '0', width: '1920px', height: '914.5px', zIndex: '1000', overflow: 'hidden' }); document.body.append(host); + const handle = api.createOrgChart({ container: host, dataSource: new api.JsonOrgChartDataSource(data), displayMode: 'readOnly', stylesheetUrls: { engine: '/core/engine.css', core: '/core/style.css' } }); + try { + await handle.mount(); + const shadow = host.querySelector('.hm-orgchart-host').shadowRoot; + await Promise.all([...shadow.querySelectorAll('link[rel="stylesheet"]')].map(link => link.sheet ? Promise.resolve() : new Promise((resolve, reject) => { link.addEventListener('load', resolve, { once: true }); link.addEventListener('error', reject, { once: true }); }))); + const waitFrames = async () => { await document.fonts.ready; for (let i = 0; i < 20; i++) await new Promise(requestAnimationFrame); }; + await waitFrames(); + const wrapper = shadow.querySelector('.hm-orgchart-composition'), stage = shadow.querySelector('.hm-orgchart-stage'); + let notifications = 0; wrapper.addEventListener('hm-orgchart-scale', () => { notifications++; }); + const rect = element => { const r = element.getBoundingClientRect(); return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height }; }; + const scroll = element => ({ width: element.scrollWidth, clientWidth: element.clientWidth, height: element.scrollHeight, clientHeight: element.clientHeight, overflowX: getComputedStyle(element).overflowX, overflowY: getComputedStyle(element).overflowY }); + const measure = () => ({ size: { width: innerWidth, height: innerHeight }, host: rect(host), stage: rect(stage), scale: rect(stage).width / 1920, centerOffset: Number(wrapper.dataset.centerOffset), canvasHeight: Number(wrapper.dataset.canvasHeight), notifications, + representative: rect(stage.querySelector('.hm-orgchart-top-leaders .hm-orgchart-employee')), hub: rect(stage.querySelector('.hm-orgchart-hub')), tree: rect(stage.querySelector('.hm-orgchart-viewport')), + scroll: { root: scroll(document.documentElement), body: scroll(document.body), host: scroll(host), wrapper: scroll(wrapper), viewport: scroll(stage.querySelector('.hm-orgchart-viewport')) }, + painted: [...stage.querySelectorAll('.hm-orgchart-employee,.hm-orgchart-organization-name,image[data-arrow],.hm-orgchart-heading,.hm-orgchart-slot-side,.hm-orgchart-slot-footer')].map(rect) }); + const before = measure(); host.style.height = '1014.5px'; await waitFrames(); const after = measure(); + return { before, after }; + } finally { handle.destroy(); host.remove(); } + }, fixture); + probes.push(value); + check(value.before.scale === 1 && value.after.scale === 1 && value.before.size.width === value.after.size.width && value.before.size.height === value.after.size.height, 'Bounded parent height-only resize retains scale 1 without viewport resize', { before: value.before.scale, after: value.after.scale, viewport: value.after.size }); + check(value.before.centerOffset === 0 && value.after.centerOffset > value.before.centerOffset && value.after.canvasHeight > value.before.canvasHeight && value.after.representative.top > value.before.representative.top && value.after.hub.top > value.before.hub.top && value.after.tree.top > value.before.tree.top, 'Bounded parent height-only resize recalculates central anchors', { beforeOffset: value.before.centerOffset, afterOffset: value.after.centerOffset, representativeDelta: value.after.representative.top - value.before.representative.top, hubDelta: value.after.hub.top - value.before.hub.top, treeDelta: value.after.tree.top - value.before.tree.top }); + check(value.before.notifications === 0 && value.after.notifications > 0, 'Adaptive anchor change emits existing hm-orgchart-scale notification at unchanged scale', { before: value.before.notifications, after: value.after.notifications }); + const scrollable = Object.entries(value.after.scroll).filter(([, item]) => ['auto', 'scroll'].includes(item.overflowX) && item.width > item.clientWidth + 1 || ['auto', 'scroll'].includes(item.overflowY) && item.height > item.clientHeight + 1); + const documentFits = ['root', 'body'].every(key => value.after.scroll[key].width <= 1921 && value.after.scroll[key].height <= 1081); + const inside = value.after.painted.every(item => item.left >= value.after.host.left - 1 && item.right <= value.after.host.right + 1 && item.top >= value.after.host.top - 1 && item.bottom <= value.after.host.bottom + 1); + check(!scrollable.length && documentFits && inside && value.after.stage.bottom <= value.after.host.bottom + 1, 'Bounded parent resize keeps actual content contained without document or internal scrollbars', { scrollable, scroll: value.after.scroll, inside }); + } catch (error) { check(false, 'Anchor notification probe completed', { message: error.message, stack: error.stack }); } + check(errors.length === 0, 'Anchor notification probe console/page errors zero', errors); + const result = { checks, probes, errors, pass: checks.filter(item => item.status === 'PASS').length, fail: checks.filter(item => item.status === 'FAIL').length }; + await fs.writeFile(path.join(output, 'anchor-notification.json'), JSON.stringify(result, null, 2)); + await browser.close(); console.log(JSON.stringify({ pass: result.pass, fail: result.fail, checks, output: path.join(output, 'anchor-notification.json') })); process.exit(result.fail ? 1 : 0); +} +// Repeat only a failed stability probe, preserving its first-run evidence. +if (process.argv.includes('--stability-only')) { + const probes = []; + try { + await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`); await ready(); + await page.evaluate(() => { window.__p95MountedStage = document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-stage'); }); + for (const size of [{ width: 1920, height: 1080 }, { width: 1920, height: 1440 }, { width: 1440, height: 900 }, { width: 1920, height: 1080 }]) { + await page.setViewportSize(size); await settle(); + const state = await page.evaluate(() => { + const host = document.querySelector('.hm-orgchart-story-full'), shadow = host.querySelector('.hm-orgchart-host').shadowRoot; + const stage = shadow.querySelector('.hm-orgchart-stage'), wrapper = shadow.querySelector('.hm-orgchart-composition'); + const r = stage.getBoundingClientRect(), h = host.getBoundingClientRect(), css = getComputedStyle(host); + const left = h.left + (parseFloat(css.borderLeftWidth) || 0) + (parseFloat(css.paddingLeft) || 0), right = h.right - (parseFloat(css.borderRightWidth) || 0) - (parseFloat(css.paddingRight) || 0); + return { horizontalError: (r.left + r.right - left - right) / 2, sameMount: stage === window.__p95MountedStage, offset: Number(wrapper.dataset.centerOffset), scale: r.width / 1920 }; + }); + probes.push({ size, ...state }); + check(state.sameMount && Math.abs(state.horizontalError) <= 1, `${size.width}x${size.height}-${probes.length}: modified centering helper retains same mount within 1px`, probes.at(-1)); + if (size.width === 1440) { + const probe = await stability(); + probes.at(-1).stability = probe; + check(probe.count === 0, '1440x900: twenty settled frames have zero Stage style mutations (failed item recheck)', probe); + } + } + const benchmark = await page.evaluate(async data => { + const api = await import('/core/index.js'), host = document.createElement('section'); + Object.assign(host.style, { position: 'fixed', left: '0', top: '0', width: '1920px', height: '914.5px', zIndex: '1000', overflow: 'hidden' }); document.body.append(host); + const handle = api.createOrgChart({ container: host, dataSource: new api.JsonOrgChartDataSource(data), displayMode: 'readOnly', stylesheetUrls: { engine: '/core/engine.css', core: '/core/style.css' } }); + try { + await handle.mount(); + const shadow = host.querySelector('.hm-orgchart-host').shadowRoot; + await Promise.all([...shadow.querySelectorAll('link[rel="stylesheet"]')].map(link => link.sheet ? Promise.resolve() : new Promise((resolve, reject) => { link.addEventListener('load', resolve, { once: true }); link.addEventListener('error', reject, { once: true }); }))); + await document.fonts.ready; for (let i = 0; i < 20; i++) await new Promise(requestAnimationFrame); + const stage = shadow.querySelector('.hm-orgchart-stage'), wrapper = shadow.querySelector('.hm-orgchart-composition'); + const rect = element => { const r = element.getBoundingClientRect(); return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height }; }; + const s = rect(stage), h = rect(host), status = rect(stage.querySelector('.hm-orgchart-slot-belowTree')), footer = rect(stage.querySelector('.hm-orgchart-slot-footer')); + return { scale: s.width / 1920, centerOffset: Number(wrapper.dataset.centerOffset), canvasHeight: Number(wrapper.dataset.canvasHeight), stage: s, host: h, status, footer, gap: footer.top - status.bottom, cards: stage.querySelectorAll('.hm-orgchart-employee').length, horizontalError: (s.left + s.right - h.left - h.right) / 2 }; + } finally { handle.destroy(); host.remove(); } + }, fixture); + probes.push({ name: 'public-1920x914.5-base-benchmark', ...benchmark }); + check(benchmark.scale === 1 && benchmark.centerOffset === 0 && benchmark.canvasHeight === 914.5 && Math.abs(benchmark.stage.height - 914.5) < .02, 'Original 1920x914.5 design benchmark retains scale 1 and center offset 0', benchmark); + check(Math.abs(benchmark.gap - 14.609375) < .02, 'Original design benchmark exposes 14.609375px status/footer gap', benchmark.gap); + check(benchmark.cards === 43 && Math.abs(benchmark.horizontalError) <= 1, 'Original design benchmark retains cards and content horizontal center', benchmark); + } catch (error) { check(false, 'Stability reproduction completed', { message: error.message, stack: error.stack }); } + check(errors.length === 0, 'Stability reproduction console/page errors zero', errors); + const result = { checks, probes, errors, pass: checks.filter(item => item.status === 'PASS').length, fail: checks.filter(item => item.status === 'FAIL').length }; + await fs.writeFile(path.join(output, 'stability-recheck.json'), JSON.stringify(result, null, 2)); + await browser.close(); console.log(JSON.stringify(result)); process.exit(result.fail ? 1 : 0); +} +try { + await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`); await ready(); + await page.evaluate(() => { window.__p95MountedStage = document.querySelector('.hm-orgchart-host').shadowRoot.querySelector('.hm-orgchart-stage'); }); + let first; + for (const size of [{ width: 1920, height: 1080 }, { width: 1920, height: 1440 }, { width: 1440, height: 900 }, { width: 1920, height: 1080 }]) { + await page.setViewportSize(size); await settle(); + const name = `${size.width}x${size.height}-${measurements.length + 1}`, current = await snapshot(name); first ??= current; + check(current.sameMount, `${name}: same Full Composition mount retained`); + check(Math.abs(current.horizontalError) < .75, `${name}: actual wrapper horizontal center retained`, current.horizontalError); + check(current.central.length === first.central.length && current.central.every((item, index) => item.key === first.central[index].key && near(item.box, first.central[index].box)), `${name}: representative, hub and all organization/card relative coordinates retained after center offset`, { count: current.central.length, offset: current.offset }); + check(current.paths.length === 5 && current.paths.every((item, index) => item.id === first.paths[index].id && item.d === first.paths[index].d && near(item.box, first.paths[index].box)), `${name}: all five SVG paths share central movement without coordinate drift`, current.paths); + check(Math.abs(current.managementEndpointError.x) < .05 && Math.abs(current.managementEndpointError.y) < .05, `${name}: management connector endpoint meets live header`, current.managementEndpointError); + check(current.arrows.length === 5 && current.arrows.every((item, index) => item.loaded && item.id === first.arrows[index].id && near(item.box, first.arrows[index].box) && item.hash === assetHashes[arrowAssets[item.id]]), `${name}: five original arrow assets and relative geometry retained`, current.arrows.map(({ id, hash, box }) => ({ id, hash, box }))); + const old = baseline.measurements.find(item => item.name === `${size.width}x${size.height}`); + check(current.arrows.every(item => { const previous = old.arrows.find(arrow => arrow.id === item.id); return near(item.box, { x: previous.box.x, y: previous.box.y, width: previous.box.width, height: previous.box.height }); }), `${name}: collaboration arrow base placement unchanged from pre-P9.5 measurement`); + const settled = await stability(); + check(settled.count === 0, `${name}: twenty settled frames have zero Stage style mutations`, settled); + } + check(measurements[1].offset > measurements[0].offset && Math.abs(measurements[3].offset - measurements[0].offset) < .01, 'Taller viewport increases center offset and reverse resize restores it', measurements.map(({ name, offset, canvasHeight }) => ({ name, offset, canvasHeight }))); + check(first.links.length === 0, 'Canonical Full Story retains its existing unconfigured group titles', first.links); + + // Compare each actually rendered arrow crop against the same crop with only + // that arrow hidden. A changed image proves painted pixels, not merely DOM presence. + for (const arrow of measurements.at(-1).arrows) { + const r = arrow.rect, left = Math.max(0, Math.floor(r.left)), top = Math.max(0, Math.floor(r.top)); + const clip = { x: left, y: top, width: Math.ceil(r.right) - left, height: Math.ceil(r.bottom) - top }; + const shown = await page.screenshot({ clip, animations: 'disabled' }); + await page.evaluate(id => { const element = document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`image[data-arrow="${id}"]`); element.dataset.p95OldStyle = element.getAttribute('style') ?? ''; element.style.visibility = 'hidden'; }, arrow.id); + const hidden = await page.screenshot({ clip, animations: 'disabled' }); + await page.evaluate(id => { const element = document.querySelector('.hm-orgchart-host').shadowRoot.querySelector(`image[data-arrow="${id}"]`); const style = element.dataset.p95OldStyle; if (style) element.setAttribute('style', style); else element.removeAttribute('style'); delete element.dataset.p95OldStyle; }, arrow.id); + const painted = !shown.equals(hidden); arrowPaint.push({ id: arrow.id, painted, clip, pngBytes: shown.length }); + check(painted, `${arrow.id}: collaboration arrow contributes visible painted pixels`, arrowPaint.at(-1)); + } + await page.screenshot({ path: path.join(output, 'interaction-artwork-1920x1080.png'), fullPage: true, animations: 'disabled' }); + + const employee = page.locator('.hm-orgchart-viewport .hm-orgchart-employee').first(); + const employeeId = await employee.getAttribute('data-employee-id'); + await employee.focus(); await page.keyboard.press('Enter'); + const dialog = page.locator('.hm-orgchart-employee-dialog[open]'); await dialog.waitFor(); + check(await dialog.getAttribute('data-employee-id') === employeeId && (await dialog.textContent()).includes('홍 길 동'), 'Full employee keyboard activation opens existing popup with employee identity', { employeeId }); + await page.screenshot({ path: path.join(output, 'interaction-employee-popup.png'), fullPage: true }); + await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' }); await settle(); + check(await employee.evaluate(element => element === element.getRootNode().activeElement), 'Full employee popup ESC restores card focus'); + + // The Story intentionally has no configured links. Exercise the public link + // API in an isolated temporary Workdesk mount; suppress native navigation. + const coreAvailable = await fetch(`${origin}/core/index.js`).then(response => response.ok).catch(() => false); + if (!coreAvailable) notVerified('Public organizationLinks native keyboard interaction', '/core/index.js route unavailable; canonical Story does not configure group links.'); + else { + const linkState = await page.evaluate(async data => { + const api = await import('/core/index.js'); + const host = document.createElement('section'); host.id = 'p95-link-probe'; + Object.assign(host.style, { position: 'fixed', left: '0', top: '0', width: '1200px', height: '600px', zIndex: '1000', overflow: 'hidden' }); + document.body.append(host); + const href = 'https://example.invalid/group/business'; + const handle = api.createOrgChart({ container: host, dataSource: new api.JsonOrgChartDataSource(data), displayMode: 'readOnly', stylesheetUrls: { engine: '/core/engine.css', core: '/core/style.css' }, organizationLinks: { 'business-management': href } }); + await handle.mount(); + const shadow = host.querySelector('.hm-orgchart-host').shadowRoot; + await Promise.all([...shadow.querySelectorAll('link[rel="stylesheet"]')].map(link => link.sheet ? Promise.resolve() : new Promise((resolve, reject) => { link.addEventListener('load', resolve, { once: true }); link.addEventListener('error', reject, { once: true }); }))); + for (let i = 0; i < 20; i++) await new Promise(requestAnimationFrame); + const link = shadow.querySelector('.hm-orgchart-organization-link'); + window.__p95LinkProbe = { handle, host, shadow, clicks: 0 }; + link.addEventListener('click', event => { event.preventDefault(); window.__p95LinkProbe.clicks++; }); + return { links: shadow.querySelectorAll('.hm-orgchart-organization-link').length, href: link.href, nativeTag: link.tagName, tabIndex: link.tabIndex, beforeCards: shadow.querySelectorAll('.hm-orgchart-employee').length }; + }, fixture); + const link = page.locator('#p95-link-probe .hm-orgchart-organization-link'); + await link.focus(); + check(await link.evaluate(element => element === element.getRootNode().activeElement) && linkState.links === 1 && linkState.nativeTag === 'A' && linkState.tabIndex === 0 && linkState.href === 'https://example.invalid/group/business', 'Public Full group link retains native anchor href and keyboard focus', linkState); + await page.keyboard.press('Enter'); await settle(); + const afterLink = await page.evaluate(() => ({ clicks: window.__p95LinkProbe.clicks, cards: window.__p95LinkProbe.shadow.querySelectorAll('.hm-orgchart-employee').length, dialogs: window.__p95LinkProbe.shadow.querySelectorAll('dialog[open]').length, url: location.href })); + check(afterLink.clicks === 1 && afterLink.cards === linkState.beforeCards && afterLink.dialogs === 0 && afterLink.url.includes('orgchart--full-composition'), 'Group link keyboard activation preserves chart and suppresses network navigation', afterLink); + await page.evaluate(() => { window.__p95LinkProbe.handle.destroy(); window.__p95LinkProbe.host.remove(); delete window.__p95LinkProbe; }); + } +} catch (error) { check(false, 'Focused Full interaction run completed', { message: error.message, stack: error.stack }); } +finally { + await page.evaluate(() => { if (window.__p95LinkProbe) { window.__p95LinkProbe.handle.destroy(); window.__p95LinkProbe.host.remove(); delete window.__p95LinkProbe; } }).catch(() => {}); + check(errors.length === 0, 'No Full interaction console/page errors', errors); + const result = { startedAt, completedAt: new Date().toISOString(), origin, browser: browser.version(), checks, measurements, arrowPaint, assetHashes, errors, pass: checks.filter(item => item.status === 'PASS').length, fail: checks.filter(item => item.status === 'FAIL').length, notVerified: checks.filter(item => item.status === 'NOT VERIFIED').length }; + await fs.writeFile(path.join(output, 'interactions.json'), JSON.stringify(result, null, 2)); + await browser.close(); console.log(JSON.stringify({ pass: result.pass, fail: result.fail, notVerified: result.notVerified, output: path.join(output, 'interactions.json') })); + if (result.fail) process.exitCode = 1; +} diff --git a/examples/org-chart/verify-p9-5-full.mjs b/examples/org-chart/verify-p9-5-full.mjs new file mode 100644 index 0000000..ae45a2c --- /dev/null +++ b/examples/org-chart/verify-p9-5-full.mjs @@ -0,0 +1,94 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root,'test-results/org-chart-p9-5-full'); +const phase = process.argv[2] ?? 'after'; +const origin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6064'; +const sizes = [{width:1920,height:1080},{width:1920,height:1440},{width:1440,height:900},{width:1280,height:1024},{width:390,height:844}]; +const browser = await launchChromium({headless:true}); +const page = await browser.newPage({viewport:sizes[0]}); +const measurements = [], checks = [], errors = []; +page.setDefaultTimeout(30000); +page.on('pageerror',e=>errors.push(e.message)); +page.on('console',m=>{if(m.type()==='error')errors.push(m.text());}); +await fs.mkdir(output,{recursive:true}); +const check=(pass,name,detail)=>{checks.push({name,status:pass?'PASS':'FAIL',detail});if(!pass)console.log('FAIL',name,JSON.stringify(detail));}; +const settle=()=>page.evaluate(async()=>{await document.fonts.ready;for(let i=0;i<16;i++)await new Promise(requestAnimationFrame);}); +async function ready(){ + await page.waitForFunction(()=>{const s=document.querySelector('.hm-orgchart-host')?.shadowRoot;return s?.querySelector('.hm-orgchart-stage[data-workdesk-artwork]')&&!s.querySelector('[aria-busy="true"]')&&[...s.querySelectorAll('link[rel="stylesheet"]')].every(l=>l.sheet);}); + await page.evaluate(async()=>{await Promise.all([...document.querySelector('.hm-orgchart-host').shadowRoot.querySelectorAll('img')].map(async i=>{i.loading='eager';await i.decode();}));}); await settle(); +} +async function snapshot(name){ + const s=await page.evaluate(async()=>{ + const host=document.querySelector('.hm-orgchart-story-full'),shadow=host.querySelector('.hm-orgchart-host').shadowRoot; + const wrapper=shadow.querySelector('.hm-orgchart-composition'),stage=shadow.querySelector('.hm-orgchart-stage'); + const rect=e=>{const r=e.getBoundingClientRect();return{left:r.left,right:r.right,top:r.top,bottom:r.bottom,width:r.width,height:r.height};}; + const hb=rect(host),st=rect(stage),css=getComputedStyle(host),scale=st.width/1920; + const content={left:hb.left+parseFloat(css.borderLeftWidth)+parseFloat(css.paddingLeft),right:hb.right-parseFloat(css.borderRightWidth)-parseFloat(css.paddingRight),top:hb.top+parseFloat(css.borderTopWidth)+parseFloat(css.paddingTop),bottom:hb.bottom-parseFloat(css.borderBottomWidth)-parseFloat(css.paddingBottom)}; + content.width=content.right-content.left;content.height=content.bottom-content.top; + const union=items=>{const b=[...items].map(rect).filter(r=>r.width&&r.height);const left=Math.min(...b.map(r=>r.left)),right=Math.max(...b.map(r=>r.right)),top=Math.min(...b.map(r=>r.top)),bottom=Math.max(...b.map(r=>r.bottom));return{left,right,top,bottom,width:right-left,height:bottom-top};}; + const norm=b=>({x:(b.left-st.left)/scale,y:(b.top-st.top)/scale,width:b.width/scale,height:b.height/scale}); + const heading=rect(stage.querySelector('.hm-orgchart-heading')),side=rect(stage.querySelector('.hm-orgchart-slot-side')); + const axis=rect(stage.querySelector('.hm-orgchart-axis')),tree=rect(stage.querySelector('.hm-orgchart-viewport')); + const status=rect(stage.querySelector('.hm-orgchart-slot-belowTree')),footer=rect(stage.querySelector('.hm-orgchart-slot-footer')); + const center=union(stage.querySelectorAll('.hm-orgchart-axis .hm-orgchart-employee,.hm-orgchart-hub,.hm-orgchart-viewport .hm-orgchart-employee,.hm-orgchart-viewport .hm-orgchart-organization-name')); + const cards=[...stage.querySelectorAll('.hm-orgchart-employee')].map(e=>({id:e.dataset.employeeId,rect:rect(e),box:norm(rect(e)),role:e.querySelector('.hm-orgchart-person-role')?.textContent,name:e.querySelector('.hm-orgchart-person-name')?.textContent,asset:e.dataset.cardAssetSize,style:Object.fromEntries(['fontFamily','fontSize','fontWeight','lineHeight','backgroundImage','backgroundColor','width','height'].map(k=>[k,getComputedStyle(e)[k]]))})); + const titles=[...stage.querySelectorAll('.hm-orgchart-viewport .hm-orgchart-organization-name')].map(e=>({id:e.closest('.node').dataset.organizationId,rect:rect(e),box:norm(rect(e)),style:Object.fromEntries(['fontFamily','fontSize','fontWeight','lineHeight','color','backgroundImage','width','height'].map(k=>[k,getComputedStyle(e)[k]]))})); + const arrows=await Promise.all([...stage.querySelectorAll('image[data-arrow]')].map(async e=>{ + const img=new Image();img.src=e.getAttribute('href');await img.decode(); + const box=rect(e);const blockers=[...stage.querySelectorAll('.hm-orgchart-employee,.hm-orgchart-organization-name,.hm-orgchart-hub')].filter(el=>{const r=rect(el);return Math.min(box.right,r.right)-Math.max(box.left,r.left)>.5&&Math.min(box.bottom,r.bottom)-Math.max(box.top,r.top)>.5;}).map(e=>e.className); + return{id:e.dataset.arrow,rect:box,box:norm(box),loaded:img.naturalWidth>0,natural:[img.naturalWidth,img.naturalHeight],blockers,display:getComputedStyle(e).display,visibility:getComputedStyle(e).visibility}; + })); + const paths=[...stage.querySelectorAll('.hm-orgchart-workdesk-overlay path')].map(e=>({id:e.dataset.connector,d:e.getAttribute('d'),rect:rect(e)})); + const decorations=[...stage.querySelectorAll('.hm-orgchart-workdesk-decoration')].map(e=>({id:e.dataset.workdeskAsset,box:norm(rect(e)),rect:rect(e)})); + const centralCards=cards.filter(c=>!stage.querySelector('.hm-orgchart-slot-belowTree [data-employee-id="'+c.id+'"]')); + const collisions=centralCards.filter(c=>Math.min(c.rect.right,status.right)-Math.max(c.rect.left,status.left)>.5&&Math.min(c.rect.bottom,status.bottom)-Math.max(c.rect.top,status.top)>.5).map(c=>c.id); + const scroll=e=>({width:e.scrollWidth,clientWidth:e.clientWidth,height:e.scrollHeight,clientHeight:e.clientHeight,overflowX:getComputedStyle(e).overflowX,overflowY:getComputedStyle(e).overflowY}); + return{size:{width:innerWidth,height:innerHeight},host:hb,content,wrapper:rect(wrapper),stage:st,scale,baseWidth:stage.offsetWidth,stageLayoutHeight:parseFloat(getComputedStyle(stage).height),top:{heading,side,bounds:union([stage.querySelector('.hm-orgchart-heading'),stage.querySelector('.hm-orgchart-slot-side')])},axis,tree,center,status,footer, + logical:{heading:norm(heading),side:norm(side),axis:norm(axis),tree:norm(tree),status:norm(status),footer:norm(footer),center:norm(center)},gap:footer.top-status.bottom,gapDesign:(footer.top-status.bottom)/scale, + horizontalError:(st.left+st.right-content.left-content.right)/2,centerOffset:Number(wrapper.dataset.centerOffset)||0, + centerAvailable:{top:Math.max(heading.bottom,side.bottom),bottom:status.top},centerErrorY:(center.top+center.bottom-Math.max(heading.bottom,side.bottom)-status.top)/2, + cards,titles,arrows,paths,decorations,collisions,totals:stage.querySelector('.hm-orgchart-slot-side').textContent,affiliations:stage.querySelector('.hm-orgchart-slot-footer').textContent, + layer:{overlay:getComputedStyle(stage.querySelector('.hm-orgchart-workdesk-overlay')).zIndex,tree:getComputedStyle(stage.querySelector('.hm-orgchart-viewport')).zIndex}, + scroll:{root:scroll(document.documentElement),body:scroll(document.body),host:scroll(host),wrapper:scroll(wrapper)}}; + });measurements.push({name,...s});await fs.writeFile(path.join(output,`${phase}-progress.json`),JSON.stringify({measurements,checks,errors},null,2));return s; +} +try{ + const baseline=phase==='after'?JSON.parse(await fs.readFile(path.join(output,'before.json'),'utf8')):null; + for(const size of sizes){ + await page.setViewportSize(size);await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`);await ready(); + const name=`${size.width}x${size.height}`,s=await snapshot(name); + await page.screenshot({path:path.join(output,`${phase}-${name}.png`),fullPage:true}); + if(phase==='after'){ + const old=baseline.measurements.find(m=>m.name===name); + check(Math.abs(s.horizontalError)<=1,`${name}: wrapper horizontal center`,s.horizontalError); + check(s.arrows.length===5&&s.arrows.every(a=>a.loaded&&a.visibility==='visible'&&a.display!=='none'&&!a.blockers.length),`${name}: 5 loaded arrows without card/title intersections`,s.arrows); + check(s.gapDesign>=12&&s.gapDesign<=16,`${name}: status/footer design gap`,{before:old.gapDesign,after:s.gapDesign,visible:s.gap}); + check(s.collisions.length===0,`${name}: center/status no collisions`,s.collisions); + check(s.cards.length===old.cards.length&&s.cards.every((c,i)=>c.id===old.cards[i].id&&JSON.stringify(c.style)===JSON.stringify(old.cards[i].style))&&s.titles.every((t,i)=>t.id===old.titles[i].id&&JSON.stringify(t.style)===JSON.stringify(old.titles[i].style)),`${name}: card/title typography and geometry preserved`); + check(s.totals===old.totals&&s.affiliations===old.affiliations,`${name}: headcounts/logos/text preserved`); + const movedBox=(current,previous)=>Math.abs(current.x-previous.x)<.1&&Math.abs(current.width-previous.width)<.1&&Math.abs(current.height-previous.height)<.1&&Math.abs(current.y-previous.y-s.centerOffset)<.1; + const inOldStatus=c=>c.box.x>=old.logical.status.x-.1&&c.box.x+c.box.width<=old.logical.status.x+old.logical.status.width+.1&&c.box.y>=old.logical.status.y-.1&&c.box.y+c.box.height<=old.logical.status.y+old.logical.status.height+.1; + check(s.cards.every((c,i)=>inOldStatus(old.cards[i])||movedBox(c.box,old.cards[i].box))&&s.titles.every((t,i)=>movedBox(t.box,old.titles[i].box))&&movedBox(s.logical.axis,old.logical.axis)&&movedBox(s.logical.tree,old.logical.tree),`${name}: entire central chart keeps relative coordinates`,s.centerOffset); + check(s.arrows.every((a,i)=>a.id===old.arrows[i].id&&movedBox(a.box,old.arrows[i].box))&&s.paths.every((p,i)=>p.id===old.paths[i].id&&p.d===old.paths[i].d)&&s.decorations.every((d,i)=>d.id===old.decorations[i].id&&movedBox(d.box,old.decorations[i].box)),`${name}: 5 arrows/connectors/decorations share center offset without resizing`); + check(s.scroll.root.width<=size.width+1&&s.scroll.root.height<=size.height+1&&s.scroll.body.width<=size.width+1&&s.scroll.body.height<=size.height+1,`${name}: document no horizontal/vertical scroll`,s.scroll); + const outside=[...s.cards.map(c=>c.rect),...s.titles.map(t=>t.rect),...s.arrows.map(a=>a.rect),s.top.heading,s.top.side,s.footer].filter(r=>r.lefts.content.right+1||r.tops.content.bottom+1); + check(!outside.length,`${name}: all actual content contained, no overflow clipping`,outside); + check(Math.abs(s.logical.heading.y-old.logical.heading.y)<.2&&Math.abs(s.logical.side.y-old.logical.side.y)<.2,`${name}: top anchors unchanged`,{old:old.top,after:s.top}); + const footerInset=(s.stage.bottom-s.footer.bottom)/s.scale; + check(Math.abs(footerInset-(old.stage.bottom-old.footer.bottom)/old.scale)<.2,`${name}: footer bottom anchor`,footerInset); + const centerBase=old.logical.center; + const deltas=s.cards.filter(c=>!c.id.startsWith('leave-')&&!c.id.startsWith('dispatch-')).map(c=>{const previous=old.cards.find(o=>o.id===c.id);return{x:c.box.x-previous.box.x,y:c.box.y-previous.box.y};}); + const central=s.cards.filter(c=>s.tree.top<=c.rect.top&&c.rect.bottom<=s.tree.bottom+.5); + check(Math.abs(s.scale-Math.min(1,s.content.width/1920,s.content.height/914.5))<.002,`${name}: one proportional scale without enlargement`,s.scale); + if(s.centerAvailable.bottom-s.centerAvailable.top>=s.center.height)check(Math.abs(s.centerErrorY)<1,`${name}: center occupies available vertical middle`,s.centerErrorY); + } + } +}catch(e){check(false,'Full layout measurement completed',{message:e.message,stack:e.stack});} +check(!errors.length,'Console/page errors zero',errors); +const result={phase,origin,browser:browser.version(),measurements,checks,errors,pass:checks.filter(c=>c.status==='PASS').length,fail:checks.filter(c=>c.status==='FAIL').length}; +await fs.writeFile(path.join(output,`${phase}.json`),JSON.stringify(result,null,2));await browser.close(); +console.log(JSON.stringify({phase,measurements:measurements.map(m=>({name:m.name,scale:m.scale,h:m.stageLayoutHeight,center:m.logical.center,status:m.logical.status,footer:m.logical.footer,gap:m.gapDesign,arrows:m.arrows.map(a=>({id:a.id,blockers:a.blockers})),centerError:m.centerErrorY})),pass:result.pass,fail:result.fail}));if(result.fail)process.exitCode=1; diff --git a/examples/org-chart/verify-p9-5-geometry.mjs b/examples/org-chart/verify-p9-5-geometry.mjs new file mode 100644 index 0000000..a8b40bb --- /dev/null +++ b/examples/org-chart/verify-p9-5-geometry.mjs @@ -0,0 +1,187 @@ +// Focused P9.5 transform geometry and pointer-coordinate probes; no P9.4 replay. +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-p9-5'); +const origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6064').replace(/\/$/, ''); +const startedAt = new Date().toISOString(), checks = [], measurements = [], errors = []; +const pointerOnly = process.argv.includes('--pointer-only'); +await fs.mkdir(output, { recursive: true }); +if (pointerOnly) { + const initial = JSON.parse(await fs.readFile(path.join(output, 'geometry-initial.json'), 'utf8')); + measurements.push(...initial.measurements); + for (const c of initial.checks) { + if (c.name === 'Focused geometry browser console/page errors zero') continue; + if (c.name.includes(': pointer move starts at fitted scale')) continue; + if (c.status === 'PASS') checks.push(c); + else if (c.name.endsWith(': connector endpoints reach intended targets')) { + checks.push({ ...c, status: c.detail.maxLocalError < 0.75 ? 'PASS' : 'FAIL', detail: { ...c.detail, toleranceLocalPx: 0.75, reason: 'Integer offset geometry and fractional CSS widths differ by up to 0.5 local px; evaluated from preserved initial measurements without browser replay.' } }); + } + } +} +const browser = await launchChromium({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 1700 } }); +page.setDefaultTimeout(30000); +page.on('pageerror', error => errors.push({ type: 'pageerror', message: error.message })); +page.on('console', message => { if (message.type() === 'error') errors.push({ type: 'console', message: message.text() }); }); +const check = (pass, name, detail) => { checks.push({ name, status: pass ? 'PASS' : 'FAIL', detail }); fs.writeFile(path.join(output, 'geometry-progress.json'), JSON.stringify({ startedAt, checks, measurements, errors }, null, 2)).catch(() => {}); if (!pass) console.log('FAIL', name, JSON.stringify(detail)); }; +const settle = () => page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 10; i++) await new Promise(requestAnimationFrame); }); +async function ready(count) { + await page.waitForFunction(count => { + const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; + return shadow && !shadow.querySelector('[aria-busy="true"]') && [...shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet) + && (count === undefined || shadow.querySelectorAll('.hm-orgchart-employee').length === count); + }, count, { timeout: 60000 }); + await settle(); +} +async function setup(type, label) { + await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); + await page.getByRole('button', { name: /^회사 선택/ }).waitFor(); await ready(140); + await page.getByRole('button', { name: /^회사 선택/ }).click(); + const panel = page.getByRole('dialog', { name: '회사 선택', exact: true }); await panel.waitFor(); + const all = panel.getByRole('checkbox', { name: '전체 선택', exact: true }); + for (let i = 0; i < 2 && await all.getAttribute('aria-checked') !== 'false'; i++) { await all.click(); await ready(); } + await ready(0); await panel.getByRole('checkbox', { name: '한맥기술', exact: true }).click(); await ready(20); + await page.keyboard.press('Escape'); await panel.waitFor({ state: 'hidden' }); + if (type !== 'tb') { + await page.getByRole('combobox', { name: '조직도 표시 유형', exact: true }).click(); + await page.getByRole('option', { name: label, exact: true }).click(); await ready(20); + } + await page.locator('.hm-orgchart-viewport').scrollIntoViewIfNeeded(); await settle(); +} +async function snapshot(name) { + const measurement = await page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + const viewport = shadow.querySelector('.hm-orgchart-viewport'), forest = shadow.querySelector('.hm-orgchart-forest'); + const scale = Number(viewport.dataset.scale), origin = forest.getBoundingClientRect(); + const localPoint = p => ({ x: (p.x - origin.left) / scale, y: (p.y - origin.top) / scale }); + const localRect = element => { const r = element.getBoundingClientRect(); return { x: (r.left - origin.left) / scale, y: (r.top - origin.top) / scale, width: r.width / scale, height: r.height / scale }; }; + const endPoint = p => localPoint(p.getPointAtLength(p.getTotalLength()).matrixTransform(p.getScreenCTM())); + const employeePaths = [...shadow.querySelectorAll('.hm-orgchart-employee-connectors path')].map(p => { + const article = p.closest('.hm-orgchart-organization'); + const card = [...article.querySelectorAll('.hm-orgchart-employee')].find(card => card.dataset.employeeId === p.dataset.employeeId); + const box = localRect(card), direction = p.dataset.direction; + const expected = direction === 'left' ? { x: box.x, y: box.y + box.height / 2 } + : direction === 'right' ? { x: box.x + box.width, y: box.y + box.height / 2 } : { x: box.x + box.width / 2, y: box.y }; + const endpoint = endPoint(p); + return { id: p.dataset.employeeId, direction, d: p.getAttribute('d'), endpoint, expected, error: Math.hypot(endpoint.x - expected.x, endpoint.y - expected.y) }; + }); + const organizationPaths = [...shadow.querySelectorAll('.hm-orgchart-organization-connectors path')].map(p => { + const tree = p.closest('.hm-orgchart-tree'); + const node = [...tree.querySelectorAll('.node')].find(node => node.dataset.organizationId === p.dataset.organizationId); + const heading = localRect(node.querySelector('.hm-orgchart-organization-name')); + const expected = p.dataset.direction === 'left' ? { x: heading.x, y: heading.y + heading.height / 2 } : { x: heading.x + heading.width / 2, y: heading.y }; + const endpoint = endPoint(p); + return { id: p.dataset.organizationId, parentId: p.dataset.parentId, direction: p.dataset.direction, d: p.getAttribute('d'), endpoint, expected, error: Math.hypot(endpoint.x - expected.x, endpoint.y - expected.y) }; + }); + const cards = [...shadow.querySelectorAll('.hm-orgchart-employee')].map(card => ({ id: card.dataset.employeeId, organizationId: card.closest('.node').dataset.organizationId, ...localRect(card) })); + return { layout: shadow.querySelector('.hm-orgchart-expanded').dataset.autoLayout, scale, x: Number(viewport.dataset.panX), y: Number(viewport.dataset.panY), cards, employeePaths, organizationPaths, organizationConnectorPresentation: organizationPaths.length ? 'measured-svg' : 'existing-TB-pseudo-elements' }; + }); + measurements.push({ name, ...measurement }); return measurement; +} +function validateEndpoints(s, label) { + const paths = [...s.employeePaths, ...s.organizationPaths]; + check(s.cards.length === 20 && s.employeePaths.length === 20, `${label}: all 20 cards retain employee connectors`, { cards: s.cards.length, employeePaths: s.employeePaths.length }); + check(paths.every(p => p.error < 0.75), `${label}: connector endpoints reach intended targets`, { paths: paths.length, organizationPaths: s.organizationPaths.length, maxLocalError: Math.max(0, ...paths.map(p => p.error)), toleranceLocalPx: 0.75 }); + if (s.layout !== 'tb') check(s.organizationPaths.length > 0, `${label}: organization routes are measured`, s.organizationPaths.length); +} +function compareGeometry(before, after, label) { + let maxDelta = 0; + const cardKeys = ['x', 'y', 'width', 'height']; + for (const a of before.cards) { + const b = after.cards.find(b => b.id === a.id); + if (!b) { maxDelta = Infinity; break; } + for (const key of cardKeys) maxDelta = Math.max(maxDelta, Math.abs(a[key] - b[key])); + } + for (const key of ['employeePaths', 'organizationPaths']) for (const a of before[key]) { + const b = after[key].find(b => b.id === a.id); + if (!b || a.d !== b.d) { maxDelta = Infinity; break; } + maxDelta = Math.max(maxDelta, Math.abs(a.endpoint.x - b.endpoint.x), Math.abs(a.endpoint.y - b.endpoint.y)); + } + check(maxDelta < 0.1, `${label}: forest-local card/connector geometry is unchanged`, { maxLocalDelta: maxDelta, beforeScale: before.scale, afterScale: after.scale }); +} +async function pan() { + await page.locator('.hm-orgchart-viewport').focus(); + await page.keyboard.press('ArrowLeft'); await page.keyboard.press('ArrowUp'); await settle(); +} +async function pointerMove(type) { + const toggle = page.getByRole('switch', { name: /Drag & Drop|조직 이동/ }); + if (await toggle.getAttribute('aria-checked') !== 'true') await toggle.click(); + await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); + const fitted = await snapshot(`${type}-drag-fit`); await pan(); + const panned = await snapshot(`${type}-drag-pan`); + check(panned.x !== fitted.x && panned.y !== fitted.y && panned.scale === fitted.scale, `${type}: pointer move starts at fitted scale with nonzero manual Pan`, { fitted: { x: fitted.x, y: fitted.y, scale: fitted.scale }, panned: { x: panned.x, y: panned.y, scale: panned.scale } }); + const pair = await page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot, viewport = shadow.querySelector('.hm-orgchart-viewport').getBoundingClientRect(); + const box = element => { const r = element.getBoundingClientRect(); return { left: r.left, top: r.top, right: r.right, bottom: r.bottom, x: r.left + r.width / 2, y: r.top + r.height / 2 }; }; + const inside = r => r.left > viewport.left + 4 && r.right < viewport.right - 4 && r.top > viewport.top + 4 && r.bottom < viewport.bottom - 4 + && r.left > 4 && r.right < innerWidth - 4 && r.top > 4 && r.bottom < innerHeight - 4; + const sources = [...shadow.querySelectorAll('.hm-orgchart-employees .hm-orgchart-employee')].map(element => ({ element, rect: box(element) })).filter(item => inside(item.rect)); + sources.sort((a, b) => Math.hypot(a.rect.x - (viewport.left + viewport.right) / 2, a.rect.y - (viewport.top + viewport.bottom) / 2) - Math.hypot(b.rect.x - (viewport.left + viewport.right) / 2, b.rect.y - (viewport.top + viewport.bottom) / 2)); + for (const source of sources) { + const sourceOrg = source.element.closest('.node').dataset.organizationId; + const targets = [...shadow.querySelectorAll('.hm-orgchart-expanded-toggle')].filter(element => element.dataset.organizationId !== sourceOrg && element.closest('.node').dataset.parentId !== '').map(element => ({ element, rect: box(element) })).filter(item => inside(item.rect)); + targets.sort((a, b) => Math.hypot(a.rect.x - source.rect.x, a.rect.y - source.rect.y) - Math.hypot(b.rect.x - source.rect.x, b.rect.y - source.rect.y)); + if (targets.length) return { employeeId: source.element.dataset.employeeId, beforeOrganizationId: sourceOrg, targetOrganizationId: targets[0].element.dataset.organizationId, source: source.rect, target: targets[0].rect }; + } + throw new Error('No visible employee/other-organization pointer pair after Fit/Pan'); + }); + // Native pointer focus may reveal/scroll a source. Settle that before taking + // client coordinates, and verify both endpoints through live shadow hit tests. + await page.locator(`.hm-orgchart-employee[data-employee-id="${pair.employeeId}"]`).focus(); await settle(); + const actualPair = await page.evaluate(pair => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + const employee = [...shadow.querySelectorAll('.hm-orgchart-employee')].find(item => item.dataset.employeeId === pair.employeeId); + const target = [...shadow.querySelectorAll('.hm-orgchart-expanded-toggle')].find(item => item.dataset.organizationId === pair.targetOrganizationId); + const center = element => { const r = element.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }; + const sourcePoint = center(employee), targetPoint = center(target); + return { ...pair, source: sourcePoint, target: targetPoint, sourceHit: shadow.elementFromPoint(sourcePoint.x, sourcePoint.y)?.closest('.hm-orgchart-employee')?.dataset.employeeId, targetHit: shadow.elementFromPoint(targetPoint.x, targetPoint.y)?.closest('.node[data-organization-id]')?.dataset.organizationId }; + }, pair); + check(actualPair.sourceHit === pair.employeeId && actualPair.targetHit === pair.targetOrganizationId, `${type}: focused pointer endpoints are visible and hit intended DOM targets`, actualPair); + await page.mouse.move(actualPair.source.x, actualPair.source.y); await page.mouse.down(); + await page.mouse.move(actualPair.source.x + 8, actualPair.source.y, { steps: 2 }); + await page.mouse.move(actualPair.target.x, actualPair.target.y, { steps: 14 }); + const hover = await page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + return { dragging: shadow.querySelector('.hm-orgchart-viewport').dataset.editDragging, target: shadow.querySelector('[data-drop-hover="allowed"]')?.dataset.organizationId, feedback: shadow.querySelector('.hm-orgchart-move-feedback').textContent }; + }); + check(hover.dragging === 'true' && hover.target === pair.targetOrganizationId, `${type}: actual pointer hit testing highlights intended transformed target`, { pair, hover }); + await page.mouse.up(); + await page.waitForFunction(({ employeeId, targetOrganizationId }) => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + const card = [...shadow.querySelectorAll('.hm-orgchart-employee')].find(card => card.dataset.employeeId === employeeId); + return card?.closest('.node').dataset.organizationId === targetOrganizationId && shadow.querySelector('.hm-orgchart-move-feedback').dataset.moveStatus === 'success'; + }, pair); await ready(20); + const moved = await snapshot(`${type}-after-pointer-move`); + check(moved.cards.find(card => card.id === pair.employeeId)?.organizationId === pair.targetOrganizationId, `${type}: pointer release moves employee to intended organization`, pair); + validateEndpoints(moved, `${type} after pointer move`); +} +try { + for (const [type, label] of [['tb', '세로형 TB'], ['lr', '가로형 LR'], ['hybrid', '혼합형 Hybrid']]) { + try { + await setup(type, label); + if (!pointerOnly) { + const centered = await snapshot(`${type}-centered`); validateEndpoints(centered, `${type} centered`); + await pan(); const panned = await snapshot(`${type}-panned`); compareGeometry(centered, panned, `${type} Pan`); validateEndpoints(panned, `${type} panned`); + await page.getByRole('button', { name: '확대', exact: true }).click(); await settle(); + const zoomed = await snapshot(`${type}-zoomed`); compareGeometry(centered, zoomed, `${type} Zoom`); validateEndpoints(zoomed, `${type} zoomed`); + await page.getByRole('button', { name: 'Fit to View', exact: true }).click(); await settle(); + const fitted = await snapshot(`${type}-fitted`); compareGeometry(centered, fitted, `${type} Fit`); validateEndpoints(fitted, `${type} fitted`); + } + await pointerMove(type); + if (type === 'hybrid') await page.screenshot({ path: path.join(output, 'geometry-pointer-hybrid.png'), fullPage: true }); + } catch (error) { + check(false, `${type}: focused geometry section completed`, { message: error.message, stack: error.stack }); + await page.mouse.up().catch(() => {}); + } + } +} finally { + check(errors.length === 0, 'Focused geometry browser console/page errors zero', errors); + const result = { startedAt, completedAt: new Date().toISOString(), origin, browser: browser.version(), repairedPointerOnly: pointerOnly, initialEvidence: pointerOnly ? 'geometry-initial.json' : undefined, scope: 'P9.5 only: 1 company × 20 staff; TB/LR/Hybrid transforms and one actual employee pointer move per mode; existing TB organization pseudo-elements unchanged', measurements, checks, errors, pass: checks.filter(c => c.status === 'PASS').length, fail: checks.filter(c => c.status === 'FAIL').length }; + await fs.writeFile(path.join(output, 'geometry.json'), JSON.stringify(result, null, 2)); + console.log(JSON.stringify({ pass: result.pass, fail: result.fail, measurements: measurements.length, output: path.join(output, 'geometry.json') })); + await browser.close(); if (result.fail) process.exitCode = 1; +} diff --git a/examples/org-chart/verify-p9-5-popup-parity.mjs b/examples/org-chart/verify-p9-5-popup-parity.mjs new file mode 100644 index 0000000..996b201 --- /dev/null +++ b/examples/org-chart/verify-p9-5-popup-parity.mjs @@ -0,0 +1,87 @@ +// Verify actual consumers and OPEN popup ownership, not just the presence of DOM. +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +const output=path.resolve('test-results/org-chart-p9-5-popup-parity'); +const story=(process.env.ORGCHART_STORY_ORIGIN??'http://127.0.0.1:6064').replace(/\/$/,''); +const vanilla=(process.env.ORGCHART_VANILLA_ORIGIN??'http://127.0.0.1:4178').replace(/\/$/,''); +const checks=[],measurements=[],errors=[],startedAt=new Date().toISOString(); +const check=(pass,name,detail)=>{checks.push({name,status:pass?'PASS':'FAIL',detail});if(!pass)console.log('FAIL',name,JSON.stringify(detail));}; +const browser=await launchChromium({headless:true}),page=await browser.newPage({viewport:{width:1440,height:900}}); +page.setDefaultTimeout(15000); +page.on('pageerror',error=>errors.push(error.message));page.on('console',msg=>{if(msg.type()==='error')errors.push(msg.text());}); +const settle=()=>page.evaluate(async()=>{await document.fonts.ready;for(let i=0;i<12;i++)await new Promise(requestAnimationFrame);}); +const coreOpen='dialog.hm-orgchart-employee-dialog[open]',hmOpen='[data-component="HMOrgChartEmployeeDialog"][data-state="open"]'; +const refClose='[data-slot="dialog-content"] button[data-variant="ghost"]'; +async function snapshot(selector){return page.locator(selector).evaluate(button=>{ + const rect=el=>{const r=el.getBoundingClientRect();return{x:r.x,y:r.y,width:r.width,height:r.height};}; + const c=getComputedStyle(button),box=rect(button),popup=button.closest('dialog,[data-slot="dialog-content"]'),pbox=rect(popup),svg=button.querySelector('svg'); + return{box,popup:pbox,inset:{top:box.y-pbox.y,right:pbox.x+pbox.width-box.x-box.width},focusVisible:button.matches(':focus-visible'), + css:Object.fromEntries(['width','height','padding','border','borderRadius','color','backgroundColor','outlineStyle','outlineOffset','transition','cursor','position','top','right','boxSizing','backgroundClip','boxShadow'].map(k=>[k,c[k]])), + svg:svg&&{box:rect(svg),attrs:Object.fromEntries(['viewBox','fill','stroke','stroke-width','stroke-linecap','stroke-linejoin','aria-hidden'].map(k=>[k,svg.getAttribute(k)])),paths:[...svg.querySelectorAll('path')].map(p=>p.getAttribute('d'))}, + popupType:popup.matches('dialog')?'Core native':'React HMDesign Dialog',employeeId:popup.dataset.employeeId, + content:popup.querySelector('dl')?.textContent,closeName:button.getAttribute('aria-label')||button.textContent.trim(),root:button.getRootNode() instanceof ShadowRoot?'ShadowRoot':'Document' + }; +});} +await page.goto(`${story}/iframe.html?id=components-overlay-dialog--all&viewMode=story`);await page.getByRole('button',{name:'기본',exact:true}).click();await page.locator(refClose).waitFor();await settle(); +await page.locator(refClose).evaluate(el=>el.blur());await page.mouse.move(0,0);const reference={normal:await snapshot(refClose)}; +await page.locator(refClose).hover();await settle();reference.hover=await snapshot(refClose); +await page.mouse.move(0,0);await page.keyboard.press('Tab');await page.locator(refClose).focus();await settle();reference.focus=await snapshot(refClose); +await page.locator('[data-slot="dialog-content"]').screenshot({path:path.join(output,'hm-dialog-reference-popup.png')}); +measurements.push({name:'HM Dialog actual public component',...reference}); +const cases=[ + {name:'storybook-full',url:`${story}/iframe.html?id=orgchart--full-composition&viewMode=story`,kind:'Core native'}, + {name:'storybook-expanded-hm',url:`${story}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`,kind:'React HMDesign Dialog'}, + {name:'storybook-expanded-core',url:`${story}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`,kind:'Core native',coreOption:true}, + {name:'vanilla-index',url:`${vanilla}/index.html`,kind:'Core native',vanilla:true}, + {name:'vanilla-full',url:`${vanilla}/p1.html?mode=readOnly`,kind:'Core native',vanilla:true}, + {name:'vanilla-expanded',url:`${vanilla}/expanded.html`,kind:'Core native',vanilla:true}, +]; +const near=(a,b)=>Math.abs(a-b)<.02,eq=(a,b)=>JSON.stringify(a)===JSON.stringify(b); +for(const width of [1440,390])for(const item of cases){ + const label=`${item.name} ${width}`;await page.setViewportSize({width,height:900});await page.goto(item.url); + await page.waitForFunction(()=>{const root=document.querySelector('.hm-orgchart-host')?.shadowRoot;return root?.querySelector('.hm-orgchart-employee[role="button"]')&&[...root.querySelectorAll('link[rel="stylesheet"]')].every(link=>link.sheet);});await settle(); + if(item.coreOption){await page.getByRole('combobox',{name:'직원 정보 팝업',exact:true}).click();await page.getByRole('option',{name:'Core 기본 팝업',exact:true}).click();await settle();} + // Use a rendered card inside the horizontally clipped viewport; the Forest + // may legitimately extend beyond the viewport without shrinking its cards. + const choice=await page.evaluate(()=>{ + const root=document.querySelector('.hm-orgchart-host').shadowRoot,v=root.querySelector('.hm-orgchart-viewport').getBoundingClientRect(); + const cards=[...root.querySelectorAll('.hm-orgchart-employee[role="button"]')]; + const candidates=cards.filter(el=>{const r=el.getBoundingClientRect();return r.width>0&&r.height>0&&r.left>=Math.max(0,v.left)&&r.right<=Math.min(innerWidth,v.right);}); + const card=(candidates.length?candidates:cards).sort((a,b)=>Math.abs(a.getBoundingClientRect().left-innerWidth/2)-Math.abs(b.getBoundingClientRect().left-innerWidth/2))[0]; + return{employeeId:card.dataset.employeeId,aria:card.getAttribute('aria-label')}; + }); + const trigger=page.locator(`.hm-orgchart-employee[data-employee-id="${choice.employeeId}"]`).first(); + await trigger.click();const popupSelector=item.kind==='Core native'?coreOpen:hmOpen;await page.locator(popupSelector).waitFor();await settle(); + const closeSelector=item.kind==='Core native'?`${coreOpen} .hm-orgchart-employee-dialog-close`:`${hmOpen} button[data-variant="ghost"]`; + const ownership=await page.evaluate(()=>({core:[...document.querySelectorAll('.hm-orgchart-host')].reduce((n,h)=>n+h.shadowRoot.querySelectorAll('dialog.hm-orgchart-employee-dialog[open]').length,0),hm:document.querySelectorAll('[data-component="HMOrgChartEmployeeDialog"][data-state="open"]').length})); + check(ownership.core===(item.kind==='Core native'?1:0)&&ownership.hm===(item.kind==='Core native'?0:1),`${label}: actual open popup owner matches`,ownership); + await page.locator(closeSelector).evaluate(el=>el.blur());await page.mouse.move(0,0);await settle();const normal=await snapshot(closeSelector); + const keys=['width','height','padding','border','borderRadius','color','backgroundColor','outlineStyle','outlineOffset','transition','cursor','position','top','right','boxSizing','backgroundClip']; + check(keys.every(k=>normal.css[k]===reference.normal.css[k]),`${label}: HM normal X button CSS matches`,normal.css); + check(near(normal.box.width,28)&&near(normal.box.height,28)&&near(normal.svg?.box.width,16)&&near(normal.svg?.box.height,16)&&eq(normal.inset,reference.normal.inset)&&eq(normal.svg?.attrs,reference.normal.svg.attrs)&&eq(normal.svg?.paths,reference.normal.svg.paths),`${label}: HM X shape, stroke, size and inset match`,normal); + await page.locator(popupSelector).screenshot({path:path.join(output,`${item.name}-${width}-popup.png`)}); + await page.screenshot({path:path.join(output,`${item.name}-${width}-context.png`)}); + await page.locator(closeSelector).hover();await settle();const hover=await snapshot(closeSelector); + check(hover.css.backgroundColor===reference.hover.css.backgroundColor&&hover.css.color===reference.hover.css.color,`${label}: HM hover matches`,hover.css); + await page.mouse.move(0,0);await page.keyboard.press('Tab');await page.locator(closeSelector).focus();await settle();const focus=await snapshot(closeSelector); + check(focus.focusVisible&&focus.css.border===reference.focus.css.border&&focus.css.outlineStyle==='none'&&focus.css.boxShadow.includes('oklab(0.462622 -0.0726663 0.0150061 / 0.5) 0px 0px 0px 3px'),`${label}: HM focus ring matches`,focus.css); + // Compare the actual policies: Core retains the open dialog on backdrop; + // Radix HM Dialog closes it. Closing must restore the original shadow card. + const expectedOpen=item.kind==='Core native';await page.mouse.click(2,2);await settle(); + check((await page.locator(popupSelector).count()>0)===expectedOpen,`${label}: existing outside-click policy retained`,{expected:expectedOpen?'stay open':'close'}); + if(!expectedOpen){check(await trigger.evaluate(el=>el.getRootNode().activeElement===el),`${label}: outside-close restores employee focus`);await trigger.click();await page.locator(popupSelector).waitFor();await settle();} + await page.locator(closeSelector).click();await settle(); + check(await page.locator(popupSelector).count()===0&&await trigger.evaluate(el=>el.getRootNode().activeElement===el),`${label}: X closes and returns focus`); + await trigger.focus();await page.keyboard.press('Enter');await page.locator(popupSelector).waitFor();await settle(); + const reopened=await snapshot(closeSelector);check(reopened.employeeId===normal.employeeId&&reopened.content===normal.content,`${label}: reopen keeps selected employee/body data`); + await page.keyboard.press('Escape');await settle();check(await page.locator(popupSelector).count()===0&&await trigger.evaluate(el=>el.getRootNode().activeElement===el),`${label}: ESC closes and returns focus`); + if(item.vanilla)check(await page.evaluate(()=>!window.React&&[...document.querySelectorAll('script[src]')].every(s=>!/(?:react|storybook)/i.test(s.src))),`${label}: actual example runs without React`); + measurements.push({name:label,url:item.url,expectedOwner:item.kind,ownership,choice,normal,hover,focus}); +} +const copied=[];for(const file of ['index.js','style.css']){const hash=async location=>createHash('sha256').update(await fs.readFile(location)).digest('hex');copied.push({file,core:await hash(`packages/org-chart/dist/${file}`),vanilla:await hash(`examples/org-chart/dist/assets/${file}`)});} +check(copied.every(item=>item.core===item.vanilla),'Actual Vanilla example JS/CSS are byte-identical to modified Core build',copied); +check(errors.length===0,'New console/page errors: zero',errors); +const result={startedAt,completedAt:new Date().toISOString(),browser:browser.version(),checks,measurements,copied,errors,pass:checks.filter(c=>c.status==='PASS').length,fail:checks.filter(c=>c.status==='FAIL').length}; +await fs.writeFile(path.join(output,'result.json'),JSON.stringify(result,null,2));await browser.close();console.log(JSON.stringify({pass:result.pass,fail:result.fail,output}));if(result.fail)process.exitCode=1; diff --git a/examples/org-chart/verify-p9-5.mjs b/examples/org-chart/verify-p9-5.mjs new file mode 100644 index 0000000..7fb4470 --- /dev/null +++ b/examples/org-chart/verify-p9-5.mjs @@ -0,0 +1,126 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-p9-5'); +const phase = process.argv[2] ?? 'after'; +const origin = process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6065'; +await fs.mkdir(output, { recursive: true }); +const browser = await launchChromium({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } }); +page.setDefaultTimeout(30000); +const errors = [], measurements = [], checks = []; +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; for (let i = 0; i < 12; i++) await new Promise(requestAnimationFrame); }); +async function ready(count) { + await page.waitForFunction(count => { + const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; + return shadow && !shadow.querySelector('[aria-busy="true"]') && [...shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet) + && (count === undefined || shadow.querySelectorAll('.hm-orgchart-employee').length === count); + }, count, { timeout: 60000 }); + await settle(); +} +async function measure(name) { + const value = await page.evaluate(() => { + const shadow = document.querySelector('.hm-orgchart-host').shadowRoot; + const viewport = shadow.querySelector('.hm-orgchart-viewport'), forest = shadow.querySelector('.hm-orgchart-forest'); + const rect = element => { const r = element.getBoundingClientRect(); return { left:r.left,top:r.top,width:r.width,height:r.height,right:r.right,bottom:r.bottom }; }; + const union = elements => { + const boxes = [...elements].filter(element => element.getClientRects().length && getComputedStyle(element).visibility !== 'hidden').map(rect).filter(r => r.width > 0 && r.height > 0); + if (!boxes.length) return null; + const left = Math.min(...boxes.map(r => r.left)), right = Math.max(...boxes.map(r => r.right)); + const top = Math.min(...boxes.map(r => r.top)), bottom = Math.max(...boxes.map(r => r.bottom)); + return { left,right,top,bottom,width:right-left,height:bottom-top }; + }; + const v = rect(viewport), f = rect(forest); + const content = { left:v.left+viewport.clientLeft,top:v.top+viewport.clientTop,width:viewport.clientWidth,height:viewport.clientHeight }; + const cx = content.left + content.width/2, cy = content.top + content.height/2; + const trees = union(shadow.querySelectorAll('.hm-orgchart-tree')); + const visible = union(shadow.querySelectorAll('.hm-orgchart-organization')); + const error = bounds => bounds ? { x:(bounds.left+bounds.right)/2-cx,y:(bounds.top+bounds.bottom)/2-cy } : null; + return { wrapper:rect(shadow.querySelector('.hm-orgchart-expanded')),viewport:v,content,contentCenter:{x:cx,y:cy},forest:f,trees,visible, + error:{forest:error(f),trees:error(trees),visible:error(visible)},scale:Number(viewport.dataset.scale),x:Number(viewport.dataset.panX),y:Number(viewport.dataset.panY), + unscaledForest:{width:forest.offsetWidth,height:forest.offsetHeight},employees:shadow.querySelectorAll('.hm-orgchart-employee').length, + companies:shadow.querySelectorAll('.node[data-parent-id=""]').length,layout:shadow.querySelector('.hm-orgchart-expanded').dataset.autoLayout }; + }); + measurements.push({ name, ...value }); + await fs.writeFile(path.join(output, `${phase}-progress.json`), JSON.stringify({ measurements,checks,errors }, null, 2)); + return value; +} +const check = (pass, name, detail) => { checks.push({name,status:pass?'PASS':'FAIL',detail}); if (!pass) console.log('FAIL',name,JSON.stringify(detail)); }; +const centered = (s, name, vertical = false) => check(Math.abs(s.error.visible.x) < 0.75 && (!vertical || Math.abs(s.error.visible.y) < 0.75), name, s.error); +async function select(label, option) { + await page.getByRole('combobox', { name:label, exact:true }).click(); + await page.getByRole('option', { name:option, exact:true }).click(); await ready(); +} +async function companies(names) { + await page.getByRole('button',{name:/^회사 선택/}).click(); + const panel = page.getByRole('dialog',{name:'회사 선택',exact:true}); await panel.waitFor(); + const all = panel.getByRole('checkbox',{name:'전체 선택',exact:true}); + for (let i=0;i<2 && await all.getAttribute('aria-checked') !== 'false';i++) { await all.click(); await ready(); } + await ready(0); + if (names.length === 7) { await all.click(); await ready(140); await page.keyboard.press('Escape'); await panel.waitFor({state:'hidden'}); return; } + for (const name of names) { await panel.getByRole('checkbox',{name,exact:true}).click(); await ready(); } + await page.keyboard.press('Escape'); await panel.waitFor({state:'hidden'}); await ready(); +} +async function count(count, total) { + await select('회사당 직원 수',`${count}명`); + const apply = page.getByRole('button',{name:'데이터 적용',exact:true}); if (await apply.isEnabled()) await apply.click(); await ready(total); +} +async function open() { + await page.goto(`${origin}/iframe.html?id=orgchart--expanded-org-chart&viewMode=story`); + await page.getByRole('button',{name:/^회사 선택/}).waitFor(); await ready(140); +} +async function fit() { await page.getByRole('button',{name:'Fit to View',exact:true}).click(); await settle(); } +try { + await open(); const initial = await measure('initial-7x20-tb'); + await page.screenshot({path:path.join(output,`${phase}-initial.png`),fullPage:true}); + if (phase === 'after') { centered(initial,'Initial 7-company forest horizontally centered'); check(initial.scale === 1,'Initial oversized forest keeps 100% scale',initial.scale); } + for (const [companyNames, label] of [[['한맥기술'],'one'],[['한맥기술','삼안'],'two'],[['한맥기술','삼안','장헌산업','장헌','피티씨','한라','바론'],'seven']]) { + if (process.argv[3] && process.argv[3] !== label) continue; + for (const size of [20,100,300]) { + for (const [type,labelText] of [['tb','세로형 TB'],['lr','가로형 LR'],['hybrid','혼합형 Hybrid']]) { + await open(); await companies(companyNames); await count(size,companyNames.length*size); + await select('조직도 표시 유형',labelText); + const s = await measure(`${label}-${size}-${type}-auto`); + if (phase === 'after') { centered(s,`${label}/${size}/${type}: automatic horizontal center`); check(s.scale === 1,`${label}/${size}/${type}: no automatic shrink`,s.scale); } + await fit(); const fitted = await measure(`${label}-${size}-${type}-fit`); + if (phase === 'after') { + centered(fitted,`${label}/${size}/${type}: Fit center both axes`,true); + check(fitted.visible.left >= fitted.content.left+15 && fitted.visible.right <= fitted.content.left+fitted.content.width-15 + && fitted.visible.top >= fitted.content.top+15 && fitted.visible.bottom <= fitted.content.top+fitted.content.height-15,`${label}/${size}/${type}: Fit contains visible tree`,fitted.visible); + } + } + } + } + if (phase === 'after') { + // User Pan preservation across resize and deliberate content/layout changes. + await open(); await companies(['한맥기술']); await fit(); + const viewport = page.locator('.hm-orgchart-viewport'); await viewport.scrollIntoViewIfNeeded(); await viewport.focus(); + await page.keyboard.press('ArrowLeft'); await page.keyboard.press('ArrowUp'); await settle(); + const panned = await measure('user-pan'); + await page.setViewportSize({width:1280,height:1100}); await ready(); const resized = await measure('pan-resize'); + await companies(['한맥기술','삼안']); const filtered = await measure('pan-company'); + await count(100,200); const changed = await measure('pan-data'); + await select('조직도 표시 유형','가로형 LR'); const switched = await measure('pan-layout'); + for (const [name,s] of [['resize',resized],['company selection',filtered],['data',changed],['layout',switched]]) { + check(s.x===panned.x && s.y===panned.y && s.scale===panned.scale,`Manual Pan retained through ${name}`,{before:panned,after:s}); + } + await fit(); centered(await measure('fit-after-pan'),'Explicit Fit restores center after user Pan',true); + for (const width of [390,768,1280,1440]) { + await page.setViewportSize({width,height:1100}); await open(); await companies(['한맥기술']); + centered(await measure(`width-${width}-initial`),`${width}px automatic horizontal center`); + await fit(); centered(await measure(`width-${width}-fit`),`${width}px Fit center`,true); + } + await page.setViewportSize({width:1440,height:1100}); await open(); await companies(['한맥기술']); + await fit(); await page.screenshot({path:path.join(output,'after-single-fit.png'),fullPage:true}); + } +} catch(error) { check(false,'Measurement completed',{message:error.message,stack:error.stack}); } +check(errors.length===0,'Browser console/page errors zero',errors); +const result = {phase,origin,browser:browser.version(),measurements,checks,errors,pass:checks.filter(c=>c.status==='PASS').length,fail:checks.filter(c=>c.status==='FAIL').length}; +await fs.writeFile(path.join(output,`${phase}.json`),JSON.stringify(result,null,2)); +console.log(JSON.stringify({phase,measurements:measurements.length,pass:result.pass,fail:result.fail,initial:measurements[0],output:path.join(output,`${phase}.json`)})); +await browser.close(); if(result.fail) process.exitCode=1; diff --git a/examples/org-chart/verify-p9.mjs b/examples/org-chart/verify-p9.mjs new file mode 100644 index 0000000..040a4d1 --- /dev/null +++ b/examples/org-chart/verify-p9.mjs @@ -0,0 +1,251 @@ +// P9 focused visual integration checks. Historical P7/P8 full suites stay unrun. +import fs from 'node:fs/promises'; +import { writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; +import { launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-p9'); +const origin = (process.env.ORGCHART_STORY_ORIGIN ?? 'http://127.0.0.1:6061').replace(/\/$/, ''); +const args = process.argv.slice(2), baselineMode = args.includes('--baseline'); +const sections = args.flatMap((arg, index) => arg === '--section' ? (args[index + 1] ?? '').split(',') : arg.startsWith('--section=') ? arg.slice(10).split(',') : []).filter(Boolean); +const run = name => !sections.length || sections.includes(name); +const widths = args.filter(arg => arg.startsWith('--width=')).map(arg => Number(arg.slice(8))); +const suffix = (baselineMode ? '-baseline' : sections.length ? '-' + sections.join('-') : '') + (widths.length ? '-' + widths.join('-') : ''); +const startedAt = new Date().toISOString(), results = [], measurements = [], errors = []; +await fs.mkdir(output, { recursive: true }); +const persist = () => writeFileSync(path.join(output, `browser-progress${suffix}.json`), JSON.stringify({ startedAt, origin, baselineMode, sections, results, measurements, errors }, null, 2)); +const check = (pass, name, detail) => { + results.push({ name, status: pass ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); persist(); + if (!pass) console.log('FAIL', name, JSON.stringify(detail)); +}; +const stories = { + overview: 'orgchart--logo-personnel-summary', department: 'orgchart--department-colors', + fixed: 'orgchart--full-composition', expanded: 'orgchart--expanded-org-chart', +}; +const sizes = [{ width: 1920, height: 1080 }, { width: 1440, height: 900 }, { width: 390, height: 844 }]; +const selectedSizes = sizes.filter(size => !widths.length || widths.includes(size.width)); +const colors = { hanmac: '#cb2e28', saman: '#f08300', 'jangheon-industry': '#4580a6', jangheon: '#1d418d', ptc: '#84338e', halla: '#0068b7', baron: '#0c367f' }; +const browser = await launchChromium({ headless: true }); +const context = await browser.newContext({ viewport: sizes[1] }); +context.on('page', target => { + target.on('pageerror', error => { errors.push({ type: 'pageerror', url: target.url(), message: error.message }); persist(); }); + target.on('console', message => { if (message.type() === 'error') { errors.push({ type: 'console', url: target.url(), message: message.text(), location: message.location() }); persist(); } }); +}); +const page = await context.newPage(); +page.setDefaultTimeout(20000); +let expectedHosts = 1; +async function settle() { await page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 5; i++) await new Promise(resolve => requestAnimationFrame(resolve)); }); } +async function ready(requirePage = true) { + if (requirePage) await page.locator('.sb-page-title').waitFor(); + await page.waitForFunction(expected => { + const hosts = [...document.querySelectorAll('.hm-orgchart-host,.hm-orgchart-component-host')]; + return hosts.length === expected && hosts.every(host => host.shadowRoot && [...host.shadowRoot.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet) && !host.shadowRoot.querySelector('[aria-busy="true"]') && [...host.shadowRoot.querySelectorAll('img')].every(image => image.complete)); + }, expectedHosts); + await settle(); +} +async function goto(name, size = sizes[1]) { expectedHosts = name === 'overview' ? 3 : 1; await page.setViewportSize(size); await page.goto(`${origin}/iframe.html?id=${stories[name]}&viewMode=story`); await ready(); } +async function capture(name, fullPage = true) { await page.screenshot({ path: path.join(output, name + '.png'), fullPage }); } +async function select(label, option) { await page.getByRole('combobox', { name: label, exact: true }).click(); await page.getByRole('option', { name: option, exact: true }).click(); await ready(); } +async function section(name, work) { if (!run(name)) return; try { await work(); } catch (error) { check(false, `${name}: runner completed`, { message: error.message, stack: error.stack }); } } +const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b); + +async function snapshot() { + return page.evaluate(async () => { + const shadows = [...document.querySelectorAll('.hm-orgchart-host,.hm-orgchart-component-host')].map(host => host.shadowRoot).filter(Boolean); + const all = selector => shadows.flatMap(shadow => [...shadow.querySelectorAll(selector)]); + const first = selector => all(selector)[0]; + const rect = element => { if (!element) return null; const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; }; + const properties = ['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'color', 'backgroundColor', 'backgroundImage', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor', 'borderRadius', 'boxShadow', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'textAlign', 'whiteSpace', 'overflowWrap', 'verticalAlign']; + const style = (element, dimensions = false) => element ? Object.fromEntries([...properties, ...(dimensions ? ['width', 'height'] : [])].map(key => [key, getComputedStyle(element)[key]])) : null; + const table = first('.hm-orgchart-workdesk-position-table'); + const summary = table ? { + title: first('.hm-orgchart-organization-totals-title')?.textContent, + rows: [...table.rows].map(row => [...row.cells].map(cell => ({ text: cell.textContent, tag: cell.tagName, scope: cell.getAttribute('scope'), position: cell.dataset.position ?? null, total: cell.dataset.totalCount ?? null }))), + styles: { + section: style(first('.hm-orgchart-organization-totals')), + title: style(first('.hm-orgchart-organization-totals-title'), true), frame: style(first('.hm-orgchart-workdesk-summary-frame'), true), table: style(table, true), + cells: [...table.rows].map(row => [...row.cells].map(cell => style(cell, true))), + }, + footer: [...table.tFoot.rows[0].cells].slice(1).map(cell => Number(cell.textContent)), + body: [...table.tBodies[0].rows].map(row => [...row.cells].slice(1).map(cell => Number(cell.textContent))), + } : null; + const bar = first('.hm-orgchart-affiliation-bar'); + const affiliation = bar ? { + items: [...bar.querySelectorAll('.hm-orgchart-affiliation-bar-item')].map(item => ({ id: item.dataset.affiliationId ?? 'total', count: item.dataset.employeeCount ?? item.dataset.totalCount, name: item.querySelector('dt')?.textContent, value: item.querySelector('dd')?.textContent })), + styles: { bar: style(bar), items: [...bar.querySelectorAll('.hm-orgchart-affiliation-bar-item')].map(item => ({ item: style(item), name: style(item.querySelector('dt')), value: style(item.querySelector('dd')), mark: style(item.querySelector('.hm-orgchart-affiliation-mark'), true), imageFit: item.querySelector('img') ? getComputedStyle(item.querySelector('img')).objectFit : null })) }, + box: rect(bar), + } : null; + const wrapper = first('.hm-orgchart-composition'), stage = first('.hm-orgchart-stage'); + const scale = Number(wrapper?.dataset.scale ?? 1), stageBox = rect(stage); + const round = value => Math.round(value * 20) / 20; + const fixed = stage ? { + scale, wrapper: rect(wrapper), stage: stageBox, baseWidth: stage.offsetWidth, baseHeight: stage.offsetHeight, matrix: getComputedStyle(stage).transform, + stageStyle: style(stage), background: getComputedStyle(wrapper).backgroundColor, + htmlHash: [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(stage.innerHTML)))].map(byte => byte.toString(16).padStart(2, '0')).join(''), + preflightElements: all('.hm-orgchart-stage .hm-orgchart-employee,.hm-orgchart-stage .hm-orgchart-organization-totals,.hm-orgchart-stage .hm-orgchart-organization-totals-title,.hm-orgchart-stage .hm-orgchart-workdesk-summary-frame,.hm-orgchart-stage .hm-orgchart-workdesk-position-table,.hm-orgchart-stage .hm-orgchart-workdesk-position-table th,.hm-orgchart-stage .hm-orgchart-workdesk-position-table td,.hm-orgchart-stage .hm-orgchart-affiliation-bar,.hm-orgchart-stage .hm-orgchart-affiliation-bar-list,.hm-orgchart-stage .hm-orgchart-affiliation-bar-item,.hm-orgchart-stage .hm-orgchart-affiliation-bar-item dt,.hm-orgchart-stage .hm-orgchart-affiliation-bar-item dd,.hm-orgchart-stage .hm-orgchart-affiliation-bar .hm-orgchart-affiliation-mark').map(element => ({ className: element.className, text: element.textContent, style: Object.fromEntries(['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'color', 'backgroundColor', 'backgroundImage', 'width', 'height', 'padding', 'border', 'borderRadius', 'boxShadow', 'textAlign', 'whiteSpace', 'gap', 'display', 'flexWrap', 'alignItems', 'justifyContent'].map(key => [key, getComputedStyle(element)[key]])) })), + invariants: all('.hm-orgchart-stage .hm-orgchart-employee,.hm-orgchart-stage .hm-orgchart-organization,.hm-orgchart-stage .hm-orgchart-organization-totals,.hm-orgchart-stage .hm-orgchart-affiliation-bar,.hm-orgchart-stage .hm-orgchart-workdesk-overlay').map(element => { + const r = rect(element); return { className: element.className, id: element.dataset.employeeId ?? element.dataset.organizationId ?? null, box: { x: round((r.x - stageBox.x) / scale), y: round((r.y - stageBox.y) / scale), width: round(r.width / scale), height: round(r.height / scale) }, style: style(element, true) }; + }), + scroll: ['.hm-orgchart-shell', '.hm-orgchart-content', '.hm-orgchart-composition', '.hm-orgchart-viewport'].map(selector => { + const element = first(selector); if (!element) return { selector, missing: true }; + const css = getComputedStyle(element); return { selector, left: element.scrollLeft, top: element.scrollTop, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, scrollHeight: element.scrollHeight, clientHeight: element.clientHeight, overflowX: css.overflowX, overflowY: css.overflowY }; + }), + } : null; + const logos = all('[data-component="Logo"] .hm-orgchart-affiliation-mark').map(mark => { + const img = mark.querySelector('img'); return { id: mark.dataset.affiliationId, available: mark.dataset.logoAvailable, box: rect(mark), loaded: Boolean(img?.complete && img.naturalWidth > 0), naturalWidth: img?.naturalWidth, naturalHeight: img?.naturalHeight, fit: img ? getComputedStyle(img).objectFit : null, style: style(mark) }; + }); + const department = first('[data-component="DepartmentColors"]'); + const departments = department ? [...department.querySelectorAll('.hm-orgchart-color-preview')].map(row => ({ + name: row.querySelector('.hm-orgchart-color-department-name').textContent, box: rect(row), preview: rect(row.querySelector('.hm-orgchart-preview-grid')), + swatches: [...row.querySelectorAll('[data-color-variable]')].map(swatch => ({ variable: swatch.dataset.colorVariable, property: swatch.dataset.cssProperty, value: swatch.parentElement.querySelector('.hm-orgchart-color-value').textContent })), + cards: [...row.querySelectorAll('.hm-orgchart-employee')].map(card => ({ kind: card.dataset.cardKind, width: card.offsetWidth, height: card.offsetHeight, box: rect(card), font: getComputedStyle(card).fontFamily, asset: card.dataset.cardAssetSize })), + })) : null; + const viewport = first('.hm-orgchart-expanded .hm-orgchart-viewport'), forest = first('.hm-orgchart-forest'); + const expanded = first('.hm-orgchart-expanded'); + const hex = color => color.replace(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/g, (_, r, g, b) => '#' + [r, g, b].map(x => Number(x).toString(16).padStart(2, '0')).join('')); + const auto = expanded ? { + type: expanded.dataset.autoLayout, viewport: rect(viewport), forest: rect(forest), employees: expanded.querySelectorAll('.hm-orgchart-employee').length, + titles: [...expanded.querySelectorAll('.node[data-company-id]')].map(node => { const title = node.querySelector('.hm-orgchart-organization-name'), toggle = node.querySelector('.hm-orgchart-expanded-toggle'); return { company: node.dataset.companyId, color: hex(getComputedStyle(title).color), buttonColor: hex(getComputedStyle(toggle).color), background: hex(getComputedStyle(title).backgroundColor) }; }), + cards: [...expanded.querySelectorAll('.hm-orgchart-employee')].map(card => { const css = getComputedStyle(card), direction = card.dataset.connectionDirection; return { company: card.dataset.companyId, direction, width: card.offsetWidth, height: card.offsetHeight, border: hex(css[direction === 'left' ? 'borderLeftColor' : direction === 'right' ? 'borderRightColor' : 'borderTopColor']), image: Boolean(card.querySelector('img')) }; }), + connectors: expanded.querySelectorAll('svg,.edge,.lines').length, + } : null; + const common = Object.fromEntries(['.sb-page-title', '.sb-page-description', '.sb-page-section-title', '.sb-page-section-header'].map(selector => [selector, style(document.querySelector(selector))])); + return { title: document.querySelector('.sb-page-title')?.textContent, titles: [...document.querySelectorAll('.sb-page-section-title')].map(element => element.textContent), document: { width: innerWidth, scrollWidth: document.documentElement.scrollWidth, scrollHeight: document.documentElement.scrollHeight, clientHeight: document.documentElement.clientHeight }, common, summary, affiliation, logos, departments, fixed, auto }; + }); +} +function noHorizontalOverflow(state) { return state.document.scrollWidth <= state.document.width + 1; } +function contains(outer, inner, tolerance = 1) { return outer && inner && inner.x >= outer.x - tolerance && inner.y >= outer.y - tolerance && inner.right <= outer.right + tolerance && inner.bottom <= outer.bottom + tolerance; } +function fitted(state) { return state.auto && contains(state.auto.viewport, state.auto.forest, 2); } +function affiliationStyles(styles) { + // Overview's permitted wrapping uses vertical content padding rather than a + // fixed 55.4px bar height. Every item/typography/logo style remains shared. + return { ...styles, bar: Object.fromEntries(Object.entries(styles.bar).filter(([key]) => !['paddingTop', 'paddingBottom'].includes(key))) }; +} +function sourceInvariant(fixed) { return { width: fixed.baseWidth, height: fixed.baseHeight, style: fixed.stageStyle, contents: fixed.invariants }; } +function geometryNear(a, b) { + if (!a || !b || a.width !== b.width || a.height !== b.height || !equal(a.style, b.style) || a.contents.length !== b.contents.length) return false; + return a.contents.every((item, index) => { const other = b.contents[index]; return item.className === other.className && item.id === other.id && equal(item.style, other.style) && Object.keys(item.box).every(key => Math.abs(item.box[key] - other.box[key]) <= 0.1); }); +} + +try { + if (baselineMode) { + const snapshots = {}; + for (const name of ['overview', 'department', 'fixed']) { await goto(name); snapshots[name] = await snapshot(); await capture(`baseline-${name}-1440`); } + check(Boolean(snapshots.fixed.summary && snapshots.fixed.affiliation && snapshots.fixed.fixed), 'Baseline: Fixed shared presenters and geometry captured'); + await fs.writeFile(path.join(output, 'visual-baseline.json'), JSON.stringify({ startedAt, completedAt: new Date().toISOString(), origin, viewport: sizes[1], snapshots }, null, 2)); + } else { + const baselinePath = await fs.access(path.join(output, 'preflight.json')).then(() => 'preflight.json', () => 'visual-baseline.json'); + const baseline = JSON.parse(await fs.readFile(path.join(output, baselinePath), 'utf8')); + const preflightFixed = baselinePath === 'preflight.json' ? baseline.snapshots.fixed[0] : null; + const preflightHash = preflightFixed ? createHash('sha256').update(preflightFixed.stage.html).digest('hex') : null; + await goto('fixed'); const reference = await snapshot(); + await section('overview', async () => { + for (const size of selectedSizes) { + await goto('overview', size); const state = await snapshot(); measurements.push({ name: `Overview ${size.width}`, state }); + check(equal(state.titles, ['관계사·회사 로고', '조직·직위별 인원 현황', '관계사별 인원 현황']), `Overview ${size.width}: exact three confirmed section titles`, state.titles); + check(Boolean(state.summary) && equal(state.summary.rows, reference.summary.rows), `Overview ${size.width}: table rows, positions and totals equal Fixed`, state.summary?.rows); + check(Boolean(state.summary) && equal(state.summary.styles, reference.summary.styles), `Overview ${size.width}: table, heading, cells, borders and font reuse Fixed computed styles`, { overview: state.summary?.styles, fixed: reference.summary.styles }); + check(Boolean(state.summary) && state.summary.footer.every(Number.isFinite) && state.summary.footer.slice(0, -1).reduce((a, b) => a + b, 0) === state.summary.footer.at(-1) && state.summary.body.every(row => row.slice(0, -1).reduce((a, b) => a + b, 0) === row.at(-1)), `Overview ${size.width}: numeric position sums and row subtotals reconcile`, { footer: state.summary?.footer, body: state.summary?.body }); + check(Boolean(state.affiliation) && equal(state.affiliation.items, reference.affiliation.items) && equal(affiliationStyles(state.affiliation.styles), affiliationStyles(reference.affiliation.styles)), `Overview ${size.width}: affiliation data, typography, logos, border and background reuse Fixed`, { overview: state.affiliation, fixed: reference.affiliation }); + const previewBox = await page.locator('.sb-page-section').last().locator('.sb-page-section-body').boundingBox(); + check(state.affiliation?.box.x >= previewBox.x - 1 && state.affiliation.box.right <= previewBox.x + previewBox.width + 1 && state.affiliation.box.y >= previewBox.y - 1 && state.affiliation.box.bottom <= previewBox.y + previewBox.height + 1, `Overview ${size.width}: complete affiliation bar is contained in page flow`, { bar: state.affiliation?.box, preview: previewBox }); + check(state.logos.length === 7 && state.logos.every(logo => logo.available === 'true' && logo.loaded && logo.fit === 'contain' && logo.naturalWidth > 0 && logo.naturalHeight > 0), `Overview ${size.width}: seven approved logos loaded with preserved contain sizing`, state.logos); + check(noHorizontalOverflow(state), `Overview ${size.width}: no document horizontal overflow`, state.document); + check(equal(state.common, reference.common), `Overview ${size.width}: common StoryPage typography and border remain identical`); + await capture(`overview-${size.width}`); + } + }); + await section('department', async () => { + for (const size of selectedSizes) { + await goto('department', size); const state = await snapshot(); measurements.push({ name: `Department ${size.width}`, state }); + check(state.departments?.length === 6 && state.departments.every(row => row.swatches.every(swatch => /#[0-9a-f]{6}/i.test(swatch.value))), `Department ${size.width}: six preserved organization color previews have computed HEX values`, state.departments?.map(row => ({ name: row.name, values: row.swatches }))); + check(state.departments?.every(row => row.cards.every(card => card.width === (card.kind === 'employee' ? 108 : 146) && card.height === (card.kind === 'employee' ? 52 : 68) && card.font.includes('Noto Sans KR') && contains(row.box, card.box))), `Department ${size.width}: original card geometry, font and containment`, state.departments?.map(row => ({ name: row.name, cards: row.cards }))); + check(noHorizontalOverflow(state) && equal(state.common, reference.common), `Department ${size.width}: responsive page retains shared frame styles`, state.document); + check(state.departments?.every((row, index, rows) => rows.every((other, otherIndex) => otherIndex === index || Math.abs(row.box.y - other.box.y) > 1 || Math.abs(row.preview.y - other.preview.y) <= 1)), `Department ${size.width}: card-preview starts align across each shared row`, state.departments?.map(row => ({ name: row.name, rowY: row.box.y, previewY: row.preview.y }))); + measurements.push({ name: `Department ${size.width} card-preview alignment`, previews: state.departments?.map(row => ({ name: row.name, row: row.box, preview: row.preview })) }); + await capture(`department-colors-${size.width}`); + } + }); + await section('fixed', async () => { + for (const size of selectedSizes) { + await goto('fixed', size); const before = await snapshot(); + await page.locator('.hm-orgchart-composition').scrollIntoViewIfNeeded(); + const box = await page.locator('.hm-orgchart-composition').boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + Math.min(30, box.height / 2)); await page.mouse.wheel(80, 80); await settle(); + const state = await snapshot(), fixed = state.fixed; measurements.push({ name: `Fixed ${size.width}`, state }); + const matrix = fixed.matrix.match(/matrix\(([^)]+)\)/)?.[1].split(',').map(Number); + check(fixed.scale > 0 && fixed.scale <= 1 && matrix && Math.abs(matrix[0] - matrix[3]) < 0.0001 && contains(fixed.wrapper, fixed.stage) && fixed.invariants.every(item => item.box.x >= -1 && item.box.y >= -1 && item.box.x + item.box.width <= fixed.baseWidth + 1 && item.box.y + item.box.height <= fixed.baseHeight + 1), `Fixed ${size.width}: complete chart bounds and proportional fit`, { scale: fixed.scale, matrix, wrapper: fixed.wrapper, stage: fixed.stage }); + check(fixed.scroll.every(item => item.missing || (item.left === 0 && item.top === 0 && (item.overflowX === 'hidden' || item.scrollWidth <= item.clientWidth + 1) && (item.overflowY === 'hidden' || item.scrollHeight <= item.clientHeight + 1))), `Fixed ${size.width}: no actual internal scroll after wheel`, { before: before.fixed.scroll, after: fixed.scroll }); + const preserved = preflightFixed ? fixed.baseWidth === preflightFixed.stage.width && fixed.baseHeight === preflightFixed.stage.height && equal(fixed.preflightElements, preflightFixed.elements) && fixed.htmlHash === preflightHash : geometryNear(sourceInvariant(fixed), sourceInvariant(baseline.snapshots.fixed.fixed)) && equal(state.summary, baseline.snapshots.fixed.summary) && equal(state.affiliation.items, baseline.snapshots.fixed.affiliation.items) && equal(state.affiliation.styles, baseline.snapshots.fixed.affiliation.styles); + check(preserved, `Fixed ${size.width}: baseline card/layout and summary styling preserved`, { baseline: baselinePath, naturalWidth: fixed.baseWidth, naturalHeight: fixed.baseHeight, baselineElementCount: preflightFixed?.elements.length, elementCount: fixed.preflightElements.length, computedStylesEqual: preflightFixed ? equal(fixed.preflightElements, preflightFixed.elements) : undefined, stageDOMHashEqual: preflightFixed ? fixed.htmlHash === preflightHash : undefined }); + check(noHorizontalOverflow(state), `Fixed ${size.width}: no document horizontal overflow`, state.document); + await page.evaluate(() => scrollTo(0, 0)); await capture(`full-composition-${size.width}`); + } + }); + await section('expanded', async () => { + for (const size of selectedSizes) { + await goto('expanded', size); + for (const [option, type] of [['세로형 TB', 'tb'], ['가로형 LR', 'lr'], ['혼합형 Hybrid', 'hybrid']]) { + if (type !== 'tb') await select('조직도 표시 유형', option); + const state = await snapshot(); measurements.push({ name: `Expanded ${type} ${size.width}`, state }); + check(state.auto?.type === type && state.auto.employees === 20 && fitted(state) && noHorizontalOverflow(state), `Expanded ${type} ${size.width}: representative layout renders unchanged sample and Fits`, state.auto); + check(state.auto?.titles.length > 0 && state.auto.titles.every(title => title.color === '#ffffff' && title.buttonColor === '#ffffff' && title.background === colors[title.company]) && new Set(state.auto.titles.map(title => title.company)).size === 7, `Expanded ${type} ${size.width}: all company title backgrounds and white text preserved`, state.auto?.titles); + check(state.auto?.cards.every(card => card.border === colors[card.company] && ['top', 'left', 'right'].includes(card.direction)) && state.auto.connectors > 0, `Expanded ${type} ${size.width}: company colored directional borders and connectors`, state.auto?.cards); + await capture(`expanded-${type}-${size.width}`); + } + } + if (!widths.length) { + await goto('expanded'); + const toggle = page.locator('.hm-orgchart-expanded-toggle:not(:disabled)').first(), id = await toggle.getAttribute('data-organization-id'); + await toggle.hover(); check(await toggle.evaluate(element => getComputedStyle(element).color) === 'rgb(255, 255, 255)', 'Expanded: hover title remains white'); + await toggle.focus(); check(await toggle.evaluate(element => getComputedStyle(element).color) === 'rgb(255, 255, 255)', 'Expanded: focus title remains white'); + await toggle.press('Enter'); await ready(); + const current = () => page.locator(`.hm-orgchart-expanded-toggle[data-organization-id="${id}"]`); + check(await current().getAttribute('aria-expanded') === 'false' && await current().evaluate(element => getComputedStyle(element).color) === 'rgb(255, 255, 255)', 'Expanded: collapsed title remains white'); + await current().press('Space'); await ready(); + check(await current().getAttribute('aria-expanded') === 'true' && await current().evaluate(element => getComputedStyle(element).color) === 'rgb(255, 255, 255)', 'Expanded: expanded title remains white'); + } + }); + await section('dialog', async () => { + for (const size of [sizes[1], sizes[2]]) { + await goto('expanded', size); + const employee = page.locator('.hm-orgchart-employee').first(), employeeId = await employee.getAttribute('data-employee-id'); + await employee.click(); const dialog = page.locator('[data-component="HMOrgChartEmployeeDialog"]'); await dialog.waitFor({ state: 'visible' }); await settle(); + const state = await dialog.evaluate(element => { const r = element.getBoundingClientRect(); return { role: element.getAttribute('role'), employeeId: element.dataset.employeeId, portal: element.parentElement === document.body, focusInside: element.contains(document.activeElement), x: r.x, right: r.right, width: r.width, scrollWidth: element.scrollWidth, clientWidth: element.clientWidth }; }); + check(state.role === 'dialog' && state.employeeId === employeeId && state.portal && state.focusInside && await page.getByRole('dialog').count() === 1 && await page.locator('dialog[open]').count() === 0 && state.x >= -1 && state.right <= size.width + 1 && state.scrollWidth <= state.clientWidth + 1, `HM Dialog ${size.width}: actual single HM Portal, identity, focus and responsive bounds`, state); + await page.keyboard.press('Tab'); await page.keyboard.press('Shift+Tab'); check(await dialog.evaluate(element => element.contains(document.activeElement)), `HM Dialog ${size.width}: Tab focus remains inside`); + await capture(`hm-dialog-${size.width}`, false); + const focusedEmployee = () => page.evaluate(() => document.querySelector('.hm-orgchart-host').shadowRoot.activeElement?.dataset.employeeId); + await page.keyboard.press('Escape'); await dialog.waitFor({ state: 'hidden' }); await settle(); check(await focusedEmployee() === employeeId, `HM Dialog ${size.width}: ESC returns focus to source card`); + await employee.press('Enter'); await dialog.waitFor({ state: 'visible' }); await dialog.getByRole('button', { name: '닫기', exact: true }).click(); await dialog.waitFor({ state: 'hidden' }); await settle(); check(await focusedEmployee() === employeeId, `HM Dialog ${size.width}: close button returns focus to source card`); + } + }); + await section('menu', async () => { + const response = await page.request.get(`${origin}/index.json`), index = await response.json(); + const entries = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title.startsWith('OrgChart/') && !(entry.tags ?? []).includes('internal')); + check(entries.length === 4 && equal(entries.map(entry => entry.id).sort(), Object.values(stories).sort()), 'Storybook: exactly four public OrgChart pages and all retained IDs', entries.map(entry => ({ id: entry.id, title: entry.title }))); + }); + await section('independent', async () => { + expectedHosts = 1; await page.setViewportSize(sizes[1]); + for (const [id, kind] of [['orgchart--position-summary', 'PositionSummary'], ['orgchart--affiliation-summary', 'AffiliationSummary']]) { + await page.goto(`${origin}/iframe.html?id=${id}&viewMode=story`); await ready(false); + const state = await page.evaluate(kind => { + const shadow = document.querySelector('.hm-orgchart-component-host').shadowRoot, root = shadow.querySelector(`[data-component="${kind}"]`); + return { kind, rootClass: root.className, total: root.querySelector('.hm-orgchart-raw-total')?.textContent, header: [...root.querySelectorAll('thead th')].map(element => element.textContent), footer: root.querySelector('tfoot')?.textContent, affiliationItems: root.querySelectorAll('.hm-orgchart-affiliation-item').length, workdeskPresenters: shadow.querySelectorAll('.hm-orgchart-workdesk-position-table,.hm-orgchart-affiliation-bar,.hm-orgchart-summary-preview').length }; + }, kind); + check(state.workdeskPresenters === 0 && state.total?.includes('41') && (kind === 'PositionSummary' ? state.rootClass.includes('hm-orgchart-position-summary') && state.header.includes('소계 (직접)') && state.header.includes('하위 포함') && state.footer.includes('전체 합계') : state.rootClass === 'hm-orgchart-affiliation-summary' && state.affiliationItems === 7), `Independent ${kind}: default presenter and aggregation contract preserved`, state); + } + }); + } +} catch (error) { check(false, 'P9 runner completed', { message: error.message, stack: error.stack }); } +finally { + check(errors.length === 0, 'No new browser console or page errors', errors); + const result = { startedAt, completedAt: new Date().toISOString(), origin, baselineMode, sections, widths, browser: browser.version(), results, measurements, errors, pass: results.filter(item => item.status === 'PASS').length, fail: results.filter(item => item.status === 'FAIL').length, + limitations: ['Focused P9 verification only; historical P7/P8 full suites and 2,100 employee tests were not repeated.', 'Shared computed styles demonstrate runtime reuse; CSS design intent approval and original Guard NOT VERIFIED remain separate.', 'StoryPage document vertical scroll is distinct from chart internal scrolling.', 'White text company colors remain approved; Saman and Jangheon Industry contrast remains a recorded accessibility issue.', 'Other browsers, physical devices, screen readers, SSR and deployment remain NOT VERIFIED.'] }; + await fs.writeFile(path.join(output, `browser${suffix}.json`), JSON.stringify(result, null, 2)); + console.log(JSON.stringify({ pass: result.pass, fail: result.fail, errors: errors.length, output: `test-results/org-chart-p9/browser${suffix}.json` })); + await context.close(); await browser.close(); if (result.fail) process.exitCode = 1; +} diff --git a/examples/org-chart/verify-package.mjs b/examples/org-chart/verify-package.mjs new file mode 100644 index 0000000..0729646 --- /dev/null +++ b/examples/org-chart/verify-package.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +const root = fileURLToPath(new URL('../../', import.meta.url)); +const consumer = path.join(root, 'test-results/org-chart-consumer'); +const packageRoot = path.join(consumer, 'node_modules/@hm-design/org-chart'); +await mkdir(packageRoot, { recursive: true }); +execFileSync('tar.exe', ['-xzf', path.join(root, 'test-results/hm-design-org-chart-0.1.0.tgz'), '-C', packageRoot, '--strip-components=1']); +await writeFile(path.join(consumer, 'package.json'), JSON.stringify({name:'org-chart-isolated-consumer',private:true,type:'module'})); +const manifest = JSON.parse(await readFile(path.join(packageRoot, 'package.json'), 'utf8')); +assert.deepEqual(manifest.dependencies, {orgchart:'6.0.0'}); +assert.equal(manifest.peerDependencies, undefined); +const consumerRequire = createRequire(path.join(consumer, 'package.json')); +for (const subpath of ['', '/style.css', '/engine.css', '/browser']) await readFile(consumerRequire.resolve(`@hm-design/org-chart${subpath}`)); +const module = await import(pathToFileURL(consumerRequire.resolve('@hm-design/org-chart')).href); +assert.equal(typeof module.createOrgChart, 'function'); +assert.equal(typeof module.JsonOrgChartDataSource, 'function'); +const fixture = JSON.parse(await readFile(new URL('data-v1.json', import.meta.url), 'utf8')); +assert.equal(module.validateOrgChartDataset(fixture).valid, true); +assert.equal(module.calculateSummary(fixture).raw.total, 4); +for (const file of ['index.js','orgchart.browser.js']) { + const bundle = await readFile(path.join(packageRoot, 'dist', file), 'utf8'); + assert.ok(!/\b(?:import|export)\s+[^;\n]*?from\s*['"](?!\.)/.test(bundle)); + assert.ok(!/\bimport\s*\(\s*['"](?!\.)/.test(bundle)); + assert.ok(!/from\s*['"]react(?:-dom)?['"]/.test(bundle)); +} +await writeFile(path.join(consumer, 'consumer.ts'), `import { createOrgChart, JsonOrgChartDataSource, type OrgChartDataset, type OrgChartDataSource } from '@hm-design/org-chart'; +declare const data: OrgChartDataset; +const source: OrgChartDataSource = new JsonOrgChartDataSource(data); +const handle = createOrgChart({dataSource:source,stylesheetUrls:{engine:'engine.css',core:'style.css'}}); +const stop = handle.subscribe(state=>{if(state.status==='ready') console.log(state.summary.raw.total);}); +stop(); handle.destroy();\n`); +await writeFile(path.join(consumer, 'tsconfig.json'), JSON.stringify({compilerOptions:{target:'ES2022',module:'NodeNext',moduleResolution:'NodeNext',lib:['ES2022','DOM'],strict:true,noEmit:true,types:[],skipLibCheck:false},include:['consumer.ts']})); +const require = createRequire(import.meta.url); +const tscManifest = require.resolve('typescript/package.json'); +const typescript = JSON.parse(await readFile(tscManifest, 'utf8')); +execFileSync(process.execPath, [path.resolve(path.dirname(tscManifest), typescript.bin.tsc), '-p', path.join(consumer,'tsconfig.json')], {stdio:'inherit'}); +const report = {status:'PASS',checks:['Tarball public ESM import without DOM','Public CSS/browser entries exist','Strict NodeNext public declarations without React types','No unresolved bare imports in browser/ESM bundles','Tarball validated fixture and summary','No React peer/runtime dependency'],consumer}; +await writeFile(path.join(consumer,'report.json'), JSON.stringify(report,null,2)); +console.log(JSON.stringify(report,null,2)); diff --git a/examples/org-chart/verify-quality-boundaries.mjs b/examples/org-chart/verify-quality-boundaries.mjs new file mode 100644 index 0000000..7f20bd3 --- /dev/null +++ b/examples/org-chart/verify-quality-boundaries.mjs @@ -0,0 +1,38 @@ +import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +import { writeFile } from 'node:fs/promises'; +await loadPlaywright(); +const browser = await launchChromium({ headless: true }); +try { + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + await page.goto('http://127.0.0.1:6027/iframe.html?id=orgchart--full-composition&viewMode=story'); + await page.waitForFunction(() => document.querySelector('.hm-orgchart-host')?.shadowRoot?.querySelector('.hm-orgchart-toggle')); + const result = await page.evaluate(async () => { + const root = document.querySelector('.hm-orgchart-host').shadowRoot; + await document.fonts.ready; + const shell = root.querySelector('.hm-orgchart-shell'); + const read = (element, pseudo) => { + const css = getComputedStyle(element, pseudo); + return { background: css.backgroundColor, border: css.borderTopColor, borderWidth: css.borderTopWidth, outline: css.outlineColor, outlineWidth: css.outlineWidth, outlineStyle: css.outlineStyle, content: css.content }; + }; + const hierarchy = [...root.querySelectorAll('.hierarchy')].find(element => getComputedStyle(element, '::before').content !== 'none'); + const node = [...root.querySelectorAll('.node')].find(element => getComputedStyle(element, '::before').content !== 'none'); + const backgroundChain = []; + for (let element = root.querySelector('.hm-orgchart-engine'); element; element = element.parentElement ?? element.getRootNode()?.host) { + const css = getComputedStyle(element); + backgroundChain.push({ className: element.className, tag: element.tagName, background: css.backgroundColor, image: css.backgroundImage }); + } + return { canvas: getComputedStyle(shell).backgroundColor, viewport: read(root.querySelector('.hm-orgchart-viewport')), + backgroundChain, + hierarchy: hierarchy ? read(hierarchy, '::before') : null, node: node ? read(node, '::before') : null, + toggle: read(root.querySelector('.hm-orgchart-toggle')), resolved: Object.fromEntries(['stroke-default', 'stroke-input-focus', 'bg-content', 'bg-surface'].map(key => [key, getComputedStyle(shell).getPropertyValue('--hm-orgchart-resolved-' + key).trim()])) }; + }); + await page.keyboard.press('Tab'); + const focused = await page.evaluate(() => { + const root = document.querySelector('.hm-orgchart-host').shadowRoot; + const viewport = root.querySelector('.hm-orgchart-viewport'); viewport.focus(); + const css = getComputedStyle(viewport); + return { focusVisible: viewport.matches(':focus-visible'), outline: css.outlineColor, width: css.outlineWidth, style: css.outlineStyle }; + }); + await writeFile('test-results/org-chart-quality/boundaries.json', JSON.stringify({ ...result, focused, interpretation: 'Hierarchy connection lines convey parent-child relation; retain contrast REVIEW instead of automatically treating all lines as decorative. Button text identifies the control, frame decoration alone is not automatically SC 1.4.11 failure.' }, null, 2)); + console.log(JSON.stringify({ ...result, focused })); +} finally { await browser.close(); } diff --git a/examples/org-chart/verify-quality-p1.mjs b/examples/org-chart/verify-quality-p1.mjs new file mode 100644 index 0000000..025068f --- /dev/null +++ b/examples/org-chart/verify-quality-p1.mjs @@ -0,0 +1,184 @@ +import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +import http from 'node:http'; +import path from 'node:path'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +// Quality-only verification. No data/source/lifecycle regression suite is run. +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = path.join(root, 'test-results/org-chart-quality'); +await mkdir(output, { recursive: true }); +await loadPlaywright(); +const results = [], errors = [], measurements = [], screenshots = []; +const check = (value, name, detail) => results.push({ name, status: value ? 'PASS' : 'FAIL', ...(detail === undefined ? {} : { detail }) }); +const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.ttf': 'font/ttf' }; +async function serve(directory) { + const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url, 'http://127.0.0.1'); + const file = path.resolve(directory, '.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname)); + const relative = path.relative(directory, file); + if (relative.startsWith('..') || path.isAbsolute(relative)) return response.writeHead(403).end(); + response.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); + response.end(await readFile(file)); + } catch { response.writeHead(404).end(); } + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + return { server, origin: `http://127.0.0.1:${server.address().port}` }; +} +function captureErrors(page, label) { + page.on('pageerror', error => errors.push({ label, type: 'pageerror', message: error.message })); + page.on('console', message => { if (message.type() === 'error') errors.push({ label, type: 'console', message: message.text() }); }); +} +async function ready(page) { + await page.waitForFunction(() => { + const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host')?.shadowRoot; + return shadow?.querySelector('.hm-orgchart-shell') && [...shadow.querySelectorAll('link')].every(link => !!link.sheet) + && !shadow.querySelector('.hm-orgchart-state')?.textContent.includes('불러오는'); + }, undefined, { timeout: 20000 }); + await page.evaluate(async () => { + await document.fonts.ready; + const shadow = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host').shadowRoot; + await Promise.all([...shadow.querySelectorAll('.hm-orgchart-affiliation-mark img')].map(image => image.decode().catch(() => {}))); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }); +} +async function measure(page) { + return page.evaluate(() => { + const host = document.querySelector('.hm-orgchart-host, .hm-orgchart-component-host'); + const shadow = host.shadowRoot; + const rect = element => { + const box = element.getBoundingClientRect(); + return { x: box.x + scrollX, y: box.y + scrollY, width: box.width, height: box.height }; + }; + const ranges = element => { + const range = document.createRange(); range.selectNodeContents(element); + return [...range.getClientRects()].map(box => ({ x: box.x + scrollX, y: box.y + scrollY, width: box.width, height: box.height })); + }; + const dimensions = element => element ? { clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, overflowX: getComputedStyle(element).overflowX } : null; + const visible = element => element.getBoundingClientRect().width > 0 && element.getBoundingClientRect().height > 0; + const text = [...shadow.querySelectorAll('.hm-orgchart-person-name, .hm-orgchart-person-role, .hm-orgchart-organization-name, .hm-orgchart-contrast-result, .hm-orgchart-affiliation-name')].filter(visible); + const textIssues = text.filter(element => element.scrollWidth > element.clientWidth + 1 || element.scrollHeight > element.clientHeight + 1).map(element => ({ className: element.className, text: element.textContent, box: rect(element) })); + const styles = element => { + const css = getComputedStyle(element); + return { color: css.color, background: css.backgroundColor, gradient: css.backgroundImage, font: css.fontFamily, fontSize: parseFloat(css.fontSize), fontWeight: css.fontWeight, border: css.borderTopColor, radius: css.borderRadius }; + }; + const backgroundFor = element => { + for (let current = element; current; current = current.parentElement) { + const css = getComputedStyle(current); + if (css.backgroundImage !== 'none') return { ...styles(current), box: rect(current) }; + if (css.backgroundColor !== 'rgba(0, 0, 0, 0)' && css.backgroundColor !== 'transparent') return { ...styles(current), box: rect(current) }; + } + return null; + }; + const selectors = '.hm-orgchart-organization-name, .hm-orgchart-hub, [data-contrast-target], .hm-orgchart-person-name, .hm-orgchart-person-role, .hm-orgchart-job-title, .hm-orgchart-status-label, .hm-orgchart-summary-title, .hm-orgchart-position-table th, .hm-orgchart-position-table td, .hm-orgchart-toggle'; + const colors = [...shadow.querySelectorAll(selectors)].filter(visible).map(element => ({ + className: element.className, organizationId: element.closest('[data-organization-id]')?.dataset.organizationId, + variant: element.closest('[data-group-variant]')?.dataset.groupVariant, + target: element.dataset.contrastTarget ?? null, + text: element.textContent.trim(), box: rect(element), ranges: ranges(element), ...styles(element), behind: backgroundFor(element), + clip: element.closest('.hm-orgchart-viewport, .hm-orgchart-summary-scroll') ? rect(element.closest('.hm-orgchart-viewport, .hm-orgchart-summary-scroll')) : null, + })); + const logos = [...shadow.querySelectorAll('.hm-orgchart-affiliation-mark')].map(mark => { + const image = mark.querySelector('img'); + return { id: mark.dataset.affiliationId, label: mark.getAttribute('aria-label'), box: rect(mark), + available: mark.dataset.logoAvailable, fallbackVisibility: getComputedStyle(mark.querySelector('.hm-orgchart-affiliation-fallback')).visibility, + loaded: !!image?.complete && image.naturalWidth > 0, naturalWidth: image?.naturalWidth, naturalHeight: image?.naturalHeight, + fit: image ? getComputedStyle(image).objectFit : null, imageBackground: image ? getComputedStyle(image).backgroundColor : null, + source: image?.src ?? null }; + }); + const cardStyles = [...shadow.querySelectorAll('.hm-orgchart-employee')].map(element => ({ id: element.dataset.employeeId, kind: element.dataset.cardKind, ...styles(element) })); + return { documentWidth: document.documentElement.scrollWidth, textIssues, viewport: dimensions(shadow.querySelector('.hm-orgchart-viewport')), + summary: dimensions(shadow.querySelector('.hm-orgchart-summary-scroll')), logos, colors, cardStyles, + rowResults: [...shadow.querySelectorAll('.hm-orgchart-contrast-result')].map(element => ({ text: element.textContent, ...element.dataset })), + policy: [...shadow.querySelectorAll('.hm-orgchart-policy')].map(element => element.textContent), + components: [...shadow.querySelectorAll('[data-component]')].map(element => element.dataset.component), + variables: [...shadow.querySelectorAll('[data-color-variable]')].map(element => ({ variable: element.dataset.colorVariable, property: element.dataset.cssProperty, computed: getComputedStyle(element).getPropertyValue(element.dataset.cssProperty), value: element.parentElement.querySelector('.hm-orgchart-color-value').textContent })), + navigation: shadow.querySelectorAll('nav, .hm-dashboard-header, .hm-gnb, .hm-lnb, [data-slot=sidebar]').length }; + }); +} +function compactSources(measurement) { + for (const logo of measurement.logos) { + logo.sha256 = logo.source?.startsWith('data:image/png;base64,') ? createHash('sha256').update(Buffer.from(logo.source.split(',')[1], 'base64')).digest('hex') : null; + delete logo.source; + } + return measurement; +} +let browser; +const servers = []; +try { + for (const directory of ['storybook-static', 'examples/org-chart/dist', 'examples/org-chart-react/dist']) servers.push(await serve(path.join(root, directory))); + browser = await launchChromium({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1, reducedMotion: 'reduce' }); + const canonical = ['logo', 'department-colors', 'position-summary', 'affiliation-summary', 'full-composition']; + const index = JSON.parse(await readFile(path.join(root, 'storybook-static/index.json'), 'utf8')); + const menu = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title.startsWith('OrgChart') && !entry.tags?.includes('internal')).map(entry => entry.id).sort(); + check(JSON.stringify(menu) === JSON.stringify(canonical.map(name => `orgchart--${name}`).sort()), 'final build retains exactly five public OrgChart menu entries', menu); + const internal = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title === 'OrgChart/Content'); + check(internal.length === 8 && internal.every(entry => entry.tags.includes('internal')), 'original eight verification Stories remain tagged internal'); + const sourceHashes = new Map(); + for (const id of ['hanmac', 'saman', 'jangheon', 'jangheon-industry', 'ptc', 'halla', 'baron']) sourceHashes.set(id, createHash('sha256').update(await readFile(path.join(root, `packages/org-chart/src/assets/affiliations/${id}.png`))).digest('hex')); + for (const name of canonical) { + const page = await context.newPage(); captureErrors(page, name); + await page.goto(`${servers[0].origin}/iframe.html?id=orgchart--${name}&viewMode=story`); await ready(page); + if (name.endsWith('summary')) check(await page.locator('body').innerText().then(text => text.includes('가상') && text.includes('운영')), `${name}: independent Story identifies virtual headcounts`); + for (const width of name === 'full-composition' ? [390, 768, 1280, 1440] : [390, 1440]) { + await page.setViewportSize({ width, height: 1000 }); await ready(page); + const value = compactSources(await measure(page)); value.label = `storybook-${name}`; value.width = width; + measurements.push(value); + check(value.documentWidth <= width + 1 && value.textIssues.length === 0, `${name} ${width}px: changed content has no page overflow or clipped text`, value.textIssues); + check(value.navigation === 0, `${name} ${width}px: no page Navigation added`); + if (value.logos.length) check(value.logos.every(logo => logo.loaded && logo.fit === 'contain' && logo.naturalWidth === 473 && logo.naturalHeight === 473 && logo.sha256 === sourceHashes.get(logo.id) && logo.imageBackground === 'rgba(0, 0, 0, 0)' && logo.fallbackVisibility === 'hidden'), `${name} ${width}px: original transparent logos and ID mappings are preserved`); + const file = `${value.label}-${width}.png`; value.screenshot = file; + await page.screenshot({ path: path.join(output, file), fullPage: true }); screenshots.push(file); + } + if (name === 'department-colors') { + const value = measurements.at(-1); + check(value.variables.length === 30 && value.variables.every(item => item.value.includes('#')), 'Department Colors retains all six HEX and variable legends'); + check(value.rowResults.length >= 18 && value.rowResults.every(item => item.contrastStatus && !item.text.includes('NaN')), 'Department Colors exposes current and unapplied candidate contrast results', value.rowResults); + } + if (name === 'logo') { + const result = await page.evaluate(async () => { + const shadow = document.querySelector('.hm-orgchart-component-host').shadowRoot; + const mark = shadow.querySelector('.hm-orgchart-affiliation-mark'); + const image = mark.querySelector('img'); + const fallback = mark.querySelector('.hm-orgchart-affiliation-fallback'); + const before = { label: mark.getAttribute('aria-label'), visibility: getComputedStyle(fallback).visibility }; + image.dispatchEvent(new Event('error')); + return { before, after: { label: mark.getAttribute('aria-label'), visibility: getComputedStyle(fallback).visibility, available: mark.dataset.logoAvailable, text: fallback.textContent, imageRemoved: !mark.querySelector('img') } }; + }); + check(result.before.visibility === 'hidden' && result.after.visibility === 'visible' && result.after.available === 'false' && result.after.text && result.after.imageRemoved && result.after.label === result.before.label, 'transparent logo change retains text fallback and accessible company name on error', result); + } + await page.close(); + } + for (const [transport, server] of [['html', servers[1]], ['react', servers[2]]]) { + const page = await context.newPage(); captureErrors(page, transport); + await page.goto(`${server.origin}/p1.html`); await ready(page); + for (const width of [390, 768, 1280, 1440]) { + await page.setViewportSize({ width, height: 1000 }); await ready(page); + const value = compactSources(await measure(page)); value.label = transport; value.width = width; measurements.push(value); + check(value.documentWidth <= width + 1 && value.textIssues.length === 0 && value.navigation === 0, `${transport} ${width}px: logo style change preserves layout and text`); + check(value.logos.every(logo => logo.loaded && logo.fit === 'contain' && logo.imageBackground === 'rgba(0, 0, 0, 0)' && logo.fallbackVisibility === 'hidden'), `${transport} ${width}px: transparent logo layer applied inside Shadow DOM`); + const file = `${transport}-${width}.png`; value.screenshot = file; + await page.screenshot({ path: path.join(output, file), fullPage: true }); screenshots.push(file); + } + await page.close(); + } + for (const width of [390, 768, 1280, 1440]) { + const html = measurements.find(item => item.label === 'html' && item.width === width); + const react = measurements.find(item => item.label === 'react' && item.width === width); + check(JSON.stringify(html.cardStyles) === JSON.stringify(react.cardStyles) && JSON.stringify(html.logos.map(logo => [logo.id, logo.sha256, logo.fit, logo.imageBackground])) === JSON.stringify(react.logos.map(logo => [logo.id, logo.sha256, logo.fit, logo.imageBackground])), `${width}px: HTML and React retain identical Core card colors and logo rendering`); + } + check(errors.length === 0, 'changed five Stories and theme-free HTML/React emit no console/runtime errors', errors); +} catch (error) { results.push({ name: 'quality harness completion', status: 'FAIL', detail: error.stack }); } +finally { + await writeFile(path.join(output, 'browser.json'), JSON.stringify({ browser: browser?.version() ?? null, results, errors, measurements, screenshots, + reused: ['Core22/22', 'HTML69/69', 'React79/79', 'P1 selector7/7', 'P1 successful196 plus console/menu4/4'], + limits: ['No data/lifecycle regression suites repeated', 'Contrast model and pixel samples are reported separately; no all-screen WCAG certification'] }, null, 2)); + if (browser) await browser.close(); + for (const { server } of servers) await new Promise(resolve => server.close(resolve)); + const failed = results.filter(result => result.status === 'FAIL'); + if (failed.length) process.exitCode = 1; + console.log(JSON.stringify({ passed: results.filter(result => result.status === 'PASS').length, failed, report: path.join(output, 'browser.json') }, null, 2)); +} diff --git a/examples/org-chart/verify.mjs b/examples/org-chart/verify.mjs new file mode 100644 index 0000000..3282d83 --- /dev/null +++ b/examples/org-chart/verify.mjs @@ -0,0 +1,218 @@ +import { loadPlaywright, launchChromium } from '../../scripts/orgchart-browser-runtime.mjs'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { readFile, mkdir, writeFile } from 'node:fs/promises'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import path from 'node:path'; + +// Browser tooling belongs to the verification environment, not the production package. +await loadPlaywright(); +const example = fileURLToPath(new URL('./dist/', import.meta.url)); +const output = fileURLToPath(new URL('../../test-results/org-chart-browser/', import.meta.url)); +await mkdir(output, { recursive: true }); +const mime = { '.html': 'text/html', '.js': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.ttf': 'font/ttf' }; +const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url, 'http://localhost'); + const file = path.resolve(example, '.' + decodeURIComponent(url.pathname)); + const relative = path.relative(example, file); + if (relative.startsWith('..') || path.isAbsolute(relative)) { response.writeHead(403).end(); return; } + const data = await readFile(file); + response.setHeader('Content-Type', mime[path.extname(file)] || 'application/octet-stream'); + response.end(data); + } 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 launchChromium({ headless: true }); +const results = [], errors = [], metrics = []; +const check = (condition, name, detail) => { results.push({ name, status: condition ? 'PASS' : 'FAIL', detail }); assert.ok(condition, `${name}: ${JSON.stringify(detail)}`); }; +try { + const context = await browser.newContext({ viewport: { width: 1440, height: 980 } }); + await context.addInitScript(() => { + const NativeObserver = window.MutationObserver; + const activeObservers = new Map(); + window.MutationObserver = class extends NativeObserver { + observe(...args) { activeObservers.set(this, args[0]); return super.observe(...args); } + disconnect() { activeObservers.delete(this); return super.disconnect(); } + }; + const nativeAdd = EventTarget.prototype.addEventListener; + const nativeRemove = EventTarget.prototype.removeEventListener; + const listeners = []; + const capture = options => typeof options === 'boolean' ? options : !!options?.capture; + EventTarget.prototype.addEventListener = function(type, callback, options) { + if ((this === window || this === document) && callback && !listeners.some(item => item.target === this && item.type === type && item.callback === callback && item.capture === capture(options))) { + const item = { target: this, type, callback, capture: capture(options) }; + listeners.push(item); + options?.signal?.addEventListener('abort', () => { const i = listeners.indexOf(item); if (i >= 0) listeners.splice(i, 1); }, { once: true }); + } + return nativeAdd.call(this, type, callback, options); + }; + EventTarget.prototype.removeEventListener = function(type, callback, options) { + const index = listeners.findIndex(item => item.target === this && item.type === type && item.callback === callback && item.capture === capture(options)); + if (index >= 0) listeners.splice(index, 1); + return nativeRemove.call(this, type, callback, options); + }; + window.orgChartResources = () => ({ + observers: activeObservers.size, + chartObservers: [...activeObservers.values()].filter(target=>target.getRootNode()?.host?.classList.contains('hm-orgchart-host')).length, + globalListeners: listeners.map(item => `${item.target === document ? 'document' : 'window'}:${item.type}`).sort(), + }); + }); + for (const [transport, url] of [['http-esm', `${origin}/index.html`], ['file-classic', pathToFileURL(path.join(example, 'classic.html')).href]]) { + const page = await context.newPage(); + page.on('pageerror', error => errors.push({ transport, type: 'pageerror', message: error.message })); + page.on('console', message => { if (message.type() === 'error') errors.push({ transport, type: 'console', message: message.text() }); }); + await page.goto(url); + await page.waitForFunction(() => window.orgChartReady); + const state = await page.evaluate(() => { + const root = window.OrgChartDemo.container.firstElementChild.shadowRoot; + return { state: window.OrgChartDemo.chart.getState(), nodes: root.querySelectorAll('.node').length, + topLeaders: [...root.querySelectorAll('.hm-orgchart-top-leaders [data-employee-id]')].map(card => card.dataset.employeeId), + shownEmployees: [...root.querySelectorAll('.hm-orgchart-top-leaders [data-employee-id], .hm-orgchart-forest [data-employee-id]')].map(card => card.dataset.employeeId).sort() }; + }); + await page.screenshot({path:path.join(output, `${transport}-initial.png`),fullPage:true}); + check(state.state.status === 'ready', `${transport}: initial ready state`, state); + // Composition contract: a root whose only direct employee is its leader shows that + // leader as the standalone top card, and its child organizations become the engine roots. + check(state.nodes === 2 && state.topLeaders.join() === 'emp-a' && state.shownEmployees.join() === 'emp-a,emp-b,emp-c,emp-d' && state.state.summary.raw.total === 4, `${transport}: mount + JSON + custom nodes`, state); + check(state.state.aggregation.status === 'unavailable', `${transport}: no hidden business policy`); + const baseline = await page.evaluate(() => window.orgChartResources()); + + for (const width of [1440, 1280, 768, 390]) { + await page.setViewportSize({ width, height: 980 }); + await page.evaluate(() => document.fonts.ready); + const measurement = await page.evaluate(() => { + const root = window.OrgChartDemo.container.firstElementChild.shadowRoot; + const node = root.querySelector('.hm-orgchart-organization'); + const style = getComputedStyle(node); + const host = window.OrgChartDemo.container; + const viewport = root.querySelector('.hm-orgchart-viewport'); + return { + document: { width: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, height: document.documentElement.clientHeight, scrollHeight: document.documentElement.scrollHeight }, + host: { width: host.clientWidth, scrollWidth: host.scrollWidth, height: host.clientHeight, scrollHeight: host.scrollHeight }, + viewport: viewport && { width: viewport.clientWidth, scrollWidth: viewport.scrollWidth, height: viewport.clientHeight, scrollHeight: viewport.scrollHeight, overflowX: getComputedStyle(viewport).overflowX }, + color: style.color, background: style.backgroundColor, font: style.fontFamily, radius: style.borderRadius, + themeColor: getComputedStyle(host).getPropertyValue('--hm-sem-fg-primary').trim(), + themeBackground: getComputedStyle(host).getPropertyValue('--hm-sem-bg-surface').trim(), + themeRadius: getComputedStyle(host).getPropertyValue('--hm-radius-container').trim(), + idWhiteSpace: getComputedStyle(root.querySelector('.hm-orgchart-domain-id')).whiteSpace, + fontLoaded: document.fonts.check('13px "Noto Sans KR"'), + connectorColor: getComputedStyle([...root.querySelectorAll('.node')][1], '::before').backgroundColor, + borderColor: getComputedStyle(viewport).borderTopColor, + parentCenter: (()=>{const n=root.querySelector('.node').getBoundingClientRect();return n.x+n.width/2;})(), + childCenters: [...root.querySelectorAll('.node')].slice(1).map(element=>{const n=element.getBoundingClientRect();return n.x+n.width/2;}), + }; + }); + metrics.push({ transport, width, ...measurement }); + check(measurement.document.scrollWidth <= width, `${transport}: ${width}px document overflow`, measurement); + check(measurement.themeColor && measurement.background !== 'rgba(0, 0, 0, 0)' && measurement.font.includes('Noto Sans KR') && measurement.fontLoaded, `${transport}: ${width}px HM theme + font`, measurement); + check(measurement.idWhiteSpace === 'nowrap', `${transport}: ${width}px one-line organization IDs`); + check(measurement.connectorColor === measurement.borderColor && Math.abs(measurement.parentCenter - (measurement.childCenters[0]+measurement.childCenters[1])/2)<2, `${transport}: ${width}px themed + aligned hierarchy connectors`, measurement); + await page.screenshot({ path: path.join(output, `${transport}-${width}.png`), fullPage: true }); + } + // Shadow DOM buttons have native keyboard activation and retain focus on collapse. + const toggle = page.locator('.hm-orgchart-toggle').first(); + await toggle.focus(); + await toggle.press('Enter'); + await page.waitForFunction(() => !window.OrgChartDemo.container.firstElementChild.shadowRoot.querySelector('.sliding')); + check(await toggle.getAttribute('aria-expanded') === 'false', `${transport}: keyboard collapse`); + check(await toggle.evaluate(element => element.getRootNode().activeElement === element), `${transport}: collapse focus retained`); + await toggle.press('Space'); + await page.waitForFunction(() => !window.OrgChartDemo.container.firstElementChild.shadowRoot.querySelector('.sliding')); + check(await toggle.getAttribute('aria-expanded') === 'true', `${transport}: keyboard expand`); + await toggle.focus(); + await page.evaluate(() => window.OrgChartDemo.update()); + check(await page.evaluate(() => { + const root = window.OrgChartDemo.container.firstElementChild.shadowRoot; + return root.activeElement?.classList.contains('hm-orgchart-viewport'); + }), `${transport}: focus returns to viewport after regeneration`); + const updated = await page.evaluate(() => window.OrgChartDemo.chart.getState()); + check(updated.status === 'ready' && updated.summary.raw.total === 5 && updated.summary.raw.byOrganization['team-a'].direct === 1 && updated.summary.raw.byOrganization['team-b'].direct === 3, `${transport}: update + movement + add + recalculated counts`, updated); + await page.evaluate(() => window.OrgChartDemo.chart.reload()); + check(await page.evaluate(() => window.OrgChartDemo.chart.getState().summary.raw.total) === 4, `${transport}: reload source remains initial`); + await page.evaluate(async () => { + const demo = window.OrgChartDemo; + const data = structuredClone(await demo.source().load()); + data.organizations.push({id:'depth-three',parentId:'team-a',name:'가상 하위 셀',type:'cell',leaderId:null,displayOrder:0}); + await demo.chart.update(data); + }); + const rootToggle = page.locator('.hm-orgchart-toggle').first(); + await rootToggle.click(); + await page.waitForFunction(() => !window.OrgChartDemo.container.firstElementChild.shadowRoot.querySelector('.sliding')); + await rootToggle.click(); + await page.waitForFunction(() => !window.OrgChartDemo.container.firstElementChild.shadowRoot.querySelector('.sliding')); + const nested = await page.evaluate(() => { + const root = window.OrgChartDemo.container.firstElementChild.shadowRoot; + const node = [...root.querySelectorAll('.node')].find(node=>node.dataset.organizationId==='team-a'); + const grandchild = [...root.querySelectorAll('.node')].find(node=>node.dataset.organizationId==='depth-three'); + return {expanded:node.querySelector('.hm-orgchart-toggle').getAttribute('aria-expanded'),visible:grandchild.getBoundingClientRect().height > 0 && !grandchild.closest('.isCollapsedDescendant')}; + }); + check((nested.expanded === 'true') === nested.visible, `${transport}: depth-three descendant toggle matches visibility`, nested); + await page.evaluate(() => window.OrgChartDemo.chart.reload()); + const lifecycle = await page.evaluate(async () => { + const { api, chart, source, stylesheetUrls, container } = window.OrgChartDemo; + const initial = await source().load(); + const changed = structuredClone(initial); changed.metadata.dataVersion = 'newest'; changed.employees.pop(); + let resolveOld, oldSignal; + const oldSource = { load({signal}) { oldSignal = signal; return new Promise(resolve => { resolveOld = resolve; }); } }; + const pending = chart.reload(oldSource); + await chart.update(changed); + changed.employees.length = 0; + resolveOld(initial); await pending; + const race = { version: chart.getState().dataVersion, total: chart.getState().summary.raw.total, aborted: oldSignal.aborted }; + await chart.reload({load: async () => { throw new Error('private server detail'); }}); + const sourceError = chart.getState(); + const invalid = structuredClone(initial); invalid.organizations[0].name = ''; + await chart.update(invalid); + const validationError = chart.getState(); + await chart.reload(source()); + const sibling = document.createElement('p'); sibling.textContent = 'host sibling'; container.append(sibling); + const secondHost = document.createElement('div'); secondHost.setAttribute('data-hm-theme', ''); + secondHost.style.setProperty('--hm-sem-fg-primary', getComputedStyle(container).getPropertyValue('--hm-sem-fg-secondary')); + secondHost.style.setProperty('--hm-radius-container', '0px'); container.after(secondHost); + const second = api.createOrgChart({container:secondHost, dataSource:source(), stylesheetUrls}); await second.mount(); + const firstRoot = container.firstElementChild.shadowRoot, secondRoot = secondHost.firstElementChild.shadowRoot; + const firstIds = [...firstRoot.querySelectorAll('[id]')].map(element => element.id); + const secondIds = [...secondRoot.querySelectorAll('[id]')].map(element => element.id); + const scopes = { distinctIds: !firstIds.some(id => secondIds.includes(id)), firstColor:getComputedStyle(firstRoot.querySelector('.hm-orgchart-organization')).color, secondColor:getComputedStyle(secondRoot.querySelector('.hm-orgchart-organization')).color, secondRadius:getComputedStyle(secondRoot.querySelector('.hm-orgchart-organization')).borderRadius }; + chart.destroy(); chart.destroy(); + const destroy = { remaining:container.children.length, siblingPreserved:sibling.isConnected, secondReady:second.getState().status === 'ready' }; + second.destroy(); secondHost.remove(); sibling.remove(); + for (let i = 0; i < 5; i++) { const next = api.createOrgChart({container, dataSource:source(), stylesheetUrls}); await next.mount(); await next.reload(); next.destroy(); } + const resources = window.orgChartResources(); + const reentrantHost = document.createElement('div'); container.after(reentrantHost); + let reentrant; + reentrant = api.createOrgChart({container:reentrantHost,stylesheetUrls,dataSource:source(),onStateChange(state){if(state.status==='loading')reentrant.destroy();}}); + await reentrant.mount(); + const reentrantDestroy = reentrantHost.children.length===0 && reentrant.getState().status==='destroyed'; reentrantHost.remove(); + const pendingHost = document.createElement('div'); container.after(pendingHost); + let resolvePending, signalPending, callbackCount = 0; + const destroyed = api.createOrgChart({container:pendingHost,stylesheetUrls,dataSource:{load({signal}){signalPending=signal;return new Promise(resolve=>{resolvePending=resolve;});}},onStateChange(){callbackCount++;}}); + const mounting = destroyed.mount(); + // Allow stylesheet loading before the source is entered. + while (!resolvePending) await new Promise(resolve=>setTimeout(resolve,0)); + destroyed.destroy(); const atDestroy = callbackCount; resolvePending(initial); await mounting; + const late = {children:pendingHost.children.length,aborted:signalPending.aborted,callbacksAfterDestroy:callbackCount-atDestroy}; pendingHost.remove(); + await window.OrgChartDemo.create(); + return {race,sourceError,validationError,scopes,destroy,resources,late,reentrantDestroy}; + }); + check(lifecycle.race.version === 'newest' && lifecycle.race.total === 3 && lifecycle.race.aborted, `${transport}: stale response aborted + immutable update`, lifecycle.race); + check(lifecycle.sourceError.status === 'error' && !JSON.stringify(lifecycle.sourceError).includes('private server detail'), `${transport}: sanitized loading failure`, lifecycle.sourceError); + check(lifecycle.validationError.status === 'error' && lifecycle.validationError.issues.some(issue=>issue.code==='UNSAFE_INPUT'), `${transport}: validation blocks engine payload`, lifecycle.validationError); + check(lifecycle.scopes.distinctIds && lifecycle.scopes.firstColor !== lifecycle.scopes.secondColor && lifecycle.scopes.secondRadius === '0px', `${transport}: multiple instances + scoped theme`, lifecycle.scopes); + check(lifecycle.destroy.remaining === 1 && lifecycle.destroy.siblingPreserved && lifecycle.destroy.secondReady, `${transport}: destroy preserves host sibling + other instance`, lifecycle.destroy); + check(lifecycle.resources.chartObservers === 0 && lifecycle.resources.observers === baseline.observers && JSON.stringify(lifecycle.resources.globalListeners) === JSON.stringify(baseline.globalListeners), `${transport}: repeated lifecycle resource stability`, { baseline, after:lifecycle.resources }); + check(lifecycle.late.children === 0 && lifecycle.late.aborted && lifecycle.late.callbacksAfterDestroy === 0, `${transport}: destroy suppresses late callback`, lifecycle.late); + check(lifecycle.reentrantDestroy, `${transport}: reentrant loading callback destroy`); + await page.close(); + } + check(errors.length === 0, 'No browser runtime/console errors', errors); +} catch (error) { + results.push({ name: 'Harness completion', status: 'FAIL', detail: error.stack }); + process.exitCode = 1; +} finally { + await writeFile(path.join(output, 'report.json'), JSON.stringify({ browser:browser.version(), results, errors, metrics, limits:['Full heap/GC recovery not verified','Screen reader announcement not verified','No visual reference supplied','Advanced responsive layout out of scope'] }, null, 2)); + await browser.close(); await new Promise(resolve => server.close(resolve)); +} +console.log(JSON.stringify({ passed:results.filter(row=>row.status==='PASS').length, failed:results.filter(row=>row.status==='FAIL'), report:path.join(output,'report.json') }, null, 2)); diff --git a/package-lock.json b/package-lock.json index b452687..a9406a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ ], "dependencies": { "@hm-design/center-graphic": "*", + "@hm-design/org-chart": "*", + "@hm-design/org-chart-react": "*", "@hm-design/ui": "*", "lucide-react": "^1.49.0", "react": "19.3.0", @@ -436,7 +438,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1209,6 +1210,14 @@ "resolved": "packages/center-graphic", "link": true }, + "node_modules/@hm-design/org-chart": { + "resolved": "packages/org-chart", + "link": true + }, + "node_modules/@hm-design/org-chart-react": { + "resolved": "packages/org-chart-react", + "link": true + }, "node_modules/@hm-design/ui": { "resolved": "packages/ui", "link": true @@ -4625,6 +4634,19 @@ "undici-types": "~7.14.0" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", @@ -4652,6 +4674,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -5248,6 +5277,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.27", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", @@ -5448,6 +5486,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -5697,6 +5755,21 @@ "node": ">=6.6.0" } }, + "node_modules/core-js": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -5780,6 +5853,15 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -5995,6 +6077,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dot-prop": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", @@ -6427,6 +6519,17 @@ "node": ">=8.6.0" } }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fast-uri": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", @@ -6472,6 +6575,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/figures": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", @@ -6791,6 +6900,19 @@ "node": ">=16.9.0" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -6883,6 +7005,12 @@ "dev": true, "license": "ISC" }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/ip-address": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz", @@ -7178,6 +7306,12 @@ "node": ">=6" } }, + "node_modules/json-digger": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/json-digger/-/json-digger-2.0.2.tgz", + "integrity": "sha512-d8Zq9WXlhfkzlDTgSa5B5C/pmR+NVaUVNtBm3tFVQhwAnlBn8AdNGj5fS7DuD61lA9Enn+Fq1sADYg1vCVXDXw==", + "license": "MIT" + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -7232,6 +7366,23 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -8034,6 +8185,17 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/orgchart": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/orgchart/-/orgchart-6.0.0.tgz", + "integrity": "sha512-ykRCXH3gnOB4Y80RN/ifEviZvFj1AMFSmTgTlPeLzcP1aNIUQcq+GyyO1j65hDIpkpSOhl1hIyC+7GrHIdcxmg==", + "license": "MIT", + "dependencies": { + "html2canvas": "^1.4.1", + "json-digger": "^2.0.2", + "jspdf": "^4.2.1" + } + }, "node_modules/oxc-parser": { "version": "0.127.0", "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.127.0.tgz", @@ -8142,6 +8304,22 @@ "node": ">=6" } }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8269,6 +8447,13 @@ "node": ">= 14.16" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8556,6 +8741,16 @@ } } }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/range-parser": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", @@ -8795,6 +8990,13 @@ "node": ">=8" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -8881,6 +9083,16 @@ "node": ">=0.10.0" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rolldown": { "version": "1.2.12", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", @@ -9337,6 +9549,16 @@ "node": ">=0.10.0" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -9562,6 +9784,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/systeminformation": { "version": "5.33.15", "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.33.15.tgz", @@ -9609,6 +9841,15 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -9948,6 +10189,15 @@ "dev": true, "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/validate-npm-package-name": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", @@ -10445,6 +10695,29 @@ "react-dom": "^19.0.0" } }, + "packages/org-chart": { + "name": "@hm-design/org-chart", + "version": "0.1.0", + "dependencies": { + "orgchart": "6.0.0" + } + }, + "packages/org-chart-react": { + "name": "@hm-design/org-chart-react", + "version": "0.1.0", + "dependencies": { + "@hm-design/org-chart": "0.1.0" + }, + "peerDependencies": { + "react": "^19.0.0", + "@hm-design/ui": "0.1.0" + }, + "peerDependenciesMeta": { + "@hm-design/ui": { + "optional": true + } + } + }, "packages/ui": { "name": "@hm-design/ui", "version": "0.1.0", diff --git a/package.json b/package.json index 55b0fd4..a9969fb 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,22 @@ "typecheck": "tsc -b && npm run typecheck --workspaces --if-present", "build:ui": "npm run build -w @hm-design/ui", "build:center-graphic": "npm run build -w @hm-design/center-graphic", - "build:packages": "npm run build --workspaces --if-present" + "build:org-chart": "npm run build -w @hm-design/org-chart", + "typecheck:org-chart": "npm run typecheck -w @hm-design/org-chart", + "test:org-chart": "npm run test -w @hm-design/org-chart", + "build:org-chart-example": "npm run build:org-chart && node examples/org-chart/prepare.mjs", + "dev:org-chart-example": "node examples/org-chart/serve.mjs", + "test:org-chart-browser": "node examples/org-chart/verify.mjs", + "build:org-chart-react": "npm run build -w @hm-design/org-chart-react", + "typecheck:org-chart-react": "npm run typecheck -w @hm-design/org-chart-react", + "build:org-chart-react-example": "vite build --config examples/org-chart-react/vite.config.ts", + "dev:org-chart-react-example": "vite --config examples/org-chart-react/vite.config.ts", + "test:org-chart-react-browser": "node examples/org-chart-react/verify.mjs", + "build:packages": "npm run build:org-chart && npm run build:ui && npm run build:center-graphic && npm run build:org-chart-react" }, "dependencies": { + "@hm-design/org-chart": "*", + "@hm-design/org-chart-react": "*", "@hm-design/center-graphic": "*", "@hm-design/ui": "*", "lucide-react": "^1.49.0", diff --git a/packages/org-chart-react/README.md b/packages/org-chart-react/README.md new file mode 100644 index 0000000..5a48681 --- /dev/null +++ b/packages/org-chart-react/README.md @@ -0,0 +1,146 @@ +# @hm-design/org-chart-react v0.1 + +React 19 lifecycle adapter for `@hm-design/org-chart@0.1.0`. The wrapper delegates organization DOM, engine work, validation, summaries, interactions and Shadow DOM styles to the existing Vanilla Core. Auto can optionally show employee information with the public HM Dialog in a separate React Portal. It has no navigation, page layout, fetch client or separate node design. + +## Usage + +```tsx +import { useMemo } from 'react'; +import { JsonOrgChartDataSource } from '@hm-design/org-chart'; +import { OrgChart } from '@hm-design/org-chart-react'; +import type { OrgChartDataset, OrgChartState } from '@hm-design/org-chart-react'; +import engineCssUrl from '@hm-design/org-chart/engine.css?url'; +import coreCssUrl from '@hm-design/org-chart/style.css?url'; +// Core P1 supplies standalone OrgChart design; no HM Theme/font import needed. + +const stylesheetUrls = { engine: engineCssUrl, core: coreCssUrl }; + +export function OrganizationContent({ data }: { data: OrgChartDataset }) { + const dataSource = useMemo(() => new JsonOrgChartDataSource(data), [data]); + const onStateChange = (state: OrgChartState) => { + // Data/source/validation/style failures are state.status === 'error'. + // A ready state contains the Core's shared summary and dataVersion. + void state; + }; + return ( + { console.error('OrgChart lifecycle failed', error); }} + /> + ); +} +``` + +The `?url` imports above are Vite asset imports, not a universal browser feature. Other hosts resolve the two Core public CSS entries to actual deployment URLs. Importing CSS only into the document does not style the Core's Shadow DOM. Engine CSS is linked first, then standalone Core CSS. P1 owns its colors, typography and geometry without depending on HM Theme. Set public `--hm-orgchart-*` properties on the wrapper container to override only this instance. No HM common variables are redefined. The default entry has no runtime or declaration dependency on HM UI. For independent Tree/Summary integration, use the [Core factories](https://gitea.hmac.kr/b25027/HMDesign/src/commit/d2b76e766466e1f156f6a0f8f63710e023af900e/docs/org-chart/standalone-components-p1.md) in a ref/effect without copying their UI. + +## Explicit popup adapters + +Minimum React installation: `@hm-design/org-chart`, `@hm-design/org-chart-react`, React 19 and the host renderer (usually `react-dom`). HM UI, HM Theme and Tailwind are unnecessary for the default entry and Core popup. Core + Vanilla requires only `@hm-design/org-chart` and its public CSS URLs. + +For the existing HM Dialog, install the optional `@hm-design/ui@0.1.0` peer and its required peers, provide the host's real HM Theme/Tailwind CSS, and explicitly select both the entry and popup mode: + +```tsx +import { OrgChart } from '@hm-design/org-chart-react/hm-dialog'; +// Existing dataSource, stylesheetUrls and all other Props are unchanged. +; +``` + +Migration from the previous HM popup setup: change the `OrgChart` import to `/hm-dialog`; keep `employeePopup="hm-dialog"`. Installing UI alone never switches the popup. Even the HM entry defaults to the Core popup. The default entry can also use the exported `HMEmployeeDialog` as `employeePopupAdapter` explicitly. + +For another design system, keep the default entry and implement a React component with the public `OrgChartEmployeePopupProps` contract: + +```tsx +import { OrgChart, type OrgChartEmployeePopupProps } from '@hm-design/org-chart-react'; +// MyEmployeePopup receives { selection, onClose }; selection is null when closed. +// Read selection.employee/context without mutating them. Own accessible modal +// semantics, Escape/close behavior and focus restoration to context.trigger. +; +``` + +Neither Core nor the dataset changes. Supplying an adapter alone does not enable it. An explicit `onEmployeeClick` still takes precedence. If an adapter mode is requested without an adapter, the wrapper retains the Core popup and reports a configuration error through `onLifecycleError`. Fixed continues to use its existing Core popup. Department Colors and Overview remain Storybook-only pages and have no new exports. + +## Public API + +Default named runtime export: `OrgChart`. `/hm-dialog` exports the same `OrgChart` Props and `HMEmployeeDialog` adapter. Type exports: `OrgChartProps`, `OrgChartEmployeeSelection`, `OrgChartEmployeePopupProps` and the Core's public types, reexported as types only. Runtime helpers such as `JsonOrgChartDataSource` remain available from `@hm-design/org-chart`. + +`OrgChartProps` reuses `Omit`: + +- Required `dataSource`: the same Core `OrgChartDataSource` interface, `load(options?: { signal?: AbortSignal }): Promise`. +- Required `stylesheetUrls`: `{ engine: string; core: string }`, the exact Core asset contract. +- Optional `displayMode`: `'interactive'` (default) or `'readOnly'`, passed to the Core composition. Changing it recreates the Core handle. +- Optional `layoutMode`: `'fixed'` (default) or `'auto'`, independent of `displayMode`. Auto renders company forests, collapse and Zoom/Pan/Fit. P5-4 retains the current Expanded card presentation by user decision; external SVG matching is no longer required. Changing mode recreates the Core handle. + +See [Expanded usage and styling](https://gitea.hmac.kr/b25027/HMDesign/src/commit/d2b76e766466e1f156f6a0f8f63710e023af900e/docs/org-chart/expanded-usage.md) for company +selection, top nodes, JSON sources, popup ownership and Auto-only CSS examples. +Keyboard focus reveals offscreen controls at the current Zoom; group-title Hover +feedback and drag/keyboard activation are implemented in the shared Core. +- Optional `companyId`: legacy Auto fallback company preset (default `hanmac`). Explicit/inherited organization company identity takes priority; Fixed ignores company colors. Changing it recreates the handle. See [Core README](https://gitea.hmac.kr/b25027/HMDesign/src/commit/d2b76e766466e1f156f6a0f8f63710e023af900e/packages/org-chart/README.md). +- Optional `selectedCompanyIds`: undefined selects all, [] selects none. Changes call Core selection on the existing instance, preserving per-company collapse state and source counts. +- Optional `autoLayout`: `{ type: 'tb' | 'lr' | 'hybrid', hybridStartDepth: 2 | 3 | 4, initialVisibleDepth: 1 | 2 | 3 | 'all', hybridOrganizationIds?: readonly string[] }` (each field optional). Changes use Core `setAutoLayout` on the existing instance; TB/LR/Hybrid changes retain valid collapse state. Per-group hybrid IDs apply only to TB; clear them when selecting LR/global Hybrid. Omission follows Core defaults (TB, Hybrid depth 3, all visible). +- Optional `interactions`: `{ collapse?: boolean, zoom?: boolean, pan?: boolean }`, all enabled by default. Auto independently enables/disables group toggles, Zoom controls/wheel Zoom and drag Pan via Core `setInteractions` on the same instance. Omitted fields are enabled, and removing the prop restores all three defaults. Existing collapse and viewport state remain; TB/LR/Hybrid has no interaction prerequisite. Fixed ignores this option and keeps its existing behavior. +- Optional `employeePopup`: `'core'` (default), `'hm-dialog'`, `'adapter'`, or `'none'`. Applies only to Auto; Fixed retains its existing Core popup. HM Dialog requires the explicit `/hm-dialog` entry or `employeePopupAdapter={HMEmployeeDialog}`, the optional `@hm-design/ui@0.1.0` peer and the consumer's real HM Theme/Tailwind CSS entry. An explicit `onEmployeeClick` always takes precedence and suppresses either default popup. Host popup state closes on source/company changes, adapter replacement and successful movement. HM focus restoration still resolves the employee's replacement card in the original ShadowRoot after a layout render. Auto uses Core `setEmployeeClickHandler` to change popup ownership on the same instance, retaining company selection, layout, collapse and viewport state. +- Optional `employeePopupAdapter`: React `ComponentType`. Receives the untouched employee/context selection and `onClose`; used only with an explicit adapter popup mode. No global registration or package-presence detection is performed. +- Optional `companyTopNodes`, `employeeConnections`, `zoom`: immutable Core configuration maps/range. Identity changes recreate the handle; memoize these objects. Names, positions and profile overrides affect only Auto presentation, not callback data. +- Optional `organizationLinks`: existing organization-ID-to-URL map, applied only to original 1Depth titles. Missing/blank values retain plain titles. Core validates HTTP(S)/relative URLs and rejects invalid URLs, other schemes and credentials. Treat the map as immutable and memoize it; a new map recreates the handle. +- Optional `onEmployeeClick(employee, context)`: host-owned information UI, replacing the Core's built-in popup. `context` contains the existing `organization`, `affiliation`, focusable `trigger` card and `originalEvent`. It works in both modes and receives untouched Employee data. +- Optional `onMove(result: OrgChartMoveResult)`: latest committed callback receives Auto employee/group move outcomes (`moved` distinguishes success from rejection). Successful moves close an open HM employee Dialog, so its employee context cannot become stale. Changing this callback does not recreate or reload Core. Movement changes the Core's in-memory snapshot; no fixture or server is written. +- Optional `aggregationPolicy`: the Core's `ApprovedAggregationPolicy`. +- Optional `className` and `style`: applied only to React's outer container. +- Optional `onStateChange(state: OrgChartState)`: latest committed callback receives actual Core state changes. +- Optional `onLifecycleError(error: unknown)`: reports unexpected Core creation or rejected mount/reload calls and unexpected cleanup errors. Controlled data, source, validation, rendering and stylesheet failures use the Core's `error` state instead. + +The container is owned by React; its dedicated children are owned by Core. There are no React children, imperative handle, title, employee or engine manipulation Props. Titles, descriptions, presentation groups, organization order and metadata use the common data contract. An omitted lifecycle error callback leaves unexpected operation errors handled but does not produce a second error UI or console log. Consumer state callback errors follow the Core's isolation behavior; lifecycle error callback exceptions are also contained. + +Without `onEmployeeClick`, employee cards follow `employeePopup` in Auto: Core read-only information dialog (default), public HM Dialog, or no popup. Fixed always retains the Core popup. Both dialogs use only existing employee/organization/affiliation fields and omit missing values. Core readOnly names match the displayed card while source data remains unchanged; HM Dialog shows source employee data. The native modal sits outside the scaled Stage and HM Dialog uses a React Portal; both support Close, ESC, focus trapping and return to the card. Group-link events remain separate; a visible name tooltip closes before popup activation. A custom callback owns its popup's accessibility and cleanup. + +```tsx + openEmployeeInformation(employee, context)} +/> +``` + +Both map and popup function above are supplied by the host; no real destination URL or employee information is embedded in the package. Omit the callback for the default popup. + +## Lifecycle and updates + +P9.4 adds `dragDropEnabled?: boolean` (default `false`) for Auto. Pass `true` to +enable employee/group pointer, keyboard and public Core moves. Changing this prop +calls the shared Core `setDragDropEnabled` synchronously on the existing handle; +it neither remounts nor reloads the source. Switching OFF cancels active movement, +clears highlights and retains completed moves, popup, collapse and viewport state. +Pan/Zoom/Fit remain independent. Fixed ignores the prop. + +Mount creates one new Core handle, subscribes to its state and calls `mount(container)`. Mount already loads the source; the wrapper does not perform an additional initial reload. The wrapper does not synthesize `idle`, `destroyed` or `empty` callbacks. Current Core emits `loading`, then `ready`/`error`; employees `[]` is ready, organizations `[]` is error. An `empty` type member does not establish a supported Empty State. + +A new `dataSource` object triggers the existing handle's `reload(nextSource)`. This starts Core's latest revision and aborts or ignores older responses. Updating fields inside the same source object is not a reload signal. `JsonOrgChartDataSource` captures inline input at its construction; create a new source for a new immutable snapshot. Memoize sources rather than constructing one on every render. + +Changing callback identity updates the latest committed callback without reload or recreation. In Auto, adding/removing `onEmployeeClick` or changing `employeePopup` updates Core's employee handler without replacing the handle. Fixed retains its original ownership policy: adding/removing `onEmployeeClick` recreates the handle. Abandoned concurrent renders do not update callbacks. Policy or `organizationLinks` identity changes, displayMode changes, or either stylesheet URL string value changes dispose the previous handle and create a new one because these settings have no Core setter. Replacing the stylesheet URL object with the same two strings does not recreate. Treat policy and link-map objects as immutable and memoize them; changing an object internally is not a configuration update signal. + +Auto separates blank-space Pan from employee-card/group-title movement. Click continues to open employee information or toggle a group; employees and groups may move within or across companies. Employee moves update `organizationId`; group moves update `parentId` and retain the subtree and IDs. Within-company moves preserve existing affiliations, including dispatched employees. Cross-company employee moves additionally update `affiliationId` to the target company; cross-company group moves update the moved group's and every descendant's `companyId` and their employees' `affiliationId`. The target company controls colors and counts; leaders and company roots remain protected. Focus a card/title and press Alt+M to begin keyboard movement, arrow keys to choose a target, Enter to confirm, or ESC to cancel. Employee cards retain a keyboard focus entry point when `employeePopup="none"`. Fixed has no movement editing. + +Cleanup invalidates the wrapper generation, unsubscribes, calls Core's terminal `destroy()` and clears its handle reference. React Strict Mode's setup → cleanup → setup uses a fresh handle. Old generations cannot deliver state/lifecycle callbacks to the new instance; superseded operation rejection handlers are also ignored. Cleanup runs before replacement, leaving at most one active Core per wrapper container. The wrapper does not repeat engine disposal or DOM removal itself. + +Auto retains valid organization collapse state on source/selection updates, fits reloads/selection and restores toggle focus. Fixed retains its previous regeneration policy. Source errors can recover by replacing the source; Core validation/summary/node behavior remains identical to Vanilla. Invalid runtime Props that prevent Core construction are lifecycle usage errors: fix them and remount the component (for example by changing its React key). No rendered fallback silently replaces an invalid configuration. + +Committed callback publication, Core ownership and source updates use layout effects in that order. Cleanup/reload runs synchronously with React commit, so a pending old response cannot notify in a gap before passive cleanup. Server rendering produces only the outer container; SSR/hydration remains outside the verified scope. + +## Package boundary and verification limits + +The build emits separate ESM `dist/index.js` and `dist/hm-dialog.js` entries, their shared UI-independent wrapper chunk, and TypeScript declarations. It preserves the `"use client"` boundary and leaves React (including `react/jsx-runtime`), `@hm-design/org-chart`, and optional `@hm-design/ui` external. Only the HM entry imports UI. React is a `^19.0.0` peer, matching the repository's existing React package convention; Core `0.1.0` is a runtime dependency. React DOM is supplied by the consuming app and is not imported by this package. Core must be built before wrapper typecheck/build in this workspace because dependency resolution uses its public declarations. Building all entries, including the HM adapter, also needs HM UI declarations in the maintainer workspace; installing the packed default entry does not. + +From the repository root: + +```sh +npm run typecheck --workspace @hm-design/org-chart-react +npm run build --workspace @hm-design/org-chart-react +``` + +Storybook is one React host, not proof of every consumer deployment. The adapter delegates Auto Pan/Zoom to Core; it adds no alternate layout, additional relation overlays or API client. SSR/hydration, full screen reader behavior, physical touch, complete heap/GC recovery, HM Dialog without its Theme, Workdesk reference visual approval and every deployment environment require their own evidence. Default/Core rendering requires no HM Theme. Public ESM import without a DOM does not establish SSR/hydration support. Consult the minimal-install verification report for actual packed-package consumer results. diff --git a/packages/org-chart-react/build.mjs b/packages/org-chart-react/build.mjs new file mode 100644 index 0000000..4cbc123 --- /dev/null +++ b/packages/org-chart-react/build.mjs @@ -0,0 +1,47 @@ +import { build } from 'vite'; +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; + +const root = fileURLToPath(new URL('./', import.meta.url)); +const require = createRequire(import.meta.url); +const manifest = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); +const externals = Object.keys({ ...manifest.dependencies, ...manifest.peerDependencies }); + +await build({ + configFile: false, + root, + publicDir: false, + build: { + target: 'es2022', + outDir: 'dist', + minify: false, + lib: { + entry: { index: path.join(root, 'src/index.ts'), 'hm-dialog': path.join(root, 'src/hm-dialog.tsx') }, + formats: ['es'], fileName: (_format, name) => `${name}.js`, + }, + rolldownOptions: { + external: id => externals.some(dependency => id === dependency || id.startsWith(`${dependency}/`)), + onwarn(warning, warn) { + // Restore the client boundary on the final public entry below. + if (warning.code === 'MODULE_LEVEL_DIRECTIVE') return; + warn(warning); + }, + }, + }, +}); + +const typescriptManifest = require.resolve('typescript/package.json'); +const typescript = JSON.parse(await readFile(typescriptManifest, 'utf8')); +execFileSync(process.execPath, [ + path.resolve(path.dirname(typescriptManifest), typescript.bin.tsc), + '-p', path.join(root, 'tsconfig.build.json'), +], { stdio: 'inherit' }); + +for (const name of ['index', 'hm-dialog']) { + const entry = path.join(root, `dist/${name}.js`); + const content = await readFile(entry, 'utf8'); + await writeFile(entry, content.startsWith('"use client";') ? content : `"use client";\n${content}`); +} diff --git a/packages/org-chart-react/package.json b/packages/org-chart-react/package.json new file mode 100644 index 0000000..8630125 --- /dev/null +++ b/packages/org-chart-react/package.json @@ -0,0 +1,30 @@ +{ + "name": "@hm-design/org-chart-react", + "version": "0.1.0", + "description": "React lifecycle adapter for the framework-independent HM OrgChart Core.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./hm-dialog": { + "types": "./dist/hm-dialog.d.ts", + "import": "./dist/hm-dialog.js", + "default": "./dist/hm-dialog.js" + } + }, + "files": ["dist", "README.md"], + "sideEffects": false, + "scripts": { + "typecheck": "tsc -p tsconfig.json", + "build": "node build.mjs", + "prepack": "npm run build" + }, + "dependencies": { "@hm-design/org-chart": "0.1.0" }, + "peerDependencies": { "react": "^19.0.0", "@hm-design/ui": "0.1.0" }, + "peerDependenciesMeta": { "@hm-design/ui": { "optional": true } } +} diff --git a/packages/org-chart-react/src/HMEmployeeDialog.tsx b/packages/org-chart-react/src/HMEmployeeDialog.tsx new file mode 100644 index 0000000..1308654 --- /dev/null +++ b/packages/org-chart-react/src/HMEmployeeDialog.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useLayoutEffect, useRef } from 'react'; +import { + Avatar, AvatarFallback, AvatarImage, Button, Dialog, DialogBody, DialogClose, + DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from '@hm-design/ui'; +import type { OrgChartEmployeeSelection, OrgChartEmployeePopupProps } from './types.js'; + +const statusNames = { active: '재직', dispatched: '파견', onLeave: '휴직' } as const; + +/** Explicit React-only adapter. The Core and default entry stay UI-independent. */ +export default function HMEmployeeDialog({ selection, onClose }: OrgChartEmployeePopupProps) { + const returnTarget = useRef(null); + const returnRoot = useRef(null); + const returnContainer = useRef(null); + useLayoutEffect(() => { + if (!selection) return; + returnTarget.current = selection; + const root = selection.context.trigger.getRootNode(); + const Shadow = selection.context.trigger.ownerDocument.defaultView?.ShadowRoot; + returnRoot.current = Shadow && root instanceof Shadow ? root : null; + returnContainer.current = returnRoot.current?.host.parentElement ?? null; + }, [selection]); + + function restoreFocus(event: Event) { + event.preventDefault(); + const previous = returnTarget.current; + returnTarget.current = null; + if (!previous) return; + const { trigger } = previous.context; + // A mode/selection render can replace the card while the Portal is open. + // Retain its ShadowRoot before replacement and resolve the same employee. + const shadow = returnRoot.current; + const currentRoot = shadow?.host.isConnected ? shadow : returnContainer.current?.firstElementChild?.shadowRoot; + const replacement = [...currentRoot?.querySelectorAll('.hm-orgchart-employee') ?? []] + .find(card => card.dataset.employeeId === previous.employee.id); + const target = trigger.isConnected ? trigger : replacement + ?? currentRoot?.querySelector('.hm-orgchart-shell'); + target?.focus({ preventScroll: true }); + } + + const displayed = selection ?? returnTarget.current; + const employee = displayed?.employee; + const context = displayed?.context; + const image = employee?.profileImage + ?? context?.trigger.querySelector('.hm-orgchart-avatar img')?.src; + const fields: [string, string | null | undefined][] = employee ? [ + ['이름', employee.name], ['직위', employee.position], + ['소속 회사', context?.affiliation?.name], ['소속 조직', context?.organization?.name], + ['직책', employee.jobTitle], ['근무 상태', statusNames[employee.workStatus]], + ] : []; + + return { if (!open) onClose(); }}> + + + 직원 정보 + 조직도에서 선택한 직원의 정보를 확인합니다. + + + + {image ? : null} + {employee?.name.slice(0, 1)} + +
{fields.filter(([, value]) => Boolean(value)).map(([label, value]) =>
+
{label}
{value}
+
)}
+
+ + + +
+
; +} diff --git a/packages/org-chart-react/src/OrgChart.tsx b/packages/org-chart-react/src/OrgChart.tsx new file mode 100644 index 0000000..5abde22 --- /dev/null +++ b/packages/org-chart-react/src/OrgChart.tsx @@ -0,0 +1,285 @@ +"use client"; + +import { useLayoutEffect, useRef, useState } from 'react'; +import { createOrgChart } from '@hm-design/org-chart'; +import type { OrgChartAutoLayoutOptions, OrgChartDataSource, OrgChartHandle, OrgChartInteractionOptions, OrgChartOptions } from '@hm-design/org-chart'; +import type { OrgChartEmployeeSelection, OrgChartProps } from './types.js'; + +function annotateEmployeeCards(shadow: ShadowRoot, popup: NonNullable, customCallback: boolean) { + const actionable = customCallback || popup !== 'none'; + for (const card of shadow.querySelectorAll('.hm-orgchart-employee')) { + const name = card.querySelector('.hm-orgchart-person-name')?.textContent ?? ''; + const position = card.querySelector('.hm-orgchart-person-role')?.textContent ?? ''; + // Auto cards also provide keyboard movement when the information popup is + // disabled; popup ownership must not remove that Core keyboard entry point. + card.tabIndex = 0; + if (actionable) card.setAttribute('role', 'button'); + else card.removeAttribute('role'); + card.setAttribute('aria-label', `${name} ${position}${actionable ? ' 직원 정보 보기' : ''}`.trim()); + if (actionable && !customCallback) card.setAttribute('aria-haspopup', 'dialog'); + else card.removeAttribute('aria-haspopup'); + } +} + +// Core captures option objects by value. Compare them the same way so a parent +// re-render with equal inline literals neither remounts nor re-fits the chart. +const optionKey = (value: unknown) => JSON.stringify(value ?? null); + +interface Instance { + readonly handle: OrgChartHandle; + readonly generation: number; + source: OrgChartDataSource; + autoLayout: OrgChartAutoLayoutOptions | undefined; + interactions: OrgChartInteractionOptions | undefined; + selectedCompanyIds: readonly string[] | undefined; + dragDropEnabled: boolean; + readonly employeeClick: NonNullable; + employeeCallbackEnabled: boolean; + popupMode: NonNullable; + operation: number; +} + +/** A React host for the exact same Core used by Vanilla HTML. */ +export function OrgChart({ + dataSource, + aggregationPolicy, + stylesheetUrls, + displayMode, + layoutMode, + autoLayout, + interactions, + dragDropEnabled = false, + companyId, + selectedCompanyIds, + companyTopNodes, + employeeConnections, + zoom, + presentationMode, + initialView, + wheelZoom, + organizationLinks, + onEmployeeClick, + onMove, + employeePopup = 'core', + employeePopupAdapter: EmployeePopupAdapter, + className, + style, + onStateChange, + onLifecycleError, +}: OrgChartProps) { + const containerRef = useRef(null); + const instanceRef = useRef(null); + const generationRef = useRef(0); + const [employeeSelection, setEmployeeSelection] = useState(null); + const requestedAdapter = employeePopup === 'hm-dialog' || employeePopup === 'adapter'; + const effectivePopup = requestedAdapter && !EmployeePopupAdapter ? 'core' : employeePopup; + const committedRef = useRef({ dataSource, onStateChange, onLifecycleError, onEmployeeClick, onMove, employeePopup: effectivePopup }); + + // Publish committed values before Core effects run, without letting callback + // identity changes or abandoned concurrent renders alter Core ownership. + useLayoutEffect(() => { + committedRef.current = { dataSource, onStateChange, onLifecycleError, onEmployeeClick, onMove, employeePopup: effectivePopup }; + }, [dataSource, onStateChange, onLifecycleError, onEmployeeClick, onMove, effectivePopup]); + + useLayoutEffect(() => { + if (layoutMode !== 'auto' || !requestedAdapter || EmployeePopupAdapter || onEmployeeClick) return; + try { + committedRef.current.onLifecycleError?.(new Error('OrgChart popup adapter is missing. Use @hm-design/org-chart-react/hm-dialog or supply employeePopupAdapter. Falling back to the Core popup.')); + } catch { /* Consumer error reporting cannot prevent Core rendering. */ } + }, [layoutMode, requestedAdapter, EmployeePopupAdapter, Boolean(onEmployeeClick)]); + + const engineStylesheetUrl = stylesheetUrls.engine; + const coreStylesheetUrl = stylesheetUrls.core; + const hasEmployeeCallback = typeof onEmployeeClick === 'function'; + const popupMode = layoutMode === 'auto' ? effectivePopup : 'core'; + const adapterOwnsPopup = popupMode !== 'core'; + const hasAdapterCallback = hasEmployeeCallback || popupMode !== 'core'; + const fixedHasEmployeeCallback = layoutMode !== 'auto' && hasEmployeeCallback; + + // Ownership changes are synchronous with the commit: an old request cannot + // notify between a committed unmount/source change and a passive cleanup. + useLayoutEffect(() => { + const container = containerRef.current; + if (!container) return; + const generation = ++generationRef.current; + let handle: OrgChartHandle | undefined; + let unsubscribe: (() => void) | undefined; + let popupObserver: MutationObserver | undefined; + + const reportError = (error: unknown) => { + if (generationRef.current !== generation) return; + try { committedRef.current.onLifecycleError?.(error); } + catch { /* Consumer errors must not leave rejected lifecycle promises. */ } + }; + + try { + const source = committedRef.current.dataSource; + const employeeClick: NonNullable = (employee, context) => { + if (generationRef.current !== generation) return; + const callback = committedRef.current.onEmployeeClick; + if (callback) callback(employee, context); + else if (committedRef.current.employeePopup === 'hm-dialog' || committedRef.current.employeePopup === 'adapter') setEmployeeSelection({ employee, context }); + }; + handle = createOrgChart({ + dataSource: source, + aggregationPolicy, + stylesheetUrls: { engine: engineStylesheetUrl, core: coreStylesheetUrl }, + displayMode, + layoutMode, + autoLayout, + interactions, + dragDropEnabled, + companyId, + selectedCompanyIds, + companyTopNodes, + employeeConnections, + zoom, + presentationMode, + initialView, + wheelZoom, + organizationLinks, + onEmployeeClick: hasAdapterCallback ? employeeClick : undefined, + onMove: result => { + if (generationRef.current !== generation) return; + if (result.moved) setEmployeeSelection(null); + committedRef.current.onMove?.(result); + }, + }); + const instance: Instance = { handle, source, autoLayout, interactions, selectedCompanyIds, dragDropEnabled, employeeClick, employeeCallbackEnabled: hasAdapterCallback, popupMode, generation, operation: 1 }; + instanceRef.current = instance; + unsubscribe = handle.subscribe(state => { + if (instanceRef.current === instance && generationRef.current === generation) { + committedRef.current.onStateChange?.(state); + } + }); + // Core mount performs the first load. The source effect below compares + // identity against this record and does not load the initial source again. + const operation = instance.operation; + void handle.mount(container).catch(error => { + if (instanceRef.current === instance && instance.operation === operation) reportError(error); + }); + const shadow = container.firstElementChild?.shadowRoot; + if (shadow && layoutMode === 'auto') { + const annotateCards = () => { + annotateEmployeeCards(shadow, committedRef.current.employeePopup, Boolean(committedRef.current.onEmployeeClick)); + }; + const Observer = container.ownerDocument.defaultView?.MutationObserver; + if (Observer) { + popupObserver = new Observer(annotateCards); + popupObserver.observe(shadow, { childList: true, subtree: true }); + } + annotateCards(); + } + } catch (error) { + reportError(error); + } + + return () => { + // Invalidate callbacks and promise continuations before terminal disposal. + if (generationRef.current === generation) ++generationRef.current; + if (instanceRef.current?.generation === generation) instanceRef.current = null; + popupObserver?.disconnect(); + try { unsubscribe?.(); } + catch (error) { + try { committedRef.current.onLifecycleError?.(error); } + catch { /* A consumer callback cannot prevent terminal Core disposal. */ } + } + finally { + try { handle?.destroy(); } + catch (error) { + try { committedRef.current.onLifecycleError?.(error); } + catch { /* Cleanup still completes when the consumer callback throws. */ } + } + } + }; + }, [optionKey(aggregationPolicy), engineStylesheetUrl, coreStylesheetUrl, displayMode, layoutMode, companyId, optionKey(companyTopNodes), optionKey(employeeConnections), optionKey(zoom), presentationMode, initialView, wheelZoom, optionKey(organizationLinks), fixedHasEmployeeCallback]); + + useLayoutEffect(() => { + const instance = instanceRef.current; + if (!instance || layoutMode !== 'auto' || (instance.employeeCallbackEnabled === hasAdapterCallback && instance.popupMode === popupMode)) return; + try { + instance.handle.setEmployeeClickHandler(hasAdapterCallback ? instance.employeeClick : undefined); + instance.employeeCallbackEnabled = hasAdapterCallback; + instance.popupMode = popupMode; + } catch (error) { + try { committedRef.current.onLifecycleError?.(error); } + catch { /* Match Core's callback ownership isolation. */ } + } + }, [hasAdapterCallback, popupMode, layoutMode]); + + useLayoutEffect(() => { + // Close a host-owned popup before committing a changed source/company view; + // no asynchronous fixture request can leave a stale employee displayed. + setEmployeeSelection(null); + const shadow = containerRef.current?.firstElementChild?.shadowRoot; + if (shadow && layoutMode === 'auto') { + annotateEmployeeCards(shadow, popupMode, hasEmployeeCallback); + } + }, [dataSource, selectedCompanyIds, popupMode, hasEmployeeCallback, layoutMode, adapterOwnsPopup, EmployeePopupAdapter]); + + useLayoutEffect(() => { + const instance = instanceRef.current; + if (!instance || layoutMode !== 'auto' || optionKey(instance.autoLayout) === optionKey(autoLayout)) return; + instance.autoLayout = autoLayout; + void instance.handle.setAutoLayout(autoLayout ?? { type: 'tb', hybridStartDepth: 3, initialVisibleDepth: 'all', hybridOrganizationIds: [] }).catch(error => { + if (instanceRef.current !== instance) return; + try { committedRef.current.onLifecycleError?.(error); } + catch { /* Keep layout setter failures in the lifecycle error contract. */ } + }); + }, [optionKey(autoLayout), layoutMode]); + + useLayoutEffect(() => { + const instance = instanceRef.current; + if (!instance || layoutMode !== 'auto' || instance.dragDropEnabled === dragDropEnabled) return; + try { + instance.handle.setDragDropEnabled(dragDropEnabled); + instance.dragDropEnabled = dragDropEnabled; + } catch (error) { + try { committedRef.current.onLifecycleError?.(error); } + catch { /* Keep option changes in the lifecycle error contract. */ } + } + }, [dragDropEnabled, layoutMode]); + + useLayoutEffect(() => { + const instance = instanceRef.current; + if (!instance || layoutMode !== 'auto' || optionKey(instance.interactions) === optionKey(interactions)) return; + instance.interactions = interactions; + void instance.handle.setInteractions({ collapse: true, zoom: true, pan: true, ...interactions }).catch(error => { + if (instanceRef.current !== instance) return; + try { committedRef.current.onLifecycleError?.(error); } + catch { /* Interaction controls use the existing lifecycle error contract. */ } + }); + }, [optionKey(interactions), layoutMode]); + + useLayoutEffect(() => { + const instance = instanceRef.current; + // Mount already passed this selection to Core; only a changed value re-applies. + if (!instance || layoutMode !== 'auto' || optionKey(instance.selectedCompanyIds) === optionKey(selectedCompanyIds)) return; + instance.selectedCompanyIds = selectedCompanyIds; + 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. */ } + }); + }, [optionKey(selectedCompanyIds), layoutMode]); + + useLayoutEffect(() => { + const instance = instanceRef.current; + if (!instance || instance.source === dataSource) return; + instance.source = dataSource; + const operation = ++instance.operation; + // Core reload synchronously starts a new revision, aborting/ignoring earlier + // source requests even when their Promise or engine import is still pending. + void instance.handle.reload(dataSource).catch(error => { + if (instanceRef.current !== instance || generationRef.current !== instance.generation || instance.operation !== operation) return; + try { committedRef.current.onLifecycleError?.(error); } + catch { /* Consumer errors must not become unhandled Promise rejections. */ } + }); + }, [dataSource]); + + return <> +
+ {(popupMode === 'hm-dialog' || popupMode === 'adapter') && EmployeePopupAdapter && !hasEmployeeCallback + ? setEmployeeSelection(null)} /> : null} + ; +} diff --git a/packages/org-chart-react/src/hm-dialog.tsx b/packages/org-chart-react/src/hm-dialog.tsx new file mode 100644 index 0000000..d4b6cc1 --- /dev/null +++ b/packages/org-chart-react/src/hm-dialog.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { OrgChart as CoreOrgChart } from './OrgChart.js'; +import HMEmployeeDialog from './HMEmployeeDialog.js'; +import type { OrgChartProps } from './types.js'; + +/** HM integration is explicit at the import and popup setting boundaries. */ +export function OrgChart(props: OrgChartProps) { + return ; +} + +export { HMEmployeeDialog }; +export type { OrgChartProps, OrgChartEmployeeSelection, OrgChartEmployeePopupProps } from './types.js'; +export type * from '@hm-design/org-chart'; diff --git a/packages/org-chart-react/src/index.ts b/packages/org-chart-react/src/index.ts new file mode 100644 index 0000000..f96de25 --- /dev/null +++ b/packages/org-chart-react/src/index.ts @@ -0,0 +1,5 @@ +"use client"; + +export { OrgChart } from './OrgChart.js'; +export type { OrgChartProps, OrgChartEmployeeSelection, OrgChartEmployeePopupProps } from './types.js'; +export type * from '@hm-design/org-chart'; diff --git a/packages/org-chart-react/src/types.ts b/packages/org-chart-react/src/types.ts new file mode 100644 index 0000000..020fa64 --- /dev/null +++ b/packages/org-chart-react/src/types.ts @@ -0,0 +1,31 @@ +import type { ComponentType, CSSProperties } from 'react'; +import type { Employee, OrgChartEmployeeClickContext, OrgChartOptions, OrgChartState } from '@hm-design/org-chart'; + +export interface OrgChartEmployeeSelection { + employee: Employee; + context: OrgChartEmployeeClickContext; +} + +/** Host-owned popup UI. Adapters own presentation and focus restoration. */ +export interface OrgChartEmployeePopupProps { + selection: OrgChartEmployeeSelection | null; + onClose: () => void; +} + +/** The Core owns data, organization rendering and interactions; React owns its + * dedicated container and optional host-owned employee information UI. */ +export interface OrgChartProps extends Omit { + className?: string; + style?: CSSProperties; + /** Auto only. Explicitly select an HM/custom adapter, retain the Vanilla + * popup, or suppress the default information UI. + * An explicit onEmployeeClick callback always owns the popup instead. */ + employeePopup?: 'core' | 'hm-dialog' | 'adapter' | 'none'; + /** Explicit UI integration. Supplying an adapter does not enable it: select + * employeePopup="adapter" (or "hm-dialog" for the HM entry) as well. */ + employeePopupAdapter?: ComponentType; + /** Latest committed callback. Controlled data/style failures arrive as error states. */ + onStateChange?: (state: OrgChartState) => void; + /** Unexpected create/mount/reload/cleanup errors, rather than controlled error states. */ + onLifecycleError?: (error: unknown) => void; +} diff --git a/packages/org-chart-react/tsconfig.build.json b/packages/org-chart-react/tsconfig.build.json new file mode 100644 index 0000000..bca2eb9 --- /dev/null +++ b/packages/org-chart-react/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "rootDir": "src", + "outDir": "dist" + } +} diff --git a/packages/org-chart-react/tsconfig.json b/packages/org-chart-react/tsconfig.json new file mode 100644 index 0000000..0cb675c --- /dev/null +++ b/packages/org-chart-react/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noEmit": true, + "types": ["react"] + }, + "include": ["src"] +} diff --git a/packages/org-chart/README.md b/packages/org-chart/README.md new file mode 100644 index 0000000..2927903 --- /dev/null +++ b/packages/org-chart/README.md @@ -0,0 +1,273 @@ +# @hm-design/org-chart v0.1 + +Framework-independent TypeScript/DOM Core backed by **dabeng orgchart 6.0.0**. No React runtime, wrapper, Storybook, navigation or API client. This version renders organizational hierarchy and employee/leader cards; additional relations are validated but are not drawn. + +P1 composes a separate top leader, presentation-only hub, hierarchy, position/affiliation summaries and dispatched/onLeave groups. The same `presentation.groups` fields hide each group independently. The hub does not create a fictitious organization or change `parentId`. + +```js +import { createOrgChart, JsonOrgChartDataSource } from '@hm-design/org-chart'; + +const chart = createOrgChart({ + container: document.getElementById('chart'), + dataSource: new JsonOrgChartDataSource(configuredJsonUrl), + // Host/bundler resolves the two exported CSS assets to absolute URLs. + stylesheetUrls: { engine: engineCssUrl, core: coreCssUrl }, + onStateChange(state) { /* idle/loading/ready/error/destroyed */ }, +}); +await chart.mount(); +await chart.update(nextDataset); +await chart.reload(); +chart.destroy(); +``` + +`displayMode` (optional, default `'interactive'`) selects the full composition presentation. `'readOnly'` is the view-only Workdesk layout: the hierarchy is fully expanded with no collapse buttons or diagnostic text (kind, ID, raw counts, vacancy, description), summaries use compact presenters of the same Summary (organization-by-position table, untitled affiliation bar and status groups), and a 1920 × 914.5 design canvas scales proportionally to the available container width and height. Standalone components ignore it. + +## Organization links and employee information (P3) + +### Layout boundary (P5-2 / P5-3) + +`layoutMode?: 'fixed' | 'auto'` is independent of `displayMode`. The default +`'fixed'` keeps the existing Workdesk composition and its display-mode behavior. +`'auto'` renders the complete validated hierarchy through dabeng 6.0.0. +Root and same-parent siblings remain in their real hierarchy; the engine allocates +recursive subtree widths. The Auto-only presenter reserves the tallest content +height at each depth and observes article resizing so variable staff counts and +font loading keep child rows and connectors aligned. Update/reload regenerate the +same layout and dispose old observers. Auto has a transformed forest, initial Fit, +5–300% configurable Zoom, Ctrl/Command wheel Zoom, pointer Pan and keyboard Pan. +Ordinary wheel scrolling remains with the page. Dialogs stay outside the forest. + +Set optional `Organization.companyId` on company roots; descendants inherit it. +Child company changes are rejected instead of mixing independent trees. +Legacy roots resolve from a known leader affiliation, then the `companyId` fallback +(default `hanmac`). `companyId` is no longer a global recoloring selector. +The seven IDs are `hanmac`, `saman`, `jangheon-industry`, `jangheon`, `ptc`, `halla`, +and `baron`. Main colors style Expanded group headers and card accents; indexed secondary +variables come from that same preset. Fixed ignores this option and preserves +its organization gradients. Unknown Auto IDs reject construction with `TypeError`. +The Auto presenter shares photo/position/name and employee dialog behavior without +Fixed SVG cards, coordinates or scaling. Group title DOM carries the organization +ID; the full title is a native toggle with aria-expanded and disabled empty groups. +Separate link icons preserve page navigation. Child collapse state survives parent +collapse and company reselection. Auto uses actual Employee names/positions; +three-character Korean names display with spaces; position whitespace is removed +only for display, preserving raw data. P5-4 retains the current Expanded cards +by user decision; the two external SVG references are no longer required. +Auto geometry variables live under +`.hm-orgchart-expanded`; there is no new public `layoutConfig` API. +The existing employee popup still uses its displayMode policy and receives raw data. + +P5-4 adds group-title Hover feedback and reveals offscreen keyboard-focused cards +without changing Zoom. Drag click suppression does not block Enter/Space activation. +See [Expanded usage and styling](https://gitea.hmac.kr/b25027/HMDesign/src/commit/d2b76e766466e1f156f6a0f8f63710e023af900e/docs/org-chart/expanded-usage.md) for complete +Vanilla/React examples, Shadow DOM CSS ownership and verification boundaries. + +`selectedCompanyIds` / `setSelectedCompanies(ids?)` filter the view: undefined +means all, [] means empty. Summary raw counts always describe the unmodified source; +the Auto selection label reports selected company/employee counts separately. +`getSelectedCompanies`, `zoomTo`, `panBy`, `fitToView`, `getViewportState` expose +view operations. Selection requires a mounted Auto handle; Fixed rejects it. + +`companyTopNodes` maps company IDs to optional employeeId, title, name, profileImage, +organizationName and display (`card`, `titleOnly`, `hidden`). An explicit employee +must belong to that company's root; null removes top-card assignment. Overrides +affect presentation only, preserving employee IDs, popup data and parentId. + +`employeeConnections` maps organization IDs to top/left/right staff routes in +TB/Hybrid (default top). Their leaders connect from the title above; LR routes all +employees from the left. SVG paths use actual article/card +coordinates; each endpoint shares its direction with the corresponding single-side +border accent. Employee preset follows its known affiliation, otherwise its tree. +All remaining borders are neutral; an inset accent width variable preserves size. +Width/height/padding/radius/photo/font/spacing variables are scoped to Auto. + +```js +const chart = createOrgChart({ + ...configuredBaseOptions, + layoutMode: 'auto', + displayMode: 'readOnly', + companyId: 'hanmac', + selectedCompanyIds: ['hanmac', 'saman'], + companyTopNodes: { hanmac: { title: '회장', display: 'card' } }, + employeeConnections: { 'hanmac-office-0': 'left' }, +}); +``` + +Layout mode/company/top-node/connection/zoom ranges are captured at construction. +Auto `autoLayout`, `interactions`, company selection and the employee callback use +their own setters on the existing handle. See the P7 matrix in the usage guide. +Standalone Tree/Summary factories keep their existing rendering paths. + +`examples/org-chart/data-expanded-{small,medium,large}.json` contain exactly +20/100/300 unique virtual employees and 3/5/7 companies. Run +`node examples/org-chart/create-expanded-fixtures.mjs` to reproduce them. +After build/prepare, open `http://127.0.0.1:4178/expanded.html`. +Expanded OrgChart is one of the same four public Stories; it offers fixture and +company controls without filtering JSON in the Story. Native Core controls are +the explicit framework-independent HTML adapter, not imitations of HM React UI. + + +### Virtual example selection (P7) + +`createOrgChartExampleDataset(template, selection)` is a pure data provider for +both Vanilla and React. Supply the existing P5-3 **large** JSON fixture, which +contains all seven company presets. The old Small/Medium/Large files and their +historical 3/5/7-company policies remain unchanged; P7 selection does not require +new JSON files. The helper validates and snapshots its input, reuses organization +IDs and employee records, and adds deterministic virtual employees when a company +needs more staff than the original fixture contains. + +```js +import { createOrgChartExampleDataset, JsonOrgChartDataSource } from '@hm-design/org-chart'; + +// template is the parsed examples/org-chart/data-expanded-large.json fixture. +const allCompanyExample = createOrgChartExampleDataset(template, { + scope: 'total', employeeCount: 100, +}); // All seven companies together: exactly 100 unique employees. + +const twoCompanyExample = createOrgChartExampleDataset(template, { + scope: 'per-company', employeeCount: 100, + companyIds: ['hanmac', 'saman'], +}); // Exactly 100 employees per company, 200 in this source. + +await chart.reload(new JsonOrgChartDataSource(twoCompanyExample)); +``` + +Supported scales are exactly `20 | 100 | 300`. Scope `'total'` allocates that +count across all seven companies using the original fixture's unequal company +proportions. An optional company subset then filters those allocations **without +redistributing** them: a single selected company therefore displays only its +share of the total example. Scope `'per-company'` gives every selected company +the requested count, up to 2,100 employees for seven companies at 300 each. +Omitting `companyIds` selects all seven; repeated/out-of-order IDs are normalized +to preset order. All returned datasets are independent, deeply frozen snapshots. +Leader IDs remain references to existing employees and are never counted twice. +Missing sampled leaders are cleared while the complete, valid parent hierarchy +is retained, including the original deeper and unequal branches. + +The helper rejects an empty `companyIds` list because the data contract requires +organizations. To display no companies, retain a valid source and call +`setSelectedCompanies([])` (or pass React `selectedCompanyIds={[]}`). Runtime +layout, collapse state, employee popup policy, and actual API sources remain +separate from example selection. The 2,100-person **data count** is distinct from +rendering/heap performance; the earlier 300-person result does not verify it. + +### Historical P5-1B verification and local runtime + +Core/React builds and Typechecks pass. Focused browser evidence covers the +complete four-depth hierarchy, unequal employee counts, updates/reload, all seven +company presets, shared HTML/React cards and popup focus, viewport overflow, and +minimal Fixed baseline regression. Evidence is generated under +`test-results/org-chart-p5-1b`; the authored runner is +`examples/org-chart/verify-p5-1b.mjs`. Its baseline must be copied from a known +pre-change build before rebuilding. The runner uses configurable +`ORGCHART_STORY_ORIGIN`, `ORGCHART_PLAYWRIGHT_MODULE`, and `ORGCHART_CHROME`. +Do not assume another PC's localhost or generated dist exists. + +Use npm with `package-lock.json`; a fresh install uses `npm ci`. The existing +package test runner imports Node's `globSync`, so use a runtime providing that API, such as the verified Node 24, +for focused unit tests. This PC's Node 20.20.1 built the packages successfully; +the installed Codex Node 24.19.0 ran the unit tests without changing the runner. +Final reference SVG design, collapse/Zoom/Pan, real screen-reader speech/touch and +historical full regression suites are not verified by this phase. + +### P3.2 follow-up: representative badges + +Representative cards never render job-title, role or status badges, empty attribute wrappers or a badge row, including standalone specimens. They display `대표이사` / `홍 길 동`, retaining the approved 146×74 asset and existing typography, with no gap between position and name. The fixture's raw representative position (`경영진`), employee data and dialog fields remain unchanged. Other 1Depth/2Depth badge policies are preserved. This display policy takes precedence over the historical P3.2/P3.1 notes below. + +### P3.2 compact typography and card logos + +Large department cards use 24px names / 18px positions; compact cards use 18px names / 15px positions. Representative typography and the existing weights, line heights and colors are retained. Read-only dispatched/onLeave cards share the compact content layout (108×52 card, 24×44 photo slot), with their original gray surface and text colors. Status headers share the standard 2Depth 20px/700/19.7px typography; existing department-specific exceptions remain. Position and badge whitespace is removed only for display. + +Employee-card logo contain boxes scale exactly 1.5×: small 12→18px; representative/large/status 14→21px. Original assets and footer logos stay unchanged. See [P3.2 report](https://gitea.hmac.kr/b25027/HMDesign/src/commit/d2b76e766466e1f156f6a0f8f63710e023af900e/docs/org-chart/implementation-report-p3.2.md) for before/after browser measurements and preservation evidence. Historical P3.1 geometry below describes that earlier stage. + +### P3.1 display policy and local font loading + +The OrgChart-scoped default family is **Noto Sans KR**; existing `--hm-orgchart-font-family` overrides remain available. The host must register its font-face once: HM Storybook already loads the existing HM Theme face. The HTML P1 host loads the unchanged `public/fonts/NotoSansKR-variable.ttf` copied to `assets/fonts/`; the React P1 host references that same source through its bundler. Global HM Theme files are unchanged. The Korean Core shell declares `lang="ko"` for consistent glyph shaping across host languages. + +Read-only Full Composition suppresses only the `경영진` **job-title badge**. Positions (including a literal `경영진` position), other raw badges, employee records, popup fields and statistics are retained. Position whitespace is removed only in the card display. Other renderer modes keep their badge policy. All card types share one rectangular neutral SVG for a missing or failed profile image; an available profile image takes precedence. Approved cards, photo-slot dimensions and P3 status-card geometry are unchanged. See [P3.1 report](https://gitea.hmac.kr/b25027/HMDesign/src/commit/d2b76e766466e1f156f6a0f8f63710e023af900e/docs/org-chart/implementation-report-p3.1.md) for focused browser evidence and limits. + +Optional `organizationLinks: Readonly>` maps existing organization IDs to host-configured URLs. Only original hierarchy depth 1 titles become native links. Missing, undefined or blank URLs keep ordinary titles; no destination is generated. HTTP(S) and relative URLs are accepted; invalid URLs, other schemes and credentials reject construction with `TypeError`. Group links retain native keyboard navigation and a visible focus outline, separately from employee activation. + +All employee cards, including the representative and dispatched/onLeave cards, support click, Enter and Space in both modes. By default they open a read-only native information dialog in the unscaled shell, outside the Stage. It shows only existing name, position, organization, job title, affiliation and work-status values; unavailable optional values are omitted. The readOnly fictitious display name matches the activated card (`홍 길 동`); underlying employee data stays unchanged. Close button, ESC, modal focus trapping and card focus restoration are provided. A name-overflow tooltip closes when information UI opens. + +To use a host-owned popup, provide the additive callback below. Supplying it replaces the built-in dialog; the host then owns popup accessibility and cleanup. The callback receives the unchanged Employee and resolved existing organization/affiliation, the focusable trigger card and original event. + +```js +const chart = createOrgChart({ + ...configuredBaseOptions, + organizationLinks: configuredOrganizationLinks, + onEmployeeClick(employee, { organization, affiliation, trigger, originalEvent }) { + openEmployeeInformation({ employee, organization, affiliation, trigger, originalEvent }); + }, +}); +``` + +Omit `onEmployeeClick` to use the default popup. Vanilla options are captured at construction; use a new handle to change the URL map or callback. There is no public Core `setOptions` method. + +`mount(container)` also accepts the container at mount time. Constructing a handle and importing the ESM facade require no DOM. Mount requires a connected HTMLElement. One handle owns one container, preserving host siblings. Duplicate mount and update/reload before mount or after destroy reject with a usage error. Data/Source failures become structured `error` states and resolve the operation. Destroy is synchronous, idempotent and terminal; use a new handle to mount again. `subscribe(listener)` returns an unsubscribe function; `getState()` returns the immutable current state. Consumer callback errors do not break the Core. + +`update` captures its input immediately and preserves the current Source. `reload(source?)` optionally replaces it. The last started operation wins; earlier requests are aborted, and stale responses cannot commit. Call completion means the operation was applied, superseded, or produced a controlled error, rather than that every image/font finished loading. Fixed keeps its prior regeneration policy. Auto preserves valid organization collapse IDs and transform state on toggles; reload/selection fits the new visible forest. Removed toggle focus is restored to the same organization, with viewport fallback. + +## Data contract + +Public `OrgChartDataset`, `Organization`, `Employee`, `Affiliation`, `Metadata`, `PresentationConfig` and Source types are exported with declarations. Preserve documented names: `displayOrder`, `jobTitle`, `profileImage`, `workStatus`. The supported schema version remains **`0.1-draft`**; no silent migration is performed. The latest implementation request requires top-level `schemaVersion`; `metadata.schemaVersion` is an optional compatibility mirror and must match when supplied. `metadata.updatedAt/updateNote` and dataset `relations/presentation` are optional extensions. The original seven design documents are historical drafts and are preserved. + +Organization and employee IDs have independent namespaces. All references are checked; duplicate IDs within a collection, unknown fields, missing values, type errors, invalid dates/versions/statuses, parent cycles, unsafe markup/URLs and empty organizations produce `{valid:false, issues:[{code,path,message}]}` from `validateOrgChartDataset`. Zero employees is valid. Leaders outside their assigned organization require a future approved rule and are rejected with `LEADER_APPROVAL_REQUIRED`. Multiple roots remain separate trees, without a fabricated parent. Order is presentation order, then displayOrder, then code-unit ID order. + +`normalizeOrgChartDataset` and `calculateSummary` accept validated datasets. `calculateSummary` exposes `raw` counts for unique original employee IDs: total, direct/inclusive per organization, position, affiliation and status. These literal source counts are **not an approved business headcount**. No policy gives `aggregation.status='unavailable'`/`policyRequired`. A caller may provide an explicitly approved policy (`policyVersion`, `includedStatuses`, `activeStatuses`, `includeLeaders:true`, `membershipMode:'primaryOnly'`). Unsupported leader exclusion/multiple-membership rules or invalid policies return `unsupportedPolicy`. This release does not infer business meaning for dispatched/onLeave status. + +`JsonOrgChartDataSource` accepts an inline dataset or an HTTP(S)/relative URL, optionally `{fetch}`. Inline input is captured at construction; every load returns a fresh immutable snapshot. External HTTP/JSON errors are sanitized and distinguished by `OrgChartSourceError.code`. Source output is unknown until Core validation. Future API sources implement `load(options?:{signal?:AbortSignal}):Promise` and do not change Core APIs. URL paths are injected by the host, not embedded in Core. External JSON Fetch is an HTTP example; it is not supported as a file:// transport. + +## Theme and CSS ownership + +P1 **ships an independent OrgChart design**. It requires no HM Theme, React/UI peer or font download. Resolve the two exported CSS assets to URLs; the Core loads them only into its dedicated open Shadow DOM. `style.css` includes its own scoped defaults and contains no private CSS imports. Summary-only components require only this CSS. `/browser` is a classic IIFE exposing `window.HMOrgChart`; `dist/index.js` is bundled ESM with no bare package imports. + +All colors, typography, spacing, borders, radius and gradients belong to `src/styles/design-tokens.css` and `src/core/style-presets.ts`. Public `--hm-orgchart-*` inputs inherit from the caller's container; internal `--hm-orgchart-resolved-*` aliases resolve defaults without overriding inherited inputs. HM Theme variables do not affect this design. Workdesk organization header gradients and white heading text are measured; neutral/card styling is a documented design adaptation, not an official Workdesk value. Existing v0.1 demos and historical validation records are preserved. P1 theme-free examples are `dist/p1.html` (HTTP/ESM) and `dist/p1-classic.html` (direct file/inline JSON). See [standalone component integration](https://gitea.hmac.kr/b25027/HMDesign/src/commit/d2b76e766466e1f156f6a0f8f63710e023af900e/docs/org-chart/standalone-components-p1.md). + +## Engine disposal boundary + +Upstream provides no official destroy API. The facade clears its DOM, aborts requests and generation event signals, clears callback/data/engine references and disconnects the init observer through a narrow managed subclass hook. pan/zoom/minimap/drag/export are disabled to bound resource ownership; hierarchy expansion uses native buttons and engine show/hide methods. Transition listeners are removed with their discarded DOM. Browser verification records repeated lifecycle DOM, global event listener and active observer stability. It does **not** establish complete heap/GC recovery. The hook depends on pinned 6.0.0 internals and must be re-audited before an engine upgrade. + +## Build and examples + +From the repository root: + +```sh +npm run typecheck:org-chart +npm run test:org-chart +npm run build:org-chart-example +npm run dev:org-chart-example +``` + +Open `http://127.0.0.1:4178/index.html` for bundled ESM + external JSON. Open `examples/org-chart/dist/classic.html` directly for classic bundle + inline fictitious JSON. Both use package build outputs, never Spike code. Example controls demonstrate reload, employee movement/addition, destroy and recreation. Build artifacts are Git-ignored; source fixtures are checked in. + +`npm run test:org-chart-browser` expects Playwright supplied by the verification environment. If not installed locally, set `ORGCHART_PLAYWRIGHT_MODULE` to its absolute `index.mjs`/`index.js` and optionally `ORGCHART_CHROME` to a Chrome executable. Production has no browser-test dependency. Evidence is written under `test-results/org-chart-browser/`. + +The React wrapper owns only ref/effect lifecycle, source/props updates and cleanup, including forwarding lookup and Auto view options; HTML and React render the same Core. Standalone `createOrgChartTree`, `createPositionSummary`, `createAffiliationSummary` and `createStatusSummary` factories are exported at the package root and return the existing `OrgChartHandle`. The four public OrgChart Stories are Logo · Personnel Summary, Department Colors, Full Composition and Expanded OrgChart. Original verification Stories and independent component Stories remain available under the excluded `internal` tag. +# P9.3 Auto 소속 변경 + +P9.4부터 `dragDropEnabled?: boolean`의 기본값은 `false`다. 아래 포인터·키보드· +`handle.move()` 소속 변경은 `dragDropEnabled: true`일 때만 허용하며 OFF에서는 +`EDITING_DISABLED` 결과를 반환한다. 순수 데이터 helper의 이동 검증·적용 계약은 유지한다. +`handle.setDragDropEnabled(boolean)` / `handle.getDragDropEnabled()`로 같은 Auto +인스턴스를 갱신·조회한다. OFF 전환은 진행 중 이동과 강조·Pointer capture를 취소하고 +완료된 이동·데이터·Zoom/Pan·접기 상태를 유지한다. Source 재요청·레이아웃 재생성은 없다. +Fixed는 생성 옵션을 무시하며 setter는 Auto 전용 사용 오류를 반환한다. + +Expanded Auto는 빈 공간 Pan과 직원 카드·그룹 제목 Drag를 구분한다. 6px 이동부터 +Drag를 시작하며 단순 클릭은 기존 정보 팝업·접기 동작이다. `Alt+M` → 방향키 → +Enter로도 이동하며 Esc로 취소한다. Fixed에는 편집을 적용하지 않는다. + +`handle.move({ kind: 'employee' | 'organization', sourceId, targetOrganizationId })`는 +포인터·키보드와 같은 검증 정책을 사용한다. `handle.getData()`는 현재 Auto의 동결된 +데이터를 반환하며 `onMove(result)`로 성공(`moved: true`)·거절 결과를 받는다. + +회사 간 직원 이동은 `organizationId`와 `affiliationId`를 대상 조직·회사로 변경한다. +회사 간 그룹 이동은 `parentId`, 전체 하위 조직의 `companyId`, 해당 조직 직원들의 +`affiliationId`를 함께 변경한다. 같은 회사 내 이동은 기존 관계사 소속을 유지한다. +회사 Root·조직장 직원·자기 자신·자손으로의 이동은 제한한다. 성공 후 기존 엔진으로 +재배치하며 대상 회사 프리셋과 새로운 조직/관계사 인원 집계를 적용한다. + +데이터는 브라우저 인스턴스에만 유지한다. 회사 표시 선택은 이동 내용을 보존하고, +새 source의 reload/update는 새 데이터로 대체한다. JSON 파일·서버 저장은 수행하지 않는다. diff --git a/packages/org-chart/build.mjs b/packages/org-chart/build.mjs new file mode 100644 index 0000000..7ba49e4 --- /dev/null +++ b/packages/org-chart/build.mjs @@ -0,0 +1,40 @@ +import { build } from 'vite'; +import { cp, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; + +const root = fileURLToPath(new URL('./', import.meta.url)); +const require = createRequire(import.meta.url); +const engineRoot = path.dirname(require.resolve('orgchart/package.json')); +const engine = JSON.parse(await readFile(path.join(engineRoot, 'package.json'), 'utf8')); +if (engine.version !== '6.0.0') throw new Error('Expected pinned orgchart 6.0.0'); +// Keep upstream UMD evaluation inside the deferred import; bundling removes bare imports. +for (const format of ['es', 'iife']) { + await build({ + configFile: false, root, publicDir: false, + build: { + target: 'es2022', outDir: 'dist', emptyOutDir: false, minify: false, + lib: { + entry: path.join(root, 'src/index.ts'), name: 'HMOrgChart', formats: [format], + fileName: () => format === 'es' ? 'index.js' : 'orgchart.browser.js', + }, + rolldownOptions: { output: { codeSplitting: false } }, + }, + }); +} +const typescriptManifest = require.resolve('typescript/package.json'); +const typescript = JSON.parse(await readFile(typescriptManifest, 'utf8')); +execFileSync(process.execPath, [path.resolve(path.dirname(typescriptManifest), typescript.bin.tsc), '-p', path.join(root, 'tsconfig.build.json')], { stdio: 'inherit' }); +// Ship one self-contained public CSS asset. ?url and file:// consumers cannot +// rely on source-relative imports or a bundler to discover private style files. +const coreStyles = await readFile(path.join(root, 'src/styles/org-chart.css'), 'utf8'); +const inlinedStyles = await Promise.all(['design-tokens.css', 'color-presets.css', 'employee-card.css', 'summary-sections.css', 'org-chart-content.css', 'org-chart-readonly.css', 'org-chart-p3-summary.css', 'employee-info-dialog.css', 'org-chart-expanded.css'].map(async file => [file, await readFile(path.join(root, 'src/styles', file), 'utf8')])); +let publicStyles = coreStyles; +for (const [file, css] of inlinedStyles) publicStyles = publicStyles.replace(`@import './${file}';`, css); +await writeFile(path.join(root, 'dist/style.css'), publicStyles); +await cp(path.join(engineRoot, 'dist/css/orgchart.css'), path.join(root, 'dist/engine.css')); +await mkdir(path.join(root, 'dist/licenses'), { recursive: true }); +await cp(path.join(engineRoot, 'LICENSE'), path.join(root, 'dist/licenses/orgchart-MIT.txt')); +await writeFile(path.join(root, 'dist/licenses/README.txt'), 'Bundled engine: dabeng orgchart 6.0.0 (MIT). Upstream UMD references html2canvas/jspdf only for export, which this Core disables. Those export libraries are not bundled or supported. json-digger is not imported by the shipped UMD. The npm dependency retains the upstream dependency manifest.\n'); diff --git a/packages/org-chart/package.json b/packages/org-chart/package.json new file mode 100644 index 0000000..d767c41 --- /dev/null +++ b/packages/org-chart/package.json @@ -0,0 +1,23 @@ +{ + "name": "@hm-design/org-chart", + "version": "0.1.0", + "description": "Framework-independent HM OrgChart core, data contract and JSON sources.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" }, + "./style.css": "./dist/style.css", + "./engine.css": "./dist/engine.css", + "./browser": "./dist/orgchart.browser.js" + }, + "files": ["dist", "README.md"], + "sideEffects": ["**/*.css"], + "scripts": { + "typecheck": "tsc -p tsconfig.json", + "build": "node build.mjs", + "test": "node test.mjs", + "prepack": "npm run build" + }, + "dependencies": { "orgchart": "6.0.0" } +} diff --git a/packages/org-chart/src/assets/affiliations/README.md b/packages/org-chart/src/assets/affiliations/README.md new file mode 100644 index 0000000..952ee3a --- /dev/null +++ b/packages/org-chart/src/assets/affiliations/README.md @@ -0,0 +1,21 @@ +# 관계사 로고 출처 + +사용자가 공식 접근 가능한 Figma Asset 재사용을 명시적으로 요청하여, 2026-10-12에 아래 Figma 파일의 관계사별 집계 Footer에서 가져온 PNG를 보존했습니다. 원본 로고를 그대로 사용하며 OrgChart 공통 카드·Footer는 같은 로컬 Asset을 참조합니다. + +- Figma file key: `Plr6RRLi5XnLIn60x9aG1s` +- Footer 원본: [관계사별 집계 Footer, node 22:179](https://www.figma.com/design/Plr6RRLi5XnLIn60x9aG1s/?node-id=22-179) +- 확인일: `2026-10-12` (KST) +- 사용 범위: OrgChart 내부 관계사 표시. 실제 인물 프로필 사진은 다운로드하지 않았습니다. +- 배포 경로: 로컬 PNG를 `?inline` import하여 번들에 포함합니다. Figma 임시 원격 다운로드 URL은 배포하지 않습니다. +- 로고 필드·Employee JSON 데이터 계약은 추가하지 않습니다. 기존 관계사 ID와 내부 Asset 매핑을 사용하며, 매핑이 없는 ID나 이미지 오류는 관계사명 모노그램으로 대체합니다. +- 접근 가능한 원본 Footer의 시각적 확인 결과를 근거로 합니다. 통합 카드·Footer의 최종 브라우저 검증은 별도 통합 검증 기록을 따릅니다. + +원본 Node 및 저장 파일 SHA-256 (모두 0바이트 초과 확인): + +- `hanmac.png` — node `22:181`; 11,828 bytes; `db154af30d5ef875d097006c92dc5bdd70962ed2538cfdd385c87b39cecb3cfa` +- `saman.png` — node `22:187`; 20,584 bytes; `5167420984ef7b0603f8203f35a99ffcfcbc7981186beb785ce38a1665fda60c` +- `jangheon.png` — node `22:193`; 56,063 bytes; `6b4047ae6ecc451121470885d1f382b3f499c838968a02120d6d3dd643503e63` +- `jangheon-industry.png` — node `22:199`; 16,683 bytes; `6d12832bfe067414889d6db2ec4ee4910f5d2e61dbd86b9edd1516ca2a51733d` +- `ptc.png` — node `22:205`; 7,613 bytes; `12c22132e83be1f4feb23e5f761b73a85366ae399387cd6b40782d501dd7cadb` +- `halla.png` — node `22:211`; 9,606 bytes; `f92408ae7f129e3427c1456de0d9da855bbd652e72b97728e3cdc6b11fa6d12b` +- `baron.png` — node `22:217`; 13,175 bytes; `089014f781d6b741397388eb87c70b9817e848eb5960fc5b7933033b724ca351` diff --git a/packages/org-chart/src/assets/affiliations/baron.png b/packages/org-chart/src/assets/affiliations/baron.png new file mode 100644 index 0000000..944d77d Binary files /dev/null and b/packages/org-chart/src/assets/affiliations/baron.png differ diff --git a/packages/org-chart/src/assets/affiliations/halla.png b/packages/org-chart/src/assets/affiliations/halla.png new file mode 100644 index 0000000..8646bfc Binary files /dev/null and b/packages/org-chart/src/assets/affiliations/halla.png differ diff --git a/packages/org-chart/src/assets/affiliations/hanmac.png b/packages/org-chart/src/assets/affiliations/hanmac.png new file mode 100644 index 0000000..a644b49 Binary files /dev/null and b/packages/org-chart/src/assets/affiliations/hanmac.png differ diff --git a/packages/org-chart/src/assets/affiliations/jangheon-industry.png b/packages/org-chart/src/assets/affiliations/jangheon-industry.png new file mode 100644 index 0000000..7bde5b7 Binary files /dev/null and b/packages/org-chart/src/assets/affiliations/jangheon-industry.png differ diff --git a/packages/org-chart/src/assets/affiliations/jangheon.png b/packages/org-chart/src/assets/affiliations/jangheon.png new file mode 100644 index 0000000..624b378 Binary files /dev/null and b/packages/org-chart/src/assets/affiliations/jangheon.png differ diff --git a/packages/org-chart/src/assets/affiliations/ptc.png b/packages/org-chart/src/assets/affiliations/ptc.png new file mode 100644 index 0000000..199520f Binary files /dev/null and b/packages/org-chart/src/assets/affiliations/ptc.png differ diff --git a/packages/org-chart/src/assets/affiliations/saman.png b/packages/org-chart/src/assets/affiliations/saman.png new file mode 100644 index 0000000..af81449 Binary files /dev/null and b/packages/org-chart/src/assets/affiliations/saman.png differ diff --git a/packages/org-chart/src/assets/cards/README.md b/packages/org-chart/src/assets/cards/README.md new file mode 100644 index 0000000..0d09d84 --- /dev/null +++ b/packages/org-chart/src/assets/cards/README.md @@ -0,0 +1,13 @@ +# OrgChart 1단계 카드 자산 + +- `대표자.svg`: 사용자가 본문으로 전달한 SVG. 최상단 독립 `topLeader` 전용, 146×74. 부서 자산 매핑에 넣지 않는다. +- `사업관리팀.svg`, `사업관리팀-sm.svg`: 사용자 PNG에서 색상·알파·내측 그라데이션 방향을 추출한 벡터. 조직장 146×68 / 일반 직원 108×52. PNG를 SVG에 삽입하지 않는다. +- 경영기획팀 / 디자인기획팀 / 솔루션통합팀 / 인재성장팀 / 통합시스템 디비전의 큰·작은 SVG 10개: `D:\디자인시스템\카드 이미지`에서 원본 바이트를 그대로 복사했다. SHA-256 일치는 `test-results/org-chart-card-design/browser.json`에서 확인한다. + +부서 매핑은 `src/core/department-card-assets.ts`, 대표자 전용 매핑은 `src/core/representative-card-asset.ts`에 있다. `solution-sales`에 솔루션통합팀 자산을 쓰는 것은 이번 작업에서 사용자가 명시 승인했다. 하위 조직은 실제 `parentId`로 상위 부서 자산을 찾으며 데이터 JSON을 수정하지 않는다. + +사업관리 PNG 원본은 확장자가 없는 `D:\디자인시스템\카드 이미지\사업관리`(PNG, 408×191)다. 추출 색은 외곽 `#DEE6F0 → #A8BBD1 → #577BA5`(마지막 opacity 0.6), 내측 `#FFFFFF → #E0E6F4`다. 경영기획과 색상 끝점은 같지만 사업관리 PNG의 투명도와 내측 그라데이션 방향을 별도로 유지했다. + +재생성: `examples/org-chart/create-business-card-assets.py <원본 PNG 경로>`. Pillow/numpy가 필요하다. 추출 근거는 `business-management-colors.json`, Chrome 실제 래스터 비교는 `business-management-comparison.json`에 보존한다. 신규 벡터의 외곽·곡선 path는 같은 크기의 경영기획 SVG를 재사용한다. + +SVG는 `?inline`으로 번들에 포함되고 별도 장식 이미지 레이어로 렌더링된다. 직원 이름·직위·직책·상태·프로필·관계사 로고는 기존 데이터 레이어다. 파견/휴직 보조 영역에는 새 자산을 전달하지 않는다. 고정 카드 크기의 소비자 override는 이 원본 비율 variant에 적용하지 않는다. diff --git a/packages/org-chart/src/assets/cards/경영기획팀-sm.svg b/packages/org-chart/src/assets/cards/경영기획팀-sm.svg new file mode 100644 index 0000000..908c772 --- /dev/null +++ b/packages/org-chart/src/assets/cards/경영기획팀-sm.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/org-chart/src/assets/cards/경영기획팀.svg b/packages/org-chart/src/assets/cards/경영기획팀.svg new file mode 100644 index 0000000..6537692 --- /dev/null +++ b/packages/org-chart/src/assets/cards/경영기획팀.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/org-chart/src/assets/cards/대표자.svg b/packages/org-chart/src/assets/cards/대표자.svg new file mode 100644 index 0000000..54e2709 --- /dev/null +++ b/packages/org-chart/src/assets/cards/대표자.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/org-chart/src/assets/cards/디자인기획팀-sm.svg b/packages/org-chart/src/assets/cards/디자인기획팀-sm.svg new file mode 100644 index 0000000..0e06f6f --- /dev/null +++ b/packages/org-chart/src/assets/cards/디자인기획팀-sm.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/org-chart/src/assets/cards/디자인기획팀.svg b/packages/org-chart/src/assets/cards/디자인기획팀.svg new file mode 100644 index 0000000..851c5ed --- /dev/null +++ b/packages/org-chart/src/assets/cards/디자인기획팀.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/org-chart/src/assets/cards/사업관리팀-sm.svg b/packages/org-chart/src/assets/cards/사업관리팀-sm.svg new file mode 100644 index 0000000..721f00e --- /dev/null +++ b/packages/org-chart/src/assets/cards/사업관리팀-sm.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/org-chart/src/assets/cards/사업관리팀.svg b/packages/org-chart/src/assets/cards/사업관리팀.svg new file mode 100644 index 0000000..0169c47 --- /dev/null +++ b/packages/org-chart/src/assets/cards/사업관리팀.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/org-chart/src/assets/cards/솔루션통합팀-sm.svg b/packages/org-chart/src/assets/cards/솔루션통합팀-sm.svg new file mode 100644 index 0000000..baa0845 --- /dev/null +++ b/packages/org-chart/src/assets/cards/솔루션통합팀-sm.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/org-chart/src/assets/cards/솔루션통합팀.svg b/packages/org-chart/src/assets/cards/솔루션통합팀.svg new file mode 100644 index 0000000..4aaa71e --- /dev/null +++ b/packages/org-chart/src/assets/cards/솔루션통합팀.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/org-chart/src/assets/cards/인재성장팀-sm.svg b/packages/org-chart/src/assets/cards/인재성장팀-sm.svg new file mode 100644 index 0000000..450d299 --- /dev/null +++ b/packages/org-chart/src/assets/cards/인재성장팀-sm.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/org-chart/src/assets/cards/인재성장팀.svg b/packages/org-chart/src/assets/cards/인재성장팀.svg new file mode 100644 index 0000000..574b620 --- /dev/null +++ b/packages/org-chart/src/assets/cards/인재성장팀.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/org-chart/src/assets/cards/통합시스템 디비전-sm.svg b/packages/org-chart/src/assets/cards/통합시스템 디비전-sm.svg new file mode 100644 index 0000000..97374d4 --- /dev/null +++ b/packages/org-chart/src/assets/cards/통합시스템 디비전-sm.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/org-chart/src/assets/cards/통합시스템 디비전.svg b/packages/org-chart/src/assets/cards/통합시스템 디비전.svg new file mode 100644 index 0000000..cdd6c5b --- /dev/null +++ b/packages/org-chart/src/assets/cards/통합시스템 디비전.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/org-chart/src/assets/person-placeholder.svg b/packages/org-chart/src/assets/person-placeholder.svg new file mode 100644 index 0000000..cb28b95 --- /dev/null +++ b/packages/org-chart/src/assets/person-placeholder.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/org-chart/src/assets/workdesk/1-8-4x-1@2x.png b/packages/org-chart/src/assets/workdesk/1-8-4x-1@2x.png new file mode 100644 index 0000000..e29afa3 Binary files /dev/null and b/packages/org-chart/src/assets/workdesk/1-8-4x-1@2x.png differ diff --git a/packages/org-chart/src/assets/workdesk/Frame2@3x.png b/packages/org-chart/src/assets/workdesk/Frame2@3x.png new file mode 100644 index 0000000..801a1cd Binary files /dev/null and b/packages/org-chart/src/assets/workdesk/Frame2@3x.png differ diff --git a/packages/org-chart/src/assets/workdesk/Group.svg b/packages/org-chart/src/assets/workdesk/Group.svg new file mode 100644 index 0000000..08033fa --- /dev/null +++ b/packages/org-chart/src/assets/workdesk/Group.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/org-chart/src/assets/workdesk/README.md b/packages/org-chart/src/assets/workdesk/README.md new file mode 100644 index 0000000..8489f4e --- /dev/null +++ b/packages/org-chart/src/assets/workdesk/README.md @@ -0,0 +1,11 @@ +# Workdesk Full Composition artwork + +These nine files are copied byte-for-byte from the read-only source +`D:\디자인시스템\gpd-workdesk\sample\조직도_v1.3\public`. +They are embedded by `?inline` imports in the shared Core ESM/IIFE bundles, so +HTML and React need no external Workdesk server or asset URL. + +The compact `screenD` hub paths and original arrow placements are implemented +in `core/workdesk-artwork.ts`. Lower organization relationships retain the +existing engine routes because the source lower SVG block is hidden. +Approved card SVGs and company logo files are untouched. diff --git a/packages/org-chart/src/assets/workdesk/bg_main.png b/packages/org-chart/src/assets/workdesk/bg_main.png new file mode 100644 index 0000000..99546b7 Binary files /dev/null and b/packages/org-chart/src/assets/workdesk/bg_main.png differ diff --git a/packages/org-chart/src/assets/workdesk/bg_sky.png b/packages/org-chart/src/assets/workdesk/bg_sky.png new file mode 100644 index 0000000..f392c6c Binary files /dev/null and b/packages/org-chart/src/assets/workdesk/bg_sky.png differ diff --git a/packages/org-chart/src/assets/workdesk/team-arrow-blue-purple.png b/packages/org-chart/src/assets/workdesk/team-arrow-blue-purple.png new file mode 100644 index 0000000..b700d16 Binary files /dev/null and b/packages/org-chart/src/assets/workdesk/team-arrow-blue-purple.png differ diff --git a/packages/org-chart/src/assets/workdesk/team-arrow-blue-short.png b/packages/org-chart/src/assets/workdesk/team-arrow-blue-short.png new file mode 100644 index 0000000..b1374e5 Binary files /dev/null and b/packages/org-chart/src/assets/workdesk/team-arrow-blue-short.png differ diff --git a/packages/org-chart/src/assets/workdesk/team-arrow-purple-teal.png b/packages/org-chart/src/assets/workdesk/team-arrow-purple-teal.png new file mode 100644 index 0000000..d401e5c Binary files /dev/null and b/packages/org-chart/src/assets/workdesk/team-arrow-purple-teal.png differ diff --git a/packages/org-chart/src/assets/workdesk/team-arrow-teal-green.png b/packages/org-chart/src/assets/workdesk/team-arrow-teal-green.png new file mode 100644 index 0000000..029df1e Binary files /dev/null and b/packages/org-chart/src/assets/workdesk/team-arrow-teal-green.png differ diff --git a/packages/org-chart/src/company-color-presets.test.ts b/packages/org-chart/src/company-color-presets.test.ts new file mode 100644 index 0000000..3274c6b --- /dev/null +++ b/packages/org-chart/src/company-color-presets.test.ts @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { companyColorPresets, getCompanyColorPreset } from './index.js'; +import type { CompanyColorPreset, CompanyId } from './index.js'; + +// Independent acceptance values from the P5-0 request, including the secondary order. +const expected: ReadonlyArray = [ + { companyId: 'hanmac', companyName: '한맥기술', primary: '#CB2E28', secondary: ['#1D4289'] }, + { companyId: 'saman', companyName: '삼안', primary: '#F08300', secondary: ['#6D6C6C'] }, + { companyId: 'jangheon-industry', companyName: '장헌산업', primary: '#4580A6', secondary: ['#92BED4'] }, + { companyId: 'jangheon', companyName: '장헌', primary: '#1D418D', secondary: ['#AE232A'] }, + { companyId: 'ptc', companyName: '피티씨', primary: '#84338E', secondary: ['#F7AF00', '#0066B5'] }, + { companyId: 'halla', companyName: '한라산업개발', primary: '#0068B7', secondary: ['#231815'] }, + { companyId: 'baron', companyName: '바론컨설턴트', primary: '#0C367F', secondary: [] }, +]; + +test('the public registry contains exactly the seven existing company IDs', () => { + assert.deepEqual(Object.keys(companyColorPresets).sort(), expected.map(preset => preset.companyId).sort()); +}); + +for (const preset of expected) { + test(`${preset.companyName}: public lookup preserves the approved ID, name and exact colors`, () => { + assert.deepEqual(getCompanyColorPreset(preset.companyId), preset); + assert.equal(getCompanyColorPreset(preset.companyId), companyColorPresets[preset.companyId]); + }); +} + +test('unknown IDs and inherited property names never select a company or fallback color', () => { + for (const id of ['', 'unknown', 'hanmaek', 'HANMAC', '__proto__', 'constructor', 'toString']) { + assert.equal(getCompanyColorPreset(id), undefined); + } +}); + +test('shared preset values cannot be mutated by HTML or React consumers', () => { + assert.ok(Object.isFrozen(companyColorPresets)); + for (const { companyId } of expected) { + const preset = getCompanyColorPreset(companyId); + assert.ok(Object.isFrozen(preset)); + assert.ok(Object.isFrozen(preset.secondary)); + assert.equal(Reflect.set(preset, 'primary', '#000000'), false); + assert.equal(Reflect.set(preset.secondary, '0', '#000000'), false); + assert.deepEqual(getCompanyColorPreset(companyId), expected.find(item => item.companyId === companyId)); + } +}); diff --git a/packages/org-chart/src/company-color-presets.ts b/packages/org-chart/src/company-color-presets.ts new file mode 100644 index 0000000..7e27fb3 --- /dev/null +++ b/packages/org-chart/src/company-color-presets.ts @@ -0,0 +1,57 @@ +/** Company colors reserved for the future Auto Layout view. + * The fixed Workdesk composition continues to use core/style-presets.ts. + * These values do not apply CSS, change rendering or depend on HM Theme. */ +export interface CompanyColorPreset { + readonly companyId: string; + readonly companyName: string; + readonly primary: string; + readonly secondary: readonly string[]; +} + +function freezePreset(preset: CompanyColorPreset): CompanyColorPreset { + return Object.freeze({ ...preset, secondary: Object.freeze([...preset.secondary]) }); +} + +/** IDs match the existing affiliation IDs. Only user-approved colors belong here. */ +export const companyColorPresets = Object.freeze({ + hanmac: freezePreset({ + companyId: 'hanmac', companyName: '한맥기술', + primary: '#CB2E28', secondary: ['#1D4289'], + }), + saman: freezePreset({ + companyId: 'saman', companyName: '삼안', + primary: '#F08300', secondary: ['#6D6C6C'], + }), + 'jangheon-industry': freezePreset({ + companyId: 'jangheon-industry', companyName: '장헌산업', + primary: '#4580A6', secondary: ['#92BED4'], + }), + jangheon: freezePreset({ + companyId: 'jangheon', companyName: '장헌', + primary: '#1D418D', secondary: ['#AE232A'], + }), + ptc: freezePreset({ + companyId: 'ptc', companyName: '피티씨', + primary: '#84338E', secondary: ['#F7AF00', '#0066B5'], + }), + halla: freezePreset({ + companyId: 'halla', companyName: '한라산업개발', + primary: '#0068B7', secondary: ['#231815'], + }), + baron: freezePreset({ + companyId: 'baron', companyName: '바론컨설턴트', + primary: '#0C367F', secondary: [], + }), +}); + +export type CompanyId = keyof typeof companyColorPresets; + +/** HTML/React share this lookup. Unknown IDs return undefined without a fallback. + * @example const { primary, secondary } = getCompanyColorPreset('hanmac'); */ +export function getCompanyColorPreset(companyId: CompanyId): CompanyColorPreset; +export function getCompanyColorPreset(companyId: string): CompanyColorPreset | undefined; +export function getCompanyColorPreset(companyId: string): CompanyColorPreset | undefined { + return Object.hasOwn(companyColorPresets, companyId) + ? companyColorPresets[companyId as CompanyId] + : undefined; +} diff --git a/packages/org-chart/src/components.ts b/packages/org-chart/src/components.ts new file mode 100644 index 0000000..4355797 --- /dev/null +++ b/packages/org-chart/src/components.ts @@ -0,0 +1,24 @@ +import { createOrgChartView } from './core/index.js'; +import type { OrgChartHandle, OrgChartOptions } from './core/types.js'; + +/** Standalone summaries share the same source, validation, update/reload and + * dispose contract as OrgChart. Only the independent core CSS is required. */ +export interface OrgChartSummaryComponentOptions extends Pick { + stylesheetUrl: string; +} +function summaryOptions(options: OrgChartSummaryComponentOptions): OrgChartOptions { + return { ...options, stylesheetUrls: { core: options.stylesheetUrl, engine: '' } }; +} +export function createPositionSummary(options: OrgChartSummaryComponentOptions): OrgChartHandle { + return createOrgChartView(summaryOptions(options), 'positionSummary'); +} +export function createAffiliationSummary(options: OrgChartSummaryComponentOptions): OrgChartHandle { + return createOrgChartView(summaryOptions(options), 'affiliationSummary'); +} +export function createStatusSummary(options: OrgChartSummaryComponentOptions): OrgChartHandle { + return createOrgChartView(summaryOptions(options), 'statusSummary'); +} +/** Tree-only component: no title, leader composition, hub or summary modules. */ +export function createOrgChartTree(options: OrgChartOptions): OrgChartHandle { + return createOrgChartView(options, 'tree'); +} diff --git a/packages/org-chart/src/core/affiliation-bar.ts b/packages/org-chart/src/core/affiliation-bar.ts new file mode 100644 index 0000000..606c72b --- /dev/null +++ b/packages/org-chart/src/core/affiliation-bar.ts @@ -0,0 +1,37 @@ +import { selectAffiliationSummary } from '../data/content-selectors.js'; +import type { RenderContext } from './render.js'; +import { createAffiliationMark } from './affiliation-mark.js'; + +/** Read-only composition presenter: one untitled line of logo · name · count and + * the total. The standalone AffiliationSummary keeps its titled card layout; + * both use the same selector and Summary counts. */ +export function createAffiliationBar(document: Document, context: RenderContext, signal: AbortSignal): HTMLElement { + const section = document.createElement('section'); + section.className = 'hm-orgchart-affiliation-bar'; + section.dataset.contentSection = 'affiliation-bar'; + section.setAttribute('aria-label', '관계사별 인원 현황'); + const list = document.createElement('dl'); + list.className = 'hm-orgchart-affiliation-bar-list'; + for (const { affiliation, count } of selectAffiliationSummary(context.data, context.summary)) { + const item = document.createElement('div'); + item.className = 'hm-orgchart-affiliation-bar-item'; + item.dataset.affiliationId = affiliation.id; + item.dataset.employeeCount = String(count); + const name = document.createElement('dt'); + name.textContent = affiliation.name; + name.prepend(createAffiliationMark(document, affiliation.id, affiliation.name, signal)); + const value = document.createElement('dd'); + value.textContent = `${count}명`; + item.append(name, value); + list.append(item); + } + const total = document.createElement('div'); + total.className = 'hm-orgchart-affiliation-bar-item hm-orgchart-affiliation-bar-total'; + total.dataset.totalCount = String(context.summary.raw.total); + const label = document.createElement('dt'); label.textContent = '총계'; + const value = document.createElement('dd'); value.textContent = `${context.summary.raw.total}명`; + total.append(label, value); + list.append(total); + section.append(list); + return section; +} diff --git a/packages/org-chart/src/core/affiliation-mark.ts b/packages/org-chart/src/core/affiliation-mark.ts new file mode 100644 index 0000000..021c86b --- /dev/null +++ b/packages/org-chart/src/core/affiliation-mark.ts @@ -0,0 +1,45 @@ +import hanmac from '../assets/affiliations/hanmac.png?inline'; +import saman from '../assets/affiliations/saman.png?inline'; +import jangheon from '../assets/affiliations/jangheon.png?inline'; +import jangheonIndustry from '../assets/affiliations/jangheon-industry.png?inline'; +import ptc from '../assets/affiliations/ptc.png?inline'; +import halla from '../assets/affiliations/halla.png?inline'; +import baron from '../assets/affiliations/baron.png?inline'; + +// Asset mapping is an internal presentation concern; the employee/affiliation +// JSON contract continues to use only its existing id and name fields. +const logos = new Map([ + ['hanmac', hanmac], ['saman', saman], ['jangheon', jangheon], + ['jangheon-industry', jangheonIndustry], ['ptc', ptc], ['halla', halla], ['baron', baron], +]); + +/** Shared by employee cards and the affiliation summary footer. */ +export function createAffiliationMark( + document: Document, affiliationId: string, name: string, signal?: AbortSignal, +): HTMLElement { + const mark = document.createElement('span'); + mark.className = 'hm-orgchart-affiliation-mark'; + mark.dataset.affiliationId = affiliationId; + mark.setAttribute('role', 'img'); + mark.setAttribute('aria-label', `관계사 ${name}`); + mark.title = name; + const fallback = document.createElement('span'); + fallback.className = 'hm-orgchart-affiliation-fallback'; + fallback.textContent = Array.from(name.trim()).slice(0, 2).join('') || '?'; + mark.append(fallback); + + const logo = logos.get(affiliationId); + mark.dataset.logoAvailable = String(Boolean(logo)); + if (logo) { + const image = document.createElement('img'); + image.alt = ''; + image.decoding = 'async'; + image.addEventListener('error', () => { + image.remove(); + mark.dataset.logoAvailable = 'false'; + }, { once: true, ...(signal ? { signal } : {}) }); + image.src = logo; + mark.append(image); + } + return mark; +} diff --git a/packages/org-chart/src/core/affiliation-summary.ts b/packages/org-chart/src/core/affiliation-summary.ts new file mode 100644 index 0000000..a230b81 --- /dev/null +++ b/packages/org-chart/src/core/affiliation-summary.ts @@ -0,0 +1,42 @@ +import { selectAffiliationSummary } from '../data/content-selectors.js'; +import type { RenderContext } from './render.js'; +import { createAffiliationMark } from './affiliation-mark.js'; + +export function createAffiliationSummary(document: Document, context: RenderContext): HTMLElement { + const section = document.createElement('section'); + section.className = 'hm-orgchart-affiliation-summary'; + section.dataset.contentSection = 'affiliation-summary'; + section.setAttribute('aria-label', '관계사별 원본 인원 현황'); + const heading = document.createElement('h3'); + heading.className = 'hm-orgchart-summary-title'; + heading.textContent = '관계사별 인원 현황'; + const total = document.createElement('p'); + total.className = 'hm-orgchart-raw-total'; + total.textContent = `Raw Count · 전체 ${context.summary.raw.total}명`; + section.append(heading, total); + const list = document.createElement('dl'); + list.className = 'hm-orgchart-affiliation-list'; + for (const { affiliation, count } of selectAffiliationSummary(context.data, context.summary)) { + const item = document.createElement('div'); + item.className = 'hm-orgchart-affiliation-item'; + item.dataset.affiliationId = affiliation.id; + const name = document.createElement('dt'); + name.className = 'hm-orgchart-affiliation-name'; + // The contract has no logo field. The declared name is the safe textual fallback. + name.textContent = affiliation.name; + name.prepend(createAffiliationMark(document, affiliation.id, affiliation.name)); + const value = document.createElement('dd'); + value.className = 'hm-orgchart-affiliation-count'; + value.textContent = `${count}명`; + item.append(name, value); + list.append(item); + } + section.append(list); + if (!context.data.affiliations.length) { + const empty = document.createElement('p'); + empty.className = 'hm-orgchart-content-empty'; + empty.textContent = '등록된 관계사가 없습니다.'; + section.append(empty); + } + return section; +} diff --git a/packages/org-chart/src/core/auto-layout-policy.test.ts b/packages/org-chart/src/core/auto-layout-policy.test.ts new file mode 100644 index 0000000..42b7e38 --- /dev/null +++ b/packages/org-chart/src/core/auto-layout-policy.test.ts @@ -0,0 +1,88 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyInitialVisibility, autoEngineOptions, resolveAutoLayout, resolveInteractions } from './auto-layout-policy.js'; +import { createOrgChart } from './index.js'; +import { engineTree, type RenderContext } from './render.js'; +import type { Organization, OrganizationTreeNode } from '../data/index.js'; + +const organization = (id: string, parentId: string | null): Organization => ({ id, parentId, name: id, type: parentId ? 'team' : 'root', leaderId: null, displayOrder: 0 }); +const organizations = [organization('root', null), organization('a', 'root'), organization('b', 'root'), organization('a1', 'a'), organization('a2', 'a1')]; + +test('each independent layout policy selects compatible installed engine options', () => { + assert.deepEqual(autoEngineOptions(resolveAutoLayout()), { direction: 't2b', visibleLevel: 999 }); + assert.deepEqual(autoEngineOptions(resolveAutoLayout({ type: 'lr', initialVisibleDepth: 1 })), { direction: 'l2r', visibleLevel: 999 }); + for (const depth of [2, 3, 4] as const) assert.deepEqual(autoEngineOptions(resolveAutoLayout({ type: 'hybrid', hybridStartDepth: depth })), { direction: 't2b', visibleLevel: 999, verticalLevel: depth }); +}); + +test('collapse, Zoom and Pan have independent validated defaults', () => { + assert.deepEqual(resolveInteractions(), { collapse: true, zoom: true, pan: true }); + assert.deepEqual(resolveInteractions({ collapse: false }), { collapse: false, zoom: true, pan: true }); + assert.deepEqual(resolveInteractions({ zoom: false }), { collapse: true, zoom: false, pan: true }); + assert.deepEqual(resolveInteractions({ pan: false }), { collapse: true, zoom: true, pan: false }); + assert.throws(() => resolveInteractions({ zoom: 0 as unknown as boolean }), /interactions.zoom must be boolean/); +}); + +test('incompatible per-group/global/directional combinations fail explicitly', () => { + assert.throws(() => resolveAutoLayout({ type: 'lr', hybridOrganizationIds: ['a'] }), /supported only with TB/); + assert.throws(() => resolveAutoLayout({ type: 'hybrid', hybridOrganizationIds: ['a'] }), /supported only with TB/); + assert.throws(() => resolveAutoLayout({ initialVisibleDepth: 0 as 1 }), /initialVisibleDepth/); + assert.throws(() => resolveAutoLayout({ hybridStartDepth: 5 as 2 }), /hybridStartDepth/); + assert.throws(() => resolveAutoLayout({ hybridOrganizationIds: [''] }), /nonempty/); + const input = ['a', 'a']; + const resolved = resolveAutoLayout({ hybridOrganizationIds: input }); + input.push('b'); + assert.deepEqual(resolved.hybridOrganizationIds, ['a']); + assert.ok(Object.isFrozen(resolved.hybridOrganizationIds)); +}); + +test('initial depth is one-based and user expansion survives subsequent renders and new data', () => { + const collapsed = new Set(), initialized = new Set(); + applyInitialVisibility(organizations, collapsed, initialized, 2); + assert.deepEqual([...collapsed], ['a', 'b', 'a1', 'a2']); + collapsed.delete('a'); + applyInitialVisibility([...organizations, organization('b1', 'b')], collapsed, initialized, 2); + assert.equal(collapsed.has('a'), false); + assert.equal(collapsed.has('b1'), true); + applyInitialVisibility(organizations, collapsed, initialized, 'all', true); + assert.equal(collapsed.size, 0); + collapsed.add('a1'); + applyInitialVisibility(organizations.filter(item => item.id !== 'a2'), collapsed, initialized, 'all'); + assert.equal(collapsed.has('a1'), true); + assert.equal(initialized.has('a2'), false); +}); + +test('Depth 1 projection keeps independent company roots while collapsed descendants stay out of DOM', () => { + const roots: OrganizationTreeNode[] = [{ organization: organizations[0]!, children: [{ organization: organizations[1]!, children: [{ organization: organizations[3]!, children: [] }] }] }]; + const collapsed = new Set(), initialized = new Set(); + applyInitialVisibility(organizations, collapsed, initialized, 1); + const context = { orderedOrganizations: [], readOnly: true, collapsedOrganizations: collapsed, autoLayout: resolveAutoLayout() } as unknown as RenderContext; + const tree = engineTree(roots, 'initial', context); + assert.equal(tree.length, 1); + assert.equal(tree[0]!.children, undefined); + assert.deepEqual(context.orderedOrganizations.map(item => item.id), ['root']); +}); + +test('per-group Hybrid projection marks only the selected parent and leaves source immutable', () => { + const roots: OrganizationTreeNode[] = [{ organization: organizations[0]!, children: [{ organization: organizations[1]!, children: [{ organization: organizations[3]!, children: [] }] }, { organization: organizations[2]!, children: [] }] }]; + const before = structuredClone(roots); + const context = { orderedOrganizations: [], readOnly: true, autoLayout: resolveAutoLayout({ hybridOrganizationIds: ['a'] }) } as unknown as RenderContext; + const tree = engineTree(roots, 'groups', context); + assert.equal(tree[0]!.hybrid, undefined); + assert.equal(tree[0]!.level, 1); + assert.equal(tree[0]!.children![0]!.hybrid, true); + assert.equal(tree[0]!.children![0]!.level, 2); + assert.equal(tree[0]!.children![0]!.children![0]!.level, 3); + assert.equal(tree[0]!.children![1]!.hybrid, undefined); + assert.deepEqual(roots, before); +}); + +test('Fixed ignores Auto configuration and facade defaults keep existing TB fully expanded', () => { + const options = { dataSource: { load: async () => ({}) }, stylesheetUrls: { engine: '/engine.css', core: '/style.css' } }; + const fixed = createOrgChart({ ...options, autoLayout: { type: 'invalid' as 'tb' } }); + assert.equal(fixed.getAutoLayout().type, 'tb'); + fixed.destroy(); + const expanded = createOrgChart({ ...options, layoutMode: 'auto' }); + assert.equal(expanded.getAutoLayout().initialVisibleDepth, 'all'); + assert.equal(expanded.getAutoLayout().type, 'tb'); + expanded.destroy(); +}); diff --git a/packages/org-chart/src/core/auto-layout-policy.ts b/packages/org-chart/src/core/auto-layout-policy.ts new file mode 100644 index 0000000..9bbd5d8 --- /dev/null +++ b/packages/org-chart/src/core/auto-layout-policy.ts @@ -0,0 +1,59 @@ +import type { Organization } from '../data/index.js'; +import type { OrgChartAutoLayoutOptions, OrgChartAutoLayoutType, OrgChartInteractionOptions } from './types.js'; + +export function resolveInteractions(options: OrgChartInteractionOptions = {}): Readonly> { + for (const key of ['collapse', 'zoom', 'pan'] as const) { + if (options[key] !== undefined && typeof options[key] !== 'boolean') throw new TypeError(`interactions.${key} must be boolean`); + } + return Object.freeze({ collapse: options.collapse ?? true, zoom: options.zoom ?? true, pan: options.pan ?? true }); +} + +export interface ResolvedAutoLayout { + readonly type: OrgChartAutoLayoutType; + readonly hybridStartDepth: 2 | 3 | 4; + readonly initialVisibleDepth: 1 | 2 | 3 | 'all'; + readonly hybridOrganizationIds: readonly string[]; +} + +/** Match installed dabeng 6's mutually exclusive directional/vertical policies. */ +export function resolveAutoLayout(options: OrgChartAutoLayoutOptions = {}): ResolvedAutoLayout { + const type = options.type ?? 'tb'; + const hybridStartDepth = options.hybridStartDepth ?? 3; + const initialVisibleDepth = options.initialVisibleDepth ?? 'all'; + if (!['tb', 'lr', 'hybrid'].includes(type)) throw new TypeError('autoLayout.type must be tb, lr or hybrid'); + if (![2, 3, 4].includes(hybridStartDepth)) throw new TypeError('autoLayout.hybridStartDepth must be 2, 3 or 4'); + if (![1, 2, 3, 'all'].includes(initialVisibleDepth)) throw new TypeError('autoLayout.initialVisibleDepth must be 1, 2, 3 or all'); + if (options.hybridOrganizationIds !== undefined && (!Array.isArray(options.hybridOrganizationIds) || options.hybridOrganizationIds.some(id => typeof id !== 'string' || !id.trim()))) throw new TypeError('autoLayout.hybridOrganizationIds must contain nonempty organization IDs'); + const hybridOrganizationIds = Object.freeze([...new Set(options.hybridOrganizationIds ?? [])]); + if (type !== 'tb' && hybridOrganizationIds.length) throw new TypeError('Per-group hybridOrganizationIds are supported only with TB; clear them before selecting LR or Hybrid'); + return Object.freeze({ type, hybridStartDepth, initialVisibleDepth, hybridOrganizationIds }); +} + +export function autoEngineOptions(layout: ResolvedAutoLayout): { direction: 't2b' | 'l2r'; verticalLevel?: number; visibleLevel: number } { + // Core owns collapse state and prunes its projection. Upstream visibleLevel + // would retain hidden descendants that disagree with Core keyboard/state APIs. + return { direction: layout.type === 'lr' ? 'l2r' : 't2b', visibleLevel: 999, + ...(layout.type === 'hybrid' ? { verticalLevel: layout.hybridStartDepth } : {}) }; +} + +/** Apply the visibleLevel policy to new IDs only, keeping user changes stable. */ +export function applyInitialVisibility(organizations: readonly Organization[], collapsed: Set, initialized: Set, visibleDepth: ResolvedAutoLayout['initialVisibleDepth'], reset = false): void { + const valid = new Set(organizations.map(organization => organization.id)); + for (const id of collapsed) if (!valid.has(id) || reset) collapsed.delete(id); + for (const id of initialized) if (!valid.has(id) || reset) initialized.delete(id); + const byId = new Map(organizations.map(organization => [organization.id, organization])); + for (const organization of organizations) { + if (initialized.has(organization.id)) continue; + initialized.add(organization.id); + if (visibleDepth === 'all') continue; + let depth = 1; + let current = organization; + const seen = new Set([current.id]); + while (current.parentId) { + const parent = byId.get(current.parentId); + if (!parent || seen.has(parent.id)) break; + seen.add(parent.id); current = parent; depth++; + } + if (depth >= visibleDepth) collapsed.add(organization.id); + } +} diff --git a/packages/org-chart/src/core/contrast.test.ts b/packages/org-chart/src/core/contrast.test.ts new file mode 100644 index 0000000..0314740 --- /dev/null +++ b/packages/org-chart/src/core/contrast.test.ts @@ -0,0 +1,97 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { contrastRatio, evaluateGradientContrast, parseOpaqueColor, parseVerticalGradient, relativeLuminance, textContrastThreshold } from './contrast.js'; + +const near = (actual: number, expected: number, tolerance = 0.001) => assert.ok(Math.abs(actual - expected) < tolerance, `${actual} ≈ ${expected}`); +const integrated = 'linear-gradient(180deg, #8c67b6 0%, #5d3494 100%)'; +const design = 'linear-gradient(180deg, rgb(9, 167, 162) 0%, rgb(0, 132, 127) 100%)'; + +test('standard sRGB luminance and contrast use unrounded channels with symmetric ratios', () => { + near(relativeLuminance([0, 0, 0]), 0); + near(relativeLuminance([255, 255, 255]), 1); + near(contrastRatio([0, 0, 0], [255, 255, 255]), 21); + near(contrastRatio([16, 36, 59], [255, 255, 255]), 15.7005); + near(contrastRatio([89, 101, 117], [244, 247, 251]), 5.5160); + assert.equal(contrastRatio([16, 36, 59], [255, 255, 255]), contrastRatio([255, 255, 255], [16, 36, 59])); +}); + +test('color parser accepts opaque HEX and computed RGB, rejects transparent/unknown compositing', () => { + assert.deepEqual(parseOpaqueColor('#09a7a2'), [9, 167, 162]); + assert.deepEqual(parseOpaqueColor('#fff'), [255, 255, 255]); + assert.deepEqual(parseOpaqueColor('rgb(16 36 59)'), [16, 36, 59]); + assert.deepEqual(parseOpaqueColor('rgba(16, 36, 59, 1)'), [16, 36, 59]); + for (const value of ['rgba(0, 0, 0, 0.5)', '#ffffff80', 'transparent', 'red', 'rgb(100% 0% 0%)', 'color(display-p3 1 0 0)', 'rgb(256, 0, 0)']) assert.equal(parseOpaqueColor(value), null); +}); + +test('large text threshold uses measured size/weight and does not treat 15px bold as large', () => { + assert.equal(textContrastThreshold(15, 700), 4.5); + assert.equal(textContrastThreshold(19.5, 700), 3); + assert.equal(textContrastThreshold(18.6, 700), 4.5); + assert.equal(textContrastThreshold(14 * 96 / 72, 700), 3); + assert.equal(textContrastThreshold(19.5, 500), 4.5); + assert.equal(textContrastThreshold(24, 400), 3); +}); + +test('restricted gradient parser preserves RGB commas and explicit ordered percent stops', () => { + assert.equal(parseVerticalGradient(design)!.stops.length, 2); + assert.deepEqual(parseVerticalGradient(design)!.stops[0]!.color, [9, 167, 162]); + assert.equal(parseVerticalGradient('linear-gradient(180deg, #000 0%, #fff 25%, #000 100%)')!.stops[1]!.offset, 0.25); + for (const value of ['linear-gradient(90deg, #000 0%, #fff 100%)', 'linear-gradient(180deg in oklab, #000 0%, #fff 100%)', 'linear-gradient(180deg, #000, #fff)', 'linear-gradient(180deg, #000 0px, #fff 100px)', 'linear-gradient(180deg, #000 0%, #fff 50%)', 'linear-gradient(180deg, #000 0%, #fff 0%, #000 100%)', 'linear-gradient(180deg, #000 0%, #fff 100%), url(x)', 'url(image.png)', 'repeating-linear-gradient(180deg, #000 0%, #fff 100%)']) assert.equal(parseVerticalGradient(value), null, value); +}); + +test('browser-computed omitted default angle is equivalent to 180deg, without inferring omitted stops', () => { + const computed = 'linear-gradient(rgb(9, 167, 162) 0%, rgb(0, 132, 127) 100%)'; + assert.deepEqual(parseVerticalGradient(computed), parseVerticalGradient(design)); + const input = { foreground: '#fff', ranges: [{ top: 6, bottom: 24 }], backgroundHeight: 30 }; + assert.deepEqual(evaluateGradientContrast({ ...input, backgroundImage: computed }), evaluateGradientContrast({ ...input, backgroundImage: design })); + assert.equal(parseVerticalGradient('linear-gradient(rgb(9, 167, 162), rgb(0, 132, 127))'), null); +}); + +test('inset actual text model can pass while a conservative stop value is below 4.5', () => { + const result = evaluateGradientContrast({ foreground: '#fff', backgroundImage: integrated, ranges: [{ top: 6, bottom: 24 }], backgroundHeight: 30 }); + assert.equal(result.status, 'MODEL'); + near(result.stopMinimum!, 4.4391); + assert.ok(result.stopMinimum! < 4.5); + assert.ok(result.textRegionMinimum! > 5); + assert.ok(result.textRegionMinimum! > result.fullGradientMinimum!); +}); + +test('current large title and normal Full title thresholds remain independent for the same actual area', () => { + const result = evaluateGradientContrast({ foreground: '#fff', backgroundImage: design, ranges: [{ top: 6, bottom: 24 }], backgroundHeight: 30 }); + assert.ok(result.textRegionMinimum! >= textContrastThreshold(19.5, 700)); + assert.ok(result.textRegionMinimum! < textContrastThreshold(15, 700)); + const black = evaluateGradientContrast({ foreground: '#000', backgroundImage: design, ranges: [{ top: 0, bottom: 30 }], backgroundHeight: 30 }); + near(black.fullGradientMinimum!, 4.6070); + assert.ok(black.fullGradientMinimum! >= 4.5); +}); + +test('all actual line ranges are included, preserving a weak wrapped line', () => { + const first = evaluateGradientContrast({ foreground: '#fff', backgroundImage: integrated, ranges: [{ top: 60, bottom: 80 }], backgroundHeight: 100 }); + const wrapped = evaluateGradientContrast({ foreground: '#fff', backgroundImage: integrated, ranges: [{ top: 60, bottom: 80 }, { top: 0, bottom: 10 }], backgroundHeight: 100 }); + assert.ok(first.textRegionMinimum! > 4.5); + near(wrapped.textRegionMinimum!, wrapped.stopMinimum!); + assert.ok(wrapped.textRegionMinimum! < first.textRegionMinimum!); +}); + +test('stop minima never substitute for whole-gradient sampling when foreground meets an interior color', () => { + const result = evaluateGradientContrast({ foreground: '#777', backgroundImage: 'linear-gradient(180deg, #000 0%, #fff 100%)', ranges: [{ top: 40, bottom: 55 }], backgroundHeight: 100 }); + assert.ok(result.stopMinimum! > 4); + assert.ok(result.fullGradientMinimum! < 1.02); + assert.ok(result.textRegionMinimum! < 1.03); +}); + +test('unsupported backgrounds, alpha, missing/clipped ranges and unbounded sample requests remain NOT VERIFIED', () => { + const valid = { foreground: '#fff', backgroundImage: integrated, ranges: [{ top: 2, bottom: 20 }], backgroundHeight: 30 }; + for (const input of [ + { ...valid, foreground: 'rgba(255, 255, 255, 0.5)' }, { ...valid, backgroundImage: 'url(x)' }, + { ...valid, ranges: [] }, { ...valid, ranges: [{ top: -1, bottom: 20 }] }, + { ...valid, ranges: [{ top: 2, bottom: 31 }] }, { ...valid, ranges: [{ top: 20, bottom: 2 }] }, + { ...valid, backgroundHeight: NaN }, { ...valid, backgroundHeight: 0 }, { ...valid, sampleStep: 0 }, + { ...valid, sampleStep: 0.000001 }, + ]) { + const result = evaluateGradientContrast(input); + assert.equal(result.status, 'NOT VERIFIED'); + assert.equal(result.textRegionMinimum, undefined); + assert.ok(result.reason); + } +}); diff --git a/packages/org-chart/src/core/contrast.ts b/packages/org-chart/src/core/contrast.ts new file mode 100644 index 0000000..06c002e --- /dev/null +++ b/packages/org-chart/src/core/contrast.ts @@ -0,0 +1,93 @@ +/** Internal contrast arithmetic. Results model opaque legacy sRGB colors; + * they are not pixel measurements or a claim about every rendered instance. */ +export type Rgb = readonly [number, number, number]; +export interface VerticalGradient { stops: readonly { color: Rgb; offset: number }[] } +export interface ContrastModel { + status: 'MODEL' | 'NOT VERIFIED'; + reason?: string; + stopMinimum?: number; + fullGradientMinimum?: number; + textRegionMinimum?: number; +} + +export function parseOpaqueColor(value: string): Rgb | null { + const text = value.trim(); + if (/^#[\da-f]{6}$/i.test(text)) return [1, 3, 5].map(index => parseInt(text.slice(index, index + 2), 16)) as unknown as Rgb; + if (/^#[\da-f]{3}$/i.test(text)) return [...text.slice(1)].map(part => parseInt(part + part, 16)) as unknown as Rgb; + const match = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+))?\s*\)$/i.exec(text); + if (!match || (match[4] !== undefined && Number(match[4]) !== 1)) return null; + const channels = match.slice(1, 4).map(Number); + return channels.every(channel => Number.isFinite(channel) && channel >= 0 && channel <= 255) ? channels as unknown as Rgb : null; +} + +export function relativeLuminance(color: Rgb): number { + const channels = color.map(channel => { + const encoded = channel / 255; + return encoded <= 0.04045 ? encoded / 12.92 : ((encoded + 0.055) / 1.055) ** 2.4; + }); + return channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722; +} + +export function contrastRatio(foreground: Rgb, background: Rgb): number { + const first = relativeLuminance(foreground), second = relativeLuminance(background); + return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05); +} + +/** Restricted intentionally: no alpha, images, multiple layers, omitted stops, + * other angles, color spaces, relative units, or repeating gradients. + * Computed CSS may omit the default 180deg angle; that is equivalent. */ +export function parseVerticalGradient(value: string): VerticalGradient | null { + const outer = /^linear-gradient\(\s*(?:180deg\s*,\s*)?(.*)\)$/i.exec(value.trim()); + if (!outer) return null; + const parts = outer[1]!.split(/,(?![^()]*\))/); + const stops: { color: Rgb; offset: number }[] = []; + for (const part of parts) { + const match = /^\s*(#[\da-f]+|rgba?\([^()]*\))\s+([\d.]+)%\s*$/i.exec(part); + const color = match && parseOpaqueColor(match[1]!); + const offset = match ? Number(match[2]) / 100 : NaN; + if (!color || !Number.isFinite(offset) || offset < 0 || offset > 1 || (stops.length > 0 && offset <= stops.at(-1)!.offset)) return null; + stops.push({ color, offset }); + } + return stops.length >= 2 && stops[0]!.offset === 0 && stops.at(-1)!.offset === 1 ? { stops } : null; +} + +function gradientColor(gradient: VerticalGradient, offset: number): Rgb { + let index = 1; + while (index < gradient.stops.length - 1 && offset > gradient.stops[index]!.offset) index++; + const before = gradient.stops[index - 1]!, after = gradient.stops[index]!; + const amount = (offset - before.offset) / (after.offset - before.offset); + return before.color.map((channel, channelIndex) => channel + (after.color[channelIndex]! - channel) * amount) as unknown as Rgb; +} + +export function textContrastThreshold(fontSizePx: number, fontWeight: number): 3 | 4.5 { + return fontSizePx >= 24 || (fontWeight >= 700 && fontSizePx >= 14 * 96 / 72) ? 3 : 4.5; +} + +/** Ranges are actual text line rectangles in CSS px relative to the gradient + * painting box, not the padded heading rectangle. The whole-gradient sample + * and stop sample are reported separately; neither substitutes for text area. */ +export function evaluateGradientContrast(input: { + foreground: string; + backgroundImage: string; + ranges: readonly { top: number; bottom: number }[]; + backgroundHeight: number; + sampleStep?: number; +}): ContrastModel { + const foreground = parseOpaqueColor(input.foreground), gradient = parseVerticalGradient(input.backgroundImage); + if (!foreground || !gradient) return { status: 'NOT VERIFIED', reason: '지원하지 않는 색상/gradient: 불투명 sRGB 180deg 및 명시적 % stop만 지원' }; + const height = input.backgroundHeight, step = input.sampleStep ?? 1; + if (!Number.isFinite(height) || height <= 0 || !Number.isFinite(step) || step <= 0 || height / step > 10000 || input.ranges.length === 0 || input.ranges.some(range => !Number.isFinite(range.top) || !Number.isFinite(range.bottom) || range.top < 0 || range.bottom > height || range.bottom <= range.top)) { + return { status: 'NOT VERIFIED', reason: '텍스트 범위/배경 높이/샘플 간격 확인 불가' }; + } + const ratioAt = (offset: number) => contrastRatio(foreground, gradientColor(gradient, offset)); + const stopMinimum = Math.min(...gradient.stops.map(stop => contrastRatio(foreground, stop.color))); + let fullGradientMinimum = stopMinimum; + for (let index = 0; index <= 256; index++) fullGradientMinimum = Math.min(fullGradientMinimum, ratioAt(index / 256)); + let textRegionMinimum = Infinity; + for (const range of input.ranges) { + textRegionMinimum = Math.min(textRegionMinimum, ratioAt(range.top / height), ratioAt(range.bottom / height)); + for (let y = range.top + step; y < range.bottom; y += step) textRegionMinimum = Math.min(textRegionMinimum, ratioAt(y / height)); + for (const stop of gradient.stops) if (stop.offset * height >= range.top && stop.offset * height <= range.bottom) textRegionMinimum = Math.min(textRegionMinimum, ratioAt(stop.offset)); + } + return { status: 'MODEL', stopMinimum, fullGradientMinimum, textRegionMinimum }; +} diff --git a/packages/org-chart/src/core/department-card-assets.ts b/packages/org-chart/src/core/department-card-assets.ts new file mode 100644 index 0000000..c173c8c --- /dev/null +++ b/packages/org-chart/src/core/department-card-assets.ts @@ -0,0 +1,53 @@ +import businessLeader from '../assets/cards/경영기획팀.svg?inline'; +import businessEmployee from '../assets/cards/경영기획팀-sm.svg?inline'; +import managementLeader from '../assets/cards/사업관리팀.svg?inline'; +import managementEmployee from '../assets/cards/사업관리팀-sm.svg?inline'; +import designLeader from '../assets/cards/디자인기획팀.svg?inline'; +import designEmployee from '../assets/cards/디자인기획팀-sm.svg?inline'; +import solutionLeader from '../assets/cards/솔루션통합팀.svg?inline'; +import solutionEmployee from '../assets/cards/솔루션통합팀-sm.svg?inline'; +import talentLeader from '../assets/cards/인재성장팀.svg?inline'; +import talentEmployee from '../assets/cards/인재성장팀-sm.svg?inline'; +import systemLeader from '../assets/cards/통합시스템 디비전.svg?inline'; +import systemEmployee from '../assets/cards/통합시스템 디비전-sm.svg?inline'; + +export type DepartmentCardSize = 'leader' | 'employee'; +export interface DepartmentCardAsset { + departmentId: string; + label: string; + size: DepartmentCardSize; + url: string; + width: number; + height: number; +} + +// Presentation-only mapping. The ten supplied SVGs are copied byte-for-byte; +// business-management uses vectors generated from the supplied PNG's colors. +// Vite embeds both in ESM/IIFE bundles, including file:// consumers. +// User-approved alias: 솔루션통합팀 assets -> existing solution-sales data. +const departments = new Map([ + ['business-management', { label: '사업관리팀', leader: managementLeader, employee: managementEmployee }], + ['business-planning', { label: '경영기획팀', leader: businessLeader, employee: businessEmployee }], + ['design-planning', { label: '디자인기획팀', leader: designLeader, employee: designEmployee }], + ['solution-sales', { label: '솔루션통합팀', leader: solutionLeader, employee: solutionEmployee }], + ['talent-growth', { label: '인재성장팀', leader: talentLeader, employee: talentEmployee }], + ['integrated-system', { label: '통합시스템 Div', leader: systemLeader, employee: systemEmployee }], +]); + +/** Resolve actual ancestry; do not infer a department from ID spelling/colors. + * Unknown organizations and departments without supplied assets stay unchanged. */ +export function getDepartmentCardAsset( + departmentId: string, size: DepartmentCardSize, + organizations: readonly { id: string; parentId: string | null }[] = [], +): DepartmentCardAsset | undefined { + const visited = new Set(); + let current: string | null = departmentId; + while (current && !visited.has(current)) { + visited.add(current); + const entry = departments.get(current); + if (entry) return { departmentId: current, label: entry.label, size, url: entry[size], + width: size === 'leader' ? 146 : 108, height: size === 'leader' ? 68 : 52 }; + current = organizations.find(item => item.id === current)?.parentId ?? null; + } + return undefined; +} diff --git a/packages/org-chart/src/core/department-colors.ts b/packages/org-chart/src/core/department-colors.ts new file mode 100644 index 0000000..9d58d42 --- /dev/null +++ b/packages/org-chart/src/core/department-colors.ts @@ -0,0 +1,185 @@ +import type { RenderContext } from './render'; +import { applyOrganizationStyle, organizationStylePresets } from './style-presets'; +import { createEmployeeCard } from './employee-card'; +import { contrastRatio, evaluateGradientContrast, parseOpaqueColor, textContrastThreshold } from './contrast'; +import { getDepartmentCardAsset } from './department-card-assets'; +import { representativeCardAsset } from './representative-card-asset'; + +export function createDepartmentColors(document: Document, context: RenderContext, signal: AbortSignal): HTMLElement { + const section = document.createElement('section'); section.className = 'hm-orgchart-department-colors'; + const note = document.createElement('p'); + note.textContent = 'OrgChart 독립 디자인. 대표자 전용 SVG 146×74, 부서 조직장 SVG 146×68, 일반 직원 SVG 108×52를 구분합니다. 5개 조직은 전달된 원본 SVG이며 사업관리는 전달 PNG의 색상·투명도를 추출해 제작한 SVG입니다. 솔루션통합팀 자산은 기존 솔루션영업에 매핑합니다. 아래 색상 견본은 기존 헤더·기본 카드 CSS 값이며 SVG 픽셀 색상이 아닙니다. 공통 HM Theme Token을 사용하지 않습니다. 직원과 인원 통계는 가상 데이터입니다.'; + section.append(note); + const contrastNote = document.createElement('p'); contrastNote.className = 'hm-orgchart-contrast-note'; + contrastNote.textContent = '대비는 불투명 sRGB 180deg gradient와 실제 텍스트 Range의 줄별 영역을 사용한 계산 MODEL입니다(픽셀 실측 아님). 전체 stop 최소값은 실제 글자 영역 판정과 별도로 표시합니다. 큰 Story 제목의 PASS는 Full Composition 15px 일반 텍스트의 PASS를 뜻하지 않습니다. 후보는 미승인·운영 미적용이며 브랜드 gradient를 유지하더라도 텍스트/배경 변경 승인이 필요합니다.'; + section.append(contrastNote); + const representative = context.data.employees.find(item => context.topLeaderIds.has(item.id)); + if (representative) { + const preview = document.createElement('section'); preview.className = 'hm-orgchart-representative-preview'; + const title = document.createElement('h2'); title.textContent = '대표자 전용 카드 · 146×74'; + const caption = document.createElement('p'); caption.textContent = '최상단 단독 리더 전용 SVG이며 부서 조직장(146×68)과 별도 종류입니다.'; + preview.append(title, createEmployeeCard(document, representative, { kind: 'topLeader', cardAsset: representativeCardAsset, + affiliationName: context.data.affiliations.find(item => item.id === representative.affiliationId)?.name }, signal), caption); + section.append(preview); + } + for (let index = 0; index < organizationStylePresets.length; index++) { + const preset = organizationStylePresets[index]!; + const row = document.createElement('section'); row.className = 'hm-orgchart-color-preview'; + applyOrganizationStyle(row, index); + const heading = document.createElement('h2'); heading.className = 'hm-orgchart-color-department-name'; heading.textContent = preset.label; + heading.dataset.contrastTarget = 'current'; + const source = document.createElement('p'); source.textContent = `${preset.source} · ${preset.observedOn} · ${preset.version} · ${preset.selector}`; + const legend = document.createElement('dl'); legend.className = 'hm-orgchart-color-legend'; + const entries = [ + ['카드 배경', 'card-background', 'current-card-background', 'background-color', 'DESIGN_ADAPTATION: OrgChart 기본값'], + ['직원 텍스트', 'card-color', 'current-card-color', 'color', 'DESIGN_ADAPTATION: OrgChart 기본값'], + ['부서명 텍스트', 'department-color', 'current-department-color', 'color', `WORKDESK_COMPUTED: ${preset.departmentColor}`], + ['테두리', 'border', 'current-border', 'background-color', `DESIGN_ADAPTATION: ${preset.borderColor}, 헤더 시작색 재사용; 원본 카드 테두리 NOT VERIFIED`], + ['Gradient', 'gradient', 'current-gradient', 'background-image', `WORKDESK_COMPUTED: ${preset.backgroundImage}`], + ]; + for (const [label, suffix, , property, evidence] of entries) { + const term = document.createElement('dt'); term.textContent = label!; + const detail = document.createElement('dd'); + const swatch = document.createElement('span'); swatch.className = 'hm-orgchart-color-swatch'; + swatch.classList.add(`hm-orgchart-color-swatch-${suffix}`); + if (property === 'color') { + swatch.textContent = 'Aa'; + swatch.style.background = suffix === 'department-color' ? 'var(--hm-orgchart-current-gradient)' : 'var(--hm-orgchart-current-card-background)'; + } + swatch.dataset.colorVariable = `${preset.variablePrefix}-${suffix}`; swatch.dataset.cssProperty = property; + const variable = document.createElement('code'); variable.textContent = swatch.dataset.colorVariable; + const value = document.createElement('span'); value.className = 'hm-orgchart-color-value'; + const provenance = document.createElement('span'); provenance.textContent = `${evidence} · OrgChart 전용 변수`; + detail.append(swatch, variable, value, provenance); legend.append(term, detail); + } + const cards = document.createElement('div'); cards.className = 'hm-orgchart-preview-grid'; + const organization = context.data.organizations.find(item => item.id === preset.organizationId); + const descendants = new Set([preset.organizationId]); + for (const item of context.orderedOrganizations) if (item.parentId && descendants.has(item.parentId)) descendants.add(item.id); + const leader = context.data.employees.find(item => item.id === organization?.leaderId); + const leaderIds = new Set(context.data.organizations.map(item => item.leaderId).filter(Boolean)); + const employee = context.data.employees.find(item => descendants.has(item.organizationId) && !leaderIds.has(item.id)); + for (const [person, size] of [[leader, 'leader'], [employee, 'employee']] as const) { + if (!person) continue; + const asset = getDepartmentCardAsset(preset.organizationId, size); + const specimen = document.createElement('div'); specimen.className = 'hm-orgchart-card-specimen'; + const caption = document.createElement('p'); caption.className = 'hm-orgchart-card-caption'; + caption.textContent = asset ? `${asset.label} · ${size === 'leader' ? '조직장' : '일반 직원'} ${asset.width}×${asset.height} · ${asset.departmentId === 'business-management' ? 'PNG 색상 추출 SVG' : '원본 SVG'}` : `${preset.label} · ${size === 'leader' ? '조직장' : '일반 직원'} · 기존 기본 카드(전달 자산 없음)`; + specimen.append(createEmployeeCard(document, person, { kind: size, cardAsset: asset, affiliationName: context.data.affiliations.find(item => item.id === person.affiliationId)?.name }, signal), caption); + cards.append(specimen); + } + const contrast = document.createElement('section'); contrast.className = 'hm-orgchart-contrast-comparison'; + const comparisonTitle = document.createElement('h3'); comparisonTitle.textContent = '텍스트 대비 MODEL · 현재 / 일반 제목 / 미적용 후보'; + const normal = document.createElement('div'); normal.className = 'hm-orgchart-contrast-normal'; normal.dataset.contrastTarget = 'full-normal'; normal.textContent = preset.label; + const candidate = document.createElement('div'); candidate.className = 'hm-orgchart-contrast-candidate'; candidate.dataset.contrastTarget = 'candidate'; candidate.dataset.designStatus = 'UNAPPROVED_NOT_APPLIED'; candidate.textContent = preset.label; + candidate.style.color = '#10243b'; + const chip = document.createElement('div'); chip.className = 'hm-orgchart-contrast-chip'; chip.dataset.designStatus = 'UNAPPROVED_NOT_APPLIED'; + const chipLabel = document.createElement('span'); chipLabel.className = 'hm-orgchart-contrast-chip-label'; chipLabel.dataset.contrastTarget = 'candidate-chip'; chipLabel.textContent = preset.label; + chipLabel.style.color = '#10243b'; chipLabel.style.backgroundColor = '#ffffff'; chip.append(chipLabel); + const results = document.createElement('dl'); results.className = 'hm-orgchart-contrast-results'; + for (const [key, label] of [['current', '현재 Story 제목: 실제 크기 기준'], ['full-normal', 'Full 제목 동등 예제: 15px bold 일반 텍스트'], ['candidate', '후보 #10243b: 미승인·미적용'], ['candidate-chip', '후보 white label + #10243b: 미승인·미적용']]) { + const term = document.createElement('dt'); term.textContent = label; + const result = document.createElement('dd'); result.className = 'hm-orgchart-contrast-result'; result.dataset.contrastFor = key; result.dataset.contrastStatus = 'NOT VERIFIED'; result.textContent = 'NOT VERIFIED · CSS/텍스트 영역 측정 대기'; results.append(term, result); + } + const stops = document.createElement('p'); stops.className = 'hm-orgchart-contrast-stops'; stops.textContent = '전체 gradient stop 및 흑색 대안 MODEL: NOT VERIFIED'; + contrast.append(comparisonTitle, normal, candidate, chip, results, stops); + row.append(heading, source, legend, cards, contrast); section.append(row); + } + return section; +} + +export function updateDepartmentColorValues(root: HTMLElement): void { + const window = root.ownerDocument.defaultView; + if (!window) return; + for (const swatch of root.querySelectorAll('[data-color-variable]')) { + const raw = window.getComputedStyle(swatch).getPropertyValue(swatch.dataset.cssProperty!); + const value = swatch.parentElement!.querySelector('.hm-orgchart-color-value')!; + const text = raw.replace(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/g, (_, r: string, g: string, b: string) => `#${[r, g, b].map(part => Number(part).toString(16).padStart(2, '0')).join('')}`); + if (value.textContent !== text) value.textContent = text; + } + updateDepartmentContrastValues(root); +} + +/** Runtime DOM geometry feeds the pure model. Unknown compositing stays NV; + * this deliberately does not claim to inspect browser-rendered pixel colors. */ +export function updateDepartmentContrastValues(root: HTMLElement): void { + const window = root.ownerDocument.defaultView; + if (!window) return; + for (const row of root.querySelectorAll('.hm-orgchart-color-preview')) { + for (const target of row.querySelectorAll('[data-contrast-target]')) { + const output = row.querySelector(`[data-contrast-for="${target.dataset.contrastTarget}"]`); + if (!output) continue; + const measurement = measureTextContrast(target); + output.dataset.contrastStatus = measurement.status; + output.dataset.contrastModel = measurement.model; + if (output.textContent !== measurement.text) output.textContent = measurement.text; + for (const key of ['contrastRatio', 'contrastThreshold', 'contrastFontSize', 'contrastFontWeight'] as const) delete output.dataset[key]; + if (measurement.ratio !== undefined) output.dataset.contrastRatio = String(measurement.ratio); + if (measurement.threshold !== undefined) output.dataset.contrastThreshold = String(measurement.threshold); + if (measurement.fontSize !== undefined) output.dataset.contrastFontSize = String(measurement.fontSize); + if (measurement.fontWeight !== undefined) output.dataset.contrastFontWeight = String(measurement.fontWeight); + } + const heading = row.querySelector('[data-contrast-target="current"]')!; + const style = window.getComputedStyle(heading), height = heading.getBoundingClientRect().height; + const current = evaluateGradientContrast({ foreground: style.color, backgroundImage: style.backgroundImage, ranges: [{ top: 0, bottom: height }], backgroundHeight: height }); + const black = evaluateGradientContrast({ foreground: '#000000', backgroundImage: style.backgroundImage, ranges: [{ top: 0, bottom: height }], backgroundHeight: height }); + const stops = row.querySelector('.hm-orgchart-contrast-stops')!; + stops.dataset.contrastModel = current.status; + const stopText = current.status === 'MODEL' && black.status === 'MODEL' + ? `전체 stop 최소 ${current.stopMinimum!.toFixed(2)}:1 · 전체 gradient 257지점 MODEL 최소 ${current.fullGradientMinimum!.toFixed(2)}:1 (실제 텍스트 영역 아님; 일괄 판정하지 않음). 미적용 #000 대안 전체 MODEL 최소 ${black.fullGradientMinimum!.toFixed(3)}:1. 디자인기획 기본 gradient의 #000 대안은 약 4.607:1이며 승인 전 미적용.` + : `전체 gradient MODEL NOT VERIFIED · ${current.reason ?? black.reason}`; + if (stops.textContent !== stopText) stops.textContent = stopText; + if (current.stopMinimum !== undefined) stops.dataset.contrastStopMinimum = String(current.stopMinimum); else delete stops.dataset.contrastStopMinimum; + if (black.fullGradientMinimum !== undefined) stops.dataset.contrastBlackMinimum = String(black.fullGradientMinimum); else delete stops.dataset.contrastBlackMinimum; + } +} + +interface TextMeasurement { status: 'PASS' | 'REVIEW' | 'NOT VERIFIED'; model: 'MODEL' | 'NOT VERIFIED'; text: string; ratio?: number; threshold?: number; fontSize?: number; fontWeight?: number } +function measureTextContrast(target: HTMLElement): TextMeasurement { + const window = target.ownerDocument.defaultView!; + const unknown = (reason: string): TextMeasurement => ({ status: 'NOT VERIFIED', model: 'NOT VERIFIED', text: `NOT VERIFIED · ${reason}` }); + for (let element: HTMLElement | null = target; element;) { + const style = window.getComputedStyle(element); + if (Number(style.opacity) !== 1 || style.filter !== 'none' || style.transform !== 'none' || style.mixBlendMode !== 'normal' || (style.backdropFilter && style.backdropFilter !== 'none')) return unknown('opacity/filter/transform/blend의 색상 합성 지원 안 함'); + const tree = element.getRootNode(); + element = element.parentElement ?? ((tree as ShadowRoot).host as HTMLElement | undefined) ?? null; + } + const style = window.getComputedStyle(target), box = target.getBoundingClientRect(); + if (style.textShadow !== 'none') return unknown('text-shadow 지원 안 함'); + const fontSize = Number.parseFloat(style.fontSize), fontWeight = style.fontWeight === 'bold' ? 700 : style.fontWeight === 'normal' ? 400 : Number(style.fontWeight); + if (!Number.isFinite(fontSize) || !Number.isFinite(fontWeight)) return unknown('텍스트 크기/굵기 확인 불가'); + const threshold = textContrastThreshold(fontSize, fontWeight); + const ranges: { top: number; bottom: number }[] = []; + const walker = target.ownerDocument.createTreeWalker(target, 4 /* SHOW_TEXT */); + const lineRects: DOMRect[] = []; + let node: Node | null; + while ((node = walker.nextNode())) { + if (!node.textContent?.trim()) continue; + const range = target.ownerDocument.createRange(); range.selectNodeContents(node); + lineRects.push(...Array.from(range.getClientRects()).filter(rect => rect.width > 0 && rect.height > 0)); + } + if (lineRects.length === 0 || box.height <= 0) return unknown('실제 텍스트 Range 없음'); + let ratio: number | undefined; + if (style.backgroundImage === 'none') { + const foreground = parseOpaqueColor(style.color), background = parseOpaqueColor(style.backgroundColor); + if (!foreground || !background) return unknown('불투명 solid 배경/텍스트만 지원'); + if (lineRects.some(rect => rect.left < box.left || rect.right > box.right || rect.top < box.top || rect.bottom > box.bottom)) return unknown('텍스트가 solid 배경 영역 밖에 있음'); + ratio = contrastRatio(foreground, background); + } else { + if (!['auto', 'auto auto'].includes(style.backgroundSize) || style.backgroundPosition !== '0% 0%' || !['border-box', 'padding-box', 'content-box'].includes(style.backgroundOrigin)) return unknown('background size/position/origin 지원 안 함'); + const borderTop = Number.parseFloat(style.borderTopWidth), borderBottom = Number.parseFloat(style.borderBottomWidth); + const borderLeft = Number.parseFloat(style.borderLeftWidth), borderRight = Number.parseFloat(style.borderRightWidth); + const content = style.backgroundOrigin === 'content-box', includeBorder = style.backgroundOrigin === 'border-box'; + const top = box.top + (includeBorder ? 0 : borderTop) + (content ? Number.parseFloat(style.paddingTop) : 0); + const bottom = box.bottom - (includeBorder ? 0 : borderBottom) - (content ? Number.parseFloat(style.paddingBottom) : 0); + const left = box.left + (includeBorder ? 0 : borderLeft) + (content ? Number.parseFloat(style.paddingLeft) : 0); + const right = box.right - (includeBorder ? 0 : borderRight) - (content ? Number.parseFloat(style.paddingRight) : 0); + if (lineRects.some(rect => rect.left < left || rect.right > right)) return unknown('텍스트가 gradient painting box 밖에 있음'); + for (const rect of lineRects) ranges.push({ top: rect.top - top, bottom: rect.bottom - top }); + const model = evaluateGradientContrast({ foreground: style.color, backgroundImage: style.backgroundImage, ranges, backgroundHeight: bottom - top }); + if (model.status !== 'MODEL') return unknown(model.reason!); + ratio = model.textRegionMinimum!; + } + const status = ratio >= threshold ? 'PASS' : 'REVIEW'; + return { status, model: 'MODEL', ratio, threshold, fontSize, fontWeight, text: `${status} · 실제 Range 영역 MODEL 최소 ${ratio.toFixed(2)}:1 / 기준 ${threshold}:1 · ${fontSize}px / ${fontWeight}. 픽셀 실측 아님; 현재 예제 영역에만 해당.` }; +} diff --git a/packages/org-chart/src/core/employee-card.ts b/packages/org-chart/src/core/employee-card.ts new file mode 100644 index 0000000..18fbb7d --- /dev/null +++ b/packages/org-chart/src/core/employee-card.ts @@ -0,0 +1,133 @@ +import type { Employee } from '../data/index.js'; +import personPlaceholder from '../assets/person-placeholder.svg?inline'; +import { createAffiliationMark } from './affiliation-mark.js'; +import type { DepartmentCardAsset } from './department-card-assets.js'; +import type { RepresentativeCardAsset } from './representative-card-asset.js'; +import { observePersonNameOverflow } from './person-name-overflow.js'; + +const statusNames = { active: '재직', dispatched: '파견', onLeave: '휴직' } as const; + +export interface EmployeeCardOptions { + kind: 'topLeader' | 'leader' | 'employee'; + affiliationName?: string; + cardAsset?: DepartmentCardAsset | RepresentativeCardAsset; + /** Full Composition presentation only; the employee dataset is untouched. */ + workdeskPresentation?: boolean; + /** Auto content presentation, independent of Fixed SVG geometry. */ + expandedPresentation?: boolean; + /** Compact read-only status groups already convey this label in their header. */ + hideStatusLabel?: boolean; +} + +function text(document: Document, tag: string, className: string, value: string): HTMLElement { + const element = document.createElement(tag); + element.className = className; + element.textContent = value; + return element; +} + +/** Content renderer shared by the hierarchy and supplementary status groups. */ +export function createEmployeeCard( + document: Document, employee: Employee, options: EmployeeCardOptions, signal: AbortSignal, +): HTMLElement { + const leader = options.kind !== 'employee'; + const representative = options.kind === 'topLeader' || options.cardAsset?.size === 'representative'; + const compactStatus = Boolean(options.workdeskPresentation && options.hideStatusLabel && !options.cardAsset); + const styledCard = Boolean(options.cardAsset) || compactStatus || options.expandedPresentation; + const presentationName = options.workdeskPresentation || (representative && !options.expandedPresentation); + const displayName = presentationName ? '홍길동' : employee.name; + 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) { + const asset = options.cardAsset; + card.dataset.cardAsset = 'departmentId' in asset ? asset.departmentId : 'representative'; + card.dataset.cardAssetSize = asset.size; + const background = document.createElement('img'); + background.className = 'hm-orgchart-card-background'; + background.alt = ''; + background.setAttribute('aria-hidden', 'true'); + background.width = asset.width; + background.height = asset.height; + background.src = asset.url; + card.append(background); + } + + // One neutral icon is shared by every card type and both runtime adapters. + // The approved rectangular photo slots keep their existing dimensions. + const avatar = document.createElement('span'); + avatar.className = 'hm-orgchart-avatar'; + avatar.setAttribute('aria-hidden', 'true'); + const image = document.createElement('img'); + image.alt = ''; + image.loading = 'lazy'; + image.decoding = 'async'; + image.referrerPolicy = 'no-referrer'; + const showPlaceholder = () => { + card.dataset.photoState = 'placeholder'; + image.dataset.photoPlaceholder = 'true'; + image.src = personPlaceholder; + }; + if (employee.profileImage) { + card.dataset.photoState = 'image'; + image.addEventListener('error', showPlaceholder, { once: true, signal }); + // Images have already passed the dataset URL validation. No HTML parsing. + image.src = employee.profileImage; + } else showPlaceholder(); + avatar.append(image); + + const details = document.createElement('div'); + details.className = 'hm-orgchart-employee-details'; + const displayedPosition = representative && !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); + } + 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'; + positionRow.append(position); + } + if (styledCard) { + const personText = document.createElement('div'); personText.className = 'hm-orgchart-person-text'; + personText.append(positionRow ?? position, name); details.append(personText); + observePersonNameOverflow(card, name, signal); + } else details.append(position, name); + const attributes = document.createElement('div'); + attributes.className = 'hm-orgchart-person-attributes'; + // Representative cards show only position/name in every consumer. Raw employee + // roles remain available to dialogs/statistics; other cards keep their policy. + const hideExecutiveBadge = options.workdeskPresentation && employee.jobTitle?.trim() === '경영진'; + if (!representative && !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 || '조직장'); + badge.title = badge.textContent!; + (positionRow ?? attributes).append(badge); + } + if (!representative && !options.hideStatusLabel) attributes.append(text(document, 'span', 'hm-orgchart-status-label', statusNames[employee.workStatus])); + if (styledCard) { + card.append(avatar, details); + if (attributes.childElementCount) card.append(attributes); + } else { + if (attributes.childElementCount) details.append(attributes); + card.append(avatar, details); + } + + if (options.affiliationName) { + const affiliation = createAffiliationMark(document, employee.affiliationId, options.affiliationName, signal); + card.dataset.hasAffiliation = 'true'; + card.append(affiliation); + } + return card; +} diff --git a/packages/org-chart/src/core/employee-info-dialog.ts b/packages/org-chart/src/core/employee-info-dialog.ts new file mode 100644 index 0000000..f814626 --- /dev/null +++ b/packages/org-chart/src/core/employee-info-dialog.ts @@ -0,0 +1,117 @@ +import type { Employee } from '../data/index.js'; +import type { OrgChartEmployeeClickContext } from './types.js'; + +const statusNames = { active: '재직', dispatched: '파견', onLeave: '휴직' } as const; + +export interface EmployeeInfoDialog { + show(employee: Employee, context: OrgChartEmployeeClickContext): void; + close(): void; +} + +/** The native modal lives in the unscaled shell and enters the browser's top + * layer. It keeps the Core shared by HTML and React, including focus handling. */ +export function createEmployeeInfoDialog( + shell: HTMLElement, signal: AbortSignal, prefix: string, readOnly: boolean, +): EmployeeInfoDialog { + const document = shell.ownerDocument; + const dialog = document.createElement('dialog'); + dialog.className = 'hm-orgchart-employee-dialog'; + dialog.dataset.component = 'EmployeeInfoDialog'; + dialog.setAttribute('aria-modal', 'true'); + const header = document.createElement('div'); + header.className = 'hm-orgchart-employee-dialog-header'; + const title = document.createElement('h2'); + title.id = `${prefix}-employee-dialog-title`; + title.textContent = '직원 정보'; + dialog.setAttribute('aria-labelledby', title.id); + const closeButton = document.createElement('button'); + closeButton.type = 'button'; + closeButton.className = 'hm-orgchart-employee-dialog-close'; + closeButton.setAttribute('aria-label', '직원 정보 닫기'); + // HM Dialog's Lucide XIcon, kept as native SVG for the shared Vanilla Core. + const closeIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + for (const [name, value] of Object.entries({ + width: '24', height: '24', viewBox: '0 0 24 24', fill: 'none', + stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', 'aria-hidden': 'true', focusable: 'false', + })) closeIcon.setAttribute(name, value); + for (const d of ['M18 6 6 18', 'm6 6 12 12']) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', d); + closeIcon.append(path); + } + closeButton.append(closeIcon); + const fields = document.createElement('dl'); + fields.className = 'hm-orgchart-employee-dialog-fields'; + header.append(title, closeButton); + dialog.append(header, fields); + shell.append(dialog); + let trigger: HTMLElement | undefined; + + function restoreFocus(): void { + const previous = trigger; + // close() restores synchronously, while the native close event is queued. + // Only a real activation owns a return target: a second close notification + // (or routine render cleanup before the dialog ever opens) must not focus + // the shell and displace the card's restored focus. + if (!previous) return; + trigger = undefined; + if (signal.aborted || !shell.isConnected) return; + (previous?.isConnected ? previous : shell).focus({ preventScroll: true }); + } + function close(): void { + if (dialog.open) dialog.close(); + restoreFocus(); + } + closeButton.addEventListener('click', close, { signal }); + dialog.addEventListener('cancel', event => { + event.preventDefault(); + close(); + }, { signal }); + dialog.addEventListener('close', () => { if (!dialog.open) restoreFocus(); }, { signal }); + dialog.addEventListener('keydown', event => { + if (event.key !== 'Tab') return; + const focusable = [...dialog.querySelectorAll('button:not(:disabled), a[href], [tabindex="0"]')]; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = (dialog.getRootNode() as ShadowRoot).activeElement; + if ((event.shiftKey && active === first) || (!event.shiftKey && active === last) || !dialog.contains(active)) { + event.preventDefault(); + (event.shiftKey ? last : first)?.focus(); + } + }, { signal }); + signal.addEventListener('abort', () => { + trigger = undefined; + if (dialog.open) dialog.close(); + dialog.remove(); + }, { once: true }); + + return { + close, + show(employee, context) { + if (signal.aborted) return; + // Never synthesize contact details, dates or other personal information. + const entries: [string, string | null | undefined][] = [ + ['이름', readOnly ? context.trigger.querySelector('.hm-orgchart-person-name')?.textContent ?? employee.name : employee.name], + ['직위', employee.position], + ['소속', context.organization?.name], + ['직책', employee.jobTitle], + ['관계사', context.affiliation?.name], + ['근무 상태', statusNames[employee.workStatus]], + ]; + fields.replaceChildren(); + for (const [label, value] of entries) { + if (!value) continue; + const term = document.createElement('dt'); + term.textContent = label; + const detail = document.createElement('dd'); + detail.textContent = value; + fields.append(term, detail); + } + dialog.dataset.employeeId = employee.id; + trigger = context.trigger; + if (!dialog.open) dialog.showModal(); + closeButton.focus({ preventScroll: true }); + }, + }; +} diff --git a/packages/org-chart/src/core/engine.ts b/packages/org-chart/src/core/engine.ts new file mode 100644 index 0000000..66161ca --- /dev/null +++ b/packages/org-chart/src/core/engine.ts @@ -0,0 +1,195 @@ +import type OrgChart from 'orgchart'; +import type { EngineNode, EngineOptions } from 'orgchart'; + +export type RenderEngineNode = EngineNode; +export interface EngineEditing { + canDrop(source: HTMLElement, target: HTMLElement): boolean; + onDrop(source: HTMLElement, target: HTMLElement): void; +} +export interface EngineAdapter { + update(data: RenderEngineNode[]): void; + toggle(node: HTMLElement): boolean | undefined; + prepareDrag(source: HTMLElement): void; + drop(source: HTMLElement, target: HTMLElement): void; + cancelDrag(): void; + dispose(): void; +} + +/** Importing the public facade never evaluates the upstream browser-only module. */ +export async function loadEngine() { + const { default: Upstream } = await import('orgchart'); + // 6.0.0 has no destroy(). Its init observer is local to triggerInitEvent. + // Override that narrow hook on our subclass to keep the resource disposable. + return class ManagedOrgChart extends Upstream { + declare initObserver?: MutationObserver; + override init(options?: Parameters[0]): OrgChart { + try { + return super.init(options); + } catch (error) { + this.disconnectInitObserver(); + this.chart?.remove(); + this.chart = null; + throw error; + } + } + override triggerInitEvent(): void { + this.disconnectInitObserver(); + const Observer = this.chartContainer?.ownerDocument.defaultView?.MutationObserver; + if (!Observer || !this.chartContainer) return; + const observer = new Observer(() => { + observer.disconnect(); + if (this.initObserver === observer) this.initObserver = undefined; + if (this.chart) this.handleCompactNodes(); + }); + this.initObserver = observer; + observer.observe(this.chartContainer, { childList: true }); + } + disconnectInitObserver(): void { + this.initObserver?.disconnect(); + this.initObserver = undefined; + } + }; +} + +export type EngineConstructor = Awaited>; + +export function createEngineAdapter( + Engine: EngineConstructor, + viewport: HTMLElement, + data: RenderEngineNode[], + decorate: (node: HTMLElement, data: RenderEngineNode, signal: AbortSignal) => void, + layoutOptions: Pick = {}, + editing?: EngineEditing, +): EngineAdapter { + const Abort = viewport.ownerDocument.defaultView?.AbortController ?? AbortController; + let generation = new Abort(); + let instances: (OrgChart & { disconnectInitObserver(): void })[] = []; + let disposed = false; + const forest = viewport.ownerDocument.createElement('div'); + forest.className = 'hm-orgchart-forest'; + viewport.append(forest); + function cancelDrag(): void { + if (!editing) return; + for (const instance of instances) instance.dragendHandler(); + } + function releaseGeneration(): void { + cancelDrag(); + generation.abort(); + for (const instance of instances) { + instance.disconnectInitObserver(); + // Keep the old chart reference until upstream feature cleanup is complete. + instance.unbindPan(); instance.unbindZoom(); instance.detachMinimap(); + } + } + function disposeInstances(): void { + releaseGeneration(); + for (const instance of instances) { + instance.chart?.remove(); + instance.chart = null; instance.chartContainer = null; + instance.opts = {}; instance.options = {}; + } + instances = []; + forest.replaceChildren(); + } + function build(roots: RenderEngineNode[]): void { + try { + for (const root of roots) { + const treeContainer = viewport.ownerDocument.createElement('div'); + treeContainer.className = 'hm-orgchart-tree'; + forest.append(treeContainer); + const instance = new Engine(treeContainer, { + ...layoutOptions, + data: root, pan: false, zoom: false, minimap: false, draggable: false, exportButton: false, + chartClass: 'hm-orgchart-engine', + ...(editing ? { dropCriteria: (source: HTMLElement, _parent: HTMLElement | null, target: HTMLElement) => editing.canDrop(source, target) } : {}), + nodeTemplate: () => '
', + createNode: (node, value) => decorate(node, value, generation.signal), + }); + instances.push(instance); + } + } catch (error) { disposeInstances(); throw error; } + } + function syncButtons(): void { + for (const instance of instances) { + for (const node of instance.chart?.querySelectorAll('.node') ?? []) { + const button = node.querySelector('.hm-orgchart-toggle'); + if (!button) continue; + const expanded = instance.getNodeState(node, 'children').visible && !node.closest('.hierarchy')?.classList.contains('isChildrenCollapsed'); + button.setAttribute('aria-expanded', String(expanded)); + const label = expanded ? '하위 조직 접기' : '하위 조직 펼치기'; + button.textContent = label; + button.setAttribute('aria-label', `${node.querySelector('.hm-orgchart-organization-name')?.textContent ?? ''} ${label}`); + } + } + } + function bindGeneration(): void { + forest.addEventListener('transitionend', syncButtons, { signal: generation.signal }); + if (!editing) return; + for (const instance of instances) { + // Core owns immutable data and rerendering. Reuse upstream drop events, + // but cancel its DOM/data mutation before either horizontal or hybrid path. + const commitDrop = (event: Event) => { + event.preventDefault(); + const detail = (event as CustomEvent<{ draggedNode?: HTMLElement; draggedItem?: HTMLElement; dropZone: HTMLElement }>).detail; + if (!detail) return; + const source = detail.draggedNode ?? detail.draggedItem; + const target = detail.dropZone; + if (source && target && instance.chart?.contains(source) && instances.some(candidate => candidate.chart?.contains(target)) && editing.canDrop(source, target)) { + editing.onDrop(source, target); + } + }; + instance.chart?.addEventListener('nodedrop.orgchart', commitDrop, { signal: generation.signal }); + instance.chart?.addEventListener('otherdropped.orgchart', commitDrop, { signal: generation.signal }); + } + } + build(data); + bindGeneration(); + return { + update(next) { + if (disposed) throw new Error('OrgChart engine is disposed'); + releaseGeneration(); + generation = new Abort(); + if (next.length === instances.length) { + for (let index = 0; index < next.length; index++) instances[index].init({ data: next[index] }); + } else { + disposeInstances(); + generation = new Abort(); + build(next); + } + bindGeneration(); + }, + toggle(node) { + const instance = instances.find(candidate => candidate.chart?.contains(node)); + if (!instance || node.closest('.hierarchy')?.querySelector('.sliding')) return undefined; + const expanded = instance.getNodeState(node, 'children').visible; + if (expanded) instance.hideChildren(node); else instance.showChildren(node); + syncButtons(); + return !expanded; + }, + prepareDrag(source) { + if (disposed || !editing) return; + cancelDrag(); + if (!instances.some(candidate => candidate.chart?.contains(source))) return; + // Independent company trees share one editing policy. Upstream caches the + // source and filters targets within its own chart, so filter each tree. + for (const instance of instances) instance.filterAllowedDropNodes(source); + }, + drop(source, target) { + if (disposed || !editing) return; + const instance = instances.find(candidate => candidate.chart?.contains(source)); + if (!instance || !instances.some(candidate => candidate.chart?.contains(target)) || !editing.canDrop(source, target)) { cancelDrag(); return; } + // Refresh upstream's cached source and allowed targets for the final data + // policy check. Pointer coordinates and thresholds stay in the Core UI. + for (const candidate of instances) candidate.filterAllowedDropNodes(source); + void instance.dropHandler({ dropZone: target }); + cancelDrag(); + }, + cancelDrag, + dispose() { + if (disposed) return; + disposed = true; + disposeInstances(); + viewport.replaceChildren(); + }, + }; +} 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..71cc1f2 --- /dev/null +++ b/packages/org-chart/src/core/expanded-company.test.ts @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import smallFixture from '../../../../examples/org-chart/data-expanded-small.json'; +import mediumFixture from '../../../../examples/org-chart/data-expanded-medium.json'; +import largeFixture from '../../../../examples/org-chart/data-expanded-large.json'; +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 = structuredClone({ small: smallFixture, medium: mediumFixture, large: largeFixture }[size]); + 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 = structuredClone(smallFixture); + 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 new file mode 100644 index 0000000..b250043 --- /dev/null +++ b/packages/org-chart/src/core/expanded-content.ts @@ -0,0 +1,109 @@ +import { createEmployeeCard } from './employee-card.js'; +import { createHeading, type RenderContext } from './render.js'; +import type { RenderEngineNode } from './engine.js'; +import { getCompanyColorPreset } from '../company-color-presets.js'; +import { alignExpandedLevels } from './expanded-layout.js'; +import type { OrgChartWheelZoom } from './types.js'; + +/** 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.draggable = false; + const counts = context.summary.raw.byOrganization[organization.id]; + node.dataset.directEmployeeCount = String(counts?.direct ?? 0); + node.dataset.inclusiveEmployeeCount = String(counts?.inclusive ?? 0); + const vertical = value.vertical === true || (context.autoLayout?.type === 'hybrid' && Number(value.depth) + 1 >= context.autoLayout.hybridStartDepth); + node.dataset.verticalLayout = String(vertical); + const layoutDirection = context.autoLayout?.type === 'lr' || vertical ? 'left' : 'top'; + 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} 하위 조직·직원 · 직접 ${counts?.direct ?? 0}명 · 하위 포함 ${counts?.inclusive ?? 0}명`); + button.setAttribute('aria-expanded', String(!collapsed)); + // Empty titles remain focusable move sources/targets. Collapse is independent. + button.setAttribute('aria-disabled', String(!hasChildren || context.interactions?.collapse === false)); + button.addEventListener('click', event => { event.stopPropagation(); if (hasChildren && context.interactions?.collapse !== false) 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) => { + card.draggable = false; + const employeePreset = getCompanyColorPreset(affiliationId) ?? preset; + card.dataset.companyId = 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 = context.autoLayout?.type === 'lr' ? 'left' : '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] ?? layoutDirection; + 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, + presentation: { chartOnly?: boolean; wheelZoom?: OrgChartWheelZoom } = {}): HTMLElement { + const content = document.createElement('div'); + content.className = 'hm-orgchart-expanded'; + content.dataset.component = 'ExpandedOrgChartContent'; + content.dataset.layoutMode = 'auto'; + if (presentation.chartOnly) content.dataset.presentationMode = 'chart-only'; + content.dataset.autoLayout = context.autoLayout?.type ?? 'tb'; + content.dataset.hybridStartDepth = String(context.autoLayout?.hybridStartDepth ?? 3); + content.dataset.initialVisibleDepth = String(context.autoLayout?.initialVisibleDepth ?? 'all'); + content.dataset.groupHybrid = String(!!context.autoLayout?.hybridOrganizationIds.length); + content.dataset.layoutStatus = 'hierarchical'; + content.dataset.cardDesignStatus = 'current-presentation'; + tree.setAttribute('aria-label', `확장형 조직 계층도 · ${presentation.wheelZoom === 'direct' ? '휠로 확대·축소' : 'Ctrl 또는 Command + 휠로 확대'} · 드래그로 이동`); + // Chart Only: titles inside organization nodes remain; the outer heading does not. + if (presentation.chartOnly) content.append(tree); + else content.append(createHeading(document, context), tree); + alignExpandedLevels(tree, signal, layoutReady, context.autoLayout); + return content; +} diff --git a/packages/org-chart/src/core/expanded-editing.ts b/packages/org-chart/src/core/expanded-editing.ts new file mode 100644 index 0000000..8315828 --- /dev/null +++ b/packages/org-chart/src/core/expanded-editing.ts @@ -0,0 +1,161 @@ +import type { OrgChartMoveRequest, OrgChartMoveValidation } from '../data/index.js'; +import type { EngineAdapter } from './engine.js'; + +export interface ExpandedEditing { + setEnabled(enabled: boolean): void; +} + +/** The pointer threshold is shared conceptually with Pan (6 screen pixels). + * Upstream owns allowedDrop and drop events; Core owns immutable relationships. + * Browser hit testing already includes the forest's Zoom/Pan transform. */ +export function createExpandedEditing(viewport: HTMLElement, feedback: HTMLElement, signal: AbortSignal, + engine: EngineAdapter, validate: (request: OrgChartMoveRequest) => OrgChartMoveValidation, + move: (request: OrgChartMoveRequest) => void, suppressClick: () => void, initiallyEnabled = false): ExpandedEditing { + const document = viewport.ownerDocument, view = document.defaultView!; + let enabled = initiallyEnabled; + const nodes = () => [...viewport.querySelectorAll('.node[data-organization-id]')]; + let pending: { source: HTMLElement; id: number; x: number; y: number; moved: boolean } | undefined; + let keyboardSource: HTMLElement | undefined; + let hover: HTMLElement | undefined; + const sourceFor = (target: EventTarget | null): HTMLElement | undefined => { + if (!(target instanceof view.Element)) return; + const employee = target.closest('.hm-orgchart-employee'); + if (employee && viewport.contains(employee)) return employee; + const title = target.closest('.hm-orgchart-expanded-toggle'); + if (title && viewport.contains(title)) return title.closest('.node') ?? undefined; + }; + const requestFor = (source: HTMLElement, target?: HTMLElement): OrgChartMoveRequest => ({ + kind: source.matches('.hm-orgchart-employee') ? 'employee' : 'organization', + sourceId: source.dataset.employeeId ?? source.dataset.organizationId!, + targetOrganizationId: target?.dataset.organizationId ?? '', + }); + const name = (node: HTMLElement) => node.querySelector('.hm-orgchart-expanded-toggle')?.textContent ?? node.dataset.organizationId!; + function clear(): void { + engine.cancelDrag(); + for (const element of viewport.querySelectorAll('[data-drop-state], [data-drop-hover], [data-edit-source]')) { + delete element.dataset.dropState; delete element.dataset.dropHover; delete element.dataset.editSource; + } + hover = undefined; delete viewport.dataset.editDragging; + } + function start(source: HTMLElement): void { + clear(); engine.prepareDrag(source); + source.dataset.editSource = 'true'; viewport.dataset.editDragging = 'true'; + for (const target of nodes()) target.dataset.dropState = validate(requestFor(source, target)).allowed ? 'allowed' : 'blocked'; + feedback.dataset.moveStatus = 'dragging'; + feedback.textContent = '이동할 조직에 놓으세요. 회사 간 이동도 가능합니다. 조직장·최상위 조직은 이동할 수 없습니다.'; + } + function selectTarget(source: HTMLElement, target?: HTMLElement): void { + if (hover) delete hover.dataset.dropHover; + hover = target; + if (!target) return; + const policy = validate(requestFor(source, target)); + target.dataset.dropHover = policy.allowed ? 'allowed' : 'blocked'; + feedback.textContent = policy.allowed ? `${name(target)} · 이동 가능` : `${name(target)} · ${policy.message}`; + } + const hitTarget = (x: number, y: number) => { + const box = viewport.getBoundingClientRect(); + if (x < box.left || x >= box.right || y < box.top || y >= box.bottom) return; + // The presenter binds before its fragment is attached to the host Shadow DOM. + // Resolve the live root at hit-test time instead of retaining that fragment. + const root = viewport.getRootNode(); + const hit = (root instanceof view.ShadowRoot ? root : document).elementFromPoint(x, y); + const node = hit?.closest('.node[data-organization-id]'); + return node && viewport.contains(node) ? node : undefined; + }; + function drop(source: HTMLElement, target?: HTMLElement): boolean { + if (!enabled) { clear(); return false; } + const request = requestFor(source, target), policy = validate(request); + // The upstream otherdropped event intentionally skips its allowedDrop check. + // Always revalidate before entering either upstream drop-event route. + if (target && policy.allowed) engine.drop(source, target); + else move(request); + clear(); + return policy.allowed && !!target; + } + viewport.addEventListener('dragstart', event => event.preventDefault(), { capture: true, signal }); + viewport.addEventListener('pointerdown', event => { + if (!enabled || event.button !== 0 || !event.isPrimary || keyboardSource) return; + const source = sourceFor(event.target); + if (!source) return; + pending = { source, id: event.pointerId, x: event.clientX, y: event.clientY, moved: false }; + }, { signal }); + view.addEventListener('pointermove', event => { + if (!enabled || !pending || pending.id !== event.pointerId) return; + if (!pending.moved && Math.hypot(event.clientX - pending.x, event.clientY - pending.y) < 6) return; + if (!pending.moved) { + pending.moved = true; viewport.setPointerCapture(event.pointerId); start(pending.source); + } + event.preventDefault(); + selectTarget(pending.source, hitTarget(event.clientX, event.clientY)); + }, { passive: false, signal }); + function finish(event: PointerEvent): void { + if (!pending || pending.id !== event.pointerId) return; + const drag = pending; pending = undefined; + if (viewport.hasPointerCapture(event.pointerId)) viewport.releasePointerCapture(event.pointerId); + if (!drag.moved) return; + suppressClick(); + if (event.type === 'pointerup') drop(drag.source, hitTarget(event.clientX, event.clientY)); + else { clear(); feedback.dataset.moveStatus = 'cancelled'; feedback.textContent = '이동을 취소했습니다.'; } + } + view.addEventListener('pointerup', finish, { signal }); + view.addEventListener('pointercancel', finish, { signal }); + viewport.addEventListener('lostpointercapture', event => { + // Touch initially captures its card. Transferring capture to the viewport + // releases that descendant and bubbles here; the viewport still owns Drag. + if (event.target !== viewport || !pending?.moved) return; + pending = undefined; clear(); feedback.textContent = '이동을 취소했습니다.'; + }, { signal }); + const cancel = (restoreFocus = true) => { + const source = keyboardSource; + if (pending?.moved) suppressClick(); + const id = pending?.id; pending = undefined; keyboardSource = undefined; + if (id !== undefined && viewport.hasPointerCapture(id)) viewport.releasePointerCapture(id); + clear(); feedback.dataset.moveStatus = 'cancelled'; feedback.textContent = '이동을 취소했습니다.'; + const focus = source?.matches('.node') ? source.querySelector('.hm-orgchart-expanded-toggle') : source; + if (restoreFocus) focus?.focus({ preventScroll: true }); + }; + view.addEventListener('blur', () => { if (pending || keyboardSource) cancel(); }, { signal }); + viewport.addEventListener('keydown', event => { + if (!enabled) return; + if (event.key === 'Escape' && (keyboardSource || pending)) { event.preventDefault(); event.stopImmediatePropagation(); cancel(); return; } + if (!keyboardSource && event.altKey && event.key.toLowerCase() === 'm') { + const source = sourceFor(event.target); + if (!source) return; + event.preventDefault(); event.stopImmediatePropagation(); keyboardSource = source; start(source); + const targets = nodes(), target = targets.find(target => validate(requestFor(source, target)).allowed) ?? targets[0]; + selectTarget(source, target); + if (target) { target.tabIndex = -1; target.focus({ preventScroll: true }); } + return; + } + if (!keyboardSource) return; + if (['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft', 'Home', 'End'].includes(event.key)) { + event.preventDefault(); event.stopImmediatePropagation(); + const targets = nodes(), index = Math.max(0, targets.indexOf(hover!)); + const next = event.key === 'Home' ? 0 : event.key === 'End' ? targets.length - 1 + : (index + (['ArrowUp', 'ArrowLeft'].includes(event.key) ? -1 : 1) + targets.length) % targets.length; + selectTarget(keyboardSource, targets[next]); + if (hover) { hover.tabIndex = -1; hover.focus({ preventScroll: true }); } + } else if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); event.stopImmediatePropagation(); const source = keyboardSource; keyboardSource = undefined; + if (!drop(source, hover)) { + const focus = source.matches('.node') ? source.querySelector('.hm-orgchart-expanded-toggle') : source; + focus?.focus({ preventScroll: true }); + } + } else if (event.key === 'Tab') { event.preventDefault(); event.stopImmediatePropagation(); cancel(); } + }, { capture: true, signal }); + signal.addEventListener('abort', () => { + const id = pending?.id; pending = undefined; keyboardSource = undefined; + if (id !== undefined && viewport.hasPointerCapture(id)) viewport.releasePointerCapture(id); + clear(); + }, { once: true }); + return { + setEnabled(next) { + if (enabled === next) return; + enabled = next; + if (!enabled) { + if (pending || keyboardSource) cancel(false); + else clear(); + } + }, + }; +} diff --git a/packages/org-chart/src/core/expanded-layout.test.ts b/packages/org-chart/src/core/expanded-layout.test.ts new file mode 100644 index 0000000..9eb74bb --- /dev/null +++ b/packages/org-chart/src/core/expanded-layout.test.ts @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +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, children: OrganizationTreeNode[] = []): OrganizationTreeNode => ({ + organization: { id, parentId, name: id, type: parentId ? 'office' : 'root', leaderId: null, displayOrder: 0 }, children, +}); + +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 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 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' } }; + +test('layout defaults remain compatible and displayMode is an independent public option', () => { + for (const displayMode of ['interactive', 'readOnly'] as const) { + for (const layoutMode of [undefined, 'fixed', 'auto'] as const) { + const handle = createOrgChart({ ...options, displayMode, layoutMode }); + assert.equal(handle.getState().status, 'idle'); + handle.destroy(); + } + } +}); + +test('invalid layout and unknown Auto company fail explicitly, while Fixed ignores company configuration', () => { + assert.throws(() => createOrgChart({ ...options, layoutMode: 'invalid' as 'auto' }), /layoutMode/); + assert.throws(() => createOrgChart({ ...options, layoutMode: 'auto', companyId: 'unknown' }), /companyId/); + const fixed = createOrgChart({ ...options, layoutMode: 'fixed', companyId: 'unknown' }); + assert.equal(fixed.getState().status, 'idle'); + fixed.destroy(); +}); diff --git a/packages/org-chart/src/core/expanded-layout.ts b/packages/org-chart/src/core/expanded-layout.ts new file mode 100644 index 0000000..332055f --- /dev/null +++ b/packages/org-chart/src/core/expanded-layout.ts @@ -0,0 +1,144 @@ +/** 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. */ +import type { ResolvedAutoLayout } from './auto-layout-policy.js'; + +export function alignExpandedLevels(viewport: HTMLElement, signal: AbortSignal, layoutReady: () => void = () => {}, layout?: ResolvedAutoLayout): 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(); + const widths = 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))); + const width = node.querySelector('.hm-orgchart-organization')?.offsetWidth ?? 0; + widths.set(node.dataset.depth!, Math.max(widths.get(node.dataset.depth!) ?? 0, Math.ceil(width))); + } + for (const node of nodes) { + node.style.minHeight = layout?.type === 'lr' || node.closest('.nodes.vertical') ? '0' : `${heights.get(node.dataset.depth!) ?? 0}px`; + if (layout?.type === 'lr') node.style.minWidth = `${widths.get(node.dataset.depth!) ?? 0}px`; + node.style.setProperty('--hm-orgchart-expanded-content-height', `${node.querySelector('.hm-orgchart-organization')?.offsetHeight ?? 0}px`); + } + drawEmployeeConnectors(viewport); + if (layout?.type === 'lr' || layout?.type === 'hybrid' || layout?.hybridOrganizationIds.length) drawOrganizationConnectors(viewport, layout); + 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(); +} + +/** Adapt dabeng's existing hierarchy DOM to tall HM cards. These paths own only + * connector presentation; dabeng still creates and recursively lays out nodes. */ +function drawOrganizationConnectors(viewport: HTMLElement, layout: ResolvedAutoLayout): void { + const document = viewport.ownerDocument; + for (const tree of viewport.querySelectorAll('.hm-orgchart-tree')) { + tree.querySelector(':scope > .hm-orgchart-organization-connectors')?.remove(); + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.classList.add('hm-orgchart-organization-connectors'); + svg.setAttribute('aria-hidden', 'true'); + svg.setAttribute('width', String(tree.offsetWidth)); + svg.setAttribute('height', String(tree.offsetHeight)); + const nodes = [...tree.querySelectorAll('.node[data-organization-id]')]; + const byId = new Map(nodes.map(node => [node.dataset.organizationId!, node])); + const local = (element: HTMLElement) => { + const treeRect = tree.getBoundingClientRect(), rect = element.getBoundingClientRect(); + // Forest scale is the only transform (LR's upstream rotation is scoped out). + const scale = Number(viewport.dataset.scale) || 1; + return { x: (rect.left - treeRect.left) / scale, y: (rect.top - treeRect.top) / scale, + width: element.offsetWidth, height: element.offsetHeight }; + }; + for (const node of nodes) { + const parent = byId.get(node.dataset.parentId!); + if (!parent) continue; + const article = parent.querySelector(':scope > .hm-orgchart-organization')!; + const heading = node.querySelector('.hm-orgchart-organization-name')!; + const from = local(article), to = local(heading); + const left = layout.type === 'lr' || !!node.closest('.nodes.vertical'); + const path = document.createElementNS(svg.namespaceURI, 'path'); + if (layout.type === 'lr') { + const parentHeading = local(parent.querySelector('.hm-orgchart-organization-name')!); + const x = parentHeading.x + parentHeading.width, y = parentHeading.y + parentHeading.height / 2; + const targetY = to.y + to.height / 2, rail = (x + to.x) / 2; + path.setAttribute('d', `M ${x} ${y} H ${rail} V ${targetY} H ${to.x}`); + } else if (left) { + const x = from.x + from.width / 2, y = from.y + from.height; + const rail = to.x - 16, targetY = to.y + to.height / 2; + const branchY = y + 12; + path.setAttribute('d', `M ${x} ${y} V ${branchY} H ${rail} V ${targetY} H ${to.x}`); + } else { + const x = from.x + from.width / 2, y = from.y + from.height; + const targetX = to.x + to.width / 2, rail = (y + to.y) / 2; + path.setAttribute('d', `M ${x} ${y} V ${rail} H ${targetX} V ${to.y}`); + } + path.setAttribute('data-organization-id', node.dataset.organizationId!); + path.setAttribute('data-parent-id', node.dataset.parentId!); + path.setAttribute('data-direction', left ? 'left' : 'top'); + svg.append(path); + } + tree.prepend(svg); + } +} + +/** 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..98a2ea0 --- /dev/null +++ b/packages/org-chart/src/core/expanded-viewport.ts @@ -0,0 +1,201 @@ +import type { OrgChartViewportState, OrgChartWheelZoom } 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; + hasManualPosition(): boolean; + fitScale(): number; + layoutReady(): void; +} + +/** One transformed forest, shared by HTML and React; dialogs live in the shell. */ +/** `toolbar` is optional: Chart Only keeps every viewport interaction without + * rendering its buttons, ratio text or guidance. */ +export function createExpandedViewport(viewport: HTMLElement, toolbar: HTMLElement | undefined, signal: AbortSignal, + config: { min: number; max: number; step: number; zoomEnabled?: boolean; panEnabled?: boolean; wheelZoom?: OrgChartWheelZoom; fitFloor?: boolean; initialFitScale?: number }, + initial?: OrgChartViewportState, initialPlacement?: 'center' | 'fit', initialManualPosition = false): 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 manualPosition = initialManualPosition; + let placement: 'center' | 'fit' | undefined = initialPlacement && !manualPosition ? initialPlacement : undefined; + // An initial Fit hides the forest until real bounds and canvas size are + // measured, so the first frame never shows an unfitted 100% tree. + if (placement === 'fit') viewport.dataset.placementPending = 'true'; + else delete viewport.dataset.placementPending; + let fitScale = config.initialFitScale ?? Infinity; + let drag: { id: number; x: number; y: number; moved: boolean } | undefined; + let suppressClick = false; + let focusFrame = 0; + const zoomEnabled = config.zoomEnabled !== false, panEnabled = config.panEnabled !== false; + viewport.dataset.zoomEnabled = String(zoomEnabled); + viewport.dataset.panEnabled = String(panEnabled); + 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)}%`; + if (buttons.length) { + buttons[0].disabled = !zoomEnabled || scale >= config.max; + buttons[1].disabled = !zoomEnabled || scale <= config.min; + } + }; + // With an initial Fit, a large forest can fit below the manual minimum. + // Zooming out from there holds the fitted scale instead of jumping inward. + const clamp = (value: number) => Math.max(config.fitFloor ? Math.min(config.min, fitScale) : config.min, Math.min(config.max, value)); + // Engine wrappers reserve asymmetric margins (LR) and empty row space (TB). + // Center the complete rendered content, including organization connector rails, + // in forest-local coordinates so Pan/Zoom do not affect the measured bounds. + const contentBounds = () => { + const origin = forest.getBoundingClientRect(); + let left = Infinity, top = Infinity, right = -Infinity, bottom = -Infinity; + for (const element of forest.querySelectorAll('.hm-orgchart-organization, .hm-orgchart-organization-connectors path')) { + const rect = element.getBoundingClientRect(); + if ((!rect.width && !rect.height) || !element.getClientRects().length) continue; + left = Math.min(left, (rect.left - origin.left) / scale); + top = Math.min(top, (rect.top - origin.top) / scale); + right = Math.max(right, (rect.right - origin.left) / scale); + bottom = Math.max(bottom, (rect.bottom - origin.top) / scale); + } + return Number.isFinite(left) ? { left, top, width: right - left, height: bottom - top } : undefined; + }; + const position = (fit: boolean) => { + const bounds = contentBounds(); + if (!bounds || !bounds.width || !bounds.height || !viewport.clientWidth || !viewport.clientHeight) return; + if (fit) { + // Only an explicit Fit shrinks to contain, even below manual Zoom's min. + scale = Math.max(Number.EPSILON, Math.min((viewport.clientWidth - 32) / bounds.width, (viewport.clientHeight - 32) / bounds.height, 1)); + fitScale = scale; + } + x = (viewport.clientWidth - bounds.width * scale) / 2 - bounds.left * scale; + y = (fit ? Math.max(16, (viewport.clientHeight - bounds.height * scale) / 2) : 16) - bounds.top * scale; + apply(); + delete viewport.dataset.placementPending; + }; + const preservePosition = () => { manualPosition = true; placement = undefined; delete viewport.dataset.placementPending; }; + const controller: ExpandedViewport = { + zoomTo(next, anchor = { x: viewport.clientWidth / 2, y: viewport.clientHeight / 2 }) { + if (!zoomEnabled) return; + if (!Number.isFinite(next)) return; + preservePosition(); + 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 (!panEnabled || !Number.isFinite(dx) || !Number.isFinite(dy)) return; preservePosition(); x += dx; y += dy; apply(); }, + fit() { + manualPosition = false; placement = 'fit'; position(true); + }, + state: () => ({ scale, x, y }), + hasManualPosition: () => manualPosition, + fitScale: () => fitScale, + layoutReady() { if (placement) position(placement === 'fit'); }, + }; + const actions: [string, () => void][] = [ + ['확대', () => controller.zoomTo(scale * config.step)], + ['축소', () => controller.zoomTo(scale / config.step)], + ['100% 복원', () => { preservePosition(); scale = 1; x = 16; y = 16; apply(); }], + ['Fit to View', () => controller.fit()], + ]; + const directWheel = config.wheelZoom === 'direct'; + if (toolbar) { + 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 = [panEnabled ? '드래그·방향키 이동' : '', zoomEnabled ? directWheel ? '휠 확대·축소' : 'Ctrl/⌘ + 휠 확대' : ''].filter(Boolean).join(' · '); + toolbar.append(hint); + } + // The listener lives on the canvas only: outside it, the page scrolls as usual. + viewport.addEventListener('wheel', event => { + if (!zoomEnabled) return; + if (!directWheel && !event.ctrlKey && !event.metaKey) return; + // Direct mode normalizes line/page deltas and caps one event so a + // high-resolution wheel cannot jump across the zoom range. + const delta = directWheel + ? Math.max(-120, Math.min(120, event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.clientHeight : 1))) + : event.deltaY; + if (directWheel && !delta) return; + event.preventDefault(); + const box = viewport.getBoundingClientRect(); + controller.zoomTo(scale * Math.exp(-delta * 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(); if (panEnabled) 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. + // Keyboard focus visibility remains available when manual Pan is disabled. + 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) { preservePosition(); x += dx; y += dy; apply(); } + }); + }, { signal }); + viewport.addEventListener('pointerdown', event => { + if (!panEnabled) return; + if (event.button !== 0 || event.isPrimary === false || (event.target as Element).closest('button,a,.hm-orgchart-employee')) 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', event => { if (event.target === viewport) { 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 (placement) position(placement === '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 new file mode 100644 index 0000000..bb65fda --- /dev/null +++ b/packages/org-chart/src/core/index.ts @@ -0,0 +1,621 @@ +import { applyOrgChartMove, calculateSummary, createOrgChartMoveValidator, normalizeOrgChartDataset, validateOrgChartDataset } from '../data/index.js'; +import type { OrgChartDataset, OrgChartMoveRequest, OrgChartMoveResult, OrgChartMoveValidation, OrgChartDataSource, ValidationIssue } from '../data/index.js'; +import { createEngineAdapter, loadEngine } from './engine.js'; +import type { EngineAdapter, EngineConstructor } from './engine.js'; +import { decorateNode, engineTree } from './render.js'; +import { createOrgChartTree } from './org-chart-tree.js'; +import { createOrgChartContent } from './org-chart-content.js'; +import { createExpandedOrgChartContent } from './expanded-content.js'; +import { getCompanyColorPreset } from '../company-color-presets.js'; +import { resolveExpandedCompanies, validateTopNodes } from './expanded-company.js'; +import { createExpandedViewport, type ExpandedViewport } from './expanded-viewport.js'; +import { createExpandedEditing, type ExpandedEditing } from './expanded-editing.js'; +import { applyInitialVisibility, autoEngineOptions, resolveAutoLayout, resolveInteractions } from './auto-layout-policy.js'; +import { createPositionSummary } from './position-summary.js'; +import { createAffiliationSummary } from './affiliation-summary.js'; +import { createStatusSummary } from './status-summary.js'; +import { createEmployeeInfoDialog } from './employee-info-dialog.js'; +import type { EmployeeInfoDialog } from './employee-info-dialog.js'; +import type { RenderContext } from './render.js'; +import type { OrgChartEmployeeClickContext, OrgChartHandle, OrgChartOptions, OrgChartState } from './types.js'; + +export type { OrgChartLayoutMode, OrgChartEmployeeClickContext, OrgChartHandle, OrgChartOptions, OrgChartOrganizationLinks, OrgChartState } from './types.js'; + +const owners = new WeakMap(); +let instanceSequence = 0; + +function freeze(value: T, seen = new WeakSet()): T { + if (value && typeof value === 'object' && !seen.has(value)) { + seen.add(value); + for (const child of Object.values(value)) freeze(child, seen); + Object.freeze(value); + } + return value; +} + +function snapshot(input: unknown): unknown { + // JSON objects are captured before any async boundary. A failure is controlled. + return freeze(structuredClone(input)); +} + +/** Framework-independent facade; construction has no DOM or request side effects. */ +export function createOrgChart(options: OrgChartOptions): OrgChartHandle { + return createOrgChartView(options, 'composition'); +} + +export type CoreView = 'composition' | 'tree' | 'positionSummary' | 'affiliationSummary' | 'statusSummary'; + +/** One source/validation/lifecycle controller for full and standalone views. + * Summary-only views never load the engine or require its stylesheet. */ +export function createOrgChartView(options: OrgChartOptions, view: CoreView): OrgChartHandle { + const needsEngine = view === 'composition' || view === 'tree'; + if (!options.dataSource || typeof options.dataSource.load !== 'function') throw new TypeError('An OrgChartDataSource is required'); + if (!options.stylesheetUrls?.core || (needsEngine && !options.stylesheetUrls.engine)) throw new TypeError('Provide the required OrgChart stylesheet URLs'); + const stylesheetUrls = { ...options.stylesheetUrls }; + if (options.displayMode !== undefined && options.displayMode !== 'interactive' && options.displayMode !== 'readOnly') throw new TypeError('displayMode must be "interactive" or "readOnly"'); + if (options.layoutMode !== undefined && options.layoutMode !== 'fixed' && options.layoutMode !== 'auto') throw new TypeError('layoutMode must be "fixed" or "auto"'); + const layoutMode = options.layoutMode ?? 'fixed'; + const expanded = view === 'composition' && layoutMode === 'auto'; + if (expanded && options.dragDropEnabled !== undefined && typeof options.dragDropEnabled !== 'boolean') throw new TypeError('dragDropEnabled must be a boolean'); + let dragDropEnabled = expanded && (options.dragDropEnabled ?? false); + let expandedEditing: ExpandedEditing | undefined; + let autoLayout = resolveAutoLayout(expanded ? options.autoLayout : undefined); + let interactionOptions = resolveInteractions(expanded ? options.interactions : undefined); + let resetVisibility = false; + let renderedLayoutKey: string | undefined; + 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'); + if (options.presentationMode !== undefined && options.presentationMode !== 'default' && options.presentationMode !== 'chart-only') throw new TypeError('presentationMode must be "default" or "chart-only"'); + if (options.initialView !== undefined && options.initialView !== 'center' && options.initialView !== 'fit') throw new TypeError('initialView must be "center" or "fit"'); + if (options.wheelZoom !== undefined && options.wheelZoom !== 'modifier' && options.wheelZoom !== 'direct') throw new TypeError('wheelZoom must be "modifier" or "direct"'); + // Fixed owns the approved Workdesk composition; never strip it silently. + if (view === 'composition' && options.presentationMode === 'chart-only' && !expanded) throw new TypeError('presentationMode "chart-only" requires layoutMode "auto"'); + const chartOnly = expanded && options.presentationMode === 'chart-only'; + const initialView = options.initialView ?? (chartOnly ? 'fit' : 'center'); + const wheelZoom = options.wheelZoom ?? (chartOnly ? 'direct' : 'modifier'); + 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: OrgChartDataset | undefined; + let moveValidator: ((request: OrgChartMoveRequest) => OrgChartMoveValidation) | undefined; + let moveMessage = ''; + let moveStatus = 'idle'; + let blockPointerClick = false; + let focusEmployee: string | undefined; + const collapsedOrganizations = new Set(); + const initializedOrganizations = 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'); + if (options.onMove !== undefined && typeof options.onMove !== 'function') throw new TypeError('onMove must be a function'); + let employeeClickCallback = options.onEmployeeClick; + for (const [id, value] of Object.entries(options.organizationLinks ?? {})) { + if (value === undefined || value === '') continue; + if (typeof value !== 'string') throw new TypeError('organizationLinks values must be URL strings'); + const href = value.trim(); + if (!href) continue; + let url: URL; + try { url = new URL(href, 'https://orgchart.invalid/'); } + catch { throw new TypeError('organizationLinks must contain valid HTTP(S) or relative URLs'); } + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new TypeError('organizationLinks must contain valid HTTP(S) or relative URLs'); + organizationLinks[id] = href; + } + let preferredContainer = options.container; + let source: OrgChartDataSource | undefined = options.dataSource; + let policy = options.aggregationPolicy ? freeze(structuredClone(options.aggregationPolicy)) : undefined; + let state: OrgChartState = freeze({ status: 'idle' }); + let destroyed = false; + let revision = 0; + let request: AbortController | undefined; + let container: HTMLElement | undefined; + let root: HTMLElement | undefined; + let shell: HTMLElement | undefined; + let status: HTMLElement | undefined; + let content: HTMLElement | undefined; + let viewport: HTMLElement | undefined; + let engine: EngineAdapter | undefined; + let renderContext: RenderContext | undefined; + let Engine: EngineConstructor | undefined; + let styleReady: Promise | undefined; + let cancelStyles: (() => void) | undefined; + let contentGeneration: AbortController | undefined; + let interactions: AbortController | undefined; + let employeeDialog: EmployeeInfoDialog | undefined; + const owner = Symbol('hm-orgchart-owner'); + const prefix = `hm-orgchart-${++instanceSequence}`; + const listeners = new Set<(state: OrgChartState) => void>(); + if (options.onStateChange) listeners.add(options.onStateChange); + + function active(version: number): boolean { return !destroyed && version === revision; } + + function notify(next: OrgChartState): void { + state = freeze(next); + const notifiedState = state; + for (const listener of [...listeners]) { + // Reentrant update/reload/destroy may supersede this notification. + if (destroyed || state !== notifiedState) break; + if (!listeners.has(listener)) continue; + try { listener(notifiedState); } catch { /* Consumer callbacks do not break Core lifecycle. */ } + } + } + + function assertMounted(): void { + if (destroyed) throw new Error('OrgChart handle is destroyed; create a new handle'); + if (!container) throw new Error('Mount OrgChart before update or reload'); + } + + const moveRequest = (source: HTMLElement, target: HTMLElement): OrgChartMoveRequest => ({ + kind: source.matches('.hm-orgchart-employee') ? 'employee' : 'organization', + sourceId: source.dataset.employeeId ?? source.dataset.organizationId!, targetOrganizationId: target.dataset.organizationId!, + }); + function validateMove(request: OrgChartMoveRequest): OrgChartMoveValidation { + if (!dragDropEnabled) return { allowed: false, code: 'EDITING_DISABLED', message: 'Drag & Drop이 OFF입니다. 조직 이동을 사용하려면 ON으로 설정하세요.' }; + // An explicitly configured company top card also owns a protected reference. + if (request?.kind === 'employee' && Object.values(topNodes).some(top => top.employeeId === request.sourceId)) { + return { allowed: false, code: 'LEADER_PROTECTED', message: '회사 상단에 지정된 직원은 상단 직원 지정 정책을 먼저 변경해야 이동할 수 있습니다.' }; + } + return moveValidator?.(request) ?? { allowed: false, code: 'INVALID_DATA', message: '조직 데이터를 불러온 뒤 이동하세요.' }; + } + function notifyMove(result: OrgChartMoveResult): void { + try { options.onMove?.(result); } catch { /* Host callbacks never own the Core transaction. */ } + } + async function move(request: OrgChartMoveRequest): Promise { + assertMounted(); + if (!expanded) throw new Error('Organization editing is available only in Auto mode'); + const policy = validateMove(request); + let result: OrgChartMoveResult = !latestData || state.status !== 'ready' + ? { moved: false, code: 'INVALID_DATA', message: '조직 데이터를 불러온 뒤 이동하세요.' } + : !policy.allowed ? { moved: false, code: policy.code, message: policy.message } + : applyOrgChartMove(latestData, request); + if (!result.moved) { + moveMessage = `이동 실패 · ${result.message}`; moveStatus = 'failure'; + const feedback = content?.querySelector('.hm-orgchart-move-feedback'); + if (feedback) { feedback.textContent = moveMessage; feedback.dataset.moveStatus = moveStatus; } + notifyMove(result); return result; + } + const label = request.kind === 'employee' ? result.data.employees.find(item => item.id === request.sourceId)?.name + : result.data.organizations.find(item => item.id === request.sourceId)?.name; + const target = result.data.organizations.find(item => item.id === request.targetOrganizationId)!; + moveMessage = `이동 완료 · ${label} → ${target.name}`; moveStatus = 'success'; + focusEmployee = request.kind === 'employee' ? request.sourceId : undefined; + focusOrganization = request.targetOrganizationId; fitNext = false; + const { version } = begin(); + try { await apply(result.data, version); } + catch { fail(version, [{ code: 'CORE_RENDER', path: 'core', message: 'Move rerender failed' }]); } + if (!active(version) || state.status !== 'ready') result = { moved: false, code: 'INVALID_DATA', message: '이동 중 다른 데이터 요청이 시작되었거나 화면을 갱신할 수 없습니다.' }; + if (active(version)) notifyMove(result); + return result; + } + + function begin(): { version: number; signal: AbortSignal } { + request?.abort(); + const Abort = container?.ownerDocument.defaultView?.AbortController ?? AbortController; + request = new Abort(); + const version = ++revision; + status!.textContent = '조직도를 불러오는 중입니다.'; + shell!.setAttribute('aria-busy', 'true'); + const signal = request.signal; + notify({ status: 'loading' }); + return { version, signal }; + } + + function releaseView(): void { + employeeDialog?.close(); + contentGeneration?.abort(); + contentGeneration = undefined; + expandedEditing = undefined; + engine?.dispose(); + engine = undefined; + renderContext = undefined; + viewport = undefined; + expandedViewport = undefined; + latestData = undefined; + moveValidator = undefined; + content?.replaceChildren(); + } + + function activateEmployee(event: MouseEvent | KeyboardEvent): void { + if (event.defaultPrevented || !renderContext || !content) return; + const Element = content.ownerDocument.defaultView?.Element; + if (!Element || !(event.target instanceof Element)) return; + // Links, collapse buttons and any future nested controls own their events. + if (event.target.closest('a, button, input, select, textarea')) return; + const card = event.target.closest('.hm-orgchart-employee'); + if (!card || !content.contains(card)) return; + const employee = renderContext.data.employees.find(item => item.id === card.dataset.employeeId); + if (!employee) return; + event.preventDefault(); + event.stopPropagation(); + card.focus({ preventScroll: true }); + // A name tooltip can be open from pointer hover. Close it before opening + // information UI; the existing hover/focus tooltip behavior stays intact. + for (const tip of shell?.querySelectorAll('.hm-orgchart-person-name-tooltip') ?? []) tip.hidden = true; + for (const name of content.querySelectorAll('[data-tooltip-open="true"]')) name.dataset.tooltipOpen = 'false'; + const context: OrgChartEmployeeClickContext = { + organization: renderContext.data.organizations.find(item => item.id === employee.organizationId), + affiliation: renderContext.data.affiliations.find(item => item.id === employee.affiliationId), + trigger: card, + originalEvent: event, + }; + if (employeeClickCallback) { + try { employeeClickCallback(employee, context); } + catch { /* Consumer callbacks do not interrupt Core event/lifecycle ownership. */ } + } else employeeDialog?.show(employee, context); + } + + function prepareEmployeeCards(element: HTMLElement): void { + for (const card of element.querySelectorAll('.hm-orgchart-employee')) { + card.tabIndex = 0; + card.setAttribute('role', 'button'); + if (!employeeClickCallback) card.setAttribute('aria-haspopup', 'dialog'); + else card.removeAttribute('aria-haspopup'); + const name = card.querySelector('.hm-orgchart-person-name')?.textContent ?? ''; + const position = card.querySelector('.hm-orgchart-person-role')?.textContent ?? ''; + card.setAttribute('aria-label', `${name} ${position} 직원 정보 보기`); + } + } + + function fail(version: number, issues: readonly ValidationIssue[]): void { + if (!active(version)) return; + releaseView(); + shell!.setAttribute('aria-busy', 'false'); + status!.textContent = '조직도를 표시할 수 없습니다.'; + const document = content!.ownerDocument; + const list = document.createElement('ul'); + list.className = 'hm-orgchart-issues'; + for (const issue of issues) { + const row = document.createElement('li'); + row.textContent = `${issue.code} · ${issue.path}: ${issue.message}`; + list.append(row); + } + content!.append(list); + notify({ status: 'error', issues }); + } + + async function apply(input: unknown, version: number): Promise { + if (!active(version)) return; + let captured: unknown; + try { captured = snapshot(input); } + catch { + fail(version, [{ code: 'INPUT_SNAPSHOT', path: '$', message: 'Input must be a cloneable JSON dataset' }]); + return; + } + const validation = validateOrgChartDataset(captured); + if (!validation.valid) { fail(version, validation.issues); return; } + const data = freeze(validation.data); + const normalized = normalizeOrgChartDataset(data); + const 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; + applyInitialVisibility(data.organizations, collapsedOrganizations, initializedOrganizations, autoLayout.initialVisibleDepth, resetVisibility); + resetVisibility = false; + latestData = data; + moveValidator = createOrgChartMoveValidator(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, + organizationCompanies: companyState?.companies, collapsedOrganizations: expanded ? collapsedOrganizations : undefined, + companyTopNodes: topNodes, employeeConnections, autoLayout: expanded ? autoLayout : undefined, interactions: expanded ? interactionOptions : undefined, toggleExpanded: id => { + if (destroyed || !latestData || !interactionOptions.collapse) 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 ? 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 styleIssues = await styleReady; + if (!active(version)) return; + if (styleIssues) { fail(version, styleIssues); return; } + const LoadedEngine = needsEngine ? Engine ?? await loadEngine() : undefined; + // Last-started operation wins even if an upstream engine import was pending. + if (!active(version)) return; + if (LoadedEngine) Engine = LoadedEngine; + const document = content!.ownerDocument; + const previousViewport = expandedViewport?.state(); + const previousFitScale = expandedViewport?.fitScale(); + const previousManualPosition = expandedViewport?.hasManualPosition(); + employeeDialog?.close(); + contentGeneration?.abort(); + const Abort = document.defaultView?.AbortController ?? AbortController; + contentGeneration = new Abort(); + renderContext = nextContext; + if (needsEngine && !viewport) { + viewport = createOrgChartTree(document); + content!.append(viewport); + } + const activeElement = root!.shadowRoot?.activeElement; + if (needsEngine) { + const layoutKey = expanded ? `${autoLayout.type}:${autoLayout.hybridStartDepth}` : 'fixed'; + // Upstream init merges old options. Rebuild only the engine when changing + // direction/vertical policy so obsolete verticalLevel cannot survive LR. + if (engine && renderedLayoutKey !== layoutKey) { engine.dispose(); engine = undefined; } + if (engine) engine.update(tree); + else engine = createEngineAdapter(Engine!, viewport!, tree, (node, value, signal) => { + if (renderContext) decorateNode(node, value, renderContext, signal, target => engine?.toggle(target)); + }, expanded ? autoEngineOptions(autoLayout) : undefined, expanded ? { + canDrop: (source, target) => validateMove(moveRequest(source, target)).allowed, + onDrop: (source, target) => { void move(moveRequest(source, target)); }, + } : undefined); + renderedLayoutKey = layoutKey; + } + // If regeneration removed keyboard focus, offer the stable viewport next. + const nextContent = view === 'composition' ? expanded + ? createExpandedOrgChartContent(document, nextContext, viewport!, contentGeneration.signal, () => expandedViewport?.layoutReady(), { chartOnly, wheelZoom }) + : 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) { + // Chart Only keeps the same viewport controller without its visible toolbar. + const toolbar = chartOnly ? undefined : document.createElement('div'); + if (toolbar) { + toolbar.className = 'hm-orgchart-expanded-toolbar'; + nextContent.insertBefore(toolbar, viewport!); + } + // A fitted replacement starts from the new forest, not the previous Pan/Zoom. + const resetView = fitNext && initialView === 'fit'; + expandedViewport = createExpandedViewport(viewport!, toolbar, contentGeneration.signal, + { ...zoom, zoomEnabled: interactionOptions.zoom, panEnabled: interactionOptions.pan, wheelZoom, fitFloor: initialView === 'fit', initialFitScale: resetView ? undefined : previousFitScale }, + resetView ? undefined : previousViewport, fitNext ? initialView : undefined, !resetView && !!previousManualPosition); + fitNext = false; + const feedback = document.createElement('p'); + feedback.className = 'hm-orgchart-move-feedback'; feedback.id = `${prefix}-move-feedback`; + feedback.setAttribute('role', 'status'); feedback.setAttribute('aria-live', 'polite'); feedback.setAttribute('aria-atomic', 'true'); + feedback.dataset.moveStatus = moveStatus; + feedback.textContent = moveMessage || (dragDropEnabled + ? 'Drag & Drop ON · 카드/제목: 이동 · Alt+M: 키보드 이동 · 빈 공간: Pan' + : 'Drag & Drop OFF · 직원 정보·그룹 접기·빈 공간 Pan 사용 가능'); + nextContent.insertBefore(feedback, viewport!); + viewport!.dataset.dragDropEnabled = String(dragDropEnabled); + expandedEditing = createExpandedEditing(viewport!, feedback, contentGeneration.signal, engine!, validateMove, + request => { void move(request); }, () => { blockPointerClick = true; }, dragDropEnabled); + for (const source of nextContent.querySelectorAll('.hm-orgchart-employee, .hm-orgchart-expanded-toggle')) { + if (dragDropEnabled) source.setAttribute('aria-keyshortcuts', 'Alt+M'); + else source.removeAttribute('aria-keyshortcuts'); + source.setAttribute('aria-describedby', feedback.id); + } + if (toolbar) { + 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); + const focusTarget = (focusEmployee ? [...content!.querySelectorAll('.hm-orgchart-employee')].find(card => card.dataset.employeeId === focusEmployee) : undefined) + ?? (focusOrganization ? [...content!.querySelectorAll('.hm-orgchart-expanded-toggle')].find(button => button.dataset.organizationId === focusOrganization) : undefined); + focusEmployee = 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; + notify({ status: 'ready', dataVersion: data.metadata.dataVersion, summary, aggregation: summary.aggregation }); + } + + async function reload(nextSource?: OrgChartDataSource): Promise { + assertMounted(); + if (expanded) { fitNext = true; moveMessage = ''; moveStatus = 'idle'; } + if (nextSource) { + if (typeof nextSource.load !== 'function') throw new TypeError('An OrgChartDataSource is required'); + source = nextSource; + } + const { version, signal } = begin(); + if (!active(version)) return; + let input: unknown; + try { + input = await source!.load({ signal }); + } catch (error) { + if (!active(version) || signal.aborted) return; + const sourceCode = error && typeof error === 'object' && 'code' in error && ['SOURCE_LOAD', 'SOURCE_PARSE', 'SOURCE_MAP', 'SOURCE_URL', 'SOURCE_SNAPSHOT'].includes(String(error.code)) ? String(error.code) : 'SOURCE_LOAD'; + fail(version, [{ code: sourceCode, path: 'dataSource', message: 'Data source or chart rendering failed; verify the source and CSS configuration' }]); + return; + } + try { if (active(version)) await apply(input, version); } + catch { fail(version, [{ code: 'CORE_RENDER', path: 'core', message: 'Chart rendering failed' }]); } + } + + return { + async mount(nextContainer = preferredContainer) { + if (destroyed) throw new Error('OrgChart handle is destroyed; create a new handle'); + if (container) throw new Error('OrgChart is already mounted'); + const Element = nextContainer?.ownerDocument.defaultView?.HTMLElement; + if (!Element || !(nextContainer instanceof Element) || !nextContainer.isConnected) throw new TypeError('Mount requires a connected HTMLElement'); + if (owners.has(nextContainer)) throw new Error('Another OrgChart instance owns this container'); + container = nextContainer; + owners.set(container, owner); + const document = container.ownerDocument; + root = document.createElement('div'); + root.className = 'hm-orgchart-host'; + root.dataset.orgchartInstance = prefix; + if (chartOnly) root.dataset.presentationMode = 'chart-only'; + const shadow = root.attachShadow({ mode: 'open' }); + const styleDisposers: (() => void)[] = []; + const styles = [...(needsEngine ? [{ key: 'engine', href: stylesheetUrls.engine }] : []), { key: 'core', href: stylesheetUrls.core }]; + styleReady = Promise.all(styles.map(({ key, href }) => new Promise(resolve => { + const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = href; + let settled = false; + const finish = (issue?: ValidationIssue) => { + if (settled) return; + settled = true; + link.removeEventListener('load', loaded); link.removeEventListener('error', failed); + resolve(issue); + }; + const loaded = () => finish(); + const failed = () => finish({ code: 'CORE_STYLE', path: `stylesheetUrls.${key}`, message: 'Required OrgChart stylesheet failed to load' }); + link.addEventListener('load', loaded); link.addEventListener('error', failed); + styleDisposers.push(() => finish()); + shadow.append(link); + }))).then(results => { + const issues = results.filter((issue): issue is ValidationIssue => !!issue); + return issues.length ? issues : undefined; + }); + cancelStyles = () => { for (const dispose of styleDisposers) dispose(); }; + shell = document.createElement('section'); + shell.className = 'hm-orgchart-shell'; + shell.dataset.layoutMode = view === 'composition' ? layoutMode : 'fixed'; + if (chartOnly) shell.dataset.presentationMode = 'chart-only'; + // The Core's Korean labels must shape identically in ko HTML and en Storybook hosts. + shell.lang = 'ko'; + shell.tabIndex = -1; + shell.setAttribute('aria-label', '조직도 콘텐츠'); + status = document.createElement('p'); status.className = 'hm-orgchart-state'; status.setAttribute('role', 'status'); status.setAttribute('aria-live', 'polite'); + content = document.createElement('div'); content.className = 'hm-orgchart-content'; + shell.append(status, content); shadow.append(shell); container.append(root); + const Abort = document.defaultView?.AbortController ?? AbortController; + interactions = new Abort(); + employeeDialog = createEmployeeInfoDialog(shell, interactions.signal, prefix, readOnly); + if (expanded) { + // This capture guard survives the generation replaced by a successful drop. + shell.addEventListener('pointerdown', () => { blockPointerClick = false; }, { capture: true, signal: interactions.signal }); + shell.addEventListener('click', event => { + if (!blockPointerClick || event.detail === 0) return; + blockPointerClick = false; event.preventDefault(); event.stopImmediatePropagation(); + }, { capture: true, signal: interactions.signal }); + } + content.addEventListener('click', activateEmployee, { signal: interactions.signal }); + content.addEventListener('keydown', event => { + if (event.key === 'Enter' || event.key === ' ') activateEmployee(event); + }, { signal: interactions.signal }); + await reload(); + }, + async update(input) { + assertMounted(); + if (expanded) fitNext = true; + // Capture before begin() invokes callbacks that can mutate consumer input. + let captured: unknown; + try { captured = snapshot(input); } + catch { + const { version } = begin(); + fail(version, [{ code: 'INPUT_SNAPSHOT', path: '$', message: 'Input must be a cloneable JSON dataset' }]); + return; + } + const { version } = begin(); + if (!active(version)) return; + try { await apply(captured, version); } + catch { fail(version, [{ code: 'CORE_RENDER', path: 'core', message: 'Chart rendering failed' }]); } + }, + reload, + destroy() { + if (destroyed) return; + destroyed = true; + ++revision; + request?.abort(); request = undefined; + cancelStyles?.(); cancelStyles = undefined; styleReady = undefined; + interactions?.abort(); interactions = undefined; + employeeDialog = undefined; + releaseView(); + root?.remove(); + if (container && owners.get(container) === owner) owners.delete(container); + container = undefined; preferredContainer = undefined; + root = undefined; shell = undefined; status = undefined; content = undefined; + source = undefined; policy = undefined; Engine = undefined; + listeners.clear(); + state = freeze({ status: 'destroyed' }); + }, + subscribe(listener) { + if (destroyed) throw new Error('OrgChart handle is destroyed; create a new handle'); + if (typeof listener !== 'function') throw new TypeError('Listener must be a function'); + listeners.add(listener); + return () => { listeners.delete(listener); }; + }, + getState() { return state; }, + 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)]); }, + async setAutoLayout(patch) { + assertMounted(); + if (!expanded) throw new Error('Auto layout configuration is available only in Auto mode'); + const next = resolveAutoLayout({ ...autoLayout, ...patch }); + resetVisibility = resetVisibility || next.initialVisibleDepth !== autoLayout.initialVisibleDepth; + autoLayout = next; fitNext = true; + if (latestData) { const { version } = begin(); await apply(latestData, version); } + }, + getAutoLayout() { return autoLayout; }, + setEmployeeClickHandler(handler) { + assertMounted(); + if (!expanded) throw new Error('Popup ownership changes are available only in Auto mode'); + if (handler !== undefined && typeof handler !== 'function') throw new TypeError('Employee click handler must be a function or undefined'); + employeeDialog?.close(); + employeeClickCallback = handler; + if (content) prepareEmployeeCards(content); + }, + async setInteractions(patch) { + assertMounted(); + if (!expanded) throw new Error('Interaction configuration is available only in Auto mode'); + interactionOptions = resolveInteractions({ ...interactionOptions, ...patch }); + fitNext = false; + if (latestData) { const { version } = begin(); await apply(latestData, version); } + }, + getInteractions() { return interactionOptions; }, + setDragDropEnabled(enabled) { + assertMounted(); + if (!expanded) throw new Error('Drag & Drop configuration is available only in Auto mode'); + if (typeof enabled !== 'boolean') throw new TypeError('dragDropEnabled must be a boolean'); + if (enabled === dragDropEnabled) return; + dragDropEnabled = enabled; + expandedEditing?.setEnabled(enabled); + if (viewport) viewport.dataset.dragDropEnabled = String(enabled); + const feedback = content?.querySelector('.hm-orgchart-move-feedback'); + const cancelled = feedback?.dataset.moveStatus === 'cancelled'; + moveMessage = ''; moveStatus = cancelled ? 'cancelled' : 'idle'; + if (feedback) { + feedback.dataset.moveStatus = moveStatus; + feedback.textContent = (cancelled ? '이동을 취소했습니다. ' : '') + (enabled + ? 'Drag & Drop ON · 카드/제목: 이동 · Alt+M: 키보드 이동 · 빈 공간: Pan' + : 'Drag & Drop OFF · 직원 정보·그룹 접기·빈 공간 Pan 사용 가능'); + } + for (const source of content?.querySelectorAll('.hm-orgchart-employee, .hm-orgchart-expanded-toggle') ?? []) { + if (enabled) source.setAttribute('aria-keyshortcuts', 'Alt+M'); + else source.removeAttribute('aria-keyshortcuts'); + } + }, + getDragDropEnabled() { return dragDropEnabled; }, + move, + getData() { return latestData; }, + zoomTo(scale) { if (interactionOptions.zoom) expandedViewport?.zoomTo(scale); }, + panBy(x, y) { if (interactionOptions.pan) expandedViewport?.panBy(x, y); }, + fitToView() { expandedViewport?.fit(); }, + getViewportState() { return expandedViewport?.state(); }, + }; +} diff --git a/packages/org-chart/src/core/logo-legend.ts b/packages/org-chart/src/core/logo-legend.ts new file mode 100644 index 0000000..4ec0f51 --- /dev/null +++ b/packages/org-chart/src/core/logo-legend.ts @@ -0,0 +1,17 @@ +import type { RenderContext } from './render'; +import { createAffiliationMark } from './affiliation-mark'; + +export function createLogoLegend(document: Document, context: RenderContext, signal: AbortSignal): HTMLElement { + const section = document.createElement('section'); + section.className = 'hm-orgchart-logo-legend'; + const heading = document.createElement('h2'); heading.textContent = '관계사·회사 로고'; + const list = document.createElement('ul'); list.className = 'hm-orgchart-logo-list'; + for (const affiliation of context.data.affiliations) { + const item = document.createElement('li'); + const name = document.createElement('span'); name.textContent = affiliation.name; + item.append(createAffiliationMark(document, affiliation.id, affiliation.name, signal), name); + list.append(item); + } + section.append(heading, list); + return section; +} diff --git a/packages/org-chart/src/core/org-chart-content.ts b/packages/org-chart/src/core/org-chart-content.ts new file mode 100644 index 0000000..b681977 --- /dev/null +++ b/packages/org-chart/src/core/org-chart-content.ts @@ -0,0 +1,247 @@ +import { createEmployeeCard } from './employee-card.js'; +import { createHeading, type RenderContext } from './render.js'; +import { createPositionSummary } from './position-summary.js'; +import { createAffiliationSummary } from './affiliation-summary.js'; +import { createStatusSummary } from './status-summary.js'; +import { representativeCardAsset } from './representative-card-asset.js'; +import { createOrganizationTotals } from './organization-totals.js'; +import { createAffiliationBar } from './affiliation-bar.js'; +import { applyWorkdeskLayout } from './workdesk-layout.js'; +import { addWorkdeskArtwork } from './workdesk-artwork.js'; +import { workdeskCanvas } from './workdesk-layout.js'; + +type SummaryComponent = 'PositionSummary' | 'AffiliationSummary' | 'StatusSummary'; +type ContentSlot = 'side' | 'footer' | 'belowTree'; +export interface ContentComposition { + placements: Record; +} +/** Internal composition contract: move modules here without editing the tree, + * recalculating data, adding React UI, or changing the npm public API. */ +export const defaultComposition: ContentComposition = { + placements: { PositionSummary: 'side', AffiliationSummary: 'footer', StatusSummary: 'belowTree' }, +}; + +export function createOrgChartContent( + document: Document, context: RenderContext, tree: HTMLElement, + signal: AbortSignal, composition: ContentComposition = defaultComposition, +): HTMLElement { + // Scale wrapper (`content`) > Base Stage (`stage`). Only the stage is transformed. + const readOnly = context.readOnly === true; + const content = document.createElement('div'); + content.className = 'hm-orgchart-composition'; + content.dataset.component = 'OrgChartContent'; + content.dataset.displayMode = readOnly ? 'readOnly' : 'interactive'; + const stage = document.createElement('div'); + stage.className = 'hm-orgchart-stage'; + content.append(stage); + const main = document.createElement('div'); + main.className = 'hm-orgchart-main'; + const heading = createHeading(document, context); + const leaders = document.createElement('section'); + leaders.className = 'hm-orgchart-top-leaders'; + leaders.setAttribute('aria-label', '최상위 조직 리더'); + for (const id of context.topLeaderIds) { + const employee = context.data.employees.find(item => item.id === id)!; + leaders.append(createEmployeeCard(document, employee, { + kind: 'topLeader', + workdeskPresentation: readOnly, + cardAsset: representativeCardAsset, + affiliationName: context.data.affiliations.find(item => item.id === employee.affiliationId)?.name, + }, signal)); + } + const hub = document.createElement('p'); + hub.className = 'hm-orgchart-hub'; + hub.dataset.component = 'CollaborationHub'; + hub.textContent = context.data.presentation?.hubText ?? ''; + if (readOnly && hub.textContent) { + // Actual inline icon from Workdesk index.html .hub-icon; Frame2 is decoration. + const label = document.createElement('span'); + label.className = 'hm-orgchart-hub-label'; + label.textContent = hub.textContent; + const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + icon.classList.add('hm-orgchart-hub-icon'); + icon.setAttribute('viewBox', '0 0 24 24'); + icon.setAttribute('fill', 'none'); + icon.setAttribute('aria-hidden', 'true'); + icon.setAttribute('focusable', 'false'); + for (const d of ['M8.5 11.2h7', 'M8.5 15h4.7', 'M7.2 18.6 4 21v-4.7a7.2 7.2 0 0 1-1.6-4.5C2.4 7.6 6.2 4.2 11 4.2s8.6 3.4 8.6 7.6-3.8 7.6-8.6 7.6c-1.3 0-2.6-.3-3.8-.8Z']) { + const path = document.createElementNS(icon.namespaceURI, 'path'); + path.setAttribute('d', d); + icon.append(path); + } + hub.replaceChildren(icon, label); + } + if (readOnly) { + // Title on the left; the top leader and hub share the canvas axis column. + const axis = document.createElement('div'); + axis.className = 'hm-orgchart-axis'; + if (leaders.childElementCount) axis.append(leaders); + if (hub.textContent) axis.append(hub); + main.append(heading, axis); + // No scroll container in the fitted canvas: a labelled, non-focusable region. + tree.removeAttribute('tabindex'); + tree.setAttribute('aria-label', '조직 계층도'); + if (applyWorkdeskLayout(tree)) addWorkdeskArtwork(stage, hub, signal); + } else { + const intro = document.createElement('div'); + intro.className = 'hm-orgchart-intro'; + intro.append(heading); + if (leaders.childElementCount) intro.append(leaders); + main.append(intro); + if (hub.textContent) main.append(hub); + } + main.append(tree); + const slots = { side: document.createElement('aside'), footer: document.createElement('div'), belowTree: document.createElement('div') }; + for (const [slot, element] of Object.entries(slots)) { + element.className = `hm-orgchart-slot-${slot}`; + element.dataset.contentSlot = slot; + } + slots.side.setAttribute('aria-label', '조직도 인원 현황'); + const groups = context.data.presentation?.groups; + // Each module consumes the same context and can be hidden independently. + // Read-only uses compact presenters of the same Summary; standalone components are unchanged. + const modules: [SummaryComponent, boolean, () => HTMLElement][] = [ + ['PositionSummary', groups?.showSummary !== false, () => readOnly ? createOrganizationTotals(document, context) : createPositionSummary(document, context)], + ['AffiliationSummary', groups?.showAffiliations !== false, () => readOnly ? createAffiliationBar(document, context, signal) : createAffiliationSummary(document, context)], + ['StatusSummary', groups?.showDispatched !== false || groups?.showOnLeave !== false, () => createStatusSummary(document, context, signal, { compact: readOnly })], + ]; + for (const [name, visible, create] of modules) { + if (!visible) continue; + const element = create(); + element.dataset.component = name; + slots[composition.placements[name]].append(element); + } + if (slots.belowTree.childElementCount) main.append(slots.belowTree); + stage.append(main); + if (slots.side.childElementCount) stage.append(slots.side); + else content.classList.add('hm-orgchart-composition-no-side'); + if (slots.footer.childElementCount) stage.append(slots.footer); + if (stage.dataset.workdeskArtwork) { + heading.dataset.compositionRegion = slots.side.dataset.compositionRegion = 'top'; + main.querySelector('.hm-orgchart-axis')!.dataset.compositionRegion = tree.dataset.compositionRegion = 'center'; + slots.belowTree.dataset.compositionRegion = slots.footer.dataset.compositionRegion = 'bottom'; + } + observeStageScale(content, stage, signal, readOnly); + return content; +} + +/** Proportional fit: one uniform scale for the whole stage, never above 1. + * The stage's offsetWidth/Height are unscaled layout sizes, so the wrapper + * height is derived as height * scale. One observer and one pending frame per + * instance; both end when the content generation aborts. + * `fitHeight` fits the actual containing content box and viewport. It never + * assumes a host Header or reserves an arbitrary 48px. */ +function observeStageScale(wrapper: HTMLElement, stage: HTMLElement, signal: AbortSignal, fitHeight = false): void { + const view = wrapper.ownerDocument.defaultView; + if (!view) return; + let frame: number | undefined; + let applied = { scale: NaN, width: NaN, height: NaN, canvasHeight: NaN, centerOffset: 0 }; + const ancestors: HTMLElement[] = []; + const collectAncestors = () => { + // The constructor runs before insertion; discover the real host only once + // the wrapper is connected. Refresh when a host reparents its chart too. + const next: HTMLElement[] = []; + let ancestor: HTMLElement | null = wrapper.parentElement; + while (ancestor) { + next.push(ancestor); + const parent: HTMLElement | null = ancestor.parentElement; + const root = ancestor.getRootNode(); + ancestor = parent ?? (root instanceof view.ShadowRoot ? root.host as HTMLElement : null); + } + if (fitHeight) { + for (const element of ancestors) if (!next.includes(element)) observer.unobserve(element); + for (const element of next) if (!ancestors.includes(element)) observer.observe(element); + } + ancestors.splice(0, ancestors.length, ...next); + }; + const heightLimit = () => { + const top = wrapper.getBoundingClientRect().top; + let bottom = view.innerHeight; + let trailingPadding = 0; + for (const element of ancestors) { + const css = view.getComputedStyle(element); + const padding = parseFloat(css.paddingBottom) || 0; + trailingPadding += padding; + // Typed OM keeps 'auto' distinct from a resolved natural pixel height. + // Falling back to an inline height still supports explicit host containers. + const typed = (element as HTMLElement & { computedStyleMap?: () => Map }).computedStyleMap?.(); + const height = typed?.get('height')?.toString() ?? element.style.height; + const boundedHeight = (!!height && height !== 'auto') || css.maxHeight !== 'none'; + const constrained = boundedHeight && element !== wrapper.ownerDocument.body && element !== wrapper.ownerDocument.documentElement; + if (constrained) { + const rect = element.getBoundingClientRect(); + bottom = Math.min(bottom, rect.bottom - (parseFloat(css.borderBottomWidth) || 0) - trailingPadding); + } + } + return Math.max(0, Math.min(bottom - top, view.innerHeight - top - trailingPadding)); + }; + const apply = () => { + frame = undefined; + if (signal.aborted || !wrapper.isConnected) return; + collectAncestors(); + const available = wrapper.clientWidth; // integer floor: never overflows the host + const baseWidth = stage.offsetWidth; + const adaptive = fitHeight && stage.dataset.workdeskArtwork === 'true'; + const baseHeight = adaptive ? workdeskCanvas.height : stage.offsetHeight; + if (!available || !baseWidth || !baseHeight) return; + let scale = Math.min(1, available / baseWidth); + const availableHeight = fitHeight ? heightLimit() : baseHeight * scale; + if (fitHeight) { + scale = Math.min(scale, availableHeight / baseHeight); + } + if (!adaptive && Math.abs(scale - applied.scale) < 0.0005 && baseWidth === applied.width && baseHeight === applied.height) return; + if (adaptive && !scale) return; + const canvasHeight = adaptive ? Math.max(baseHeight, availableHeight / scale) : baseHeight; + const scaleChanged = scale !== applied.scale; + if (Math.abs(scale - applied.scale) >= 0.000001 || baseWidth !== applied.width || canvasHeight !== applied.canvasHeight) { + stage.style.transform = scale < 1 ? `scale(${scale})` : ''; + if (adaptive) stage.style.setProperty('--hm-orgchart-workdesk-stage-height', `${canvasHeight}px`); + wrapper.style.height = `${canvasHeight * scale}px`; + } + let centerOffset = 0; + if (adaptive && canvasHeight > baseHeight + 0.01) { + const origin = stage.getBoundingClientRect(); + const local = (element: Element) => { + const rect = element.getBoundingClientRect(); + return { top: (rect.top - origin.top) / scale, bottom: (rect.bottom - origin.top) / scale }; + }; + const center = [...stage.querySelectorAll('.hm-orgchart-axis .hm-orgchart-employee,.hm-orgchart-hub,.hm-orgchart-viewport .hm-orgchart-employee,.hm-orgchart-viewport .hm-orgchart-organization-name,image[data-arrow]')] + .filter(element => element.getClientRects().length).map(local); + const top = [...stage.querySelectorAll('[data-composition-region="top"]')].map(local); + const bottom = [...stage.querySelectorAll('[data-composition-region="bottom"]')].filter(element => element.childElementCount).map(local); + if (center.length && top.length) { + const start = Math.min(...center.map(rect => rect.top)) - applied.centerOffset; + const end = Math.max(...center.map(rect => rect.bottom)) - applied.centerOffset; + const bottomAnchor = bottom.length ? Math.min(...bottom.map(rect => rect.top)) : canvasHeight - (parseFloat(view.getComputedStyle(stage).paddingBottom) || 0); + const target = (Math.max(...top.map(rect => rect.bottom)) + bottomAnchor - start - end) / 2; + // Keep the baseline intact when the screen is short; share only newly + // available height until the entire central chart fits between anchors. + centerOffset = Math.max(0, Math.min(canvasHeight - baseHeight, target)); + } + } + if (adaptive && (Math.abs(centerOffset - applied.centerOffset) > 0.01 || !Number.isFinite(applied.scale))) { + stage.style.setProperty('--hm-orgchart-workdesk-center-offset', `${centerOffset.toFixed(4)}px`); + } else centerOffset = applied.centerOffset; + const positionChanged = adaptive && (Math.abs(canvasHeight - applied.canvasHeight) > 0.01 || Math.abs(centerOffset - applied.centerOffset) > 0.01); + applied = { scale, width: baseWidth, height: baseHeight, canvasHeight, centerOffset }; + wrapper.dataset.scale = scale.toFixed(4); + wrapper.dataset.baseWidth = String(baseWidth); + wrapper.dataset.baseHeight = String(baseHeight); + if (adaptive) { wrapper.dataset.canvasHeight = String(canvasHeight); wrapper.dataset.centerOffset = centerOffset.toFixed(4); } + // Open tooltips use the old card bounds. A bounded parent can move the + // central chart without changing scale or emitting a window resize. + if (scaleChanged || positionChanged) wrapper.dispatchEvent(new view.CustomEvent('hm-orgchart-scale', { bubbles: true })); + }; + const schedule = () => { if (frame === undefined && !signal.aborted) frame = view.requestAnimationFrame(apply); }; + const observer = new view.ResizeObserver(schedule); + observer.observe(wrapper); + observer.observe(stage); + wrapper.ownerDocument.fonts?.addEventListener('loadingdone', schedule, { signal }); + // Viewport height changes do not resize the wrapper; observe them directly. + if (fitHeight) view.addEventListener('resize', schedule, { signal }); + signal.addEventListener('abort', () => { + observer.disconnect(); + if (frame !== undefined) view.cancelAnimationFrame(frame); + frame = undefined; + }, { once: true }); +} diff --git a/packages/org-chart/src/core/org-chart-tree.ts b/packages/org-chart/src/core/org-chart-tree.ts new file mode 100644 index 0000000..4472fbb --- /dev/null +++ b/packages/org-chart/src/core/org-chart-tree.ts @@ -0,0 +1,10 @@ +/** Stable engine/focus boundary; summaries never become engine nodes. */ +export function createOrgChartTree(document: Document): HTMLElement { + const viewport = document.createElement('div'); + viewport.className = 'hm-orgchart-viewport'; + viewport.dataset.component = 'OrgChartTree'; + viewport.tabIndex = 0; + viewport.setAttribute('role', 'region'); + viewport.setAttribute('aria-label', '조직 계층도 가로 스크롤 영역'); + return viewport; +} diff --git a/packages/org-chart/src/core/organization-totals.ts b/packages/org-chart/src/core/organization-totals.ts new file mode 100644 index 0000000..d29fa83 --- /dev/null +++ b/packages/org-chart/src/core/organization-totals.ts @@ -0,0 +1,95 @@ +import { selectMajorOrganizationTotals, selectOrganizationPositionCounts } from '../data/content-selectors.js'; +import type { RenderContext } from './render.js'; + +function text(document: Document, tag: string, className: string, value: string): HTMLElement { + const element = document.createElement(tag); + element.className = className; + element.textContent = value; + return element; +} + +/** Read-only Workdesk matrix: disjoint major organization rows, position + * columns and one subtotal. The independent PositionSummary retains its + * detailed direct/inclusive counts, aggregation policy and public API. */ +export function createOrganizationTotals(document: Document, context: RenderContext): HTMLElement { + const section = document.createElement('section'); + section.className = 'hm-orgchart-organization-totals'; + section.dataset.contentSection = 'organization-totals'; + const title = text(document, 'h3', 'hm-orgchart-organization-totals-title', '직위별 인원 현황 (팀별)'); + section.append(title); + + // Keep the source's five position columns where they exist. Additional + // declared positions remain visible rather than being dropped or relabelled. + const declaredPositions = Object.keys(context.summary.raw.byPosition); + const preferredPositions = ['경영진', '수석', '책임', '선임', '연구']; + const positions = [ + ...preferredPositions.filter(position => declaredPositions.includes(position)), + ...declaredPositions.filter(position => !preferredPositions.includes(position)), + ]; + section.style.setProperty('--hm-orgchart-workdesk-summary-width', `${84 + (positions.length + 1) * 41}px`); + const matrix = selectOrganizationPositionCounts(context.data); + const children = new Map(); + for (const organization of context.data.organizations) { + if (organization.parentId === null) continue; + const siblings = children.get(organization.parentId) ?? []; + siblings.push(organization.id); + children.set(organization.parentId, siblings); + } + const frame = document.createElement('div'); + frame.className = 'hm-orgchart-workdesk-summary-frame'; + const table = document.createElement('table'); + table.className = 'hm-orgchart-workdesk-position-table'; + table.setAttribute('aria-label', '직위별 인원 현황 (팀별)'); + const columns = document.createElement('colgroup'); + for (let index = 0; index < positions.length + 2; index++) { + const column = document.createElement('col'); + column.className = index === 0 ? 'hm-orgchart-workdesk-label-column' : 'hm-orgchart-workdesk-value-column'; + columns.append(column); + } + const head = document.createElement('thead'); + const header = document.createElement('tr'); + for (const label of ['팀원', ...positions, '소계']) { + const cell = document.createElement('th'); + cell.scope = 'col'; + cell.textContent = label; + header.append(cell); + } + head.append(header); + const body = document.createElement('tbody'); + for (const { organization, scope, count } of selectMajorOrganizationTotals(context.data, context.summary)) { + const row = document.createElement('tr'); + row.dataset.organizationId = organization.id; + row.dataset.countScope = scope; + const label = document.createElement('th'); + label.scope = 'row'; + label.textContent = organization.name; + row.append(label); + const organizations = [organization.id]; + if (scope === 'inclusive') { + for (let index = 0; index < organizations.length; index++) organizations.push(...children.get(organizations[index]!) ?? []); + } + for (const position of positions) { + const positionCount = organizations.reduce((sum, id) => sum + (matrix.get(id)?.get(position) ?? 0), 0); + const cell = text(document, 'td', 'hm-orgchart-workdesk-count-cell', String(positionCount)); + cell.dataset.position = position; + row.append(cell); + } + row.append(text(document, 'td', 'hm-orgchart-workdesk-count-cell hm-orgchart-workdesk-subtotal', String(count))); + body.append(row); + } + const foot = document.createElement('tfoot'); + const total = document.createElement('tr'); + const totalLabel = document.createElement('th'); + totalLabel.scope = 'row'; + totalLabel.textContent = '계'; + total.append(totalLabel); + for (const position of positions) total.append(text(document, 'td', 'hm-orgchart-workdesk-count-cell', String(context.summary.raw.byPosition[position] ?? 0))); + const totalCount = text(document, 'td', 'hm-orgchart-workdesk-count-cell hm-orgchart-workdesk-subtotal', String(context.summary.raw.total)); + totalCount.dataset.totalCount = String(context.summary.raw.total); + total.append(totalCount); + foot.append(total); + table.append(columns, head, body, foot); + frame.append(table); + section.append(frame); + return section; +} diff --git a/packages/org-chart/src/core/person-name-overflow.ts b/packages/org-chart/src/core/person-name-overflow.ts new file mode 100644 index 0000000..3c50ecf --- /dev/null +++ b/packages/org-chart/src/core/person-name-overflow.ts @@ -0,0 +1,72 @@ +let tooltipSequence = 0; + +/** Presentation-only overflow. Keep the complete text node for accessibility; + * cropped names enter the tab order and expose a hoverable/dismissible tooltip. + * Each observer/listener/tooltip belongs to the existing card abort boundary. */ +export function observePersonNameOverflow(card: HTMLElement, name: HTMLElement, signal: AbortSignal): void { + const document = card.ownerDocument; + const window = document.defaultView; + if (!window) return; + const tooltip = document.createElement('span'); + tooltip.id = `hm-orgchart-person-name-tip-${++tooltipSequence}`; + tooltip.className = 'hm-orgchart-person-name-tooltip'; + tooltip.setAttribute('role', 'tooltip'); + tooltip.textContent = name.textContent; + tooltip.hidden = true; + let timer: number | undefined; + let hoveringTooltip = false; + let scaleListening = false; + const focused = () => (name.getRootNode() as ShadowRoot).activeElement === name; + function cancelTimer(): void { if (timer !== undefined) window!.clearTimeout(timer); timer = undefined; } + function hide(): void { cancelTimer(); tooltip.hidden = true; name.dataset.tooltipOpen = 'false'; } + function refresh(): void { + if (signal.aborted || !name.isConnected || name.clientWidth === 0) return; + const overflow = name.scrollWidth > name.clientWidth; + name.dataset.nameOverflow = String(overflow); + if (overflow) { + name.tabIndex = 0; + name.setAttribute('aria-describedby', tooltip.id); + // Fixed positioning avoids adding hidden content to tree/document scroll + // extents. Append inside the same Shadow Root for IDREF accessibility. + const shell = card.closest('.hm-orgchart-shell'); + shell?.append(tooltip); + // Full Composition scale changes move cards without a window resize. + if (shell && !scaleListening) { scaleListening = true; shell.addEventListener('hm-orgchart-scale', hide, { signal }); } + } else { + name.removeAttribute('tabindex'); + name.removeAttribute('aria-describedby'); + hide(); + tooltip.remove(); + } + } + function show(): void { + cancelTimer(); + refresh(); + if (name.dataset.nameOverflow !== 'true') return; + tooltip.hidden = false; + name.dataset.tooltipOpen = 'true'; + const bounds = name.getBoundingClientRect(); + const tip = tooltip.getBoundingClientRect(); + const left = Math.max(8, Math.min(bounds.left, window!.innerWidth - tip.width - 8)); + const below = bounds.bottom + 6; + const top = below + tip.height <= window!.innerHeight - 8 ? below : Math.max(8, bounds.top - tip.height - 6); + tooltip.style.left = `${left}px`; + tooltip.style.top = `${top}px`; + } + name.addEventListener('mouseenter', show, { signal }); + name.addEventListener('mouseleave', () => { + if (!focused()) timer = window.setTimeout(() => { if (!hoveringTooltip) hide(); }, 180); + }, { signal }); + name.addEventListener('focus', show, { signal }); + name.addEventListener('blur', () => { if (!hoveringTooltip) hide(); }, { signal }); + name.addEventListener('keydown', event => { if (event.key === 'Escape') hide(); }, { signal }); + tooltip.addEventListener('mouseenter', () => { hoveringTooltip = true; cancelTimer(); }, { signal }); + tooltip.addEventListener('mouseleave', () => { hoveringTooltip = false; if (!focused()) hide(); }, { signal }); + window.addEventListener('scroll', hide, { capture: true, signal }); + window.addEventListener('resize', hide, { signal }); + document.fonts.addEventListener('loadingdone', refresh, { signal }); + const observer = new window.ResizeObserver(refresh); + observer.observe(name); + void document.fonts.ready.then(refresh); + signal.addEventListener('abort', () => { cancelTimer(); observer.disconnect(); tooltip.remove(); }, { once: true }); +} diff --git a/packages/org-chart/src/core/position-summary.ts b/packages/org-chart/src/core/position-summary.ts new file mode 100644 index 0000000..38c43a5 --- /dev/null +++ b/packages/org-chart/src/core/position-summary.ts @@ -0,0 +1,82 @@ +import { selectOrganizationPositionCounts } from '../data/content-selectors.js'; +import type { RenderContext } from './render.js'; + +function text(document: Document, tag: string, className: string, value: string): HTMLElement { + const element = document.createElement(tag); + element.className = className; + element.textContent = value; + return element; +} + +/** Independent, passive content component; composition controls its visibility and placement. */ +export function createPositionSummary(document: Document, context: RenderContext): HTMLElement { + const section = document.createElement('section'); + section.className = 'hm-orgchart-summary hm-orgchart-position-summary'; + section.setAttribute('aria-label', '조직 및 직위별 원본 인원 현황'); + section.dataset.contentSection = 'position-summary'; + section.append(text(document, 'h3', 'hm-orgchart-summary-title', '조직 · 직위별 인원 현황')); + section.append(text(document, 'p', 'hm-orgchart-raw-total', `Raw Count · 원본 직원 전체 ${context.summary.raw.total}명`)); + section.append(text(document, 'p', 'hm-orgchart-policy', '원본 직원 ID를 직접 소속 조직에서 한 번 집계합니다. 조직장 · 파견 · 휴직을 포함하며 업무 총원 정책을 뜻하지 않습니다.')); + section.append(text(document, 'p', 'hm-orgchart-policy', context.summary.aggregation.status === 'ready' + ? `승인 정책 ${context.summary.aggregation.policyVersion}: 총원 ${context.summary.aggregation.counts.total}명` + : '정책 총원 미확정: 집계 정책 승인이 필요합니다.')); + + const scroll = document.createElement('div'); + scroll.className = 'hm-orgchart-summary-scroll'; + scroll.tabIndex = 0; + scroll.setAttribute('role', 'region'); + scroll.setAttribute('aria-label', '조직 및 직위별 인원 표, 가로 스크롤 가능'); + const table = document.createElement('table'); + table.className = 'hm-orgchart-position-table'; + const caption = document.createElement('caption'); + caption.textContent = '직위별 교차 인원과 소계는 직접 소속 기준입니다. 하위 포함 수는 서로 합산하지 않습니다.'; + const positions = Object.keys(context.summary.raw.byPosition); + const matrix = selectOrganizationPositionCounts(context.data); + const head = document.createElement('thead'); + const headerRow = document.createElement('tr'); + for (const label of ['조직', ...positions, '소계 (직접)', '하위 포함']) { + const cell = document.createElement('th'); + cell.scope = 'col'; + cell.textContent = label; + headerRow.append(cell); + } + head.append(headerRow); + const body = document.createElement('tbody'); + for (const organization of context.orderedOrganizations) { + const row = document.createElement('tr'); + row.dataset.organizationId = organization.id; + const name = document.createElement('th'); + name.scope = 'row'; + name.textContent = organization.name; + row.append(name); + for (const position of positions) row.append(text(document, 'td', 'hm-orgchart-count-cell', String(matrix.get(organization.id)?.get(position) ?? 0))); + const counts = context.summary.raw.byOrganization[organization.id]; + row.append(text(document, 'td', 'hm-orgchart-count-cell hm-orgchart-subtotal', String(counts?.direct ?? 0))); + row.append(text(document, 'td', 'hm-orgchart-count-cell hm-orgchart-inclusive', String(counts?.inclusive ?? 0))); + body.append(row); + } + const foot = document.createElement('tfoot'); + const totalRow = document.createElement('tr'); + const totalLabel = document.createElement('th'); + totalLabel.scope = 'row'; + totalLabel.textContent = '전체 합계'; + totalRow.append(totalLabel); + for (const position of positions) totalRow.append(text(document, 'td', 'hm-orgchart-count-cell', String(context.summary.raw.byPosition[position] ?? 0))); + totalRow.append(text(document, 'td', 'hm-orgchart-count-cell', String(context.summary.raw.total))); + const inclusiveTotal = text(document, 'td', 'hm-orgchart-inclusive', '—'); + inclusiveTotal.setAttribute('aria-label', '하위 포함 수는 중복되어 합산하지 않음'); + totalRow.append(inclusiveTotal); + foot.append(totalRow); + table.append(caption, head, body, foot); + scroll.append(table); + section.append(scroll); + + section.append(text(document, 'h4', 'hm-orgchart-group-title', '근무 상태별 원본 직원')); + const statuses = document.createElement('dl'); + statuses.className = 'hm-orgchart-summary-group hm-orgchart-status-totals'; + for (const [key, label] of [['active', '재직'], ['dispatched', '파견'], ['onLeave', '휴직']] as const) { + statuses.append(text(document, 'dt', '', label), text(document, 'dd', '', `${context.summary.raw.byStatus[key]}명`)); + } + section.append(statuses); + return section; +} diff --git a/packages/org-chart/src/core/render.ts b/packages/org-chart/src/core/render.ts new file mode 100644 index 0000000..8063f03 --- /dev/null +++ b/packages/org-chart/src/core/render.ts @@ -0,0 +1,161 @@ +import type { Employee, OrgChartDataset, OrgChartSummary, OrganizationTreeNode } from '../data/index.js'; +import type { RenderEngineNode } from './engine.js'; +import { createEmployeeCard } from './employee-card.js'; +import { applyOrganizationStyle, organizationStylePresets } from './style-presets.js'; +import { getDepartmentCardAsset } from './department-card-assets.js'; +import type { OrgChartOrganizationLinks } from './types.js'; +import type { CompanyColorPreset } from '../company-color-presets.js'; +import { decorateExpandedNode } from './expanded-content.js'; +import type { OrgChartConnectionDirection, OrgChartTopNode, OrgChartInteractionOptions } from './types.js'; +import type { ResolvedAutoLayout } from './auto-layout-policy.js'; + +const organizationNames = { root: '최상위 조직', division: '부문', office: '실', div: 'Div', cell: '셀', team: '팀' } as const; + +export interface RenderContext { + data: OrgChartDataset; + summary: OrgChartSummary; + orderedOrganizations: OrganizationTreeNode['organization'][]; + employeesByOrganization: ReadonlyMap; + topLeaderIds: ReadonlySet; + /** View-only composition: no collapse controls or diagnostic text are created. */ + readOnly?: boolean; + organizationLinks?: OrgChartOrganizationLinks; + /** Only the Expanded presenter supplies company header colors. */ + expandedCompanyPreset?: CompanyColorPreset; + organizationCompanies?: ReadonlyMap; + collapsedOrganizations?: ReadonlySet; + companyTopNodes?: Readonly>; + employeeConnections?: Readonly>; + autoLayout?: ResolvedAutoLayout; + interactions?: Readonly>; + toggleExpanded?: (organizationId: string) => void; +} + +export function engineTree(roots: OrganizationTreeNode[], prefix: string, context: RenderContext, startDepth = 0): RenderEngineNode[] { + function convert(tree: OrganizationTreeNode, depth: number, variant: number): RenderEngineNode { + variant = organizationStylePresets.find(preset => preset.organizationId === tree.organization.id)?.id ?? variant; + const index = context.orderedOrganizations.length; + context.orderedOrganizations.push(tree.organization); + return { + id: `${prefix}-org-${index}`, name: '', organizationIndex: index, depth, groupVariant: variant, + // Make public (root=Depth 1) semantics explicit rather than depending on + // upstream's detached-DOM ancestor inference during mode reconstruction. + ...(context.autoLayout ? { level: depth + 1 } : {}), + ...(context.autoLayout?.type === 'tb' && context.autoLayout.hybridOrganizationIds.includes(tree.organization.id) ? { hybrid: true } : {}), + ...(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. + return roots.map((tree, index) => convert(tree, startDepth, index % 6)); +} + +function text(document: Document, tag: string, className: string, value: string): HTMLElement { + const element = document.createElement(tag); + element.className = className; + element.textContent = value; + return element; +} + +export function decorateNode( + node: HTMLElement, value: RenderEngineNode, context: RenderContext, + signal: AbortSignal, toggle: (node: HTMLElement) => boolean | undefined, +): void { + const organization = context.orderedOrganizations[Number(value.organizationIndex)]; + if (!organization) return; + 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; + node.dataset.depth = String(value.depth); + // Engine roots can be flattened for composition. Card policy uses the + // original ancestry rather than the engine's display depth or job title. + let organizationDepth = 0; + let ancestor = organization; + while (ancestor.parentId) { + const parent = context.data.organizations.find(item => item.id === ancestor.parentId); + if (!parent) break; + organizationDepth++; + ancestor = parent; + } + node.dataset.organizationDepth = String(organizationDepth); + applyOrganizationStyle(node, Number(value.groupVariant)); + node.dataset.organizationType = organization.type; + // Header role is styling-only: depth 0-1 are organization titles, deeper nodes are cell/team headers. + node.dataset.headerRole = (context.readOnly ? organizationDepth : Number(value.depth)) <= 1 ? 'organization' : 'group'; + // Hide upstream mouse-only arrows, retaining the engine's hierarchy connectors. + for (const edge of node.querySelectorAll('.edge')) edge.setAttribute('aria-hidden', 'true'); + const readOnly = context.readOnly === true; + // Kind, ID, raw counts and vacancy notes are diagnostic; read-only shows the header and people only. + if (!readOnly) card.append(text(document, 'span', 'hm-orgchart-kind', organizationNames[organization.type])); + const heading = text(document, 'h3', 'hm-orgchart-organization-name', organization.name); + const href = organizationDepth === 1 ? context.organizationLinks?.[organization.id] : undefined; + if (href) { + const link = document.createElement('a'); + link.className = 'hm-orgchart-organization-link'; + link.href = href; + link.textContent = organization.name; + heading.replaceChildren(link); + // Native links retain keyboard and browser navigation semantics; this + // click must not also reach the upstream node selection/collapse handler. + link.addEventListener('click', event => event.stopPropagation(), { signal }); + link.addEventListener('keydown', event => event.stopPropagation(), { signal }); + } + card.append(heading); + if (!readOnly) { + const id = text(document, 'span', 'hm-orgchart-domain-id', organization.id); + id.title = organization.id; + card.append(id); + const counts = context.summary.raw.byOrganization[organization.id]; + card.append(text(document, 'p', 'hm-orgchart-node-count', `원본 직원: 직접 ${counts?.direct ?? 0}명 · 하위 포함 ${counts?.inclusive ?? 0}명`)); + } + const leader = organization.leaderId ? context.data.employees.find(employee => employee.id === organization.leaderId) : undefined; + const affiliationName = (employee: Employee) => context.data.affiliations.find(item => item.id === employee.affiliationId)?.name; + if (leader && !context.topLeaderIds.has(leader.id)) card.append(createEmployeeCard(document, leader, { + kind: 'leader', affiliationName: affiliationName(leader), workdeskPresentation: readOnly, + cardAsset: getDepartmentCardAsset(organization.id, readOnly && organizationDepth !== 1 ? 'employee' : 'leader', context.data.organizations), + }, signal)); + else if (readOnly) { /* The top leader card or an empty slot needs no placeholder text. */ } + else if (leader) card.append(text(document, 'p', 'hm-orgchart-vacancy', '조직장: 상단 독립 카드')); + else card.append(text(document, 'p', 'hm-orgchart-vacancy', '조직장 미지정')); + if (context.data.presentation?.groups.showEmployees !== false) { + const employees = document.createElement('div'); + employees.className = 'hm-orgchart-employees'; + for (const employee of context.employeesByOrganization.get(organization.id) ?? []) { + if (employee.id !== leader?.id && !context.topLeaderIds.has(employee.id)) employees.append(createEmployeeCard(document, employee, { kind: 'employee', workdeskPresentation: readOnly, affiliationName: affiliationName(employee), cardAsset: getDepartmentCardAsset(organization.id, 'employee', context.data.organizations) }, signal)); + } + card.append(employees); + } + // Read-only hierarchy is always fully expanded; no toggle (and no leftover space) is created. + if (value.children?.length && !readOnly) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'hm-orgchart-toggle'; + button.textContent = '하위 조직 접기'; + button.setAttribute('aria-label', `${organization.name} 하위 조직 접기`); + button.setAttribute('aria-expanded', 'true'); + button.addEventListener('click', event => { + event.stopPropagation(); + const expanded = toggle(node); + if (expanded === undefined) return; + button.setAttribute('aria-expanded', String(expanded)); + const label = expanded ? '하위 조직 접기' : '하위 조직 펼치기'; + button.textContent = label; + button.setAttribute('aria-label', `${organization.name} ${label}`); + }, { signal }); + card.append(button); + } +} + +export function createHeading(document: Document, context: RenderContext): HTMLElement { + const header = document.createElement('header'); + header.className = 'hm-orgchart-heading'; + header.append(text(document, 'h2', 'hm-orgchart-title', context.data.presentation?.title ?? '조직도')); + // Read-only keeps the title and as-of date; description and relation notes are explanatory text. + if (context.data.presentation?.description && !context.readOnly) header.append(text(document, 'p', 'hm-orgchart-description', context.data.presentation.description)); + header.append(text(document, 'p', 'hm-orgchart-date', `기준일 ${context.data.metadata.asOfDate}`)); + if (context.data.relations?.length && !context.readOnly) header.append(text(document, 'p', 'hm-orgchart-description', `추가 관계 ${context.data.relations.length}건은 이번 기본 조직 계층선에 포함되지 않습니다.`)); + return header; +} diff --git a/packages/org-chart/src/core/representative-card-asset.ts b/packages/org-chart/src/core/representative-card-asset.ts new file mode 100644 index 0000000..87203ec --- /dev/null +++ b/packages/org-chart/src/core/representative-card-asset.ts @@ -0,0 +1,14 @@ +import url from '../assets/cards/대표자.svg?inline'; + +export interface RepresentativeCardAsset { + size: 'representative'; + label: string; + url: string; + width: number; + height: number; +} + +/** Separate from department mapping: only the standalone topLeader uses it. */ +export const representativeCardAsset: RepresentativeCardAsset = { + size: 'representative', label: '대표자 전용', url, width: 146, height: 74, +}; diff --git a/packages/org-chart/src/core/status-summary.ts b/packages/org-chart/src/core/status-summary.ts new file mode 100644 index 0000000..c61c252 --- /dev/null +++ b/packages/org-chart/src/core/status-summary.ts @@ -0,0 +1,45 @@ +import { selectStatusGroups } from '../data/content-selectors.js'; +import { createEmployeeCard } from './employee-card.js'; +import type { RenderContext } from './render.js'; + +/** `compact` is the read-only composition form: label-only headers and no policy note. */ +export function createStatusSummary(document: Document, context: RenderContext, signal: AbortSignal, { compact = false } = {}): HTMLElement { + const section = document.createElement('section'); + section.className = 'hm-orgchart-status-summary'; + section.dataset.contentSection = 'status-summary'; + section.setAttribute('aria-label', '파견 및 휴직 직원'); + const affiliations = new Map(context.data.affiliations.map(affiliation => [affiliation.id, affiliation.name])); + for (const { status, employees, count } of selectStatusGroups(context.data, context.summary)) { + const group = document.createElement('section'); + group.className = 'hm-orgchart-status-group'; + group.dataset.workStatus = status; + const label = status === 'dispatched' ? '파견' : '휴직'; + group.setAttribute('aria-label', `${label} 직원`); + const heading = document.createElement('h3'); + heading.className = 'hm-orgchart-summary-title'; + heading.textContent = compact ? label : `${label} · ${count}명`; + if (compact) heading.setAttribute('aria-label', `${label} ${count}명`); + group.append(heading); + const cards = document.createElement('div'); + cards.className = 'hm-orgchart-status-employees'; + for (const employee of employees) { + cards.append(createEmployeeCard(document, employee, { kind: 'employee', workdeskPresentation: context.readOnly, + hideStatusLabel: compact && context.readOnly, affiliationName: affiliations.get(employee.affiliationId) }, signal)); + } + group.append(cards); + if (!employees.length) { + const empty = document.createElement('p'); + empty.className = 'hm-orgchart-content-empty'; + empty.textContent = `${label} 직원이 없습니다.`; + group.append(empty); + } + if (!compact) { + const note = document.createElement('p'); + note.className = 'hm-orgchart-policy'; + note.textContent = '원본 직원의 상태별 별도 표시입니다. 전체 인원에 중복 합산하지 않습니다.'; + group.append(note); + } + section.append(group); + } + return section; +} diff --git a/packages/org-chart/src/core/style-presets.ts b/packages/org-chart/src/core/style-presets.ts new file mode 100644 index 0000000..fffe15d --- /dev/null +++ b/packages/org-chart/src/core/style-presets.ts @@ -0,0 +1,63 @@ +/** Presentation-only colors measured from Workdesk organization headers. + * Header measurements do not verify employee card edges/fills. Standalone + * OrgChart defaults and employee JSON remain separate from this local config. */ +const status = 'WORKDESK_COMPUTED' as const; +const source = 'https://workdesk.hmac.kr/' as const; +const observedOn = '2026-10-12' as const; +const version = 'v1.2610.9c5e' as const; +const foregroundVariable = '--hm-orgchart-resolved-on-action-primary' as const; +const cardBackground = 'var(--hm-orgchart-resolved-bg-surface)' as const; +const departmentColor = '#ffffff' as const; +const cardStyleStatus = 'DESIGN_ADAPTATION' as const; +const verificationScope = 'Organization header background only; employee card edges/fills NOT VERIFIED' as const; + +export const organizationStylePresets = [ + { + id: 0, label: '사업관리', accentColor: '#194d82', + organizationId: 'business-management', variablePrefix: '--hm-orgchart-business-management', + cardBackground, departmentColor, borderColor: '#194d82', cardStyleStatus, + backgroundImage: 'linear-gradient(180deg, #194d82 0%, #1d395c 100%)', + foregroundVariable, status, source, observedOn, version, selector: '.h33', verificationScope, + }, + { + id: 1, label: '경영기획', accentColor: '#2164a8', + organizationId: 'business-planning', variablePrefix: '--hm-orgchart-business-planning', + cardBackground, departmentColor, borderColor: '#2164a8', cardStyleStatus, + backgroundImage: 'linear-gradient(180deg, #2164a8 0%, #254975 100%)', + foregroundVariable, status, source, observedOn, version, selector: '.h33.org-team-link', verificationScope, + }, + { + id: 2, label: '인재성장', accentColor: '#0f6ace', + organizationId: 'talent-growth', variablePrefix: '--hm-orgchart-talent-growth', + cardBackground, departmentColor, borderColor: '#0f6ace', cardStyleStatus, + backgroundImage: 'linear-gradient(180deg, #0f6ace 0%, #044a94 100%)', + foregroundVariable, status, source, observedOn, version, selector: '.h38.org-team-link', verificationScope, + }, + { + id: 3, label: '통합시스템 Div', accentColor: '#8c67b6', + organizationId: 'integrated-system', variablePrefix: '--hm-orgchart-integrated-system', + cardBackground, departmentColor, borderColor: '#8c67b6', cardStyleStatus, + backgroundImage: 'linear-gradient(180deg, #8c67b6 0%, #5d3494 100%)', + foregroundVariable, status, source, observedOn, version, selector: '.h310.org-team-link', verificationScope, + }, + { + id: 4, label: '디자인기획', accentColor: '#09a7a2', + organizationId: 'design-planning', variablePrefix: '--hm-orgchart-design-planning', + cardBackground, departmentColor, borderColor: '#09a7a2', cardStyleStatus, + backgroundImage: 'linear-gradient(180deg, #09a7a2 0%, #00847f 100%)', + foregroundVariable, status, source, observedOn, version, selector: '.h312.org-team-link', verificationScope, + }, + { + id: 5, label: '솔루션영업', accentColor: '#5a963c', + organizationId: 'solution-sales', variablePrefix: '--hm-orgchart-solution-sales', + cardBackground, departmentColor, borderColor: '#5a963c', cardStyleStatus, + backgroundImage: 'linear-gradient(180deg, #5a963c 0%, #3f7629 100%)', + foregroundVariable, status, source, observedOn, version, selector: '.h36.org-team-link', verificationScope, + }, +] as const; + +export function applyOrganizationStyle(element: HTMLElement, variant: number): void { + const preset = organizationStylePresets[variant] ?? organizationStylePresets[0]; + element.dataset.groupVariant = String(preset.id); + // CSS owns defaults and the cascade; JS only selects the existing preset. +} diff --git a/packages/org-chart/src/core/types.ts b/packages/org-chart/src/core/types.ts new file mode 100644 index 0000000..5d49d0d --- /dev/null +++ b/packages/org-chart/src/core/types.ts @@ -0,0 +1,146 @@ +import type { Affiliation, ApprovedAggregationPolicy, Employee, OrgChartDataSource, OrgChartDataset, OrgChartMoveRequest, OrgChartMoveResult, OrgChartSummary, Organization, ValidationIssue } from '../data/index.js'; + +export type OrgChartState = + | { readonly status: 'idle' | 'loading' | 'empty' | 'destroyed' } + | { readonly status: 'ready'; readonly dataVersion: string; readonly summary: OrgChartSummary; readonly aggregation: OrgChartSummary['aggregation'] } + | { readonly status: 'error'; readonly issues: readonly ValidationIssue[] }; + +/** 'interactive' (default) keeps collapse controls and diagnostic text. + * 'readOnly' is the view-only Workdesk composition: expanded hierarchy, no + * controls or development text, compact summaries on a fitted design canvas. */ +export type OrgChartDisplayMode = 'interactive' | 'readOnly'; + +/** Layout is independent of displayMode. Auto uses the complete hierarchy + * with natural overflow; Fixed preserves the Workdesk composition. */ +export type OrgChartLayoutMode = 'fixed' | 'auto'; + +/** Independent Auto presentation policies. Root organizations are Depth 1. */ +export type OrgChartAutoLayoutType = 'tb' | 'lr' | 'hybrid'; +export interface OrgChartAutoLayoutOptions { + type?: OrgChartAutoLayoutType; + /** Hybrid only. The first displayed depth rendered as parent-local lists. */ + hybridStartDepth?: 2 | 3 | 4; + /** Initial expansion policy; changing it intentionally resets collapse state. + * Later user toggles survive layout and data changes for valid organizations. */ + initialVisibleDepth?: 1 | 2 | 3 | 'all'; + /** TB only. Each listed organization's descendants use a local vertical list. + * Cannot be combined with LR or global Hybrid. */ + hybridOrganizationIds?: readonly string[]; +} +export interface OrgChartInteractionOptions { + /** Auto only; each optional feature defaults to enabled and is independent. */ + collapse?: boolean; + zoom?: boolean; + /** Disables manual pointer/arrow/API Pan; keyboard focus reveal stays available. */ + pan?: boolean; +} + +/** 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 } + +/** Auto only. 'chart-only' renders a white canvas with nodes and connectors: + * no heading, as-of date, company/count summary, toolbar or visible guidance. + * Accessible live status stays visually hidden. Fixed rejects it. */ +export type OrgChartPresentationMode = 'default' | 'chart-only'; +/** Auto only. Placement on first render and on full data/layout replacement. + * 'fit' contains the whole forest (never above 100%) and centers it. */ +export type OrgChartInitialView = 'center' | 'fit'; +/** Auto only. 'modifier' zooms with Ctrl/⌘ + wheel; 'direct' zooms with the + * plain wheel inside the canvas. Both anchor at the pointer. */ +export type OrgChartWheelZoom = 'modifier' | 'direct'; + +export interface OrgChartEmployeeClickContext { + readonly organization: Organization | undefined; + readonly affiliation: Affiliation | undefined; + /** Focusable card that activated this request, outside the default dialog. */ + readonly trigger: HTMLElement; + readonly originalEvent: MouseEvent | KeyboardEvent; +} + +export interface OrgChartOptions { + container?: HTMLElement; + dataSource: OrgChartDataSource; + aggregationPolicy?: ApprovedAggregationPolicy; + /** Resolved CSS URLs inserted in the instance's Shadow DOM, in this order. */ + stylesheetUrls: { engine: string; core: string }; + /** Full composition presentation only; standalone views ignore it. */ + displayMode?: OrgChartDisplayMode; + /** Full composition only. Omission preserves the existing Fixed renderer. */ + layoutMode?: OrgChartLayoutMode; + /** 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[]; + /** Auto only; Fixed ignores these independent presentation options. */ + autoLayout?: OrgChartAutoLayoutOptions; + interactions?: OrgChartInteractionOptions; + /** Auto-only employee/group editing. Omission disables pointer, keyboard and + * handle moves; Pan and employee information remain independent. Fixed ignores it. */ + dragDropEnabled?: boolean; + companyTopNodes?: Readonly>; + /** Actual TB/Hybrid staff connector route, default top; their leaders connect + * from above. LR always routes employees from the left. */ + employeeConnections?: Readonly>; + zoom?: { min?: number; max?: number; step?: number }; + /** Auto only. Defaults to 'default' (existing heading, summary and toolbar). */ + presentationMode?: OrgChartPresentationMode; + /** Auto only. Defaults to 'fit' in chart-only, otherwise 'center'. */ + initialView?: OrgChartInitialView; + /** Auto only. Defaults to 'direct' in chart-only, otherwise 'modifier'. */ + wheelZoom?: OrgChartWheelZoom; + /** No entry (or an empty URL) keeps an ordinary title. HTTP(S) and relative URLs are supported. */ + organizationLinks?: OrgChartOrganizationLinks; + /** Optional host-owned information UI. When supplied, it replaces the default read-only dialog. + * Called for every employee card in interactive and readOnly modes. */ + onEmployeeClick?: (employee: Employee, context: OrgChartEmployeeClickContext) => void; + /** Auto-only browser-state editing. Called after a move or policy rejection; + * never saves to the source or a server. Consumer callback errors are isolated. */ + onMove?: (result: OrgChartMoveResult) => void; + onStateChange?: (state: OrgChartState) => void; +} + +export interface OrgChartHandle { + mount(container?: HTMLElement): Promise; + update(input: unknown): Promise; + reload(source?: OrgChartDataSource): Promise; + /** Terminal, idempotent disposal. Create a new handle for a later mount. */ + 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[]; + /** Merge an Auto-only presentation patch without remounting or losing valid collapse state. */ + setAutoLayout(options: OrgChartAutoLayoutOptions): Promise; + getAutoLayout(): Readonly; + /** Auto only. Change popup ownership without rebuilding or changing view state. + * undefined restores the existing Core dialog. */ + setEmployeeClickHandler(handler?: OrgChartOptions['onEmployeeClick']): void; + /** Auto only. Merge independent feature flags while retaining view state. */ + setInteractions(options: OrgChartInteractionOptions): Promise; + getInteractions(): Readonly; + /** Auto only. Cancel active editing without reloading or resetting completed moves. */ + setDragDropEnabled(enabled: boolean): void; + getDragDropEnabled(): boolean; + /** Auto only. Uses the same policy as pointer and keyboard moves. */ + move(request: OrgChartMoveRequest): Promise; + /** Immutable current Auto dataset, including hidden companies and local moves. */ + getData(): OrgChartDataset | undefined; + zoomTo(scale: number): void; + panBy(x: number, y: number): void; + fitToView(): void; + getViewportState(): OrgChartViewportState | undefined; +} diff --git a/packages/org-chart/src/core/vendor.d.ts b/packages/org-chart/src/core/vendor.d.ts new file mode 100644 index 0000000..48aaac8 --- /dev/null +++ b/packages/org-chart/src/core/vendor.d.ts @@ -0,0 +1,45 @@ +declare module '*.svg?inline' { + const url: string; + export default url; +} +declare module '*.png?inline' { + const url: string; + export default url; +} +declare module 'orgchart' { + export interface EngineNode { id: string; name: string; children?: EngineNode[]; [key: string]: unknown } + export interface EngineOptions { + data: EngineNode | EngineNode[]; + pan: boolean; + zoom: boolean; + minimap: boolean; + draggable: boolean; + exportButton: boolean; + chartClass: string; + direction?: 't2b' | 'l2r'; + verticalLevel?: number; + visibleLevel?: number; + dropCriteria?: (source: HTMLElement, parent: HTMLElement | null, target: HTMLElement) => boolean; + nodeTemplate: () => string; + createNode: (node: HTMLElement, data: EngineNode) => void; + } + export default class OrgChart { + constructor(container: HTMLElement, options: EngineOptions); + chart: HTMLElement | null; + chartContainer: HTMLElement | null; + opts: Partial; + options: Partial; + init(options?: Partial): OrgChart; + triggerInitEvent(): void; + handleCompactNodes(): void; + getNodeState(node: HTMLElement, relation: string): { visible: boolean }; + hideChildren(node: HTMLElement): void; + showChildren(node: HTMLElement): void; + unbindPan(): void; + unbindZoom(): void; + detachMinimap(): void; + filterAllowedDropNodes(source: HTMLElement): void; + dropHandler(event: { dropZone: HTMLElement }): Promise; + dragendHandler(): void; + } +} diff --git a/packages/org-chart/src/core/workdesk-artwork.ts b/packages/org-chart/src/core/workdesk-artwork.ts new file mode 100644 index 0000000..1e177c1 --- /dev/null +++ b/packages/org-chart/src/core/workdesk-artwork.ts @@ -0,0 +1,124 @@ +import ring from '../assets/workdesk/Group.svg?inline'; +import sky from '../assets/workdesk/bg_sky.png?inline'; +import meeting from '../assets/workdesk/bg_main.png?inline'; +import hubFrame from '../assets/workdesk/Frame2@3x.png?inline'; +import grid from '../assets/workdesk/1-8-4x-1@2x.png?inline'; +import blueShort from '../assets/workdesk/team-arrow-blue-short.png?inline'; +import bluePurple from '../assets/workdesk/team-arrow-blue-purple.png?inline'; +import purpleTeal from '../assets/workdesk/team-arrow-purple-teal.png?inline'; +import tealGreen from '../assets/workdesk/team-arrow-teal-green.png?inline'; +import { workdeskCanvas } from './workdesk-layout.js'; + +const svgNamespace = 'http://www.w3.org/2000/svg'; +const hubPaths = [ + ['hub-to-business', 'M411.134 66.9559C456.067 45.0289 560.802 1.17493 695.801 1.17493'], + ['hub-to-design-solution', 'M1258.34 66.9559C1203.65 45.0289 1044.265 1.17493 848.038 1.17493'], + ['hub-to-teams-arc', 'M143.631104 74.8701C350.174 18.7487 874.423 -59.8214 1660.47 74.8701'], +] as const; +const arrows = [ + ['blue-short-1', blueShort, 194.15, 311.75, 66.25, 19.8], + ['blue-short-2', blueShort, 430.40, 311.75, 66.25, 19.8], + ['blue-purple', bluePurple, 666.65, 302, 183.75, 42], + ['purple-teal', purpleTeal, 1044, 302, 258.916, 42], + ['teal-green', tealGreen, 1520.55, 305, 135.349, 36], +] as const; + +/** Local, byte-preserved Workdesk artwork embedded by the package bundler. + * All geometry belongs to the same stage as the cards (one uniform scale). + * Original compact screenD paths are from org-dashboard-data.js; the obsolete + * static HTML fourth hub path and hidden executive branch are not rendered. */ +export function addWorkdeskArtwork(stage: HTMLElement, hub: HTMLElement, signal: AbortSignal): void { + const document = stage.ownerDocument; + stage.dataset.workdeskArtwork = 'true'; + const decoration = (id: string, source: string, parent = stage) => { + const image = document.createElement('img'); + image.className = `hm-orgchart-workdesk-decoration hm-orgchart-workdesk-${id}`; + image.dataset.workdeskAsset = id; + image.alt = ''; + image.setAttribute('aria-hidden', 'true'); + image.draggable = false; + image.src = source; + parent.append(image); + }; + decoration('ring', ring); + decoration('sky', sky); + decoration('meeting', meeting); + decoration('grid', grid); + decoration('hub-frame', hubFrame, hub); + + const overlay = document.createElementNS(svgNamespace, 'svg'); + overlay.classList.add('hm-orgchart-workdesk-overlay'); + overlay.setAttribute('viewBox', `0 0 ${workdeskCanvas.width} ${workdeskCanvas.height}`); + overlay.setAttribute('aria-hidden', 'true'); + overlay.setAttribute('focusable', 'false'); + const path = (id: string, d: string, parent: SVGElement = overlay) => { + const element = document.createElementNS(svgNamespace, 'path'); + element.dataset.connector = id; + element.setAttribute('d', d); + element.setAttribute('fill', 'none'); + element.setAttribute('stroke', '#45639F'); + element.setAttribute('stroke-width', '0.893023'); + element.setAttribute('stroke-dasharray', '1.79 1.79'); + parent.append(element); + return element; + }; + const curves = document.createElementNS(svgNamespace, 'g'); + curves.setAttribute('transform', 'translate(162 202)'); + overlay.append(curves); + for (const [id, d] of hubPaths) path(id, d, curves); + const representative = path('representative-to-hub', 'M935 142V286'); + representative.setAttribute('transform', 'translate(0 -17)'); + // Original Workdesk org-arrow-jang-head: a solid upward chevron, separate + // from the five team PNG arrows and the dashed representative connector. + const representativeHead = document.createElementNS(svgNamespace, 'path'); + representativeHead.dataset.arrowHead = 'representative'; + representativeHead.setAttribute('d', 'M0.00652573 5.54699C-0.0317927 5.092 0.0997532 4.66961 0.37176 4.32434L3.97332 0.436642C4.57335 -0.142366 5.40409 -0.144766 6.01239 0.424923L9.63348 4.33508C10.7765 5.85831 9.00989 7.82069 7.56414 6.63684L4.99774 3.88781L2.44207 6.62609C1.53083 7.44647 0.11378 6.8181 0.00652573 5.54699Z'); + representativeHead.setAttribute('fill', '#45639F'); + representativeHead.setAttribute('transform', 'translate(930 121.2969)'); + overlay.append(representativeHead); + const business = path('hub-to-business-management', 'M834 204C650 204 250 235 105 256.2'); + for (const [id, source, x, y, width, height] of arrows) { + const image = document.createElementNS(svgNamespace, 'image'); + image.dataset.arrow = id; + for (const [key, value] of Object.entries({ x, y, width, height, href: source, preserveAspectRatio: 'none' })) image.setAttribute(key, String(value)); + overlay.append(image); + } + stage.append(overlay); + + // The original management curve follows the live header's top-center. + // Convert transformed DOM bounds back to the stage coordinate system. + const view = document.defaultView; + if (!view) return; + let frame: number | undefined; + const update = () => { + frame = undefined; + if (signal.aborted || !stage.isConnected) return; + const header = stage.querySelector('.node[data-organization-id="business-management"] .hm-orgchart-organization-name'); + if (!header) return; + // The central chart and overlay move together inside the adaptive stage. + // Measuring against the overlay avoids adding that offset a second time. + const origin = overlay.getBoundingClientRect(); + const bounds = header.getBoundingClientRect(); + const scale = origin.width / workdeskCanvas.width; + if (!scale) return; + const card = stage.querySelector('.hm-orgchart-top-leaders .hm-orgchart-employee'); + if (card) { + const leader = card.getBoundingClientRect(); + const axis = (leader.left + leader.width / 2 - origin.left) / scale; + // Attach the upward tip to the live card's bottom edge in the same + // overlay coordinates, including proportional scale and center offset. + const headTop = (leader.bottom - origin.top) / scale; + representativeHead.setAttribute('transform', `translate(${(axis - 5).toFixed(4)} ${headTop.toFixed(4)})`); + representative.setAttribute('d', `M${axis.toFixed(4)} ${(headTop + 7 + 17).toFixed(4)}V286`); + } + const x = (bounds.left + bounds.width / 2 - origin.left) / scale; + const y = (bounds.top - origin.top) / scale + 2; + business.setAttribute('d', `M834 204C650 204 250 235 ${x.toFixed(2)} ${y.toFixed(2)}`); + }; + const schedule = () => { if (frame === undefined && !signal.aborted) frame = view.requestAnimationFrame(update); }; + const observer = new view.ResizeObserver(schedule); + observer.observe(stage); + document.fonts?.addEventListener('loadingdone', schedule, { signal }); + view.addEventListener('resize', schedule, { signal }); + signal.addEventListener('abort', () => { observer.disconnect(); if (frame !== undefined) view.cancelAnimationFrame(frame); }, { once: true }); +} diff --git a/packages/org-chart/src/core/workdesk-layout.ts b/packages/org-chart/src/core/workdesk-layout.ts new file mode 100644 index 0000000..3e880b6 --- /dev/null +++ b/packages/org-chart/src/core/workdesk-layout.ts @@ -0,0 +1,55 @@ +/** Read-only composition geometry measured on the authenticated Workdesk + * organization chart (v1.2610.9c5e, 2026-10-12). Presentation-only: keyed by + * organization ID like style-presets; data, parentId and order stay untouched. + * Organizations without an entry fall back to the centered flow layout. */ +export const workdeskCanvas = { width: 1920, height: 914.5, axisX: 935 } as const; + +/** Header width equals the leader card (146px); columns are placed by header center. */ +const headerWidth = 146; + +export interface WorkdeskColumnLayout { + organizationId: string; + /** Measured header left edge (px) on the 1920 canvas. */ + headerX: number; + /** Child cells/teams: vertical stack (default) or one horizontal row. */ + children: 'vertical' | 'horizontal'; +} + +export const workdeskColumns: readonly WorkdeskColumnLayout[] = [ + { organizationId: 'business-management', headerX: 32, children: 'vertical' }, + { organizationId: 'business-planning', headerX: 268, children: 'vertical' }, + { organizationId: 'talent-growth', headerX: 505, children: 'vertical' }, + { organizationId: 'integrated-system', headerX: 858, children: 'horizontal' }, + { organizationId: 'design-planning', headerX: 1336, children: 'horizontal' }, + { organizationId: 'solution-sales', headerX: 1689, children: 'vertical' }, +]; + +/** Applies column centers and child arrangement to the rendered engine forest. + * Runs after every engine (re)build; returns false when any top-level + * organization has no measured column, so the caller keeps the flow layout. */ +export function applyWorkdeskLayout(viewport: HTMLElement): boolean { + const forest = viewport.querySelector('.hm-orgchart-forest'); + if (!forest) return false; + const trees = [...forest.querySelectorAll(':scope > .hm-orgchart-tree')]; + const columns = trees.map(tree => { + const id = tree.querySelector('.node[data-organization-id]')?.dataset.organizationId; + return workdeskColumns.find(column => column.organizationId === id); + }); + for (const node of forest.querySelectorAll('.node[data-organization-id]')) { + const column = workdeskColumns.find(item => item.organizationId === node.dataset.organizationId); + if (column?.children === 'horizontal') node.dataset.childrenLayout = 'horizontal'; + else delete node.dataset.childrenLayout; + } + const placed = trees.length > 0 && columns.every(Boolean); + if (!placed) { + delete forest.dataset.layout; + for (const tree of trees) tree.style.removeProperty('--hm-orgchart-column-center'); + return false; + } + const centers = columns.map(column => column!.headerX + headerWidth / 2); + trees.forEach((tree, index) => tree.style.setProperty('--hm-orgchart-column-center', `${centers[index]}px`)); + forest.dataset.layout = 'workdesk'; + forest.style.setProperty('--hm-orgchart-branch-start', `${Math.min(...centers)}px`); + forest.style.setProperty('--hm-orgchart-branch-end', `${Math.max(...centers)}px`); + return true; +} diff --git a/packages/org-chart/src/data/content-selectors.test.ts b/packages/org-chart/src/data/content-selectors.test.ts new file mode 100644 index 0000000..9d0bcb7 --- /dev/null +++ b/packages/org-chart/src/data/content-selectors.test.ts @@ -0,0 +1,133 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { selectAffiliationSummary, selectMajorOrganizationTotals, selectOrganizationPositionCounts, selectStatusGroups } from './content-selectors.js'; +import { calculateSummary } from './summary.js'; +import type { OrgChartDataset } from './types.js'; + +function fixture(): OrgChartDataset { + return { + schemaVersion: '0.1-draft', + organizations: [ + { id: 'root', parentId: null, name: '가상 본부', type: 'root', leaderId: 'leader', displayOrder: 0 }, + { id: 'team', parentId: 'root', name: '가상 팀', type: 'team', leaderId: null, displayOrder: 1 }, + { id: 'empty', parentId: 'root', name: '빈 가상 팀', type: 'team', leaderId: null, displayOrder: 2 }, + ], + employees: [ + { id: 'leader', organizationId: 'root', affiliationId: 'first', name: '가상 가', position: '대표', jobTitle: '본부장', profileImage: null, workStatus: 'active' }, + { id: 'sent', organizationId: 'team', affiliationId: 'second', name: '가상 나', position: '책임', jobTitle: null, profileImage: null, workStatus: 'dispatched' }, + { id: 'leave', organizationId: 'team', affiliationId: 'first', name: '가상 다', position: '책임', jobTitle: null, profileImage: null, workStatus: 'onLeave' }, + ], + affiliations: [{ id: 'first', name: '가상 관계사 가' }, { id: 'second', name: '가상 관계사 나' }, { id: 'zero', name: '가상 관계사 다' }], + metadata: { asOfDate: '2026-10-12', dataVersion: 'selector-test' }, + }; +} + +test('position matrix uses direct primary membership, includes leaders and all work statuses', () => { + const data = fixture(); + const matrix = selectOrganizationPositionCounts(data); + const summary = calculateSummary(data); + assert.deepEqual([...matrix.get('root')!], [['대표', 1]]); + assert.deepEqual([...matrix.get('team')!], [['책임', 2]]); + assert.equal(matrix.get('empty')!.size, 0); + assert.equal(matrix.get('root')!.get('책임'), undefined); + assert.equal(summary.raw.byOrganization.root!.inclusive, 3); + for (const organization of data.organizations) { + assert.equal([...matrix.get(organization.id)!.values()].reduce((sum, count) => sum + count, 0), summary.raw.byOrganization[organization.id]!.direct); + } + for (const [position, count] of Object.entries(summary.raw.byPosition)) { + assert.equal([...matrix.values()].reduce((sum, row) => sum + (row.get(position) ?? 0), 0), count); + } + assert.equal([...matrix.values()].flatMap(row => [...row.values()]).reduce((sum, count) => sum + count, 0), summary.raw.total); + assert.equal(summary.aggregation.status, 'unavailable'); +}); + +test('duplicate IDs cannot inflate matrix or status groups and original Employee references survive', () => { + const data = fixture(); + data.employees.push({ ...data.employees[1]!, organizationId: 'root', position: '대표' }); + const summary = calculateSummary(data); + const matrix = selectOrganizationPositionCounts(data); + assert.equal(matrix.get('team')!.get('책임'), 2); + assert.equal(matrix.get('root')!.get('대표'), 1); + const groups = selectStatusGroups(data, summary); + assert.equal(groups[0]!.employees.length, 1); + assert.strictEqual(groups[0]!.employees[0], data.employees[1]); + assert.strictEqual(groups[1]!.employees[0], data.employees[2]); + assert.equal(summary.raw.total, 3); +}); + +test('opaque prototype-like organization IDs and position strings remain ordinary matrix keys', () => { + const data = fixture(); + data.organizations[1]!.id = '__proto__'; + data.employees[1]!.organizationId = '__proto__'; + data.employees[2]!.organizationId = '__proto__'; + data.employees[1]!.position = 'constructor'; + data.employees[2]!.position = '__proto__'; + const row = selectOrganizationPositionCounts(data).get('__proto__')!; + assert.equal(row.get('constructor'), 1); + assert.equal(row.get('__proto__'), 1); +}); + +test('affiliation summary preserves declared order, zero entries and original affiliation objects', () => { + const data = fixture(); + const summary = calculateSummary(data); + const rows = selectAffiliationSummary(data, summary); + assert.deepEqual(rows.map(row => [row.affiliation.id, row.count]), [['first', 2], ['second', 1], ['zero', 0]]); + assert.strictEqual(rows[2]!.affiliation, data.affiliations[2]); + assert.equal(rows.reduce((sum, row) => sum + row.count, 0), summary.raw.total); +}); + +test('status flags independently hide groups without changing Raw Count or affiliation totals', () => { + const data = fixture(); + const summary = calculateSummary(data); + data.presentation = { + title: '가상 조직도', description: null, hubText: null, organizationOrder: [], + groups: { showEmployees: false, showSummary: false, showAffiliations: false, showDispatched: false, showOnLeave: true }, + }; + assert.deepEqual(selectStatusGroups(data, summary).map(group => group.status), ['onLeave']); + data.presentation.groups.showDispatched = true; + data.presentation.groups.showOnLeave = false; + assert.deepEqual(selectStatusGroups(data, summary).map(group => group.status), ['dispatched']); + data.presentation.groups.showDispatched = false; + assert.deepEqual(selectStatusGroups(data, summary), []); + assert.deepEqual(calculateSummary(data), summary); + assert.equal(selectAffiliationSummary(data, summary).length, 3); +}); + +test('zero employees retains all organization/affiliation rows and both empty status groups', () => { + const data = fixture(); + data.employees = []; + data.organizations[0]!.leaderId = null; + const summary = calculateSummary(data); + assert.equal(selectOrganizationPositionCounts(data).size, 3); + assert.ok([...selectOrganizationPositionCounts(data).values()].every(row => row.size === 0)); + assert.ok(selectAffiliationSummary(data, summary).every(row => row.count === 0)); + assert.deepEqual(selectStatusGroups(data, summary).map(group => [group.status, group.count, group.employees.length]), [['dispatched', 0, 0], ['onLeave', 0, 0]]); +}); + +test('new JSON snapshot updates matrix, affiliations and status groups without a cached count', () => { + const data = fixture(); + const before = calculateSummary(data); + data.employees[1]!.organizationId = 'root'; + data.employees[1]!.position = '수석'; + data.employees[1]!.affiliationId = 'zero'; + data.employees[1]!.workStatus = 'active'; + const after = calculateSummary(data); + const matrix = selectOrganizationPositionCounts(data); + assert.equal(matrix.get('root')!.get('수석'), 1); + assert.equal(matrix.get('team')!.get('책임'), 1); + assert.deepEqual(selectAffiliationSummary(data, after).map(row => row.count), [2, 0, 1]); + assert.equal(selectStatusGroups(data, after)[0]!.employees.length, 0); + assert.equal(before.raw.byStatus.dispatched, 1); + assert.equal(after.raw.total, before.raw.total); +}); + +test('major organization totals: root direct members then inclusive root children, summing to the raw total', () => { + const data = fixture(); + data.organizations.push({ id: 'cell', parentId: 'team', name: '가상 셀', type: 'cell', leaderId: null, displayOrder: 3 }); + data.employees.push({ id: 'member', organizationId: 'cell', affiliationId: 'zero', name: '가상 라', position: '선임', jobTitle: null, profileImage: null, workStatus: 'active' }); + const summary = calculateSummary(data); + const rows = selectMajorOrganizationTotals(data, summary); + assert.deepEqual(rows.map(row => [row.organization.id, row.scope, row.count]), [['root', 'direct', 1], ['team', 'inclusive', 3], ['empty', 'inclusive', 0]]); + assert.equal(rows.reduce((sum, row) => sum + row.count, 0), summary.raw.total); + assert.equal(rows.some(row => row.organization.id === 'cell'), false); +}); diff --git a/packages/org-chart/src/data/content-selectors.ts b/packages/org-chart/src/data/content-selectors.ts new file mode 100644 index 0000000..ab94fb8 --- /dev/null +++ b/packages/org-chart/src/data/content-selectors.ts @@ -0,0 +1,79 @@ +import { normalizeOrgChartDataset } from './normalize.js'; +import type { Employee, OrgChartDataset, OrgChartSummary, Organization, WorkStatus } from './types.js'; + +/** Internal content selectors: these do not expand the package's public data API. */ +function uniqueEmployees(data: OrgChartDataset): Employee[] { + const seen = new Set(); + return data.employees.filter(employee => { + if (seen.has(employee.id)) return false; + seen.add(employee.id); + return true; + }); +} + +/** Literal unique IDs in their primary organization, including leaders and all statuses. + * Inclusive organization counts stay in Summary; they must never be summed as a subtotal. + * Map keys safely retain opaque organization IDs and position names such as __proto__. + */ +export function selectOrganizationPositionCounts(data: OrgChartDataset): ReadonlyMap> { + const counts = new Map>(); + for (const organization of data.organizations) counts.set(organization.id, new Map()); + for (const employee of uniqueEmployees(data)) { + const row = counts.get(employee.organizationId); + if (row) row.set(employee.position, (row.get(employee.position) ?? 0) + 1); + } + return counts; +} + +export function selectAffiliationSummary(data: OrgChartDataset, summary: OrgChartSummary) { + // Include all declared affiliations, including zero, without recounting employees. + return data.affiliations.map(affiliation => ({ + affiliation, + count: summary.raw.byAffiliation[affiliation.id] ?? 0, + })); +} + +export interface MajorOrganizationTotal { + organization: Organization; + /** 'direct' for a root's own members (e.g. its leader); 'inclusive' for a major organization. */ + scope: 'direct' | 'inclusive'; + count: number; +} + +/** Compact totals for the read-only composition: each root's direct members, then + * each major (root child) organization including its cells/teams. Primary + * membership is single, so the rows are disjoint and add up to the raw total. */ +export function selectMajorOrganizationTotals(data: OrgChartDataset, summary: OrgChartSummary): MajorOrganizationTotal[] { + const rows: MajorOrganizationTotal[] = []; + for (const root of normalizeOrgChartDataset(data).roots) { + const counts = summary.raw.byOrganization[root.organization.id]; + if (!root.children.length) { + rows.push({ organization: root.organization, scope: 'inclusive', count: counts?.inclusive ?? 0 }); + continue; + } + if (counts?.direct) rows.push({ organization: root.organization, scope: 'direct', count: counts.direct }); + for (const child of root.children) { + rows.push({ organization: child.organization, scope: 'inclusive', count: summary.raw.byOrganization[child.organization.id]?.inclusive ?? 0 }); + } + } + return rows; +} + +export interface StatusContentGroup { + status: Extract; + employees: readonly Employee[]; + count: number; +} + +export function selectStatusGroups(data: OrgChartDataset, summary: OrgChartSummary): StatusContentGroup[] { + const employees = uniqueEmployees(data); + const groups: StatusContentGroup[] = []; + if (data.presentation?.groups.showDispatched !== false) { + groups.push({ status: 'dispatched', employees: employees.filter(employee => employee.workStatus === 'dispatched'), count: summary.raw.byStatus.dispatched }); + } + if (data.presentation?.groups.showOnLeave !== false) { + groups.push({ status: 'onLeave', employees: employees.filter(employee => employee.workStatus === 'onLeave'), count: summary.raw.byStatus.onLeave }); + } + // Cards retain original Employee references. A second display never changes Summary. + return groups; +} diff --git a/packages/org-chart/src/data/data.test.ts b/packages/org-chart/src/data/data.test.ts new file mode 100644 index 0000000..f1ca605 --- /dev/null +++ b/packages/org-chart/src/data/data.test.ts @@ -0,0 +1,241 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + calculateSummary, JsonOrgChartDataSource, normalizeOrgChartDataset, OrgChartSourceError, + snapshotOrgChartDataset, validateOrgChartDataset, type ApprovedAggregationPolicy, type OrgChartDataset, +} from './index.js'; + +function fixture(): OrgChartDataset { + return { + schemaVersion: '0.1-draft', + organizations: [ + { id: 'root', parentId: null, name: '가상 본부', type: 'root', leaderId: 'a', displayOrder: 0 }, + { id: 'team-a', parentId: 'root', name: '가상 기획팀', type: 'team', leaderId: 'b', displayOrder: 2 }, + { id: 'team-b', parentId: 'root', name: '가상 운영팀', type: 'team', leaderId: null, displayOrder: 1 }, + ], + employees: [ + { id: 'a', organizationId: 'root', affiliationId: 'aff-a', name: '가상직원 가', position: '대표', jobTitle: '본부장', profileImage: null, workStatus: 'active' }, + { id: 'b', organizationId: 'team-a', affiliationId: 'aff-a', name: '가상직원 나', position: '책임', jobTitle: '팀장', profileImage: null, workStatus: 'active' }, + { id: 'c', organizationId: 'team-a', affiliationId: 'aff-b', name: '가상직원 다', position: '선임', jobTitle: null, profileImage: './profiles/virtual.png', workStatus: 'dispatched' }, + { id: 'd', organizationId: 'team-b', affiliationId: 'aff-b', name: '가상직원 라', position: '선임', jobTitle: null, profileImage: 'https://example.invalid/virtual.png', workStatus: 'onLeave' }, + ], + affiliations: [{ id: 'aff-a', name: '가상 관계사 A' }, { id: 'aff-b', name: '가상 관계사 B' }], + metadata: { asOfDate: '2026-10-12', dataVersion: 'test-1' }, + }; +} +function codes(input: unknown): string[] { + const result = validateOrgChartDataset(input); + assert.equal(result.valid, false); + return result.valid ? [] : result.issues.map(issue => issue.code); +} +const policy: ApprovedAggregationPolicy = { + policyVersion: 'approved-test-1', includedStatuses: ['active', 'dispatched'], activeStatuses: ['active'], + includeLeaders: true, membershipMode: 'primaryOnly', +}; + +test('minimal v0.1-draft dataset validates and owns an immutable copy', () => { + const original = fixture(); + const result = validateOrgChartDataset(original); + assert.equal(result.valid, true); + if (!result.valid) return; + original.organizations[0]!.name = 'Changed'; + assert.equal(result.data.organizations[0]!.name, '가상 본부'); + assert.ok(Object.isFrozen(result.data.employees[0])); +}); +test('version is required at top level; compatibility metadata mirror must agree', () => { + const original = fixture() as unknown as Record; + delete original.schemaVersion; + assert.ok(codes(original).includes('SCHEMA_VERSION')); + const incompatible = fixture(); + (incompatible.metadata as unknown as Record).schemaVersion = 'future'; + assert.ok(codes(incompatible).includes('SCHEMA_VERSION')); +}); +test('required fields, nullability, whitespace and unknown fields are rejected', () => { + const original = fixture(); + original.organizations[0]!.name = ' '; + original.employees[0]!.position = null as unknown as string; + (original.organizations[1] as unknown as Record).nodeTemplate = ''; + assert.ok(codes(original).includes('REQUIRED_VALUE')); + assert.ok(codes(original).includes('UNKNOWN_FIELD')); +}); +test('dates are real calendar dates and timestamps require valid clock/timezone', () => { + for (const date of ['2026-02-30', '2026-13-01', '2026-1-1']) { + const original = fixture(); original.metadata.asOfDate = date; + assert.ok(codes(original).includes('REQUIRED_VALUE')); + } + const original = fixture(); original.metadata.updatedAt = '2026-10-12T24:00:00Z'; + assert.ok(codes(original).includes('REQUIRED_VALUE')); + original.metadata.updatedAt = '2026-10-12T11:23:45+09:00'; + assert.equal(validateOrgChartDataset(original).valid, true); +}); +test('zero employees is valid; zero organizations is a data error', () => { + const original = fixture(); original.employees = []; + original.organizations.forEach(org => { org.leaderId = null; }); + assert.equal(validateOrgChartDataset(original).valid, true); + original.organizations = []; + assert.ok(codes(original).includes('EMPTY_ORGANIZATIONS')); +}); +test('duplicate collection IDs never overwrite an earlier item', () => { + const original = fixture(); original.employees.push({ ...original.employees[0]! }); + assert.ok(codes(original).includes('DUPLICATE_ID')); +}); +test('employee, parent, affiliation and leader references are checked', () => { + const original = fixture(); + original.employees[0]!.organizationId = 'missing'; + original.employees[1]!.affiliationId = 'missing'; + original.organizations[2]!.parentId = 'missing'; + original.organizations[2]!.leaderId = 'missing'; + const failures = codes(original); + for (const code of ['ORGANIZATION_REFERENCE', 'AFFILIATION_REFERENCE', 'PARENT_REFERENCE', 'LEADER_REFERENCE']) assert.ok(failures.includes(code)); +}); +test('leader cross-organization membership requires explicit business approval', () => { + const original = fixture(); original.organizations[2]!.leaderId = 'b'; + assert.ok(codes(original).includes('LEADER_APPROVAL_REQUIRED')); +}); +test('self-parent and multi-level cycles are rejected independently of sorting', () => { + const original = fixture(); original.organizations[0]!.parentId = 'team-a'; + assert.ok(codes(original).includes('HIERARCHY_CYCLE')); + original.organizations[0]!.parentId = 'root'; + assert.ok(codes(original).includes('HIERARCHY_CYCLE')); +}); +test('unknown vocabularies are rejected without implicit status mapping', () => { + const original = fixture(); original.employees[0]!.workStatus = 'retired' as never; + original.organizations[0]!.type = 'unknown' as never; + assert.ok(codes(original).includes('INVALID_STATUS')); + assert.ok(codes(original).includes('INVALID_TYPE')); +}); +test('relation endpoints, self-relations and duplicate directed relations are checked', () => { + const original = fixture(); + original.relations = [ + { id: 'r1', sourceOrganizationId: 'team-a', targetOrganizationId: 'team-b', type: 'support' }, + { id: 'r2', sourceOrganizationId: 'team-a', targetOrganizationId: 'team-b', type: 'support' }, + { id: 'r3', sourceOrganizationId: 'missing', targetOrganizationId: 'missing', type: 'support' }, + ]; + assert.ok(codes(original).includes('INVALID_RELATION')); + assert.ok(codes(original).includes('RELATION_REFERENCE')); +}); +test('markup, executable URLs and identifier control characters are rejected', () => { + for (const image of ['javascript:alert(1)', 'data:image/svg+xml,', '//host.invalid/x', 'file:///x', 'https://user:password@example.invalid/x', 'images\\x.png']) { + const original = fixture(); original.employees[0]!.profileImage = image; + assert.ok(codes(original).includes('UNSAFE_INPUT')); + } + const original = fixture(); original.employees[0]!.name = ''; + original.organizations[1]!.id += '\u202e'; + assert.ok(codes(original).includes('UNSAFE_INPUT')); +}); +test('normalization preserves hierarchy, forest roots and deterministic presentation order', () => { + const original = fixture(); + original.organizations.push({ id: 'other-root', parentId: null, name: '추가 본부', type: 'root', leaderId: null, displayOrder: 1 }); + const natural = normalizeOrgChartDataset(original); + assert.deepEqual(natural.roots.map(node => node.organization.id), ['root', 'other-root']); + assert.deepEqual(natural.roots[0]!.children.map(node => node.organization.id), ['team-b', 'team-a']); + original.presentation = { + title: '가상 조직도', description: null, hubText: null, + groups: { showEmployees: false, showSummary: true, showDispatched: false, showOnLeave: false, showAffiliations: false }, + organizationOrder: ['team-a'], + }; + assert.equal(validateOrgChartDataset(original).valid, true); + assert.deepEqual(normalizeOrgChartDataset(original).roots[0]!.children.map(node => node.organization.id), ['team-a', 'team-b']); + original.presentation.organizationOrder.push('missing'); + assert.ok(codes(original).includes('PRESENTATION_REFERENCE')); +}); +test('raw summary uses unique IDs, primary membership and position rather than job title', () => { + const original = fixture(); const summary = calculateSummary(original); + assert.equal(summary.raw.total, 4); + assert.deepEqual(summary.raw.byOrganization.root, { direct: 1, inclusive: 4 }); + assert.deepEqual(summary.raw.byOrganization['team-a'], { direct: 2, inclusive: 2 }); + assert.equal(summary.raw.byPosition['선임'], 2); + assert.equal(summary.raw.byPosition['팀장'], undefined); + assert.equal(summary.raw.byAffiliation['aff-b'], 2); + assert.deepEqual(summary.raw.byStatus, { active: 2, dispatched: 1, onLeave: 1 }); + assert.deepEqual(summary.aggregation, { status: 'unavailable', reason: 'policyRequired' }); +}); +test('approved filtering changes business counts; raw counts and hidden groups remain unchanged', () => { + const original = fixture(); const result = calculateSummary(original, policy); + assert.equal(result.raw.total, 4); + assert.equal(result.aggregation.status, 'ready'); + if (result.aggregation.status === 'ready') { + assert.equal(result.aggregation.counts.total, 3); + assert.equal(result.aggregation.counts.byOrganization.root!.inclusive, 3); + assert.equal(result.aggregation.counts.byStatus.onLeave, 0); + } + original.presentation = { + title: '가상 조직도', description: null, hubText: '설명 콘텐츠', + groups: { showEmployees: false, showSummary: false, showDispatched: false, showOnLeave: false, showAffiliations: false }, + organizationOrder: ['team-b'], + }; + original.relations = [{ id: 'rel-a', sourceOrganizationId: 'team-a', targetOrganizationId: 'team-b', type: 'cooperation' }]; + assert.deepEqual(calculateSummary(original, policy), result); + original.employees[2]!.organizationId = 'team-b'; + original.employees[2]!.workStatus = 'active'; + original.employees[2]!.affiliationId = 'aff-a'; + const moved = calculateSummary(original); + assert.equal(moved.raw.byOrganization['team-a']!.direct, 1); + assert.equal(moved.raw.byOrganization['team-b']!.direct, 2); + assert.equal(moved.raw.byStatus.active, 3); + assert.equal(moved.raw.byAffiliation['aff-a'], 3); +}); +test('ambiguous or invalid business policy stays unavailable', () => { + for (const invalid of [ + { ...policy, includeLeaders: false }, { ...policy, membershipMode: 'uniqueEmployee' }, + { ...policy, policyVersion: '' }, { ...policy, includedStatuses: ['active', 'active'] }, + { ...policy, includedStatuses: ['dispatched'] }, + ]) { + const result = calculateSummary(fixture(), invalid as ApprovedAggregationPolicy); + assert.deepEqual(result.aggregation, { status: 'unavailable', reason: 'unsupportedPolicy' }); + } +}); +test('prototype-like opaque IDs and positions cannot corrupt summary records', () => { + const original = fixture(); original.employees[0]!.position = '__proto__'; + assert.equal(calculateSummary(original).raw.byPosition['__proto__'], 1); +}); +test('source captures inline input once and returns owned copies', async () => { + const original = fixture(); const source = new JsonOrgChartDataSource(original); + original.metadata.dataVersion = 'consumer-mutated'; + const first = await source.load() as OrgChartDataset; + assert.equal(first.metadata.dataVersion, 'test-1'); + assert.notEqual(first, await source.load()); + assert.ok(Object.isFrozen(first)); + const snapshot = snapshotOrgChartDataset(original); + assert.notEqual(snapshot, original); +}); +test('URL source passes cancellation signal and captures the response', async () => { + const controller = new AbortController(); let receivedSignal: AbortSignal | null | undefined; + const source = new JsonOrgChartDataSource('./virtual.json', { fetch: (async (_url, options) => { + receivedSignal = options?.signal; + return new Response(JSON.stringify(fixture()), { status: 200 }); + }) as typeof fetch }); + assert.equal(validateOrgChartDataset(await source.load({ signal: controller.signal })).valid, true); + assert.equal(receivedSignal, controller.signal); +}); +test('default network source loads with a receiver-sensitive browser-native fetch', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async function(this: unknown) { + if (this !== globalThis) throw new TypeError('Illegal invocation'); + return new Response(JSON.stringify(fixture()), { status: 200 }); + } as typeof fetch; + try { + const source = new JsonOrgChartDataSource('./virtual.json'); + assert.equal(validateOrgChartDataset(await source.load()).valid, true); + } finally { + globalThis.fetch = originalFetch; + } +}); +test('source errors distinguish HTTP, parsing, unsafe URL and abort without leaking upstream text', async () => { + assert.throws(() => new JsonOrgChartDataSource('javascript:alert(1)'), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_URL'); + const failed = new JsonOrgChartDataSource('/virtual.json', { fetch: (async () => { throw new Error('PRIVATE_SERVER_ERROR'); }) as typeof fetch }); + await assert.rejects(failed.load(), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_LOAD' && !error.message.includes('PRIVATE')); + const http = new JsonOrgChartDataSource('/virtual.json', { fetch: (async () => new Response('private', { status: 500 })) as typeof fetch }); + await assert.rejects(http.load(), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_LOAD'); + const parse = new JsonOrgChartDataSource('/virtual.json', { fetch: (async () => new Response('{invalid')) as typeof fetch }); + await assert.rejects(parse.load(), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_PARSE'); + const controller = new AbortController(); controller.abort(); + await assert.rejects(failed.load({ signal: controller.signal }), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_ABORT'); +}); +test('late ignored abort response cannot become a successful source result', async () => { + const controller = new AbortController(); + const source = new JsonOrgChartDataSource('/virtual.json', { fetch: (async () => { + controller.abort(); return new Response(JSON.stringify(fixture())); + }) as typeof fetch }); + await assert.rejects(source.load({ signal: controller.signal }), (error: unknown) => error instanceof OrgChartSourceError && error.code === 'SOURCE_ABORT'); +}); diff --git a/packages/org-chart/src/data/examples.test.ts b/packages/org-chart/src/data/examples.test.ts new file mode 100644 index 0000000..aedbb4e --- /dev/null +++ b/packages/org-chart/src/data/examples.test.ts @@ -0,0 +1,111 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fixture from '../../../../examples/org-chart/data-expanded-large.json'; +import { companyColorPresets, type CompanyId } from '../company-color-presets.js'; +import { + calculateSummary, createOrgChartExampleDataset, JsonOrgChartDataSource, + normalizeOrgChartDataset, snapshotOrgChartDataset, validateOrgChartDataset, + type OrgChartDataset, type OrgChartExampleEmployeeCount, +} from './index.js'; + +const base = fixture as OrgChartDataset; +const companies = Object.keys(companyColorPresets) as CompanyId[]; +const sizes: OrgChartExampleEmployeeCount[] = [20, 100, 300]; + +function assertContract(dataset: OrgChartDataset) { + const result = validateOrgChartDataset(dataset); + assert.equal(result.valid, true, result.valid ? undefined : JSON.stringify(result.issues)); + assert.equal(new Set(dataset.employees.map(employee => employee.id)).size, dataset.employees.length); + assert.equal(new Set(dataset.organizations.map(org => org.id)).size, dataset.organizations.length); + assert.equal(new Set(dataset.affiliations.map(affiliation => affiliation.id)).size, dataset.affiliations.length); + const summary = calculateSummary(dataset).raw; + assert.equal(summary.total, dataset.employees.length, 'leaders count once as employees'); + const pending = normalizeOrgChartDataset(dataset).roots.map(node => ({ node, depth: 1 })); + let maximumDepth = 0; + while (pending.length) { + const { node, depth } = pending.pop()!; + maximumDepth = Math.max(maximumDepth, depth); + pending.push(...node.children.map(child => ({ node: child, depth: depth + 1 }))); + } + assert.ok(maximumDepth >= 3); + assert.ok(new Set(Object.values(summary.byOrganization).map(value => value.direct)).size > 1, 'unequal organization membership'); +} + +for (const employeeCount of sizes) { + test(`total ${employeeCount} contains exactly that many unique employees across all seven preset companies`, () => { + const dataset = createOrgChartExampleDataset(base, { scope: 'total', employeeCount }); + assertContract(dataset); + assert.equal(dataset.employees.length, employeeCount); + assert.deepEqual(dataset.affiliations.map(item => item.id), companies); + for (const companyId of companies) assert.ok(dataset.employees.some(employee => employee.affiliationId === companyId)); + }); + test(`per-company ${employeeCount} supports every company, pairs, and the full seven-company selection`, () => { + for (const companyId of companies) { + const dataset = createOrgChartExampleDataset(base, { scope: 'per-company', employeeCount, companyIds: [companyId] }); + assertContract(dataset); + assert.equal(dataset.employees.length, employeeCount); + assert.ok(dataset.organizations.every(org => org.companyId === companyId)); + assert.ok(dataset.employees.every(employee => employee.affiliationId === companyId)); + } + for (const companyIds of [['hanmac', 'saman'], companies] as CompanyId[][]) { + const dataset = createOrgChartExampleDataset(base, { scope: 'per-company', employeeCount, companyIds }); + assertContract(dataset); + assert.equal(dataset.employees.length, employeeCount * companyIds.length); + for (const id of companyIds) assert.equal(dataset.employees.filter(employee => employee.affiliationId === id).length, employeeCount); + } + }); +} + +test('total company filtering preserves company allocation without redistribution or lost ancestors', () => { + for (const employeeCount of sizes) { + const complete = createOrgChartExampleDataset(base, { scope: 'total', employeeCount }); + const selected = createOrgChartExampleDataset(base, { scope: 'total', employeeCount, companyIds: ['saman', 'hanmac'] }); + assertContract(selected); + assert.deepEqual(selected.employees, complete.employees.filter(employee => ['hanmac', 'saman'].includes(employee.affiliationId))); + assert.deepEqual(selected.organizations.map(org => org.id), complete.organizations.filter(org => ['hanmac', 'saman'].includes(org.companyId!)).map(org => org.id)); + } +}); + +test('total 300 reuses every P5-3 large employee record and the original organization IDs', () => { + const dataset = createOrgChartExampleDataset(base, { scope: 'total', employeeCount: 300 }); + const originalById = new Map(base.employees.map(employee => [employee.id, employee])); + assert.equal(dataset.employees.length, base.employees.length); + for (const employee of dataset.employees) assert.deepEqual(employee, originalById.get(employee.id)); + assert.deepEqual(dataset.organizations, base.organizations); +}); + +test('selections are deterministic sets and snapshots never mutate the source or another result', async () => { + const before = structuredClone(base); + const frozen = snapshotOrgChartDataset(base); + const first = createOrgChartExampleDataset(frozen, { scope: 'per-company', employeeCount: 100, companyIds: ['saman', 'hanmac', 'saman'] }); + const equivalent = createOrgChartExampleDataset(frozen, { scope: 'per-company', employeeCount: 100, companyIds: ['hanmac', 'saman'] }); + assert.deepEqual(first, equivalent); + assert.notEqual(first, equivalent); + assert.notEqual(first.employees[0], equivalent.employees[0]); + assert.ok(Object.isFrozen(first) && Object.isFrozen(first.organizations) && Object.isFrozen(first.employees[0])); + assert.throws(() => { first.employees[0]!.name = 'changed'; }, TypeError); + assert.deepEqual(base, before); + assert.deepEqual(await new JsonOrgChartDataSource(first).load(), first); + const single = createOrgChartExampleDataset(base, { scope: 'per-company', employeeCount: 100, companyIds: ['saman'] }); + assert.deepEqual(single.employees, first.employees.filter(employee => employee.affiliationId === 'saman')); +}); + +test('expansion resolves source IDs that already use the generated employee namespace', () => { + const template = structuredClone(base); + const employee = template.employees.find(employee => employee.affiliationId === 'hanmac' && !template.organizations.some(org => org.leaderId === employee.id))!; + employee.id = 'p7-virtual-hanmac-050'; + const dataset = createOrgChartExampleDataset(template, { scope: 'per-company', employeeCount: 100 }); + assertContract(dataset); + assert.ok(dataset.employees.some(employee => employee.id === 'p7-virtual-hanmac-050-example')); +}); + +test('invalid scale, company IDs, empty view selection, and incomplete templates fail explicitly', () => { + assert.throws(() => createOrgChartExampleDataset(base, { scope: 'invalid' as 'total', employeeCount: 20 }), /scope/); + assert.throws(() => createOrgChartExampleDataset(base, { scope: 'total', employeeCount: 99 as 20 }), /employeeCount/); + assert.throws(() => createOrgChartExampleDataset(base, { scope: 'total', employeeCount: 20, companyIds: ['unknown' as CompanyId] }), /presets/); + assert.throws(() => createOrgChartExampleDataset(base, { scope: 'total', employeeCount: 20, companyIds: [] }), /empty view selection/); + const incomplete = structuredClone(base); + incomplete.employees = incomplete.employees.filter(employee => employee.affiliationId !== 'baron'); + incomplete.organizations.filter(org => org.companyId === 'baron').forEach(org => { org.leaderId = null; }); + assert.throws(() => createOrgChartExampleDataset(incomplete, { scope: 'total', employeeCount: 20 }), /all seven/); +}); diff --git a/packages/org-chart/src/data/examples.ts b/packages/org-chart/src/data/examples.ts new file mode 100644 index 0000000..e6a6efc --- /dev/null +++ b/packages/org-chart/src/data/examples.ts @@ -0,0 +1,124 @@ +import { companyColorPresets, type CompanyId } from '../company-color-presets.js'; +import { normalizeOrgChartDataset } from './normalize.js'; +import { snapshotOrgChartDataset } from './snapshot.js'; +import { validateOrgChartDataset } from './validate.js'; +import type { Employee, OrgChartDataset, Organization } from './types.js'; + +export type OrgChartExampleScope = 'total' | 'per-company'; +export type OrgChartExampleEmployeeCount = 20 | 100 | 300; +export interface OrgChartExampleSelection { + /** Total is allocated across all seven companies before company filtering. */ + scope: OrgChartExampleScope; + employeeCount: OrgChartExampleEmployeeCount; + /** Omission selects all companies. An empty selection belongs to view state. */ + companyIds?: readonly CompanyId[]; +} + +const companyIds = Object.keys(companyColorPresets) as CompanyId[]; + +/** Deterministic virtual examples derived from an existing seven-company fixture. + * This provider is independent of layout, React and popup policy. Use its result + * with the existing JsonOrgChartDataSource; no production/API source is replaced. + * + * The P5-3 large fixture's hierarchy and employee records are reused. Larger + * company examples add virtual, company-namespaced employees. Neither the fixture + * nor a previously returned dataset is modified. Total-company selection never + * redistributes unselected companies' staff into a remaining company. + */ +export function createOrgChartExampleDataset(template: OrgChartDataset, selection: OrgChartExampleSelection): OrgChartDataset { + if (!selection || !['total', 'per-company'].includes(selection.scope)) throw new RangeError('Example scope must be total or per-company.'); + if (![20, 100, 300].includes(selection.employeeCount)) throw new RangeError('Example employeeCount must be 20, 100 or 300.'); + const requested = selection.companyIds ?? companyIds; + if (!Array.isArray(requested) || !requested.length) throw new RangeError('Example data requires at least one company; use an empty view selection to hide all companies.'); + if (requested.some(id => !Object.hasOwn(companyColorPresets, id))) throw new RangeError('Example companyIds require registered company presets.'); + // Preset order makes equivalent set selections identical, including IDs/order. + const selected = companyIds.filter(id => requested.includes(id)); + const validation = validateOrgChartDataset(template); + if (!validation.valid) throw new TypeError('Example template must be a valid OrgChart dataset.'); + const base = validation.data; + const organizationCompanies = resolveTemplateCompanies(base); + const rosters = new Map(companyIds.map(id => [id, []])); + for (const employee of base.employees) { + const company = organizationCompanies.get(employee.organizationId)!; + if (employee.affiliationId !== company) throw new TypeError('Example employees must belong to their organization company affiliation.'); + rosters.get(company)!.push(employee); + } + if (companyIds.some(id => !rosters.get(id)!.length)) throw new TypeError('Example template must contain employees from all seven companies.'); + const counts = selection.scope === 'total' + ? allocateTotal(selection.employeeCount, companyIds.map(id => rosters.get(id)!.length)) + : companyIds.map(() => selection.employeeCount); + const chosen = new Set(selected); + const organizations: Organization[] = base.organizations.filter(org => chosen.has(organizationCompanies.get(org.id)!)) + .map(org => ({ ...org, companyId: organizationCompanies.get(org.id)! })); + const employees: Employee[] = []; + const existingIds = new Set(base.employees.map(employee => employee.id)); + const organizationPool = new Map(selected.map(id => [id, organizations.filter(org => org.companyId === id && org.parentId !== null)])); + for (const id of selected) { + const roster = rosters.get(id)!; + const count = counts[companyIds.indexOf(id)]!; + employees.push(...roster.slice(0, count).map(employee => ({ ...employee }))); + // Unequal weighted placement reuses the original tree instead of cloning it. + const pool = organizationPool.get(id)!; + const weighted = (pool.length ? pool : organizations.filter(org => org.companyId === id)) + .flatMap((org, index) => Array.from({ length: 1 + index % 4 }, () => org)); + for (let index = roster.length; index < count; index++) { + let employeeId = `p7-virtual-${id}-${String(index + 1).padStart(3, '0')}`; + // A caller fixture may already use our namespace. Resolve collisions without + // changing the input or tying identity to the other selected companies. + while (existingIds.has(employeeId)) employeeId += '-example'; + existingIds.add(employeeId); + const organization = weighted[(index - roster.length) % weighted.length]!; + employees.push({ + id: employeeId, organizationId: organization.id, affiliationId: id, + name: `가상직원 ${companyColorPresets[id].companyName} ${String(index + 1).padStart(3, '0')}`, + position: ['사원', '선임', '책임', '수석'][index % 4]!, jobTitle: null, + profileImage: null, workStatus: 'active', + }); + } + } + const employeeIds = new Set(employees.map(employee => employee.id)); + for (const organization of organizations) if (organization.leaderId && !employeeIds.has(organization.leaderId)) organization.leaderId = null; + const organizationIds = new Set(organizations.map(org => org.id)); + return snapshotOrgChartDataset({ + ...base, organizations, employees, + affiliations: base.affiliations.filter(affiliation => chosen.has(affiliation.id as CompanyId)), + ...(base.relations ? { relations: base.relations.filter(relation => organizationIds.has(relation.sourceOrganizationId) && organizationIds.has(relation.targetOrganizationId)) } : {}), + ...(base.presentation ? { presentation: { ...base.presentation, organizationOrder: base.presentation.organizationOrder.filter(id => organizationIds.has(id)) } } : {}), + metadata: { ...base.metadata, dataVersion: `p7-example-${selection.scope}-${selection.employeeCount}-${selected.join('_')}`, updateNote: '가상 예시 데이터 · 운영 정보 아님' }, + }); +} + +function resolveTemplateCompanies(data: OrgChartDataset): Map { + const employees = new Map(data.employees.map(employee => [employee.id, employee])); + const result = new Map(); + const pending = normalizeOrgChartDataset(data).roots.map(node => ({ node, inherited: undefined as CompanyId | undefined })); + while (pending.length) { + const { node, inherited } = pending.pop()!; + const company = node.organization.companyId ?? inherited ?? (node.organization.leaderId ? employees.get(node.organization.leaderId)?.affiliationId : undefined); + if (!company || !Object.hasOwn(companyColorPresets, company) || (inherited && company !== inherited)) throw new TypeError('Example template requires a consistent registered company on each organization tree.'); + result.set(node.organization.id, company as CompanyId); + pending.push(...node.children.map(child => ({ node: child, inherited: company as CompanyId }))); + } + return result; +} + +/** Largest-remainder allocation, with at least one employee in every company. */ +function allocateTotal(total: number, weights: number[]): number[] { + const sum = weights.reduce((value, weight) => value + weight, 0); + const targets = weights.map(weight => total * weight / sum); + const counts = targets.map(target => Math.max(1, Math.floor(target))); + let current = counts.reduce((value, count) => value + count, 0); + while (current !== total) { + const adding = current < total; + let candidate = -1; + let greatest = -Infinity; + counts.forEach((count, index) => { + if (!adding && count <= 1) return; + const difference = adding ? targets[index]! - count : count - targets[index]!; + if (difference > greatest) { greatest = difference; candidate = index; } + }); + counts[candidate]! += adding ? 1 : -1; + current += adding ? 1 : -1; + } + return counts; +} diff --git a/packages/org-chart/src/data/index.ts b/packages/org-chart/src/data/index.ts new file mode 100644 index 0000000..6cdfa01 --- /dev/null +++ b/packages/org-chart/src/data/index.ts @@ -0,0 +1,10 @@ +export * from './types.js'; +export { validateOrgChartDataset, isSafeImageUrl } from './validate.js'; +export { normalizeOrgChartDataset } from './normalize.js'; +export { calculateSummary } from './summary.js'; +export { snapshotOrgChartDataset } from './snapshot.js'; +export { JsonOrgChartDataSource, OrgChartSourceError, type OrgChartSourceErrorCode, type JsonOrgChartDataSourceOptions } from './source.js'; +export { createOrgChartExampleDataset } from './examples.js'; +export type { OrgChartExampleScope, OrgChartExampleEmployeeCount, OrgChartExampleSelection } from './examples.js'; +export { createOrgChartMoveValidator, validateOrgChartMove, applyOrgChartMove } from './moves.js'; +export type { OrgChartMoveRequest, OrgChartMoveFailureCode, OrgChartMoveFailure, OrgChartMoveValidation, OrgChartMoveResult } from './moves.js'; diff --git a/packages/org-chart/src/data/moves.test.ts b/packages/org-chart/src/data/moves.test.ts new file mode 100644 index 0000000..9317592 --- /dev/null +++ b/packages/org-chart/src/data/moves.test.ts @@ -0,0 +1,214 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyOrgChartMove, createOrgChartMoveValidator, validateOrgChartMove, type OrgChartMoveFailureCode, type OrgChartMoveRequest } from './moves.js'; +import { calculateSummary } from './summary.js'; +import { normalizeOrgChartDataset } from './normalize.js'; +import { snapshotOrgChartDataset } from './snapshot.js'; +import { validateOrgChartDataset } from './validate.js'; +import type { Employee, OrgChartDataset } from './types.js'; + +function member(id: string, organizationId: string, affiliationId = 'company-a'): Employee { + return { id, organizationId, affiliationId, name: `가상 ${id}`, position: '선임', jobTitle: null, profileImage: './profiles/virtual.png', workStatus: 'active' }; +} + +function fixture(): OrgChartDataset { + return { + schemaVersion: '0.1-draft', + organizations: [ + { id: 'a-root', companyId: 'company-a', parentId: null, name: '가상 A', type: 'root', leaderId: 'root-leader', displayOrder: 0 }, + { id: 'a-division', parentId: 'a-root', name: '가상 본부', type: 'division', leaderId: null, displayOrder: 4 }, + { id: 'a-team', parentId: 'a-division', name: '가상 팀', type: 'team', leaderId: 'team-leader', displayOrder: 8 }, + { id: 'a-cell', parentId: 'a-team', name: '가상 셀', type: 'cell', leaderId: null, displayOrder: 7 }, + { id: 'a-other', parentId: 'a-root', name: '가상 다른 본부', type: 'division', leaderId: null, displayOrder: 2 }, + { id: 'b-root', companyId: 'company-b', parentId: null, name: '가상 B', type: 'root', leaderId: null, displayOrder: 1 }, + { id: 'b-team', parentId: 'b-root', name: '가상 B 팀', type: 'team', leaderId: null, displayOrder: 1 }, + ], + employees: [member('root-leader', 'a-root'), member('team-leader', 'a-team'), member('staff', 'a-team', 'company-b'), member('cell-staff', 'a-cell'), member('b-staff', 'b-team', 'company-b')], + affiliations: [{ id: 'company-a', name: '가상 A 회사' }, { id: 'company-b', name: '가상 B 회사' }], + relations: [{ id: 'support', sourceOrganizationId: 'a-team', targetOrganizationId: 'a-other', type: 'support' }], + presentation: { + title: '가상 조직도', description: null, hubText: null, + groups: { showEmployees: true, showSummary: true, showDispatched: true, showOnLeave: true, showAffiliations: true }, + organizationOrder: ['a-other', 'a-team'], + }, + metadata: { asOfDate: '2026-10-13', dataVersion: 'move-test' }, + }; +} + +function request(kind: OrgChartMoveRequest['kind'], sourceId: string, targetOrganizationId: string): OrgChartMoveRequest { + return { kind, sourceId, targetOrganizationId }; +} + +function expectRejected(data: OrgChartDataset, move: OrgChartMoveRequest, code: OrgChartMoveFailureCode) { + const before = JSON.stringify(data); + const policy = validateOrgChartMove(data, move); + assert.equal(policy.allowed, false); + if (!policy.allowed) { assert.equal(policy.code, code); assert.ok(policy.message.length > 0); } + const result = applyOrgChartMove(data, move); + assert.equal(result.moved, false); + if (!result.moved) assert.equal(result.code, code); + assert.equal(JSON.stringify(data), before, 'rejected movement retains every input record'); +} + +test('employee move changes organizationId alone, retains affiliation and IDs, updates counts, and owns frozen state', () => { + const original = snapshotOrgChartDataset(fixture()); + const before = JSON.stringify(original); + const move = request('employee', 'staff', 'a-other'); + const result = applyOrgChartMove(original, move); + assert.equal(result.moved, true); + if (!result.moved) return; + assert.equal(result.previousOrganizationId, 'a-team'); + assert.deepEqual(result.request, move); + assert.deepEqual(result.data.employees.find(employee => employee.id === 'staff'), { ...original.employees.find(employee => employee.id === 'staff')!, organizationId: 'a-other' }); + assert.deepEqual(result.data, { ...original, employees: original.employees.map(employee => employee.id === 'staff' ? { ...employee, organizationId: 'a-other' } : employee) }); + assert.equal(JSON.stringify(original), before); + assert.ok(Object.isFrozen(result.data) && Object.isFrozen(result.data.employees) && Object.isFrozen(result.data.employees[0]) && Object.isFrozen(result.request)); + assert.notEqual(result.data.employees[0], original.employees[0]); + const normalized = normalizeOrgChartDataset(result.data); + assert.equal(normalized.employeesByOrganization.get('a-team')!.some(employee => employee.id === 'staff'), false); + assert.equal(normalized.employeesByOrganization.get('a-other')!.filter(employee => employee.id === 'staff').length, 1); + const beforeCounts = calculateSummary(original).raw; + const afterCounts = calculateSummary(result.data).raw; + assert.equal(afterCounts.total, beforeCounts.total); + assert.equal(afterCounts.byOrganization['a-team']!.direct, beforeCounts.byOrganization['a-team']!.direct - 1); + assert.equal(afterCounts.byOrganization['a-other']!.inclusive, beforeCounts.byOrganization['a-other']!.inclusive + 1); + assert.equal(new Set(result.data.employees.map(employee => employee.id)).size, original.employees.length); + assert.equal(validateOrgChartDataset(result.data).valid, true); +}); + +test('group move changes parentId alone, preserving subtree, leaders, sorting, company, relations and employee total', () => { + const original = snapshotOrgChartDataset(fixture()); + const before = JSON.stringify(original); + const result = applyOrgChartMove(original, request('organization', 'a-team', 'a-other')); + assert.equal(result.moved, true); + if (!result.moved) return; + assert.equal(result.previousOrganizationId, 'a-division'); + assert.deepEqual(result.data, { ...original, organizations: original.organizations.map(org => org.id === 'a-team' ? { ...org, parentId: 'a-other' } : org) }); + assert.equal(JSON.stringify(original), before); + const normalized = normalizeOrgChartDataset(result.data); + assert.equal(normalized.organizationsById.get('a-cell')!.parentId, 'a-team'); + assert.equal(normalized.organizationsById.get('a-team')!.leaderId, 'team-leader'); + assert.equal(normalized.organizationsById.get('a-team')!.displayOrder, 8); + assert.equal(new Set(result.data.organizations.map(org => org.id)).size, original.organizations.length); + const beforeCounts = calculateSummary(original).raw; + const afterCounts = calculateSummary(result.data).raw; + assert.equal(afterCounts.total, beforeCounts.total); + assert.equal(afterCounts.byOrganization['a-team']!.inclusive, beforeCounts.byOrganization['a-team']!.inclusive); + assert.equal(afterCounts.byOrganization['a-division']!.inclusive, 0); + assert.equal(afterCounts.byOrganization['a-other']!.inclusive, beforeCounts.byOrganization['a-team']!.inclusive); + assert.equal(validateOrgChartDataset(result.data).valid, true); +}); + +test('employee policy rejects leaders, absent IDs and existing membership', () => { + const data = fixture(); + expectRejected(data, request('employee', 'team-leader', 'a-other'), 'LEADER_PROTECTED'); + expectRejected(data, request('employee', 'missing', 'a-other'), 'SOURCE_NOT_FOUND'); + expectRejected(data, request('employee', 'staff', 'missing'), 'TARGET_NOT_FOUND'); + expectRejected(data, request('employee', 'staff', 'a-team'), 'SAME_ORGANIZATION'); +}); + +test('group policy rejects roots, self, descendants, existing parent and absent IDs', () => { + const data = fixture(); + expectRejected(data, request('organization', 'a-root', 'a-other'), 'ROOT_PROTECTED'); + expectRejected(data, request('organization', 'a-team', 'a-team'), 'SELF_TARGET'); + expectRejected(data, request('organization', 'a-team', 'a-cell'), 'DESCENDANT_TARGET'); + expectRejected(data, request('organization', 'a-team', 'a-division'), 'SAME_ORGANIZATION'); + expectRejected(data, request('organization', 'missing', 'a-other'), 'SOURCE_NOT_FOUND'); + expectRejected(data, request('organization', 'a-team', 'missing'), 'TARGET_NOT_FOUND'); +}); + +test('cross-company employee move updates existing organization and affiliation references, company totals and immutable state', () => { + const data = fixture(); data.employees[2]!.affiliationId = 'company-a'; + const original = snapshotOrgChartDataset(data), before = JSON.stringify(original); + const move = request('employee', 'staff', 'b-team'); + assert.deepEqual(validateOrgChartMove(original, move), { allowed: true, previousOrganizationId: 'a-team', previousCompanyId: 'company-a', companyId: 'company-b' }); + const result = applyOrgChartMove(original, move); + assert.equal(result.moved, true); + if (!result.moved) return; + assert.deepEqual(result.data, { ...original, employees: original.employees.map(employee => employee.id === 'staff' + ? { ...employee, organizationId: 'b-team', affiliationId: 'company-b' } : employee) }); + const beforeCounts = calculateSummary(original).raw, afterCounts = calculateSummary(result.data).raw; + assert.equal(afterCounts.total, beforeCounts.total); + assert.equal(afterCounts.byOrganization['a-root']!.inclusive, beforeCounts.byOrganization['a-root']!.inclusive - 1); + assert.equal(afterCounts.byOrganization['b-root']!.inclusive, beforeCounts.byOrganization['b-root']!.inclusive + 1); + assert.equal(afterCounts.byAffiliation['company-a'], beforeCounts.byAffiliation['company-a']! - 1); + assert.equal(afterCounts.byAffiliation['company-b'], beforeCounts.byAffiliation['company-b']! + 1); + assert.equal(new Set(result.data.employees.map(employee => employee.id)).size, original.employees.length); + assert.ok(Object.isFrozen(result.data.employees[2])); + assert.equal(JSON.stringify(original), before); + assert.equal(validateOrgChartDataset(result.data).valid, true); +}); + +test('cross-company group move transfers all descendant companies and employee affiliations while retaining subtree and leaders', () => { + const original = snapshotOrgChartDataset(fixture()), before = JSON.stringify(original); + const result = applyOrgChartMove(original, request('organization', 'a-team', 'b-team')); + assert.equal(result.moved, true); + if (!result.moved) return; + const subtree = new Set(['a-team', 'a-cell']); + assert.deepEqual(result.data, { ...original, + organizations: original.organizations.map(org => org.id === 'a-team' ? { ...org, parentId: 'b-team', companyId: 'company-b' } + : subtree.has(org.id) ? { ...org, companyId: 'company-b' } : org), + employees: original.employees.map(employee => subtree.has(employee.organizationId) ? { ...employee, affiliationId: 'company-b' } : employee), + }); + assert.equal(result.data.organizations.find(org => org.id === 'a-cell')!.parentId, 'a-team'); + assert.equal(result.data.organizations.find(org => org.id === 'a-team')!.leaderId, 'team-leader'); + assert.equal(result.data.employees.find(employee => employee.id === 'team-leader')!.organizationId, 'a-team'); + const beforeCounts = calculateSummary(original).raw, afterCounts = calculateSummary(result.data).raw; + const transferred = beforeCounts.byOrganization['a-team']!.inclusive; + assert.equal(afterCounts.total, beforeCounts.total); + assert.equal(afterCounts.byOrganization['a-root']!.inclusive, beforeCounts.byOrganization['a-root']!.inclusive - transferred); + assert.equal(afterCounts.byOrganization['b-root']!.inclusive, beforeCounts.byOrganization['b-root']!.inclusive + transferred); + assert.equal(afterCounts.byAffiliation['company-b'], 4); + assert.equal(new Set(result.data.organizations.map(org => org.id)).size, original.organizations.length); + assert.equal(new Set(result.data.employees.map(employee => employee.id)).size, original.employees.length); + assert.equal(JSON.stringify(original), before); + assert.equal(validateOrgChartDataset(result.data).valid, true); + assert.equal(validateOrgChartMove(result.data, request('organization', 'a-cell', 'a-other')).allowed, true, 'transferred subtree can subsequently move back'); +}); + +test('cross-company moves reject unknown target affiliation rather than inventing a new company record', () => { + const data = fixture(); data.organizations[5]!.companyId = 'unregistered-company'; + assert.equal(validateOrgChartDataset(data).valid, true); + expectRejected(data, request('employee', 'staff', 'b-team'), 'TARGET_AFFILIATION_MISSING'); + expectRejected(data, request('organization', 'a-team', 'b-team'), 'TARGET_AFFILIATION_MISSING'); +}); + +test('policy rejects malformed request, duplicate IDs, invalid source relationships and company hierarchy contradictions', () => { + expectRejected(fixture(), { kind: 'unknown', sourceId: 'staff', targetOrganizationId: 'a-other' } as unknown as OrgChartMoveRequest, 'INVALID_REQUEST'); + const duplicate = fixture(); duplicate.employees.push({ ...duplicate.employees[2]! }); + expectRejected(duplicate, request('employee', 'staff', 'a-other'), 'INVALID_DATA'); + const cycle = fixture(); cycle.organizations[1]!.parentId = 'a-cell'; + expectRejected(cycle, request('employee', 'staff', 'a-other'), 'INVALID_DATA'); + const invalidEmployee = fixture(); invalidEmployee.employees[2]!.organizationId = 'missing'; + expectRejected(invalidEmployee, request('employee', 'staff', 'a-other'), 'INVALID_DATA'); + const conflict = fixture(); conflict.organizations[2]!.companyId = 'company-b'; + expectRejected(conflict, request('organization', 'a-team', 'a-other'), 'INVALID_DATA'); +}); + +test('legacy roots derive company through their leader but unresolved company identity prevents moves', () => { + const legacy = fixture(); delete legacy.organizations[0]!.companyId; + assert.equal(validateOrgChartMove(legacy, request('employee', 'staff', 'a-other')).allowed, true); + legacy.organizations[0]!.leaderId = null; + expectRejected(legacy, request('employee', 'staff', 'a-other'), 'COMPANY_UNKNOWN'); +}); + +test('drop-target validator snapshots once and retains its policy if a caller later mutates the dataset', () => { + const data = fixture(); + const policy = createOrgChartMoveValidator(data); + data.employees[2]!.organizationId = 'b-team'; + assert.deepEqual(policy(request('employee', 'staff', 'a-other')), { allowed: true, previousOrganizationId: 'a-team', previousCompanyId: 'company-a', companyId: 'company-a' }); + assert.deepEqual(validateOrgChartMove(data, request('employee', 'staff', 'a-other')), { allowed: true, previousOrganizationId: 'b-team', previousCompanyId: 'company-b', companyId: 'company-a' }); +}); + +test('opaque IDs remain safe move keys without duplicate or synthetic records', () => { + const data = fixture(); + data.organizations[4]!.id = '__proto__'; + data.relations![0]!.targetOrganizationId = '__proto__'; + data.presentation!.organizationOrder[0] = '__proto__'; + const result = applyOrgChartMove(data, request('employee', 'staff', '__proto__')); + assert.equal(result.moved, true); + if (!result.moved) return; + assert.equal(result.data.employees.find(employee => employee.id === 'staff')!.organizationId, '__proto__'); + assert.equal(result.data.employees.length, data.employees.length); + assert.equal(validateOrgChartDataset(result.data).valid, true); +}); diff --git a/packages/org-chart/src/data/moves.ts b/packages/org-chart/src/data/moves.ts new file mode 100644 index 0000000..02d803c --- /dev/null +++ b/packages/org-chart/src/data/moves.ts @@ -0,0 +1,150 @@ +import { normalizeOrgChartDataset } from './normalize.js'; +import { validateOrgChartDataset } from './validate.js'; +import type { OrgChartDataset } from './types.js'; + +/** Source IDs retain their original collection identity after a browser-state move. */ +export interface OrgChartMoveRequest { + kind: 'employee' | 'organization'; + sourceId: string; + targetOrganizationId: string; +} + +export type OrgChartMoveFailureCode = + | 'EDITING_DISABLED' + | 'INVALID_DATA' | 'INVALID_REQUEST' | 'SOURCE_NOT_FOUND' | 'TARGET_NOT_FOUND' + | 'COMPANY_UNKNOWN' | 'TARGET_AFFILIATION_MISSING' | 'LEADER_PROTECTED' | 'ROOT_PROTECTED' + | 'SELF_TARGET' | 'DESCENDANT_TARGET' | 'SAME_ORGANIZATION'; + +export interface OrgChartMoveFailure { + code: OrgChartMoveFailureCode; + message: string; +} +export type OrgChartMoveValidation = + | { allowed: true; previousOrganizationId: string; previousCompanyId: string; companyId: string } + | ({ allowed: false } & OrgChartMoveFailure); +export type OrgChartMoveResult = + | { moved: true; data: OrgChartDataset; request: OrgChartMoveRequest; previousOrganizationId: string } + | ({ moved: false } & OrgChartMoveFailure); + +const messages: Record = { + EDITING_DISABLED: 'Drag & Drop이 OFF입니다. 조직 이동을 사용하려면 ON으로 설정하세요.', + INVALID_DATA: '조직 데이터의 관계가 유효하지 않아 이동할 수 없습니다.', + INVALID_REQUEST: '이동 요청이 올바르지 않습니다.', + SOURCE_NOT_FOUND: '이동할 직원 또는 조직이 존재하지 않습니다.', + TARGET_NOT_FOUND: '이동할 대상 조직이 존재하지 않습니다.', + COMPANY_UNKNOWN: '회사 소속을 확인할 수 없어 이동할 수 없습니다.', + TARGET_AFFILIATION_MISSING: '대상 회사의 소속 정보가 없어 회사 간 이동을 완료할 수 없습니다.', + LEADER_PROTECTED: '조직장은 조직장 지정 정책을 먼저 변경해야 이동할 수 있습니다.', + ROOT_PROTECTED: '회사 최상위 조직은 이동할 수 없습니다.', + SELF_TARGET: '조직을 자기 자신에게 이동할 수 없습니다.', + DESCENDANT_TARGET: '하위 조직으로 이동하면 순환 관계가 생겨 이동할 수 없습니다.', + SAME_ORGANIZATION: '이미 해당 조직에 속해 있습니다.', +}; + +function deny(code: OrgChartMoveFailureCode): OrgChartMoveValidation { + return { allowed: false, code, message: messages[code] }; +} + +/** Validate once for all highlighted drop targets. The closure owns an immutable + * dataset snapshot, so later consumer mutations cannot change a drag's policy. + * Company is the organization tree's identity, not an employee's affiliation. + * Legacy roots may establish it through their leader's affiliation; no arbitrary + * fallback company is invented when ownership cannot be established. + */ +export function createOrgChartMoveValidator(data: OrgChartDataset): (request: OrgChartMoveRequest) => OrgChartMoveValidation { + const validation = validateOrgChartDataset(data); + if (!validation.valid) return () => deny('INVALID_DATA'); + const owned = validation.data; + const normalized = normalizeOrgChartDataset(owned); + const employees = new Map(owned.employees.map(employee => [employee.id, employee])); + const affiliations = new Set(owned.affiliations.map(affiliation => affiliation.id)); + const leaders = new Set(owned.organizations.flatMap(org => org.leaderId === null ? [] : [org.leaderId])); + const companies = new Map(); + const pending = normalized.roots.map(node => ({ node, inherited: undefined as string | undefined })); + while (pending.length) { + const { node, inherited } = pending.pop()!; + const org = node.organization; + const company = org.companyId ?? inherited ?? (org.parentId === null && org.leaderId ? employees.get(org.leaderId)?.affiliationId : undefined); + if (inherited && company !== inherited) return () => deny('INVALID_DATA'); + companies.set(org.id, company); + pending.push(...node.children.map(child => ({ node: child, inherited: company }))); + } + + return request => { + if (!request || !['employee', 'organization'].includes(request.kind) + || typeof request.sourceId !== 'string' || !request.sourceId + || typeof request.targetOrganizationId !== 'string' || !request.targetOrganizationId) return deny('INVALID_REQUEST'); + const source = request.kind === 'employee' ? employees.get(request.sourceId) : normalized.organizationsById.get(request.sourceId); + if (!source) return deny('SOURCE_NOT_FOUND'); + const target = normalized.organizationsById.get(request.targetOrganizationId); + if (!target) return deny('TARGET_NOT_FOUND'); + const sourceOrganization = request.kind === 'employee' + ? normalized.organizationsById.get(employees.get(request.sourceId)!.organizationId)! + : normalized.organizationsById.get(request.sourceId)!; + const previousOrganizationId = request.kind === 'employee' ? sourceOrganization.id : sourceOrganization.parentId; + if (request.kind === 'employee') { + if (sourceOrganization.id === target.id) return deny('SAME_ORGANIZATION'); + if (leaders.has(request.sourceId)) return deny('LEADER_PROTECTED'); + } else { + if (sourceOrganization.id === target.id) return deny('SELF_TARGET'); + if (sourceOrganization.parentId === null || sourceOrganization.type === 'root') return deny('ROOT_PROTECTED'); + if (sourceOrganization.parentId === target.id) return deny('SAME_ORGANIZATION'); + let ancestor: typeof target | undefined = target; + while (ancestor) { + if (ancestor.id === sourceOrganization.id) return deny('DESCENDANT_TARGET'); + ancestor = ancestor.parentId === null ? undefined : normalized.organizationsById.get(ancestor.parentId); + } + } + const sourceCompany = companies.get(sourceOrganization.id); + const targetCompany = companies.get(target.id); + if (!sourceCompany || !targetCompany) return deny('COMPANY_UNKNOWN'); + if (sourceCompany !== targetCompany && !affiliations.has(targetCompany)) return deny('TARGET_AFFILIATION_MISSING'); + return { allowed: true, previousOrganizationId: previousOrganizationId!, previousCompanyId: sourceCompany, companyId: targetCompany }; + }; +} + +/** Pure policy check for a single keyboard or pointer move. */ +export function validateOrgChartMove(data: OrgChartDataset, request: OrgChartMoveRequest): OrgChartMoveValidation { + return createOrgChartMoveValidator(data)(request); +} + +/** Create a new validated, deeply frozen browser-state dataset. Same-company + * moves change membership alone. Cross-company employee moves also change the + * existing affiliationId; group moves retarget companyId for its whole subtree + * and affiliationId for every subtree employee. No new contract fields are used. + * IDs, descendants, leaders, personal data and display ordering are retained. + * Failed moves return no replacement data and never alter the input. + */ +export function applyOrgChartMove(data: OrgChartDataset, request: OrgChartMoveRequest): OrgChartMoveResult { + const policy = validateOrgChartMove(data, request); + if (!policy.allowed) return { moved: false, code: policy.code, message: policy.message }; + const crossCompany = policy.previousCompanyId !== policy.companyId; + let candidate: OrgChartDataset; + if (request.kind === 'employee') { + candidate = { ...data, employees: data.employees.map(employee => employee.id === request.sourceId + ? { ...employee, organizationId: request.targetOrganizationId, ...(crossCompany ? { affiliationId: policy.companyId } : {}) } : employee) }; + } else { + const subtree = new Set([request.sourceId]); + if (crossCompany) { + const children = new Map(); + for (const org of data.organizations) { + if (org.parentId === null) continue; + const siblings = children.get(org.parentId) ?? []; + siblings.push(org.id); children.set(org.parentId, siblings); + } + const pending = [request.sourceId]; + while (pending.length) for (const id of children.get(pending.pop()!) ?? []) { subtree.add(id); pending.push(id); } + } + candidate = { + ...data, + organizations: data.organizations.map(org => org.id === request.sourceId + ? { ...org, parentId: request.targetOrganizationId, ...(crossCompany ? { companyId: policy.companyId } : {}) } + : crossCompany && subtree.has(org.id) ? { ...org, companyId: policy.companyId } : org), + employees: crossCompany ? data.employees.map(employee => subtree.has(employee.organizationId) + ? { ...employee, affiliationId: policy.companyId } : employee) : data.employees, + }; + } + const validation = validateOrgChartDataset(candidate); + if (!validation.valid) return { moved: false, code: 'INVALID_DATA', message: messages.INVALID_DATA }; + return { moved: true, data: validation.data, request: Object.freeze({ ...request }), previousOrganizationId: policy.previousOrganizationId }; +} diff --git a/packages/org-chart/src/data/normalize.ts b/packages/org-chart/src/data/normalize.ts new file mode 100644 index 0000000..fb07749 --- /dev/null +++ b/packages/org-chart/src/data/normalize.ts @@ -0,0 +1,27 @@ +import type { Employee, NormalizedOrgChartDataset, OrganizationTreeNode, OrgChartDataset } from './types.js'; + +/** Pure derivation for validated datasets. Multiple roots remain a forest. */ +export function normalizeOrgChartDataset(data: OrgChartDataset): NormalizedOrgChartDataset { + const organizationsById = new Map(data.organizations.map(org => [org.id, org])); + const employeesByOrganization = new Map(data.organizations.map(org => [org.id, []])); + for (const employee of data.employees) employeesByOrganization.get(employee.organizationId)?.push(employee); + const nodes = new Map(data.organizations.map(org => [org.id, { organization: org, children: [] }])); + const roots: OrganizationTreeNode[] = []; + for (const node of nodes.values()) { + if (node.organization.parentId === null) roots.push(node); + else nodes.get(node.organization.parentId)?.children.push(node); + } + const order = new Map((data.presentation?.organizationOrder ?? []).map((id, index) => [id, index])); + const compare = (a: OrganizationTreeNode, b: OrganizationTreeNode) => { + const aOrder = order.get(a.organization.id) ?? Infinity; + const bOrder = order.get(b.organization.id) ?? Infinity; + if (aOrder !== bOrder) return aOrder < bOrder ? -1 : 1; + const difference = a.organization.displayOrder - b.organization.displayOrder; + if (difference) return difference; + // Code-unit order is deterministic across environments and independent of locale. + return a.organization.id < b.organization.id ? -1 : a.organization.id > b.organization.id ? 1 : 0; + }; + roots.sort(compare); + for (const node of nodes.values()) node.children.sort(compare); + return { roots, organizationsById, employeesByOrganization }; +} diff --git a/packages/org-chart/src/data/snapshot.ts b/packages/org-chart/src/data/snapshot.ts new file mode 100644 index 0000000..e81b8fc --- /dev/null +++ b/packages/org-chart/src/data/snapshot.ts @@ -0,0 +1,14 @@ +/** Own a JSON-like input at the call boundary; never retain consumer objects. */ +export function snapshotOrgChartDataset(input: T): T { + const copy = structuredClone(input); + const pending: unknown[] = [copy]; + const seen = new WeakSet(); + while (pending.length) { + const current = pending.pop(); + if (current === null || typeof current !== 'object' || seen.has(current)) continue; + seen.add(current); + for (const child of Object.values(current)) pending.push(child); + Object.freeze(current); + } + return copy; +} diff --git a/packages/org-chart/src/data/source.ts b/packages/org-chart/src/data/source.ts new file mode 100644 index 0000000..0d9e8bf --- /dev/null +++ b/packages/org-chart/src/data/source.ts @@ -0,0 +1,61 @@ +import { snapshotOrgChartDataset } from './snapshot.js'; +import type { OrgChartDataSource } from './types.js'; + +export type OrgChartSourceErrorCode = 'SOURCE_LOAD' | 'SOURCE_PARSE' | 'SOURCE_ABORT' | 'SOURCE_URL' | 'SOURCE_SNAPSHOT'; +export class OrgChartSourceError extends Error { + readonly path = 'dataSource'; + constructor(readonly code: OrgChartSourceErrorCode, message: string) { + super(message); + this.name = 'OrgChartSourceError'; + } +} +export interface JsonOrgChartDataSourceOptions { fetch?: typeof globalThis.fetch } +/** Inline JSON is captured at construction; URL sources load a fresh snapshot on each call. */ +export class JsonOrgChartDataSource implements OrgChartDataSource { + private readonly inline: unknown; + private readonly url: string | undefined; + private readonly fetcher: typeof globalThis.fetch | undefined; + constructor(input: unknown, options: JsonOrgChartDataSourceOptions = {}) { + // Browser-native fetch may require the global Window receiver. Retaining it as + // an instance property must not accidentally call it with this source as `this`. + this.fetcher = options.fetch ?? globalThis.fetch?.bind(globalThis); + if (typeof input === 'string' || input instanceof URL) { + const url = String(input); + if (!safeSourceUrl(url)) throw new OrgChartSourceError('SOURCE_URL', 'Data source URL must use HTTP(S) or a relative JSON path.'); + this.url = url; + } else { + try { this.inline = snapshotOrgChartDataset(input); } + catch { throw new OrgChartSourceError('SOURCE_SNAPSHOT', 'Data source input must be cloneable JSON.'); } + } + } + async load(options: { signal?: AbortSignal } = {}): Promise { + const signal = options.signal; + const aborted = () => { if (signal?.aborted) throw new OrgChartSourceError('SOURCE_ABORT', 'Data source request was cancelled.'); }; + aborted(); + if (this.url === undefined) return snapshotOrgChartDataset(this.inline); + if (!this.fetcher) throw new OrgChartSourceError('SOURCE_LOAD', 'No fetch implementation is available.'); + let response: Response; + try { response = await this.fetcher(this.url, { signal }); } + catch { + aborted(); + throw new OrgChartSourceError('SOURCE_LOAD', 'Data source could not be loaded.'); + } + aborted(); + if (!response.ok) throw new OrgChartSourceError('SOURCE_LOAD', 'Data source returned an unsuccessful response.'); + let payload: unknown; + try { payload = await response.json(); } + catch { + aborted(); + throw new OrgChartSourceError('SOURCE_PARSE', 'Data source response is not valid JSON.'); + } + aborted(); + try { return snapshotOrgChartDataset(payload); } + catch { throw new OrgChartSourceError('SOURCE_SNAPSHOT', 'Data source response must be cloneable JSON.'); } + } +} +function safeSourceUrl(value: string): boolean { + if (!value.trim() || /[\s\\<>\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/.test(value) || value.startsWith('//')) return false; + if (!/^[a-z][a-z\d+.-]*:/i.test(value)) return !/%(?:0[0-9a-f]|1[0-9a-f]|7f|5c)/i.test(value); + try { const parsed = new URL(value); return ['http:', 'https:'].includes(parsed.protocol) && !parsed.username && !parsed.password; } + catch { return false; } +} diff --git a/packages/org-chart/src/data/summary.ts b/packages/org-chart/src/data/summary.ts new file mode 100644 index 0000000..5784d67 --- /dev/null +++ b/packages/org-chart/src/data/summary.ts @@ -0,0 +1,56 @@ +import { normalizeOrgChartDataset } from './normalize.js'; +import type { ApprovedAggregationPolicy, Employee, OrgChartDataset, OrgChartSummary, SummaryCounts, WorkStatus } from './types.js'; + +const statuses: WorkStatus[] = ['active', 'dispatched', 'onLeave']; +function supportedPolicy(policy: ApprovedAggregationPolicy): boolean { + const validStatuses = (values: unknown): values is WorkStatus[] => Array.isArray(values) + && values.every(value => statuses.includes(value)) && new Set(values).size === values.length; + return !!policy && typeof policy.policyVersion === 'string' && !!policy.policyVersion.trim() + && !/[\u0000-\u001f<>]/.test(policy.policyVersion) + && validStatuses(policy.includedStatuses) && validStatuses(policy.activeStatuses) + && policy.activeStatuses.every(status => policy.includedStatuses.includes(status)) + && policy.includeLeaders === true && policy.membershipMode === 'primaryOnly'; +} +function count(data: OrgChartDataset, employees: readonly Employee[]): SummaryCounts { + const counts: SummaryCounts = { + total: 0, + byOrganization: Object.create(null) as SummaryCounts['byOrganization'], + byPosition: Object.create(null) as SummaryCounts['byPosition'], + byAffiliation: Object.create(null) as SummaryCounts['byAffiliation'], + byStatus: { active: 0, dispatched: 0, onLeave: 0 }, + }; + for (const org of data.organizations) counts.byOrganization[org.id] = { direct: 0, inclusive: 0 }; + for (const affiliation of data.affiliations) counts.byAffiliation[affiliation.id] = 0; + const seen = new Set(); + for (const employee of employees) { + if (seen.has(employee.id)) continue; + seen.add(employee.id); + counts.total += 1; + counts.byOrganization[employee.organizationId]!.direct += 1; + counts.byPosition[employee.position] = (counts.byPosition[employee.position] ?? 0) + 1; + counts.byAffiliation[employee.affiliationId] = (counts.byAffiliation[employee.affiliationId] ?? 0) + 1; + counts.byStatus[employee.workStatus] += 1; + } + // Single primary membership makes child totals disjoint; compute a postorder iteratively. + const stack = [...normalizeOrgChartDataset(data).roots]; + const traversal = []; + while (stack.length) { + const node = stack.pop()!; + traversal.push(node); + stack.push(...node.children); + } + for (let index = traversal.length - 1; index >= 0; index--) { + const node = traversal[index]!; + counts.byOrganization[node.organization.id]!.inclusive = counts.byOrganization[node.organization.id]!.direct + + node.children.reduce((sum, child) => sum + counts.byOrganization[child.organization.id]!.inclusive, 0); + } + return counts; +} +/** Raw counts are literal unique employee IDs, independent of presentation or business totals. */ +export function calculateSummary(data: OrgChartDataset, policy?: ApprovedAggregationPolicy): OrgChartSummary { + const raw = count(data, data.employees); + if (!policy) return { raw, aggregation: { status: 'unavailable', reason: 'policyRequired' } }; + if (!supportedPolicy(policy)) return { raw, aggregation: { status: 'unavailable', reason: 'unsupportedPolicy' } }; + const selected = data.employees.filter(employee => policy.includedStatuses.includes(employee.workStatus)); + return { raw, aggregation: { status: 'ready', policyVersion: policy.policyVersion, counts: count(data, selected) } }; +} diff --git a/packages/org-chart/src/data/types.ts b/packages/org-chart/src/data/types.ts new file mode 100644 index 0000000..7b3c79b --- /dev/null +++ b/packages/org-chart/src/data/types.ts @@ -0,0 +1,98 @@ +export const SCHEMA_VERSION = '0.1-draft' as const; +export type ID = string; +export type OrganizationType = 'root' | 'division' | 'office' | 'div' | 'cell' | 'team'; +export type WorkStatus = 'active' | 'dispatched' | 'onLeave'; +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; + leaderId: ID | null; + displayOrder: number; +} +export interface Employee { + id: ID; + organizationId: ID; + affiliationId: ID; + name: string; + position: string; + jobTitle: string | null; + profileImage: string | null; + workStatus: WorkStatus; +} +export interface Affiliation { id: ID; name: string } +export interface OrganizationRelation { + id: ID; + sourceOrganizationId: ID; + targetOrganizationId: ID; + type: RelationType; +} +export interface PresentationConfig { + title: string; + description: string | null; + hubText: string | null; + groups: { + showEmployees: boolean; + showSummary: boolean; + showDispatched: boolean; + showOnLeave: boolean; + showAffiliations: boolean; + }; + organizationOrder: ID[]; +} +export interface Metadata { + /** Compatibility mirror; the top-level schemaVersion is authoritative. */ + schemaVersion?: typeof SCHEMA_VERSION; + asOfDate: string; + dataVersion: string; + updatedAt?: string | null; + updateNote?: string | null; +} +export interface OrgChartDataset { + schemaVersion: typeof SCHEMA_VERSION; + organizations: Organization[]; + employees: Employee[]; + affiliations: Affiliation[]; + relations?: OrganizationRelation[]; + presentation?: PresentationConfig; + metadata: Metadata; +} +export interface OrgChartDataSource { + load(options?: { signal?: AbortSignal }): Promise; +} +export interface ValidationIssue { code: string; path: string; message: string } +export type ValidationResult = + | { valid: true; data: OrgChartDataset } + | { valid: false; issues: ValidationIssue[] }; +export interface OrganizationTreeNode { + organization: Organization; + children: OrganizationTreeNode[]; +} +export interface NormalizedOrgChartDataset { + roots: OrganizationTreeNode[]; + organizationsById: ReadonlyMap; + employeesByOrganization: ReadonlyMap; +} +export interface SummaryCounts { + total: number; + byOrganization: Record; + byPosition: Record; + byAffiliation: Record; + byStatus: Record; +} +export interface ApprovedAggregationPolicy { + policyVersion: string; + includedStatuses: WorkStatus[]; + activeStatuses: WorkStatus[]; + includeLeaders: boolean; + membershipMode: 'primaryOnly' | 'uniqueEmployee'; +} +export type AggregationPolicy = ApprovedAggregationPolicy; +export type AggregationResult = + | { status: 'unavailable'; reason: 'policyRequired' | 'unsupportedPolicy' } + | { status: 'ready'; policyVersion: string; counts: SummaryCounts }; +export interface OrgChartSummary { raw: SummaryCounts; aggregation: AggregationResult } diff --git a/packages/org-chart/src/data/validate.ts b/packages/org-chart/src/data/validate.ts new file mode 100644 index 0000000..510315c --- /dev/null +++ b/packages/org-chart/src/data/validate.ts @@ -0,0 +1,146 @@ +import { SCHEMA_VERSION, type OrgChartDataset, type ValidationIssue, type ValidationResult } from './types.js'; +import { snapshotOrgChartDataset } from './snapshot.js'; + +const organizationTypes = ['root', 'division', 'office', 'div', 'cell', 'team']; +const statuses = ['active', 'dispatched', 'onLeave']; +const relationTypes = ['cooperation', 'support']; +const unsafeText = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]|<\/?[a-z][^>]*>|(?:javascript|vbscript)\s*:/i; +type RecordValue = Record; +function record(input: unknown): input is RecordValue { + return !!input && typeof input === 'object' && !Array.isArray(input) + && (Object.getPrototypeOf(input) === Object.prototype || Object.getPrototypeOf(input) === null); +} +export function isSafeImageUrl(input: string): boolean { + if (!input.trim() || unsafeText.test(input) || /\s|\\/.test(input)) return false; + if (/^[a-z][a-z\d+.-]*:/i.test(input)) { + try { const url = new URL(input); return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password; } + catch { return false; } + } + // Protocol-relative URLs inherit network origins; require an explicit HTTP(S) URL instead. + return !input.startsWith('//') && !/%(?:0[0-9a-f]|1[0-9a-f]|7f|5c)/i.test(input); +} +function validDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const parsed = new Date(value); + return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value; +} +function validTimestamp(value: string): boolean { + const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|([+-])(\d{2}):(\d{2}))$/.exec(value); + return !!match && validDate(match[1]!) && Number(match[2]) < 24 && Number(match[3]) < 60 + && Number(match[4]) < 60 && (!match[6] || Number(match[6]) < 24) && (!match[7] || Number(match[7]) < 60) + && Number.isFinite(Date.parse(value)); +} + +/** Strict contract validation. Messages contain no input values or server details. */ +export function validateOrgChartDataset(input: unknown): ValidationResult { + const issues: ValidationIssue[] = []; + const issue = (code: string, path: string, message: string) => issues.push({ code, path, message }); + const object = (value: unknown, path: string, keys: string[]): value is RecordValue => { + if (!record(value)) { issue('REQUIRED_VALUE', path, 'Expected a plain JSON object.'); return false; } + for (const key of Object.keys(value)) if (!keys.includes(key)) issue('UNKNOWN_FIELD', `${path}.${key}`, 'Field is not part of this contract.'); + return true; + }; + const text = (value: unknown, path: string, nullable = false): boolean => { + if (nullable && value === null) return true; + if (typeof value !== 'string' || !value.trim()) { issue('REQUIRED_VALUE', path, 'Expected a non-empty text value.'); return false; } + if (unsafeText.test(value)) { issue('UNSAFE_INPUT', path, 'Text contains unsupported executable markup or control characters.'); return false; } + return true; + }; + const choice = (value: unknown, path: string, allowed: string[], code: string) => { + if (typeof value !== 'string' || !allowed.includes(value)) issue(code, path, 'Value is outside the supported contract vocabulary.'); + }; + const collection = (value: unknown, path: string, keys: string[], inspect: (item: RecordValue, path: string) => void, optional = false) => { + if (optional && value === undefined) return; + if (!Array.isArray(value)) { issue('REQUIRED_VALUE', path, 'Expected an array.'); return; } + const ids = new Set(); + value.forEach((item, index) => { + const location = `${path}[${index}]`; + if (!object(item, location, keys)) return; + if (text(item.id, `${location}.id`)) { + if (ids.has(item.id as string)) issue('DUPLICATE_ID', `${location}.id`, 'ID must be unique within its collection.'); + ids.add(item.id as string); + } + inspect(item, location); + }); + }; + 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', '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.'); + }); + if (Array.isArray(input.organizations) && input.organizations.length === 0) issue('EMPTY_ORGANIZATIONS', '$.organizations', 'At least one organization is required.'); + collection(input.employees, '$.employees', ['id', 'organizationId', 'affiliationId', 'name', 'position', 'jobTitle', 'profileImage', 'workStatus'], (employee, path) => { + for (const key of ['organizationId', 'affiliationId', 'name', 'position']) text(employee[key], `${path}.${key}`); + text(employee.jobTitle, `${path}.jobTitle`, true); + if (text(employee.profileImage, `${path}.profileImage`, true) && typeof employee.profileImage === 'string' && !isSafeImageUrl(employee.profileImage)) issue('UNSAFE_INPUT', `${path}.profileImage`, 'Image URL must use HTTP(S) or a safe relative path.'); + choice(employee.workStatus, `${path}.workStatus`, statuses, 'INVALID_STATUS'); + }); + collection(input.affiliations, '$.affiliations', ['id', 'name'], (affiliation, path) => { text(affiliation.name, `${path}.name`); }); + collection(input.relations, '$.relations', ['id', 'sourceOrganizationId', 'targetOrganizationId', 'type'], (relation, path) => { + text(relation.sourceOrganizationId, `${path}.sourceOrganizationId`); text(relation.targetOrganizationId, `${path}.targetOrganizationId`); + choice(relation.type, `${path}.type`, relationTypes, 'INVALID_RELATION'); + }, true); + if (object(input.metadata, '$.metadata', ['schemaVersion', 'asOfDate', 'dataVersion', 'updatedAt', 'updateNote'])) { + const meta = input.metadata; + if (meta.schemaVersion !== undefined && meta.schemaVersion !== SCHEMA_VERSION) issue('SCHEMA_VERSION', '$.metadata.schemaVersion', 'Compatibility schema version must match the top-level version.'); + if (text(meta.asOfDate, '$.metadata.asOfDate') && !validDate(meta.asOfDate as string)) issue('REQUIRED_VALUE', '$.metadata.asOfDate', 'Expected a real calendar date in YYYY-MM-DD format.'); + text(meta.dataVersion, '$.metadata.dataVersion'); + if (meta.updatedAt !== undefined && text(meta.updatedAt, '$.metadata.updatedAt', true) && typeof meta.updatedAt === 'string' && !validTimestamp(meta.updatedAt)) issue('REQUIRED_VALUE', '$.metadata.updatedAt', 'Expected an RFC 3339 timestamp with timezone.'); + if (meta.updateNote !== undefined) text(meta.updateNote, '$.metadata.updateNote', true); + } + if (input.presentation !== undefined && object(input.presentation, '$.presentation', ['title', 'description', 'hubText', 'groups', 'organizationOrder'])) { + const presentation = input.presentation; + text(presentation.title, '$.presentation.title'); text(presentation.description, '$.presentation.description', true); text(presentation.hubText, '$.presentation.hubText', true); + const flags = ['showEmployees', 'showSummary', 'showDispatched', 'showOnLeave', 'showAffiliations']; + if (object(presentation.groups, '$.presentation.groups', flags)) for (const flag of flags) if (typeof presentation.groups[flag] !== 'boolean') issue('REQUIRED_VALUE', `$.presentation.groups.${flag}`, 'Expected a boolean.'); + if (!Array.isArray(presentation.organizationOrder)) issue('REQUIRED_VALUE', '$.presentation.organizationOrder', 'Expected an array of organization IDs.'); + else presentation.organizationOrder.forEach((id, index) => text(id, `$.presentation.organizationOrder[${index}]`)); + } + if (issues.length) return { valid: false, issues }; + const data = input as unknown as OrgChartDataset; + const organizations = new Map(data.organizations.map(org => [org.id, org])); + const employees = new Map(data.employees.map(employee => [employee.id, employee])); + const affiliations = new Set(data.affiliations.map(affiliation => affiliation.id)); + data.organizations.forEach((org, index) => { + if (org.parentId !== null && !organizations.has(org.parentId)) issue('PARENT_REFERENCE', `$.organizations[${index}].parentId`, 'Parent organization does not exist.'); + if (org.leaderId !== null) { + const leader = employees.get(org.leaderId); + if (!leader) issue('LEADER_REFERENCE', `$.organizations[${index}].leaderId`, 'Leader employee does not exist.'); + else if (leader.organizationId !== org.id) issue('LEADER_APPROVAL_REQUIRED', `$.organizations[${index}].leaderId`, 'Leader membership outside this organization requires an approved business policy.'); + } + }); + data.employees.forEach((employee, index) => { + if (!organizations.has(employee.organizationId)) issue('ORGANIZATION_REFERENCE', `$.employees[${index}].organizationId`, 'Employee organization does not exist.'); + if (!affiliations.has(employee.affiliationId)) issue('AFFILIATION_REFERENCE', `$.employees[${index}].affiliationId`, 'Employee affiliation does not exist.'); + }); + const checked = new Set(); + for (const org of data.organizations) { + if (checked.has(org.id)) continue; + const chain = new Set(); + let current: typeof org | undefined = org; + while (current && !checked.has(current.id)) { + if (chain.has(current.id)) { issue('HIERARCHY_CYCLE', '$.organizations', 'Organization parent references contain a cycle.'); break; } + chain.add(current.id); + current = current.parentId === null ? undefined : organizations.get(current.parentId); + } + for (const id of chain) checked.add(id); + } + const relationKeys = new Set(); + data.relations?.forEach((relation, index) => { + if (!organizations.has(relation.sourceOrganizationId) || !organizations.has(relation.targetOrganizationId)) issue('RELATION_REFERENCE', `$.relations[${index}]`, 'Relation endpoint organization does not exist.'); + const key = JSON.stringify([relation.sourceOrganizationId, relation.targetOrganizationId, relation.type]); + if (relation.sourceOrganizationId === relation.targetOrganizationId || relationKeys.has(key)) issue('INVALID_RELATION', `$.relations[${index}]`, 'Self-relations and duplicate directed relations are unsupported.'); + relationKeys.add(key); + }); + const orderIds = new Set(); + data.presentation?.organizationOrder.forEach((id, index) => { + if (!organizations.has(id) || orderIds.has(id)) issue('PRESENTATION_REFERENCE', `$.presentation.organizationOrder[${index}]`, 'Presentation order requires existing, unique organization IDs.'); + orderIds.add(id); + }); + if (issues.length) return { valid: false, issues }; + try { return { valid: true, data: snapshotOrgChartDataset(data) }; } + catch { return { valid: false, issues: [{ code: 'UNSAFE_INPUT', path: '$', message: 'Input must be a cloneable JSON dataset.' }] }; } +} diff --git a/packages/org-chart/src/index.ts b/packages/org-chart/src/index.ts new file mode 100644 index 0000000..4350dae --- /dev/null +++ b/packages/org-chart/src/index.ts @@ -0,0 +1,7 @@ +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 { OrgChartInteractionOptions, OrgChartPresentationMode, OrgChartInitialView, OrgChartWheelZoom, OrgChartAutoLayoutType, OrgChartAutoLayoutOptions, 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/color-presets.css b/packages/org-chart/src/styles/color-presets.css new file mode 100644 index 0000000..3f54c0a --- /dev/null +++ b/packages/org-chart/src/styles/color-presets.css @@ -0,0 +1,106 @@ +/* Public inputs remain undeclared so ancestor themes inherit through Shadow DOM. + Internal resolved values are declared at their actual presentation scope. + Values mirror the approved company/Fixed presets; no JS inline color writes. */ + +[data-group-variant="0"] { + --hm-orgchart-current-card-background: var(--hm-orgchart-business-management-card-background, var(--hm-orgchart-card-background, var(--hm-orgchart-resolved-bg-surface))); + --hm-orgchart-current-card-color: var(--hm-orgchart-business-management-card-color, var(--hm-orgchart-card-color, var(--hm-orgchart-resolved-fg-primary))); + --hm-orgchart-current-department-color: var(--hm-orgchart-business-management-department-color, var(--hm-orgchart-department-color, #ffffff)); + --hm-orgchart-current-border: var(--hm-orgchart-business-management-border, var(--hm-orgchart-border, #194d82)); + --hm-orgchart-current-gradient: var(--hm-orgchart-business-management-gradient, var(--hm-orgchart-gradient, linear-gradient(180deg, #194d82 0%, #1d395c 100%))); + --hm-orgchart-accent: var(--hm-orgchart-current-border); + --hm-orgchart-group-background: var(--hm-orgchart-current-gradient); +} + +[data-group-variant="1"] { + --hm-orgchart-current-card-background: var(--hm-orgchart-business-planning-card-background, var(--hm-orgchart-card-background, var(--hm-orgchart-resolved-bg-surface))); + --hm-orgchart-current-card-color: var(--hm-orgchart-business-planning-card-color, var(--hm-orgchart-card-color, var(--hm-orgchart-resolved-fg-primary))); + --hm-orgchart-current-department-color: var(--hm-orgchart-business-planning-department-color, var(--hm-orgchart-department-color, #ffffff)); + --hm-orgchart-current-border: var(--hm-orgchart-business-planning-border, var(--hm-orgchart-border, #2164a8)); + --hm-orgchart-current-gradient: var(--hm-orgchart-business-planning-gradient, var(--hm-orgchart-gradient, linear-gradient(180deg, #2164a8 0%, #254975 100%))); + --hm-orgchart-accent: var(--hm-orgchart-current-border); + --hm-orgchart-group-background: var(--hm-orgchart-current-gradient); +} + +[data-group-variant="2"] { + --hm-orgchart-current-card-background: var(--hm-orgchart-talent-growth-card-background, var(--hm-orgchart-card-background, var(--hm-orgchart-resolved-bg-surface))); + --hm-orgchart-current-card-color: var(--hm-orgchart-talent-growth-card-color, var(--hm-orgchart-card-color, var(--hm-orgchart-resolved-fg-primary))); + --hm-orgchart-current-department-color: var(--hm-orgchart-talent-growth-department-color, var(--hm-orgchart-department-color, #ffffff)); + --hm-orgchart-current-border: var(--hm-orgchart-talent-growth-border, var(--hm-orgchart-border, #0f6ace)); + --hm-orgchart-current-gradient: var(--hm-orgchart-talent-growth-gradient, var(--hm-orgchart-gradient, linear-gradient(180deg, #0f6ace 0%, #044a94 100%))); + --hm-orgchart-accent: var(--hm-orgchart-current-border); + --hm-orgchart-group-background: var(--hm-orgchart-current-gradient); +} + +[data-group-variant="3"] { + --hm-orgchart-current-card-background: var(--hm-orgchart-integrated-system-card-background, var(--hm-orgchart-card-background, var(--hm-orgchart-resolved-bg-surface))); + --hm-orgchart-current-card-color: var(--hm-orgchart-integrated-system-card-color, var(--hm-orgchart-card-color, var(--hm-orgchart-resolved-fg-primary))); + --hm-orgchart-current-department-color: var(--hm-orgchart-integrated-system-department-color, var(--hm-orgchart-department-color, #ffffff)); + --hm-orgchart-current-border: var(--hm-orgchart-integrated-system-border, var(--hm-orgchart-border, #8c67b6)); + --hm-orgchart-current-gradient: var(--hm-orgchart-integrated-system-gradient, var(--hm-orgchart-gradient, linear-gradient(180deg, #8c67b6 0%, #5d3494 100%))); + --hm-orgchart-accent: var(--hm-orgchart-current-border); + --hm-orgchart-group-background: var(--hm-orgchart-current-gradient); +} + +[data-group-variant="4"] { + --hm-orgchart-current-card-background: var(--hm-orgchart-design-planning-card-background, var(--hm-orgchart-card-background, var(--hm-orgchart-resolved-bg-surface))); + --hm-orgchart-current-card-color: var(--hm-orgchart-design-planning-card-color, var(--hm-orgchart-card-color, var(--hm-orgchart-resolved-fg-primary))); + --hm-orgchart-current-department-color: var(--hm-orgchart-design-planning-department-color, var(--hm-orgchart-department-color, #ffffff)); + --hm-orgchart-current-border: var(--hm-orgchart-design-planning-border, var(--hm-orgchart-border, #09a7a2)); + --hm-orgchart-current-gradient: var(--hm-orgchart-design-planning-gradient, var(--hm-orgchart-gradient, linear-gradient(180deg, #09a7a2 0%, #00847f 100%))); + --hm-orgchart-accent: var(--hm-orgchart-current-border); + --hm-orgchart-group-background: var(--hm-orgchart-current-gradient); +} + +[data-group-variant="5"] { + --hm-orgchart-current-card-background: var(--hm-orgchart-solution-sales-card-background, var(--hm-orgchart-card-background, var(--hm-orgchart-resolved-bg-surface))); + --hm-orgchart-current-card-color: var(--hm-orgchart-solution-sales-card-color, var(--hm-orgchart-card-color, var(--hm-orgchart-resolved-fg-primary))); + --hm-orgchart-current-department-color: var(--hm-orgchart-solution-sales-department-color, var(--hm-orgchart-department-color, #ffffff)); + --hm-orgchart-current-border: var(--hm-orgchart-solution-sales-border, var(--hm-orgchart-border, #5a963c)); + --hm-orgchart-current-gradient: var(--hm-orgchart-solution-sales-gradient, var(--hm-orgchart-gradient, linear-gradient(180deg, #5a963c 0%, #3f7629 100%))); + --hm-orgchart-accent: var(--hm-orgchart-current-border); + --hm-orgchart-group-background: var(--hm-orgchart-current-gradient); +} + +.hm-orgchart-expanded [data-company-id="hanmac"] { + --hm-orgchart-expanded-primary: var(--hm-orgchart-company-hanmac, #CB2E28); + --hm-orgchart-expanded-card-primary: var(--hm-orgchart-company-hanmac, #CB2E28); + --hm-orgchart-expanded-secondary-1: var(--hm-orgchart-company-hanmac-secondary-1, #1D4289); +} + +.hm-orgchart-expanded [data-company-id="saman"] { + --hm-orgchart-expanded-primary: var(--hm-orgchart-company-saman, #F08300); + --hm-orgchart-expanded-card-primary: var(--hm-orgchart-company-saman, #F08300); + --hm-orgchart-expanded-secondary-1: var(--hm-orgchart-company-saman-secondary-1, #6D6C6C); +} + +.hm-orgchart-expanded [data-company-id="jangheon-industry"] { + --hm-orgchart-expanded-primary: var(--hm-orgchart-company-jangheon-industry, #4580A6); + --hm-orgchart-expanded-card-primary: var(--hm-orgchart-company-jangheon-industry, #4580A6); + --hm-orgchart-expanded-secondary-1: var(--hm-orgchart-company-jangheon-industry-secondary-1, #92BED4); +} + +.hm-orgchart-expanded [data-company-id="jangheon"] { + --hm-orgchart-expanded-primary: var(--hm-orgchart-company-jangheon, #1D418D); + --hm-orgchart-expanded-card-primary: var(--hm-orgchart-company-jangheon, #1D418D); + --hm-orgchart-expanded-secondary-1: var(--hm-orgchart-company-jangheon-secondary-1, #AE232A); +} + +.hm-orgchart-expanded [data-company-id="ptc"] { + --hm-orgchart-expanded-primary: var(--hm-orgchart-company-ptc, #84338E); + --hm-orgchart-expanded-card-primary: var(--hm-orgchart-company-ptc, #84338E); + --hm-orgchart-expanded-secondary-1: var(--hm-orgchart-company-ptc-secondary-1, #F7AF00); + --hm-orgchart-expanded-secondary-2: var(--hm-orgchart-company-ptc-secondary-2, #0066B5); +} + +.hm-orgchart-expanded [data-company-id="halla"] { + --hm-orgchart-expanded-primary: var(--hm-orgchart-company-halla, #0068B7); + --hm-orgchart-expanded-card-primary: var(--hm-orgchart-company-halla, #0068B7); + --hm-orgchart-expanded-secondary-1: var(--hm-orgchart-company-halla-secondary-1, #231815); +} + +.hm-orgchart-expanded [data-company-id="baron"] { + --hm-orgchart-expanded-primary: var(--hm-orgchart-company-baron, #0C367F); + --hm-orgchart-expanded-card-primary: var(--hm-orgchart-company-baron, #0C367F); + +} diff --git a/packages/org-chart/src/styles/design-tokens.css b/packages/org-chart/src/styles/design-tokens.css new file mode 100644 index 0000000..ad25f52 --- /dev/null +++ b/packages/org-chart/src/styles/design-tokens.css @@ -0,0 +1,68 @@ +/* Standalone OrgChart design defaults. No HM Theme dependency. + Only the six organization header gradients were measured in Workdesk. + Neutral surfaces, card styling, typography and geometry are independent + design adaptations, not verified official Workdesk values. + Public --hm-orgchart-* inputs are intentionally NOT declared here: host + overrides must inherit through the Shadow DOM. Resolve defaults locally. */ +.hm-orgchart-shell { + --hm-orgchart-resolved-bg-surface: var(--hm-orgchart-bg-surface, #ffffff); + --hm-orgchart-resolved-bg-content: var(--hm-orgchart-bg-content, #f4f7fb); + --hm-orgchart-resolved-bg-subtle: var(--hm-orgchart-bg-subtle, #e9edf3); + --hm-orgchart-resolved-bg-brand: var(--hm-orgchart-bg-brand, linear-gradient(180deg, #45639f 0%, #809dd7 100%)); + --hm-orgchart-resolved-fg-primary: var(--hm-orgchart-fg-primary, #10243b); + --hm-orgchart-resolved-fg-secondary: var(--hm-orgchart-fg-secondary, #596575); + --hm-orgchart-resolved-stroke-default: var(--hm-orgchart-stroke-default, #bac7d7); + --hm-orgchart-resolved-stroke-input-default: var(--hm-orgchart-stroke-input-default, #d3dce8); + --hm-orgchart-resolved-stroke-input-focus: var(--hm-orgchart-stroke-input-focus, #45639f); + --hm-orgchart-resolved-accent: var(--hm-orgchart-accent-color, #eef2fa); + --hm-orgchart-resolved-on-accent: var(--hm-orgchart-on-accent, #224387); + --hm-orgchart-resolved-action-primary: var(--hm-orgchart-action-primary, #45639f); + --hm-orgchart-resolved-on-action-primary: var(--hm-orgchart-on-action-primary, #ffffff); + --hm-orgchart-resolved-font-family: var(--hm-orgchart-font-family, 'Noto Sans KR', 'Malgun Gothic', Arial, sans-serif); + --hm-orgchart-resolved-font-weight-bold: var(--hm-orgchart-font-weight-bold, 700); + --hm-orgchart-resolved-font-weight-medium: var(--hm-orgchart-font-weight-medium, 500); + --hm-orgchart-resolved-radius-container: var(--hm-orgchart-radius-container, 4px); + --hm-orgchart-resolved-radius-control-md: var(--hm-orgchart-radius-control-md, 4px); + --hm-orgchart-resolved-radius-pill: var(--hm-orgchart-radius-pill, 999px); + + --hm-orgchart-resolved-spacing-xs: var(--hm-orgchart-spacing-xs, 4px); + --hm-orgchart-resolved-spacing-sm: var(--hm-orgchart-spacing-sm, 8px); + --hm-orgchart-resolved-spacing-md: var(--hm-orgchart-spacing-md, 12px); + --hm-orgchart-resolved-spacing-lg: var(--hm-orgchart-spacing-lg, 16px); + --hm-orgchart-resolved-spacing-xl: var(--hm-orgchart-spacing-xl, 20px); + --hm-orgchart-resolved-spacing-xxl: var(--hm-orgchart-spacing-xxl, 24px); + --hm-orgchart-resolved-border-width: var(--hm-orgchart-border-width, 1px); + --hm-orgchart-resolved-border-top-width: var(--hm-orgchart-border-top-width, 3px); + --hm-orgchart-resolved-font-size-title: var(--hm-orgchart-font-size-title, 24px); + --hm-orgchart-resolved-font-size-base: var(--hm-orgchart-font-size-base, 13px); + --hm-orgchart-resolved-font-size-person: var(--hm-orgchart-font-size-person, 13px); + --hm-orgchart-resolved-font-size-meta: var(--hm-orgchart-font-size-meta, 10px); + --hm-orgchart-resolved-card-width-employee: var(--hm-orgchart-card-width-employee, 114px); + --hm-orgchart-resolved-card-width-leader: var(--hm-orgchart-card-width-leader, 154px); + --hm-orgchart-resolved-card-width-top-leader: var(--hm-orgchart-card-width-top-leader, 155px); + --hm-orgchart-resolved-card-height-employee: var(--hm-orgchart-card-height-employee, 54px); + --hm-orgchart-resolved-card-height-leader: var(--hm-orgchart-card-height-leader, 72px); + --hm-orgchart-resolved-card-height-top-leader: var(--hm-orgchart-card-height-top-leader, 78px); + + --hm-orgchart-resolved-table-background: var(--hm-orgchart-table-background, var(--hm-orgchart-resolved-bg-surface)); + --hm-orgchart-resolved-table-text: var(--hm-orgchart-table-text, var(--hm-orgchart-resolved-fg-primary)); + --hm-orgchart-resolved-table-font-size: var(--hm-orgchart-table-font-size, 12px); + --hm-orgchart-resolved-table-line-height: var(--hm-orgchart-table-line-height, 18px); + --hm-orgchart-resolved-table-cell-padding-x: var(--hm-orgchart-table-cell-padding-x, 8px); + --hm-orgchart-resolved-table-cell-padding-y: var(--hm-orgchart-table-cell-padding-y, 6px); + --hm-orgchart-resolved-table-divider-color: var(--hm-orgchart-table-divider-color, var(--hm-orgchart-resolved-stroke-input-default)); + --hm-orgchart-resolved-table-divider-width: var(--hm-orgchart-table-divider-width, var(--hm-orgchart-resolved-border-width)); + --hm-orgchart-resolved-table-header-background: var(--hm-orgchart-table-header-background, #edf2f8); + --hm-orgchart-resolved-table-header-text: var(--hm-orgchart-table-header-text, #224387); + --hm-orgchart-resolved-table-header-font-size: var(--hm-orgchart-table-header-font-size, 12px); + --hm-orgchart-resolved-table-header-font-weight: var(--hm-orgchart-table-header-font-weight, var(--hm-orgchart-resolved-font-weight-bold)); + --hm-orgchart-resolved-table-header-border-top: var(--hm-orgchart-table-header-border-top, var(--hm-orgchart-resolved-stroke-input-focus)); + --hm-orgchart-resolved-table-header-border-bottom: var(--hm-orgchart-table-header-border-bottom, var(--hm-orgchart-resolved-stroke-input-default)); + --hm-orgchart-resolved-table-row-hover-background: var(--hm-orgchart-table-row-hover-background, var(--hm-orgchart-resolved-bg-content)); + --hm-orgchart-resolved-table-footer-background: var(--hm-orgchart-table-footer-background, var(--hm-orgchart-resolved-bg-subtle)); + --hm-orgchart-resolved-table-footer-font-weight: var(--hm-orgchart-table-footer-font-weight, var(--hm-orgchart-resolved-font-weight-medium)); + --hm-orgchart-resolved-table-caption-color: var(--hm-orgchart-table-caption-color, var(--hm-orgchart-resolved-fg-secondary)); + --hm-orgchart-resolved-table-caption-font-size: var(--hm-orgchart-table-caption-font-size, 11px); + --hm-orgchart-resolved-table-caption-line-height: var(--hm-orgchart-table-caption-line-height, 16px); + --hm-orgchart-resolved-table-caption-gap: var(--hm-orgchart-table-caption-gap, 8px); +} diff --git a/packages/org-chart/src/styles/employee-card.css b/packages/org-chart/src/styles/employee-card.css new file mode 100644 index 0000000..2d5a54f --- /dev/null +++ b/packages/org-chart/src/styles/employee-card.css @@ -0,0 +1,335 @@ +/* OrgChart content cards, not overrides of HM React components. + Colors, typography and geometry use standalone OrgChart variables. + Observed card geometry is a minimum; text may increase the card height. */ +.hm-orgchart-employee[data-card-kind] { + position: relative; + display: flex; + align-items: center; + gap: 6px; + width: var(--hm-orgchart-resolved-card-width-employee); + min-width: var(--hm-orgchart-resolved-card-width-employee); + min-height: var(--hm-orgchart-resolved-card-height-employee); + margin: 0; + padding: 6px; + border: var(--hm-orgchart-resolved-border-width) solid var(--hm-orgchart-card-border, var(--hm-orgchart-current-border, var(--hm-orgchart-resolved-stroke-default))); + border-radius: var(--hm-orgchart-resolved-radius-container); + background: var(--hm-orgchart-current-card-background, var(--hm-orgchart-resolved-bg-surface)); + color: var(--hm-orgchart-current-card-color, var(--hm-orgchart-resolved-fg-primary)); + font-family: var(--hm-orgchart-resolved-font-family); + text-align: left; +} +.hm-orgchart-employee[data-card-kind]::before { + content: ""; + position: absolute; + z-index: 0; + top: 0; + right: 0; + width: 42px; + height: 10px; + border-radius: 0 var(--hm-orgchart-resolved-radius-container) 0 100%; + background: var(--hm-orgchart-resolved-bg-content); + pointer-events: none; +} +.hm-orgchart-employee[data-card-kind="leader"], +.hm-orgchart-employee[data-card-kind="topLeader"] { + width: var(--hm-orgchart-resolved-card-width-leader); + min-width: var(--hm-orgchart-resolved-card-width-leader); + min-height: var(--hm-orgchart-resolved-card-height-leader); + gap: 8px; + padding: 8px; + border-top: var(--hm-orgchart-resolved-border-top-width) solid var(--hm-orgchart-card-accent, var(--hm-orgchart-current-border, var(--hm-orgchart-resolved-action-primary))); + background: var(--hm-orgchart-current-card-background, var(--hm-orgchart-resolved-bg-content)); +} +.hm-orgchart-employee[data-card-kind="topLeader"] { + width: var(--hm-orgchart-resolved-card-width-top-leader); + min-width: var(--hm-orgchart-resolved-card-width-top-leader); + min-height: var(--hm-orgchart-resolved-card-height-top-leader); + border-color: var(--hm-orgchart-accent, var(--hm-orgchart-resolved-action-primary)); + background: var(--hm-orgchart-current-card-background, var(--hm-orgchart-resolved-bg-surface)); +} +.hm-orgchart-employee[data-card-kind] .hm-orgchart-avatar { + position: relative; + z-index: 1; + display: flex; + flex: 0 0 28px; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + overflow: hidden; + border-radius: 3.5px; + background: var(--hm-orgchart-resolved-bg-subtle); + color: var(--hm-orgchart-resolved-fg-secondary); + font-size: 13px; + font-weight: var(--hm-orgchart-resolved-font-weight-medium); +} +.hm-orgchart-employee[data-card-kind="leader"] .hm-orgchart-avatar { flex-basis: 36px; width: 36px; height: 36px; } +.hm-orgchart-employee[data-card-kind="topLeader"] .hm-orgchart-avatar { flex-basis: 40px; width: 40px; height: 40px; } +.hm-orgchart-employee[data-card-kind] .hm-orgchart-avatar img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} +.hm-orgchart-employee[data-card-kind] .hm-orgchart-avatar img[data-photo-placeholder="true"] { + object-fit: contain; + background: #f1f5f8; +} +.hm-orgchart-employee[data-card-kind] .hm-orgchart-employee-details { position: relative; z-index: 1; flex: 1 1 0; min-width: 0; } +.hm-orgchart-employee[data-card-kind] .hm-orgchart-person-name { + display: block; + font-size: 13px; + line-height: 18px; + font-weight: var(--hm-orgchart-resolved-font-weight-bold); + overflow-wrap: anywhere; + word-break: normal; +} +.hm-orgchart-employee[data-card-kind] .hm-orgchart-person-role { + display: block; + font-size: 10px; + line-height: 13px; + color: var(--hm-orgchart-resolved-fg-secondary); + overflow-wrap: anywhere; +} +.hm-orgchart-employee[data-card-kind][data-has-affiliation="true"] .hm-orgchart-person-role { padding-right: 18px; } +.hm-orgchart-employee[data-card-kind] .hm-orgchart-person-attributes { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px 4px; +} +.hm-orgchart-employee[data-card-kind] .hm-orgchart-job-title { + padding: 0 3px; + border-radius: var(--hm-orgchart-resolved-radius-control-md); + background: var(--hm-orgchart-resolved-accent); + color: var(--hm-orgchart-resolved-on-accent); + font-size: 10px; + line-height: 14px; + font-weight: var(--hm-orgchart-resolved-font-weight-medium); + overflow-wrap: anywhere; + max-width: 100%; +} +.hm-orgchart-employee[data-card-kind] .hm-orgchart-status-label { + font-size: 10px; + line-height: 14px; + color: var(--hm-orgchart-resolved-fg-secondary); + overflow-wrap: anywhere; +} +.hm-orgchart-employee[data-card-kind][data-work-status="dispatched"], +.hm-orgchart-employee[data-card-kind][data-work-status="onLeave"] { border-style: dashed; } +.hm-orgchart-employee[data-card-kind] .hm-orgchart-affiliation-mark { + position: absolute; + z-index: 1; + top: 4px; + right: 4px; + width: 16px; + min-height: 16px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--hm-orgchart-resolved-stroke-input-default); + border-radius: var(--hm-orgchart-resolved-radius-control-md); + background: var(--hm-orgchart-resolved-bg-content); + color: var(--hm-orgchart-resolved-fg-secondary); + font-size: 8px; + line-height: 12px; + overflow-wrap: anywhere; +} +.hm-orgchart-affiliation-mark img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + background: transparent; +} +/* Card image boxes are 1.5 times the P3.1 display: 14 -> 21px. + Keep the 1px transparent inset; summary logos stay unchanged. */ +.hm-orgchart-employee[data-card-kind] .hm-orgchart-affiliation-mark { + top: 0; + right: 0; + width: 23px; + height: 23px; + min-height: 23px; + background: transparent; + border: none; + box-shadow: none; + padding: 1px; +} +.hm-orgchart-employee[data-card-kind] .hm-orgchart-affiliation-mark img { + inset: 1px; + width: calc(100% - 2px); + height: calc(100% - 2px); +} +.hm-orgchart-affiliation-mark[data-logo-available="true"] .hm-orgchart-affiliation-fallback { visibility: hidden; } + +/* Approved SVG cards and compact read-only status cards share content layout. + Status cards retain their separate gray surface and do not receive an SVG. */ +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) { + box-sizing: border-box; + width: 108px; + min-width: 108px; + height: 52px; + min-height: 52px; + padding: 4px; + gap: 4px; + border: 0; + border-radius: 0; + background: transparent; +} +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="leader"], +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="representative"] { + width: 146px; + min-width: 146px; + height: 68px; + min-height: 68px; + padding: 6px; + gap: 6px; +} +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="representative"] { height: 74px; min-height: 74px; } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="leader"] { column-gap: 10px; } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])::before { content: none; } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-card-background { + position: absolute; + z-index: 0; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + pointer-events: none; +} +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-avatar { flex-basis: 24px; width: 24px; height: 24px; } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="leader"] .hm-orgchart-avatar { flex-basis: 32px; width: 32px; height: 32px; } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="representative"] .hm-orgchart-avatar { flex-basis: 36px; width: 36px; height: 36px; } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) { + --hm-orgchart-person-position-color: #797979; + --hm-orgchart-person-position-size: 15px; + --hm-orgchart-person-position-line-height: 18px; + --hm-orgchart-person-position-letter-spacing: -0.6px; + --hm-orgchart-person-name-size: 18px; + --hm-orgchart-person-name-line-height: 24px; + --hm-orgchart-person-name-letter-spacing: -0.8px; +} +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="leader"], +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="representative"] { + --hm-orgchart-person-position-size: 20px; + --hm-orgchart-person-position-line-height: 22px; + --hm-orgchart-person-position-letter-spacing: -0.8px; + --hm-orgchart-person-name-size: 24px; + --hm-orgchart-person-name-line-height: 30px; + --hm-orgchart-person-name-letter-spacing: -0.96px; +} +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="representative"] { + --hm-orgchart-person-position-color: #224387; + --hm-orgchart-person-name-letter-spacing: -0.8px; +} +.hm-orgchart-employee[data-card-asset][data-card-asset-size="leader"] { --hm-orgchart-person-position-size: 18px; } +/* Retain the status border while matching the approved small photo/text origins. */ +.hm-orgchart-employee[data-card-layout="compact"] { padding: 3px; } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-person-text { display: flex; flex-direction: column; gap: 0; } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-position-row { display: flex; align-items: center; gap: 4px; margin: 0; padding: 0; } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-position-row .hm-orgchart-person-role { flex: 0 0 auto; } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-position-row .hm-orgchart-job-title { flex: 0 0 auto; margin: 0; } +/* The source's compact tag shares the position row. Align its lower edge with + that row so long raw tags clear the unchanged upper-right affiliation logo + within the approved 146px cards; row/name gap and typography stay intact. */ +.hm-orgchart-employee[data-workdesk-presentation="true"][data-card-asset="integrated-system"][data-card-asset-size="leader"] .hm-orgchart-position-row .hm-orgchart-job-title { align-self: flex-end; } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-person-role, +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-person-name { margin: 0; padding: 0; white-space: nowrap; } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-has-affiliation="true"] .hm-orgchart-person-role { padding: 0; } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-person-role { + color: var(--hm-orgchart-card-position-color, var(--hm-orgchart-person-position-color)); + font-size: var(--hm-orgchart-person-position-size); + font-weight: 400; + line-height: var(--hm-orgchart-person-position-line-height); + letter-spacing: var(--hm-orgchart-person-position-letter-spacing); +} +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-person-name { + color: var(--hm-orgchart-card-color, #071D3A); + font-size: var(--hm-orgchart-person-name-size); + font-weight: 600; + line-height: var(--hm-orgchart-person-name-line-height); + letter-spacing: var(--hm-orgchart-person-name-letter-spacing); + overflow: hidden; + text-overflow: clip; +} +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-person-name[data-name-overflow="true"] { text-overflow: ellipsis; } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]):is([data-card-asset-size="employee"], [data-card-layout="compact"]) .hm-orgchart-person-name { color: var(--hm-orgchart-card-color, #323232); } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-person-name:focus-visible { outline: 2px solid var(--hm-orgchart-resolved-stroke-input-focus); outline-offset: 1px; } +.hm-orgchart-employee[data-card-asset-size="employee"] .hm-orgchart-affiliation-mark { top: 2px; right: 1px; width: 20px; height: 20px; min-height: 20px; } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]):is([data-card-asset-size="employee"], [data-card-layout="compact"]) .hm-orgchart-affiliation-fallback { font-size: 6px; white-space: nowrap; } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-person-attributes { position: absolute; z-index: 1; left: 3px; bottom: 3px; width: 28px; justify-content: center; } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="leader"] .hm-orgchart-person-attributes, +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="representative"] .hm-orgchart-person-attributes { left: 4px; width: 36px; } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-job-title { max-width: 100%; padding: 0 2px; font-size: 8px; line-height: 12px; letter-spacing: -0.2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="leader"] .hm-orgchart-job-title { font-size: 9px; font-weight: 700; } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]):is([data-card-asset-size="employee"], [data-card-layout="compact"]) .hm-orgchart-job-title { font-size: 8.5px; font-weight: 600; } +/* Workdesk source: index.css .rank-position/.role-rank and .bg-*. + Preserve the approved SVG geometry and position/name typography. The photo + slot uses the existing reserved width because the approved cards are smaller + than Workdesk's source cards; missing photos use the shared neutral icon. */ +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]), +.hm-orgchart-employee[data-workdesk-presentation="true"] { + --hm-orgchart-role-tag-color: #0e5492; + --hm-orgchart-role-tag-background: rgba(14, 84, 146, 0.08); + --hm-orgchart-role-tag-border: rgba(14, 84, 146, 0.2); +} +.hm-orgchart-employee[data-card-asset="business-planning"] { + --hm-orgchart-role-tag-color: #254e7e; + --hm-orgchart-role-tag-background: rgba(37, 78, 126, 0.1); + --hm-orgchart-role-tag-border: rgba(37, 78, 126, 0.26); +} +.hm-orgchart-employee[data-card-asset="talent-growth"] { + --hm-orgchart-role-tag-color: #0e67c8; + --hm-orgchart-role-tag-background: rgba(14, 103, 200, 0.1); + --hm-orgchart-role-tag-border: rgba(14, 103, 200, 0.26); +} +.hm-orgchart-employee[data-card-asset="integrated-system"] { + --hm-orgchart-role-tag-color: #6e47a0; + --hm-orgchart-role-tag-background: rgba(110, 71, 160, 0.1); + --hm-orgchart-role-tag-border: rgba(110, 71, 160, 0.26); +} +.hm-orgchart-employee[data-card-asset="design-planning"] { + --hm-orgchart-role-tag-color: #069a95; + --hm-orgchart-role-tag-background: rgba(6, 154, 149, 0.1); + --hm-orgchart-role-tag-border: rgba(6, 154, 149, 0.26); +} +.hm-orgchart-employee[data-card-asset="solution-sales"] { + --hm-orgchart-role-tag-color: #518b35; + --hm-orgchart-role-tag-background: rgba(81, 139, 53, 0.1); + --hm-orgchart-role-tag-border: rgba(81, 139, 53, 0.26); +} +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-avatar, +.hm-orgchart-employee[data-workdesk-presentation="true"] .hm-orgchart-avatar { + border-radius: 3.5px; + background: transparent; +} +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-avatar { height: 44px; } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="leader"] .hm-orgchart-avatar { height: 56px; } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"])[data-card-asset-size="representative"] .hm-orgchart-avatar { height: 62px; } +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-job-title, +.hm-orgchart-employee[data-workdesk-presentation="true"] .hm-orgchart-job-title { + display: inline-flex; + align-items: center; + min-height: 13px; + padding: 0 3px; + border: 1px solid var(--hm-orgchart-role-tag-border); + border-radius: 4px; + background: var(--hm-orgchart-role-tag-background); + color: var(--hm-orgchart-role-tag-color); + font-size: 9px; + font-weight: 700; + line-height: 1; + letter-spacing: 0; +} +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]):is([data-card-asset-size="employee"], [data-card-layout="compact"]) .hm-orgchart-job-title { + padding: 0 2px; + font-size: 8.5px; + font-weight: 600; +} +/* Status is retained for assistive reading; the specified two-line card + composition has no third status row. Supplementary status cards are intact. */ +.hm-orgchart-employee:is([data-card-asset], [data-card-layout="compact"]) .hm-orgchart-status-label { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } +.hm-orgchart-person-name-tooltip { position: fixed; z-index: 10; box-sizing: border-box; max-width: min(320px, calc(100vw - 16px)); padding: 6px 8px; border: 1px solid #224387; border-radius: 4px; background: #ffffff; color: #071D3A; font-family: var(--hm-orgchart-resolved-font-family); font-size: 14px; line-height: 20px; overflow-wrap: anywhere; pointer-events: auto; } +.hm-orgchart-person-name-tooltip[hidden] { display: none; } diff --git a/packages/org-chart/src/styles/employee-info-dialog.css b/packages/org-chart/src/styles/employee-info-dialog.css new file mode 100644 index 0000000..4835afc --- /dev/null +++ b/packages/org-chart/src/styles/employee-info-dialog.css @@ -0,0 +1,85 @@ +.hm-orgchart-organization-link { + display: block; + color: inherit; + text-decoration: none; + border-radius: inherit; +} +.hm-orgchart-organization-link:hover { text-decoration: underline; } +.hm-orgchart-organization-link:focus-visible, +.hm-orgchart-employee[role="button"]:focus-visible { + outline: 2px solid var(--hm-orgchart-card-focus-color, var(--hm-orgchart-resolved-stroke-input-focus)); + outline-offset: 3px; +} +.hm-orgchart-employee[role="button"] { cursor: pointer; } +.hm-orgchart-employee-dialog { + width: min(420px, calc(100vw - 32px)); + max-width: calc(100vw - 32px); + max-height: calc(100dvh - 32px); + padding: var(--hm-orgchart-resolved-spacing-xxl); + border: var(--hm-orgchart-resolved-border-width) solid var(--hm-orgchart-dialog-border, var(--hm-orgchart-resolved-stroke-default)); + border-radius: var(--hm-orgchart-resolved-radius-container); + background: var(--hm-orgchart-dialog-background, var(--hm-orgchart-resolved-bg-surface)); + color: var(--hm-orgchart-dialog-color, var(--hm-orgchart-resolved-fg-primary)); + font: inherit; + overflow: auto; +} +.hm-orgchart-employee-dialog::backdrop { + background: color-mix(in srgb, var(--hm-orgchart-resolved-fg-primary) 38%, transparent); +} +.hm-orgchart-employee-dialog-header { + display: flex; align-items: center; justify-content: space-between; gap: var(--hm-orgchart-resolved-spacing-lg); + /* Reserve the former text button's height so the title, body and dialog size stay put. */ + min-height: calc(1lh + 2 * var(--hm-orgchart-resolved-spacing-xs) + 2 * var(--hm-orgchart-resolved-border-width)); +} +.hm-orgchart-employee-dialog-header h2 { margin: 0; font-size: 18px; font-weight: var(--hm-orgchart-resolved-font-weight-bold); } +.hm-orgchart-employee-dialog-close { + /* Match HM Dialog's ghost / icon-sm Button without a React or theme dependency. + Fallbacks are the declared HM defaults (theme/default.css and primitives.css). */ + --hm-orgchart-dialog-close-ink: var(--hm-orgchart-dialog-close-color, var(--foreground, var(--hm-sem-fg-primary, #0a221d))); + --hm-orgchart-dialog-close-hover: var(--hm-orgchart-dialog-close-hover-background, var(--muted, var(--hm-sem-bg-subtle, #f4f1ea))); + --hm-orgchart-dialog-close-ring: var(--hm-orgchart-dialog-focus-color, var(--ring, var(--hm-sem-stroke-input-focus, #266651))); + position: absolute; + top: calc(var(--spacing, .25rem) * 2); + right: calc(var(--spacing, .25rem) * 2); + display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; + box-sizing: border-box; + width: calc(var(--spacing, .25rem) * 7); + height: calc(var(--spacing, .25rem) * 7); + padding: 0; + border: 1px solid transparent; + border-radius: min(var(--radius-md, calc(var(--radius, var(--hm-radius-container, 4px)) * .8)), 12px); + background: transparent; + background-clip: padding-box; + color: var(--hm-orgchart-dialog-close-ink); + font: inherit; font-size: .875rem; line-height: calc(var(--spacing, .25rem) * 5); font-weight: 500; + outline: none; outline-offset: 0; box-shadow: none; + cursor: default; user-select: none; white-space: nowrap; + transition: all 150ms cubic-bezier(.4, 0, .2, 1); +} +.hm-orgchart-employee-dialog-close svg { + width: calc(var(--spacing, .25rem) * 4); + height: calc(var(--spacing, .25rem) * 4); + pointer-events: none; flex-shrink: 0; +} +.hm-orgchart-employee-dialog-close:hover { + background-color: var(--hm-orgchart-dialog-close-hover); +} +:host-context(.dark) .hm-orgchart-employee-dialog-close:hover, +.dark .hm-orgchart-employee-dialog-close:hover { + background-color: color-mix(in oklab, var(--hm-orgchart-dialog-close-hover) 50%, transparent); +} +.hm-orgchart-employee-dialog-close:focus-visible { + border-color: var(--hm-orgchart-dialog-close-ring); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--hm-orgchart-dialog-close-ring) 50%, transparent); +} +.hm-orgchart-employee-dialog-close:active { + transform: translateY(1px); +} +.hm-orgchart-employee-dialog-fields { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--hm-orgchart-resolved-spacing-md) var(--hm-orgchart-resolved-spacing-lg); + margin: var(--hm-orgchart-resolved-spacing-xl) 0 0; +} +.hm-orgchart-employee-dialog-fields dt { color: var(--hm-orgchart-resolved-fg-secondary); } +.hm-orgchart-employee-dialog-fields dd { margin: 0; overflow-wrap: anywhere; } diff --git a/packages/org-chart/src/styles/org-chart-content.css b/packages/org-chart/src/styles/org-chart-content.css new file mode 100644 index 0000000..9c15757 --- /dev/null +++ b/packages/org-chart/src/styles/org-chart-content.css @@ -0,0 +1,156 @@ +/* Content composition only; no Header/Navigation or HM component overrides. */ +/* Full Composition: Scale wrapper (.hm-orgchart-composition) > Base Stage (.hm-orgchart-stage). + The stage keeps its natural width (tree + side) and is uniformly scaled by Core JS; + there is no reflow breakpoint and no inner horizontal scroll. Column/group gaps are + screen-only layout values (no data or parentId change). */ +.hm-orgchart-composition { position: relative; width: 100%; min-width: 0; overflow: hidden; background: var(--hm-orgchart-fixed-canvas-background, transparent); } +.hm-orgchart-stage { + background: var(--hm-orgchart-fixed-canvas-background, transparent); + /* Engine padding (32px) + node margins (16px) already separate the six 252px + organization columns; the tree wrapper adds no extra width. */ + --hm-orgchart-column-gap: 0px; + display: grid; + grid-template-columns: max-content 330px; + column-gap: var(--hm-orgchart-resolved-spacing-xl); + align-items: start; + width: max-content; + transform-origin: 0 0; +} +.hm-orgchart-composition-no-side .hm-orgchart-stage { grid-template-columns: max-content; } +/* Stage-level rows: intro/hub are centered on the whole stage (tree + side). */ +.hm-orgchart-main { display: contents; } +.hm-orgchart-slot-side, .hm-orgchart-slot-footer, .hm-orgchart-slot-belowTree { min-width: 0; } +.hm-orgchart-slot-side { grid-column: 2; grid-row: 1 / span 4; } +.hm-orgchart-slot-footer { grid-column: 1 / -1; grid-row: 5; margin-top: 16px; contain: inline-size; } +.hm-orgchart-slot-side, .hm-orgchart-slot-footer, .hm-orgchart-slot-belowTree { display: grid; gap: 16px; } +.hm-orgchart-slot-belowTree { grid-column: 1; grid-row: 4; margin-top: 16px; contain: inline-size; } +.hm-orgchart-intro { grid-column: 1 / -1; grid-row: 1; contain: inline-size; display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: start; gap: 16px; } +.hm-orgchart-intro .hm-orgchart-heading { grid-column: 1; } +.hm-orgchart-top-leaders { grid-column: 2; display: flex; justify-content: center; gap: 12px; } +.hm-orgchart-hub { + grid-column: 1 / -1; + grid-row: 2; + position: relative; + width: fit-content; + max-width: 100%; + margin: 28px auto 18px; + z-index: 1; + padding: 8px 20px; + border-radius: var(--hm-orgchart-resolved-radius-pill); + background: var(--hm-orgchart-resolved-bg-brand); + color: var(--hm-orgchart-resolved-on-action-primary); + font-weight: var(--hm-orgchart-resolved-font-weight-bold); + overflow-wrap: anywhere; +} +.hm-orgchart-hub::before, .hm-orgchart-hub::after { + content: ''; + position: absolute; + left: 50%; + height: 18px; + border-left: 1px dashed var(--hm-orgchart-resolved-stroke-default); + pointer-events: none; +} +.hm-orgchart-hub::before { bottom: 100%; } +.hm-orgchart-hub::after { top: 100%; } +/* Stage 4: hub-to-organization branch lines (visual only, not data relations). + Each top-level tree draws its own bar segment so the six branches read as one row. */ +.hm-orgchart-main > .hm-orgchart-viewport { grid-column: 1; grid-row: 3; overflow: visible; padding-top: 0; border-top-color: transparent; border-radius: 0 0 var(--hm-orgchart-resolved-radius-container) var(--hm-orgchart-resolved-radius-container); } +.hm-orgchart-main > .hm-orgchart-viewport .hm-orgchart-forest { align-items: flex-start; justify-content: flex-start; } +.hm-orgchart-main > .hm-orgchart-viewport .hm-orgchart-tree { position: relative; padding: 18px calc(var(--hm-orgchart-column-gap) / 2) 0; } +.hm-orgchart-main > .hm-orgchart-viewport .hm-orgchart-tree::before, +.hm-orgchart-main > .hm-orgchart-viewport .hm-orgchart-tree::after { content: ''; position: absolute; top: 0; pointer-events: none; border-top: 1px dashed var(--hm-orgchart-resolved-stroke-default); } +.hm-orgchart-main > .hm-orgchart-viewport .hm-orgchart-tree::before { left: 0; right: 0; } +.hm-orgchart-main > .hm-orgchart-viewport .hm-orgchart-tree:first-child::before { left: 50%; } +.hm-orgchart-main > .hm-orgchart-viewport .hm-orgchart-tree:last-child::before { right: 50%; } +.hm-orgchart-main > .hm-orgchart-viewport .hm-orgchart-tree:only-child::before { display: none; } +.hm-orgchart-main > .hm-orgchart-viewport .hm-orgchart-tree::after { left: 50%; height: 18px; border-top: 0; border-left: 1px dashed var(--hm-orgchart-resolved-stroke-default); } +/* Six top branches are siblings supplied by the data. Deeper cells/teams stack + inside their branch, with engine hierarchy and collapse behavior intact. */ +.hm-orgchart-engine.orgchart .node[data-depth] { width: 204px; } +.hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-organization { + width: 204px; + padding: 10px; + background: var(--hm-orgchart-current-card-background); + border-top: var(--hm-orgchart-resolved-border-top-width) solid var(--hm-orgchart-group-border, var(--hm-orgchart-current-border)); +} +.hm-orgchart-engine.orgchart .node[data-depth="0"] { width: 244px; } +.hm-orgchart-engine.orgchart .node[data-depth="0"] .hm-orgchart-organization { width: 244px; } +.hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth="1"]) > .nodes, +.hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth="2"]) > .nodes, +.hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth="3"]) > .nodes { flex-direction: column; align-items: center; } +/* Stacked siblings: the engine's horizontal sibling bar becomes a vertical + spine through the 20px engine node margin above each cell/team. */ +.hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth="1"]) > .nodes > .hierarchy::before, +.hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth="2"]) > .nodes > .hierarchy::before, +.hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth="3"]) > .nodes > .hierarchy::before { top: -20px; left: calc(50% - 1px); width: 2px; height: 20px; border-width: 0 0 0 2px; } +/* Stage 3 headers. Colors come from the per-organization preset variables; + sizes are DESIGN_ADAPTATION (Workdesk computed size/padding NOT VERIFIED). */ +.hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-organization-name { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + margin: 8px 0 0; + text-align: center; + border-radius: var(--hm-orgchart-resolved-radius-container); + color: var(--hm-orgchart-group-title-color, var(--hm-orgchart-current-department-color)); +} +.hm-orgchart-engine.orgchart .node[data-header-role="organization"] .hm-orgchart-organization-name { + min-height: var(--hm-orgchart-title-height, 40px); + padding: 6px 12px; + background: var(--hm-orgchart-group-title-background, var(--hm-orgchart-group-background)); + font-size: var(--hm-orgchart-title-font-size, 18px); + font-weight: var(--hm-orgchart-resolved-font-weight-bold); + line-height: var(--hm-orgchart-title-line-height, 26px); +} +.hm-orgchart-engine.orgchart .node[data-header-role="group"] .hm-orgchart-organization-name { + min-height: var(--hm-orgchart-group-header-height, 32px); + padding: 4px 8px; + background: var(--hm-orgchart-group-title-background, var(--hm-orgchart-current-border)); + font-size: var(--hm-orgchart-group-header-font-size, 14px); + font-weight: var(--hm-orgchart-resolved-font-weight-bold); + line-height: var(--hm-orgchart-group-header-line-height, 20px); +} +.hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-leader { margin: 10px auto; } +.hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employees { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 10px; } +.hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee { flex-shrink: 0; } +.hm-orgchart-preview-grid { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin-top: 12px; } +.hm-orgchart-card-specimen { display: flex; flex-direction: column; align-items: center; gap: 4px; max-width: 100%; } +.hm-orgchart-card-caption { margin: 0; max-width: 180px; font-size: 11px; line-height: 16px; text-align: center; } +.hm-orgchart-component-cards { min-width: 0; } +.hm-orgchart-logo-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--hm-orgchart-resolved-spacing-lg); padding: 0; list-style: none; } +.hm-orgchart-logo-list li { display: flex; flex-direction: column; align-items: center; gap: var(--hm-orgchart-resolved-spacing-sm); padding: var(--hm-orgchart-resolved-spacing-lg); border: var(--hm-orgchart-resolved-border-width) solid var(--hm-orgchart-resolved-stroke-default); border-radius: var(--hm-orgchart-resolved-radius-container); } +.hm-orgchart-logo-list .hm-orgchart-affiliation-mark { position: relative; width: 96px; height: 96px; } +.hm-orgchart-department-colors { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr)); gap: var(--hm-orgchart-resolved-spacing-lg); } +.hm-orgchart-department-colors > p { grid-column: 1 / -1; } +.hm-orgchart-representative-preview { grid-column: 1 / -1; } +.hm-orgchart-color-preview { min-width: 0; padding: var(--hm-orgchart-resolved-spacing-lg); border: var(--hm-orgchart-resolved-border-width) solid var(--hm-orgchart-current-border); border-radius: var(--hm-orgchart-resolved-radius-container); background: var(--hm-orgchart-current-card-background); overflow-wrap: anywhere; } +/* Align title, provenance, legend, cards and contrast blocks across each row + of specimens even when their descriptions wrap to different heights. */ +.hm-orgchart-color-preview { display: grid; grid-template-rows: subgrid; grid-row: span 5; row-gap: 0; } +.hm-orgchart-color-department-name { display: flex; align-items: center; justify-content: center; min-height: var(--hm-orgchart-title-height, 40px); padding: 6px 12px; font-size: var(--hm-orgchart-title-font-size, 18px); line-height: var(--hm-orgchart-title-line-height, 26px); font-weight: var(--hm-orgchart-resolved-font-weight-bold); background: var(--hm-orgchart-current-gradient); color: var(--hm-orgchart-current-department-color); border-radius: var(--hm-orgchart-resolved-radius-container); } +.hm-orgchart-color-legend { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: var(--hm-orgchart-resolved-spacing-sm); } +.hm-orgchart-color-legend dd { margin: 0; display: grid; gap: 4px; } +.hm-orgchart-color-swatch { display: block; width: 44px; height: 22px; border: 1px solid var(--hm-orgchart-resolved-stroke-default); } +.hm-orgchart-color-swatch-card-background { background-color: var(--hm-orgchart-current-card-background); } +.hm-orgchart-color-swatch-card-color { color: var(--hm-orgchart-current-card-color); } +.hm-orgchart-color-swatch-department-color { color: var(--hm-orgchart-current-department-color); } +.hm-orgchart-color-swatch-border { background-color: var(--hm-orgchart-current-border); } +.hm-orgchart-color-swatch-gradient { background-image: var(--hm-orgchart-current-gradient); } +.hm-orgchart-color-swatch[data-css-property="color"] { text-align: center; font-weight: var(--hm-orgchart-resolved-font-weight-bold); } +.hm-orgchart-contrast-normal, .hm-orgchart-contrast-candidate, .hm-orgchart-contrast-chip { + margin: var(--hm-orgchart-resolved-spacing-sm) 0; + padding: 4px 8px; + font-size: 15px; + font-weight: var(--hm-orgchart-resolved-font-weight-bold); + line-height: 22px; + border-radius: var(--hm-orgchart-resolved-radius-container); + background: var(--hm-orgchart-current-gradient); + color: var(--hm-orgchart-current-department-color); +} +.hm-orgchart-contrast-candidate { color: var(--hm-orgchart-contrast-candidate-color, #10243b); } +.hm-orgchart-contrast-chip-label { display: inline-block; background: var(--hm-orgchart-contrast-candidate-chip-background, #ffffff); color: var(--hm-orgchart-contrast-candidate-color, #10243b); padding: 0 4px; border-radius: var(--hm-orgchart-resolved-radius-control-md); } +.hm-orgchart-contrast-comparison { min-width: 0; } +.hm-orgchart-contrast-results { margin: var(--hm-orgchart-resolved-spacing-sm) 0; } +.hm-orgchart-contrast-results dd { margin-left: 0; } +.hm-orgchart-contrast-result, .hm-orgchart-contrast-stops { margin: var(--hm-orgchart-resolved-spacing-xs) 0; } diff --git a/packages/org-chart/src/styles/org-chart-expanded.css b/packages/org-chart/src/styles/org-chart-expanded.css new file mode 100644 index 0000000..0bc2dba --- /dev/null +++ b/packages/org-chart/src/styles/org-chart-expanded.css @@ -0,0 +1,322 @@ +/* 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); + /* Meaningful Auto relationships: separate from neutral card/Fixed borders. */ + --hm-orgchart-resolved-expanded-line-color: var(--hm-orgchart-expanded-line-color, #738399); + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: var(--hm-orgchart-resolved-spacing-lg); + width: 100%; + min-width: 0; + padding: var(--hm-orgchart-resolved-spacing-lg); + /* Optional host review frame inputs affect only this outer Auto container. */ + background: var(--hm-orgchart-expanded-preview-background, var(--hm-orgchart-resolved-bg-content)); + border: var(--hm-orgchart-expanded-preview-border, 0); + border-radius: var(--hm-orgchart-expanded-preview-radius, 0); +} +.hm-orgchart-expanded .hm-orgchart-heading { margin: 0; } +.hm-orgchart-expanded .hm-orgchart-viewport { + position: relative; + height: clamp(320px, 65vh, 800px); + padding: 0; + overflow: hidden; + touch-action: none; + cursor: grab; + /* Canvas input is independent of cards and the outer preview frame. + Keep existing bg-surface themes as the fallback (standalone: #ffffff). */ + background: var(--hm-orgchart-expanded-canvas-background, var(--hm-orgchart-resolved-bg-surface)); +} +.hm-orgchart-expanded .hm-orgchart-forest { + position: absolute; + top: 0; + left: 0; + width: max-content; + min-width: 0; + transform-origin: 0 0; + justify-content: flex-start; + align-items: flex-start; + gap: var(--hm-orgchart-expanded-sibling-gap); +} +.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: 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 { + display: flex; + flex-direction: column; + align-items: center; + 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; +} +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-organization-name { + align-self: stretch; + margin: 0; + height: auto; + min-height: 0; + padding: 0; + background: var(--hm-orgchart-group-title-background, var(--hm-orgchart-expanded-primary)); + color: var(--hm-orgchart-group-title-color, #ffffff); + 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-organization-link { color: inherit; } +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employees { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--hm-orgchart-expanded-employee-gap); + margin: 0; +} +/* 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-card-border, var(--hm-orgchart-resolved-stroke-default)); + background: var(--hm-orgchart-card-background, var(--hm-orgchart-resolved-bg-surface)); + color: var(--hm-orgchart-card-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-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-card-kind="leader"] { + background: var(--hm-orgchart-leader-card-background, var(--hm-orgchart-card-background, var(--hm-orgchart-resolved-bg-surface))); +} +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-card-kind="topLeader"] { + background: var(--hm-orgchart-top-leader-card-background, var(--hm-orgchart-card-background, var(--hm-orgchart-resolved-bg-surface))); +} +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-work-status="dispatched"] { + --hm-orgchart-resolved-status-card-background: var(--hm-orgchart-dispatched-card-gradient, var(--hm-orgchart-resolved-role-card-background)); + background: var(--hm-orgchart-resolved-status-card-background); + border-color: var(--hm-orgchart-dispatched-card-border, var(--hm-orgchart-card-border, var(--hm-orgchart-resolved-stroke-default))); + color: var(--hm-orgchart-dispatched-card-color, var(--hm-orgchart-card-color, var(--hm-orgchart-resolved-fg-primary))); +} +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-work-status="onLeave"] { + --hm-orgchart-resolved-status-card-background: var(--hm-orgchart-on-leave-card-gradient, var(--hm-orgchart-resolved-role-card-background)); + background: var(--hm-orgchart-resolved-status-card-background); + border-color: var(--hm-orgchart-on-leave-card-border, var(--hm-orgchart-card-border, var(--hm-orgchart-resolved-stroke-default))); + color: var(--hm-orgchart-on-leave-card-color, var(--hm-orgchart-card-color, var(--hm-orgchart-resolved-fg-primary))); +} +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee[data-card-presentation="expanded"]:hover { + background: var(--hm-orgchart-card-hover-background, var(--hm-orgchart-resolved-status-card-background, var(--hm-orgchart-resolved-role-card-background))); +} +.hm-orgchart-expanded .hm-orgchart-employee[data-card-kind="employee"] { --hm-orgchart-resolved-role-card-background: var(--hm-orgchart-card-background, var(--hm-orgchart-resolved-bg-surface)); } +.hm-orgchart-expanded .hm-orgchart-employee[data-card-kind="leader"] { --hm-orgchart-resolved-role-card-background: var(--hm-orgchart-leader-card-background, var(--hm-orgchart-card-background, var(--hm-orgchart-resolved-bg-surface))); } +.hm-orgchart-expanded .hm-orgchart-employee[data-card-kind="topLeader"] { --hm-orgchart-resolved-role-card-background: var(--hm-orgchart-top-leader-card-background, var(--hm-orgchart-card-background, var(--hm-orgchart-resolved-bg-surface))); } +.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; + border-color: var(--hm-orgchart-resolved-expanded-line-color); +} +.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); + background-color: var(--hm-orgchart-resolved-expanded-line-color); +} +.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); + background-color: var(--hm-orgchart-resolved-expanded-line-color); +} + +/* 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:is(:disabled, [aria-disabled="true"]) { 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-viewport[data-pan-enabled="false"] { cursor: default; } +.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-expanded-line-color); 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-card-accent, var(--hm-orgchart-expanded-card-primary)); + border-top-color: var(--hm-orgchart-card-accent, 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-card-accent, var(--hm-orgchart-expanded-card-primary)); + border-left-color: var(--hm-orgchart-card-accent, 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-card-accent, var(--hm-orgchart-expanded-card-primary)); + border-right-color: var(--hm-orgchart-card-accent, 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); } + +/* P7: direction/vertical adapters keep dabeng's recursive hierarchy and the + existing HM card geometry. Plain TB continues to use its original rules. */ +.hm-orgchart-expanded .hm-orgchart-tree { position: relative; flex: none; } +.hm-orgchart-expanded[data-auto-layout="lr"] .hm-orgchart-forest { flex-direction: column; } +.hm-orgchart-expanded[data-auto-layout="lr"] .hm-orgchart-engine.orgchart.l2r { + position: relative; transform: none; transform-origin: 0 0; +} +.hm-orgchart-expanded[data-auto-layout="lr"] .hm-orgchart-engine.orgchart .hierarchy { + display: flex; flex-direction: row; align-items: center; +} +.hm-orgchart-expanded[data-auto-layout="lr"] .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth]) > .nodes { + flex-direction: column; align-items: flex-start; +} +.hm-orgchart-expanded[data-auto-layout="lr"] .hm-orgchart-engine.orgchart .node[data-depth] { + flex: none; height: auto; min-height: 0; + margin: calc(var(--hm-orgchart-expanded-sibling-gap) / 2) var(--hm-orgchart-expanded-level-gap) calc(var(--hm-orgchart-expanded-sibling-gap) / 2) 0; +} +.hm-orgchart-expanded[data-auto-layout="lr"] .hm-orgchart-engine.orgchart .node[data-depth] > .hm-orgchart-organization { + margin-inline: auto; padding-inline: var(--hm-orgchart-resolved-spacing-xxl); +} +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .nodes.vertical, +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .nodes.vertical .nodes, +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth]) > .nodes.vertical { + display: flex; flex-direction: column; align-items: flex-start; + width: max-content; padding-left: var(--hm-orgchart-resolved-spacing-xxl); + gap: calc(var(--hm-orgchart-expanded-level-gap) / 2); +} +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .nodes.vertical { + margin-inline: auto; +} +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .nodes.vertical .nodes { + padding-top: calc(var(--hm-orgchart-expanded-level-gap) / 2); +} +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .nodes.vertical .hierarchy:has(> .node[data-depth]) > .nodes { + flex-direction: column; align-items: flex-start; +} +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .nodes.vertical .node[data-depth] { + margin: 0; min-height: 0; +} +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .nodes.vertical .node[data-depth] > .hm-orgchart-organization { + padding-inline: var(--hm-orgchart-resolved-spacing-xxl); +} +/* Measured Auto connector routes replace upstream fixed-card pseudo geometry + in LR/global Hybrid/TB per-group Hybrid only. */ +.hm-orgchart-expanded:is([data-auto-layout="lr"], [data-auto-layout="hybrid"], [data-group-hybrid="true"]) .hm-orgchart-engine.orgchart .hierarchy::before, +.hm-orgchart-expanded:is([data-auto-layout="lr"], [data-auto-layout="hybrid"], [data-group-hybrid="true"]) .hm-orgchart-engine.orgchart .hierarchy::after, +.hm-orgchart-expanded:is([data-auto-layout="lr"], [data-auto-layout="hybrid"], [data-group-hybrid="true"]) .hm-orgchart-engine.orgchart .node[data-depth]::before, +.hm-orgchart-expanded:is([data-auto-layout="lr"], [data-auto-layout="hybrid"], [data-group-hybrid="true"]) .hm-orgchart-engine.orgchart .node[data-depth]::after { + content: none; +} +.hm-orgchart-expanded .hm-orgchart-engine.orgchart .node > .toggleBtn { display: none; } +.hm-orgchart-expanded .hm-orgchart-organization-connectors { + position: absolute; inset: 0; overflow: visible; pointer-events: none; +} +.hm-orgchart-expanded .hm-orgchart-organization-connectors path { + fill: none; stroke: var(--hm-orgchart-resolved-expanded-line-color); stroke-width: var(--hm-orgchart-expanded-line-width); +} + +/* P9.3: outlines do not resize cards or replace company accents/connectors. */ +.hm-orgchart-expanded .hm-orgchart-viewport[data-drag-drop-enabled="true"] :is(.hm-orgchart-employee, .hm-orgchart-expanded-toggle) { cursor: grab; } +.hm-orgchart-expanded .hm-orgchart-viewport[data-drag-drop-enabled="false"] :is(.hm-orgchart-employee, .hm-orgchart-expanded-toggle) { cursor: pointer; } +.hm-orgchart-expanded .hm-orgchart-move-feedback { margin: 0; overflow-wrap: anywhere; color: var(--hm-orgchart-resolved-fg-secondary); } +.hm-orgchart-expanded .hm-orgchart-move-feedback[data-move-status="success"] { color: var(--hm-orgchart-resolved-action-primary); font-weight: var(--hm-orgchart-resolved-font-weight-bold); } +.hm-orgchart-expanded .hm-orgchart-move-feedback[data-move-status="failure"] { color: var(--hm-orgchart-resolved-fg-primary); text-decoration: underline; } +.hm-orgchart-expanded .hm-orgchart-viewport[data-edit-dragging] { cursor: grabbing; user-select: none; } +.hm-orgchart-expanded [data-edit-source="true"] { opacity: 0.55; } +.hm-orgchart-expanded .node[data-drop-state="allowed"] > .hm-orgchart-organization { outline: 2px dashed var(--hm-orgchart-resolved-stroke-input-focus); outline-offset: 2px; } +.hm-orgchart-expanded .node[data-drop-state="blocked"] > .hm-orgchart-organization { outline: 1px dotted var(--hm-orgchart-resolved-stroke-default); outline-offset: 2px; } +.hm-orgchart-expanded .node[data-drop-hover="allowed"] > .hm-orgchart-organization { outline: 3px solid var(--hm-orgchart-resolved-action-primary); } +.hm-orgchart-expanded .node[data-drop-hover="blocked"] > .hm-orgchart-organization { outline: 3px double var(--hm-orgchart-resolved-fg-secondary); } + +/* Chart Only (presentationMode="chart-only"): one white canvas that fills the + host container. Default Expanded rules above are unchanged. The host sets + the canvas size; without a definite height the 320px minimum applies. */ +:host([data-presentation-mode="chart-only"]) { height: 100%; } +.hm-orgchart-shell[data-presentation-mode="chart-only"] { height: 100%; background: var(--hm-orgchart-resolved-bg-surface); } +.hm-orgchart-shell[data-presentation-mode="chart-only"] > .hm-orgchart-content { height: 100%; } +/* Loading/error status overlays the canvas instead of shifting it. */ +.hm-orgchart-shell[data-presentation-mode="chart-only"] > .hm-orgchart-state { position: absolute; top: var(--hm-orgchart-resolved-spacing-lg); left: var(--hm-orgchart-resolved-spacing-lg); z-index: 1; margin: 0; } +.hm-orgchart-expanded[data-presentation-mode="chart-only"] { + display: block; height: 100%; padding: 0; border: 0; border-radius: 0; + background: var(--hm-orgchart-resolved-bg-surface); +} +.hm-orgchart-expanded[data-presentation-mode="chart-only"] .hm-orgchart-viewport { height: 100%; min-height: 320px; border: 0; border-radius: 0; } +.hm-orgchart-expanded[data-presentation-mode="chart-only"] .hm-orgchart-viewport:focus-visible { outline-offset: -2px; } +/* Live move status remains available to assistive technology only. */ +.hm-orgchart-expanded[data-presentation-mode="chart-only"] .hm-orgchart-move-feedback { + position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; + clip-path: inset(50%); white-space: nowrap; border: 0; +} +.hm-orgchart-expanded .hm-orgchart-viewport[data-placement-pending] .hm-orgchart-forest { visibility: hidden; } diff --git a/packages/org-chart/src/styles/org-chart-p3-summary.css b/packages/org-chart/src/styles/org-chart-p3-summary.css new file mode 100644 index 0000000..3d3298e --- /dev/null +++ b/packages/org-chart/src/styles/org-chart-p3-summary.css @@ -0,0 +1,127 @@ +/* P3 read-only summary presenters. Values are from Workdesk index.css: + headcount-panel / bold-text / sidebar- / #summary-table and the bottom + .section legend. These are standalone OrgChart content, not HM overrides. */ +:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-organization-totals { + display: flex; + flex-direction: column; + align-items: flex-end; + width: calc(var(--hm-orgchart-workdesk-summary-width) + 2px); + overflow: visible; + border: 0; + border-radius: 0; + background: none; + color: #18253a; + font-size: 14px; +} +:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-organization-totals-title { + width: 176px; + margin: 0; + padding: 4px 0; + border-radius: 7px 0 0 0; + background: linear-gradient(180deg, #004890, #00366f); + color: #fff; + font-size: 14px; + font-weight: 700; + line-height: 15.2px; + text-align: center; +} +.hm-orgchart-workdesk-summary-frame { + width: 100%; + box-shadow: 0 6.5px 12.9px rgba(51, 73, 96, 0.11); + border: 0.8px solid #c4d3e2; + border-radius: 6.33px 0 6.33px 6.33px; + background: #fff; + overflow: hidden; +} +.hm-orgchart-workdesk-position-table { + width: var(--hm-orgchart-workdesk-summary-width); + table-layout: fixed; + border-collapse: separate; + border-spacing: 0; + color: #18253a; + font-size: 14px; + line-height: 1.15; + text-align: center; +} +/* Preserve the 330px fixture table while giving its complete organization + labels and three-character first position enough room at the source's 14px. + Remaining numeric columns share the available width without changing data. */ +.hm-orgchart-workdesk-label-column { width: 112px; } +.hm-orgchart-workdesk-value-column { width: auto; } +.hm-orgchart-workdesk-value-column:nth-child(2) { width: 46px; } +.hm-orgchart-workdesk-position-table th, +.hm-orgchart-workdesk-position-table td { + height: 19px; + padding: 0; + border-top: 0.5px solid #d7e1ea; + font-weight: 400; + vertical-align: middle; + white-space: nowrap; + overflow-wrap: normal; +} +.hm-orgchart-workdesk-position-table th:first-child { padding: 0 7px; } +.hm-orgchart-workdesk-position-table :is(th, td) + :is(th, td) { border-left: 0.8px solid #d7e1ea; } +.hm-orgchart-workdesk-position-table thead { color: #223149; } +.hm-orgchart-workdesk-position-table thead th, +.hm-orgchart-workdesk-position-table tfoot :is(th, td) { background: #f2f6fa; } +.hm-orgchart-workdesk-position-table tfoot :is(th, td) { height: 20px; } +.hm-orgchart-workdesk-count-cell { font-variant-numeric: tabular-nums; } + +:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 55.4px; + min-height: 55.4px; + padding: 0 19px; + border: 0; + border-radius: 10.7px; + background: rgba(255, 255, 255, 0.82); + box-shadow: 0 5.4px 14.3px rgba(44, 68, 96, 0.08); + color: #1c2534; +} +:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar-list { + flex-wrap: nowrap; + gap: 26.8px; +} +:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar-item { + position: relative; + height: 42.9px; + min-height: 42.9px; + padding: 0; + gap: 4px; + font-size: 19.6px; + font-weight: 500; + line-height: 19.7px; +} +:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar-item + .hm-orgchart-affiliation-bar-item { border: 0; } +:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar-item + .hm-orgchart-affiliation-bar-item::before { + content: ''; + position: absolute; + top: 50%; + left: -13.9px; + width: 0.9px; + height: 21.4px; + background: rgba(113, 142, 176, 0.48); + transform: translateY(-50%); +} +:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar-item dt { gap: 0.9px; } +:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar .hm-orgchart-affiliation-mark { + width: 39.3px; + height: 39.3px; + flex-basis: 39.3px; +} +:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar .hm-orgchart-affiliation-mark img { object-fit: cover; } +:is(.hm-orgchart-composition[data-display-mode="readOnly"], .hm-orgchart-summary-preview) .hm-orgchart-affiliation-bar-total { + color: #071d3a; + font-weight: 700; +} + +/* Overview shares the bar's internal design, but lets its items wrap in the + page flow. No Fixed stage/absolute positioning is copied into the preview. */ +.hm-orgchart-shell.hm-orgchart-summary-preview .hm-orgchart-affiliation-bar { height: auto; padding-block: 6.25px; } +.hm-orgchart-shell.hm-orgchart-summary-preview .hm-orgchart-affiliation-bar-list { flex-wrap: wrap; row-gap: 8px; } +@media (max-width: 767px) { + .hm-orgchart-shell.hm-orgchart-summary-preview .hm-orgchart-affiliation-bar-item + .hm-orgchart-affiliation-bar-item::before { display: none; } +} diff --git a/packages/org-chart/src/styles/org-chart-readonly.css b/packages/org-chart/src/styles/org-chart-readonly.css new file mode 100644 index 0000000..e3dcea5 --- /dev/null +++ b/packages/org-chart/src/styles/org-chart-readonly.css @@ -0,0 +1,304 @@ +/* Read-only composition (displayMode: 'readOnly'): Workdesk-style view-only canvas. + Scoped to the composition attribute; interactive composition, standalone + components and the HM Theme are unaffected. Canvas geometry follows the + measured Workdesk layout (1920 × 914.5, axis X 935); spacing that differs + from Workdesk is a DESIGN_ADAPTATION to the confirmed card sizes. */ +.hm-orgchart-composition[data-display-mode="readOnly"] { + --hm-orgchart-group-title-font-size: 20px; + --hm-orgchart-group-title-font-weight: 700; + --hm-orgchart-group-title-line-height: 19.7px; + background: var(--hm-orgchart-fixed-canvas-background, var(--hm-orgchart-bg-content, #F5F8FB)); +} +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage { + position: relative; + isolation: isolate; + display: grid; + /* Heading | zero-width axis column at X 935 | side. */ + grid-template-columns: 935px 0 minmax(0, 1fr); + grid-template-rows: auto auto 1fr auto; + grid-template-areas: + "heading axis side" + "tree tree tree" + "status status status" + "footer footer footer"; + column-gap: 0; + width: max-content; + min-width: 1920px; + min-height: 914.5px; + padding: 12px 0; + background: var(--hm-orgchart-fixed-canvas-background, var(--hm-orgchart-bg-content, #F5F8FB)); +} +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-heading { grid-area: heading; justify-self: start; align-self: start; max-width: calc(100% - 52px - 96px); margin: 0 0 0 52px; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-title { font-size: 32px; line-height: 40px; letter-spacing: -0.5px; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-date { margin: 4px 0 0; font-size: 14px; line-height: 20px; } + +/* Top leader and hub on the canvas axis, joined by one dashed line. */ +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-axis { + grid-area: axis; + justify-self: center; + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + width: max-content; +} +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-axis::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 50%; + border-left: 1px dashed var(--hm-orgchart-resolved-stroke-default); + pointer-events: none; +} +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-top-leaders { position: relative; z-index: 1; grid-column: auto; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-hub { grid-area: auto; margin: auto 0 18px; white-space: nowrap; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-hub::before { content: none; } + +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-slot-side { grid-area: side; justify-self: end; align-self: start; margin-right: 20px; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-slot-belowTree { grid-area: status; align-self: end; margin: 12px 0 0 52px; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-slot-footer { grid-area: footer; margin: 8px 57px 0; } + +/* Hierarchy: no scroll frame; columns are positioned by measured header centers. */ +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-main > .hm-orgchart-viewport { grid-area: tree; padding: 0; border: 0; border-radius: 0; overflow: visible; } +/* Positioned columns are visually translated; keep their layout overhang out of the canvas width. */ +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-viewport:has(> .hm-orgchart-forest[data-layout="workdesk"]) { contain: inline-size; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-forest[data-layout="workdesk"] { display: grid; position: relative; width: 100%; min-width: 0; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-forest[data-layout="workdesk"] > .hm-orgchart-tree { + grid-area: 1 / 1; + justify-self: start; + width: max-content; + margin-left: var(--hm-orgchart-column-center); + translate: -50% 0; +} +/* One hub branch bar from the first to the last column center. */ +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-forest[data-layout="workdesk"]::before { + content: ''; + position: absolute; + top: 0; + left: var(--hm-orgchart-branch-start); + width: calc(var(--hm-orgchart-branch-end) - var(--hm-orgchart-branch-start)); + border-top: 1px dashed var(--hm-orgchart-resolved-stroke-default); + pointer-events: none; +} +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-forest[data-layout="workdesk"] > .hm-orgchart-tree::before { content: none; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart { min-width: 0; padding: 0; } + +/* Organization nodes: header + people only, no group card frame. */ +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-depth] { width: max-content; min-width: 108px; margin: 0 8px 16px; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-header-role="organization"] { + --hm-orgchart-title-height: 36px; +} +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-organization { + display: flex; + flex-direction: column; + align-items: center; + width: auto; + padding: 0; + border: 0; + border-radius: 0; + background: none; + box-shadow: none; +} +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-organization-name { align-self: stretch; margin: 0; } +/* Workdesk 1Depth typography; vertical padding must not enlarge the 36px header. */ +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-header-role="organization"] .hm-orgchart-organization-name { + min-width: 146px; + height: 36px; + padding: 0 12px; + font-size: 20px; + font-weight: 700; + line-height: 36px; + white-space: nowrap; +} +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-header-role="group"] { + --hm-orgchart-group-header-height: 30.4px; +} +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-header-role="group"] .hm-orgchart-organization-name { + align-self: center; + width: 114px; + min-width: 114px; + min-height: 30.4px; + padding: 0; + background: var(--hm-orgchart-group-title-background, var(--hm-orgchart-current-gradient)); + border-radius: 7.1px; + box-shadow: 0 3.6px 8px rgba(45, 73, 104, 0.2); + font-size: var(--hm-orgchart-group-title-font-size); + font-weight: var(--hm-orgchart-group-title-font-weight); + line-height: var(--hm-orgchart-group-title-line-height); + letter-spacing: normal; + text-align: center; +} +/* Workdesk .hw/.hr labels share compact typography; .sw/.ux retain 20px/700. */ +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-header-role="group"][data-organization-id^="business-planning-"] .hm-orgchart-organization-name, +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-header-role="group"][data-organization-id^="talent-growth-"] .hm-orgchart-organization-name { font-size: 18px; font-weight: 600; line-height: 16.9px; } +/* Stacked cells: the vertical spine follows the 16px node gap. */ +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-depth]) > .nodes > .hierarchy::before { top: -16px; height: 16px; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-leader { margin: 8px 0 0; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employees { flex-direction: column; flex-wrap: nowrap; align-items: center; gap: 8px; margin-top: 8px; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employees:empty { display: none; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee { margin-top: 0; } +/* Final external gap: this must follow the generic employee margin reset. + Keeping semantic leader class preserves badges while SVG size uses ancestry. */ +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-depth] .hm-orgchart-employee.hm-orgchart-leader { margin: 8px 0 0; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-header-role="group"] .hm-orgchart-employee.hm-orgchart-leader { margin-top: 8px; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node[data-header-role="group"] .hm-orgchart-employees:first-of-type { margin-top: 8px; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-employee-details { gap: 0; } + +/* Cells/teams of Div and Design: one horizontal row with the engine's sibling bar. */ +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-children-layout="horizontal"]) > .nodes { flex-direction: row; align-items: flex-start; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-children-layout="horizontal"]) > .nodes > .hierarchy::before { + top: -9px; left: 0; width: 100%; height: auto; border-width: 2px 0 0 0; +} +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-children-layout="horizontal"]) > .nodes > .hierarchy:first-child::before { left: calc(50% - 1px); width: calc(50% + 1px); } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-children-layout="horizontal"]) > .nodes > .hierarchy:last-child::before { left: 0; width: calc(50% + 1px); } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .hierarchy:has(> .node[data-children-layout="horizontal"]) > .nodes > .hierarchy:only-child::before { left: calc(50% - 1px); width: 2px; } + +/* Workdesk compact artwork. Preserve flow placement for unknown organizations; + the measured preset alone enables this original 1920×914.5 coordinate layer. */ +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] { grid-template-rows: 224.2px auto 1fr auto; height: var(--hm-orgchart-workdesk-stage-height, 914.5px); } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-axis { width: 198px; height: 224.2px; z-index: 12; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-top-leaders { position: absolute; top: 35.3px; z-index: 35; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-hub { + position: absolute; + top: 176px; + left: 50%; + translate: -50% 0; + z-index: 12; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + width: max-content; + /* The fixed 198px axis is a center anchor, not a text-width constraint. */ + max-width: none; + min-width: 198px; + height: 42px; + margin: 0; + padding: 9px 24px 9px 22px; + border-radius: 892.1px; + background: linear-gradient(-28.46deg, #809dd7, #45639f 79.33%); + gap: 8px; +} +.hm-orgchart-hub-icon { width: 20px; height: 20px; flex: 0 0 20px; stroke: #f8fcff; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; filter: drop-shadow(0 0 8px rgba(0, 0, 0, 0.38)); } +.hm-orgchart-hub-label { position: relative; font-size: 21px; font-weight: 750; line-height: 24px; letter-spacing: normal; background: linear-gradient(180deg, #dbeeff, #f8fcff 15.38%, #fff 48.08%, #f4faff 79.33%, #dbeeff); background-clip: text; -webkit-text-fill-color: transparent; filter: drop-shadow(0 0 10px rgba(0, 0, 0, 0.5)); } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-axis::before, +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-hub::after, +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-forest::before, +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-tree::after { content: none; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-viewport { position: relative; z-index: 7; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-heading, +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-slot-footer { position: relative; z-index: 7; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-slot-side { position: relative; z-index: 18; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-slot-belowTree { position: relative; z-index: 32; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-slot-belowTree { position: absolute; left: 32px; bottom: 82px; width: 240px; margin: 0; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-status-summary { gap: 12px; flex-wrap: nowrap; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-slot-footer { position: absolute; left: 57px; right: 57px; bottom: 12px; margin: 0; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-person-role, +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-person-name, +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-job-title { white-space: nowrap; } +/* Keep the logo above the unchanged position row. */ +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-employee[data-card-asset-size="leader"] .hm-orgchart-affiliation-mark { top: 0; } +.hm-orgchart-workdesk-overlay { position: absolute; inset: 0; width: 1920px; height: 914.5px; z-index: 4; pointer-events: none; } +.hm-orgchart-workdesk-overlay path[data-connector] { stroke: var(--hm-orgchart-fixed-line-color, #45639F); } +.hm-orgchart-workdesk-overlay path[data-arrow-head] { fill: var(--hm-orgchart-fixed-line-color, #45639F); } +.hm-orgchart-workdesk-decoration { position: absolute; z-index: 0; pointer-events: none; user-select: none; object-fit: contain; } +.hm-orgchart-workdesk-ring { top: 0; left: 50%; width: 1757px; height: 901px; translate: -50% 0; mix-blend-mode: multiply; } +.hm-orgchart-workdesk-sky { inset: 0; width: 100%; height: 100%; object-position: top center; opacity: 0.1; } +.hm-orgchart-workdesk-meeting { left: 68px; bottom: 32px; width: 557px; height: 344px; opacity: 0.8; mix-blend-mode: multiply; } +.hm-orgchart-workdesk-grid { left: 62px; bottom: 18.9px; width: 1768px; height: 275px; } +.hm-orgchart-workdesk-hub-frame { inset: 0; width: 100%; height: 100%; z-index: -1; object-position: top; } +/* One base-coordinate central layer; top and bottom modules keep their own + anchors. Decorations retain their original size and 914.5px coordinates. */ +.hm-orgchart-stage[data-workdesk-artwork] :is(.hm-orgchart-axis,.hm-orgchart-viewport,.hm-orgchart-workdesk-overlay,.hm-orgchart-workdesk-decoration:not(.hm-orgchart-workdesk-hub-frame)) { transform: translateY(var(--hm-orgchart-workdesk-center-offset, 0px)); } +.hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-workdesk-sky { bottom: auto; height: 914.5px; } +.hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-workdesk-meeting { top: 538.5px; bottom: auto; } +.hm-orgchart-stage[data-workdesk-artwork] .hm-orgchart-workdesk-grid { top: 620.6px; bottom: auto; } +/* P3: artwork owns representative/hub/top-level routes. Hide every engine + hierarchy route in readOnly only, including upstream pseudo-element paths. */ +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .hierarchy::before, +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .hierarchy::after, +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node::before, +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-engine.orgchart .node::after { content: none; display: none; } + +/* Organization totals (compact presenter of the shared Summary). */ +.hm-orgchart-organization-totals { + width: 332px; + overflow: hidden; + border: var(--hm-orgchart-resolved-border-width) solid var(--hm-orgchart-resolved-table-divider-color); + border-radius: var(--hm-orgchart-resolved-radius-container); + background: var(--hm-orgchart-resolved-table-background); + color: var(--hm-orgchart-resolved-table-text); +} +.hm-orgchart-organization-totals-title { + margin: 0; + padding: 3px 8px; + background: var(--hm-orgchart-resolved-table-header-text); + color: var(--hm-orgchart-resolved-on-action-primary); + font-size: 13px; + line-height: 18px; + font-weight: var(--hm-orgchart-resolved-font-weight-bold); + text-align: center; +} +.hm-orgchart-organization-totals-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; } +.hm-orgchart-organization-total { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; + padding: 3px 10px; + border-top: var(--hm-orgchart-resolved-table-divider-width) solid var(--hm-orgchart-resolved-table-divider-color); + font-size: var(--hm-orgchart-resolved-table-font-size); + line-height: var(--hm-orgchart-resolved-table-line-height); +} +.hm-orgchart-organization-total:nth-child(odd) { border-right: var(--hm-orgchart-resolved-table-divider-width) solid var(--hm-orgchart-resolved-table-divider-color); } +.hm-orgchart-organization-total:nth-child(odd):last-child { grid-column: 1 / -1; border-right: 0; } +.hm-orgchart-organization-total dt { min-width: 0; overflow-wrap: anywhere; } +.hm-orgchart-organization-total dd { flex: 0 0 auto; margin: 0; font-variant-numeric: tabular-nums; font-weight: var(--hm-orgchart-resolved-font-weight-medium); } +.hm-orgchart-organization-total-sum { background: var(--hm-orgchart-resolved-table-footer-background); font-weight: var(--hm-orgchart-resolved-font-weight-bold); } +.hm-orgchart-organization-total-sum dd { font-weight: var(--hm-orgchart-resolved-font-weight-bold); } + +/* Status groups: header pill + cards, no surrounding card. Title/card colors + come from the confirmed status styles in summary-sections.css. */ +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-status-summary { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 12px 48px; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-status-group { display: grid; justify-items: start; gap: 8px; padding: 0; border: 0; border-radius: 0; background: none; box-shadow: none; } +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-status-group > .hm-orgchart-summary-title { + display: flex; + align-items: center; + justify-content: center; + width: 108px; + min-height: 24px; + margin: 0; + border-radius: var(--hm-orgchart-resolved-radius-container); + color: var(--hm-orgchart-status-title-color, var(--hm-orgchart-resolved-on-action-primary)); + font-size: var(--hm-orgchart-group-title-font-size); + font-weight: var(--hm-orgchart-group-title-font-weight); + line-height: var(--hm-orgchart-group-title-line-height); +} +.hm-orgchart-composition[data-display-mode="readOnly"] .hm-orgchart-status-employees { gap: 12px; margin: 0; } + +/* Affiliation bar: one untitled line of logo · name · count and the total. */ +.hm-orgchart-affiliation-bar { + padding: 8px 24px; + border: var(--hm-orgchart-resolved-border-width) solid var(--hm-orgchart-resolved-stroke-input-default); + border-radius: var(--hm-orgchart-resolved-radius-container); + background: var(--hm-orgchart-resolved-bg-surface); +} +.hm-orgchart-affiliation-bar-list { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; row-gap: 4px; margin: 0; } +.hm-orgchart-affiliation-bar-item { display: flex; align-items: center; gap: 6px; padding: 0 20px; font-size: 16px; line-height: 24px; white-space: nowrap; } +.hm-orgchart-affiliation-bar-item + .hm-orgchart-affiliation-bar-item { border-left: var(--hm-orgchart-resolved-border-width) solid var(--hm-orgchart-resolved-stroke-default); } +.hm-orgchart-affiliation-bar-item dt { display: flex; align-items: center; gap: 6px; } +.hm-orgchart-affiliation-bar-item dd { margin: 0; font-variant-numeric: tabular-nums; } +.hm-orgchart-affiliation-bar-total { font-weight: var(--hm-orgchart-resolved-font-weight-bold); } +.hm-orgchart-affiliation-bar .hm-orgchart-affiliation-mark { + position: relative; + display: inline-flex; + flex: 0 0 24px; + align-items: center; + justify-content: center; + width: 24px; + height: 20px; + font-size: 9px; +} diff --git a/packages/org-chart/src/styles/org-chart.css b/packages/org-chart/src/styles/org-chart.css new file mode 100644 index 0000000..ae77864 --- /dev/null +++ b/packages/org-chart/src/styles/org-chart.css @@ -0,0 +1,57 @@ +@import './design-tokens.css'; +@import './color-presets.css'; +@import './employee-card.css'; +@import './summary-sections.css'; +@import './org-chart-content.css'; +@import './org-chart-readonly.css'; +@import './org-chart-p3-summary.css'; +@import './employee-info-dialog.css'; +@import './org-chart-expanded.css'; +/* Standalone defaults are scoped to the shell; only OrgChart public variables + inherit across the Shadow DOM boundary. No host HM Theme is required. */ +:host { display: block; min-width: 0; max-width: 100%; font: inherit; } +.hm-orgchart-shell { position: relative; min-width: 0; color: var(--hm-orgchart-resolved-fg-primary); font-family: var(--hm-orgchart-resolved-font-family); font-size: var(--hm-orgchart-resolved-font-size-base); line-height: 1.5; } +.hm-orgchart-shell *, .hm-orgchart-shell *::before, .hm-orgchart-shell *::after { box-sizing: border-box; } +.hm-orgchart-state:empty { display: none; } +.hm-orgchart-state, .hm-orgchart-issues { padding: 12px; background: var(--hm-orgchart-resolved-bg-content); border-radius: var(--hm-orgchart-resolved-radius-container); } +.hm-orgchart-heading { margin-bottom: 16px; } +.hm-orgchart-title { margin: 0; font-size: var(--hm-orgchart-resolved-font-size-title); line-height: 1.4; font-weight: var(--hm-orgchart-resolved-font-weight-bold); } +.hm-orgchart-description, .hm-orgchart-date { margin: 4px 0; color: var(--hm-orgchart-resolved-fg-secondary); overflow-wrap: anywhere; } +.hm-orgchart-viewport { width: 100%; max-width: 100%; min-width: 0; overflow: auto; padding: 8px 0; border: 1px solid var(--hm-orgchart-resolved-stroke-default); border-radius: var(--hm-orgchart-resolved-radius-container); } +.hm-orgchart-viewport:focus-visible, .hm-orgchart-toggle:focus-visible { outline: 2px solid var(--hm-orgchart-resolved-stroke-input-focus); outline-offset: 2px; } +.hm-orgchart-forest { display: flex; width: max-content; min-width: 100%; } +.hm-orgchart-tree { flex: 0 0 auto; } +.hm-orgchart-engine.orgchart { min-width: 100%; background-image: none; user-select: text; padding: 20px 16px 0; } +.hm-orgchart-engine.orgchart .node { width: 244px; margin-right: 8px; margin-left: 8px; padding: 0; border: 0; background: none; text-align: left; } +.hm-orgchart-engine.orgchart .node:hover, .hm-orgchart-engine.orgchart .node.focused { background: none; } +.hm-orgchart-engine.orgchart .node .edge { display: none; } +.hm-orgchart-engine.orgchart .hierarchy { text-align: center; } +.hm-orgchart-engine.orgchart .hierarchy::before { border-color: var(--hm-orgchart-fixed-line-color, var(--hm-orgchart-resolved-stroke-default)); } +.hm-orgchart-engine.orgchart > ul > li > ul li > .node::before { background-color: var(--hm-orgchart-fixed-line-color, var(--hm-orgchart-resolved-stroke-default)); } +.hm-orgchart-engine.orgchart .hierarchy:not(.couple) > .node:has(+ .nodes)::after { background-color: var(--hm-orgchart-fixed-line-color, var(--hm-orgchart-resolved-stroke-default)); } +.hm-orgchart-organization { width: 244px; padding: 12px; border: 1px solid var(--hm-orgchart-resolved-stroke-default); border-radius: var(--hm-orgchart-resolved-radius-container); background: var(--hm-orgchart-resolved-bg-surface); } +.hm-orgchart-kind { display: block; font-size: 11px; color: var(--hm-orgchart-resolved-fg-secondary); } +.hm-orgchart-organization-name { margin: 2px 0 0; font-size: 15px; line-height: 22px; overflow-wrap: anywhere; } +.hm-orgchart-domain-id { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: var(--hm-orgchart-resolved-fg-secondary); } +.hm-orgchart-node-count, .hm-orgchart-vacancy { margin: 8px 0; font-size: 11px; color: var(--hm-orgchart-resolved-fg-secondary); } +.hm-orgchart-employee { display: flex; gap: 8px; align-items: center; margin-top: 8px; } +.hm-orgchart-leader { padding: 8px; background: var(--hm-orgchart-resolved-bg-content); border-radius: var(--hm-orgchart-resolved-radius-container); } +.hm-orgchart-avatar { position: relative; display: flex; flex: 0 0 32px; width: 32px; height: 32px; align-items: center; justify-content: center; border-radius: 50%; overflow: hidden; background: var(--hm-orgchart-resolved-bg-subtle); color: var(--hm-orgchart-resolved-fg-secondary); } +.hm-orgchart-avatar img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.hm-orgchart-employee-details { min-width: 0; } +.hm-orgchart-person-name, .hm-orgchart-person-role, .hm-orgchart-status-label { display: block; overflow-wrap: anywhere; } +.hm-orgchart-person-role, .hm-orgchart-status-label { font-size: 11px; color: var(--hm-orgchart-resolved-fg-secondary); } +.hm-orgchart-toggle { display: block; width: 100%; margin-top: 12px; padding: 6px 8px; border: 1px solid var(--hm-orgchart-resolved-stroke-default); border-radius: var(--hm-orgchart-resolved-radius-container); background: var(--hm-orgchart-resolved-bg-surface); color: var(--hm-orgchart-resolved-fg-primary); font: inherit; cursor: pointer; } +.hm-orgchart-summary { margin-top: 16px; min-width: 0; } +.hm-orgchart-summary-title { margin: 0 0 8px; font-size: 15px; } +.hm-orgchart-raw-total, .hm-orgchart-policy { margin: 4px 0; } +.hm-orgchart-policy { color: var(--hm-orgchart-resolved-fg-secondary); } +.hm-orgchart-summary table { width: 100%; border-collapse: collapse; table-layout: fixed; margin-top: 12px; } +.hm-orgchart-summary caption { padding-bottom: 8px; text-align: left; color: var(--hm-orgchart-resolved-fg-secondary); } +.hm-orgchart-summary th, .hm-orgchart-summary td { padding: 8px; border-bottom: 1px solid var(--hm-orgchart-resolved-stroke-default); text-align: left; overflow-wrap: anywhere; } +.hm-orgchart-summary th { font-weight: 500; } +.hm-orgchart-group-title { margin: 12px 0 4px; font-size: 13px; } +.hm-orgchart-summary-group { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 16px; margin: 0; } +.hm-orgchart-summary-group dt { overflow-wrap: anywhere; } +.hm-orgchart-summary-group dd { margin: 0; } +@media (prefers-reduced-motion: reduce) { .hm-orgchart-engine.orgchart .node { transition-duration: 0.01ms; } } diff --git a/packages/org-chart/src/styles/summary-sections.css b/packages/org-chart/src/styles/summary-sections.css new file mode 100644 index 0000000..116251e --- /dev/null +++ b/packages/org-chart/src/styles/summary-sections.css @@ -0,0 +1,154 @@ +/* Independent content summaries inside Core's Shadow DOM. Colors and radii use + the standalone OrgChart defaults in design-tokens.css. + Content gaps/dimensions are local geometry, not overrides of HM components. */ +.hm-orgchart-position-summary, +.hm-orgchart-affiliation-summary, +.hm-orgchart-status-group { + min-width: 0; + padding: 16px; + border: 1px solid var(--hm-orgchart-resolved-stroke-default); + border-radius: var(--hm-orgchart-resolved-radius-container); + background: var(--hm-orgchart-resolved-bg-surface); +} +.hm-orgchart-position-summary { margin-top: 0; } +.hm-orgchart-position-summary .hm-orgchart-summary-title, +.hm-orgchart-affiliation-summary .hm-orgchart-summary-title, +.hm-orgchart-status-summary .hm-orgchart-summary-title { + margin: 0 0 8px; + font-size: 15px; + font-weight: var(--hm-orgchart-resolved-font-weight-bold); + line-height: 1.5; +} +.hm-orgchart-position-summary .hm-orgchart-raw-total, +.hm-orgchart-affiliation-summary .hm-orgchart-raw-total { + margin: 0 0 8px; + font-weight: var(--hm-orgchart-resolved-font-weight-medium); +} +.hm-orgchart-position-summary .hm-orgchart-policy, +.hm-orgchart-status-summary .hm-orgchart-policy { + margin: 4px 0; + color: var(--hm-orgchart-resolved-fg-secondary); + font-size: 11px; + overflow-wrap: anywhere; +} +.hm-orgchart-summary-scroll { max-width: 100%; overflow: auto; margin-top: 12px; } +.hm-orgchart-summary-scroll:focus-visible { + outline: 2px solid var(--hm-orgchart-resolved-stroke-input-focus); + outline-offset: 2px; +} +.hm-orgchart-summary .hm-orgchart-position-table { + width: max-content; + min-width: 100%; + margin-top: 0; + table-layout: auto; + border-collapse: collapse; + color: var(--hm-orgchart-resolved-table-text); + background: var(--hm-orgchart-resolved-table-background); + font-size: var(--hm-orgchart-resolved-table-font-size); + line-height: var(--hm-orgchart-resolved-table-line-height); +} +.hm-orgchart-summary .hm-orgchart-position-table caption { + max-width: 480px; + padding: 0 0 var(--hm-orgchart-resolved-table-caption-gap); + text-align: left; + color: var(--hm-orgchart-resolved-table-caption-color); + font-size: var(--hm-orgchart-resolved-table-caption-font-size); + line-height: var(--hm-orgchart-resolved-table-caption-line-height); +} +.hm-orgchart-summary .hm-orgchart-position-table th, +.hm-orgchart-summary .hm-orgchart-position-table td { + min-width: 64px; + max-width: 160px; + padding: var(--hm-orgchart-resolved-table-cell-padding-y) var(--hm-orgchart-resolved-table-cell-padding-x); + border-bottom: var(--hm-orgchart-resolved-table-divider-width) solid var(--hm-orgchart-resolved-table-divider-color); + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; + vertical-align: middle; +} +.hm-orgchart-summary .hm-orgchart-position-table th:first-child { min-width: 100px; } +.hm-orgchart-summary .hm-orgchart-position-table thead th { + background: var(--hm-orgchart-resolved-table-header-background); + color: var(--hm-orgchart-resolved-table-header-text); + border-top: var(--hm-orgchart-resolved-table-divider-width) solid var(--hm-orgchart-resolved-table-header-border-top); + border-bottom-color: var(--hm-orgchart-resolved-table-header-border-bottom); + font-size: var(--hm-orgchart-resolved-table-header-font-size); + font-weight: var(--hm-orgchart-resolved-table-header-font-weight); +} +.hm-orgchart-summary .hm-orgchart-position-table tbody th { font-weight: var(--hm-orgchart-resolved-font-weight-medium); } +.hm-orgchart-summary .hm-orgchart-position-table .hm-orgchart-count-cell { text-align: right; } +.hm-orgchart-summary .hm-orgchart-position-table .hm-orgchart-subtotal { font-weight: var(--hm-orgchart-resolved-font-weight-bold); } +.hm-orgchart-summary .hm-orgchart-position-table .hm-orgchart-inclusive { color: var(--hm-orgchart-resolved-fg-secondary); text-align: right; } +.hm-orgchart-summary .hm-orgchart-position-table tfoot { + background: var(--hm-orgchart-resolved-table-footer-background); + font-weight: var(--hm-orgchart-resolved-table-footer-font-weight); +} +.hm-orgchart-summary .hm-orgchart-position-table tbody tr:hover { background: var(--hm-orgchart-resolved-table-row-hover-background); } +.hm-orgchart-status-totals { font-size: 12px; } +.hm-orgchart-affiliation-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 160px), 1fr)); + gap: 8px; + margin: 12px 0 0; +} +.hm-orgchart-affiliation-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 0; + padding: 12px; + border: 1px solid var(--hm-orgchart-resolved-stroke-input-default); + border-radius: var(--hm-orgchart-resolved-radius-container); + background: var(--hm-orgchart-resolved-bg-content); +} +.hm-orgchart-affiliation-name { min-width: 0; overflow-wrap: anywhere; font-weight: var(--hm-orgchart-resolved-font-weight-medium); } +.hm-orgchart-affiliation-name { display: flex; align-items: center; gap: 8px; } +.hm-orgchart-affiliation-name .hm-orgchart-affiliation-mark { + display: inline-flex; + position: relative; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + flex: 0 0 28px; + font-size: 10px; +} +.hm-orgchart-affiliation-count { flex: 0 0 auto; margin: 0; font-variant-numeric: tabular-nums; font-weight: var(--hm-orgchart-resolved-font-weight-bold); } +.hm-orgchart-status-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); + gap: 16px; + min-width: 0; +} +.hm-orgchart-status-summary:empty { display: none; } +.hm-orgchart-status-employees { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px; margin-bottom: 12px; } +.hm-orgchart-status-employees .hm-orgchart-employee { margin-top: 0; } +.hm-orgchart-status-group > .hm-orgchart-summary-title { + background: var(--hm-orgchart-resolved-status-title-background); + color: var(--hm-orgchart-status-title-color, var(--hm-orgchart-resolved-fg-primary)); + border: 1px solid var(--hm-orgchart-status-title-border, rgba(92, 101, 112, 0.52)); + box-shadow: 0 7px 16px rgba(75, 84, 96, 0.18); +} +.hm-orgchart-status-group .hm-orgchart-employee[data-card-kind] { + margin: 0; + background-image: var(--hm-orgchart-resolved-status-card-gradient); + border: 1px solid var(--hm-orgchart-resolved-status-card-border); + border-radius: var(--br-3_5, var(--hm-orgchart-resolved-radius-container)); + cursor: pointer; +} +.hm-orgchart-status-group .hm-orgchart-employee[data-card-kind] .hm-orgchart-person-role { color: var(--hm-orgchart-status-position-color, #555f6a); } +.hm-orgchart-status-group .hm-orgchart-employee[data-card-kind] .hm-orgchart-person-name { color: var(--hm-orgchart-resolved-status-card-color); } +.hm-orgchart-status-group[data-work-status="dispatched"] { + --hm-orgchart-resolved-status-title-background: var(--hm-orgchart-dispatched-title-background, var(--hm-orgchart-status-title-background, linear-gradient(180deg, #87909c 0%, #68727e 100%))); + --hm-orgchart-resolved-status-card-gradient: var(--hm-orgchart-dispatched-card-gradient, var(--hm-orgchart-status-card-gradient, linear-gradient(180deg, #f4f5f6 0%, #dfe3e7 100%))); + --hm-orgchart-resolved-status-card-border: var(--hm-orgchart-dispatched-card-border, var(--hm-orgchart-status-card-border, rgba(120, 130, 142, 0.35))); + --hm-orgchart-resolved-status-card-color: var(--hm-orgchart-dispatched-card-color, var(--hm-orgchart-status-card-color, #3e4650)); +} +.hm-orgchart-status-group[data-work-status="onLeave"] { + --hm-orgchart-resolved-status-title-background: var(--hm-orgchart-on-leave-title-background, var(--hm-orgchart-status-title-background, linear-gradient(180deg, #87909c 0%, #68727e 100%))); + --hm-orgchart-resolved-status-card-gradient: var(--hm-orgchart-on-leave-card-gradient, var(--hm-orgchart-status-card-gradient, linear-gradient(180deg, #f4f5f6 0%, #dfe3e7 100%))); + --hm-orgchart-resolved-status-card-border: var(--hm-orgchart-on-leave-card-border, var(--hm-orgchart-status-card-border, rgba(120, 130, 142, 0.35))); + --hm-orgchart-resolved-status-card-color: var(--hm-orgchart-on-leave-card-color, var(--hm-orgchart-status-card-color, #3e4650)); +} +.hm-orgchart-status-group .hm-orgchart-affiliation-mark img { filter: grayscale(1); opacity: 0.72; } +.hm-orgchart-content-empty { margin: 8px 0; color: var(--hm-orgchart-resolved-fg-secondary); font-size: 12px; } diff --git a/packages/org-chart/test.mjs b/packages/org-chart/test.mjs new file mode 100644 index 0000000..7164d4f --- /dev/null +++ b/packages/org-chart/test.mjs @@ -0,0 +1,17 @@ +import { build } from 'vite'; +import { globSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +const root = fileURLToPath(new URL('./', import.meta.url)); +// Optional exact paths let a change run only its affected tests; default keeps +// the existing full data test command and discovery unchanged. +const selected = process.argv.slice(2).map(file => file.replaceAll('\\', '/')); +const tests = [...globSync('src/**/*.test.ts', { cwd: root })].filter(file => !selected.length || selected.includes(file.replaceAll('\\', '/'))); +if (!tests.length) throw new Error('No OrgChart data tests found'); +await build({ configFile: false, root, publicDir: false, build: { + outDir: '../../test-results/org-chart-unit', emptyOutDir: true, target: 'node22', minify: false, + lib: { entry: Object.fromEntries(tests.map((file, i) => [`test-${i}`, path.join(root, file)])), formats: ['es'], fileName: (_, entry) => `${entry}.mjs` }, + rolldownOptions: { external: [/^node:/] }, +} }); +execFileSync(process.execPath, ['--test', ...tests.map((_, i) => path.join(root, '../../test-results/org-chart-unit', `test-${i}.mjs`))], { stdio: 'inherit' }); diff --git a/packages/org-chart/tsconfig.build.json b/packages/org-chart/tsconfig.build.json new file mode 100644 index 0000000..bca2eb9 --- /dev/null +++ b/packages/org-chart/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "rootDir": "src", + "outDir": "dist" + } +} diff --git a/packages/org-chart/tsconfig.json b/packages/org-chart/tsconfig.json new file mode 100644 index 0000000..6e9226a --- /dev/null +++ b/packages/org-chart/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": false, + "noEmit": true, + "types": [] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/scripts/guard-orgchart-theme.test.mjs b/scripts/guard-orgchart-theme.test.mjs new file mode 100644 index 0000000..9232ec5 --- /dev/null +++ b/scripts/guard-orgchart-theme.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { parse } from '@babel/parser'; +import postcss from 'postcss'; +import { themeAnalysis } from './guard-theme-analysis.mjs'; + +const workspace = fileURLToPath(new URL('../', import.meta.url)); +function walk(node, visitor, parent = null, field = null) { + if (!node || typeof node !== 'object') return; + if (node.type) visitor(node, parent, field); + for (const [key, value] of Object.entries(node)) { + if (['loc', 'start', 'end', 'comments', 'tokens', 'extra'].includes(key)) continue; + if (Array.isArray(value)) value.forEach(item => walk(item, visitor, node, key)); + else if (value && typeof value === 'object') walk(value, visitor, node, key); + } +} +function analyze(edges, source) { + const issues = []; + const file = fileURLToPath(import.meta.url); + const result = themeAnalysis({ workspace, postcss, walk, selectedFile: () => true, + records: [{ file, edges, ast: parse(source, { sourceType: 'module' }) }], + add: (severity, code, file, node, message) => issues.push({ severity, code, message }), + }); + return { ...result, issues }; +} +test('OrgChart public CSS and ?url collect the same scoped defaults', () => { + const source = 'const values = "var(--hm-orgchart-current-gradient) var(--hm-orgchart-current-card-background) var(--hm-orgchart-current-border) var(--hm-orgchart-resolved-bg-surface)";'; + const direct = analyze([{ spec: '@hm-design/org-chart/style.css', runtime: true }], source); + const shadow = analyze([{ spec: '@hm-design/org-chart/style.css?url', runtime: false }], source); + assert.deepEqual(direct.definitions, shadow.definitions); + assert.deepEqual(shadow.issues, []); + assert.equal(shadow.incomplete, false); + assert.ok(shadow.references.every(ref => ref.status === 'PASS' && ref.origin === 'PUBLIC')); +}); +test('Disconnected OrgChart CSS cannot lend definitions to UI or another Story', () => { + const source = 'const value = "var(--hm-orgchart-current-gradient, red)";'; + for (const edges of [[], [{ spec: '@hm-design/ui/theme.css', runtime: true }], [{ spec: '@hm-design/org-chart/style.css?raw', runtime: false }]]) { + assert.ok(analyze(edges, source).issues.some(issue => issue.code === 'TOKEN-001' && issue.severity === 'FAIL')); + } +}); +test('Fallbacks and JS setProperty do not waive undeclared HM tokens', () => { + const result = analyze([{ spec: '@hm-design/org-chart/style.css?url', runtime: false }], 'el.style.setProperty("--hm-fake", "red"); const value = "var(--hm-fake, red)";'); + assert.ok(result.issues.some(issue => issue.code === 'TOKEN-001' && issue.severity === 'FAIL')); +}); +test('Shared UI declaration collection and unknown token policy remain intact', () => { + const result = analyze([{ spec: '@hm-design/ui/theme.css', runtime: true }], 'const value = "var(--hm-sem-fg-primary) var(--hm-missing, blue)";'); + assert.equal(result.references.find(ref => ref.name === '--hm-sem-fg-primary').status, 'PASS'); + assert.equal(result.references.find(ref => ref.name === '--hm-missing').status, 'FAIL'); +}); +test('All seven CSS company defaults and six Fixed gradients match approved presets', () => { + const css = fs.readFileSync(path.join(workspace, 'packages/org-chart/src/styles/color-presets.css'), 'utf8'); + const presets = fs.readFileSync(path.join(workspace, 'packages/org-chart/src/company-color-presets.ts'), 'utf8'); + for (const [id, color] of [['hanmac', '#CB2E28'], ['saman', '#F08300'], ['jangheon-industry', '#4580A6'], ['jangheon', '#1D418D'], ['ptc', '#84338E'], ['halla', '#0068B7'], ['baron', '#0C367F']]) { + assert.ok(presets.includes(`primary: '${color}'`)); + assert.ok(css.includes(`var(--hm-orgchart-company-${id}, ${color})`)); + } + const fixed = fs.readFileSync(path.join(workspace, 'packages/org-chart/src/core/style-presets.ts'), 'utf8'); + for (const match of fixed.matchAll(/backgroundImage: '([^']+)'/g)) assert.ok(css.includes(match[1])); +}); diff --git a/scripts/guard-theme-analysis.mjs b/scripts/guard-theme-analysis.mjs index df9ebbe..24a472b 100644 --- a/scripts/guard-theme-analysis.mjs +++ b/scripts/guard-theme-analysis.mjs @@ -81,10 +81,17 @@ export function themeAnalysis({ records, workspace, postcss, walk, selectedFile, }; for (const r of records) { if (r.css) css(r.file, true); - for (const e of r.edges.filter(e => e.runtime)) { + for (const e of r.edges) { + // OrgChart deliberately loads its public CSS inside Shadow DOM via ?url. + // Inventory that export's declarations, without treating arbitrary raw/url + // imports as applied styles or collecting tokens from unrelated packages. + const orgChartCSS = e.spec === '@hm-design/org-chart/style.css' || e.spec === '@hm-design/org-chart/style.css?url'; + if (!e.runtime && !orgChartCSS) continue; const publicCSS = { '@hm-design/ui/theme.css': 'packages/ui/src/theme.css', '@hm-design/ui/dashboard-status.css': 'packages/ui/src/patterns/dashboard-status/dashboard-status.css', + '@hm-design/org-chart/style.css': 'packages/org-chart/src/styles/org-chart.css', + '@hm-design/org-chart/style.css?url': 'packages/org-chart/src/styles/org-chart.css', }[e.spec]; if (publicCSS) css(path.join(workspace, publicCSS)); } diff --git a/scripts/orgchart-browser-runtime.mjs b/scripts/orgchart-browser-runtime.mjs new file mode 100644 index 0000000..32a6290 --- /dev/null +++ b/scripts/orgchart-browser-runtime.mjs @@ -0,0 +1,37 @@ +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +let playwrightPromise; + +// Resolve project dependencies normally; an external installation is optional. +export async function loadPlaywright() { + playwrightPromise ??= (async () => { + const modulePath = process.env.ORGCHART_PLAYWRIGHT_MODULE?.trim(); + try { + const runtime = await import(modulePath ? pathToFileURL(path.resolve(modulePath)).href : 'playwright'); + if (typeof runtime.chromium?.launch !== 'function') throw new Error('Module does not export chromium.launch'); + return runtime; + } catch (cause) { + throw new Error( + `OrgChart browser verification cannot load Playwright (${modulePath || 'project module: playwright'}). ` + + 'Use an existing project Playwright installation, or set ORGCHART_PLAYWRIGHT_MODULE to an existing Playwright entry file.', + { cause }, + ); + } + })(); + return playwrightPromise; +} + +export async function launchChromium(options = {}) { + const { chromium } = await loadPlaywright(); + const executablePath = process.env.ORGCHART_CHROME?.trim(); + try { + return await chromium.launch({ ...options, ...(executablePath ? { executablePath: path.resolve(executablePath) } : {}) }); + } catch (cause) { + throw new Error( + `OrgChart browser verification cannot launch Chromium (${executablePath || 'Playwright-managed browser'}). ` + + 'Ensure this Playwright installation has its Chromium browser, or set ORGCHART_CHROME to an existing browser executable.', + { cause }, + ); + } +} diff --git a/scripts/verify-orgchart-hm-story.mjs b/scripts/verify-orgchart-hm-story.mjs new file mode 100644 index 0000000..9e028f3 --- /dev/null +++ b/scripts/verify-orgchart-hm-story.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import path from 'node:path'; +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +// Target only the affected existing Story and the unchanged Fixed composition. +const evidence = fileURLToPath(new URL('../test-results/org-chart-minimal-install/', import.meta.url)); +const root = path.join(evidence, 'storybook'); +const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' }; +const server = http.createServer(async (request, response) => { + const filename = path.resolve(root, '.' + decodeURIComponent(new URL(request.url, 'http://localhost').pathname)); + if (!filename.startsWith(root + path.sep)) { response.writeHead(403).end(); return; } + try { const bytes = await readFile(filename); response.writeHead(200, { 'Content-Type': mime[path.extname(filename)] || 'application/octet-stream' }).end(bytes); } + catch { response.writeHead(404).end(); } +}); +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +assert.ok(process.env.ORGCHART_PLAYWRIGHT_MODULE, 'Set ORGCHART_PLAYWRIGHT_MODULE'); +const { chromium } = await import(pathToFileURL(process.env.ORGCHART_PLAYWRIGHT_MODULE).href); +const browser = await chromium.launch({ headless: true, executablePath: process.env.ORGCHART_CHROME || 'C:/Program Files/Google/Chrome/Application/chrome.exe' }); +const errors = [], results = []; +try { + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); + for (const id of ['orgchart--expanded-org-chart', 'orgchart--full-composition']) { + await page.goto(`http://127.0.0.1:${server.address().port}/iframe.html?id=${id}&viewMode=story`); + const card = page.locator('.hm-orgchart-employee').first(); + await card.waitFor(); await card.focus(); await page.keyboard.press('Enter'); + const hm = page.locator('[data-component="HMOrgChartEmployeeDialog"]'); + const core = page.locator('dialog.hm-orgchart-employee-dialog'); + if (id.includes('expanded')) { + await hm.waitFor({ state: 'visible' }); + assert.equal(await core.evaluate(e => e.open), false); + await page.screenshot({ path: path.join(evidence, 'storybook-expanded-hm.png') }); + await page.keyboard.press('Escape'); await hm.waitFor({ state: 'hidden' }); + assert.equal(await card.evaluate(e => e.getRootNode().activeElement === e), true); + const trigger = page.getByRole('combobox', { name: '직원 정보 팝업' }); + await trigger.click(); await page.getByRole('option', { name: 'Core 기본 팝업', exact: true }).click(); + // Wait for Radix's exiting Select portal/focus restoration before opening + // another modal with the keyboard. External consumers have no Select here. + await page.getByRole('listbox').waitFor({ state: 'detached' }); + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + await card.focus(); await page.keyboard.press('Enter'); + await core.waitFor({ state: 'visible' }); + assert.equal(await hm.count(), 0); + assert.equal(await core.evaluate(e => e.contains(e.getRootNode().activeElement)), true, 'Core modal receives focus after Select closes'); + await page.keyboard.press('Escape'); await core.waitFor({ state: 'hidden' }); + results.push({ story: id, status: 'PASS', checks: ['Existing default HM Dialog', 'Escape and focus restoration', 'Existing selector switches to Core popup'] }); + } else { + await core.waitFor({ state: 'visible' }); assert.equal(await hm.count(), 0); + await page.screenshot({ path: path.join(evidence, 'storybook-fixed-core.png') }); + await page.keyboard.press('Escape'); await core.waitFor({ state: 'hidden' }); + results.push({ story: id, status: 'PASS', checks: ['Existing Fixed render and Core popup', 'Escape'] }); + } + } + assert.deepEqual(errors, []); + await writeFile(path.join(evidence, 'storybook-browser.json'), JSON.stringify({ status: 'PASS', results, errors }, null, 2)); + console.log(JSON.stringify({ status: 'PASS', results, errors })); +} finally { + await browser.close(); await new Promise(resolve => server.close(resolve)); +} diff --git a/scripts/verify-orgchart-minimal-install.mjs b/scripts/verify-orgchart-minimal-install.mjs new file mode 100644 index 0000000..8702990 --- /dev/null +++ b/scripts/verify-orgchart-minimal-install.mjs @@ -0,0 +1,225 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { mkdir, mkdtemp, readFile, writeFile, copyFile, access } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +// External consumers are outside the checkout and its node_modules ancestors. +// Preserve the temp projects, lockfiles and logs for inspection; never clean them. +const root = fileURLToPath(new URL('../', import.meta.url)); +const evidence = path.join(root, 'test-results/org-chart-minimal-install'); +await mkdir(evidence, { recursive: true }); +const npmCli = process.env.ORGCHART_NPM_CLI || path.join(path.dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'); +const externalRoot = process.env.ORGCHART_CONSUMER_ROOT || await mkdtemp(path.join(tmpdir(), 'hm-orgchart-minimal-')); +const projects = Object.fromEntries(['A', 'B', 'C'].map(name => [name, path.join(externalRoot, name)])); +await writeFile(path.join(evidence, 'projects.json'), JSON.stringify({ externalRoot, projects }, null, 2)); +console.log(`External projects: ${externalRoot}`); + +function run(args, cwd, log) { + const result = spawnSync(process.execPath, args, { cwd, encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 }); + return { status: result.status, output: `${result.stdout ?? ''}${result.stderr ?? ''}`, error: result.error, log }; +} +async function npm(args, cwd, log) { + const result = run([npmCli, ...args], cwd, log); + await writeFile(path.join(evidence, `${log}.log`), result.output); + assert.equal(result.status, 0, `${log} failed: ${result.output.slice(-5000)}`); + console.log(`${log}: PASS`); + return result; +} + +if (process.env.ORGCHART_SKIP_PACK !== '1') for (const name of ['org-chart', 'ui', 'org-chart-react']) { + await npm(['pack', '--workspace', `@hm-design/${name}`, '--pack-destination', evidence], root, `pack-${name}`); +} +const tarball = name => `file:${path.join(evidence, `hm-design-${name}-0.1.0.tgz`).replaceAll('\\', '/')}`; +const versions = { react: '19.3.0', 'react-dom': '19.3.0' }; +for (const [name, directory] of Object.entries(projects)) { + await mkdir(directory, { recursive: true }); + const manifest = { + name: `hm-orgchart-minimal-${name.toLowerCase()}`, private: true, type: 'module', + scripts: { typecheck: 'tsc --noEmit', build: 'npm run typecheck && vite build' }, + dependencies: { '@hm-design/org-chart': tarball('org-chart'), ...(name === 'A' ? {} : { ...versions, '@hm-design/org-chart-react': tarball('org-chart-react') }), ...(name === 'C' ? { '@hm-design/ui': tarball('ui'), tailwindcss: '4.3.3' } : {}) }, + devDependencies: { typescript: '7.0.2', vite: '8.3.0', ...(name === 'A' ? {} : { '@types/react': '19.3.0', '@types/react-dom': '19.3.0' }), ...(name === 'C' ? { '@tailwindcss/vite': '4.3.3' } : {}) }, + }; + await writeFile(path.join(directory, 'package.json'), JSON.stringify(manifest, null, 2)); + await writeFile(path.join(directory, 'tsconfig.json'), JSON.stringify({ compilerOptions: { target: 'ES2022', lib: ['ES2022', 'DOM', 'DOM.Iterable'], module: 'ESNext', moduleResolution: 'Bundler', jsx: 'react-jsx', strict: true, skipLibCheck: false, noEmit: true, types: ['vite/client'], resolveJsonModule: true }, include: ['main.ts', 'main.tsx'] }, null, 2)); + await copyFile(path.join(root, 'examples/org-chart/data-expanded-small.json'), path.join(directory, 'data.json')); + await writeFile(path.join(directory, 'index.html'), 'OrgChart package verification
'); + const shared = `import { createOrgChart, JsonOrgChartDataSource } from '@hm-design/org-chart'; +import engine from '@hm-design/org-chart/engine.css?url'; +import core from '@hm-design/org-chart/style.css?url'; +import data from './data.json'; +const source = new JsonOrgChartDataSource(data); +const stylesheetUrls = { engine, core }; +const params = new URLSearchParams(location.search); +const layoutMode = params.get('layout') === 'fixed' ? 'fixed' : 'auto'; +const events: unknown[] = [], errors: string[] = []; +const onStateChange = (state: import('@hm-design/org-chart').OrgChartState) => { events.push(state); document.documentElement.dataset.chartState = state.status; }; +const onLifecycleError = (error: unknown) => errors.push(String(error)); +Object.assign(window, { verification: { events, errors, dataBefore: JSON.stringify(data), data } }); +document.body.style.margin = '0'; +`; + if (name === 'A') { + await writeFile(path.join(directory, 'main.ts'), shared + `const handle = createOrgChart({dataSource: source, stylesheetUrls, layoutMode, displayMode: 'readOnly', onStateChange}); +Object.assign(window, { handle }); +await handle.mount(document.getElementById('app')!); +window.addEventListener('pagehide', () => handle.destroy()); +`); + } else { + await writeFile(path.join(directory, 'main.tsx'), shared.replace('createOrgChart, ', '') + `import { StrictMode, useLayoutEffect, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { OrgChart as BaseOrgChart, type OrgChartEmployeePopupProps, type OrgChartProps } from '@hm-design/org-chart-react'; +${name === 'C' ? "import { OrgChart as HMOrgChart } from '@hm-design/org-chart-react/hm-dialog';\nimport './hm.css';" : ''} +function CustomPopup({selection, onClose}: OrgChartEmployeePopupProps) { + useLayoutEffect(() => { if (!selection) return; const trigger = selection.context.trigger; return () => { if (trigger.isConnected) trigger.focus(); }; }, [selection]); + return selection ?

{selection.employee.name}

: null; +} +function App() { + const [mode, setMode] = useState>((params.get('popup') as OrgChartProps['employeePopup']) || 'core'); + const [callback, setCallback] = useState(false); + const Chart = ${name === 'C' ? "params.get('entry') === 'base' ? BaseOrgChart : HMOrgChart" : 'BaseOrgChart'}; + Object.assign(window, { setPopup: setMode, setCallback, source }); + return { Object.assign(window, { clicked: { employee, organization: context.organization } }); } : undefined} + onStateChange={onStateChange} onLifecycleError={onLifecycleError} />; +} +createRoot(document.getElementById('app')!).render(); +`); + if (name === 'C') { + await writeFile(path.join(directory, 'hm.css'), '@import "tailwindcss";\n@import "@hm-design/ui/theme.css";\n@source "./node_modules/@hm-design/ui/dist";\n'); + await writeFile(path.join(directory, 'vite.config.mjs'), "import tailwindcss from '@tailwindcss/vite'; export default { plugins: [tailwindcss()] };\n"); + } + } + await npm(['install', ...(process.env.ORGCHART_OFFLINE === '1' ? ['--offline'] : []), '--no-audit', '--no-fund'], directory, `install-${name}`); + const require = createRequire(path.join(directory, 'package.json')); + assert.ok(!require.resolve('@hm-design/org-chart').includes(root)); + if (name !== 'C') { + assert.throws(() => require.resolve('@hm-design/ui'), { code: 'MODULE_NOT_FOUND' }); + await assert.rejects(access(path.join(directory, 'node_modules/@hm-design/ui'))); + } + if (name === 'A') { + assert.throws(() => require.resolve('react'), { code: 'MODULE_NOT_FOUND' }); + assert.throws(() => require.resolve('@hm-design/org-chart-react'), { code: 'MODULE_NOT_FOUND' }); + } + await npm(['ls', '--all', '--json'], directory, `dependencies-${name}`); + await npm(['run', 'build'], directory, `build-${name}`); +} +// Traverse the shipped default runtime and declaration graph, including shared chunks. +const baseDir = path.join(projects.B, 'node_modules/@hm-design/org-chart-react/dist'); +const graph = []; +async function inspect(file) { + if (graph.includes(file)) return; + graph.push(file); + const text = await readFile(path.join(baseDir, file), 'utf8'); + assert.ok(!text.includes('@hm-design/ui') && !text.includes('HMEmployeeDialog') && !text.includes('hm-dialog.js'), `UI leaked into ${file}`); + for (const match of text.matchAll(/(?:from\s*|import\s*\()?["'](\.\/[^"']+\.(?:js|ts))["']/g)) { + let relative = match[1].slice(2); + if (file.endsWith('.d.ts') && relative.endsWith('.js')) relative = relative.slice(0, -3) + '.d.ts'; + await inspect(relative); + } +} +await inspect('index.js'); await inspect('index.d.ts'); +await writeFile(path.join(evidence, 'default-entry-graph.json'), JSON.stringify({ status: 'PASS', files: graph }, null, 2)); + +const playwrightPath = process.env.ORGCHART_PLAYWRIGHT_MODULE; +assert.ok(playwrightPath, 'Set ORGCHART_PLAYWRIGHT_MODULE to an existing Playwright entry (test-only).'); +const { chromium } = await import(pathToFileURL(playwrightPath).href); +const browser = await chromium.launch({ headless: true, executablePath: process.env.ORGCHART_CHROME || 'C:/Program Files/Google/Chrome/Application/chrome.exe' }); +const results = [], servers = [], runtimeErrors = []; +const check = (name, detail = null) => { results.push({ name, status: 'PASS', detail }); console.log(`browser: ${name}: PASS`); }; +try { + for (const [name, directory] of Object.entries(projects)) { + const vite = await import(pathToFileURL(createRequire(path.join(directory, 'package.json')).resolve('vite')).href); + const server = await vite.preview({ root: directory, preview: { host: '127.0.0.1', port: 0 }, logLevel: 'silent' }); + servers.push(server); + const url = server.resolvedUrls.local[0]; + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + page.on('pageerror', error => runtimeErrors.push({ project: name, message: error.message })); + page.on('console', message => { if (message.type() === 'error') runtimeErrors.push({ project: name, message: message.text() }); }); + page.on('response', response => { if (response.status() >= 400) runtimeErrors.push({ project: name, url: response.url(), status: response.status() }); }); + async function ready(query) { + await page.goto(url + query); + await page.waitForFunction(() => document.documentElement.dataset.chartState === 'ready'); + await page.locator('.hm-orgchart-employee').first().waitFor(); + assert.equal(await page.locator('.hm-orgchart-host').count(), 1); + assert.equal(await page.evaluate(() => JSON.stringify(window.verification.data) === window.verification.dataBefore), true); + } + const card = () => page.locator('.hm-orgchart-employee').first(); + const coreDialog = () => page.locator('dialog.hm-orgchart-employee-dialog'); + const hmDialog = () => page.locator('[data-component="HMOrgChartEmployeeDialog"]'); + async function corePopup(label) { + await card().focus(); await page.keyboard.press('Enter'); + await coreDialog().waitFor({ state: 'visible' }); + assert.equal(await coreDialog().evaluate(e => e.open), true); + assert.equal(await hmDialog().count(), 0); + await page.keyboard.press('Escape'); + await coreDialog().waitFor({ state: 'hidden' }); + assert.equal(await card().evaluate(e => e.getRootNode().activeElement === e), true); + await card().click(); await coreDialog().waitFor({ state: 'visible' }); + await page.getByRole('button', { name: '직원 정보 닫기', exact: true }).click(); + await coreDialog().waitFor({ state: 'hidden' }); + check(label); + } + for (const layout of ['auto', 'fixed']) { + await ready(`?layout=${layout}`); + await corePopup(`${name} ${layout}: render, Core popup, keyboard, close, focus`); + assert.deepEqual(await page.evaluate(() => window.verification.errors), []); + await page.screenshot({ path: path.join(evidence, `${name}-${layout}.png`) }); + } + if (name !== 'A') { + // Presence of a custom adapter or the HM entry never automatically opts in. + await ready('?layout=auto&adapter=supplied'); + await corePopup(`${name}: supplied adapter with default mode retains Core`); + await page.evaluate(() => window.setPopup('adapter')); + await card().click(); + await page.locator('[data-component="CustomPopup"]').waitFor({ state: 'visible' }); + assert.equal(await coreDialog().evaluate(e => e.open), false); + await page.getByRole('button', { name: 'Close custom' }).click(); + await page.locator('[data-component="CustomPopup"]').waitFor({ state: 'detached' }); + check(`${name}: custom adapter without Core/data changes`); + await page.evaluate(() => window.setCallback(true)); + await card().click(); + await page.waitForFunction(() => !!window.clicked); + assert.equal(await page.locator('[data-component="CustomPopup"]').count(), 0); + check(`${name}: onEmployeeClick takes precedence`); + } + if (name === 'B') { + await ready('?layout=auto&popup=hm-dialog'); + await corePopup('B: missing HM adapter safely retains Core popup'); + assert.ok((await page.evaluate(() => window.verification.errors)).every(e => e.includes('popup adapter is missing'))); + } + if (name === 'C') { + await ready('?layout=auto&entry=base'); + await corePopup('C: UI installed, base entry still uses Core'); + await ready('?layout=auto&popup=hm-dialog'); + await card().focus(); await page.keyboard.press('Enter'); + await hmDialog().waitFor({ state: 'visible' }); + assert.equal(await coreDialog().evaluate(e => e.open), false); + const employeeName = await card().locator('.hm-orgchart-person-name').textContent(); + assert.ok((await hmDialog().textContent()).includes(employeeName.trim())); + await page.screenshot({ path: path.join(evidence, 'C-hm-dialog.png') }); + await page.keyboard.press('Escape'); await hmDialog().waitFor({ state: 'hidden' }); + assert.equal(await card().evaluate(e => e.getRootNode().activeElement === e), true); + await card().click(); await hmDialog().waitFor({ state: 'visible' }); + await hmDialog().getByRole('button', { name: '닫기', exact: true }).click(); await hmDialog().waitFor({ state: 'hidden' }); + check('C: HM Dialog employee data, Enter, Escape, close, focus restoration'); + // Switch ownership without rebuilding Core, with an open HM popup. + await card().click(); await hmDialog().waitFor({ state: 'visible' }); + await page.evaluate(() => { window.savedHost = document.querySelector('.hm-orgchart-host'); window.setPopup('core'); }); + await hmDialog().waitFor({ state: 'hidden' }); + assert.equal(await page.evaluate(() => window.savedHost === document.querySelector('.hm-orgchart-host')), true); + await corePopup('C: HM to Core switch retains same Core instance'); + assert.deepEqual(await page.evaluate(() => window.verification.errors), []); + } + await page.close(); + } + assert.deepEqual(runtimeErrors, []); + check('No browser exceptions, console errors or HTTP failures', runtimeErrors); + await writeFile(path.join(evidence, 'browser.json'), JSON.stringify({ status: 'PASS', browser: browser.version(), results, runtimeErrors }, null, 2)); +} finally { + await browser.close(); + await Promise.all(servers.map(server => new Promise(resolve => server.httpServer.close(resolve)))); +} +console.log(`PASS: ${results.length} targeted browser checks; artifacts: ${evidence}`); diff --git a/src/org-chart/center-composition-preview.ts b/src/org-chart/center-composition-preview.ts new file mode 100644 index 0000000..183acba --- /dev/null +++ b/src/org-chart/center-composition-preview.ts @@ -0,0 +1,58 @@ +/** Story-only alignment of the entire transformed Workdesk stage in the visible + * Content Wrapper's content box. Core retains its proportional width/height fit. */ +export function centerCompositionPreview(host: HTMLElement): () => void { + let frame: number | undefined; + let shadow: ShadowRoot | undefined; + let stage: HTMLElement | undefined; + const schedule = () => { frame ??= requestAnimationFrame(align); }; + const resize = new ResizeObserver(schedule); + const mutation = new MutationObserver(schedule); + const outer = new MutationObserver(schedule); + const align = () => { + frame = undefined; + const nextShadow = host.querySelector('.hm-orgchart-host')?.shadowRoot; + if (!nextShadow) return; + if (shadow !== nextShadow) { + mutation.disconnect(); + shadow = nextShadow; + mutation.observe(shadow, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'data-layout'] }); + } + const wrapper = shadow.querySelector('.hm-orgchart-composition'); + const nextStage = shadow.querySelector('.hm-orgchart-stage[data-workdesk-artwork]'); + if (!wrapper || !nextStage) return; + if (stage !== nextStage) { + if (stage) resize.unobserve(stage); + stage = nextStage; + resize.observe(stage); + } + const container = host.getBoundingClientRect(); + const css = getComputedStyle(host); + const inset = (key: 'borderLeftWidth' | 'borderRightWidth' | 'paddingLeft' | 'paddingRight') => parseFloat(css[key]) || 0; + const left = container.left + inset('borderLeftWidth') + inset('paddingLeft'); + const right = container.right - inset('borderRightWidth') - inset('paddingRight'); + const bounds = stage.getBoundingClientRect(); + if (right <= left || !bounds.width) return; + // The stage keeps its unscaled layout width, so centering that width with + // flex/grid would misplace Core's transformed rectangle. Measure the actual + // rendered rectangle instead; translate it as one unit without changing the + // transform, scale, 1920 x 914.5 canvas or any stage-relative coordinates. + const difference = (left + right - bounds.left - bounds.right) / 2; + // Fractional proportional scales can alternate the rounded correction by + // 0.0001px. Keep the same content-box calculation without a feedback loop. + if (Math.abs(difference) < 0.001) return; + const translation = `${Number(((parseFloat(stage.style.translate) || 0) + difference).toFixed(4))}px`; + if (stage.style.translate !== translation) stage.style.translate = translation; + }; + resize.observe(host); + // A padding redistribution can move the content center without resizing it. + outer.observe(host, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); + window.addEventListener('resize', schedule); + document.fonts.addEventListener('loadingdone', schedule); + schedule(); + return () => { + resize.disconnect(); mutation.disconnect(); outer.disconnect(); + window.removeEventListener('resize', schedule); + document.fonts.removeEventListener('loadingdone', schedule); + if (frame !== undefined) cancelAnimationFrame(frame); + }; +} diff --git a/src/org-chart/core-component-preview.tsx b/src/org-chart/core-component-preview.tsx new file mode 100644 index 0000000..d7ec988 --- /dev/null +++ b/src/org-chart/core-component-preview.tsx @@ -0,0 +1,154 @@ +/// +import { useLayoutEffect, useRef } from 'react'; +import { calculateSummary, normalizeOrgChartDataset, validateOrgChartDataset } 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 centerPreviewUrl from '../center/center-stories.css?url'; +import departmentPreviewUrl from './department-colors-preview.css?url'; +import { createEmployeeCard, type EmployeeCardOptions } from '../../packages/org-chart/src/core/employee-card'; +import { decorateNode, engineTree, type RenderContext } from '../../packages/org-chart/src/core/render'; +import { applyOrganizationStyle } from '../../packages/org-chart/src/core/style-presets'; +import { createPositionSummary } from '../../packages/org-chart/src/core/position-summary'; +import { createAffiliationSummary } from '../../packages/org-chart/src/core/affiliation-summary'; +import { createOrganizationTotals } from '../../packages/org-chart/src/core/organization-totals'; +import { createAffiliationBar } from '../../packages/org-chart/src/core/affiliation-bar'; +import { createStatusSummary } from '../../packages/org-chart/src/core/status-summary'; +import { createLogoLegend } from '../../packages/org-chart/src/core/logo-legend'; +import { createDepartmentColors, updateDepartmentColorValues } from '../../packages/org-chart/src/core/department-colors'; +import fixture from '../../examples/org-chart/data-p1.json'; + +type PreviewKind = 'Logo' | 'DepartmentColors' | 'PositionSummary' | 'AffiliationSummary' | 'StatusSummary' | 'EmployeeCard' | 'OrganizationCards'; +interface Props { kind: PreviewKind; data?: unknown; variant?: number; cardKind?: EmployeeCardOptions['kind']; presentation?: 'standalone' | 'workdesk' | 'design'; hideLogoHeading?: boolean } + +/** Story host only. Every pixel/card/count is rendered by the same Core modules + * used by Vanilla HTML and the production React Wrapper, not JSX copies. */ +export function CoreComponentPreview({ kind, data = fixture, variant = 0, cardKind = 'employee', presentation = 'standalone', hideLogoHeading = false }: Props) { + const ref = useRef(null); + useLayoutEffect(() => { + const validation = validateOrgChartDataset(data); + const host = ref.current!; + if (!validation.valid) { host.textContent = validation.issues.map(issue => issue.code).join(', '); return; } + const compactDepartmentPreview = kind === 'DepartmentColors' && presentation === 'design'; + const sampleName = '홍길동'; + const displayedSampleName = [...sampleName].join(' '); + // Department-only display data: preserve original identity/roles/membership. + // Format before Core rendering so the shared overflow tooltip has the same name. + const dataset = compactDepartmentPreview + ? { ...validation.data, employees: validation.data.employees.map(employee => ({ ...employee, name: displayedSampleName })) } + : validation.data; + const normalized = normalizeOrgChartDataset(dataset); + const context: RenderContext = { + data: dataset, summary: calculateSummary(dataset), orderedOrganizations: [], employeesByOrganization: normalized.employeesByOrganization, + topLeaderIds: new Set(normalized.roots.filter(node => node.organization.type === 'root' && node.organization.leaderId).map(node => node.organization.leaderId!)), + }; + engineTree(normalized.roots, 'component-preview', context); + const controller = new AbortController(); + const root = document.createElement('div'); + root.className = 'hm-orgchart-component-host'; + const shadow = root.attachShadow({ mode: 'open' }); + for (const href of [engineUrl, coreUrl, ...(compactDepartmentPreview ? [centerPreviewUrl, departmentPreviewUrl] : [])]) { + const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = href; shadow.append(link); + if (!compactDepartmentPreview) link.addEventListener('load', () => updateDepartmentColorValues(shell), { signal: controller.signal }); + } + const shell = document.createElement('section'); + // Share Fixed's passive presenters/styles without its stage coordinates. + shell.className = presentation === 'workdesk' ? 'hm-orgchart-shell hm-orgchart-summary-preview' : 'hm-orgchart-shell'; + let component: HTMLElement; + if (kind === 'Logo') { + component = createLogoLegend(document, context, controller.signal); + // Overview already supplies the section heading; keep the shared logo list intact. + if (hideLogoHeading) component.querySelector(':scope > h2')?.remove(); + } + else if (kind === 'DepartmentColors') component = createDepartmentColors(document, context, controller.signal); + else if (kind === 'PositionSummary') component = presentation === 'workdesk' ? createOrganizationTotals(document, context) : createPositionSummary(document, context); + else if (kind === 'AffiliationSummary') component = presentation === 'workdesk' ? createAffiliationBar(document, context, controller.signal) : createAffiliationSummary(document, context); + else if (kind === 'StatusSummary') component = createStatusSummary(document, context, controller.signal); + else if (kind === 'OrganizationCards') { + const organization = normalized.roots[0]?.children[variant]?.organization ?? normalized.roots[0]!.organization; + const wrapper = document.createElement('div'); + wrapper.className = 'hm-orgchart-engine orgchart hm-orgchart-component-cards'; + component = document.createElement('div'); component.className = 'node'; + const card = document.createElement('article'); card.className = 'hm-orgchart-organization'; component.append(card); + decorateNode(component, { id: 'preview-org', name: '', organizationIndex: context.orderedOrganizations.indexOf(organization), depth: 1, groupVariant: variant }, context, controller.signal, () => undefined); + // Branch members stay in their actual fixture organizations; previews use + // the shared EmployeeCard renderer without modifying primary membership. + const roster = document.createElement('div'); roster.className = 'hm-orgchart-preview-grid'; + const descendants = new Set([organization.id]); + for (const item of context.orderedOrganizations) if (item.parentId && descendants.has(item.parentId)) descendants.add(item.id); + for (const employee of dataset.employees.filter(item => descendants.has(item.organizationId) && item.id !== organization.leaderId).slice(0, 3)) { + roster.append(createEmployeeCard(document, employee, { kind: 'employee', affiliationName: dataset.affiliations.find(item => item.id === employee.affiliationId)?.name }, controller.signal)); + } + component.append(roster); wrapper.append(component); component = wrapper; + } else { + const organization = normalized.roots[0]?.children[variant]?.organization ?? normalized.roots[0]!.organization; + const id = cardKind === 'topLeader' ? normalized.roots[0]!.organization.leaderId : organization.leaderId; + const employee = cardKind !== 'employee' ? dataset.employees.find(item => item.id === id) : dataset.employees.find(item => item.id !== id && item.organizationId === organization.id) ?? dataset.employees.find(item => !item.jobTitle); + component = createEmployeeCard(document, employee ?? dataset.employees[0]!, { kind: cardKind, affiliationName: dataset.affiliations.find(item => item.id === employee?.affiliationId)?.name }, controller.signal); + applyOrganizationStyle(component, variant); + } + if (compactDepartmentPreview) { + // Keep the representative and department cards built by the shared Core. + // The Tunnel review frame styles wrappers only; approved artwork stays intact. + shell.classList.add('sb-center', 'hm-orgchart-department-preview'); + const representative = component.querySelector(':scope > .hm-orgchart-representative-preview'); + if (representative) { + const card = representative.querySelector('[data-card-kind="topLeader"]')!; + const header = document.createElement('header'); + const title = document.createElement('h2'); title.textContent = '대표자 카드'; + header.append(title); + const stage = document.createElement('div'); stage.className = 'tunnel-reference__stage hm-orgchart-department-stage'; + stage.append(card); + representative.classList.add('tunnel-reference__sample'); + representative.replaceChildren(header, stage); + } + const rows = Array.from(component.querySelectorAll(':scope > .hm-orgchart-color-preview')); + for (const row of rows) { + const heading = row.querySelector('.hm-orgchart-color-department-name')!; + const cards = row.querySelector('.hm-orgchart-preview-grid')!; + for (const caption of cards.querySelectorAll('.hm-orgchart-card-caption')) caption.remove(); + const header = document.createElement('header'); header.append(heading); + const stage = document.createElement('div'); stage.className = 'tunnel-reference__stage hm-orgchart-department-stage'; + stage.append(cards); + row.classList.add('tunnel-reference__sample'); + row.replaceChildren(header, stage); + } + // Reuse Fixed's compact status presenter, retaining its card ancestry/styles. + // One specimen per status is enough; the original fixture remains untouched. + const employees = ['dispatched', 'onLeave'].flatMap(status => { + const employee = dataset.employees.find(person => person.workStatus === status); + return employee ? [employee] : []; + }); + const statusData = { ...dataset, employees }; + const statuses = createStatusSummary(document, { + ...context, data: statusData, summary: calculateSummary(statusData), readOnly: true, + }, controller.signal, { compact: true }); + const statusPreviews = Array.from(statuses.querySelectorAll(':scope > .hm-orgchart-status-group')).map(group => { + const preview = document.createElement('section'); + preview.className = 'hm-orgchart-status-preview tunnel-reference__sample'; + const header = document.createElement('header'); + const title = document.createElement('h2'); + title.textContent = group.dataset.workStatus === 'dispatched' ? '파견자 카드' : '휴직자 카드'; + header.append(title); + const stage = document.createElement('div'); + stage.className = 'tunnel-reference__stage hm-orgchart-department-stage'; + const composition = document.createElement('div'); + composition.className = 'hm-orgchart-composition hm-orgchart-status-preview-composition'; + composition.dataset.displayMode = 'readOnly'; + const summary = statuses.cloneNode(false) as HTMLElement; + summary.append(group); composition.append(summary); stage.append(composition); + preview.append(header, stage); + return preview; + }); + component.replaceChildren(...(representative ? [representative] : []), ...rows, ...statusPreviews); + component.classList.add('hm-orgchart-department-colors-design', 'tunnel-reference__variant-grid'); + for (const name of component.querySelectorAll('.hm-orgchart-person-name')) name.setAttribute('aria-label', sampleName); + } + component.dataset.component = kind; + shell.append(component); shadow.append(shell); host.replaceChildren(root); + if (!compactDepartmentPreview) updateDepartmentColorValues(shell); + const observer = kind === 'DepartmentColors' && !compactDepartmentPreview ? new ResizeObserver(() => updateDepartmentColorValues(shell)) : undefined; + observer?.observe(root); + return () => { observer?.disconnect(); controller.abort(); root.remove(); }; + }, [kind, data, variant, cardKind, presentation, hideLogoHeading]); + return
; +} diff --git a/src/org-chart/department-colors-preview.css b/src/org-chart/department-colors-preview.css new file mode 100644 index 0000000..343f396 --- /dev/null +++ b/src/org-chart/department-colors-preview.css @@ -0,0 +1,50 @@ +/* Department Colors Storybook adapter only. The actual Tunnel sample/stage + frame comes from center-stories.css; this sheet never selects employee cards. */ +.hm-orgchart-department-preview .hm-orgchart-department-colors-design { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--sb-center-column-gap); +} + +.hm-orgchart-department-preview .hm-orgchart-color-preview { + grid-template-rows: auto 1fr; + grid-row: auto; +} + +.hm-orgchart-department-preview .hm-orgchart-representative-preview { + grid-column: 1 / -1; +} + +/* Group titles are OrgChart artwork. Preserve their established typography, + white text and organization gradient within the common review header. */ +.hm-orgchart-department-preview .hm-orgchart-color-preview > header > .hm-orgchart-color-department-name { + box-sizing: border-box; + width: 100%; + color: var(--hm-orgchart-current-department-color); + font-size: var(--hm-orgchart-title-font-size, 18px); + line-height: var(--hm-orgchart-title-line-height, 26px); + font-weight: var(--hm-orgchart-resolved-font-weight-bold); +} + +.hm-orgchart-department-preview .hm-orgchart-department-stage { + display: grid; + justify-items: safe center; + align-items: center; +} + +.hm-orgchart-department-preview .hm-orgchart-preview-grid { + margin-top: 0; + gap: var(--sb-center-column-gap); +} + +/* Keep Fixed's status presenter inside the existing review surface, without + introducing the full canvas background or coordinates into the specimen. */ +.hm-orgchart-department-preview .hm-orgchart-status-preview-composition { + width: max-content; + background: transparent; +} + +@media (max-width: 760px) { + .hm-orgchart-department-preview .hm-orgchart-department-colors-design { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/src/org-chart/org-chart-cards.stories.tsx b/src/org-chart/org-chart-cards.stories.tsx new file mode 100644 index 0000000..589dad3 --- /dev/null +++ b/src/org-chart/org-chart-cards.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { CoreComponentPreview } from './core-component-preview'; +import { organizationStylePresets } from '../../packages/org-chart/src/core/style-presets'; + +const meta = { title: 'OrgChart/Components/Cards', tags: ['internal'], component: CoreComponentPreview, parameters: { layout: 'padded', controls: { disable: true } } } satisfies Meta; +export default meta; +type Story = StoryObj; +function organization(variant: number): Story { + const preset = organizationStylePresets[variant]!; + return { + name: preset.label, + args: { kind: 'OrganizationCards', variant }, + render: () =>
+

{preset.label} · 실측 헤더 컬러 {preset.accentColor} · {preset.backgroundImage}

+

OrgChart 전용 --hm-orgchart-accent / --hm-orgchart-group-background. Workdesk {preset.version}, {preset.observedOn}, {preset.selector}. 직원 정보와 통계는 가상 데이터입니다. 카드 배경은 OrgChart 독립 기본값을 사용합니다.

+ +
, + }; +} +export const BusinessManagement = organization(0); +export const BusinessPlanning = organization(1); +export const TalentGrowth = organization(2); +export const IntegratedSystem = organization(3); +export const DesignPlanning = organization(4); +export const SolutionSales = organization(5); +export const TopLeader: Story = { name: '최상위 리더', args: { kind: 'EmployeeCard', cardKind: 'topLeader' } }; +export const OrganizationLeader: Story = { name: '조직장', args: { kind: 'EmployeeCard', cardKind: 'leader' } }; +export const Employee: Story = { name: '일반 직원', args: { kind: 'EmployeeCard', cardKind: 'employee' } }; diff --git a/src/org-chart/org-chart-department-colors.stories.tsx b/src/org-chart/org-chart-department-colors.stories.tsx new file mode 100644 index 0000000..b3cd009 --- /dev/null +++ b/src/org-chart/org-chart-department-colors.stories.tsx @@ -0,0 +1,8 @@ +import { StrictMode } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { DepartmentTypographyPreview } from './org-chart-page-previews'; + +const meta = { title: 'OrgChart/Department Colors', component: DepartmentTypographyPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => ] } satisfies Meta; +export default meta; +type Story = StoryObj; +export const DepartmentColors: Story = { name: 'Department Colors', parameters: { __id: 'orgchart--department-colors' } }; diff --git a/src/org-chart/org-chart-expanded-story.css b/src/org-chart/org-chart-expanded-story.css new file mode 100644 index 0000000..dab094b --- /dev/null +++ b/src/org-chart/org-chart-expanded-story.css @@ -0,0 +1,66 @@ +/* Existing Expanded Story content layout; HM widget styles stay unchanged. */ +/* Reuse the existing Storybook review card surface through inherited inputs. + Only the outer Auto preview consumes these; its controls/artwork stay intact. */ +.hm-orgchart-story-expanded .hm-orgchart-story-preview.sb-center { + --hm-orgchart-expanded-preview-background: var(--sb-center-card-background); + --hm-orgchart-expanded-preview-border: var(--sb-center-card-border); + --hm-orgchart-expanded-preview-radius: var(--sb-center-card-radius); + padding: 0; +} + +.hm-orgchart-story-expanded .hm-orgchart-story-settings { + display: grid; + gap: 0.75rem; + margin: 0; + padding: 0; + border: 0; + min-width: 0; +} +/* StorySection supplies the visible heading; keep each fieldset named for assistive technology. */ +.hm-orgchart-story-expanded .hm-orgchart-story-section-legend { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +.hm-orgchart-story-expanded .hm-orgchart-story-controls { + align-items: flex-start; +} +.hm-orgchart-story-expanded .hm-orgchart-story-instance { + flex: 1 1 12rem; +} +.hm-orgchart-story-expanded .hm-orgchart-story-popup-setting { + flex: 0 1 24rem; + width: 100%; +} +.hm-orgchart-story-expanded .hm-orgchart-story-companies { margin: 0; } +.hm-orgchart-story-expanded .hm-orgchart-story-company-trigger { + width: 100%; + min-width: 0; + justify-content: space-between; +} +.hm-orgchart-story-expanded .hm-orgchart-story-company-summary { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; +} +/* Popover is portaled outside StoryPage. Only this Story's content layout is + composed here; the HM panel surface, padding and widget states stay intact. */ +.hm-orgchart-story-company-popover { + max-height: var(--radix-popover-content-available-height); + overflow-y: auto; +} +.hm-orgchart-story-company-options { + display: grid; + gap: 0.75rem; + margin: 0; + padding: 0; + border: 0; + min-width: 0; +} diff --git a/src/org-chart/org-chart-expanded.stories.tsx b/src/org-chart/org-chart-expanded.stories.tsx new file mode 100644 index 0000000..07cef0d --- /dev/null +++ b/src/org-chart/org-chart-expanded.stories.tsx @@ -0,0 +1,254 @@ +import { StrictMode, useEffect, useId, useMemo, useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { ChevronDownIcon } from 'lucide-react'; +import { + Button, Checkbox, FilterBar, FilterBarActions, Label, + Popover, PopoverContent, PopoverTrigger, Separator, + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, +} from '@hm-design/ui'; +import { OrgChart, type OrgChartProps } from '@hm-design/org-chart-react/hm-dialog'; +import { + companyColorPresets, createOrgChartExampleDataset, JsonOrgChartDataSource, + type CompanyId, type OrgChartAutoLayoutOptions, type OrgChartDataset, type OrgChartExampleSelection, type OrgChartInteractionOptions, +} 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 { StoryPage, StoryPageHeader, StorySection } from '@/components/story-page'; +import large from '../../examples/org-chart/data-expanded-large.json'; +import './org-chart-stories.css'; +import '../center/center-stories.css'; +import './org-chart-expanded-story.css'; + +const template = large as OrgChartDataset; +const companies = Object.values(companyColorPresets); +const allCompanies = companies.map(company => company.companyId as CompanyId); +const compositionStylesheets = { engine: engineUrl, core: coreUrl }; +const topNodes = { ptc: { title: '기술총괄', name: '가상책임자 5' } }; +type DataSettings = Required>; +const initialSettings: DataSettings = { employeeCount: 20, companyIds: allCompanies }; + +/** Existing Expanded Story only: public FilterBar layout/actions match data + * conditions. The preview selects samples rather than searching employees. */ +function ExpandedPreview() { + const id = useId(); + const [draft, setDraft] = useState(initialSettings); + const [applied, setApplied] = useState(initialSettings); + const [dataRevision, setDataRevision] = useState(0); + const [layout, setLayout] = useState({ type: 'tb', hybridStartDepth: 2, initialVisibleDepth: 'all' }); + const [popup, setPopup] = useState('hm-dialog'); + const [interactions, setInteractions] = useState({ collapse: true, zoom: true, pan: true }); + const [dragDropEnabled, setDragDropEnabled] = useState(false); + const [error, setError] = useState(null); + const dataset = useMemo(() => createOrgChartExampleDataset(template, { + scope: 'per-company', + employeeCount: applied.employeeCount, + // Company selection is view state. Keep every company in the canonical + // session source so hiding/revealing a company retains successful moves. + companyIds: allCompanies, + }), [applied.employeeCount, dataRevision]); + const source = useMemo(() => new JsonOrgChartDataSource(dataset), [dataset]); + // Core owns edits. This read model updates counts/options without replacing + // dataSource and reloading the unchanged fixture over a successful move. + const [session, setSession] = useState(() => ({ baseline: dataset, current: dataset })); + const currentDataset = session.baseline === dataset ? session.current : dataset; + const organizationCompanies = useMemo(() => { + const byId = new Map(currentDataset.organizations.map(organization => [organization.id, organization])); + const companyById = new Map(); + for (const organization of currentDataset.organizations) { + let current = organization; + while (!current.companyId && current.parentId && byId.has(current.parentId)) current = byId.get(current.parentId)!; + if (current.companyId) companyById.set(organization.id, current.companyId as CompanyId); + } + return companyById; + }, [currentDataset]); + const groupChoices = useMemo(() => { + const parents = new Set(currentDataset.organizations.map(organization => organization.parentId)); + return currentDataset.organizations.filter(organization => parents.has(organization.id)).map(organization => { + const companyId = organizationCompanies.get(organization.id); + if (!companyId || !applied.companyIds.includes(companyId)) return null; + const company = companies.find(item => item.companyId === companyId); + const prefix = company ? `${company.companyName} · ` : ''; + return { id: organization.id, name: organization.name.startsWith(prefix) ? organization.name : `${prefix}${organization.name}` }; + }).filter((organization): organization is { id: string; name: string } => organization !== null); + }, [currentDataset, organizationCompanies, applied.companyIds]); + useEffect(() => { + const valid = new Set(groupChoices.map(organization => organization.id)); + setLayout(current => { + const previous = current.hybridOrganizationIds ?? []; + const ids = previous.filter(organizationId => valid.has(organizationId)); + return ids.length === previous.length ? current : { ...current, hybridOrganizationIds: ids }; + }); + }, [groupChoices]); + const predicted = draft.companyIds.length * draft.employeeCount; + const displayedTotal = new Set(currentDataset.employees.filter(employee => { + const companyId = organizationCompanies.get(employee.organizationId); + return companyId !== undefined && applied.companyIds.includes(companyId); + }).map(employee => employee.id)).size; + const sessionTotal = new Set(currentDataset.employees.map(employee => employee.id)).size; + const companyEmployeeCounts = useMemo(() => { + const employeeIds = new Map>(allCompanies.map(companyId => [companyId, new Set()])); + for (const employee of currentDataset.employees) { + const companyId = organizationCompanies.get(employee.organizationId); + if (companyId) employeeIds.get(companyId)?.add(employee.id); + } + return new Map([...employeeIds].map(([companyId, ids]) => [companyId, ids.size])); + }, [currentDataset, organizationCompanies]); + const companyCountSummary = applied.companyIds.map(companyId => `${companies.find(company => company.companyId === companyId)!.companyName} ${companyEmployeeCounts.get(companyId) ?? 0}명`).join(' · '); + const pending = draft.employeeCount !== applied.employeeCount + || draft.companyIds.join() !== applied.companyIds.join(); + const selectedCompanyCount = draft.companyIds.length; + const companySummary = selectedCompanyCount === allCompanies.length ? `전체 회사 (${allCompanies.length}개)` + : selectedCompanyCount === 1 ? companies.find(company => company.companyId === draft.companyIds[0])!.companyName + : selectedCompanyCount > 1 ? `${selectedCompanyCount}개 회사 선택` : '회사를 선택하세요'; + const allCompaniesChecked = selectedCompanyCount === allCompanies.length ? true + : selectedCompanyCount > 0 ? 'indeterminate' : false; + const changeCompanies = (companyIds: readonly CompanyId[]) => { + const selected = allCompanies.filter(companyId => companyIds.includes(companyId)); + setError(null); + // Company filtering is immediate; pending employee-count changes still belong + // to the existing Data Apply action and P7 dataset generation contract. + setDraft(current => ({ ...current, companyIds: selected })); + setApplied(current => ({ ...current, companyIds: selected })); + }; + + return + + +
+ 데이터 설정 + +
+ + + + + + +
+ 표시할 회사 · 복수 선택 가능 +
+ changeCompanies(checked === true ? allCompanies : [])} /> + +
+ + {companies.map(company =>
+ { + changeCompanies(checked === true ? [...draft.companyIds, company.companyId as CompanyId] : draft.companyIds.filter(value => value !== company.companyId)); + }} /> + +
)} +
+
+
+
+
+ + +
+ + + + +
+

+ {pending ? '적용 예정' : '예시 생성 기준'}: {draft.companyIds.length}개 회사 · 회사당 {draft.employeeCount}명 · 총 {predicted.toLocaleString('ko-KR')}명{pending ? ' · 데이터 적용을 눌러 반영하세요.' : ''} +

+

회사 숨김·다시 표시는 현재 소속 변경을 유지합니다. 데이터 적용은 선택한 조건의 예시를 새로 만들고, 초기화는 최초 데이터 설정과 소속으로 돌아갑니다.

+
+
+ +
+ 레이아웃 설정 +
+
+ + +
+
+ + + Hybrid에만 적용합니다. +
+
+ + + TB에서 선택한 그룹 아래만 적용합니다. LR·Hybrid 전환 시 해제됩니다. +
+
+ + +
+
+

Compact: 미지원. 기존 직원 카드 디자인을 보존하며, 압축 배치 선택은 제공하지 않습니다.

+
+
+ +
+
+ 독립 기능 설정 · 모든 표시 유형에서 개별 선택 +
+ + + {dragDropEnabled ? 'ON' : 'OFF'} +
+ {([ + ['collapse', '접기·펼치기 사용'], ['zoom', 'Zoom 사용'], ['pan', 'Pan 사용'], + ] as const).map(([key, label]) =>
+ setInteractions(current => ({ ...current, [key]: checked === true }))} /> + +
)} +
+
+
+ + +
+
+
+
+ +
+

빈 공간 드래그는 Pan입니다. {dragDropEnabled ? '직원 카드·그룹 타이틀 드래그로 회사 안팎의 소속을 변경합니다. 키보드: Alt+M → 방향키 → Enter / ESC 취소.' : '소속 변경은 Drag & Drop을 ON으로 설정하면 사용할 수 있습니다.'} 클릭은 직원 정보·그룹 접기입니다.

+

+ 표시 중: {applied.companyIds.length}개 회사 · 현재 소속 기준 직원 합계 {displayedTotal.toLocaleString('ko-KR')}명 / 전체 {sessionTotal.toLocaleString('ko-KR')}명 + {companyCountSummary ? ` · ${companyCountSummary}` : ''} +

+ {error ?

{error}

: null} +
+ { if (result.moved) setSession({ baseline: dataset, current: result.data }); }} onLifecycleError={cause => setError(cause instanceof Error ? cause.message : '조직도 설정을 적용할 수 없습니다.')} onStateChange={state => { if (state.status === 'error') setError(state.issues.map(issue => issue.message).join(' / ')); }} /> +
+
+
+
; +} + +const meta = { title: 'OrgChart/Expanded OrgChart', component: ExpandedPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => ] } satisfies Meta; +export default meta; +type Story = StoryObj; +export const ExpandedOrgChart: Story = { name: 'Expanded OrgChart', parameters: { __id: 'orgchart--expanded-org-chart' } }; diff --git a/src/org-chart/org-chart-full-composition.stories.tsx b/src/org-chart/org-chart-full-composition.stories.tsx new file mode 100644 index 0000000..95a0956 --- /dev/null +++ b/src/org-chart/org-chart-full-composition.stories.tsx @@ -0,0 +1,8 @@ +import { StrictMode } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { CompositionPreview } from './org-chart-page-previews'; + +const meta = { title: 'OrgChart/Full Composition', component: CompositionPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => ] } satisfies Meta; +export default meta; +type Story = StoryObj; +export const FullComposition: Story = { name: 'Full Composition', parameters: { __id: 'orgchart--full-composition' } }; diff --git a/src/org-chart/org-chart-overview-internal.stories.tsx b/src/org-chart/org-chart-overview-internal.stories.tsx new file mode 100644 index 0000000..9914fa0 --- /dev/null +++ b/src/org-chart/org-chart-overview-internal.stories.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { CoreComponentPreview } from './core-component-preview'; +import './org-chart-stories.css'; + +const meta = { title: 'OrgChart/Verification/Overview', component: CoreComponentPreview, tags: ['internal'], parameters: { layout: 'padded', controls: { disable: true } }, decorators: [(Story) => ] } satisfies Meta; +export default meta; +type Story = StoryObj; +// Literal IDs preserve every existing direct URL after moving the hidden CSF entries. +export const Logo: Story = { name: 'Logo', args: { kind: 'Logo' }, parameters: { __id: 'orgchart--logo' }, render: () => }; +export const PositionSummary: Story = { name: 'Position Summary', args: { kind: 'PositionSummary' }, parameters: { __id: 'orgchart--position-summary' }, render: () => <>

가상 직원 데이터 자동 집계 · 실제 운영 인원수가 아닙니다.

}; +export const AffiliationSummary: Story = { name: 'Affiliation Summary', args: { kind: 'AffiliationSummary' }, parameters: { __id: 'orgchart--affiliation-summary' }, render: () => <>

가상 직원 데이터 자동 집계 · 실제 운영 인원수가 아닙니다.

}; diff --git a/src/org-chart/org-chart-p1.stories.tsx b/src/org-chart/org-chart-p1.stories.tsx new file mode 100644 index 0000000..c52c331 --- /dev/null +++ b/src/org-chart/org-chart-p1.stories.tsx @@ -0,0 +1,8 @@ +import { StrictMode } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { OverviewPreview } from './org-chart-page-previews'; + +const meta = { title: 'OrgChart/Overview', component: OverviewPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => ] } satisfies Meta; +export default meta; +type Story = StoryObj; +export const LogoPersonnelSummary: Story = { name: 'Overview', parameters: { __id: 'orgchart--logo-personnel-summary' } }; diff --git a/src/org-chart/org-chart-page-previews.tsx b/src/org-chart/org-chart-page-previews.tsx new file mode 100644 index 0000000..873fdb6 --- /dev/null +++ b/src/org-chart/org-chart-page-previews.tsx @@ -0,0 +1,53 @@ +import { useLayoutEffect, useRef } from 'react'; +import { OrgChart } from '@hm-design/org-chart-react'; +import { JsonOrgChartDataSource } from '@hm-design/org-chart'; +import engineUrl from '@hm-design/org-chart/engine.css?url'; +import coreUrl from '@hm-design/org-chart/style.css?url'; +import { StoryPage, StoryPageHeader, StorySection } from '@/components/story-page'; +import fixture from '../../examples/org-chart/data-p1.json'; +import './org-chart-stories.css'; +import { CoreComponentPreview } from './core-component-preview'; +import { centerCompositionPreview } from './center-composition-preview'; + +const compositionSource = new JsonOrgChartDataSource(fixture); +const compositionStylesheets = { engine: engineUrl, core: coreUrl }; + +/** View-only Workdesk composition: the same Core/React Wrapper with + * displayMode="readOnly". No Story notes, test controls or collapse buttons; + * JSON update/lifecycle and collapse checks stay in the internal Content Stories. */ +export function CompositionPreview() { + const host = useRef(null); + useLayoutEffect(() => centerCompositionPreview(host.current!), []); + return + + +
+ +
+
+
; +} + +export function DepartmentTypographyPreview() { + return + + +
+
+
; +} + +export function OverviewPreview() { + return + + +
+
+ +
+
+ +
+
+
; +} diff --git a/src/org-chart/org-chart-stories.css b/src/org-chart/org-chart-stories.css new file mode 100644 index 0000000..939a6e2 --- /dev/null +++ b/src/org-chart/org-chart-stories.css @@ -0,0 +1,69 @@ +/* Story host layout only. Core nodes/styles remain inside their Shadow DOM. */ +.hm-orgchart-story { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1rem; + width: 100%; + min-width: 0; + max-width: 1880px; +} + +/* Full Composition scales itself to the available width; no host cap. */ +.hm-orgchart-story-full { max-width: none; } +.hm-orgchart-story-full { background: #F5F8FB; } + +/* Reuse StoryPage's inner/gutters; specimens receive the measured content width. + No page selector reaches into the Core's Shadow DOM. Fixed has no extra inset. */ +.hm-orgchart-story-page .hm-orgchart-story { max-width: none; } +.hm-orgchart-story-page .hm-orgchart-story-preview { min-width: 0; max-width: 100%; } + +.hm-orgchart-story-note { + margin: 0; + overflow-wrap: anywhere; +} + +.hm-orgchart-story-controls { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + min-width: 0; +} + +.hm-orgchart-story-state { + display: block; + overflow-wrap: anywhere; +} + +.hm-orgchart-story-chart, +.hm-orgchart-story-responsive, +.hm-orgchart-story-instance { + min-width: 0; + 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%; +} + +.hm-orgchart-story-instances { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + min-width: 0; +} + +.hm-orgchart-story-instance { + display: grid; + align-content: start; + gap: 0.5rem; +} + +@media (max-width: 767px) { + .hm-orgchart-story-instances { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/src/org-chart/org-chart-summary.stories.tsx b/src/org-chart/org-chart-summary.stories.tsx new file mode 100644 index 0000000..0264860 --- /dev/null +++ b/src/org-chart/org-chart-summary.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" +import { CoreComponentPreview } from "./core-component-preview" + +// The shared P1 fixture uses real organization/company names, virtual employees, +// and automatic Raw Count. Acquired logo mapping stays in the shared Core/asset +// layer; Stories do not duplicate summary UI or add employee data fields. +const meta = { + title: "OrgChart/Components/Summaries", + tags: ["internal"], + component: CoreComponentPreview, + args: { kind: "PositionSummary" }, + parameters: { layout: "padded", controls: { disable: true } }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const PositionSummary: Story = { + render: () => , +} + +export const AffiliationSummary: Story = { + args: { kind: "AffiliationSummary" }, + render: () => , +} + +export const StatusSummary: Story = { + args: { kind: "StatusSummary" }, + render: () => , +} diff --git a/src/org-chart/org-chart.stories.tsx b/src/org-chart/org-chart.stories.tsx new file mode 100644 index 0000000..0205fd4 --- /dev/null +++ b/src/org-chart/org-chart.stories.tsx @@ -0,0 +1,217 @@ +import { StrictMode, useCallback, useState, type ReactNode } from "react" +import type { Meta, StoryObj } from "@storybook/react-vite" +import { Button } from "@hm-design/ui" +import { OrgChart } from "@hm-design/org-chart-react" +import { JsonOrgChartDataSource, type OrgChartDataSource, type OrgChartState } from "@hm-design/org-chart" +import engineCssUrl from "@hm-design/org-chart/engine.css?url" +import coreCssUrl from "@hm-design/org-chart/style.css?url" +import dataV1 from "../../examples/org-chart/data-v1.json" +import dataV2 from "../../examples/org-chart/data-v2.json" +import "./org-chart-stories.css" + +// These are the same source files consumed by the Vanilla examples. The Core +// validates the unknown JSON, rather than a Story casting it to a trusted type. +const sourceV1 = new JsonOrgChartDataSource(dataV1) +const sourceV2 = new JsonOrgChartDataSource(dataV2) +const stylesheetUrls = { engine: engineCssUrl, core: coreCssUrl } + +const hierarchyData = structuredClone(dataV1) +hierarchyData.presentation.groups.showEmployees = false +hierarchyData.presentation.groups.showSummary = false +const hierarchySource = new JsonOrgChartDataSource(hierarchyData) + +const employeeData = structuredClone(dataV1) +employeeData.presentation.groups.showSummary = false +employeeData.organizations[1].name = "가상 기획팀 · 긴 조직명을 가진 콘텐츠 표시 확인" +employeeData.employees[2].name = "가상직원 긴 이름 표시 확인" +employeeData.employees[2].position = "가상 장기 프로젝트 기획 및 운영 담당 선임" +employeeData.employees[2].jobTitle = "가상 콘텐츠 및 협업 담당" +const employeeSource = new JsonOrgChartDataSource(employeeData) + +const noEmployeeData = structuredClone(dataV1) +noEmployeeData.employees = [] +for (const organization of noEmployeeData.organizations) organization.leaderId = null +noEmployeeData.metadata.dataVersion = "story-zero-employees" +const noEmployeeSource = new JsonOrgChartDataSource(noEmployeeData) + +const noOrganizationData = structuredClone(noEmployeeData) +noOrganizationData.organizations = [] +noOrganizationData.relations = [] +noOrganizationData.presentation.organizationOrder = [] +noOrganizationData.metadata.dataVersion = "story-zero-organizations" +const noOrganizationSource = new JsonOrgChartDataSource(noOrganizationData) + +/** Test transport only: chart DOM, validation and errors remain owned by Core. */ +function delayedSource(payload: unknown, delayMs: number, fail = false, ignoreAbort = false): OrgChartDataSource { + return { + load({ signal } = {}) { + return new Promise((resolve, reject) => { + const abort = () => { + clearTimeout(timer) + signal?.removeEventListener("abort", abort) + reject(new DOMException("Story source cancelled", "AbortError")) + } + const timer = setTimeout(() => { + signal?.removeEventListener("abort", abort) + if (fail) reject(new Error("Fictitious Story source failure")) + else resolve(structuredClone(payload)) + }, delayMs) + if (!ignoreAbort) { + if (signal?.aborted) abort() + else signal?.addEventListener("abort", abort, { once: true }) + } + }) + }, + } +} + +function Content({ note, children }: { note: string; children: ReactNode }) { + return

{note}

{children}
+} + +function StateOutput({ state, updates, label = "현재 상태" }: { state: OrgChartState; updates: number; label?: string }) { + return ( + + {label}: {state.status} + {state.status === "ready" && ` · ${state.dataVersion} · 원본 직원 ${state.summary.raw.total}명`} + {state.status === "error" && ` · ${state.issues.map(issue => issue.code).join(", ")}`} + {` · 상태 알림 ${updates}회`} + + ) +} + +function useChartState() { + const [state, setState] = useState({ status: "idle" }) + const [updates, setUpdates] = useState(0) + const onStateChange = useCallback((next: OrgChartState) => { + setState(next) + setUpdates(count => count + 1) + }, []) + return { state, updates, onStateChange } +} + +function DataUpdateContent() { + const [source, setSource] = useState(sourceV1) + const [mounted, setMounted] = useState(true) + const status = useChartState() + return ( + +
+ + + +
+ + {mounted && } +
+ ) +} + +function LoadingContent() { + const [source, setSource] = useState(() => delayedSource(dataV1, 1800)) + const status = useChartState() + return ( + +
+ + + + + + +
+ + +
+ ) +} + +function ResponsiveContent() { + const [width, setWidth] = useState(390) + return ( + +
+ {[390, 768, 1280, 1440].map(value => )} +
+
+ +
+
+ ) +} + +function MultipleContent() { + const [sourceA, setSourceA] = useState(sourceV1) + const [mountedA, setMountedA] = useState(true) + const a = useChartState() + const b = useChartState() + return ( + +
+ + + +
+
+
+ + {mountedA && } +
+
+ + +
+
+
+ ) +} + +const meta = { + title: "OrgChart/Content", + tags: ["internal"], + component: OrgChart, + parameters: { + layout: "padded", + docs: { description: { component: "OrgChart v0.1 콘텐츠: React Wrapper → 동일 Vanilla Core → dabeng 엔진 / HM Theme / 노드 UI. 기존 HTML JSON을 공유하며 Navigation·Zoom/Pan·실제 인사 데이터는 포함하지 않습니다." } }, + }, + args: { dataSource: sourceV1, stylesheetUrls }, + argTypes: { + dataSource: { control: false }, + stylesheetUrls: { control: false }, + aggregationPolicy: { control: false }, + onStateChange: { control: false }, + onLifecycleError: { control: false }, + className: { control: false }, + style: { control: false }, + }, + decorators: [(Story) => ], +} satisfies Meta +export default meta +type Story = StoryObj + +export const BasicOrganization: Story = { + name: "Basic Organization", + render: args => , +} + +export const OrganizationHierarchy: Story = { + name: "Organization Hierarchy", + args: { dataSource: hierarchySource }, + render: args => , +} + +export const EmployeeCards: Story = { + name: "Employee Cards", + args: { dataSource: employeeSource }, + render: args => , +} + +export const OrganizationSummary: Story = { + name: "Organization Summary", + render: args => , +} + +export const JSONDataUpdate: Story = { name: "JSON Data Update", render: () => } +export const LoadingEmptyError: Story = { name: "Loading / Empty / Error", render: () => } +export const ResponsiveContainer: Story = { name: "Responsive Container", render: () => } +export const MultipleInstances: Story = { name: "Multiple Instances", render: () => }