From c5db3778584fab7724732283344e7f9f99b707fc Mon Sep 17 00:00:00 2001
From: jack755051
Date: Thu, 1 Oct 2026 14:54:06 +0800
Subject: [PATCH 1/7] docs: add P33 org-chart plan (ADR-0002 + charter)
Co-Authored-By: Claude Opus 5.5
---
.claude/adrs/0002-org-chart-layout-engine.md | 50 +++++++
.claude/charters/p33-org-chart.md | 130 +++++++++++++++++++
TODOLIST.md | 19 +++
3 files changed, 199 insertions(+)
create mode 100644 .claude/adrs/0002-org-chart-layout-engine.md
create mode 100644 .claude/charters/p33-org-chart.md
diff --git a/.claude/adrs/0002-org-chart-layout-engine.md b/.claude/adrs/0002-org-chart-layout-engine.md
new file mode 100644
index 00000000..04d52ba5
--- /dev/null
+++ b/.claude/adrs/0002-org-chart-layout-engine.md
@@ -0,0 +1,50 @@
+---
+schema_version: 1
+adr_id: "0002"
+title: org-chart-layout-engine
+status: Proposed
+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 搶主線程。
+- ⚠️ ~460 KB gzip:必須確保 worker chunk 只在該頁載入,不進主 bundle(spike 驗證)。
+- ⚠️ EPL-2.0:法務若否決 → 退路是 dagre(MIT),代價是放棄直角繞線與 Worker。
+- ⚠️ 虛線若參與 layered 分層,可能扯歪實線主幹(spike 驗證,見 charter 批次 A)。
+
+## 推翻條件
+
+- spike 任一 gate 不過(見 charter §6)→ 回到本 ADR 重評 dagre 或縮減需求。
+- 出現第二個「節點 + 連線」block(如流程圖)→ 另開 ADR 評估抽 `diagram-*` 原語。
diff --git a/.claude/charters/p33-org-chart.md b/.claude/charters/p33-org-chart.md
new file mode 100644
index 00000000..2031adfa
--- /dev/null
+++ b/.claude/charters/p33-org-chart.md
@@ -0,0 +1,130 @@
+---
+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`**——其他檔案 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`、`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 跨層虛線。
+
+- [ ] A1 虛線處理:比較「虛線一起參與 layered」vs「只用實線排版、虛線另算繞線」,截圖存證,選一種
+- [ ] A2 效能:300 / 1000 節點在 Worker 中 `layout()` 耗時(記錄數字)
+- [ ] A3 打包:`ng build` 後確認 elk 位於獨立 worker chunk,主 bundle 無 elk
+- [ ] A4 結論寫回 ADR-0002(Status → Accepted 或 Rejected)
+
+### 批次 B:`layout.ts`
+
+- [ ] 型別:`OrgNode { id, ... }`、`OrgEdge { id, source, target, kind: 'solid' | 'dotted' }`
+- [ ] 選項定案(依 A1);輸出 `{ nodes: {id,x,y,width,height}[], edges: {id,kind,points}[] }`
+- [ ] 循環偵測
+- [ ] unit test(sync bundled 版,小圖)
+
+### 批次 C:渲染殼
+
+- [ ] 卡片層(`@for` + 絕對定位)+ SVG 連線層(實線 / 虛線走 token)
+- [ ] loading / empty / error 三態
+
+### 批次 D:互動
+
+- [ ] 平移、縮放(滑鼠 + 觸控板)、fit-to-view、捲到指定節點
+
+### 批次 E:a11y
+
+- [ ] `tree` 側欄聯動:選取同步、焦點移到對應卡片
+- [ ] axe 掃描零違規
+
+### 批次 F:Registry + docs
+
+- [ ] `registry.json` `blocks[]`、`/blocks` 頁示範
+- [ ] docs 註明 EPL-2.0 與 ~460 KB(gzip)worker 體積
+
+### 批次 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 layout.ts # 必須無輸出
+grep -rn "d3-" registry/blocks/org-chart package.json # 必須無輸出
+```
+
+### 收尾驗證
+
+- [ ] `pnpm build` 後主 bundle 無 elk(A3 的方法重跑)
+- [ ] `pnpm test:e2e:docs`、`pnpm test:e2e:cli` 通過
+- [ ] §2 核心重點逐條勾選
diff --git a/TODOLIST.md b/TODOLIST.md
index 5ffbdfef..94ddaaf3 100644
--- a/TODOLIST.md
+++ b/TODOLIST.md
@@ -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)
+
+- [ ] 批次 A Spike(gate):虛線處理、300/1000 節點耗時、worker chunk 分離
+- [ ] 批次 B `layout.ts`
+- [ ] 批次 C 渲染殼
+- [ ] 批次 D 互動(平移縮放)
+- [ ] 批次 E a11y(tree 聯動)
+- [ ] 批次 F registry + docs
+- [ ] 批次 G E2E + changeset
+
+**成本**:中高。批次 A 決定可行性,先做;C–G 各自可拆 PR。
From ccad987bea50643843e1cb22f7e29aa6581102eb Mon Sep 17 00:00:00 2001
From: jack755051
Date: Thu, 1 Oct 2026 15:02:00 +0800
Subject: [PATCH 2/7] chore(p33): record org-chart spike results, add elkjs
Spike gate (batch A) passed: elk layered handles dual managers and
dotted lines, 1000 nodes lay out in ~170 ms in a worker, and elk ships
only in the worker chunk. Spike page removed; findings live in ADR-0002.
Co-Authored-By: Claude Opus 5.5
---
.claude/adrs/0002-org-chart-layout-engine.md | 51 ++++++++++++++++++--
.claude/charters/p33-org-chart.md | 21 +++++---
TODOLIST.md | 2 +-
package.json | 1 +
pnpm-lock.yaml | 8 +++
5 files changed, 71 insertions(+), 12 deletions(-)
diff --git a/.claude/adrs/0002-org-chart-layout-engine.md b/.claude/adrs/0002-org-chart-layout-engine.md
index 04d52ba5..378bf776 100644
--- a/.claude/adrs/0002-org-chart-layout-engine.md
+++ b/.claude/adrs/0002-org-chart-layout-engine.md
@@ -2,7 +2,7 @@
schema_version: 1
adr_id: "0002"
title: org-chart-layout-engine
-status: Proposed
+status: Accepted
date: 2026-10-01
deciders: [jack755051]
related: [.claude/charters/p33-org-chart.md]
@@ -40,9 +40,54 @@ related: [.claude/charters/p33-org-chart.md]
- ✅ 矩陣匯報、跨層虛線、直角連線一次到位;CLI 零改動。
- ✅ 排版在 Worker,不跟 Angular change detection 搶主線程。
-- ⚠️ ~460 KB gzip:必須確保 worker chunk 只在該頁載入,不進主 bundle(spike 驗證)。
+- ⚠️ worker chunk 1.45 MB raw / ~336 KB transfer:只在該頁載入,不進主 bundle(spike 已驗證)。
- ⚠️ EPL-2.0:法務若否決 → 退路是 dagre(MIT),代價是放棄直角繞線與 Worker。
-- ⚠️ 虛線若參與 layered 分層,可能扯歪實線主幹(spike 驗證,見 charter 批次 A)。
+- ⚠️ 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。
## 推翻條件
diff --git a/.claude/charters/p33-org-chart.md b/.claude/charters/p33-org-chart.md
index 2031adfa..ac68a524 100644
--- a/.claude/charters/p33-org-chart.md
+++ b/.claude/charters/p33-org-chart.md
@@ -23,7 +23,7 @@ related_adr: .claude/adrs/0002-org-chart-layout-engine.md
## 2. 核心重點(每批都要守)
-1. **elkjs 只在 `layout.ts`**——其他檔案 grep 不到 `elkjs`。
+1. **elkjs 只在 `layout.ts` 與 `org-chart.worker.ts`**——其他檔案 grep 不到 `elkjs`。
2. **演算法只用 `elk.layered`**,`elk.edgeRouting: ORTHOGONAL`。
3. **節點 HTML、連線 SVG**,同一座標系;不做純 SVG 圖。
4. **Worker 預設開**;elk 不得進主 bundle。
@@ -35,7 +35,7 @@ related_adr: .claude/adrs/0002-org-chart-layout-engine.md
### ✅ 可做
-- `registry/blocks/org-chart/`(`org-chart.component.ts`、`layout.ts`、`index.ts`)
+- `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 結束移除)
@@ -60,13 +60,18 @@ related_adr: .claude/adrs/0002-org-chart-layout-engine.md
docs app 暫時頁 + 假資料:≥1 位雙主管員工、≥1 條 CEO → 基層 PM 跨層虛線。
-- [ ] A1 虛線處理:比較「虛線一起參與 layered」vs「只用實線排版、虛線另算繞線」,截圖存證,選一種
-- [ ] A2 效能:300 / 1000 節點在 Worker 中 `layout()` 耗時(記錄數字)
-- [ ] A3 打包:`ng build` 後確認 elk 位於獨立 worker chunk,主 bundle 無 elk
-- [ ] A4 結論寫回 ADR-0002(Status → Accepted 或 Rejected)
+- [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 結果」。
+
+- [ ] 修正 mergeEdges 讓虛線起點被實線匯流排蓋住的問題
+- [ ] worker 檔隨 block 出貨(`org-chart.worker.ts`,內容僅 `import 'elkjs/lib/elk-worker.min.js'`)
+
- [ ] 型別:`OrgNode { id, ... }`、`OrgEdge { id, source, target, kind: 'solid' | 'dotted' }`
- [ ] 選項定案(依 A1);輸出 `{ nodes: {id,x,y,width,height}[], edges: {id,kind,points}[] }`
- [ ] 循環偵測
@@ -89,7 +94,7 @@ docs app 暫時頁 + 假資料:≥1 位雙主管員工、≥1 條 CEO → 基
### 批次 F:Registry + docs
- [ ] `registry.json` `blocks[]`、`/blocks` 頁示範
-- [ ] docs 註明 EPL-2.0 與 ~460 KB(gzip)worker 體積
+- [ ] docs 註明 EPL-2.0、worker 體積(~336 KB transfer)、`allowedCommonJsDependencies: ["elkjs"]`
### 批次 G:E2E + 發版
@@ -119,7 +124,7 @@ docs app 暫時頁 + 假資料:≥1 位雙主管員工、≥1 條 CEO → 基
```bash
pnpm lint
pnpm test
-grep -rln "elkjs" registry/blocks/org-chart | grep -v layout.ts # 必須無輸出
+grep -rln "elkjs" registry/blocks/org-chart | grep -v -e layout.ts -e worker.ts # 必須無輸出
grep -rn "d3-" registry/blocks/org-chart package.json # 必須無輸出
```
diff --git a/TODOLIST.md b/TODOLIST.md
index 94ddaaf3..713ddb29 100644
--- a/TODOLIST.md
+++ b/TODOLIST.md
@@ -91,7 +91,7 @@
- 選型決策:[ADR-0002](.claude/adrs/0002-org-chart-layout-engine.md)(elkjs `layered`,不用 d3、不開 primitive)
- 範圍 / 步驟 / 停止條件 / 驗證:[charter p33-org-chart](.claude/charters/p33-org-chart.md)
-- [ ] 批次 A Spike(gate):虛線處理、300/1000 節點耗時、worker chunk 分離
+- [x] 批次 A Spike(gate):全數通過,結果見 ADR-0002
- [ ] 批次 B `layout.ts`
- [ ] 批次 C 渲染殼
- [ ] 批次 D 互動(平移縮放)
diff --git a/package.json b/package.json
index 368c36b6..5e793fad 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
"@sanring/date-picker-core": "^0.5.0",
"@stackblitz/sdk": "^1.11.1",
"clsx": "^2.1.1",
+ "elkjs": "^0.12.0",
"embla-carousel": "^8.6.0",
"rxjs": "~7.8.0",
"shiki": "^4.2.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5e0d0a8f..96baa228 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -44,6 +44,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
+ elkjs:
+ specifier: ^0.12.0
+ version: 0.12.0
embla-carousel:
specifier: ^8.6.0
version: 8.6.0
@@ -2692,6 +2695,9 @@ packages:
electron-to-chromium@1.5.372:
resolution: {integrity: sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==}
+ elkjs@0.12.0:
+ resolution: {integrity: sha512-YZcKynxVxYoKIOEpywEPwCFdg+BTbxQRNf3pbwdDCvc8O3kQD8bmIwSxKU1eOTVc4Xo+VG9Te+575mlfvOrhEQ==}
+
embla-carousel@8.6.0:
resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==}
@@ -6693,6 +6699,8 @@ snapshots:
electron-to-chromium@1.5.372: {}
+ elkjs@0.12.0: {}
+
embla-carousel@8.6.0: {}
emoji-regex@10.6.0: {}
From c156d11affea2c378ca01833ad612f369261f384 Mon Sep 17 00:00:00 2001
From: jack755051
Date: Thu, 1 Oct 2026 15:10:18 +0800
Subject: [PATCH 3/7] feat(org-chart): add elk layout module for org-chart
block
layoutOrg() turns people + solid/dotted reporting lines into plain
coordinates via elk layered with orthogonal routing. Solid and dotted
edges use separate fixed ports so dotted lines never ride the solid
bus. Bad edges (unknown nodes, self-loops, solid reporting cycles) are
dropped with a warning. elk runs in a worker in apps; tests use the
bundled build.
Co-Authored-By: Claude Opus 5.5
---
.claude/charters/p33-org-chart.md | 13 +-
TODOLIST.md | 2 +-
.../app/pages/blocks/org-chart-layout.spec.ts | 147 ++++++++++++
apps/docs/tsconfig.spec.json | 1 +
registry/blocks/org-chart/index.ts | 1 +
registry/blocks/org-chart/layout.ts | 219 ++++++++++++++++++
registry/blocks/org-chart/org-chart.worker.ts | 5 +
7 files changed, 380 insertions(+), 8 deletions(-)
create mode 100644 apps/docs/src/app/pages/blocks/org-chart-layout.spec.ts
create mode 100644 registry/blocks/org-chart/index.ts
create mode 100644 registry/blocks/org-chart/layout.ts
create mode 100644 registry/blocks/org-chart/org-chart.worker.ts
diff --git a/.claude/charters/p33-org-chart.md b/.claude/charters/p33-org-chart.md
index ac68a524..61a34be2 100644
--- a/.claude/charters/p33-org-chart.md
+++ b/.claude/charters/p33-org-chart.md
@@ -69,13 +69,12 @@ docs app 暫時頁 + 假資料:≥1 位雙主管員工、≥1 條 CEO → 基
結果與選項定案見 ADR-0002「Spike 結果」。
-- [ ] 修正 mergeEdges 讓虛線起點被實線匯流排蓋住的問題
-- [ ] worker 檔隨 block 出貨(`org-chart.worker.ts`,內容僅 `import 'elkjs/lib/elk-worker.min.js'`)
-
-- [ ] 型別:`OrgNode { id, ... }`、`OrgEdge { id, source, target, kind: 'solid' | 'dotted' }`
-- [ ] 選項定案(依 A1);輸出 `{ nodes: {id,x,y,width,height}[], edges: {id,kind,points}[] }`
-- [ ] 循環偵測
-- [ ] unit test(sync bundled 版,小圖)
+- [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:渲染殼
diff --git a/TODOLIST.md b/TODOLIST.md
index 713ddb29..a777c64b 100644
--- a/TODOLIST.md
+++ b/TODOLIST.md
@@ -92,7 +92,7 @@
- 範圍 / 步驟 / 停止條件 / 驗證:[charter p33-org-chart](.claude/charters/p33-org-chart.md)
- [x] 批次 A Spike(gate):全數通過,結果見 ADR-0002
-- [ ] 批次 B `layout.ts`
+- [x] 批次 B `layout.ts`
- [ ] 批次 C 渲染殼
- [ ] 批次 D 互動(平移縮放)
- [ ] 批次 E a11y(tree 聯動)
diff --git a/apps/docs/src/app/pages/blocks/org-chart-layout.spec.ts b/apps/docs/src/app/pages/blocks/org-chart-layout.spec.ts
new file mode 100644
index 00000000..0d0fe09a
--- /dev/null
+++ b/apps/docs/src/app/pages/blocks/org-chart-layout.spec.ts
@@ -0,0 +1,147 @@
+// Lives in the docs app (not next to the block) because registry/ ships inside
+// the CLI tarball. Uses elk's bundled build: same engine, no worker.
+import ELK from 'elkjs/lib/elk.bundled.js';
+import {
+ layoutOrg,
+ orgEdgePath,
+ type OrgEdge,
+ type OrgLayout,
+ type OrgPoint,
+ type PositionedOrgNode,
+} from '../../../../../../registry/blocks/org-chart/layout';
+
+const engine = new ELK();
+const size = { nodeWidth: 180, nodeHeight: 64 };
+
+const s = (source: string, target: string): OrgEdge => ({ source, target, kind: 'solid' });
+const d = (source: string, target: string): OrgEdge => ({ source, target, kind: 'dotted' });
+
+// mia reports to eng2 and sal1; ceo has a dotted line to a junior pm two levels down.
+const people = [
+ 'ceo', 'cto', 'cfo', 'cso', 'eng1', 'eng2', 'fin1', 'sal1', 'sal2',
+ 'dev1', 'dev2', 'dev3', 'mia', 'acc1', 'rep1', 'rep2', 'pm',
+].map((id) => ({ id }));
+const links = [
+ s('ceo', 'cto'), s('ceo', 'cfo'), s('ceo', 'cso'),
+ s('cto', 'eng1'), s('cto', 'eng2'), s('cfo', 'fin1'), s('cso', 'sal1'), s('cso', 'sal2'),
+ s('eng1', 'dev1'), s('eng1', 'dev2'), s('eng2', 'dev3'), s('eng2', 'mia'), s('sal1', 'mia'),
+ s('fin1', 'acc1'), s('sal2', 'rep1'), s('sal2', 'rep2'), s('eng1', 'pm'),
+ d('ceo', 'pm'), d('fin1', 'rep2'),
+];
+
+type Segment = [OrgPoint, OrgPoint];
+const segments = (points: OrgPoint[]): Segment[] =>
+ points.slice(1).map((p, i) => [points[i], p] as Segment);
+
+function overlaps([a1, a2]: Segment, [b1, b2]: Segment): boolean {
+ const span = (p: number, q: number, r: number, t: number) =>
+ Math.min(Math.max(p, q), Math.max(r, t)) - Math.max(Math.min(p, q), Math.min(r, t));
+ if (a1.x === a2.x && b1.x === b2.x && a1.x === b1.x) return span(a1.y, a2.y, b1.y, b2.y) > 0.5;
+ if (a1.y === a2.y && b1.y === b2.y && a1.y === b1.y) return span(a1.x, a2.x, b1.x, b2.x) > 0.5;
+ return false;
+}
+
+function crossesCard([a, b]: Segment, n: PositionedOrgNode): boolean {
+ return (
+ Math.max(a.x, b.x) > n.x + 1 &&
+ Math.min(a.x, b.x) < n.x + n.width - 1 &&
+ Math.max(a.y, b.y) > n.y + 1 &&
+ Math.min(a.y, b.y) < n.y + n.height - 1
+ );
+}
+
+describe('layoutOrg', () => {
+ let layout: OrgLayout;
+ const node = (id: string) => layout.nodes.find((n) => n.id === id)!;
+
+ beforeAll(async () => {
+ layout = await layoutOrg(engine, people, links, size);
+ });
+
+ it('places every person and routes every edge', () => {
+ expect(layout.nodes).toHaveLength(people.length);
+ expect(layout.edges).toHaveLength(links.length);
+ expect(layout.dropped).toEqual([]);
+ expect(layout.width).toBeGreaterThan(0);
+ });
+
+ it('puts managers above reports, including both managers of a dual report', () => {
+ expect(node('cto').y).toBeGreaterThan(node('ceo').y);
+ expect(node('mia').y).toBeGreaterThan(node('eng2').y);
+ expect(node('mia').y).toBeGreaterThan(node('sal1').y);
+ });
+
+ it('keeps a dotted line from pulling its target out of its solid level', () => {
+ expect(node('pm').y).toBe(node('dev1').y);
+ });
+
+ it('draws one shared bus per manager', () => {
+ const starts = layout.edges
+ .filter((e) => e.kind === 'solid' && e.source === 'ceo')
+ .map((e) => `${e.points[0].x},${e.points[0].y}`);
+ expect(new Set(starts).size).toBe(1);
+ });
+
+ it('never lays a dotted line on top of a solid one', () => {
+ const solid = layout.edges.filter((e) => e.kind === 'solid').flatMap((e) => segments(e.points));
+ const dotted = layout.edges.filter((e) => e.kind === 'dotted').flatMap((e) => segments(e.points));
+ const hits = dotted.filter((a) => solid.some((b) => overlaps(a, b)));
+ expect(hits).toEqual([]);
+ });
+
+ it('routes no edge through a card', () => {
+ const hits = layout.edges.flatMap((e) =>
+ segments(e.points)
+ .filter((seg) => layout.nodes.some((n) => crossesCard(seg, n)))
+ .map(() => `${e.source}->${e.target}`),
+ );
+ expect(hits).toEqual([]);
+ });
+});
+
+describe('layoutOrg edge sanitising', () => {
+ beforeEach(() => vi.spyOn(console, 'warn').mockImplementation(() => undefined));
+ afterEach(() => vi.restoreAllMocks());
+
+ it('drops the solid edge that closes a reporting cycle and warns', async () => {
+ const result = await layoutOrg(
+ engine,
+ [{ id: 'a' }, { id: 'b' }, { id: 'c' }],
+ [s('a', 'b'), s('b', 'c'), s('c', 'a')],
+ size,
+ );
+ expect(result.dropped).toEqual([s('c', 'a')]);
+ expect(result.edges).toHaveLength(2);
+ expect(console.warn).toHaveBeenCalledOnce();
+ });
+
+ it('allows a dotted line that points back up the hierarchy', async () => {
+ const result = await layoutOrg(engine, [{ id: 'a' }, { id: 'b' }], [s('a', 'b'), d('b', 'a')], size);
+ expect(result.dropped).toEqual([]);
+ expect(result.edges).toHaveLength(2);
+ });
+
+ it('drops unknown endpoints and self-loops, and dedupes repeats', async () => {
+ const result = await layoutOrg(
+ engine,
+ [{ id: 'a' }, { id: 'b' }],
+ [{ source: 'a', target: 'b' }, s('a', 'b'), s('a', 'ghost'), s('b', 'b')],
+ size,
+ );
+ expect(result.edges).toHaveLength(1);
+ expect(result.edges[0].kind).toBe('solid');
+ expect(result.dropped).toEqual([s('a', 'ghost'), s('b', 'b')]);
+ });
+
+ it('lays out an empty org', async () => {
+ const result = await layoutOrg(engine, [], [], size);
+ expect(result.nodes).toEqual([]);
+ expect(result.edges).toEqual([]);
+ });
+});
+
+describe('orgEdgePath', () => {
+ it('builds an orthogonal SVG path', () => {
+ expect(orgEdgePath([{ x: 0, y: 0 }, { x: 0, y: 10 }, { x: 20, y: 10 }])).toBe('M0 0 L0 10 L20 10');
+ });
+});
diff --git a/apps/docs/tsconfig.spec.json b/apps/docs/tsconfig.spec.json
index 48fcc2fd..e6d05f76 100644
--- a/apps/docs/tsconfig.spec.json
+++ b/apps/docs/tsconfig.spec.json
@@ -4,6 +4,7 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "../../out-tsc/spec",
+ "rootDir": "../../",
"types": ["vitest/globals"]
},
"include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
diff --git a/registry/blocks/org-chart/index.ts b/registry/blocks/org-chart/index.ts
new file mode 100644
index 00000000..5d15fe1b
--- /dev/null
+++ b/registry/blocks/org-chart/index.ts
@@ -0,0 +1 @@
+export * from './layout';
diff --git a/registry/blocks/org-chart/layout.ts b/registry/blocks/org-chart/layout.ts
new file mode 100644
index 00000000..cc57c38d
--- /dev/null
+++ b/registry/blocks/org-chart/layout.ts
@@ -0,0 +1,219 @@
+// The only module (with org-chart.worker.ts) that knows about elkjs. Everything
+// it returns is plain coordinates, so swapping the engine only touches this file.
+import ELK, { type ElkExtendedEdge, type ElkNode, type ElkPort } from 'elkjs/lib/elk-api';
+
+export type OrgEdgeKind = 'solid' | 'dotted';
+
+export interface OrgNode {
+ id: string;
+}
+
+/** `solid` = line manager; `dotted` = matrix / dotted-line reporting. */
+export interface OrgEdge {
+ source: string;
+ target: string;
+ kind?: OrgEdgeKind;
+}
+
+export interface OrgLayoutOptions {
+ nodeWidth: number;
+ nodeHeight: number;
+ /** Horizontal gap between siblings. */
+ nodeGap?: number;
+ /** Vertical gap between levels. */
+ levelGap?: number;
+}
+
+export interface OrgPoint {
+ x: number;
+ y: number;
+}
+
+export interface PositionedOrgNode {
+ id: string;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+export interface RoutedOrgEdge {
+ id: string;
+ source: string;
+ target: string;
+ kind: OrgEdgeKind;
+ points: OrgPoint[];
+}
+
+export interface OrgLayout {
+ width: number;
+ height: number;
+ nodes: PositionedOrgNode[];
+ edges: RoutedOrgEdge[];
+ /** Edges left out of the layout: unknown endpoints, self-loops, or solid reporting cycles. */
+ dropped: OrgEdge[];
+}
+
+/** Anything with elk's `layout()` — the worker-backed engine in the app, the bundled one in tests. */
+export interface OrgLayoutEngine {
+ layout(graph: ElkNode): Promise;
+}
+
+/** Runs elk in a Web Worker so large charts never block change detection. */
+export function createOrgLayoutEngine(): OrgLayoutEngine {
+ return new ELK({
+ workerFactory: () =>
+ new Worker(new URL('./org-chart.worker', import.meta.url), { type: 'module' }),
+ });
+}
+
+// Solid and dotted edges leave / enter through separate ports. With one shared
+// port (or `mergeEdges`), elk routes dotted lines along the solid bus, so the
+// first stretch of a dotted line is drawn on top of a solid one.
+const DOTTED_PORT_OFFSET = 24;
+
+const LAYOUT_OPTIONS: Record = {
+ 'elk.algorithm': 'layered',
+ 'elk.direction': 'DOWN',
+ 'elk.edgeRouting': 'ORTHOGONAL',
+ 'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF',
+ 'elk.layered.nodePlacement.bk.fixedAlignment': 'BALANCED',
+ 'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
+};
+
+export async function layoutOrg(
+ engine: OrgLayoutEngine,
+ nodes: readonly OrgNode[],
+ edges: readonly OrgEdge[],
+ options: OrgLayoutOptions,
+): Promise {
+ const { nodeWidth: w, nodeHeight: h, nodeGap = 24, levelGap = 48 } = options;
+ const { kept, dropped } = sanitizeEdges(nodes, edges);
+ if (dropped.length > 0) {
+ console.warn('[org-chart] dropped edges (unknown node, self-loop, or reporting cycle):', dropped);
+ }
+
+ const port = (node: string, name: string, side: 'NORTH' | 'SOUTH', x: number): ElkPort => ({
+ id: `${node}:${name}`,
+ width: 1,
+ height: 1,
+ x,
+ y: side === 'NORTH' ? -1 : h,
+ layoutOptions: { 'elk.port.side': side },
+ });
+
+ const graph: ElkNode = {
+ id: 'root',
+ layoutOptions: {
+ ...LAYOUT_OPTIONS,
+ 'elk.spacing.nodeNode': String(nodeGap),
+ 'elk.layered.spacing.nodeNodeBetweenLayers': String(levelGap),
+ },
+ children: nodes.map((node) => ({
+ id: node.id,
+ width: w,
+ height: h,
+ layoutOptions: { 'elk.portConstraints': 'FIXED_POS' },
+ ports: [
+ port(node.id, 'in', 'NORTH', w / 2),
+ port(node.id, 'out', 'SOUTH', w / 2),
+ port(node.id, 'dotted-in', 'NORTH', w / 2 + DOTTED_PORT_OFFSET),
+ port(node.id, 'dotted-out', 'SOUTH', w / 2 + DOTTED_PORT_OFFSET),
+ ],
+ })),
+ edges: kept.map((edge) => {
+ const dotted = edge.kind === 'dotted';
+ return {
+ id: edge.id,
+ sources: [`${edge.source}:${dotted ? 'dotted-out' : 'out'}`],
+ targets: [`${edge.target}:${dotted ? 'dotted-in' : 'in'}`],
+ };
+ }),
+ };
+
+ const result = await engine.layout(graph);
+ const byId = new Map(kept.map((edge) => [edge.id, edge]));
+
+ return {
+ width: result.width ?? 0,
+ height: result.height ?? 0,
+ nodes: (result.children ?? []).map((child) => ({
+ id: child.id,
+ x: child.x ?? 0,
+ y: child.y ?? 0,
+ width: child.width ?? w,
+ height: child.height ?? h,
+ })),
+ edges: (result.edges ?? []).flatMap((edge) => {
+ const source = byId.get(edge.id);
+ const section = edge.sections?.[0];
+ if (!source || !section) return [];
+ return [
+ {
+ id: edge.id,
+ source: source.source,
+ target: source.target,
+ kind: source.kind,
+ points: [section.startPoint, ...(section.bendPoints ?? []), section.endPoint],
+ },
+ ];
+ }),
+ dropped,
+ };
+}
+
+/** SVG `d` for an orthogonal polyline. */
+export function orgEdgePath(points: readonly OrgPoint[]): string {
+ return points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x} ${p.y}`).join(' ');
+}
+
+type KeptEdge = Required & { id: string };
+
+function sanitizeEdges(
+ nodes: readonly OrgNode[],
+ edges: readonly OrgEdge[],
+): { kept: KeptEdge[]; dropped: OrgEdge[] } {
+ const ids = new Set(nodes.map((node) => node.id));
+ const dropped: OrgEdge[] = [];
+ const seen = new Set();
+ const candidates: KeptEdge[] = [];
+
+ for (const edge of edges) {
+ const kind = edge.kind ?? 'solid';
+ const id = `${kind}:${edge.source}->${edge.target}`;
+ if (seen.has(id)) continue;
+ if (!ids.has(edge.source) || !ids.has(edge.target) || edge.source === edge.target) {
+ dropped.push(edge);
+ continue;
+ }
+ seen.add(id);
+ candidates.push({ ...edge, kind, id });
+ }
+
+ // A cycle in line management is bad data (A manages B manages A). Drop the
+ // solid edge that closes each cycle; dotted lines may point anywhere.
+ const children = new Map();
+ for (const edge of candidates) {
+ if (edge.kind !== 'solid') continue;
+ children.set(edge.source, [...(children.get(edge.source) ?? []), edge]);
+ }
+ const state = new Map();
+ const backEdges = new Set();
+ const visit = (id: string) => {
+ state.set(id, 'visiting');
+ for (const edge of children.get(id) ?? []) {
+ const next = state.get(edge.target);
+ if (next === 'visiting') backEdges.add(edge.id);
+ else if (next === undefined) visit(edge.target);
+ }
+ state.set(id, 'done');
+ };
+ for (const node of nodes) if (!state.has(node.id)) visit(node.id);
+
+ const kept: KeptEdge[] = [];
+ for (const edge of candidates) {
+ if (backEdges.has(edge.id)) dropped.push({ source: edge.source, target: edge.target, kind: edge.kind });
+ else kept.push(edge);
+ }
+ return { kept, dropped };
+}
diff --git a/registry/blocks/org-chart/org-chart.worker.ts b/registry/blocks/org-chart/org-chart.worker.ts
new file mode 100644
index 00000000..e4a1e562
--- /dev/null
+++ b/registry/blocks/org-chart/org-chart.worker.ts
@@ -0,0 +1,5 @@
+///
+// Inside a worker scope elk-worker installs `self.onmessage` and speaks the
+// elk-api protocol used by createOrgLayoutEngine(). Do not swap this for
+// elk.bundled.js — that build spawns its own nested worker and fails here.
+import 'elkjs/lib/elk-worker.min.js';
From 68f6b970061e9802af872aed1762da8563379640 Mon Sep 17 00:00:00 2001
From: jack755051
Date: Thu, 1 Oct 2026 15:20:16 +0800
Subject: [PATCH 4/7] fix(registry): context-menu shared import path,
table-page menu item value
Both broke compilation in a consumer app after `sanring add`. Found by
type-checking the registry in its installed layout.
Co-Authored-By: Claude Opus 5.5
---
.changeset/registry-compile-fixes.md | 5 +++++
registry/blocks/table-page/table-page.component.ts | 2 +-
registry/components/context-menu/context-menu.component.ts | 2 +-
3 files changed, 7 insertions(+), 2 deletions(-)
create mode 100644 .changeset/registry-compile-fixes.md
diff --git a/.changeset/registry-compile-fixes.md b/.changeset/registry-compile-fixes.md
new file mode 100644
index 00000000..aaa18c44
--- /dev/null
+++ b/.changeset/registry-compile-fixes.md
@@ -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.
diff --git a/registry/blocks/table-page/table-page.component.ts b/registry/blocks/table-page/table-page.component.ts
index d218eab3..5855ca1f 100644
--- a/registry/blocks/table-page/table-page.component.ts
+++ b/registry/blocks/table-page/table-page.component.ts
@@ -180,7 +180,7 @@ const SEED: TablePageRow[] = [
-
+
+
+
+ {{ i18n.t('blocks.org.body') }}
+
+
+
+
+
+
+
+
`,
})
@@ -190,14 +209,54 @@ export class BlocksPageComponent {
{ id: 'dashboard-shell', titleKey: 'blocks.dashboard.title' },
{ id: 'login', titleKey: 'blocks.login.title' },
{ id: 'table-page', titleKey: 'blocks.table.title' },
+ { id: 'org-chart', titleKey: 'blocks.org.title' },
];
protected readonly installAll = `npx @sanring/cli add block/dashboard-shell
npx @sanring/cli add block/login
-npx @sanring/cli add block/table-page`;
+npx @sanring/cli add block/table-page
+npx @sanring/cli add block/org-chart`;
protected readonly installDashboard = `npx @sanring/cli add block/dashboard-shell`;
protected readonly installLogin = `npx @sanring/cli add block/login`;
protected readonly installTable = `npx @sanring/cli add block/table-page`;
+ protected readonly installOrg = `npx @sanring/cli add block/org-chart`;
+ protected readonly orgSnippet = ``;
+ protected readonly orgSelected = signal(null);
+ protected readonly orgPeople: OrgPerson[] = [
+ { id: 'ada', name: 'Ada Lin', title: 'CEO' },
+ { id: 'ben', name: 'Ben Ho', title: 'CTO', department: 'Eng' },
+ { id: 'cora', name: 'Cora Wu', title: 'CFO', department: 'Finance' },
+ { id: 'dan', name: 'Dan Chen', title: 'VP Sales', department: 'Sales' },
+ { id: 'eve', name: 'Eve Tsai', title: 'Eng Manager', department: 'Eng' },
+ { id: 'finn', name: 'Finn Kao', title: 'Eng Manager', department: 'Eng' },
+ { id: 'gus', name: 'Gus Lee', title: 'Controller', department: 'Finance' },
+ { id: 'hana', name: 'Hana Su', title: 'Sales Lead', department: 'Sales' },
+ { id: 'jo', name: 'Jo Yang', title: 'Engineer', department: 'Eng' },
+ { id: 'kai', name: 'Kai Lu', title: 'Engineer', department: 'Eng' },
+ { id: 'mia', name: 'Mia Hsu', title: 'Solutions Eng', department: 'Eng' },
+ { id: 'nia', name: 'Nia Lo', title: 'Accountant', department: 'Finance' },
+ { id: 'oto', name: 'Oto Pan', title: 'Account Exec', department: 'Sales' },
+ { id: 'quin', name: 'Quin Fang', title: 'Junior PM', department: 'Eng' },
+ ];
+ protected readonly orgLinks: OrgLink[] = [
+ { source: 'ada', target: 'ben' },
+ { source: 'ada', target: 'cora' },
+ { source: 'ada', target: 'dan' },
+ { source: 'ben', target: 'eve' },
+ { source: 'ben', target: 'finn' },
+ { source: 'cora', target: 'gus' },
+ { source: 'dan', target: 'hana' },
+ { source: 'eve', target: 'jo' },
+ { source: 'eve', target: 'quin' },
+ { source: 'finn', target: 'kai' },
+ // Mia reports to both Finn and Hana.
+ { source: 'finn', target: 'mia' },
+ { source: 'hana', target: 'mia' },
+ { source: 'gus', target: 'nia' },
+ { source: 'hana', target: 'oto' },
+ // The CEO sponsors Quin's project directly.
+ { source: 'ada', target: 'quin', kind: 'dotted' },
+ ];
protected readonly dashboardSnippet = `
`;
diff --git a/registry/blocks/org-chart/index.ts b/registry/blocks/org-chart/index.ts
index 5d15fe1b..8c6b00eb 100644
--- a/registry/blocks/org-chart/index.ts
+++ b/registry/blocks/org-chart/index.ts
@@ -1 +1,2 @@
export * from './layout';
+export * from './org-chart.component';
diff --git a/registry/blocks/org-chart/org-chart.component.ts b/registry/blocks/org-chart/org-chart.component.ts
new file mode 100644
index 00000000..a2c7ac9d
--- /dev/null
+++ b/registry/blocks/org-chart/org-chart.component.ts
@@ -0,0 +1,166 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ DestroyRef,
+ computed,
+ inject,
+ input,
+ linkedSignal,
+ model,
+ resource,
+} from '@angular/core';
+import { SANRING_AVATAR_IMPORTS } from '../avatar';
+import { BadgeDirective } from '../badge';
+import { SkeletonDirective } from '../skeleton';
+import {
+ createOrgLayoutEngine,
+ layoutOrg,
+ orgEdgePath,
+ type OrgEdge,
+ type OrgLayout,
+ type OrgLayoutEngine,
+} from './layout';
+
+export interface OrgPerson {
+ id: string;
+ name: string;
+ title?: string;
+ department?: string;
+ avatarUrl?: string;
+}
+
+/** `solid` = line manager, `dotted` = matrix / dotted-line reporting. */
+export type OrgLink = OrgEdge;
+
+@Component({
+ selector: 'sanring-org-chart',
+ standalone: true,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [SANRING_AVATAR_IMPORTS, BadgeDirective, SkeletonDirective],
+ host: {
+ class:
+ 'block overflow-auto rounded-[var(--sanring-radius-lg)] border border-[var(--sanring-border)] bg-[var(--sanring-background)]',
+ },
+ template: `
+ @if (layout.error()) {
+ Could not lay out the org chart.
+ } @else if (people().length === 0) {
+ No people to show.
+ } @else if (chart(); as c) {
+
+
+ @for (node of c.nodes; track node.id) {
+
+
+ @if (node.person.avatarUrl) {
+
+ }
+ {{ initials(node.person.name) }}
+
+
+ {{ node.person.name }}
+ @if (node.person.title) {
+ {{ node.person.title }}
+ }
+
+ @if (node.person.department) {
+ {{ node.person.department }}
+ }
+
+ }
+
+ } @else {
+
+ }
+ `,
+})
+export class OrgChartComponent {
+ readonly people = input.required();
+ readonly links = input.required();
+ readonly nodeWidth = input(240);
+ readonly nodeHeight = input(64);
+ /** Id of the highlighted person. */
+ readonly selected = model(null);
+
+ private engine?: OrgLayoutEngine & { terminateWorker?: () => void };
+
+ protected readonly layout = resource({
+ params: () => ({
+ people: this.people(),
+ links: this.links(),
+ nodeWidth: this.nodeWidth(),
+ nodeHeight: this.nodeHeight(),
+ }),
+ loader: ({ params }) => {
+ this.engine ??= createOrgLayoutEngine();
+ return layoutOrg(this.engine, params.people, params.links, params);
+ },
+ });
+
+ // Keep showing the previous layout while a new one is computed, so editing
+ // the data does not flash the loading skeleton.
+ private readonly lastLayout = linkedSignal({
+ source: () => this.layout.value(),
+ computation: (next, previous) => next ?? previous?.value,
+ });
+
+ protected readonly chart = computed(() => {
+ const layout = this.lastLayout();
+ if (!layout) return undefined;
+ const byId = new Map(this.people().map((person) => [person.id, person]));
+ return {
+ width: layout.width,
+ height: layout.height,
+ nodes: layout.nodes.flatMap((node) => {
+ const person = byId.get(node.id);
+ return person ? [{ ...node, person }] : [];
+ }),
+ edges: layout.edges.map((edge) => ({ id: edge.id, kind: edge.kind, d: orgEdgePath(edge.points) })),
+ };
+ });
+
+ constructor() {
+ inject(DestroyRef).onDestroy(() => this.engine?.terminateWorker?.());
+ }
+
+ protected initials(name: string): string {
+ return name
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((part) => part[0]!.toUpperCase())
+ .join('');
+ }
+}
From a9bca237dfd2c90137018871a90243dc635cc976 Mon Sep 17 00:00:00 2001
From: jack755051
Date: Fri, 2 Oct 2026 16:50:12 +0800
Subject: [PATCH 7/7] feat(docs): add Blocks section to sidebar and mobile nav
Surface block templates (including org-chart) with fragment links so the docs shell matches the /blocks page.
Co-authored-by: Cursor
---
.../src/app/navigation/docs-navigation.ts | 44 +++++++++++++++++++
.../shell/header/feature-list.component.ts | 14 +++++-
.../sidebar/docs-blocks-list.component.ts | 18 ++++++++
.../shell/sidebar/docs-section.component.ts | 32 ++++++++++++--
.../shell/sidebar/docs-sidebar.component.ts | 4 +-
5 files changed, 106 insertions(+), 6 deletions(-)
create mode 100644 apps/docs/src/app/shell/sidebar/docs-blocks-list.component.ts
diff --git a/apps/docs/src/app/navigation/docs-navigation.ts b/apps/docs/src/app/navigation/docs-navigation.ts
index a6ad8c3b..6a11472e 100644
--- a/apps/docs/src/app/navigation/docs-navigation.ts
+++ b/apps/docs/src/app/navigation/docs-navigation.ts
@@ -16,6 +16,7 @@ export const docsComponentStatusDotClass: Record =
export interface DocsSidebarItem {
labelKey: TranslationKey;
path?: string;
+ fragment?: string;
exact?: boolean;
active?: boolean;
badge?: boolean;
@@ -99,6 +100,49 @@ export const docsSectionItems: DocsSidebarItem[] = [
{ labelKey: 'sidebar.roadmap', path: '/roadmap', active: true },
];
+export interface DocsBlockNavItem extends DocsSidebarItem {
+ id: string;
+ path: '/blocks';
+ fragment: string;
+ descriptionKey: TranslationKey;
+}
+
+export const docsBlockItems: DocsBlockNavItem[] = [
+ {
+ id: 'dashboard-shell',
+ labelKey: 'blocks.dashboard.title',
+ descriptionKey: 'blocks.dashboard.body',
+ path: '/blocks',
+ fragment: 'dashboard-shell',
+ active: true,
+ },
+ {
+ id: 'login',
+ labelKey: 'blocks.login.title',
+ descriptionKey: 'blocks.login.body',
+ path: '/blocks',
+ fragment: 'login',
+ active: true,
+ },
+ {
+ id: 'table-page',
+ labelKey: 'blocks.table.title',
+ descriptionKey: 'blocks.table.body',
+ path: '/blocks',
+ fragment: 'table-page',
+ active: true,
+ },
+ {
+ id: 'org-chart',
+ labelKey: 'blocks.org.title',
+ descriptionKey: 'blocks.org.body',
+ path: '/blocks',
+ fragment: 'org-chart',
+ active: true,
+ badge: true,
+ },
+];
+
export const docsComponentItems: DocsComponentNavItem[] = [
{
id: 'accordion',
diff --git a/apps/docs/src/app/shell/header/feature-list.component.ts b/apps/docs/src/app/shell/header/feature-list.component.ts
index efa02e7e..9508fb4b 100644
--- a/apps/docs/src/app/shell/header/feature-list.component.ts
+++ b/apps/docs/src/app/shell/header/feature-list.component.ts
@@ -4,11 +4,13 @@ import { LucideMenu, LucideMonitor, LucideMoon, LucideSearch, LucideSun } from '
import { CommandDialogComponent, SANRING_COMMAND_IMPORTS, SANRING_SHEET_IMPORTS } from '@sanring/ui';
import { I18nService } from '../../i18n/i18n.service';
import {
+ docsBlockItems,
docsComponentItems,
docsSectionItems,
visibleDocsComponentItems,
} from '../../navigation/docs-navigation';
import { isRecentlyUpdatedComponentId } from '../../pages/changelog/component-changelog';
+import { DocsBlocksListComponent } from '../sidebar/docs-blocks-list.component';
import { DocsComponentsListComponent } from '../sidebar/docs-components-list.component';
import { DocsSectionComponent } from '../sidebar/docs-section.component';
import { DocsNavStateService } from '../docs-nav-state.service';
@@ -33,6 +35,7 @@ const MAX_SEARCH_RESULTS = 8;
imports: [
HeaderActionButtonComponent,
RouterLink,
+ DocsBlocksListComponent,
DocsComponentsListComponent,
DocsSectionComponent,
LucideMenu,
@@ -88,6 +91,8 @@ const MAX_SEARCH_RESULTS = 8;
[items]="mobileNavigationItems"
/>
+
+
@if (navState.hasSidebar()) {
} @else {
@@ -242,6 +247,13 @@ export class FeatureListComponent {
const sectionItems = docsSectionItems
.filter((item): item is typeof item & { path: string } => !!item.path && !item.disabled)
.map((item) => ({ label: this.i18n.t(item.labelKey), path: item.path }));
+ const blockItems = docsBlockItems
+ .filter((item) => !item.disabled)
+ .map((item) => ({
+ label: this.i18n.t(item.labelKey),
+ description: this.i18n.t(item.descriptionKey),
+ path: `${item.path}#${item.fragment}`,
+ }));
const componentItems = docsComponentItems
.filter((item) => !item.disabled)
.map((item) => ({
@@ -250,7 +262,7 @@ export class FeatureListComponent {
path: item.path,
}));
- return [...sectionItems, ...componentItems];
+ return [...sectionItems, ...blockItems, ...componentItems];
});
// 空字串時直接瀏覽全部項目(Command Dialog 是完整 modal,讓使用者不用打字也能瀏覽比較合理);
diff --git a/apps/docs/src/app/shell/sidebar/docs-blocks-list.component.ts b/apps/docs/src/app/shell/sidebar/docs-blocks-list.component.ts
new file mode 100644
index 00000000..0ed1537f
--- /dev/null
+++ b/apps/docs/src/app/shell/sidebar/docs-blocks-list.component.ts
@@ -0,0 +1,18 @@
+import { Component, inject, Input } from '@angular/core';
+import { I18nService } from '../../i18n/i18n.service';
+import { docsBlockItems } from '../../navigation/docs-navigation';
+import { DocsSectionComponent } from './docs-section.component';
+
+@Component({
+ selector: 'app-docs-blocks-list',
+ imports: [DocsSectionComponent],
+ template: `
+
+ `,
+})
+export class DocsBlocksListComponent {
+ @Input() sectionClass = 'mt-11';
+
+ protected readonly items = docsBlockItems;
+ protected readonly i18n = inject(I18nService);
+}
diff --git a/apps/docs/src/app/shell/sidebar/docs-section.component.ts b/apps/docs/src/app/shell/sidebar/docs-section.component.ts
index d8926014..acbf7c4c 100644
--- a/apps/docs/src/app/shell/sidebar/docs-section.component.ts
+++ b/apps/docs/src/app/shell/sidebar/docs-section.component.ts
@@ -1,5 +1,7 @@
import { Component, inject, Input } from '@angular/core';
-import { RouterLink, RouterLinkActive } from '@angular/router';
+import { toSignal } from '@angular/core/rxjs-interop';
+import { NavigationEnd, Router, RouterLink } from '@angular/router';
+import { filter, map, startWith } from 'rxjs';
import {
docsComponentStatusBadgeKeys,
docsComponentStatusDotClass,
@@ -9,7 +11,7 @@ import { I18nService } from '../../i18n/i18n.service';
@Component({
selector: 'app-docs-section',
- imports: [RouterLink, RouterLinkActive],
+ imports: [RouterLink],
template: `
{{ title }}
@@ -22,9 +24,9 @@ import { I18nService } from '../../i18n/i18n.service';
} @else {
{{ i18n.t(item.labelKey) }}
@if (item.badge) {
@@ -56,4 +58,26 @@ export class DocsSectionComponent {
protected readonly i18n = inject(I18nService);
protected readonly statusBadgeKeys = docsComponentStatusBadgeKeys;
protected readonly statusDotClass = docsComponentStatusDotClass;
+
+ private readonly router = inject(Router);
+ private readonly url = toSignal(
+ this.router.events.pipe(
+ filter((event): event is NavigationEnd => event instanceof NavigationEnd),
+ map(() => this.router.url),
+ startWith(this.router.url),
+ ),
+ { initialValue: this.router.url },
+ );
+
+ protected isActive(item: DocsSidebarItem): boolean {
+ if (!item.path || !item.active) return false;
+ const tree = this.router.parseUrl(this.url());
+ const currentPath = '/' + (tree.root.children['primary']?.segments.map((segment) => segment.path).join('/') ?? '');
+ const pathMatches = item.exact
+ ? currentPath === item.path
+ : currentPath === item.path || currentPath.startsWith(`${item.path}/`);
+ if (!pathMatches) return false;
+ if (item.fragment) return tree.fragment === item.fragment;
+ return true;
+ }
}
diff --git a/apps/docs/src/app/shell/sidebar/docs-sidebar.component.ts b/apps/docs/src/app/shell/sidebar/docs-sidebar.component.ts
index fbdc8604..12fd5710 100644
--- a/apps/docs/src/app/shell/sidebar/docs-sidebar.component.ts
+++ b/apps/docs/src/app/shell/sidebar/docs-sidebar.component.ts
@@ -1,16 +1,18 @@
import { Component, DestroyRef, inject, OnInit } from '@angular/core';
import { DocsNavStateService } from '../docs-nav-state.service';
+import { DocsBlocksListComponent } from './docs-blocks-list.component';
import { DocsComponentsListComponent } from './docs-components-list.component';
import { DocsSectionsListComponent } from './docs-sections-list.component';
@Component({
selector: 'app-docs-sidebar',
- imports: [DocsSectionsListComponent, DocsComponentsListComponent],
+ imports: [DocsSectionsListComponent, DocsBlocksListComponent, DocsComponentsListComponent],
template: `
`,