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,228 @@
1
+ // p10 — ELK 直用 + 自寫 SVG 渲染器(資料驅動)
2
+ // genPng(data): 正規化繪圖數據 → elkjs(layered, 巢狀容器 INCLUDE_CHILDREN, 正交邊路由)算佈局
3
+ // → 自寫 SVG 渲染(容器/節點/正交邊/標籤, foreignObject 渲中文)→ 截圖回傳 PNG Buffer
4
+ // p1~p9 為現成套件; p10 為自寫渲染器產線。ELK 的分層演算法對「巢狀容器 + 循環 + 正交路由」最強。
5
+ // 原則: 字型全 JhengHei; 群組容器由 ELK 正確包住成員(不逃逸); 緊湊; 中文不爆框; 邊標籤白光暈不遮線。
6
+ import { chromium } from 'playwright'
7
+ import { colorOf, isGroupCls, EDGE } from '../common/palette.mjs'
8
+ import { pkgScript } from '../common/pkg.mjs'
9
+
10
+ // elkjs 由本機 node_modules 內聯注入(取代 CDN, 斷網環境可用)
11
+ const ELK_JS = pkgScript('elkjs/lib/elk.bundled.js')
12
+
13
+ // 正規化數據 → spec(節點/群組(可巢狀)/邊)
14
+ export function translate(data) {
15
+ // mi=原始數據索引(容器與葉節點同一序列): 供渲染端 elk.position 保序(同層混排容器/葉節點時仍可比較)
16
+ const groups = data.nodes.filter(n => isGroupCls(n.cls)).map(n => { const c = colorOf(n.cls); return { id: n.id, label: n.label, parent: n.group || null, align: n.align, mi: data.nodes.indexOf(n), fill: c.fill, stroke: c.stroke, font: c.text } })
17
+ const nodes = data.nodes.filter(n => !isGroupCls(n.cls)).map(n => { const c = colorOf(n.cls); return { id: n.id, label: n.label, title: n.title, items: n.items, parent: n.group || null, mi: data.nodes.indexOf(n), diamond: c.shape === 'diamond', fill: c.fill, stroke: c.stroke, font: c.text } })
18
+ const edges = data.edges.map((e, i) => ({ id: 'e' + i, from: e.from, to: e.to, label: e.label || '', dashed: e.kind === 'dashed' }))
19
+ // 每邊之 LCA 容器: ELK 對「宣告於某容器的邊」回傳之 section 座標相對該容器; 故把邊放到 source/target 之最近共同祖先容器, 渲染才對位(否則跑到左上)
20
+ const parentOf = {}; groups.forEach(g => parentOf[g.id] = g.parent); nodes.forEach(n => parentOf[n.id] = n.parent)
21
+ const anc = id => { const r = []; let p = parentOf[id]; while (p) { r.push(p); p = parentOf[p] } return r }
22
+ edges.forEach(e => { const at = new Set(anc(e.to)); e.container = anc(e.from).find(a => at.has(a)) || null })
23
+ return { dir: data.dir || 'TB', nodes, groups, edges }
24
+ }
25
+
26
+ export const PAGE_HTML = `<!doctype html><html lang="zh-Hant"><head><meta charset="utf-8">
27
+ <script>${ELK_JS}</script>
28
+ <style>body{margin:0;background:#fff} .lbl{font-family:'Microsoft JhengHei','微軟正黑體',sans-serif}</style>
29
+ </head><body>
30
+ <div id="meas" style="position:absolute;left:-99999px;top:0;font-family:'Microsoft JhengHei',sans-serif;visibility:hidden"></div>
31
+ <div id="stage"></div>
32
+ <script>
33
+ const FONT = "'Microsoft JhengHei','微軟正黑體',sans-serif"
34
+ const EDGE_LINE = '${EDGE.line}', EDGE_TEXT = '${EDGE.text}', HALO = '${EDGE.haloColor}'
35
+ const FS = 14, GFS = 15, EFS = 12.5, MAXW = 200
36
+ // 容器內矩形框之垂直對齊: 預設 top; 可於數據之群組節點設 align:'top'|'center'|'bottom' 個別覆寫(改 DEF_VALIGN 改全域預設)
37
+ const VALIGN = { top:'TOP', center:'CENTER', bottom:'BOTTOM' }, DEF_VALIGN = 'top'
38
+ const meas = document.getElementById('meas')
39
+ function measure(label, fs, maxW){
40
+ const d=document.createElement('div')
41
+ d.style.cssText='display:inline-block;white-space:normal;overflow-wrap:break-word;word-break:normal;text-align:center;line-height:1.4;font-size:'+fs+'px;max-width:'+maxW+'px;padding:0;font-family:'+FONT
42
+ d.textContent=label; meas.appendChild(d); const r=d.getBoundingClientRect(); meas.removeChild(d)
43
+ return { w:Math.ceil(r.width), h:Math.ceil(r.height) }
44
+ }
45
+ // 量整塊 items 尺寸: 完全比照渲染(line-height 1.55、各項 nowrap)→ 直接得 items 區實際高度, 避免逐項估高(用錯 line-height)累積成底部多餘留白
46
+ function measureItems(items, fs){
47
+ const d=document.createElement('div')
48
+ d.style.cssText='display:inline-block;font-size:'+fs+'px;line-height:1.55;font-family:'+FONT
49
+ d.innerHTML=items.map(it=>'<div style="white-space:nowrap">•&#160;'+String(it).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')+'</div>').join('')
50
+ meas.appendChild(d); const r=d.getBoundingClientRect(); meas.removeChild(d)
51
+ return { w:Math.ceil(r.width), h:Math.ceil(r.height) }
52
+ }
53
+ const esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
54
+ const mix = (a,b,t) => { const pa=[1,3,5].map(i=>parseInt(a.slice(i,i+2),16)), pb=[1,3,5].map(i=>parseInt(b.slice(i,i+2),16)); return '#'+pa.map((x,i)=>Math.round(x*(1-t)+pb[i]*t).toString(16).padStart(2,'0')).join('') }
55
+ // 確保「起點後第一段」與「終點前最後一段」之直線長度 >= MIN(轉折不貼端點, 維持箭頭指向性)。
56
+ // 做法: 末段過短時, 沿趨近軸向把「最後轉角」與「其前一轉角」一起後移(兩者同移=保正交); 需 >=4 點才不動端點。
57
+ function ensureStub(pts, MIN){
58
+ function fixEnd(p){
59
+ const n=p.length; if(n<4) return
60
+ const C=p[n-1], B=p[n-2]
61
+ const dx=C.x-B.x, dy=C.y-B.y, len=Math.abs(dx)+Math.abs(dy)
62
+ if(len>=MIN || len<0.5) return
63
+ const need=MIN-len, ux=dx/len, uy=dy/len
64
+ p[n-2]={x:B.x-ux*need, y:B.y-uy*need}
65
+ p[n-3]={x:p[n-3].x-ux*need, y:p[n-3].y-uy*need}
66
+ }
67
+ fixEnd(pts)
68
+ pts.reverse(); fixEnd(pts); pts.reverse()
69
+ return pts
70
+ }
71
+
72
+ window.renderFig = async function(spec){
73
+ try {
74
+ const dir = spec.dir==='LR' ? 'RIGHT' : 'DOWN'
75
+ const nById={}, gById={}
76
+ spec.nodes.forEach(n=>nById[n.id]=n); spec.groups.forEach(g=>gById[g.id]=g)
77
+
78
+ // 節點尺寸量測
79
+ const size={}
80
+ spec.nodes.forEach(n=>{
81
+ if(n.items && n.items.length){ // 標題+項目節點(類 UML class box): 量標題與各項目 → 框寬=最寬列+邊距, 框高=標題列+各項目列
82
+ const tm=measure(n.title||n.label, FS, 250), im=measureItems(n.items, FS-1)
83
+ const headH=tm.h+12 // 標題列高(框頂到分隔線), 供分層繪製定位
84
+ size[n.id]={ w:Math.max(tm.w, im.w)+30, h:headH+im.h+10, headH } // items 區高 = 實量塊高 + 上下 padding(6+4), 與渲染一致無多餘留白
85
+ } else { const m=measure(n.label, FS, n.diamond?150:MAXW); if(n.diamond){ const d=Math.round(Math.max(m.w,m.h)*1.5+34); size[n.id]={w:d,h:d} } else size[n.id]={w:m.w+24,h:m.h+16} }
86
+ })
87
+
88
+ // 遞迴建 ELK 子樹(群組含 children)
89
+ // 保序: 數據之節點順序=語義順序(選單/報告章節順序), 令 ELK 同層節點依 model order 排列, 不因交叉最小化重排(如 GIS2D/GIS3D 被拆開)。
90
+ // considerModelOrder/forceNodeModelOrder 於 elkjs + INCLUDE_CHILDREN(巢狀)會內部崩潰, 故改用 semiInteractive:
91
+ // crossingMinimization.semiInteractive + 各葉節點 elk.position 位置提示(以數據序為序), 同層節點依提示排序, 巢狀圖亦支援。
92
+ // 例外: 含循環(如流程之退回邊)的圖, 位置提示會干擾分層使入口節點偏離頂端 → 根層不加提示(容器內仍保序)。
93
+ const ORDER_OPTS = { 'elk.layered.crossingMinimization.semiInteractive':'true' }
94
+ const hasCycle = (() => { const adj={}; spec.edges.forEach(e=>{ (adj[e.from]=adj[e.from]||[]).push(e.to) }); const st={}; const dfs=(u)=>{ st[u]=1; for(const v of (adj[u]||[])){ if(st[v]===1) return true; if(!st[v] && dfs(v)) return true } st[u]=2; return false }; return Object.keys(adj).some(u=>!st[u]&&dfs(u)) })()
95
+ const gOpts = { 'elk.padding':'[top=10,left=14,bottom=14,right=14]', 'elk.nodeLabels.placement':'[H_CENTER, V_TOP, INSIDE]', 'elk.algorithm':'layered', 'elk.direction':dir, 'elk.edgeRouting':'ORTHOGONAL', 'elk.hierarchyHandling':'INCLUDE_CHILDREN', 'elk.spacing.nodeNode':'28', 'elk.layered.spacing.nodeNodeBetweenLayers':'30', 'elk.spacing.edgeEdge':'22', 'elk.layered.spacing.edgeEdgeBetweenLayers':'24', ...ORDER_OPTS }
96
+ function elkEdge(e){ const lab = e.label ? [{ text:e.label, width: measure(e.label,EFS,300).w, height:18 }] : []; return { id:e.id, sources:[e.from], targets:[e.to], labels:lab } }
97
+ function build(parentId, padMap, wrapAR){
98
+ const children=[], edges=[]
99
+ spec.groups.filter(g=>g.parent===parentId).forEach(g=>{ const sub=build(g.id, padMap, wrapAR); const gm=measure(g.label,GFS,400)
100
+ // 第二趟: 標題比容器寬時加大該容器左右 padding 撐寬(ELK 尊重複合容器 padding; 巢狀父容器自動跟著變寬, 不溢出又不過寬)
101
+ const ex=(padMap&&padMap[g.id])||0
102
+ const go={ ...gOpts, 'elk.padding':'[top=10,left='+(14+ex)+',bottom=14,right='+(14+ex)+']' }
103
+ // A4 折排: 該容器被挑中時設 aspectRatio 觸發 ELK wrapping, 把線性鏈折成多欄/列(跨欄/列連接線由 ELK 自動正交路由)
104
+ const ar=wrapAR&&wrapAR[g.id]; if(ar){ go['elk.aspectRatio']=String(ar); go['elk.layered.wrapping.strategy']='SINGLE_EDGE' }
105
+ if(parentId || !hasCycle){ go['elk.position']='('+g.mi+','+g.mi+')' }
106
+ children.push({ id:g.id, labels:[{text:g.label, width:gm.w+12, height:gm.h+6}], layoutOptions:go, children:sub.children, edges:sub.edges }) })
107
+ spec.nodes.filter(n=>n.parent===parentId).forEach(n=>{ const va=VALIGN[(gById[parentId]&&gById[parentId].align)||DEF_VALIGN]||'TOP'; const lo={ 'elk.alignment':va }; if(parentId || !hasCycle){ lo['elk.position']='('+n.mi+','+n.mi+')' } children.push({ id:n.id, width:size[n.id].w, height:size[n.id].h, layoutOptions:lo }) })
108
+ spec.edges.filter(e=>e.container===parentId).forEach(e=>{ edges.push(elkEdge(e)) })
109
+ return { children, edges }
110
+ }
111
+ const rootOpts = { 'elk.algorithm':'layered', 'elk.direction':dir, 'elk.edgeRouting':'ORTHOGONAL', 'elk.hierarchyHandling':'INCLUDE_CHILDREN', 'elk.spacing.nodeNode':'30', 'elk.layered.spacing.nodeNodeBetweenLayers':'34', 'elk.spacing.edgeNode':'24', 'elk.spacing.edgeEdge':'22', 'elk.layered.spacing.edgeEdgeBetweenLayers':'24', 'elk.layered.spacing.edgeNodeBetweenLayers':'26', ...(hasCycle ? { 'elk.layered.cycleBreaking.strategy':'MODEL_ORDER' } : ORDER_OPTS) }
112
+ const elk = new ELK()
113
+ const mkGraph = (pm, war) => { const rb = build(null, pm, war); return { id:'root', layoutOptions:rootOpts, children:rb.children, edges:rb.edges } }
114
+ let res = await elk.layout(mkGraph({}, {}))
115
+ // 第二趟: 偵測「標題寬 > 容器寬」之容器 → 加大其左右 padding 撐寬, 重新佈局(巢狀父容器自動跟進; 標題不溢出又不過寬)
116
+ const padMap = {}
117
+ ;(function collect(cell){ (cell.children||[]).forEach(ch=>{ if(gById[ch.id]){ const lw=(ch.labels&&ch.labels[0]&&ch.labels[0].width)||0; if(lw>(ch.width||0)) padMap[ch.id]=Math.ceil((lw-ch.width)/2)+6; collect(ch) } }) })(res)
118
+ if(Object.keys(padMap).length) res = await elk.layout(mkGraph(padMap, {}))
119
+ // ---- A4 長寬比自動折排 ----
120
+ // A4 直式 h/w≈1.414、橫式≈0.707. 過高(R>HIGH)→挑最高之多子容器折成多欄縮高; 過寬(R<LOW)→挑最寬者折成多列增高.
121
+ // 折排引擎=ELK wrapping(設 aspectRatio 觸發, 自動正交畫跨欄/列連接線); 逐次加深直到落入 A4 區間 / 無可折 / 達上限.
122
+ const A4HIGH=1.45, A4LOW=0.70, wrapAR={}
123
+ const foldable = () => { const out=[]; (function w(cell){ (cell.children||[]).forEach(ch=>{ if(gById[ch.id]){ if((ch.children||[]).length>=4) out.push({id:ch.id, w:ch.width||0, h:ch.height||0}); w(ch) } }) })(res); return out }
124
+ for(let it=0; it<8; it++){
125
+ const R=(res.height||1)/(res.width||1)
126
+ if(R<=A4HIGH && R>=A4LOW) break
127
+ const fs=foldable(); if(!fs.length) break
128
+ let pick
129
+ if(R>A4HIGH){ fs.sort((a,b)=>b.h-a.h); pick=fs.find(g=>(wrapAR[g.id]||0.6)<6) }
130
+ else { fs.sort((a,b)=>b.w-a.w); pick=fs.find(g=>(wrapAR[g.id]||3.5)>0.22) }
131
+ if(!pick) break
132
+ wrapAR[pick.id] = R>A4HIGH ? (wrapAR[pick.id]||0.6)*1.9 : (wrapAR[pick.id]||3.5)/1.9
133
+ res = await elk.layout(mkGraph(padMap, wrapAR))
134
+ }
135
+
136
+ // 遞迴收集: 容器(深度序)、節點(絕對座標)、邊(以容器絕對座標 + section 點)
137
+ const groupsOut=[], nodesOut=[], edgesOut=[]
138
+ function walk(cell, ox, oy, depth){
139
+ ;(cell.children||[]).forEach(ch=>{
140
+ const ax=ox+(ch.x||0), ay=oy+(ch.y||0)
141
+ if (gById[ch.id]) { groupsOut.push({ g:gById[ch.id], x:ax, y:ay, w:ch.width, h:ch.height, depth, lbl:(ch.labels&&ch.labels[0])||null }); walk(ch, ax, ay, depth+1) }
142
+ else if (nById[ch.id]) nodesOut.push({ n:nById[ch.id], x:ax, y:ay, w:ch.width, h:ch.height })
143
+ })
144
+ ;(cell.edges||[]).forEach(e=>{ edgesOut.push({ e:e, ox:ox, oy:oy }) })
145
+ }
146
+ walk(res, 0, 0, 0)
147
+
148
+ const W=Math.ceil(res.width), H=Math.ceil(res.height), PAD=18
149
+ let svg=''
150
+ // 1) 容器(外層先畫=墊底; depth 小先畫)
151
+ groupsOut.sort((a,b)=>a.depth-b.depth).forEach(o=>{
152
+ svg+='<rect x="'+(o.x)+'" y="'+(o.y)+'" width="'+o.w+'" height="'+o.h+'" rx="8" fill="'+o.g.fill+'" fill-opacity="0.38" stroke="'+o.g.stroke+'" stroke-width="2"/>'
153
+ const tw=o.lbl?o.lbl.width+12:o.w, th=o.lbl?o.lbl.height+6:24, tx=o.x+Math.max(0,(o.w-tw)/2), ty=o.y+(o.lbl?o.lbl.y:5)
154
+ svg+='<foreignObject x="'+tx+'" y="'+ty+'" width="'+tw+'" height="'+th+'"><div xmlns="http://www.w3.org/1999/xhtml" class="lbl" style="text-align:center;font-weight:bold;font-size:'+GFS+'px;color:'+o.g.stroke+';line-height:1.3">'+esc(o.g.label)+'</div></foreignObject>'
155
+ })
156
+ // 2) 邊(在節點之下、容器之上)
157
+ edgesOut.forEach(o=>{
158
+ const e=o.e
159
+ ;(e.sections||[]).forEach(sec=>{
160
+ const pts=[sec.startPoint].concat(sec.bendPoints||[]).concat([sec.endPoint]).map(p=>({x:o.ox+p.x, y:o.oy+p.y}))
161
+ ensureStub(pts, 18)
162
+ let d='M '+pts[0].x+' '+pts[0].y; for(let i=1;i<pts.length;i++) d+=' L '+pts[i].x+' '+pts[i].y
163
+ const dash = (e.id && spec.edges.find(x=>x.id===e.id) && spec.edges.find(x=>x.id===e.id).dashed) ? ' stroke-dasharray="6 4"' : ''
164
+ svg+='<path d="'+d+'" fill="none" stroke="'+EDGE_LINE+'" stroke-width="2"'+dash+'/>'
165
+ // 箭頭(終點)
166
+ const a=pts[pts.length-2], b=pts[pts.length-1], ang=Math.atan2(b.y-a.y,b.x-a.x), L=9, w=4
167
+ const x1=b.x-L*Math.cos(ang)+w*Math.sin(ang), y1=b.y-L*Math.sin(ang)-w*Math.cos(ang)
168
+ const x2=b.x-L*Math.cos(ang)-w*Math.sin(ang), y2=b.y-L*Math.sin(ang)+w*Math.cos(ang)
169
+ svg+='<path d="M '+b.x+' '+b.y+' L '+x1+' '+y1+' L '+x2+' '+y2+' Z" fill="'+EDGE_LINE+'"/>'
170
+ })
171
+ // 邊標籤(白光暈)
172
+ ;(e.labels||[]).forEach(l=>{ if(!l.text) return; const lx=o.ox+(l.x||0), ly=o.oy+(l.y||0), lw=l.width||40, lh=l.height||18
173
+ svg+='<foreignObject x="'+lx+'" y="'+ly+'" width="'+(lw+6)+'" height="'+(lh+4)+'"><div xmlns="http://www.w3.org/1999/xhtml" class="lbl" style="font-size:'+EFS+'px;color:'+EDGE_TEXT+';text-shadow:0 0 3px '+HALO+',0 0 3px '+HALO+',0 0 3px '+HALO+',0 0 3px '+HALO+';white-space:nowrap">'+esc(l.text)+'</div></foreignObject>' })
174
+ })
175
+ // 3) 節點(最上)
176
+ nodesOut.forEach(o=>{
177
+ const n=o.n
178
+ if(n.items && n.items.length){ // 標題+項目框: 依序分層(框背景→標題背景→框線→分隔線→標題→items), 使框線置頂不被標題背景遮蔽、線寬完整
179
+ const headH=(size[n.id]&&size[n.id].headH)||26, r=7, sep=o.y+headH
180
+ const lis=n.items.map(it=>'<div style="white-space:nowrap">•&#160;'+esc(it)+'</div>').join('')
181
+ svg+='<rect x="'+o.x+'" y="'+o.y+'" width="'+o.w+'" height="'+o.h+'" rx="'+r+'" fill="'+n.fill+'"/>' // 1 框背景
182
+ svg+='<path d="M '+(o.x+r)+' '+o.y+' H '+(o.x+o.w-r)+' A '+r+' '+r+' 0 0 1 '+(o.x+o.w)+' '+(o.y+r)+' V '+sep+' H '+o.x+' V '+(o.y+r)+' A '+r+' '+r+' 0 0 1 '+(o.x+r)+' '+o.y+' Z" fill="'+mix(n.fill,n.stroke,0.16)+'"/>' // 2 標題背景(上圓角下平)
183
+ svg+='<rect x="'+o.x+'" y="'+o.y+'" width="'+o.w+'" height="'+o.h+'" rx="'+r+'" fill="none" stroke="'+n.stroke+'" stroke-width="1.6"/>' // 3 框線(頂層)
184
+ svg+='<line x1="'+o.x+'" y1="'+sep+'" x2="'+(o.x+o.w)+'" y2="'+sep+'" stroke="'+n.stroke+'" stroke-width="1"/>' // 4 分隔線
185
+ svg+='<foreignObject x="'+o.x+'" y="'+o.y+'" width="'+o.w+'" height="'+headH+'"><div xmlns="http://www.w3.org/1999/xhtml" class="lbl" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;text-align:center;font-weight:bold;font-size:'+FS+'px;color:'+n.font+'">'+esc(n.title||n.label)+'</div></foreignObject>' // 5 標題文字
186
+ svg+='<foreignObject x="'+o.x+'" y="'+sep+'" width="'+o.w+'" height="'+(o.h-headH)+'"><div xmlns="http://www.w3.org/1999/xhtml" class="lbl" style="font-size:'+(FS-1)+'px;line-height:1.55;padding:6px 9px 4px;text-align:left;color:'+n.font+'">'+lis+'</div></foreignObject>' // 6 items文字
187
+ } else if(n.diamond){ const cx=o.x+o.w/2, cy=o.y+o.h/2
188
+ svg+='<polygon points="'+cx+','+o.y+' '+(o.x+o.w)+','+cy+' '+cx+','+(o.y+o.h)+' '+o.x+','+cy+'" fill="'+n.fill+'" stroke="'+n.stroke+'" stroke-width="1.8"/>'
189
+ svg+='<foreignObject x="'+o.x+'" y="'+o.y+'" width="'+o.w+'" height="'+o.h+'"><div xmlns="http://www.w3.org/1999/xhtml" class="lbl" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;text-align:center;font-size:'+FS+'px;color:'+n.font+';line-height:1.4;padding:0 4px;box-sizing:border-box">'+esc(n.label)+'</div></foreignObject>'
190
+ } else {
191
+ svg+='<rect x="'+o.x+'" y="'+o.y+'" width="'+o.w+'" height="'+o.h+'" rx="7" fill="'+n.fill+'" stroke="'+n.stroke+'" stroke-width="1.6"/>'
192
+ svg+='<foreignObject x="'+o.x+'" y="'+o.y+'" width="'+o.w+'" height="'+o.h+'"><div xmlns="http://www.w3.org/1999/xhtml" class="lbl" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;text-align:center;font-size:'+FS+'px;color:'+n.font+';line-height:1.4;padding:0 4px;box-sizing:border-box">'+esc(n.label)+'</div></foreignObject>'
193
+ }
194
+ })
195
+
196
+ const full='<svg xmlns="http://www.w3.org/2000/svg" width="'+(W+PAD*2)+'" height="'+(H+PAD*2)+'" viewBox="'+(-PAD)+' '+(-PAD)+' '+(W+PAD*2)+' '+(H+PAD*2)+'" style="background:#fff">'+svg+'</svg>'
197
+ document.getElementById('stage').innerHTML = full
198
+ // 幾何輸出(供 test 斷言: 存在性/包含/標題不溢出/重疊/A4比例/items框高); 不影響 SVG/PNG
199
+ const geom = {
200
+ W:W+PAD*2, H:H+PAD*2,
201
+ nodes: nodesOut.map(o=>({ id:o.n.id, x:o.x, y:o.y, w:o.w, h:o.h, parent:o.n.parent||null, kind:(o.n.items&&o.n.items.length)?'items':(o.n.diamond?'diamond':'box'), headH:(size[o.n.id]&&size[o.n.id].headH)||null })),
202
+ groups: groupsOut.map(o=>({ id:o.g.id, x:o.x, y:o.y, w:o.w, h:o.h, depth:o.depth, parent:o.g.parent||null, labelW:(o.lbl&&o.lbl.width)||0 })),
203
+ edges: edgesOut.map(o=>({ id:o.e.id, segs:(o.e.sections||[]).map(sec=>[sec.startPoint].concat(sec.bendPoints||[]).concat([sec.endPoint]).map(p=>({x:o.ox+p.x,y:o.oy+p.y}))) }))
204
+ }
205
+ return { ok:true, w:W+PAD*2, h:H+PAD*2, geom }
206
+ } catch(e){ return { ok:false, err:String(e&&(e.stack||e.message)||e).slice(0,400) } }
207
+ }
208
+ window.__ready = true
209
+ </script></body></html>`
210
+
211
+ // 單張渲染 → PNG Buffer(供 WFlowchart 統一入口呼叫; 自帶瀏覽器生命週期)
212
+ export async function genPng(data, opt = {}) {
213
+ const browser = await chromium.launch()
214
+ try {
215
+ const page = await browser.newPage({ deviceScaleFactor: 2 })
216
+ await page.setContent(PAGE_HTML, { waitUntil: 'load' })
217
+ await page.waitForFunction(() => window.__ready, { timeout: 60000 }).catch(() => {})
218
+ await page.evaluate(() => document.fonts && document.fonts.ready)
219
+ const res = await page.evaluate(s => window.renderFig(s), translate(data))
220
+ if (!res.ok) throw new Error('p10 render failed: ' + res.err)
221
+ await page.evaluate(() => document.fonts && document.fonts.ready)
222
+ await page.waitForTimeout(150)
223
+ return await page.locator('#stage svg').screenshot()
224
+ }
225
+ finally {
226
+ await browser.close()
227
+ }
228
+ }
@@ -0,0 +1,87 @@
1
+ # p2 — mermaid@11 + ELK 產線
2
+
3
+ ## 技術核心
4
+
5
+ - **繪圖庫**:mermaid 第 11 版系列(package.json 以 npm alias `mermaid11` 安裝,與 p1 之第 10 版系列並存)+ `@mermaid-js/layout-elk`。兩者皆為 chunk 式 ESM(無 UMD 版可內聯注入),改以虛擬 origin `https://pkg.local/` 供應本機 `node_modules` 檔案:`routeLocalPkgs()` 對該 page 掛 `page.route(PKG_ORIGIN + '**', ...)`,攔截後以 `fs.readFileSync(pkgFile(rel))` 讀本機檔案回應(含相對 chunk import,因同 origin 而一併被攔截解析),頁內腳本以 `await import('https://pkg.local/mermaid11/dist/mermaid.esm.min.mjs')` 等路徑動態載入;不經 CDN,斷網環境亦可運作。
6
+ - **ELK 排版引擎**:啟動前呼叫 `mermaid.registerLayoutLoaders(elk)`,再於 frontmatter(`FM` 常數)中指定 `layout: elk`,使所有圖改由 ELK 演算法自動計算節點/邊位置,取代 mermaid 原生 Dagre。**實測事實**:舊版以 CDN `+esm` 載入 `@mermaid-js/layout-elk` 時,`registerLayoutLoaders` 會靜默失效並悄悄退回 dagre 版面(無錯誤訊息);改為現行本機 ESM chunk 載入後,才確實套用 ELK 排版。
7
+ - **渲染方式**:透過 `mermaid.render('g'+idx, code)` 在頁內取得 SVG 字串,注入 `#box` div,再呼叫 `page.locator('#box svg').screenshot()` 對 SVG 元素精確截圖,回傳 PNG Buffer(不落地寫檔)。
8
+ - **解析度**:`browser.newPage({ deviceScaleFactor: 2 })` 讓截圖以 2× 像素密度渲染,輸出圖像像素尺寸為 SVG 邏輯尺寸的兩倍。
9
+ - **SVG 尺寸固定**:`renderFig` 內先讀 `viewBox.baseVal` 取 `w`/`h`,若取不到則 fallback 至 `svgEl.getBBox()`,再以 `setAttribute('width', Math.ceil(w))` / `setAttribute('height', Math.ceil(h))` 鎖定尺寸,防止截圖留白。
10
+ - **字型**:頁面 body 及 mermaid `themeVariables.fontFamily` 均設為微軟正黑體(`'微軟正黑體','Microsoft JhengHei',sans-serif`),截圖前等待 `document.fonts.ready` 確保字型載入完成。
11
+
12
+ ## 產製原理(資料驅動)
13
+
14
+ 所有邏輯集中於 `translate(data)` 函式,將標準正規化數據轉為 mermaid flowchart DSL 字串。
15
+
16
+ ### 流向(dir)
17
+
18
+ - 取 `data.dir`,預設 `'TB'`(Top-to-Bottom)。
19
+ - `innerDir`(子群組內部方向)= 補方向:`dir === 'TB'` → `innerDir = 'LR'`;`dir === 'LR'` → `innerDir = 'TB'`。
20
+
21
+ ### 節點形狀
22
+
23
+ - `cls === 'diamond'` → 菱形語法 `{label}`(決策節點)。
24
+ - 其餘所有 cls → 方形圓角語法 `["label"]`。
25
+
26
+ ### 群組容器(subgraph)
27
+
28
+ - 判斷依據:`isGroupCls(nd.cls)`,即 cls 以 `G` 或 `G2` 結尾(如 `blueG`、`greenG2`)。
29
+ - 群組容器輸出為 `subgraph nodeId["label"]` + `direction innerDir` + 遞迴子節點 + `end`。
30
+ - 群組成員關係由 `nd.group` 欄位指向父群組 id;無 `group` 屬性的節點為頂層節點,遞迴入口為 `topNodes`。
31
+ - 嵌套群組:子群組本身若 `isGroupCls`,繼續遞迴展開為內層 `subgraph`,縮排隨遞迴深度加倍(每層 2 個空格)。
32
+
33
+ ### 邊與虛線
34
+
35
+ - `ed.kind === 'dashed'` → 虛線箭頭 `-.->` ;否則實線箭頭 `-->`。
36
+ - 有 `ed.label` → 插入 `|"label"|` 於箭頭中。
37
+ - 跨容器邊(from/to 分屬不同 subgraph)直接在 edges 區輸出,mermaid + ELK 自行處理跨界路由,不需額外標記。
38
+
39
+ ### 顏色(classDef)
40
+
41
+ - 遍歷所有節點的 `cls`,從 `common/palette.mjs` 的 `PALETTE` 取 `fill`、`stroke`、`text`;查無對應 cls 則 fallback 至 `PALETTE.blue`。
42
+ - 輸出 `classDef <cls> fill:...,stroke:...,stroke-width:1.5px,color:...;`,再輸出 `class <id1,id2,...> <cls>;` 將同 cls 節點合併至同一 class 行。
43
+ - 群組容器的 `stroke-width` 與一般節點同為 `1.5px`(程式碼中 `isGroupCls` 分支現值相同)。
44
+
45
+ ### 視覺後處理(setup 頁內腳本)
46
+
47
+ 渲染後對 DOM 套用強制覆寫:
48
+ - 所有 `.edgePaths path`、`path.flowchart-link` 等邊線:`stroke: #333333; stroke-width: 3px`(`!important`)。
49
+ - 所有 `marker path`(箭頭):`fill: #333333; stroke: #333333`。
50
+ - 節點 rect/polygon/circle/path:`stroke-width: 2px`。
51
+ - cluster(subgraph 框)rect/polygon/path:`stroke: #777; stroke-width: 1.5px`。
52
+
53
+ ### 對外介面
54
+
55
+ - 產線唯一輸出入口為 `genPng(data, opt)`,`data` 為正規化繪圖數據 `{ dir, nodes, edges }`,回傳 `Promise<Buffer>`(PNG);`genPng` 自帶瀏覽器生命週期(`chromium.launch()` → 渲染 → `browser.close()`),呼叫端不需自行管理 Playwright。
56
+ - 統一由 `src/WFlowchart.mjs` 依 `mode === 'p2'` 呼叫,不提供批次流程或落地寫檔行為,是否寫檔、寫至何處由呼叫端決定。
57
+
58
+ ## 自動化機制
59
+
60
+ ### 零魔術數字
61
+
62
+ p2 產線完全依賴 ELK 演算法自動排版,不手動指定任何節點座標、層距、畫布尺寸。版面計算由 ELK 根據圖結構(節點數、邊數、巢狀群組深度、`dir`/`innerDir`)自行決定。
63
+
64
+ ### 畫布自適應
65
+
66
+ `renderFig` 在渲染後讀取實際 SVG 尺寸(`viewBox` → `getBBox` fallback),以 `Math.ceil` 取整後寫回 `width`/`height` attribute,使截圖框恰好貼合圖形,不留多餘白邊。
67
+
68
+ ### 單張渲染
69
+
70
+ `genPng` 每次呼叫各自 `chromium.launch()` 一個新瀏覽器、開一個新 `page`,呼叫 `window.renderFig(code, 0)` 完成單張渲染後,等待 250 ms(`page.waitForTimeout(250)`)確保 SVG 穩定再截圖,最後關閉瀏覽器;多張圖需由呼叫端(如 `WFlowchart.mjs`)逐張呼叫 `genPng`。
71
+
72
+ ### 方向自動取補
73
+
74
+ `innerDir` 由 `dir` 自動取補(無需逐圖手調),確保子群組在視覺上與父層流向正交,整體排列更清晰。
75
+
76
+ ### 色票統一
77
+
78
+ classDef 由 `PALETTE` 查表生成,所有產線(pN)共用同一套色票,保證跨產線視覺一致;新增 cls 只需在 palette.mjs 新增一項,產線無需改動。
79
+
80
+ ## 已知限制 / 回退
81
+
82
+ - **線性鏈可能被橫向拉長**:ELK 對純線性長鏈(無分支、無群組)在 `TB` 方向有時會將節點散佈過寬,標籤換行不受控。
83
+ - **subgraph 邊框樣式有限**:mermaid subgraph 邊框不支援 dashed,群組容器視覺區分度依賴填色深淺,複雜巢狀時邊框層次較難辨別。
84
+ - **同標籤平行邊重疊**:mermaid + ELK 對同一 from→to 存在多條邊(不同 label)時,邊路徑可能重疊,label 互相遮蔽。
85
+ - **跨容器邊路由**:ELK 對穿越多層 subgraph 的邊,有時路由繞行路徑較長,視覺上不夠直觀。
86
+ - **無碰撞偵測迴圈**:p2 不含自訂碰撞偵測或間距迭代,全部交給 ELK;若 ELK 版本升級改變排版策略,輸出版面可能有差異。
87
+ - **setup 失敗處理**:mermaid/ELK 模組載入或初始化若拋錯,setup 頁內腳本以 `try/catch` 設 `window.__setupErr`,`genPng` 讀到後 `throw new Error(setupErr)` 中止該次渲染。
package/src/p2/gen.mjs ADDED
@@ -0,0 +1,210 @@
1
+ // p2 — mermaid@11 + ELK 產線 adapter(資料驅動)
2
+ // genPng(data): 正規化繪圖數據 → 轉 mermaid flowchart DSL → ELK 自動排版渲染 → 回傳 PNG Buffer
3
+ // 版面通用化: dir 取自 data.dir; 子群組方向自動取補方向; classDef/顏色取自 palette;
4
+ // 無逐圖魔術數字, ELK 全自動排版。
5
+ import { chromium } from 'playwright'
6
+ import fs from 'fs'
7
+ import { PALETTE, isGroupCls } from '../common/palette.mjs'
8
+ import { pkgFile } from '../common/pkg.mjs'
9
+
10
+ // mermaid@11 與 layout-elk 為 chunk 式 ESM(無 UMD 可內聯), 改以虛擬 origin 提供本機 node_modules 檔案
11
+ // (page.route 攔截 https://pkg.local/**, fs 直讀回應; 相對 chunk import 亦循同 origin 解析; 斷網環境可用)
12
+ const PKG_ORIGIN = 'https://pkg.local/'
13
+ async function routeLocalPkgs(page) {
14
+ await page.route(PKG_ORIGIN + '**', (route) => {
15
+ const rel = decodeURIComponent(route.request().url().slice(PKG_ORIGIN.length).split('?')[0])
16
+ try {
17
+ route.fulfill({ body: fs.readFileSync(pkgFile(rel)), contentType: rel.endsWith('.css') ? 'text/css' : 'text/javascript' })
18
+ }
19
+ catch (err) {
20
+ route.fulfill({ status: 404, body: String(err && err.message || err) })
21
+ }
22
+ })
23
+ }
24
+
25
+ const FM = `---\nconfig:\n layout: elk\n---\n`
26
+
27
+ // 正規化數據 → mermaid flowchart DSL 字串
28
+ // 策略:
29
+ // 1. flowchart {dir} 開頭
30
+ // 2. 建立 group→children 樹, 遞迴輸出 subgraph 區塊
31
+ // 3. 子群組方向 = 補方向(dir=TB → innerDir=LR; dir=LR → innerDir=TB)
32
+ // 4. 純葉節點(無 group, 非 isGroupCls): 直接輸出節點行
33
+ // 5. diamond cls → {label} 語法; 其他 → ["label"] 語法
34
+ // 6. edges: dashed → -.->, solid → -->; 有 label → |"label"| 插入
35
+ // 7. classDef: 對每個出現的 cls 從 palette 取色, 統一輸出; class 行將同 cls 節點合併
36
+ export function translate(data) {
37
+ const dir = data.dir || 'TB'
38
+ const innerDir = dir === 'TB' ? 'LR' : 'TB'
39
+
40
+ // 建立 group → children 映射(children 依 nodes 原序)
41
+ const childrenOf = {} // groupId → [nodeId]
42
+ const nodeById = {}
43
+ for (const nd of data.nodes) {
44
+ nodeById[nd.id] = nd
45
+ if (nd.group) {
46
+ if (!childrenOf[nd.group]) childrenOf[nd.group] = []
47
+ childrenOf[nd.group].push(nd.id)
48
+ }
49
+ }
50
+
51
+ // 頂層節點: 無 group 屬性的節點
52
+ const topNodes = data.nodes.filter(nd => !nd.group)
53
+
54
+ const lines = []
55
+
56
+ // 遞迴輸出: 若節點為群組容器(isGroupCls)→ 輸出 subgraph 區塊; 否則輸出節點行
57
+ function renderNode(nd, indent) {
58
+ const sp = ' '.repeat(indent)
59
+ if (isGroupCls(nd.cls)) {
60
+ lines.push(`${sp}subgraph ${nd.id}["${nd.label}"]`)
61
+ lines.push(`${sp} direction ${innerDir}`)
62
+ const kids = childrenOf[nd.id] || []
63
+ for (const cid of kids) renderNode(nodeById[cid], indent + 1)
64
+ lines.push(`${sp}end`)
65
+ } else {
66
+ const shape = nd.cls === 'diamond'
67
+ ? `{${nd.label}}`
68
+ : `["${nd.label}"]`
69
+ lines.push(`${sp}${nd.id}${shape}`)
70
+ }
71
+ }
72
+
73
+ lines.push(`flowchart ${dir}`)
74
+ for (const nd of topNodes) renderNode(nd, 1)
75
+
76
+ // edges
77
+ for (const ed of data.edges) {
78
+ const arrow = ed.kind === 'dashed' ? '-.->' : '-->'
79
+ const lbl = ed.label ? `|"${ed.label}"|` : ''
80
+ lines.push(` ${ed.from} ${arrow}${lbl} ${ed.to}`)
81
+ }
82
+
83
+ // classDef: 蒐集所有出現的 cls, 從 palette 取色
84
+ const clsSet = new Set(data.nodes.map(nd => nd.cls))
85
+ const clsByNodes = {} // cls → [id]
86
+ for (const nd of data.nodes) {
87
+ if (!clsByNodes[nd.cls]) clsByNodes[nd.cls] = []
88
+ clsByNodes[nd.cls].push(nd.id)
89
+ }
90
+ for (const cls of clsSet) {
91
+ const p = PALETTE[cls] || PALETTE.blue
92
+ const fillColor = p.fill
93
+ const strokeColor = p.stroke
94
+ const textColor = p.text
95
+ const sw = isGroupCls(cls) ? '1.5px' : '1.5px'
96
+ lines.push(` classDef ${cls} fill:${fillColor},stroke:${strokeColor},stroke-width:${sw},color:${textColor};`)
97
+ }
98
+ for (const [cls, ids] of Object.entries(clsByNodes)) {
99
+ lines.push(` class ${ids.join(',')} ${cls};`)
100
+ }
101
+
102
+ return lines.join('\n')
103
+ }
104
+
105
+ // 渲染後對 SVG 套用統一視覺加強(邊線加粗/箭頭顏色/框線寬度/邊標籤光暈)
106
+ const setup = `<!doctype html><html lang="zh-Hant"><head><meta charset="utf-8">
107
+ <style>
108
+ /* 修正 A: 邊標籤光暈 — paint-order + stroke 白邊讓線段透出、文字仍清晰 */
109
+ .edgeLabel text, .edgeLabel tspan {
110
+ paint-order: stroke fill;
111
+ stroke: #ffffff;
112
+ stroke-width: 3.5px;
113
+ stroke-linejoin: round;
114
+ }
115
+ /* 修正 A: 邊標籤背景框透明(移除不透明白底, 否則會遮斷轉折線) */
116
+ .edgeLabel rect, .edgeLabel .label-container {
117
+ fill: transparent !important;
118
+ fill-opacity: 0 !important;
119
+ stroke: none !important;
120
+ }
121
+ /* 修正 B: 節點標籤禁止英數識別字被攔腰斷行 */
122
+ .node foreignObject div, .node foreignObject span,
123
+ .label foreignObject div, .label foreignObject span {
124
+ word-break: keep-all;
125
+ overflow-wrap: break-word;
126
+ white-space: normal;
127
+ }
128
+ /* 修正 C: 字型全覆蓋 — subgraph/容器標題(cluster-label)強制 Microsoft JhengHei */
129
+ .cluster-label foreignObject div, .cluster-label foreignObject span,
130
+ .cluster-label text, .cluster-label tspan,
131
+ .nodeLabel, .nodeLabel span, text, tspan {
132
+ font-family: 'Microsoft JhengHei','微軟正黑體',sans-serif !important;
133
+ }
134
+ </style>
135
+ </head>
136
+ <body style="margin:0;background:#fff;font-family:'微軟正黑體','Microsoft JhengHei',sans-serif">
137
+ <div id="box" style="display:inline-block;padding:24px"></div>
138
+ <script type="module">
139
+ try {
140
+ const mermaid = (await import('https://pkg.local/mermaid11/dist/mermaid.esm.min.mjs')).default
141
+ const elk = (await import('https://pkg.local/@mermaid-js/layout-elk/dist/mermaid-layout-elk.esm.min.mjs')).default
142
+ mermaid.registerLayoutLoaders(elk)
143
+ mermaid.initialize({ startOnLoad:false, theme:'base', flowchart:{ useMaxWidth:false }, themeVariables:{ fontFamily:"'微軟正黑體','Microsoft JhengHei',sans-serif", fontSize:'17px', primaryColor:'#eef4fb', primaryBorderColor:'#3f6fb0', lineColor:'#44505a', primaryTextColor:'#1c2b36' } })
144
+ window.renderFig = async (code, idx) => {
145
+ try {
146
+ const { svg } = await mermaid.render('g'+idx, code)
147
+ const box = document.getElementById('box'); box.innerHTML = svg
148
+ const svgEl = box.querySelector('svg')
149
+ try {
150
+ const vb = svgEl.viewBox && svgEl.viewBox.baseVal
151
+ let w = vb && vb.width, h = vb && vb.height
152
+ if (!w || !h) { const bb = svgEl.getBBox(); w = bb.width; h = bb.height }
153
+ svgEl.style.maxWidth = 'none'
154
+ svgEl.setAttribute('width', Math.ceil(w))
155
+ svgEl.setAttribute('height', Math.ceil(h))
156
+ } catch(e){}
157
+ const DK='#333333', W='3px'
158
+ box.querySelectorAll('.edgePaths path, .edgePath path, path.flowchart-link, g.edgePaths path').forEach(p=>{p.style.setProperty('stroke',DK,'important');p.style.setProperty('stroke-width',W,'important')})
159
+ box.querySelectorAll('marker path, .marker, .marker path').forEach(m=>{m.style.setProperty('fill',DK,'important');m.style.setProperty('stroke',DK,'important')})
160
+ box.querySelectorAll('.node rect, .node polygon, .node circle, .node path').forEach(n=>n.style.setProperty('stroke-width','2px','important'))
161
+ box.querySelectorAll('.cluster rect, .cluster polygon, .cluster path').forEach(c=>{c.style.setProperty('stroke','#777','important');c.style.setProperty('stroke-width','1.5px','important')})
162
+ // 修正 A (JS 層): 邊標籤背景矩形透明 + 文字白色光暈(JS inline style 強制覆蓋 mermaid 注入的樣式)
163
+ box.querySelectorAll('.edgeLabel rect, .edgeLabel .label-container').forEach(r=>{
164
+ r.style.setProperty('fill','transparent','important')
165
+ r.style.setProperty('fill-opacity','0','important')
166
+ r.style.setProperty('stroke','none','important')
167
+ })
168
+ box.querySelectorAll('.edgeLabel text, .edgeLabel tspan').forEach(t=>{
169
+ t.style.setProperty('paint-order','stroke fill','important')
170
+ t.style.setProperty('stroke','#ffffff','important')
171
+ t.style.setProperty('stroke-width','3.5px','important')
172
+ t.style.setProperty('stroke-linejoin','round','important')
173
+ })
174
+ // 修正 C (JS 層): 字型全覆蓋 — subgraph/容器標題 cluster-label + 所有 SVG text/tspan
175
+ const FF = "'Microsoft JhengHei','微軟正黑體',sans-serif"
176
+ box.querySelectorAll('.cluster-label foreignObject div, .cluster-label foreignObject span').forEach(el=>{
177
+ el.style.setProperty('font-family',FF,'important')
178
+ })
179
+ box.querySelectorAll('text, tspan').forEach(el=>{
180
+ el.style.setProperty('font-family',FF,'important')
181
+ })
182
+ if (document.fonts && document.fonts.ready) await document.fonts.ready
183
+ return { ok:true }
184
+ } catch(e){ return { ok:false, err:(e&&(e.message||e.stack))||String(e) } }
185
+ }
186
+ window.__ready = true
187
+ } catch(e){ window.__setupErr = (e&&(e.message||e.stack))||String(e) }
188
+ </script></body></html>`
189
+
190
+ // 單張渲染 — 供其他模組 import 使用
191
+ // data: 正規化繪圖數據 { dir, nodes, edges }(caller 已先做 label 衍生)
192
+ // 回傳: PNG 圖片的 Node Buffer
193
+ export async function genPng(data, opt = {}) {
194
+ const code = FM + translate(data)
195
+ const browser = await chromium.launch()
196
+ try {
197
+ const page = await browser.newPage({ deviceScaleFactor: 2 })
198
+ await routeLocalPkgs(page)
199
+ await page.setContent(setup, { waitUntil: 'load' })
200
+ await page.waitForFunction(() => window.__ready || window.__setupErr, { timeout: 90000 }).catch(() => {})
201
+ const setupErr = await page.evaluate(() => window.__setupErr)
202
+ if (setupErr) throw new Error(setupErr)
203
+ const r = await page.evaluate(([c, idx]) => window.renderFig(c, idx), [code, 0])
204
+ if (!r.ok) throw new Error(r.err)
205
+ await page.waitForTimeout(250)
206
+ return await page.locator('#box svg').screenshot()
207
+ } finally {
208
+ await browser.close()
209
+ }
210
+ }