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/.claude/adrs/0002-org-chart-layout-engine.md b/.claude/adrs/0002-org-chart-layout-engine.md new file mode 100644 index 00000000..378bf776 --- /dev/null +++ b/.claude/adrs/0002-org-chart-layout-engine.md @@ -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-*` 原語。 diff --git a/.claude/charters/p33-org-chart.md b/.claude/charters/p33-org-chart.md new file mode 100644 index 00000000..cde08b00 --- /dev/null +++ b/.claude/charters/p33-org-chart.md @@ -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` + 絕對定位,卡片是 ` + } + + } @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(''); + } +} 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'; 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[] = [ - diff --git a/registry/components/context-menu/context-menu.component.ts b/registry/components/context-menu/context-menu.component.ts index a85a431a..1d48b2af 100644 --- a/registry/components/context-menu/context-menu.component.ts +++ b/registry/components/context-menu/context-menu.component.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, Component, model, output, signal } from '@angular/core'; -import { focusAdjacentDocumentTabStop } from '../../shared/menu-navigation'; +import { focusAdjacentDocumentTabStop } from '../shared/menu-navigation'; export interface ContextMenuPosition { x: number;