Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/registry-compile-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sanring/cli": patch
---

Fix two registry items that failed to compile after install: `context-menu` imported `../../shared/menu-navigation` instead of `../shared/menu-navigation`, and `block/table-page` left out the required `value` on its row "Delete" menu item.
95 changes: 95 additions & 0 deletions .claude/adrs/0002-org-chart-layout-engine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
---
schema_version: 1
adr_id: "0002"
title: org-chart-layout-engine
status: Accepted
date: 2026-10-01
deciders: [jack755051]
related: [.claude/charters/p33-org-chart.md]
---

# ADR-0002: Org Chart 排版引擎與交付形態

## Context

- **觸發事件**:需要「經典組織圖」——主管在上、同儕橫排,且必須支援**雙主管 / 矩陣匯報**、**跨層虛線**、**直角連線避開卡片**。
- **既有狀況**:`tree` 元件是 ARIA treeview(側欄導航),不是 2D 圖。repo 沒有任何圖排版能力。
- **既有約束**:
- TODOLIST P32「明確不做」:不新增 primitive,官方方向在 blocks 與 registry 生態。
- block 與 component 共用 `RegistryComponent` 型別(`packages/cli/src/registry.ts:148`),已支援 `peerDependencies`;`sanring add` 會自動安裝 peer(`collectPeerDeps`)。
- 前例:`carousel` 以 `embla-carousel` 為 peerDependency。

## 候選引擎(2026-10-01 以 `npm pack` 實測)

| 引擎 | 授權 | 主檔 gzip | 多父節點 | 直角繞線 | Worker |
|---|---|---|---|---|---|
| d3-hierarchy 3.1.2 | ISC | ~6 KB(`d3-hierarchy.min.js`) | ❌ 嚴格樹 | ❌ 只給端點 | 不需要 |
| @dagrejs/dagre 3.1.1 | MIT | ~17 KB(`dagre.min.js`) | ✅ | ❌ polyline | ❌ |
| elkjs 0.12.0 | EPL-2.0 OR GPL-3.0-or-later | ~460 KB(`elk-worker.min.js` / `elk.bundled.js`) | ✅ `layered` | ✅ `elk.edgeRouting: ORTHOGONAL` | ✅ `elk-api.js` + worker |

## Decision

1. **引擎:elkjs,演算法固定 `org.eclipse.elk.layered`。** 唯一同時滿足多父節點 + 直角繞線 + Worker 的選項。`mrtree` 是純樹演算法,禁用。
2. **交付形態:單一 block `block/org-chart`**,`peerDependencies: { "elkjs": "^0.12.0" }`。不新增 `diagram-*` / `org-chart` primitive。
3. **引擎隔離:`layout.ts` 是唯一 import elkjs 的檔案**,輸出引擎無關的 `{ nodes, edges }` 座標結構;換引擎只改此檔。
4. **渲染:節點 HTML、連線 SVG**,同一座標系;path 由 elk 轉折點直接拼 `M…L…`。
5. **不引入 d3**:`d3-shape` 對直角線無用;`d3-zoom` 帶入 selection / drag / transition / interpolate 且直接操作 DOM,與 signal 狀態雙軌。平移縮放自寫(pointer + wheel + CSS `transform`)。
6. **授權**:消費端選 EPL-2.0 分支;elkjs 不進 `@sanring/cli` tarball(由 `sanring add` 安裝到消費專案);docs 頁明示授權與體積。

## Consequences

- ✅ 矩陣匯報、跨層虛線、直角連線一次到位;CLI 零改動。
- ✅ 排版在 Worker,不跟 Angular change detection 搶主線程。
- ⚠️ worker chunk 1.45 MB raw / ~336 KB transfer:只在該頁載入,不進主 bundle(spike 已驗證)。
- ⚠️ EPL-2.0:法務若否決 → 退路是 dagre(MIT),代價是放棄直角繞線與 Worker。
- ⚠️ mergeEdges 讓虛線與實線共用匯流排段(批次 B 處理)。

## Spike 結果(批次 A,2026-10-01)

docs app 暫時頁 `/spike/org-chart`(已移除),17 人 demo(含雙主管、CEO → Junior PM 跨層虛線、跨部門虛線)+ 合成 300 / 1000 人。

### A1 虛線處理 → 採「虛線一起參與 layered」

| 方案 | 結果 |
|---|---|
| 虛線一起參與(all-equal) | ✅ 分層未被扯歪;elk 為虛線繞線並避開卡片 |
| 虛線降 priority(solid-priority) | 與 all-equal 視覺上無差,不值得多一組選項 |
| 只排實線、虛線事後畫直角折線(solid-only) | ❌ 虛線直接穿過卡片(CEO → Junior PM 穿過 CTO 卡片) |

**「經典組織圖」外觀必須加這組選項**(不加則父節點不置中、每條線各自出埠):

```ts
'elk.algorithm': 'layered',
'elk.direction': 'DOWN',
'elk.edgeRouting': 'ORTHOGONAL',
'elk.layered.mergeEdges': 'true', // 同一主管的子線共用匯流排
'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF',
'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED', // 父節點置中
'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES', // 尊重輸入順序
```

**已知瑕疵(批次 B 處理)**:`mergeEdges` 會讓虛線與同源實線共用匯流排段,起點附近虛線被實線蓋住、看起來像實線。

### A2 效能(Worker 往返,dev server,Apple Silicon)

| 節點 | 耗時 |
|---|---|
| 17(首次,含 worker 啟動) | ~135–215 ms |
| 17(暖機後) | ~10–16 ms |
| 300 | ~51 ms |
| 1000 | ~166–173 ms |

遠低於 2 秒 gate。

### A3 打包 → 通過

- 正確接法:主線程 `import ELK from 'elkjs/lib/elk-api'` + `workerFactory: () => new Worker(new URL('./x.worker', import.meta.url), { type: 'module' })`;worker 檔只寫 `import 'elkjs/lib/elk-worker.min.js'`(它在 worker scope 自己掛 `self.onmessage`)。
- ❌ 不要在自訂 worker 裡用 `elk.bundled.js`:它會再開內層 worker,報 `_Worker is not a constructor`。
- `ng build` 結果:elk 只出現在 `worker-*.js`(1.45 MB raw / ~336 KB transfer);主 bundle 只多 `elk-api`(~1 KB)。docs app 無需 `webWorkerTsConfig`。
- 會出現 `not ESM` 警告(`elk-api`、`elk-worker.min.js`)→ docs 要教消費端加 `allowedCommonJsDependencies: ["elkjs"]`。
- dev server 首次載入時 Vite 重新最佳化依賴,可能先報一次 `_Worker` 錯,重整後正常;只影響 dev。

## 推翻條件

- spike 任一 gate 不過(見 charter §6)→ 回到本 ADR 重評 dagre 或縮減需求。
- 出現第二個「節點 + 連線」block(如流程圖)→ 另開 ADR 評估抽 `diagram-*` 原語。
136 changes: 136 additions & 0 deletions .claude/charters/p33-org-chart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
---
schema_version: 1
charter_id: p33-org-chart
charter_name: P33 — Block org-chart(elkjs 排版 + 薄殼渲染)
status: draft
charter_type: feature
parent_charter:
date: 2026-10-01
owner: jack755051
branch: feat/p33-org-chart
related_prd:
related_constitution:
related_adr: .claude/adrs/0002-org-chart-layout-engine.md
---

# Task Charter: P33 — Block org-chart

選型理由見 [ADR-0002](../adrs/0002-org-chart-layout-engine.md)。本檔只管**範圍、順序、驗證**。

## 1. 目的 (Purpose)

交付 `sanring add block/org-chart`:經典組織圖頁面模板,支援雙主管 / 矩陣匯報、跨層虛線、直角連線避障、平移縮放,並以 `tree` 側欄提供鍵盤可及性。

## 2. 核心重點(每批都要守)

1. **elkjs 只在 `layout.ts` 與 `org-chart.worker.ts`**——其他檔案 grep 不到 `elkjs`。
2. **演算法只用 `elk.layered`**,`elk.edgeRouting: ORTHOGONAL`。
3. **節點 HTML、連線 SVG**,同一座標系;不做純 SVG 圖。
4. **Worker 預設開**;elk 不得進主 bundle。
5. **狀態在 block 的 signal**;不建 `providedIn: 'root'` service。
6. **圖不是 ARIA treeview**;鍵盤導航交給既有 `tree`,兩者共用同一份資料。
7. **不引入 d3**。

## 3. 可做範圍 vs 不可做範圍

### ✅ 可做

- `registry/blocks/org-chart/`(`org-chart.component.ts`、`layout.ts`、`org-chart.worker.ts`、`index.ts`)
- `registry/registry.json` `blocks[]` 新增一筆,`peerDependencies: { "elkjs": "^0.12.0" }`
- 根 `package.json` 加 `elkjs`(docs app / spike 用)
- docs app:`/blocks` 頁加 org-chart 示範;spike 期間可加暫時頁面(批次 A 結束移除)
- 卡片組合既有元件:`card`、`avatar`、`badge`、`dropdown-menu`、`tree`、`sheet`、`skeleton`、`button`
- 固定卡片尺寸 input(`nodeW` / `nodeH`)
- 平移、縮放、fit-to-view、捲動到指定節點(自寫)
- 循環匯報:偵測後 `console.warn`,不畫

### ❌ 不可做

- `diagram-*` / `org-chart` primitive(`packages/ui` 不動)
- 改 `packages/cli`(含授權提示機制)
- elkjs 進 `@sanring/cli` tarball
- d3 任何套件
- 可變卡片尺寸 / 隱藏量測、部門分組框(compound node)、節點拖曳
- CRUD、權限、搜尋後端
- `mrtree` 或其他 elk 演算法切換 API

## 4. 分批策略 (Phased Plan)

### 批次 A:Spike(Gate,不進 registry)

docs app 暫時頁 + 假資料:≥1 位雙主管員工、≥1 條 CEO → 基層 PM 跨層虛線。

- [x] A1 虛線處理:比較「虛線一起參與 layered」vs「只用實線排版、虛線另算繞線」,截圖存證,選一種
- [x] A2 效能:300 / 1000 節點在 Worker 中 `layout()` 耗時(記錄數字)
- [x] A3 打包:`ng build` 後確認 elk 位於獨立 worker chunk,主 bundle 無 elk
- [x] A4 結論寫回 ADR-0002(Status → Accepted 或 Rejected)

### 批次 B:`layout.ts`

結果與選項定案見 ADR-0002「Spike 結果」。

- [x] 修正虛線被實線匯流排蓋住:改用每節點 4 個固定 port(實線 in/out 置中、虛線 in/out 右移 24px),不開 `mergeEdges`;同主管實線仍共用單一出線點
- [x] worker 檔隨 block 出貨(`org-chart.worker.ts`)
- [x] 型別:`OrgNode { id }`、`OrgEdge { source, target, kind?: 'solid' | 'dotted' }`;引擎以 `OrgLayoutEngine` 注入(app 用 `createOrgLayoutEngine()` 走 worker,測試用 bundled)
- [x] 輸出 `OrgLayout { width, height, nodes, edges: { id, source, target, kind, points }[], dropped }` + `orgEdgePath()`
- [x] 邊清理:未知節點、自環、重複邊、實線匯報循環(drop 閉環那條,`console.warn`);虛線可反向指上
- [x] unit test:`apps/docs/src/app/pages/blocks/org-chart-layout.spec.ts`(放 docs 是因為 `registry/` 整包進 CLI tarball)——驗證雙主管、虛線不改層級、同主管單一匯流排、虛線不疊實線、線不穿卡片、循環 / 清理

### 批次 C:渲染殼

- [x] 卡片層(`@for` + 絕對定位,卡片是 `<button>`,`[(selected)]` model)+ SVG 連線層(實線 `--sanring-border-strong`、虛線 `--sanring-primary`)
- [x] loading(skeleton)/ empty / error 三態;重新排版時保留上一版畫面(`linkedSignal`),不閃 skeleton
- [x] 樣式只用 `var(--sanring-*)` token(不用 shadcn 類名,消費端沒有那些 theme 色)
- [x] docs `/blocks` 頁即時預覽——靠 `apps/docs/scripts/stage-registry.mjs` 把 registry 攤成消費端佈局後編譯(另見 CONTRIBUTING)

### 批次 D:互動

- [ ] 平移、縮放(滑鼠 + 觸控板)、fit-to-view、捲到指定節點

### 批次 E:a11y

- [ ] `tree` 側欄聯動:選取同步、焦點移到對應卡片
- [ ] axe 掃描零違規

### 批次 F:Registry + docs

- [ ] `registry.json` `blocks[]`、`/blocks` 頁示範
- [ ] docs 註明 EPL-2.0、worker 體積(~336 KB transfer)、`allowedCommonJsDependencies: ["elkjs"]`

### 批次 G:E2E + 發版

- [ ] Playwright:渲染、縮放、tree 聯動
- [ ] CLI e2e:`sanring add block/org-chart` 會裝 elkjs
- [ ] changeset

## 5. Commit 邊界

- 一批一個(或數個)commit,不跨批混雜。
- 批次 A 的暫時頁面不得留在最終 PR;或於批次 A 結束時刪除。

## 6. 停止條件 (Stop Conditions)

遇到以下任一,停工回報,不自行繞過:

- A1:兩種虛線處理方式都會讓實線主幹明顯變形
- A2:1000 節點 Worker 排版 > 2 秒
- A3:Angular 22 build 無法把 elk 切成獨立 worker chunk
- 任一步需要改 `packages/ui` 或 `packages/cli`
- 法務否決 EPL-2.0

## 7. 驗證標準 (Verification)

### 每批 commit 前必跑

```bash
pnpm lint
pnpm test
grep -rln "elkjs" registry/blocks/org-chart | grep -v -e layout.ts -e worker.ts # 必須無輸出
grep -rn "d3-" registry/blocks/org-chart package.json # 必須無輸出
```

### 收尾驗證

- [ ] `pnpm build` 後主 bundle 無 elk(A3 的方法重跑)
- [ ] `pnpm test:e2e:docs`、`pnpm test:e2e:cli` 通過
- [ ] §2 核心重點逐條勾選
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
/bazel-out
packages/*/dist/
packages/cli/registry/
apps/docs/src/registry-stage/

# Node
node_modules/
Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ pnpm start # docs dev server → http://localhost:4200

**改動 `registry/` 底下的元件檔案後,一定要重新 build 一次 CLI 套件**(或至少跑 `pnpm --filter @sanring/cli sync-registry`),否則發布出去的 CLI 會裝到舊版程式碼——這正是 [.changeset/plenty-pumas-sync.md](.changeset/plenty-pumas-sync.md) 修的問題。

docs app 另有一份 `apps/docs/src/registry-stage/`(不進 git),由 `apps/docs/scripts/stage-registry.mjs` 把 `registry/` 攤平成消費端安裝後的佈局(`<component>/`、`<block>/`、`shared/` 同層)。block 用 `'../card'` 這種相對路徑引用元件,只有這個佈局解析得到;docs 因此能編譯、型別檢查所有 registry 元件與 block,並直接渲染 block 預覽。`pnpm install`(postinstall)與 `pnpm start` / `build` / `test` 前都會自動刷新;直接編輯 `registry/` 時可另開一個終端跑 `pnpm stage:registry --watch`。

`pnpm test:e2e:cli` 會使用 `packages/cli/e2e/fresh-angular.mjs` 建立獨立暫存專案,成功時自動清除;失敗時保留路徑供除錯。若要保留成功專案,也可使用 `SANRING_E2E_KEEP_TEMP=1 pnpm test:e2e:cli`。這項測試會從 npm 安裝 fresh project dependencies,CI 已在 `Test (@sanring/cli)` job 執行。

## `@sanring/cli` 版本相容性(Changesets)
Expand Down
19 changes: 19 additions & 0 deletions TODOLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,22 @@
**影響**:不處理的話,消費者會在「看起來該有的兄弟 API」上卡關(sheet 關閉鈕、radio 尺寸、popover 左右、transfer 整組停用),或照著 dropdown-menu 鍵盤表做出不能用方向鍵開的子選單。

**成本**:中。各子項可獨立交付;table docs 配方最低、sheet/radio 次之、dropdown-menu submenu 最重(要接 `@angular/aria/menu` 巢狀,不能沿用現在的兩欄 hover)。

---

## P33 — Block:`org-chart`(elkjs 排版 + 薄殼渲染)

經典組織圖 block:雙主管 / 矩陣匯報、跨層虛線、直角連線避障、平移縮放,`tree` 側欄負責鍵盤導航。

- 選型決策:[ADR-0002](.claude/adrs/0002-org-chart-layout-engine.md)(elkjs `layered`,不用 d3、不開 primitive)
- 範圍 / 步驟 / 停止條件 / 驗證:[charter p33-org-chart](.claude/charters/p33-org-chart.md)

- [x] 批次 A Spike(gate):全數通過,結果見 ADR-0002
- [x] 批次 B `layout.ts`
- [x] 批次 C 渲染殼
- [ ] 批次 D 互動(平移縮放)
- [ ] 批次 E a11y(tree 聯動)
- [ ] 批次 F registry + docs
- [ ] 批次 G E2E + changeset

**成本**:中高。批次 A 決定可行性,先做;C–G 各自可拆 PR。
1 change: 1 addition & 0 deletions angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"browser": "apps/docs/src/main.ts",
"tsConfig": "apps/docs/tsconfig.app.json",
"inlineStyleLanguage": "css",
"allowedCommonJsDependencies": ["elkjs"],
"assets": [
{
"glob": "**/*",
Expand Down
51 changes: 51 additions & 0 deletions apps/docs/scripts/stage-registry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env node
// Copies registry/ into apps/docs/src/registry-stage/ in the layout `sanring add`
// produces in a consumer app:
//
// registry-stage/<component>/ ← registry/components/<component>/
// registry-stage/<block>/ ← registry/blocks/<block>/
// registry-stage/shared/ ← registry/shared/
//
// Blocks import siblings relatively (`'../card'`), which only resolves in that
// layout — so this is what lets the docs app compile (and type-check) blocks
// and render live previews. The output is gitignored; it is refreshed on
// `pnpm install` (postinstall) and before start/build/test. Pass `--watch` to
// keep it in sync while editing registry files.
import { cpSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync, watch } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../../..');
const REGISTRY = join(ROOT, 'registry');
const DEST = join(ROOT, 'apps/docs/src/registry-stage');
const TMP = `${DEST}.tmp`;

function stage() {
rmSync(TMP, { recursive: true, force: true });
mkdirSync(TMP, { recursive: true });
for (const group of ['components', 'blocks']) {
const dir = join(REGISTRY, group);
for (const name of readdirSync(dir)) {
if (existsSync(join(TMP, name))) throw new Error(`registry-stage: "${name}" exists in more than one group`);
cpSync(join(dir, name), join(TMP, name), { recursive: true });
}
}
cpSync(join(REGISTRY, 'shared'), join(TMP, 'shared'), { recursive: true });
rmSync(DEST, { recursive: true, force: true });
renameSync(TMP, DEST);
}

stage();
console.log(`✔ Staged registry → ${DEST}`);

if (process.argv.includes('--watch')) {
let timer;
watch(REGISTRY, { recursive: true }, () => {
clearTimeout(timer);
timer = setTimeout(() => {
stage();
console.log('✔ Re-staged registry');
}, 100);
});
console.log('… watching registry/ for changes');
}
3 changes: 3 additions & 0 deletions apps/docs/src/app/i18n/locales/en/pages/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,7 @@ export const blocksTranslations = {
'blocks.table.title': 'Table page',
'blocks.table.body':
'A data table with search, status filter, row selection, a create sheet, loading skeletons, and toast.',
'blocks.org.title': 'Org chart',
'blocks.org.body':
'A classic org chart: managers above reports, dual managers and dotted-line reporting, orthogonal connectors routed around cards. Layout runs in a Web Worker via elkjs (EPL-2.0), which the CLI installs as a peer dependency.',
} as const;
3 changes: 3 additions & 0 deletions apps/docs/src/app/i18n/locales/zh/pages/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,7 @@ export const blocksTranslations = {
'blocks.table.title': '資料表頁',
'blocks.table.body':
'含搜尋、狀態篩選、列選取、新增抽屜、loading skeleton 與 toast 的資料表頁。',
'blocks.org.title': '組織圖',
'blocks.org.body':
'經典組織圖:主管在上、下屬在下,支援雙主管與虛線匯報,直角連線自動繞開卡片。排版由 elkjs(EPL-2.0)在 Web Worker 中計算,CLI 會以 peer dependency 安裝。',
} as const;
Loading
Loading