w-flowchart 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/.editorconfig +9 -0
  2. package/.eslintignore +3 -0
  3. package/.eslintrc.js +55 -0
  4. package/.jsdoc +25 -0
  5. package/LICENSE +21 -0
  6. package/README.md +49 -0
  7. package/SECURITY.md +5 -0
  8. package/babel.config.js +16 -0
  9. package/dist/w-flowchart.umd.js +7 -0
  10. package/dist/w-flowchart.umd.js.map +1 -0
  11. package/docs/fonts/Montserrat/Montserrat-Bold.eot +0 -0
  12. package/docs/fonts/Montserrat/Montserrat-Bold.ttf +0 -0
  13. package/docs/fonts/Montserrat/Montserrat-Bold.woff +0 -0
  14. package/docs/fonts/Montserrat/Montserrat-Bold.woff2 +0 -0
  15. package/docs/fonts/Montserrat/Montserrat-Regular.eot +0 -0
  16. package/docs/fonts/Montserrat/Montserrat-Regular.ttf +0 -0
  17. package/docs/fonts/Montserrat/Montserrat-Regular.woff +0 -0
  18. package/docs/fonts/Montserrat/Montserrat-Regular.woff2 +0 -0
  19. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.eot +0 -0
  20. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.svg +978 -0
  21. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.ttf +0 -0
  22. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff +0 -0
  23. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff2 +0 -0
  24. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.eot +0 -0
  25. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.svg +1049 -0
  26. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.ttf +0 -0
  27. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff +0 -0
  28. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff2 +0 -0
  29. package/docs/index.html +84 -0
  30. package/docs/scripts/collapse.js +39 -0
  31. package/docs/scripts/commonNav.js +28 -0
  32. package/docs/scripts/linenumber.js +25 -0
  33. package/docs/scripts/nav.js +12 -0
  34. package/docs/scripts/polyfill.js +4 -0
  35. package/docs/scripts/prettify/Apache-License-2.0.txt +202 -0
  36. package/docs/scripts/prettify/lang-css.js +2 -0
  37. package/docs/scripts/prettify/prettify.js +28 -0
  38. package/docs/scripts/search.js +99 -0
  39. package/docs/styles/jsdoc.css +776 -0
  40. package/docs/styles/prettify.css +80 -0
  41. package/package.json +49 -0
  42. package/script.txt +17 -0
  43. package/src/WFlowchart.mjs +0 -0
  44. package/src/common/derive.mjs +7 -0
  45. package/src/common/palette.mjs +34 -0
  46. package/src/common/pkg.mjs +31 -0
  47. package/src/p1/README.md +65 -0
  48. package/src/p1/gen.mjs +203 -0
  49. package/src/p10/README.md +31 -0
  50. package/src/p10/gen.mjs +228 -0
  51. package/src/p2/README.md +87 -0
  52. package/src/p2/gen.mjs +210 -0
  53. package/src/p3/README.md +99 -0
  54. package/src/p3/gen.mjs +188 -0
  55. package/src/p4/README.md +84 -0
  56. package/src/p4/gen.mjs +82 -0
  57. package/src/p5/README.md +90 -0
  58. package/src/p5/gen.mjs +182 -0
  59. package/src/p6/README.md +87 -0
  60. package/src/p6/gen.mjs +186 -0
  61. package/src/p7/README.md +84 -0
  62. package/src/p7/gen.mjs +386 -0
  63. package/src/p8/README.md +92 -0
  64. package/src/p8/gen.mjs +416 -0
  65. package/src/p9/README.md +90 -0
  66. package/src/p9/gen.mjs +63 -0
  67. package/src/p9/page9.html +420 -0
  68. package/test/cases.mjs +349 -0
  69. package/test/data//344/275/234/346/245/255/346/265/201/347/250/213/345/234/226.json +92 -0
  70. package/test/data//345/211/215/347/253/257/346/236/266/346/247/213/350/210/207/350/263/207/346/226/231/346/265/201/345/234/226.json +112 -0
  71. package/test/data//345/276/214/347/253/257/346/234/215/345/213/231/347/265/204/346/210/220/345/234/226.json +116 -0
  72. package/test/data//346/211/271/346/254/241/344/270/212/345/202/263/350/231/225/347/220/206/346/265/201/347/250/213/345/234/226.json +251 -0
  73. package/test/data//347/250/213/345/274/217/345/205/203/344/273/266/347/265/204/346/210/220/345/234/226.json +126 -0
  74. package/test/data//347/250/213/345/274/217/345/210/206/345/261/244/346/236/266/346/247/213/345/234/226.json +133 -0
  75. package/test/data//347/263/273/347/265/261/346/236/266/346/247/213/345/234/226.json +102 -0
  76. package/test/data//347/263/273/347/265/261/351/227/234/350/201/257/345/234/226.json +117 -0
  77. package/test/data//347/267/232/344/270/212/347/225/253/351/235/242/351/227/234/350/201/257/345/234/226.json +162 -0
  78. package/test/figures.mjs +10 -0
  79. package/test/lib.mjs +116 -0
  80. package/test/p10.test.mjs +89 -0
  81. package/test/pics/cycle.png +0 -0
  82. package/test/pics/cycle.svg +1 -0
  83. package/test/pics/deep-nest.png +0 -0
  84. package/test/pics/deep-nest.svg +1 -0
  85. package/test/pics/diamond.png +0 -0
  86. package/test/pics/diamond.svg +1 -0
  87. package/test/pics/edges-mix.png +0 -0
  88. package/test/pics/edges-mix.svg +1 -0
  89. package/test/pics/english.png +0 -0
  90. package/test/pics/english.svg +1 -0
  91. package/test/pics/flat-tb.png +0 -0
  92. package/test/pics/flat-tb.svg +1 -0
  93. package/test/pics/fold-tall.png +0 -0
  94. package/test/pics/fold-tall.svg +1 -0
  95. package/test/pics/fold-wide.png +0 -0
  96. package/test/pics/fold-wide.svg +1 -0
  97. package/test/pics/group-edge.png +0 -0
  98. package/test/pics/group-edge.svg +1 -0
  99. package/test/pics/items-node.png +0 -0
  100. package/test/pics/items-node.svg +1 -0
  101. package/test/pics/long-label.png +0 -0
  102. package/test/pics/long-label.svg +1 -0
  103. package/test/pics/lr-flat.png +0 -0
  104. package/test/pics/lr-flat.svg +1 -0
  105. package/test/pics/nested-group.png +0 -0
  106. package/test/pics/nested-group.svg +1 -0
  107. package/test/pics/no-edges.png +0 -0
  108. package/test/pics/no-edges.svg +1 -0
  109. package/test/pics/title-overflow.png +0 -0
  110. package/test/pics/title-overflow.svg +1 -0
  111. package/test/pics/valign.png +0 -0
  112. package/test/pics/valign.svg +1 -0
  113. package/test/pics//344/275/234/346/245/255/346/265/201/347/250/213/345/234/226.png +0 -0
  114. package/test/pics//344/275/234/346/245/255/346/265/201/347/250/213/345/234/226.svg +1 -0
  115. package/test/pics//345/211/215/347/253/257/346/236/266/346/247/213/350/210/207/350/263/207/346/226/231/346/265/201/345/234/226.png +0 -0
  116. package/test/pics//345/211/215/347/253/257/346/236/266/346/247/213/350/210/207/350/263/207/346/226/231/346/265/201/345/234/226.svg +1 -0
  117. package/test/pics//345/276/214/347/253/257/346/234/215/345/213/231/347/265/204/346/210/220/345/234/226.png +0 -0
  118. package/test/pics//345/276/214/347/253/257/346/234/215/345/213/231/347/265/204/346/210/220/345/234/226.svg +1 -0
  119. package/test/pics//346/211/271/346/254/241/344/270/212/345/202/263/350/231/225/347/220/206/346/265/201/347/250/213/345/234/226.png +0 -0
  120. package/test/pics//346/211/271/346/254/241/344/270/212/345/202/263/350/231/225/347/220/206/346/265/201/347/250/213/345/234/226.svg +1 -0
  121. package/test/pics//347/250/213/345/274/217/345/205/203/344/273/266/347/265/204/346/210/220/345/234/226.png +0 -0
  122. package/test/pics//347/250/213/345/274/217/345/205/203/344/273/266/347/265/204/346/210/220/345/234/226.svg +1 -0
  123. package/test/pics//347/250/213/345/274/217/345/210/206/345/261/244/346/236/266/346/247/213/345/234/226.png +0 -0
  124. package/test/pics//347/250/213/345/274/217/345/210/206/345/261/244/346/236/266/346/247/213/345/234/226.svg +1 -0
  125. package/test/pics//347/263/273/347/265/261/346/236/266/346/247/213/345/234/226.png +0 -0
  126. package/test/pics//347/263/273/347/265/261/346/236/266/346/247/213/345/234/226.svg +1 -0
  127. package/test/pics//347/263/273/347/265/261/351/227/234/350/201/257/345/234/226.png +0 -0
  128. package/test/pics//347/263/273/347/265/261/351/227/234/350/201/257/345/234/226.svg +1 -0
  129. package/test/pics//347/267/232/344/270/212/347/225/253/351/235/242/351/227/234/350/201/257/345/234/226.png +0 -0
  130. package/test/pics//347/267/232/344/270/212/347/225/253/351/235/242/351/227/234/350/201/257/345/234/226.svg +1 -0
  131. package/test/wflowchart.test.mjs +41 -0
  132. package/toolg/addVersion.mjs +4 -0
  133. package/toolg/cleanFolder.mjs +4 -0
  134. package/toolg/gDistRollup.mjs +51 -0
  135. package/toolg/modifyReadme.mjs +4 -0
@@ -0,0 +1,99 @@
1
+ # p3 — nomnoml 產線
2
+
3
+ ## 技術核心
4
+
5
+ - **繪圖庫**:nomnoml(`nomnoml/dist/nomnoml.js`),與其相依 `graphre`(`graphre/dist/graphre.js`)由本機 `node_modules` 讀出後內聯注入頁面 `<script>`(`common/pkg.mjs` 的 `pkgScript()`),非透過 CDN,斷網環境亦可渲染;nomnoml UMD 模組依賴全域 `graphre`,注入順序須 graphre 先於 nomnoml。npm 版 nomnoml 之佈局由套件自宣告相依的 graphre 驅動,與舊版 CDN bundle(`nomnoml.web.js`,內部改捆 `@dagrejs/dagre`)佈局引擎不同,兩者排版結果可能略有差異。
6
+ - **渲染方式**:於頁面注入 `window.renderAndDetect(src)` 函式,呼叫 `window.nomnoml.renderSvg(src)` 將 DSL 字串渲染為 SVG,並將結果寫入 `#box` div 的 innerHTML。
7
+ - **截圖目標**:以 Playwright locator `#box svg` 精確擷取 SVG 元素本身,排除頁面邊距干擾。
8
+ - **解析度**:啟動 `browser.newPage({ deviceScaleFactor: 2 })`,輸出為 2× 實體像素的高解析度 PNG。
9
+ - **呼叫方式**:`genPng(data, opt)` 為單張渲染函式,接受正規化繪圖數據 `{ dir, nodes, edges }`,內部自建 browser/page 並於結束時關閉,回傳 PNG 的 Node Buffer(不落地檔案),由 `src/WFlowchart.mjs` 統一調用(`mode: 'p3'`)。
10
+
11
+ ## 產製原理(資料驅動)
12
+
13
+ ### 資料輸入格式(標準化數據)
14
+
15
+ 每份圖的數據含:
16
+ - `nodes`:`{ id, label, cls, group? }` — 節點清單,`group` 為所屬容器節點 id
17
+ - `edges`:`{ from, to, label?, kind }` — 邊清單,`kind` 為 `'solid'` 或 `'dashed'`
18
+ - `dir`:`'LR'`(左右)或 `'TB'`(上下)
19
+
20
+ ### nomnoml 兩項庫層限制與對策
21
+
22
+ nomnoml 有兩項硬限制(經實測 + 源碼 classifier regex `<([a-z]*)>` 確認),決定本套轉譯策略:
23
+
24
+ - **限制一**:classifier 名(`<...>`)只接受純小寫字母 `[a-z]+`;含大寫或數字(如 `blueG`、`greenG2`)比對失敗,整段 `<...>` 會被當成字面標籤文字渲染,外洩內部標記。
25
+ 對策:`clsAlias(cls)` 把每個 cls 逐字映射成純小寫字母且彼此唯一的別名(大寫字母 → 小寫、數字 `2` → `b`、其餘非 `[a-z]` 字元 → `g`),如 `blueG→blueg`、`blueG2→bluegb`、`greenG2→greengb`;樣式(`#.<alias>: fill=... stroke=...`)仍照常套用,classifier 名不外顯。
26
+ - **限制二**:nomnoml 節點識別子=標籤文字本身(無 `id=` 屬性),且巢狀容器(`[容器 | 子節點]`)內的子節點無法被外部邊參照——以子節點標籤當邊端點會「新建一份重複節點」而非連到既有子節點,造成同內容畫兩份/連線斷裂。nomnoml 無 compound graph,此為庫本身限制。
27
+ 對策:所有節點一律「頂層平鋪宣告」(不用巢狀),群組歸屬改以「容器→成員」虛線關聯邊表達(比照 p10/vis 對相同限制之作法;成員與容器共用色相已表群組),邊端點一律以「既有節點之確切標籤」參照,形成單一連通圖、零重複節點。
28
+
29
+ ### translate(data) 轉換流程
30
+
31
+ **方向映射**:`data.dir === 'LR'` 輸出 `#direction: right`;否則輸出 `#direction: down`。
32
+
33
+ **節點著色與形狀**:
34
+ - `buildClsDirectives()` 迭代 `PALETTE`,為每個非 `diamond` cls 輸出一行 nomnoml 自訂類別指令,格式為 `#.<clsAlias(cls)>: fill=<fill> stroke=<stroke>`。
35
+ - `diamond` cls 直接對應 nomnoml 內建 `<choice>` 分類器(不需自訂樣式);其餘 cls 經 `classifier()` 對應 `<clsAlias(cls)>`(如 `blue`→`<blue>`、`blueG`→`<blueg>`)。
36
+
37
+ **節點宣告(頂層平鋪)**:
38
+ - 所有節點不分是否屬於群組,一律輸出頂層宣告 `[<classifier>標籤]`,不使用巢狀容器語法。
39
+
40
+ **群組歸屬(關聯邊,取代巢狀容器)**:
41
+ - 節點含 `group` 欄位且該 group 對應之節點存在時,額外輸出一條「容器 → 成員」關聯邊 `[容器標籤] --> [成員標籤]`(語意上僅表示歸屬,非資料流向),使排版把成員擺在容器旁;群組邊界主要靠成員與容器共用色相辨識。
42
+
43
+ **數據明列之邊**:
44
+ - 邊端點以節點的確切標籤文字參照既有節點(不新建節點)。
45
+ - `kind === 'dashed'` 輸出 `-->`;其餘輸出 `->`。
46
+ - 邊的標籤插在箭頭符號前(如 `[A] 標籤 -> [B]`);無標籤時插一個空格。
47
+
48
+ **字型**:全域以 `#font: Microsoft JhengHei` directive 強制統一——nomnoml 對每個 `<text>` 內聯輸出 CSS `font:` 簡寫(預設 Helvetica),會壓過 body 的 `font-family`,`#font:` 為庫層唯一能統一套用之機制,值須為裸族名(不帶引號/逗號,否則破壞 CSS 簡寫)。
49
+
50
+ ### DSL 輸出結構
51
+
52
+ ```
53
+ #direction: right|down
54
+ #font: Microsoft JhengHei
55
+ #.<clsAlias>: fill=... stroke=... ← 每個非 diamond cls 一行
56
+ [<classifier>節點標籤] ← 所有節點頂層平鋪
57
+ [容器標籤] --> [成員標籤] ← 群組歸屬關聯邊
58
+ [A] 標籤 -> [B] ← 數據明列之邊
59
+ ```
60
+
61
+ ## 自動化機制
62
+
63
+ ### 邊標籤可讀性:白色光暈 + z-order 提層
64
+
65
+ nomnoml 不為邊標籤畫底色(邊標籤背景透明),緊湊間距下邊標籤文字若與節點/容器填色重疊會被蓋住。`renderAndDetect` 對每個「無 `data-name` 屬性」的 `<text>`(節點標籤皆帶 `data-name`,無此屬性者即為邊標籤)做兩件事:
66
+
67
+ - **白色描邊**(`paint-order:stroke` + `stroke=#ffffff`、`stroke-width≈3.5`,對應 `common/palette.mjs` 的 `EDGE.haloColor`/`EDGE.haloWidth`),使文字在線段/填色上仍清晰可讀。
68
+ - **提升 z-order**:nomnoml SVG 的 DOM 順序為「邊標籤 → 邊線 → 節點 rect/text」,故將每個邊標籤 `<text>` 搬移至 `<svg>` 尾端(最後繪製),使其永遠疊在節點 rect/容器填色之上。
69
+
70
+ ### 碰撞偵測迴圈(autofix)
71
+
72
+ 本產線「額外添加」的自動化核心,替代逐圖手調間距的魔術數字:
73
+
74
+ - `renderAndDetect(src)` 渲染後取頁內所有 `svg text` 元素的 `getBoundingClientRect()`,兩兩比對重疊量,若兩軸重疊均超過 2px 則計為一次碰撞。**只計「節點×節點」文字重疊**(節點標籤帶 `data-name`,邊標籤無)——任一方為邊標籤的重疊一律不算碰撞,因其可讀性已由上述 halo + z-order 保證,不該用加大間距處理(否則長邊標籤會把圖撐爆);只有節點本體互疊才驅動間距調整。
75
+ - `autofix(page, src)` 以掃描序列 `[40, 60, 85]`(px)依序嘗試 `#spacing: N`,取「第一個達到零碰撞」的最小間距;若掃完仍有碰撞,取碰撞數最少的間距(不放棄輸出)。掃描上限封在 85(原則:封頂緊湊)——若 85 仍殘留少量重疊,寧可保持緊湊留下該重疊(代表此圖不適合 nomnoml,交由挑選階段換其他產線),絕不灌大間距讓圖撐大、字變小。
76
+ - 確定最佳間距後,以 `#spacing: <best> + DSL` 重繪一次,等待字型就緒(`document.fonts.ready`)及 180ms 穩定後截圖。
77
+
78
+ 此機制使產線不需逐圖指定間距,完全由碰撞結果通用決定版面密度。
79
+
80
+ ### 版面通用化原則
81
+
82
+ - `#direction` 直接從數據 `dir` 推算,無逐圖硬碼方向。
83
+ - 間距(`#spacing`)由碰撞偵測迴圈通用決定,無逐圖魔術數字。
84
+ - 群組歸屬由 `group` 欄位自動合成關聯邊,無逐圖手工排列子節點。
85
+ - 節點分類器與著色由 `PALETTE` 統一驅動,新增 cls 只需在 `palette.mjs` 中定義即可自動生效(`clsAlias()` 自動處理別名唯一性)。
86
+
87
+ ### 穩定性處置
88
+
89
+ 截圖前執行 `document.fonts.ready` 等待中文字型(Microsoft JhengHei)載入完成,再加 180 ms 額外緩衝,避免字型未就緒造成文字 bbox 量測偏差而誤報碰撞或截圖模糊。
90
+
91
+ ## 已知限制 / 回退
92
+
93
+ - **邊以 label 文字匹配節點**:若兩個節點擁有相同的 label 文字,nomnoml 無法區分,邊可能連錯目標。數據設計應確保 label 唯一。
94
+ - **同 label 平行邊重疊**:nomnoml 對兩節點間的多條邊不做分叉偏移,平行邊會堆疊在同一條線上,標籤重疊難以辨識。
95
+ - **無原生群組容器框**:改以「容器→成員」關聯邊表達歸屬(見上),視覺上無實體容器外框,僅靠共用色相辨識群組邊界,不若原生 compound graph 直觀。
96
+ - **線性鏈被拉長**:對節點數多且為線性序列的圖(如長流程鏈),nomnoml 可能把畫布拉得很長,碰撞偵測的間距調整無法改善長寬比。
97
+ - **碰撞偵測上限**:掃描序列最大值為 85px,若在此間距下仍有碰撞(如標籤極長或節點極密),會以殘留碰撞數最少者輸出。
98
+ - **本機套件相依**:nomnoml/graphre 由本機 `node_modules` 讀出內聯注入,需先完成 `npm install`;執行環境本身不需連網。
99
+ </content>
package/src/p3/gen.mjs ADDED
@@ -0,0 +1,188 @@
1
+ // p3 — nomnoml 產線 adapter(資料驅動)
2
+ // genPng(data): 正規化繪圖數據 → 轉 nomnoml DSL → 碰撞偵測自動調 spacing → 截圖回傳 PNG Buffer
3
+ // 版面通用化: direction 取自數據 dir; spacing 由碰撞偵測迴圈自動決定; 無逐圖魔術數字。
4
+ //
5
+ // nomnoml 1.2.0 兩項硬限制(經實測 + 源碼 classifier regex `<([a-z]*)>` 確認), 決定本套轉譯策略:
6
+ // (1) classifier 名只接受純小寫字母 [a-z]+: 含大寫/數字(如 blueG、greenG2)會比對失敗,
7
+ // 整段 <...> 被當「字面標籤文字」渲染 → 標題外洩 <orangeG> 之類內部標記。
8
+ // 對策: 以 clsAlias() 把每個 cls 映射成「純小寫字母」且彼此唯一之別名(G→g, 2→b),
9
+ // 樣式照常套用、classifier 名不顯示。
10
+ // (2) 節點識別子 = 標籤文字本身(無 id= 屬性), 且「巢狀容器內子節點」無法被外部邊參照——
11
+ // 以 [容器 | 子] 巢狀宣告後, 再以 [子] 當邊端點會「新建一份重複節點」而非連到既有子節點,
12
+ // 造成同內容畫兩份/斷裂(原 bug)。nomnoml 無 compound graph,此為庫本身限制。
13
+ // 對策: 全部節點一律「頂層平鋪宣告」(不用巢狀), 群組歸屬改以「容器→成員」虛線關聯邊表達
14
+ // (比照 p10/vis 對相同限制之作法; 成員與容器共用色相已表群組), 邊端點皆以「既有節點之確切標籤」
15
+ // 參照 → 單一連通圖、零重複節點。
16
+ import { chromium } from 'playwright'
17
+ import { PALETTE, EDGE } from '../common/palette.mjs'
18
+ import { pkgScript } from '../common/pkg.mjs'
19
+
20
+ // nomnoml(與其相依 graphre)由本機 node_modules 內聯注入(取代 CDN nomnoml.web.js, 斷網環境可用)
21
+ // nomnoml UMD 依賴全域 graphre, 故 graphre 先載
22
+ const GRAPHRE_JS = pkgScript('graphre/dist/graphre.js')
23
+ const NOMNOML_JS = pkgScript('nomnoml/dist/nomnoml.js')
24
+
25
+ // cls → nomnoml classifier 別名(純小寫字母 [a-z]+, 否則 nomnoml 視為字面文字而外洩)
26
+ // 逐字映射保唯一: 大寫 G → 'g'、數字 2 → 'b'(其餘非 [a-z] 字元一律 → 'g'),
27
+ // 故 blueG→blueg、blueG2→bluegb、greenG2→greengb… 彼此不碰撞。
28
+ function clsAlias(cls) {
29
+ return String(cls).toLowerCase().replace(/[^a-z]/g, c => (c === '2' ? 'b' : 'g'))
30
+ }
31
+
32
+ // cls → nomnoml 樣式指令行(以小寫別名宣告; 群組 cls 也加入, 供容器節點著色)
33
+ // diamond 用 nomnoml 內建 <choice>, 不需自訂樣式
34
+ function buildClsDirectives() {
35
+ const lines = []
36
+ for (const [cls, p] of Object.entries(PALETTE)) {
37
+ if (cls === 'diamond') continue // <choice> 內建, 不加自訂
38
+ lines.push(`#.${clsAlias(cls)}: fill=${p.fill} stroke=${p.stroke}`)
39
+ }
40
+ return lines.join('\n')
41
+ }
42
+
43
+ // 取 nomnoml 節點分類器 token:diamond → '<choice>'(內建菱形), 其餘 → '<小寫別名>'
44
+ function classifier(cls) {
45
+ if (cls === 'diamond') return '<choice>'
46
+ return `<${clsAlias(cls)}>`
47
+ }
48
+
49
+ // 把正規化數據轉成 nomnoml DSL 字串
50
+ // 策略(因應 nomnoml 無 compound graph、節點以標籤為識別子):
51
+ // 1. 所有節點一律頂層平鋪宣告 [<別名>標籤](不用巢狀, 避免重複節點 bug)
52
+ // 2. 群組歸屬: 由 group 欄位合成「容器→成員」虛線關聯邊(無資料邊標籤)
53
+ // 3. 數據明列之邊: 端點以「節點確切標籤」參照, 依 kind 選 -> 或 -->, 帶 label 時插在箭頭前
54
+ // → 全圖單一連通圖、零重複節點、無 classifier 名外洩
55
+ function translate(data) {
56
+ const dir = data.dir === 'LR' ? 'right' : 'down'
57
+ const clsDirs = buildClsDirectives()
58
+
59
+ // 字型全覆蓋(全域修正): nomnoml 對「每個 <text>」內聯輸出 CSS `font:` 簡寫(預設 Helvetica),
60
+ // 會壓過 body 的 font-family → 節點/邊標籤/<choice>/容器標題全落 Helvetica(中文僅靠瀏覽器後備)。
61
+ // 唯一從庫層強制統一之機制是 `#font:` directive(getConfig 讀 d.font, 套進每個 text 的 font 簡寫)。
62
+ // 值須為「裸族名」(directive 以 ':' 切, 值原樣插入 `font:<值>, Helvetica, sans-serif`),
63
+ // 故傳 'Microsoft JhengHei'(不帶引號/逗號, 否則破壞 CSS 簡寫)。經實測各類文字 computed 皆含此字型。
64
+
65
+ // id → node
66
+ const byId = {}
67
+ for (const nd of data.nodes) byId[nd.id] = nd
68
+
69
+ // 所有節點頂層平鋪宣告
70
+ const nodeDsls = data.nodes.map(nd => `[${classifier(nd.cls)}${nd.label}]`).join('\n')
71
+
72
+ // 群組歸屬邊: 容器(group 所指節點) → 成員, 虛線無箭頭語意以外之標籤
73
+ // nomnoml 無容器框, 比照 p10/vis 以關聯邊表達歸屬, 讓排版把成員擺在容器旁
74
+ const assocDsls = data.nodes
75
+ .filter(nd => nd.group && byId[nd.group])
76
+ .map(nd => `[${byId[nd.group].label}] --> [${nd.label}]`)
77
+ .join('\n')
78
+
79
+ // 數據明列之邊: 端點以既有節點之確切標籤參照(不新建節點)
80
+ const edgeDsls = data.edges.map(ed => {
81
+ const fromLabel = byId[ed.from]?.label ?? ed.from
82
+ const toLabel = byId[ed.to]?.label ?? ed.to
83
+ const arrow = ed.kind === 'dashed' ? '-->' : '->'
84
+ const edgeLabel = ed.label ? ` ${ed.label} ` : ' '
85
+ return `[${fromLabel}]${edgeLabel}${arrow} [${toLabel}]`
86
+ }).join('\n')
87
+
88
+ return `#direction: ${dir}
89
+ #font: Microsoft JhengHei
90
+ ${clsDirs}
91
+ ${nodeDsls}
92
+ ${assocDsls}
93
+ ${edgeDsls}`
94
+ }
95
+
96
+ // setup:renderAndDetect(src) 渲染後量所有文字 bbox、偵測重疊(>2px)、回傳碰撞數
97
+ // 並對「邊標籤文字」(無 data-name 之 <text>, 即非節點內標籤)做兩件事:
98
+ // (A) 白色光暈/描邊: paint-order:stroke + stroke=#ffffff stroke-width≈3.5——
99
+ // 背景框本就透明(nomnoml 不畫邊標籤白底), 故只需加描邊使線段透出、文字仍清晰。
100
+ // (B) z-order 提層(原則2): nomnoml SVG 之 DOM 順序為「邊標籤 → 邊線 → 節點 rect/text」,
101
+ // 邊標籤排在節點 rect 之前 → 緊湊間距下若幾何重疊, 後畫的節點填色會「蓋住」邊標籤。
102
+ // 對策不加大間距, 而以繪製順序解: 將每個邊標籤 <text> append 至 svg 尾端(最後繪製),
103
+ // 使其永遠疊在容器填色/節點 rect 之上, 任何文字皆不被區塊遮住。
104
+ const setup = `<!doctype html><html lang="zh-Hant"><head><meta charset="utf-8">
105
+ <script>${GRAPHRE_JS}</script>
106
+ <script>${NOMNOML_JS}</script>
107
+ </head><body style="margin:0;background:#fff;font-family:'Microsoft JhengHei',sans-serif">
108
+ <div id="box" style="display:inline-block;padding:20px"></div>
109
+ <script>
110
+ var HALO_COLOR = ${JSON.stringify(EDGE.haloColor)}, HALO_W = ${EDGE.haloWidth}
111
+ function applyEdgeLabelHalo(box){
112
+ // 邊標籤 = <text> 無 data-name(節點內標籤皆帶 data-name)。
113
+ var svg = box.querySelector('svg')
114
+ var ts = Array.prototype.slice.call(box.querySelectorAll('svg text'))
115
+ ts.forEach(function(t){
116
+ if (t.hasAttribute('data-name')) return // 節點標籤, 不處理
117
+ // (A) 白描邊 + paint-order:stroke 使描邊在字下方
118
+ t.setAttribute('paint-order','stroke')
119
+ t.setAttribute('stroke', HALO_COLOR)
120
+ t.setAttribute('stroke-width', HALO_W)
121
+ t.setAttribute('stroke-linejoin','round')
122
+ // (B) z-order: 移至 svg 尾端 → 最後繪製, 永不被節點 rect/容器填色蓋住
123
+ svg.appendChild(t)
124
+ })
125
+ }
126
+ window.renderAndDetect = function(src){
127
+ try {
128
+ var svg = window.nomnoml.renderSvg(src)
129
+ var box = document.getElementById('box'); box.innerHTML = svg
130
+ applyEdgeLabelHalo(box)
131
+ var texts = Array.prototype.slice.call(box.querySelectorAll('svg text'))
132
+ var R = texts.map(function(t){ return t.getBoundingClientRect() })
133
+ // 只計「節點×節點」文字重疊驅動間距(原則2/3): 節點標籤帶 data-name; 邊標籤無 data-name。
134
+ // 凡牽涉邊標籤之重疊(邊標籤×節點 / 邊標籤×邊標籤)一律「不算碰撞」——其可讀性已由
135
+ // halo(白描邊)+ z-order(提至最上層)保證, 不該用「加大間距」處理(否則 進度回報×Worker Thread
136
+ // 這類邊標籤壓節點之 bbox 重疊會把圖撐爆)。唯有節點本體互疊才是 z-order 無法解、須間距處理者。
137
+ var isNode = texts.map(function(t){ return t.hasAttribute('data-name') })
138
+ var col = 0, details = []
139
+ for (var i=0;i<R.length;i++) for (var j=i+1;j<R.length;j++){
140
+ if (!(isNode[i] && isNode[j])) continue // 任一方為邊標籤 → 交給 halo+z-order, 不驅動間距
141
+ var a=R[i], b=R[j]
142
+ var ix = Math.min(a.right,b.right)-Math.max(a.left,b.left)
143
+ var iy = Math.min(a.bottom,b.bottom)-Math.max(a.top,b.top)
144
+ if (ix>2 && iy>2){ col++; if(details.length<3) details.push(((texts[i].textContent||'').trim().slice(0,6))+'×'+((texts[j].textContent||'').trim().slice(0,6))) }
145
+ }
146
+ return { ok:true, collisions: col, details: details }
147
+ } catch(e){ return { ok:false, err:(e&&(e.message||e.stack))||String(e) } }
148
+ }
149
+ window.__ready = true
150
+ </script></body></html>`
151
+
152
+ // 自動調間距:由小到大掃描 #spacing,取「第一個達到零碰撞」的最小間距;都無法歸零則取碰撞最少者
153
+ // 原則3(封頂緊湊): sweep 上限封在 85, 移除舊有 115/150——後者會把圖撐爆(後端服務組成圖原掃到
154
+ // spacing=150 → 1946x2396)。改用緊湊上限後, 若 85 仍殘留少量重疊, 寧可保持緊湊留下該重疊
155
+ // (代表本套不適合此圖, 由挑選階段換套), 絕不灌大間距讓圖爆大、字變小。
156
+ async function autofix(page, src) {
157
+ const sweep = [40, 60, 85]
158
+ let best = null
159
+ for (const s of sweep) {
160
+ const r = await page.evaluate((x) => window.renderAndDetect(x), '#spacing: ' + s + '\n' + src)
161
+ if (!r.ok) return { ok: false, err: String(r.err).split('\n')[0] }
162
+ if (best === null || r.collisions < best.collisions) best = { spacing: s, collisions: r.collisions, details: r.details }
163
+ if (r.collisions === 0) { best = { spacing: s, collisions: 0, details: [] }; break }
164
+ }
165
+ return { ok: true, ...best }
166
+ }
167
+
168
+ // 單張渲染: 供其他模組 import 呼叫。輸入單份正規化繪圖數據(結構同 FIGURES[n].data,
169
+ // caller 已先做 label 衍生), 內部自建 browser/page(避免與批次流程共用 state), 沿用批次流程
170
+ // 同一套 deviceScaleFactor、等待邏輯、碰撞偵測自動調 spacing, 回傳 PNG 之 Node Buffer(不落地檔案)。
171
+ export async function genPng(data, opt = {}) {
172
+ const browser = await chromium.launch()
173
+ try {
174
+ const page = await browser.newPage({ deviceScaleFactor: 2 })
175
+ await page.setContent(setup, { waitUntil: 'load' })
176
+ await page.waitForFunction(() => window.__ready, { timeout: 60000 }).catch(() => {})
177
+ const src = translate(data)
178
+ const fx = await autofix(page, src)
179
+ if (!fx.ok) throw new Error(fx.err)
180
+ // 以最佳間距重繪後截圖
181
+ await page.evaluate((x) => window.renderAndDetect(x), '#spacing: ' + fx.spacing + '\n' + src)
182
+ await page.evaluate(() => document.fonts && document.fonts.ready)
183
+ await page.waitForTimeout(180)
184
+ return await page.locator('#box svg').screenshot()
185
+ } finally {
186
+ await browser.close()
187
+ }
188
+ }
@@ -0,0 +1,84 @@
1
+ # p4 — Cytoscape.js + dagre 產線
2
+
3
+ ## 技術核心
4
+
5
+ - **繪圖庫**:Cytoscape.js(`cytoscape/dist/cytoscape.min.js`)搭配 dagre 排版引擎(`dagre/dist/dagre.min.js`)與橋接外掛(`cytoscape-dagre/dist/cytoscape-dagre.js`),三者由本機 `node_modules` 讀出後內聯注入頁面 `<script>`(`common/pkg.mjs` 的 `pkgScript()`),非透過 CDN,斷網環境亦可渲染。
6
+ - **渲染環境**:由 Playwright `chromium.launch()` 開啟無頭瀏覽器,`page.setContent()` 注入含三段內聯 script 的靜態 HTML,頁面掛載一個 `1400×1400px` 的 `<div id="cy">` 作為 Cytoscape 容器。
7
+ - **截圖與匯出**:排版完成後呼叫 `cy.png({ full:true, scale:2, bg:'#ffffff', output:'base64' })` 取得 base64 字串,存入全域變數 `window.__png`,再由 `page.evaluate()` 讀回 Node 端,以 `Buffer.from(b64, 'base64')` 轉為 PNG Buffer 回傳。`full:true` 使輸出範圍自動裁切至實際元素範圍,`scale:2` 使最終 PNG 解析度為實際佈局尺寸的 2 倍;`deviceScaleFactor` 設為 `1`(由 `browser.newPage({ deviceScaleFactor:1 })` 指定),縮放完全由 `cy.png` 的 `scale:2` 控制。
8
+ - **呼叫方式**:`genPng(data, opt)` 為單張渲染函式,接受正規化繪圖數據 `{ dir, nodes, edges }`,內部自建 browser/page 並於結束時關閉,回傳 PNG 的 Node Buffer(不落地檔案),由 `src/WFlowchart.mjs` 統一調用(`mode: 'p4'`)。
9
+
10
+ ## 產製原理(資料驅動)
11
+
12
+ ### translate(data) — `toEls(data)`
13
+
14
+ `toEls(data)` 將標準數據轉成 Cytoscape elements 陣列:
15
+
16
+ - **節點**:每個 `node` 轉成 `{ data: { id, label, parent }, classes }` 元素。
17
+ - `parent` 直接取自 `nd.group`;若 `nd.group` 為 falsy(無所屬群組),`parent` 為 `undefined`,Cytoscape 視為根節點。
18
+ - `classes` 直接取自 `nd.cls`(如 `green`、`diamond`、`blueG` 等),用於後續 style selector 指派顏色與形狀。
19
+ - **邊**:每條 `edge` 轉成 `{ data: { source, target, label }, classes }` 元素。
20
+ - `ed.kind === 'dashed'` 時 `classes` 設為 `'dashed'`,否則為 `undefined`,套用虛線樣式。
21
+
22
+ ### 節點上色與形狀
23
+
24
+ 樣式由行內 JSON style 陣列定義,與 `common/palette.mjs` 語意對應:
25
+
26
+ | cls | 形狀 | 填色 / 框色 |
27
+ |---|---|---|
28
+ | (預設 / blue) | round-rectangle | `#eef4fb` / `#3f6fb0` |
29
+ | `green` | round-rectangle | `#eaf4ef` / `#348a5c` |
30
+ | `orange` | round-rectangle | `#fcf0e2` / `#c46e1a` |
31
+ | `purple` | round-rectangle | `#f4f0fa` / `#8163ad` |
32
+ | `red` | round-rectangle | `#fbecea` / `#c0392b` |
33
+ | `done` | round-rectangle | `#eaf4ef` / `#256046`(文字同色) |
34
+ | `diamond` | diamond | `#fcf0e2` / `#c46e1a`,`padding:34px`,`text-max-width:120px` |
35
+ | `blueG` / `blueG2` | 群組容器 | 較淺藍色系,標題文字藍色 |
36
+ | `greenG` / `greenG2` | 群組容器 | 較淺綠色系,標題文字綠色 |
37
+ | `orangeG` | 群組容器 | 較淺橘色系,標題文字橘色 |
38
+ | `purpleG` | 群組容器 | 較淺紫色系,標題文字紫色 |
39
+
40
+ ### 群組容器
41
+
42
+ Cytoscape 原生支援 compound node(巢狀節點)。`:parent` selector(凡帶有子節點的節點皆命中)套用群組容器樣式(`text-valign:top`、`background-opacity:0.35`、`border-width:2`、`padding:16px`,標題名置頂),再由 `blueG`/`greenG`/`orangeG`/`purpleG` 等 cls selector 疊加各群組色相。子節點在 `data.parent` 欄位帶入群組節點的 `id` 即可自動歸屬,**無需額外處理**,巢狀亦可多層。
43
+
44
+ ### 邊與虛線
45
+
46
+ 所有邊使用 bezier 曲線(`curve-style:bezier`);文字標籤背景透明(`text-background-opacity:0`,不使用不透明白底),可讀性改以白色描邊光暈達成(`text-outline-color:#ffffff`、`text-outline-width:3`)。`classes:'dashed'` 的邊由 `edge.dashed` selector 套用 `line-style:dashed`。
47
+
48
+ ### 流向(dir)
49
+
50
+ `data.dir`(如 `'TB'`、`'LR'`)直接作為 dagre layout 的 `rankDir` 參數,由呼叫端傳入的數據決定排版方向,產線本身不硬寫任何方向值。
51
+
52
+ ## 自動化機制
53
+
54
+ ### 版面通用推算
55
+
56
+ dagre layout 使用三個通用常數,**不因圖而異**:
57
+
58
+ - `nodeSep: 42`——同 rank 內節點水平間距(px)
59
+ - `rankSep: 55`——相鄰 rank 垂直間距(px)
60
+ - `edgeSep: 18`——同 rank 邊的間距(px)
61
+
62
+ 節點本身的寬高由 Cytoscape 的 `width:"label"` / `height:"label"` 機制自動依文字量決定,輔以 `padding:"10px"` 與 `text-max-width:"360px"` 限制,無需逐圖指定固定尺寸。
63
+
64
+ ### 自動裁切至元素範圍
65
+
66
+ `cy.png({ full:true, ... })` 使輸出 PNG 自動裁切到所有元素的 bounding box,不輸出容器 `<div>` 的空白留白。
67
+
68
+ ### 排版穩定等待
69
+
70
+ `l.promiseOn('layoutstop')` 確保 dagre 排版完成後才執行截圖;排版事件後再 `setTimeout(..., 250)` 給予 250ms 讓瀏覽器完成渲染,避免字型/邊標尚未繪製完即截圖。
71
+
72
+ ### 頁面字型就緒
73
+
74
+ 在呼叫 `renderFig` 前執行 `document.fonts.ready`,確保 Microsoft JhengHei 等中文字型載入完成,避免字型回退造成尺寸偏差。
75
+
76
+ ## 已知限制 / 回退
77
+
78
+ - **線性鏈拉長**:節點數少、邊為單鏈時,dagre 會將所有節點排成一列,圖形縱向(TB)或橫向(LR)被拉長,留白偏多。
79
+ - **同標籤平行邊重疊**:兩節點間有多條方向相同的邊(`from`/`to` 相同)時,bezier 路徑幾乎重疊,標籤互蓋,難以區分。
80
+ - **diamond 節點在群組容器內偏大**:`diamond` cls 固定使用 `padding:34px` 以撐開菱形可視範圍,在小型群組容器內可能撐爆容器邊界。
81
+ - **跨容器邊路由**:Cytoscape compound node 下,跨群組的邊由 dagre 自動路由,部分情況下路由線會穿越容器框線,視覺上不夠乾淨,但功能正確。
82
+ - **無碰撞偵測迴圈**:版面為單次 dagre 計算,若節點數量極多、標籤極長,可能出現節點標籤截斷(超出 `text-max-width:360px`)或節點互疊,無自動調整機制。
83
+ - **本機套件相依**:cytoscape/dagre/cytoscape-dagre 由本機 `node_modules` 讀出內聯注入,需先完成 `npm install`;執行環境本身不需連網。
84
+ </content>
package/src/p4/gen.mjs ADDED
@@ -0,0 +1,82 @@
1
+ // p4 — cytoscape + dagre 產線 adapter(資料驅動)
2
+ // genPng(data): 正規化繪圖數據 → 轉 cytoscape els → dagre 自動排版渲染 → 回傳 PNG Buffer
3
+ // 版面通用化: rankDir 取自數據 dir; nodeSep/rankSep 為通用常數; full:true + 自動尺寸, 無逐圖魔術數字。
4
+ import { chromium } from 'playwright'
5
+ import { pkgScript } from '../common/pkg.mjs'
6
+
7
+ // cytoscape + dagre + cytoscape-dagre 由本機 node_modules 內聯注入(取代 CDN, 斷網環境可用)
8
+ const CYTOSCAPE_JS = pkgScript('cytoscape/dist/cytoscape.min.js')
9
+ const DAGRE_JS = pkgScript('dagre/dist/dagre.min.js')
10
+ const CYTOSCAPE_DAGRE_JS = pkgScript('cytoscape-dagre/dist/cytoscape-dagre.js')
11
+
12
+ // 正規化數據 → cytoscape elements(節點帶 parent=group; 邊 dashed→classes)
13
+ function toEls(data) {
14
+ const els = []
15
+ for (const nd of data.nodes) els.push({ data: { id: nd.id, label: nd.label, parent: nd.group }, classes: nd.cls })
16
+ for (const ed of data.edges) els.push({ data: { source: ed.from, target: ed.to, label: ed.label }, classes: ed.kind === 'dashed' ? 'dashed' : undefined })
17
+ return els
18
+ }
19
+
20
+ // 樣式(語意類別→色/形, 對應 common/palette.mjs)
21
+ const style = `[
22
+ { "selector":"node", "style":{ "shape":"round-rectangle","background-color":"#eef4fb","border-color":"#3f6fb0","border-width":1.8,"label":"data(label)","text-valign":"center","text-halign":"center","font-family":"Microsoft JhengHei, sans-serif","font-size":14,"color":"#1c2b36","width":"label","height":"label","padding":"10px","text-wrap":"wrap","text-max-width":"360px" } },
23
+ { "selector":"node.green", "style":{ "background-color":"#eaf4ef","border-color":"#348a5c" } },
24
+ { "selector":"node.orange", "style":{ "background-color":"#fcf0e2","border-color":"#c46e1a" } },
25
+ { "selector":"node.purple", "style":{ "background-color":"#f4f0fa","border-color":"#8163ad" } },
26
+ { "selector":"node.red", "style":{ "background-color":"#fbecea","border-color":"#c0392b" } },
27
+ { "selector":"node.done", "style":{ "background-color":"#eaf4ef","border-color":"#256046","color":"#256046" } },
28
+ { "selector":"node.diamond", "style":{ "shape":"diamond","background-color":"#fcf0e2","border-color":"#c46e1a","padding":"34px","text-max-width":"120px" } },
29
+ { "selector":":parent", "style":{ "text-valign":"top","text-halign":"center","font-family":"Microsoft JhengHei, sans-serif","font-weight":"bold","padding":"16px","background-opacity":0.35,"border-width":2,"z-compound-depth":"bottom" } },
30
+ { "selector":"node.blueG", "style":{ "background-color":"#f5f9fe","border-color":"#3f6fb0","color":"#3f6fb0" } },
31
+ { "selector":"node.blueG2", "style":{ "background-color":"#eef4fb","border-color":"#3f6fb0","color":"#3f6fb0" } },
32
+ { "selector":"node.greenG", "style":{ "background-color":"#f0f7f3","border-color":"#348a5c","color":"#348a5c" } },
33
+ { "selector":"node.greenG2", "style":{ "background-color":"#eaf4ef","border-color":"#348a5c","color":"#348a5c" } },
34
+ { "selector":"node.orangeG", "style":{ "background-color":"#fdf6ec","border-color":"#c46e1a","color":"#c46e1a" } },
35
+ { "selector":"node.purpleG", "style":{ "background-color":"#faf7fd","border-color":"#8163ad","color":"#8163ad" } },
36
+ { "selector":"edge", "style":{ "width":2.2,"line-color":"#44505a","target-arrow-color":"#44505a","target-arrow-shape":"triangle","curve-style":"bezier","label":"data(label)","font-family":"Microsoft JhengHei, sans-serif","font-size":12.5,"color":"#4a5560","text-background-opacity":0,"text-outline-color":"#ffffff","text-outline-width":3,"text-margin-y":-2 } },
37
+ { "selector":"edge.dashed", "style":{ "line-style":"dashed" } }
38
+ ]`
39
+
40
+ const html = `<!doctype html><html lang="zh-Hant"><head><meta charset="utf-8">
41
+ <script>${CYTOSCAPE_JS}</script>
42
+ <script>${DAGRE_JS}</script>
43
+ <script>${CYTOSCAPE_DAGRE_JS}</script>
44
+ </head><body style="margin:0;background:#fff">
45
+ <div id="cy" style="width:1400px;height:1400px;background:#fff"></div>
46
+ <script>
47
+ if (window.cytoscapeDagre) { try { cytoscape.use(window.cytoscapeDagre) } catch(e){} }
48
+ window.renderFig = function(els, styleJson, rankDir){ return new Promise(function(resolve){
49
+ try {
50
+ var cy = cytoscape({ container: document.getElementById('cy'), elements: els, style: JSON.parse(styleJson) })
51
+ els.forEach(function(x){ if (x.classes && x.data && x.data.id) cy.getElementById(x.data.id).addClass(x.classes) })
52
+ var l = cy.layout({ name:'dagre', rankDir:rankDir, nodeSep:42, rankSep:55, edgeSep:18 })
53
+ l.promiseOn('layoutstop').then(function(){
54
+ setTimeout(function(){
55
+ try { window.__png = cy.png({ full:true, scale:2, bg:'#ffffff', output:'base64' }); var bb=cy.elements().boundingBox(); resolve({ ok:true, w:Math.round(bb.w), h:Math.round(bb.h) }) }
56
+ catch(e){ resolve({ ok:false, err:String(e.message||e) }) }
57
+ }, 250)
58
+ })
59
+ l.run()
60
+ } catch(e){ resolve({ ok:false, err:String(e.message||e) }) }
61
+ })}
62
+ window.__ready = true
63
+ </script></body></html>`
64
+
65
+ // 單張渲染 — 供其他模組 import 使用
66
+ // data: 正規化繪圖數據 { dir, nodes, edges }(caller 已先做 label 衍生)
67
+ // 回傳: PNG 圖片的 Node Buffer
68
+ export async function genPng(data, opt = {}) {
69
+ const browser = await chromium.launch()
70
+ try {
71
+ const page = await browser.newPage({ deviceScaleFactor: 1 })
72
+ await page.setContent(html, { waitUntil: 'load' })
73
+ await page.waitForFunction(() => window.__ready, { timeout: 60000 }).catch(() => {})
74
+ await page.evaluate(() => document.fonts && document.fonts.ready)
75
+ const res = await page.evaluate(([els, st, rd]) => window.renderFig(els, st, rd), [toEls(data), style, data.dir])
76
+ if (!res.ok) throw new Error(res.err)
77
+ const b64 = await page.evaluate(() => window.__png)
78
+ return Buffer.from(b64, 'base64')
79
+ } finally {
80
+ await browser.close()
81
+ }
82
+ }
@@ -0,0 +1,90 @@
1
+ # p5 — D2 (Node 端 + sharp) 產線
2
+
3
+ ## 技術核心
4
+
5
+ - **繪圖庫**:`@terrastruct/d2`(JS 包裝 + 內建 WASM 引擎),於 Node 端直接呼叫 `new D2()`,不啟動瀏覽器。
6
+ - **載入方式**:純 Node 端套件匯入(`import { D2 } from '@terrastruct/d2'`),無 Playwright、無 CDN。
7
+ - **D2 instance 生命週期**:`D2` 建構即啟動常駐 `worker_threads` 且無公開關閉 API;若掛在模組層,僅 import 就會令 caller 程序無法自然結束。因此 `genPng` 每次呼叫皆逐次 `new D2()`,並在 `finally` 等待 `d2inst.ready` 後呼叫 `d2inst.worker.terminate()` 釋放 worker。
8
+ - **渲染流程**:
9
+ 1. `d2inst.compile({ fs: { index: d2src }, options: baseOpt })` — 把 D2 DSL 字串交給 WASM 版面引擎(dagre),取得 `diagram` 與 `renderOptions`。
10
+ 2. `d2inst.render(res.diagram, res.renderOptions)` — 產生 SVG 字串。
11
+ 3. `injectEdgeLabelHalo(svg)` — 修正邊標籤遮罩、為標籤文字加白色光暈描邊(見下節「邊標籤光暈」)。
12
+ 4. `injectFont(svg)` — 在 SVG 內注入 `<style>` 覆寫 `font-family`(見下節字型處理)。
13
+ 5. `sharp(Buffer.from(svg), { density }).png().toBuffer()` — 由 sharp(底層 librsvg)依系統字型描繪文字、轉成 PNG Buffer。
14
+ - **解析度處理**:不使用固定 `deviceScaleFactor`,改為通用公式依 SVG `viewBox` 寬動態推算 `density`(見自動化機制節),讓輸出像素寬落在 1600–2200 px 區間。
15
+ - **字型問題**:本專案所用之 D2 傳入自訂 font buffer 會穩定回傳 `"invalid JSON input"`(實測),故不傳字型給 D2,改在產生的 SVG 插入 `<style>text{font-family:"Microsoft JhengHei","Microsoft YaHei",sans-serif !important;}</style>` 讓 librsvg 以系統安裝的 Microsoft JhengHei 描繪中文。D2 引擎本身已內建 CJK 全形寬度量測,故即使長中文標籤,box 寬度也不溢出。
16
+ - **邊標籤光暈**:D2 對有標籤的邊會用 `mask` + 黑色 `rect` 遮斷連線偽造白底效果,且標籤文字疊在連線正上方;`injectEdgeLabelHalo(svg)` 移除 mask 內黑色 rect 讓連線完整穿過標籤區,並注入 CSS 為 `.text-italic` 加 `paint-order:stroke` + 白色描邊,使標籤文字在線段上仍清晰可讀。
17
+ - **介面**:`genPng(data, opt)` 為單張渲染函式,回傳值為 PNG Buffer(不寫檔),由 `src/WFlowchart.mjs` 統一調用;`data` 為正規化繪圖數據 `{ dir, nodes, edges }`(caller 已完成 label 衍生)。
18
+
19
+ ## 產製原理(資料驅動)
20
+
21
+ ### translate(data) 的轉換邏輯
22
+
23
+ `translate(data)` 將 caller 傳入的正規化繪圖數據(`{ dir, nodes, edges }`)轉成 D2 DSL 字串:
24
+
25
+ #### 全域方向
26
+
27
+ ```
28
+ direction: <DIR_MAP[data.dir]>
29
+ ```
30
+
31
+ `data.dir` 為 `TB / LR / BT / RL`,透過 `DIR_MAP` 對應至 D2 關鍵字 `down / right / up / left`。
32
+
33
+ #### 節點與群組容器
34
+
35
+ - **父子關係建立**:掃描 `nodes`,凡有 `nd.group` 欄位者記入 `parentMap`(`nodeId → parentId`)與 `childrenMap`(`parentId → [node]`)。無 `group` 的節點為頂層節點。
36
+ - **群組容器**(`isGroupCls(nd.cls)` 為真,即 cls 結尾為 `G` 或 `G2`):
37
+ - 產生 D2 block(`id: "label" { ... }`),在 block 內設定 `direction`(延用圖的頂層方向)與樣式。
38
+ - 遞迴呼叫 `renderNode` 展開子節點,縮排 +2 空格。
39
+ - `style.bold: true`(群組標題加粗)、`style.font-color` 取自 palette `text` 欄。
40
+ - **一般節點**(含 diamond):同樣產生 block,由 `styLines` 填入 `style.fill`、`style.stroke`;若 `c.shape === 'diamond'` 則補 `shape: diamond`。
41
+ - **cls 上色**:呼叫 `colorOf(cls)`(查無則回退 `blue`),對應 `common/palette.mjs` 的 `PALETTE` 色票,取 `fill / stroke / text / shape`。
42
+
43
+ #### 邊
44
+
45
+ 所有邊一律在 DSL 頂層宣告。跨群組容器的邊使用 `qualifiedId(nodeId, parentMap)` 遞迴向上追溯,組成 `"父.子"` 完整路徑,讓 D2 正確解析巢狀容器內的子節點引用。
46
+
47
+ - `ed.kind === 'dashed'`:補 `{ style.stroke-dash: 4 }`。
48
+ - `ed.label` 存在時補 `: "label"`。
49
+ - `$` 字元以 `esc()` 轉成 `\$`,避免 D2 變數展開。
50
+
51
+ ## 自動化機制
52
+
53
+ ### 版面全通用,無逐圖魔術數字
54
+
55
+ 所有 9 張圖共用同一組通用常數:
56
+
57
+ | 常數/公式 | 值 / 說明 |
58
+ |---|---|
59
+ | `layout` | `'dagre'`(所有圖一致) |
60
+ | `pad` | `40`(D2 四邊留白,單位 px,SVG 空間) |
61
+ | `themeID` | `0`(D2 預設主題,所有圖一致) |
62
+ | `density` 公式 | `96 × clamp(1.4, 2.4, 1700 / viewBoxWidth)` |
63
+
64
+ ### density 動態推算
65
+
66
+ 渲染完 SVG 後,以正規表達式取出 `viewBox="0 0 W H"` 的寬度 `W`,代入:
67
+
68
+ ```
69
+ density = 96 * Math.min(2.4, Math.max(1.4, 1700 / W))
70
+ ```
71
+
72
+ - `W` 越小(節點少、圖緊湊)→ density 趨近 2.4(放大),確保輸出不糊。
73
+ - `W` 越大(節點多、圖寬)→ density 趨近 1.4(縮小),避免輸出過大。
74
+ - `W` 無法取得時回退 `1000`。
75
+ - 輸出像素寬大致落在 **1600–2200 px**。
76
+
77
+ 此公式取代舊版逐圖手調 scale 或固定 `deviceScaleFactor`,對全部 9 張圖自動適應。
78
+
79
+ ### 字型注入(自動覆寫,非逐圖設定)
80
+
81
+ `injectFont(svg)` 自動在 SVG 頂層插入 style 區塊,注入點邏輯:若 SVG 已含 `<style`,則插在首個 `<style` 之前;否則插在根元素開頭 `>` 之後。無需對各圖個別處理。
82
+
83
+ ## 已知限制 / 回退
84
+
85
+ - **無碰撞偵測與自動間距迴圈**:版面全部由 dagre 排定,不另行疊代調整間距,節點密集時可能重疊或擠壓。
86
+ - **線性鏈被拉長**:dagre 對純線性鏈(無分支)傾向垂直拉伸,圖面縱橫比可能不理想。
87
+ - **平行邊重疊**:同一對節點若有兩條方向相同的邊(含同標籤平行邊),D2/dagre 可能使其重疊,難以區分。
88
+ - **不支援自訂字型傳入 D2**:傳 font buffer 必定回 `"invalid JSON input"`(實測),字型須倚賴系統安裝的 Microsoft JhengHei,移至無此字型的環境時中文渲染可能退化為 sans-serif 備援。
89
+ - **無自動裁切貼邊**:`pad: 40` 為固定四邊留白,不會依內容動態裁切。
90
+ - **群組容器巢狀深度**:D2 DSL 支援巢狀 block,但 dagre 版面引擎對三層以上深度巢狀的排版品質未有保證。