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
package/src/p5/gen.mjs ADDED
@@ -0,0 +1,182 @@
1
+ // p5 — D2(Node 端) 產線 adapter(資料驅動)
2
+ // genPng(data): 正規化繪圖數據 → 轉 D2 DSL → @terrastruct/d2 算 SVG → sharp 轉 PNG Buffer
3
+ // 版面通用化: dir 取自數據; density 依 SVG viewBox 寬自動推算, 無逐圖魔術數字。
4
+ //
5
+ // 注意:本版 D2(0.1.33 / WASM v0.7.0) 傳入自訂 font buffer 會穩定回 "invalid JSON input"。
6
+ // 改為不傳字型編譯,渲染為 SVG 後注入 font-family 覆寫,交由 librsvg(sharp) 以系統「Microsoft JhengHei」實際描繪。
7
+ // D2 v0.7 內建 CJK 全形寬度量測,box 寬即使長中文亦不溢出(已實測)。
8
+ import { D2 } from '@terrastruct/d2'
9
+ import sharp from 'sharp'
10
+ import { colorOf, isGroupCls } from '../common/palette.mjs'
11
+
12
+ // ── 字型注入 ────────────────────────────────────────────────────────────────
13
+ const FONT_FAMILY = `"Microsoft JhengHei","Microsoft YaHei",sans-serif`
14
+ const FONT_OVERRIDE = `<style>text,.text-bold,.text-italic,.text-underline,tspan{font-family:${FONT_FAMILY} !important;}.text-bold{font-weight:bold !important;}</style>`
15
+ function injectFont(svg) {
16
+ // 全圖中文字型統一: 將 SVG 內所有 font-family 宣告(CSS rule 與屬性值)一律改成 Microsoft JhengHei。
17
+ // 僅靠 text{} CSS 蓋不過 D2 對粗體/容器標題等所用 class(如 .text-bold)之字型(class 特異度較高),
18
+ // 故直接覆寫所有 font-family 值, 再加 !important 覆寫保險。
19
+ svg = svg.replace(/font-family\s*:\s*[^;}<]+/gi, `font-family:${FONT_FAMILY}`)
20
+ svg = svg.replace(/font-family\s*=\s*"[^"]*"/gi, `font-family="${FONT_FAMILY}"`)
21
+ const idx = svg.indexOf('<style')
22
+ if (idx >= 0) return svg.slice(0, idx) + FONT_OVERRIDE + svg.slice(idx)
23
+ const p = svg.indexOf('>')
24
+ return svg.slice(0, p + 1) + FONT_OVERRIDE + svg.slice(p + 1)
25
+ }
26
+
27
+ // ── 邊標籤光暈注入(全域修正 A) ────────────────────────────────────────────────
28
+ // D2 對有標籤的邊會:
29
+ // (1) 以 mask + fill="black" rect 遮斷連線讓背景白色透出(偽造白底效果)
30
+ // (2) 在連線上方放 class="text-italic fill-N2" 的 <text> 元素
31
+ // 本函式同時處理兩件事:
32
+ // A. 移除 <mask> 內 fill="black" 的 <rect>,讓連線完整穿過標籤區(無白色截斷)
33
+ // B. 注入 CSS 為 .text-italic 加 paint-order:stroke + stroke:white + stroke-width:3.5px,
34
+ // 使文字帶白色光暈,在線段上仍清晰可讀
35
+ const EDGE_HALO_STYLE = `<style>.text-italic{paint-order:stroke;stroke:#ffffff;stroke-width:3.5px;}</style>`
36
+ function injectEdgeLabelHalo(svg) {
37
+ // A. 移除 <mask>...</mask> 內的 fill="black" rect(線段遮罩孔洞)
38
+ // 模式:<mask ...> 內含 <rect ... fill="black"></rect>,保留白色 rect(mask 基底)
39
+ svg = svg.replace(/<mask\b([^>]*)>([\s\S]*?)<\/mask>/g, (maskBlock, attrs, inner) => {
40
+ // 移除 fill="black" 的 rect(這些是標籤位置的遮罩孔洞)
41
+ const cleaned = inner.replace(/<rect[^>]*fill="black"[^>]*><\/rect>/g, '')
42
+ return `<mask${attrs}>${cleaned}</mask>`
43
+ })
44
+ // B. 在第一個 <style 之前注入光暈 CSS
45
+ const idx = svg.indexOf('<style')
46
+ if (idx >= 0) return svg.slice(0, idx) + EDGE_HALO_STYLE + svg.slice(idx)
47
+ const p = svg.indexOf('>')
48
+ return svg.slice(0, p + 1) + EDGE_HALO_STYLE + svg.slice(p + 1)
49
+ }
50
+
51
+ // ── D2 escape:$ 需轉義以避免 D2 變數展開 ───────────────────────────────────
52
+ const esc = (s) => String(s).replace(/\$/g, '\\$')
53
+
54
+ // ── D2 方向映射:標準數據 dir → D2 direction 關鍵字 ─────────────────────────
55
+ const DIR_MAP = { TB: 'down', LR: 'right', BT: 'up', RL: 'left' }
56
+
57
+ // ── 樣式區塊:依 cls 從 palette 取色,群組加 bold ─────────────────────────────
58
+ function styLines(cls, isGroup) {
59
+ const c = colorOf(cls)
60
+ const lines = []
61
+ lines.push(` style.fill: "${c.fill}"`)
62
+ lines.push(` style.stroke: "${c.stroke}"`)
63
+ if (c.text && (isGroup || cls === 'done')) lines.push(` style.font-color: "${c.text}"`)
64
+ if (isGroup) lines.push(` style.bold: true`)
65
+ if (c.shape === 'diamond') lines.push(` shape: diamond`)
66
+ return lines
67
+ }
68
+
69
+ // ── 取節點在 D2 中的完整路徑(用於邊的 from/to 引用) ────────────────────────
70
+ // D2 巢狀容器中子節點須以 "父.子" 表示;遞迴向上追溯直到頂層。
71
+ function qualifiedId(nodeId, parentMap) {
72
+ const parts = []
73
+ let cur = nodeId
74
+ while (cur) {
75
+ parts.unshift(cur)
76
+ cur = parentMap[cur]
77
+ }
78
+ return parts.join('.')
79
+ }
80
+
81
+ // ── 遞迴產生 D2 區塊:容器(群組)巢狀展開 ────────────────────────────────────
82
+ // childrenMap: parentId → [node, ...]
83
+ // parentMap: nodeId → parentId | undefined
84
+ function renderNode(nd, childrenMap, parentMap, dataDir, indent) {
85
+ const pad = ' '.repeat(indent)
86
+ const isGroup = isGroupCls(nd.cls)
87
+ const lines = []
88
+
89
+ if (isGroup) {
90
+ // 群組容器:開 block,在內部設 direction + style,再遞迴展開子節點
91
+ lines.push(`${pad}${nd.id}: "${esc(nd.label)}" {`)
92
+ // 若群組本身有子節點,為其設定方向(延用圖的頂層方向)
93
+ const kids = childrenMap[nd.id] || []
94
+ if (kids.length > 0) {
95
+ lines.push(`${pad} direction: ${DIR_MAP[dataDir] || 'down'}`)
96
+ }
97
+ for (const sl of styLines(nd.cls, true)) {
98
+ lines.push(`${pad}${sl}`)
99
+ }
100
+ for (const kid of kids) {
101
+ lines.push(renderNode(kid, childrenMap, parentMap, dataDir, indent + 2))
102
+ }
103
+ lines.push(`${pad}}`)
104
+ } else {
105
+ // 一般節點(含 diamond)
106
+ lines.push(`${pad}${nd.id}: "${esc(nd.label)}" {`)
107
+ for (const sl of styLines(nd.cls, false)) {
108
+ lines.push(`${pad}${sl}`)
109
+ }
110
+ lines.push(`${pad}}`)
111
+ }
112
+
113
+ return lines.join('\n')
114
+ }
115
+
116
+ // ── translate:標準數據 → D2 DSL 字串 ─────────────────────────────────────────
117
+ // 邊一律在頂層宣告,跨群組時用完整路徑(parent.child)讓 D2 正確解析。
118
+ function translate(data) {
119
+ // 建立 parentMap(nodeId → parentId) 與 childrenMap(parentId → [node])
120
+ const parentMap = {}
121
+ const childrenMap = {}
122
+ for (const nd of data.nodes) {
123
+ if (nd.group) {
124
+ parentMap[nd.id] = nd.group
125
+ if (!childrenMap[nd.group]) childrenMap[nd.group] = []
126
+ childrenMap[nd.group].push(nd)
127
+ }
128
+ }
129
+
130
+ // 找出頂層節點(無 group 的節點)
131
+ const topNodes = data.nodes.filter(nd => !nd.group)
132
+
133
+ const blocks = []
134
+
135
+ // 全域方向
136
+ blocks.push(`direction: ${DIR_MAP[data.dir] || 'down'}`)
137
+
138
+ // 頂層節點(含群組容器)
139
+ for (const nd of topNodes) {
140
+ blocks.push(renderNode(nd, childrenMap, parentMap, data.dir, 0))
141
+ }
142
+
143
+ // 邊:全部在頂層,跨層路徑用 qualifiedId 展開
144
+ for (const ed of data.edges) {
145
+ const src = qualifiedId(ed.from, parentMap)
146
+ const dst = qualifiedId(ed.to, parentMap)
147
+ let s = `${src} -> ${dst}`
148
+ if (ed.label) s += `: "${esc(ed.label)}"`
149
+ if (ed.kind === 'dashed') s += ` { style.stroke-dash: 4 }`
150
+ blocks.push(s)
151
+ }
152
+
153
+ return blocks.join('\n\n')
154
+ }
155
+
156
+ // ── 渲染 ───────────────────────────────────────────────────────────────────
157
+ // pad=40 與 themeID=0 為通用常數,對所有 9 張圖一致;無逐圖數值。
158
+ const baseOpt = { layout: 'dagre', pad: 40, themeID: 0 }
159
+
160
+ // ── 單張渲染:正規化繪圖數據 → PNG Buffer ─────────────────────────────────────
161
+ // data 結構同 FIGURES[n].data({ dir, nodes, edges }),caller 須先做 label 衍生。
162
+ // D2 建構時即起常駐 worker 且無公開關閉 API(worker 為 node worker_threads 實例),
163
+ // 若掛在模組層, 光 import 本檔就會令 caller 程序無法自然結束;故逐次建立並於 finally terminate。
164
+ export async function genPng(data, opt = {}) {
165
+ const d2inst = new D2()
166
+ try {
167
+ const d2src = translate(data)
168
+ const res = await d2inst.compile({ fs: { index: d2src }, options: baseOpt })
169
+ let svg = await d2inst.render(res.diagram, res.renderOptions)
170
+ svg = injectEdgeLabelHalo(svg)
171
+ svg = injectFont(svg)
172
+ // density 通用公式:依 SVG viewBox 寬推算,讓輸出寬約落在 1600–2200px
173
+ const m = svg.match(/viewBox="0 0 (\d+(?:\.\d+)?) (\d+(?:\.\d+)?)"/)
174
+ const w = m ? parseFloat(m[1]) : 1000
175
+ const density = 96 * Math.min(2.4, Math.max(1.4, 1700 / w))
176
+ return await sharp(Buffer.from(svg), { density }).png().toBuffer()
177
+ }
178
+ finally {
179
+ await d2inst.ready.catch(() => {})
180
+ if (d2inst.worker && d2inst.worker.terminate) await d2inst.worker.terminate()
181
+ }
182
+ }
@@ -0,0 +1,87 @@
1
+ # p6 — AntV G6 v5 (antv-dagre) 產線
2
+
3
+ ## 技術核心
4
+
5
+ - **繪圖庫**:`@antv/g6@5`,由本機 `node_modules/@antv/g6/dist/g6.min.js`(`common/pkg.mjs` 的 `pkgScript()` 讀取檔案內容)內聯注入 Playwright 頁面的 `<script>` 執行,取代舊版 CDN 載入,斷網環境亦可執行。`@antv/g6` 仍是套件 `dependencies` 之一(供 `node_modules` 內取得 dist 檔),但 Node 執行期不以 `import` 方式使用它,僅頁面內 `<script>` 使用其全域 `G6`。
6
+ - **渲染方式**:以 `chromium.launch()` 開啟無頭瀏覽器,`page.setContent(html)` 載入內嵌 G6 頁面,於頁面內呼叫 `window.renderFig()`,以 `G6.Graph` + `layout: { type:'antv-dagre' }` 完成排版與渲染。
7
+ - **截圖/輸出**:呼叫 `graph.toDataURL({ mode:'overall' })` 取得整圖 DataURL(非 viewport 截圖),透過 `window.snap()` 取回後在 Node 端解 base64,組成 `Buffer` 回傳(不寫檔)。
8
+ - **解析度**:`browser.newPage({ deviceScaleFactor: 2 })`,輸出為 2× 實體像素解析度的 PNG。
9
+ - **介面**:`genPng(data, opt)` 為單張渲染函式,由 `src/WFlowchart.mjs` 統一調用;`data` 為正規化繪圖數據 `{ dir, nodes, edges }`(caller 已完成 label 衍生)。每次呼叫皆逐次 `chromium.launch()`,並在 `finally` 呼叫 `browser.close()`,不跨圖共用頁面。
10
+
11
+ ## 產製原理(資料驅動)
12
+
13
+ `translate(data)` 將標準正規化數據轉換為 G6 v5 輸入格式 `{ nodes, combos, edges }`:
14
+
15
+ ### 三大核心品質原則(全庫設定層落實,非逐圖補)
16
+
17
+ - **原則 1.字型全覆蓋**:node / edge / combo 三層 `graph` 預設與各元素 `style` 皆設 `labelFontFamily = FONT`(Microsoft JhengHei),任一層漏設仍由 graph 預設兜底,確保節點、邊標籤、容器標題全部使用同一字型。
18
+ - **原則 2.z-order 解遮蓋**:combo(半透明填色)`zIndex` 墊底(`COMBO_Z = -10`),節點居中(沿用預設 zIndex),邊與邊標籤畫最上層(`EDGE_Z = 100`);即使非成員節點幾何落入容器框,其不透明節點本體與最上層邊標籤皆不被容器填色染淡或遮住。
19
+ - **原則 3.緊湊不撐大**:不做「碰撞偵測 sweep 放大間距 until 零碰撞」,改採固定緊湊間距(`NODESEP` / `RANKSEP`)維持報告可用的緊湊尺寸;若緊湊間距下仍有節點互疊且非 z-order 可解,則保持緊湊留下重疊(代表此套版型不適合此圖,由挑選階段換別套)。
20
+
21
+ ### 節點 → nodes / combos
22
+
23
+ - **群組容器**:`cls` 結尾為 `G` 或 `G2`(由 `isGroupCls()` 判別)的節點轉為 G6 `combos`,類型固定為 `'rect'`,`combo` 欄位設為父容器 id(支援巢狀容器,如 DET in CORE、FES in FE)。填色取 `colorOf(nd.cls).fill`,`fillOpacity: 0.3`,標籤置頂(`labelPlacement: 'top'`),`labelFontFamily` 設為 `FONT`(原則1)。`style.zIndex` 設為 `COMBO_Z`(原則2,墊最底)。
24
+ - **一般節點**:其餘節點轉為 G6 `nodes`。`palette.mjs` 中 `shape === 'diamond'`(即 `cls: 'diamond'`)的節點類型為 `'diamond'`,其餘為 `'rect'`(圓角 `radius: 6`)。`combo` 欄位設為 `nd.group`(所屬容器 id)。節點本體不透明(`fillOpacity` 預設 1),不特別設定 `zIndex`(沿用預設,居於 combo 之上)。
25
+ - **節點尺寸**:由 `measure(label)` 通用公式推算,不逐圖手填:
26
+ - 寬:各行字符逐字累加(CJK 含 `()/` 每字 15.5 px,英數/符號每字 8.5 px),取最長行 + 左右內距 34 px。
27
+ - 高:行數 × 26 px + 上下內距 20 px。
28
+ - 菱形節點額外追加寬 60 px(`DIAMOND_KW`)、高 50 px(`DIAMOND_KH`)以容納斜邊空間。
29
+
30
+ ### 邊 → edges
31
+
32
+ - 全部邊類型為 `'polyline'`(頁面端全域預設),`endArrow: true`,線寬 2.2,`style.zIndex` 設為 `EDGE_Z`(原則2,畫最上層,不被容器填色或任何節點區塊遮住)。
33
+ - `kind === 'dashed'` 的邊:`lineDash: [7, 5]`,顏色改為 `colorOf('orange').stroke`(橘色虛線);否則使用 `EDGE.line`。
34
+ - 邊標籤背景框透明(`labelBackground: false`,不遮轉折線),改以白色光暈描邊呈現:`labelStroke: EDGE.haloColor`、`labelLineWidth: EDGE.haloWidth`、`labelPaintOrder: 'stroke'`(paint-order:stroke 使白邊畫在文字之後,形成光暈,在線段上仍清晰可讀),`labelAutoRotate: false`。
35
+ - **跨容器邊處理**:G6 v5 不接受以 combo id 作為邊端點,`translate` 建立 `kpRepByCombo` 映射(各容器第一個直屬葉節點為代表),透過 `resolveEnd()` 將指向容器 id 的邊端點改接其代表成員,無需逐圖手寫成員 id。
36
+
37
+ ### 排版方向 (dir)
38
+
39
+ `rankdir` 直接取自 `data.dir`(由 caller 傳入的正規化繪圖數據決定,如 `'TB'`、`'LR'`),傳入 `antv-dagre` layout,產線不寫死方向。
40
+
41
+ ### 回傳值
42
+
43
+ `genPng(data, opt)` 回傳 PNG Buffer(不寫檔),落地或其他後續用途由呼叫端(`src/WFlowchart.mjs`)決定。
44
+
45
+ ## 自動化機制
46
+
47
+ ### 版面通用推算(零逐圖魔術數字)
48
+
49
+ 所有排版參數皆為全圖共用常數,取代舊版逐圖手調:
50
+
51
+ | 常數 | 值 | 說明 |
52
+ |---|---|---|
53
+ | `CANVAS` | 2200 | 大畫布尺寸,配合 `autoFit:'view'` 自動縮放,所有圖共用同一畫布 |
54
+ | `NODESEP` | 28 | 同層節點間距(緊湊;取代舊版碰撞偵測 sweep 22→86 之逐次放大間距) |
55
+ | `RANKSEP` | 60 | 層距(緊湊;取代舊版碰撞偵測 sweep 48→190 之逐次放大間距) |
56
+ | `COMBO_Z` | -10 | 容器填色 zIndex,墊最底(原則2) |
57
+ | `EDGE_Z` | 100 | 邊與邊標籤 zIndex,畫最上層(原則2) |
58
+ | `CJK_W` | 15.5 | CJK 字元估寬(px/字) |
59
+ | `ASCII_W` | 8.5 | 英數符號估寬(px/字) |
60
+ | `LINE_H` | 26 | 每行高度(px) |
61
+ | `PAD_W` | 34 | 矩形左右內距總和(px) |
62
+ | `PAD_H` | 20 | 矩形上下內距總和(px) |
63
+ | `DIAMOND_KW` | 60 | 菱形額外追加寬(px) |
64
+ | `DIAMOND_KH` | 50 | 菱形額外追加高(px) |
65
+
66
+ ### 自動縮放
67
+
68
+ G6.Graph 設定 `autoFit: 'view'`,所有圖統一在 2200×2200 px 畫布上自動縮放貼合,不需逐圖設定 `width`/`height`。
69
+
70
+ ### 自動排版
71
+
72
+ 排版交由 `antv-dagre` 處理,設 `sortByCombo: true`(有 combos 時),確保同群組節點聚合排列,不需手動設定節點座標。
73
+
74
+ ### 容器代表節點自動推算
75
+
76
+ `kpRepByCombo` 映射於 `translate` 中動態建立,取各容器第一個直屬葉節點為代表,`resolveEnd()` 統一修正邊端點,是舊版「層級邊改走成員節點(fe1→if1)」手法的通用化。
77
+
78
+ ### 字體就緒等待
79
+
80
+ `page.evaluate(() => document.fonts && document.fonts.ready)` 確保字型載入完畢再渲染,避免中文字型未套用導致量測與實際渲染不符。`renderFig` 內 `graph.render()` 完成後另等 300 ms(`setTimeout(r, 300)`),`snap` 擷圖前再等 200 ms(`setTimeout(r, 200)`),合計約 500 ms 確保 G6 完整繪製後才截圖。
81
+
82
+ ## 已知限制 / 回退
83
+
84
+ - **線性鏈拉長**:節點數少、以單向鏈串接的圖(如簡單流程圖),`antv-dagre` 易將鏈路水平或垂直拉得過長,視覺上留白偏多。
85
+ - **同標籤平行邊重疊**:兩節點間若有多條邊(同方向),`polyline` 路由可能重疊,邊標籤難以區分。
86
+ - **容器代表節點限制**:跨容器邊只接到容器的第一個直屬葉節點(代表節點),若語意上應接到其他成員節點,目前需改動數據層或在 `translate` 中另設優先順序。
87
+ - **無碰撞偵測**:節點尺寸由字串估算,非實際渲染量測;CJK 與英數混排的標籤若含大量短 ASCII,估寬可能略偏窄,極端情況文字溢出節點框。
package/src/p6/gen.mjs ADDED
@@ -0,0 +1,186 @@
1
+ // p6 — AntV G6 v5 產線 adapter(資料驅動)
2
+ // genPng(data): 正規化繪圖數據 → translate 轉 G6 { nodes, combos, edges } → antv-dagre 自動排版
3
+ // → 緊湊固定間距(nodesep/ranksep, 不掃描放大) → 渲染 → 回傳 PNG Buffer
4
+ // 版面通用化: rankdir 取自數據 dir; 間距為全圖共用緊湊常數; 大畫布 + autoFit:'view', 無逐圖 w/h/edgeType 魔術數字。
5
+ //
6
+ // 三大核心品質原則(全庫設定層落實, 非逐圖補):
7
+ // 原則1 字型全覆蓋: node / edge / combo 三層 graph 預設 + 各元素 style 皆設 labelFontFamily=FONT(JhengHei),
8
+ // 任一層漏設仍由 graph 預設兜底, 確保節點/邊標籤/容器標題全用同一字型。
9
+ // 原則2 z-order 解遮蓋: combo(半透明填色)zIndex 墊底(COMBO_Z), 節點居中(預設), 邊與邊標籤畫最上層(EDGE_Z);
10
+ // 即使非成員節點幾何落入容器框, 其不透明節點本體與最上層邊標籤皆不被容器填色染淡或遮住。
11
+ // 原則3 緊湊不撐大: 不做「碰撞偵測 sweep 放大間距 until 零碰撞」; 採固定緊湊間距, 維持報告可用的緊湊尺寸。
12
+ // 若緊湊間距下仍有節點互疊且非 z-order 可解, 則保持緊湊留下重疊(代表此套不適合此圖, 由挑選階段換別套)。
13
+ import { chromium } from 'playwright'
14
+ import { colorOf, isGroupCls, EDGE, FONT } from '../common/palette.mjs'
15
+ import { pkgScript } from '../common/pkg.mjs'
16
+
17
+ // G6 v5 由本機 node_modules 內聯注入(取代 CDN, 斷網環境可用)
18
+ const G6_JS = pkgScript('@antv/g6/dist/g6.min.js')
19
+
20
+ // ===== 通用排版常數(全圖共用, 無逐圖客製) =====
21
+ const CANVAS = 2200 // 大畫布:給足夠空間, 配合 autoFit:'view' 自動縮放, 不逐圖設 w/h
22
+ // 原則3:緊湊固定間距(不掃描放大)。nodesep 同層節點間距、ranksep 層距, 取緊湊值維持報告可用尺寸。
23
+ const NODESEP = 28 // 同層節點間距(緊湊;取代舊版碰撞偵測 sweep 22→86)
24
+ const RANKSEP = 60 // 層距(緊湊;取代舊版碰撞偵測 sweep 48→190)
25
+ // 原則2:繪製順序(z-index)。combo 墊底、節點居中(預設 1)、邊與邊標籤最上層。
26
+ const COMBO_Z = -10 // 容器填色墊最底(半透明填色不染淡其上節點/文字)
27
+ const EDGE_Z = 100 // 邊與邊標籤畫最上層(不被任何區塊遮住)
28
+ // 標籤量測(依字數/行數推節點尺寸): CJK 約 15.5px/字, 英數/符號約 8.5px
29
+ const CJK_W = 15.5
30
+ const ASCII_W = 8.5
31
+ const LINE_H = 26 // 每行高度
32
+ const PAD_W = 34 // 矩形左右內距總和
33
+ const PAD_H = 20 // 矩形上下內距總和
34
+ const DIAMOND_KW = 60 // 菱形相對矩形之額外寬(通用常數, 取代舊版逐呼叫 +60)
35
+ const DIAMOND_KH = 50 // 菱形相對矩形之額外高(通用常數, 取代舊版逐呼叫 +50)
36
+
37
+ // 依標籤算尺寸(通用公式:逐字寬 + 內距; 高度依行數)
38
+ function measure(label) {
39
+ const lines = String(label).split('\n')
40
+ const widthOf = (s) => [...s].reduce((a, ch) => a + (/[一-鿿()/]/.test(ch) ? CJK_W : ASCII_W), 0)
41
+ const w = Math.round(Math.max(...lines.map(widthOf)) + PAD_W)
42
+ const h = Math.round(lines.length * LINE_H + PAD_H)
43
+ return [w, h]
44
+ }
45
+
46
+ // 正規化數據 → G6 v5 輸入 { nodes, combos, edges }
47
+ // 群組容器(cls 結尾 G/G2)→ combos(可巢狀:容器本身帶 group 時設 combo=父容器)
48
+ // 其餘節點 → nodes(type rect/diamond 依 palette.shape; combo=所屬容器; 樣式依 cls)
49
+ // 邊 → polyline; kind:dashed → lineDash;
50
+ // 邊標籤 → 背景框透明(labelBackground:false, 不遮轉折線)+ 白色光暈描邊(labelStroke/labelLineWidth/labelPaintOrder:'stroke')
51
+ function translate(data) {
52
+ const nodes = []
53
+ const combos = []
54
+ // G6 v5 不接受以 combo id 當邊端點; 建「容器 → 代表葉節點」對照, 將指向容器的邊改接其代表成員。
55
+ // 通用化舊版「層級邊改走成員節點(fe1→if1)」的手法:取各容器第一個直屬葉節點為代表, 不逐圖寫死成員 id。
56
+ const comboIds = new Set(data.nodes.filter(nd => isGroupCls(nd.cls)).map(nd => nd.id))
57
+ const kpRepByCombo = {}
58
+ for (const nd of data.nodes) {
59
+ if (nd.group && comboIds.has(nd.group) && !isGroupCls(nd.cls) && !(nd.group in kpRepByCombo)) {
60
+ kpRepByCombo[nd.group] = nd.id
61
+ }
62
+ }
63
+ const resolveEnd = (id) => (comboIds.has(id) && kpRepByCombo[id]) ? kpRepByCombo[id] : id
64
+ for (const nd of data.nodes) {
65
+ const c = colorOf(nd.cls)
66
+ if (isGroupCls(nd.cls)) {
67
+ combos.push({
68
+ id: nd.id,
69
+ combo: nd.group, // 巢狀容器(如 DET in CORE / FES in FE):父容器 id
70
+ type: 'rect',
71
+ style: {
72
+ // 原則2:容器填色墊最底, 半透明填色不會染淡/遮住其上節點與文字
73
+ zIndex: COMBO_Z,
74
+ labelText: nd.label, labelPlacement: 'top',
75
+ labelFontFamily: FONT, labelFontSize: 15, labelFontWeight: 700, labelFill: c.text,
76
+ fill: c.fill, fillOpacity: 0.3, stroke: c.stroke, lineWidth: 2,
77
+ },
78
+ })
79
+ continue
80
+ }
81
+ const isDiamond = c.shape === 'diamond'
82
+ let [w, h] = measure(nd.label)
83
+ if (isDiamond) { w += DIAMOND_KW; h += DIAMOND_KH } // 菱形需更大內距容納斜邊
84
+ nodes.push({
85
+ id: nd.id,
86
+ combo: nd.group, // 所屬容器 id(無則 undefined)
87
+ type: isDiamond ? 'diamond' : 'rect',
88
+ style: {
89
+ size: [w, h], radius: isDiamond ? 0 : 6, lineWidth: 1.8,
90
+ // 節點本體不透明(fillOpacity 預設 1), 居於 combo 之上, 完整遮住其下半透明容器填色
91
+ fill: c.fill, stroke: c.stroke,
92
+ labelText: nd.label, labelPlacement: 'center', labelFill: c.text,
93
+ labelFontSize: 14, labelFontFamily: FONT,
94
+ },
95
+ })
96
+ }
97
+ let eid = 0
98
+ const edges = data.edges.map((ed) => {
99
+ eid++
100
+ const dashed = ed.kind === 'dashed'
101
+ return {
102
+ id: 'e' + eid, source: resolveEnd(ed.from), target: resolveEnd(ed.to),
103
+ style: {
104
+ // 原則2:邊與邊標籤畫最上層, 不被容器填色或任何區塊遮住
105
+ zIndex: EDGE_Z,
106
+ lineWidth: 2.2, endArrow: true,
107
+ stroke: dashed ? colorOf('orange').stroke : EDGE.line,
108
+ lineDash: dashed ? [7, 5] : undefined,
109
+ labelText: ed.label || '',
110
+ labelFontFamily: FONT, labelFontSize: 12.5, labelFill: EDGE.text,
111
+ // 背景框透明(不遮轉折線)+ 文字白色光暈描邊(paint-order:stroke 使白邊在文字之後繪, 形成光暈)
112
+ labelBackground: false,
113
+ labelStroke: EDGE.haloColor, labelLineWidth: EDGE.haloWidth, labelPaintOrder: 'stroke',
114
+ labelAutoRotate: false,
115
+ },
116
+ }
117
+ })
118
+ return { nodes, combos, edges }
119
+ }
120
+
121
+ // ===== 渲染外殼(CDN 載入 G6 v5 + render + toDataURL overall 截圖) =====
122
+ // 原則1:graph 三層(node/edge/combo)預設 labelFontFamily 統一為 FONT, 任一元素層漏設仍由此兜底。
123
+ // 原則2:combo.style.zIndex 墊底、edge.style.zIndex 最上層(於 translate 各元素已設, graph 預設再兜底)。
124
+ // 原則3:固定緊湊 nodesep/ranksep, 無碰撞偵測掃描放大。
125
+ const html = `<!doctype html><html lang="zh-Hant"><head><meta charset="utf-8">
126
+ <script>${G6_JS}</script>
127
+ </head><body style="margin:0;background:#fff">
128
+ <div id="g6" style="background:#fff"></div>
129
+ <script>
130
+ window.renderFig = async function(g, rankdir, nodesep, ranksep, canvas, font, comboZ, edgeZ){
131
+ try {
132
+ const el = document.getElementById('g6')
133
+ el.style.width = canvas + 'px'; el.style.height = canvas + 'px'
134
+ if (window.__graph) { try { window.__graph.destroy() } catch(e){} }
135
+ const graph = new G6.Graph({
136
+ container: el, width: canvas, height: canvas, autoFit:'view',
137
+ data: { nodes: g.nodes, edges: g.edges, combos: g.combos },
138
+ layout: { type:'antv-dagre', rankdir: rankdir, nodesep: nodesep, ranksep: ranksep, sortByCombo: (g.combos && g.combos.length>0) },
139
+ // 原則2:combo 墊底、edge 最上層之 graph 預設(各元素 style 已設, 此處兜底)
140
+ combo: { type:'rect', style:{ zIndex: comboZ, padding:[30,18,18,18], labelPlacement:'top', labelFontFamily: font } },
141
+ edge: { type:'polyline', style:{ zIndex: edgeZ, lineWidth:2.2, endArrow:true, labelFontFamily: font } },
142
+ // 原則1:node/edge/combo 三層 graph 預設字型統一(JhengHei), 全覆蓋兜底
143
+ node: { style:{ labelFontFamily: font } },
144
+ })
145
+ window.__graph = graph
146
+ await graph.render()
147
+ await new Promise(r=>setTimeout(r,300))
148
+ return { ok:true }
149
+ } catch(e){ return { ok:false, err:String(e&&(e.stack||e.message)||e).slice(0,400) } }
150
+ }
151
+ // 以目前已渲染之圖截圖
152
+ window.snap = async function(){
153
+ try {
154
+ await new Promise(r=>setTimeout(r,200))
155
+ const png = await window.__graph.toDataURL({ mode:'overall' })
156
+ return { ok:true, png }
157
+ } catch(e){ return { ok:false, err:String(e&&(e.stack||e.message)||e).slice(0,400) } }
158
+ }
159
+ window.__ready=true
160
+ </script></body></html>`
161
+
162
+ // 單張渲染:給定單份正規化繪圖數據(結構同 FIGURES[n].data, caller 已先做 label 衍生)→ PNG Buffer。
163
+ // 供其他程式 import 呼叫, 不落地寫檔。沿用批次流程完全相同之 translate/render/擷圖邏輯
164
+ // (window.renderFig 排版渲染 + window.snap 之 toDataURL overall 裁切擷圖), 僅將輸出由寫檔改為
165
+ // 回傳 Buffer, 故視覺輸出(含裁切範圍)與批次流程逐圖產出完全一致。
166
+ export async function genPng(data, opt = {}) {
167
+ const browser = await chromium.launch()
168
+ try {
169
+ const page = await browser.newPage({ deviceScaleFactor: 2 })
170
+ const errs = []
171
+ page.on('pageerror', e => errs.push(e.message))
172
+ page.on('console', m => { if (m.type() === 'error') errs.push('c:' + m.text()) })
173
+ await page.setContent(html, { waitUntil: 'load' })
174
+ await page.waitForFunction(() => window.__ready, { timeout: 60000 }).catch(() => {})
175
+ await page.evaluate(() => document.fonts && document.fonts.ready)
176
+ const g = translate(data)
177
+ const r = await page.evaluate(([gg, rd, ns, rs, cv, ft, cz, ez]) => window.renderFig(gg, rd, ns, rs, cv, ft, cz, ez),
178
+ [g, data.dir, NODESEP, RANKSEP, CANVAS, FONT, COMBO_Z, EDGE_Z])
179
+ if (!r.ok) throw new Error('renderFig failed :: ' + String(r.err).split('\n')[0])
180
+ const res = await page.evaluate(() => window.snap())
181
+ if (!res.ok) throw new Error('snap failed :: ' + res.err)
182
+ return Buffer.from(res.png.split(',')[1], 'base64')
183
+ } finally {
184
+ await browser.close()
185
+ }
186
+ }
@@ -0,0 +1,84 @@
1
+ # p7 — JointJS + dagre 產線
2
+
3
+ ## 技術核心
4
+
5
+ - **繪圖庫**:`@joint/core`(JointJS)搭配 `dagre`,由 `common/pkg.mjs` 的 `pkgScript()` 讀取本機 `node_modules/@joint/core/dist/joint.min.js` 與 `node_modules/dagre/dist/dagre.min.js` 內容,內聯注入 Playwright 頁內 `<script>` 標籤(取代 CDN,斷網環境可用)。
6
+ - **載入方式**:Node 端以 Playwright 的 `page.setContent(html)` 建立含兩段內聯 `<script>` 的完整 HTML 頁,等候 `window.__ready` 旗標確認兩套庫皆就緒後,再透過 `page.evaluate()` 驅動頁內 JavaScript 函式進行排版與渲染。
7
+ - **渲染方式**:排版完成後以 `P.fitToContent({ padding: 28, allowNewOrigin: 'any', useModelGeometry: true })` 讓 JointJS Paper 自適應內容邊界,截圖目標為 `#paper svg`(`page.$('#paper svg').screenshot()`),直接輸出 PNG。
8
+ - **解析度**:`browser.newPage({ deviceScaleFactor: 2 })` 以 2 倍像素密度開頁,截出的 PNG 實際像素為邏輯尺寸的兩倍,保有高清品質。
9
+ - **字型**:頁內使用 `Microsoft JhengHei`(微軟正黑體)作為主字型,canvas 量測與 JointJS 渲染均使用同一 `FONT` 字串,確保量測結果與渲染結果一致。
10
+ - **調用方式**:本模組僅匯出 `genPng(data, opt) → Promise<Buffer>`,輸入為正規化繪圖數據 `{ dir, nodes, edges }`,回傳 PNG 之 Node Buffer;由 `src/WFlowchart.mjs` 統一匯入並依 `p7` 鍵值調用,本身不寫檔、不涉及批次流程。
11
+
12
+ ## 產製原理(資料驅動)
13
+
14
+ ### translate(data) 轉換
15
+
16
+ `translate(data)` 將正規化數據轉為 JointJS 渲染外殼所需的 `els` 陣列與 `rankDir`:
17
+
18
+ - **節點映射**:每個 `node` 轉為 `{ id, label, cls, parent }`,其中 `node.group` 直接映射為 `parent`(群組歸屬);`cls` 缺省時補 `'blue'`。
19
+ - **邊映射**:每個 `edge` 轉為 `{ source, target, label, cls }`,`from/to` 改名為 `source/target`;`kind === 'dashed'` 時 `cls` 設為 `'dashed'`,觸發後續虛線樣式;`label` 缺省補空字串。
20
+ - **流向映射**:`data.dir` 直接成為 dagre 的 `rankDir`(如 `'TB'`、`'LR'`),控制整體排列方向。
21
+
22
+ ### CLS 色票推導
23
+
24
+ `CLS` 由 `common/palette.mjs` 的 `PALETTE` 在 Node 端靜態推導後序列化注入頁內:
25
+
26
+ - 所有 cls 均帶 `fill`、`stroke`、`color`(對應 PALETTE 的 `text`)。
27
+ - `PALETTE.shape === 'diamond'` 時加入 `shape: 'diamond'`,後續節點渲染選用 `joint.shapes.standard.Polygon`(`refPoints: '50,0 100,50 50,100 0,50'`);否則使用 `joint.shapes.standard.Rectangle`(`rx/ry: 6`)。
28
+ - `PALETTE.group === true` 時加入 `group: true`,節點被識別為群組容器。
29
+
30
+ ### 群組容器表達
31
+
32
+ - `groups`(`CLS[cls].group === true` 的節點)以 `joint.shapes.standard.Rectangle` 繪製外框,`fillOpacity: 0.5`、`rx/ry: 8`,標籤欄位留空。
33
+ - 群組標題另用獨立 `joint.shapes.standard.TextBlock` 置於容器左上角(`x+8, y+4`),文字為動態換行後的多行標題,字重為 `bold`,字色取 `CLS[cls].color`(群組色調的深色前景)。
34
+ - 群組標題元件最後 push 進 cells(置於最上層),確保標題不被節點遮蔽;群組矩形本體在 `graph.resetCells()` 後再呼叫 `cellById[gp.id].toBack()` 確保沉底。
35
+ - 支援巢狀群組:`layoutContainer` 遞迴排版,子群組先排內部再以整體尺寸參與外層 dagre。
36
+
37
+ ### 邊與虛線
38
+
39
+ - 所有邊使用 `joint.shapes.standard.Link`,`stroke: '#44505a'`、`strokeWidth: 2.2`,`connector: 'rounded'`(`radius: 6`),箭頭為實心三角。
40
+ - `cls === 'dashed'` 時加 `strokeDasharray: '6,4'`。
41
+ - 有標籤的邊以自訂 markup(單一 `text` 元素,無背景框)顯示標籤;文字以白色描邊(`stroke: '#ffffff'`、`strokeWidth: 3.5`、`paintOrder: 'stroke'`)形成光暈,使底下轉折線能透出而文字仍清晰。標籤位置 `position.distance`:虛線邊固定 `0.32`(靠近來源端);實線邊依端點是否落在群組內動態調整——進入群組(僅目標端在群組內)為 `0.26`(靠來源/群組外),離開群組(僅來源端在群組內)為 `0.74`(靠目標/群組外),其餘置中 `0.5`,避免標籤疊入群組內節點。
42
+
43
+ ### 跨容器邊處理
44
+
45
+ 兩段式 layout 採用 `liftTo(nodeId, containerId)` 函式,將邊端點往上抬升至指定容器的直接子節點,使 dagre 排版能正確決定群組之間的相對 rank,同時用 `seen` 集合去除因多條邊被抬升至同一對容器而產生的重複邊,避免 dagre compound 對「群組為邊端點」的 rank bug。
46
+
47
+ ## 自動化機制
48
+
49
+ ### 兩段式 layout 流程
50
+
51
+ 1. **第一段 `layoutContainer('__root')`(由葉到根遞迴)**:對每一容器(`__root` 或具名群組)以 dagre 排版其直接子節點,得到子節點在容器內容區的相對偏移 `innerPos[childId + '@' + containerId]`,同時計算容器所需內容尺寸。群組尺寸包含兩側內距 `GPAD = 16`、動態計算的標題列高度 `titleH`。
52
+ 2. **第二段 `place('__root', 0, 0)`(由根到葉遞迴攤平)**:將相對偏移疊加為絕對中心座標 `pos[id] = { x, y, w, h }`,群組內容區起點為 `(x + GPAD, y + GPAD + titleH)`。
53
+
54
+ ### 節點尺寸通用推算
55
+
56
+ 所有葉節點尺寸由 canvas 量測(`__cx.measureText`)自動推算,無逐圖手調:
57
+
58
+ - 換行寬度上限:`maxLeafW = 480`(通用常數),`wrapText` 逐字累積,超限即斷行。
59
+ - **矩形節點**:`w = max(textWidth + 34, 90)`、`h = lines × (14 + 7) + 24`(字型大小 `FS = 14`、行間距 `+7`、上下 padding `24`)。
60
+ - **菱形節點**:`tw = textWidth + 24`、`th = lines × (14 + 6) + 16`,再以 `1.9` 倍放大後取 `max(tw*1.9, 130)` × `max(th*1.9, 90)`,使菱形外框有足夠空間包住文字。
61
+ - 群組標題列高度 `titleH`:`wrapText(gp.label, FSG=14.5, cw - GPAD*2)` 後 `lines × (14.5 + 4) + 12`,依群組內容寬度動態換行。
62
+
63
+ ### dagre 排版常數
64
+
65
+ 各層 dagre 使用相同通用常數:`nodesep: 32`、`ranksep: 48`(`genPng` 傳入之緊湊固定間距 `SEP`)、`marginx/marginy: 0`、`ranker: 'tight-tree'`,無逐圖調整。
66
+
67
+ ### fitToContent 自動裁切貼邊
68
+
69
+ `P.fitToContent({ padding: 28, allowNewOrigin: 'any', useModelGeometry: true })` 讓 JointJS Paper 依實際內容邊界自動調整 Paper 尺寸與原點,截圖結果自動貼邊,四周留 28px 留白,無需手動指定畫布寬高。
70
+
71
+ ### 從逐圖手調一般化的原則
72
+
73
+ - **群組標題列高度**:舊版需手動為每張圖的群組指定標題高度,現改為依群組內容寬換行後動態計算。
74
+ - **虛線邊標籤位置**:舊版逐圖調整 `distance`,現統一:虛線邊固定 `0.32`;實線邊依端點是否落在群組內動態取 `0.26`/`0.5`/`0.74`(見上「邊與虛線」)。
75
+ - **節點換行寬度**:舊版逐圖指定換行點,現統一 `maxLeafW = 480`,由 canvas 量測決定實際斷行位置。
76
+ - **菱形放大倍率**:統一 `1.9`,確保所有菱形節點文字不溢出。
77
+
78
+ ## 已知限制 / 回退
79
+
80
+ - **長線性鏈**:dagre `tight-tree` ranker 在純線性鏈(無分叉)時可能將各節點縱向等距拉伸,導致圖高過長。
81
+ - **跨容器邊路由**:JointJS `router: 'normal'` 不感知容器邊界,跨群組邊可能穿越其他群組框體而非繞行。
82
+ - **平行邊重疊**:同一對節點若有多條邊(如雙向箭頭或多標籤),`router: 'normal'` 會使邊完全重疊,僅顯示最後繪製的一條。
83
+ - **群組巢狀深度**:目前兩段式 layout 支援任意深度巢狀,但 dagre 跨多層的 rank 對齊可能因 `liftTo` 抬升導致內層節點相對位置與預期有偏差。
84
+ - **canvas 量測字型依賴**:節點尺寸量測使用頁內 canvas,若環境未安裝 Microsoft JhengHei 字型,量測結果(`measureText`)與最終渲染寬度可能有數像素差距,影響節點框的緊湊度。