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/p7/gen.mjs ADDED
@@ -0,0 +1,386 @@
1
+ // p7 — JointJS + dagre 產線 adapter(資料驅動)
2
+ // genPng(data): 正規化繪圖數據 → translate 轉 JointJS 內部 els → 兩段式 dagre 自動排版渲染 → 回傳 PNG Buffer
3
+ // 版面通用化: rankDir 取自數據 dir; nodesep/ranksep/GPAD/換行寬度 皆為通用常數或依標籤字寬/行數自動推算, 無逐圖魔術數字。
4
+ import { chromium } from 'playwright'
5
+ import { PALETTE } from '../common/palette.mjs'
6
+ import { pkgScript } from '../common/pkg.mjs'
7
+
8
+ // @joint/core + dagre 由本機 node_modules 內聯注入(取代 CDN, 斷網環境可用)
9
+ const JOINT_JS = pkgScript('@joint/core/dist/joint.min.js')
10
+ const DAGRE_JS = pkgScript('dagre/dist/dagre.min.js')
11
+
12
+ // CLS(JointJS 內部樣式表)由共用色票 PALETTE 推導: fill/stroke/text→color; diamond 帶 shape; *G/*G2 帶 group:true
13
+ const CLS = Object.fromEntries(Object.entries(PALETTE).map(([cls, p]) => {
14
+ const o = { fill: p.fill, stroke: p.stroke, color: p.text }
15
+ if (p.shape === 'diamond') o.shape = 'diamond'
16
+ if (p.group) o.group = true
17
+ return [cls, o]
18
+ }))
19
+
20
+ // translate(data) — 正規化數據 → JointJS 渲染外殼所需 els:
21
+ // node{id,label,cls,group?} → {id,label,cls,parent}(group→parent, 容器歸屬)
22
+ // edge{from,to,label?,kind?} → {source,target,label,cls}(from/to→source/target, kind:dashed→cls:'dashed' 走虛線)
23
+ function translate(data) {
24
+ const els = []
25
+ for (const nd of data.nodes) els.push({ id: nd.id, label: nd.label, cls: nd.cls || 'blue', parent: nd.group || null })
26
+ for (const ed of data.edges) els.push({ source: ed.from, target: ed.to, label: ed.label || '', cls: ed.kind === 'dashed' ? 'dashed' : '' })
27
+ return { els, rankDir: data.dir }
28
+ }
29
+
30
+ const html = `<!doctype html><html lang="zh-Hant"><head><meta charset="utf-8">
31
+ <script>${JOINT_JS}</script>
32
+ <script>${DAGRE_JS}</script>
33
+ </head><body style="margin:0;background:#fff">
34
+ <div id="paper" style="background:#fff"></div>
35
+ <script>
36
+ var CLS = ${JSON.stringify(CLS)}
37
+ var FONT = 'Microsoft JhengHei, "Microsoft JhengHei", sans-serif'
38
+ var FS = 14, FSG = 14.5, FSL = 12.5
39
+
40
+ // 量測文字寬度(canvas)
41
+ var __cv = document.createElement('canvas'); var __cx = __cv.getContext('2d')
42
+ function measure(text, fs){ __cx.font = fs + 'px ' + FONT; return __cx.measureText(text).width }
43
+
44
+ // 依字寬自動換行(回傳 lines 陣列 + 最大寬)
45
+ function wrapText(text, fs, maxW){
46
+ // 先嘗試以全形分隔符/括號斷點,否則逐字
47
+ var lines = [], cur = ''
48
+ for (var i = 0; i < text.length; i++){
49
+ var ch = text[i]
50
+ var test = cur + ch
51
+ if (measure(test, fs) > maxW && cur !== ''){ lines.push(cur); cur = ch }
52
+ else cur = test
53
+ }
54
+ if (cur) lines.push(cur)
55
+ var w = 0; lines.forEach(function(l){ w = Math.max(w, measure(l, fs)) })
56
+ return { lines: lines, w: w }
57
+ }
58
+
59
+ function renderFig(els, rankDir, sep){
60
+ return new Promise(function(resolve){
61
+ try {
62
+ var NODESEP = (sep && sep.nodesep) || 32 // 同層節點間距(緊湊固定基準)
63
+ var RANKSEP = (sep && sep.ranksep) || 48 // 層間距(緊湊固定基準)
64
+ var nodes = els.filter(function(x){ return x.id !== undefined })
65
+ var edges = els.filter(function(x){ return x.source !== undefined })
66
+ var nodeById = {}; nodes.forEach(function(nn){ nodeById[nn.id] = nn })
67
+
68
+ // 分類:群組(parent)與一般節點
69
+ var groups = nodes.filter(function(nn){ return CLS[nn.cls] && CLS[nn.cls].group })
70
+ var groupIds = {}; groups.forEach(function(g){ groupIds[g.id] = true })
71
+ var leaves = nodes.filter(function(nn){ return !groupIds[nn.id] })
72
+
73
+ // 量測 leaf 節點尺寸(含換行)
74
+ var maxLeafW = 480
75
+ var sizing = {}
76
+ leaves.forEach(function(nn){
77
+ var st = CLS[nn.cls] || CLS.blue
78
+ var wr = wrapText(nn.label, FS, maxLeafW)
79
+ var w, h
80
+ if (st.shape === 'diamond'){
81
+ var tw = wr.w + 24, th = wr.lines.length * (FS + 6) + 16
82
+ w = Math.max(tw * 1.9, 130); h = Math.max(th * 1.9, 90)
83
+ } else {
84
+ w = Math.max(wr.w + 34, 90); h = wr.lines.length * (FS + 7) + 24
85
+ }
86
+ sizing[nn.id] = { w: Math.round(w), h: Math.round(h), lines: wr.lines }
87
+ })
88
+
89
+ // ── 兩段式 layout(避開 dagre compound 對「群組為邊端點」的 rank bug)──
90
+ // 巢狀關係:childrenOf[parentId] = [子節點...];topLevel = 無 parent 者
91
+ var GPAD = 16 // 群組內距(標題列高 titleH 依群組各自動態計算)
92
+ var childrenOf = {}
93
+ nodes.forEach(function(nn){ var p = nn.parent || '__root'; (childrenOf[p] = childrenOf[p] || []).push(nn) })
94
+ var grpById = {}; groups.forEach(function(gp){ grpById[gp.id] = gp })
95
+ var parentOf = {}; nodes.forEach(function(nn){ parentOf[nn.id] = nn.parent || '__root' })
96
+ // 將節點往上抬升,直到其 parent 為指定容器(回傳該容器之直接子);無則回 null
97
+ function liftTo(nodeId, containerId){
98
+ var cur = nodeId
99
+ while (cur != null){
100
+ if (parentOf[cur] === containerId) return cur
101
+ cur = (parentOf[cur] === '__root') ? null : parentOf[cur]
102
+ }
103
+ return null
104
+ }
105
+
106
+ // size[id] = {w,h}:leaf 直接取量測;group 需先排內部子圖才得知
107
+ var size = {}
108
+ leaves.forEach(function(nn){ size[nn.id] = { w: sizing[nn.id].w, h: sizing[nn.id].h } })
109
+ // 群組內每個子節點相對群組內容左上角的偏移
110
+ var innerPos = {} // innerPos[childId] = {x,y} 相對其 parent 內容區
111
+ var groupContentW = {}, groupContentH = {}
112
+ var titleH = {}, titleLines = {} // 每個群組標題列高度與換行
113
+
114
+ // 對一個容器(root 或 group)內的直接子節點做 dagre 排版,回傳 {w,h} 內容尺寸
115
+ function layoutContainer(containerId){
116
+ var kids = (childrenOf[containerId] || [])
117
+ // 先遞迴排子群組,取得其尺寸
118
+ kids.forEach(function(k){ if (grpById[k.id]) { var s = layoutContainer(k.id); size[k.id] = { w: s.w, h: s.h } } })
119
+ var dg = new dagre.graphlib.Graph()
120
+ dg.setGraph({ rankdir: rankDir, nodesep: NODESEP, ranksep: RANKSEP, marginx: 0, marginy: 0, ranker: 'tight-tree' })
121
+ dg.setDefaultEdgeLabel(function(){ return {} })
122
+ var kidSet = {}; kids.forEach(function(k){ kidSet[k.id] = true; dg.setNode(k.id, { width: size[k.id].w, height: size[k.id].h }) })
123
+ // 納入「抬升至本容器直接子」後兩端相異之邊(含跨群組邊),避免重複
124
+ var seen = {}
125
+ edges.forEach(function(ed){
126
+ var s = liftTo(ed.source, containerId), t = liftTo(ed.target, containerId)
127
+ if (s && t && s !== t && kidSet[s] && kidSet[t]){
128
+ var key = s + '' + t
129
+ if (!seen[key]){ seen[key] = true; dg.setEdge(s, t) }
130
+ }
131
+ })
132
+ dagre.layout(dg)
133
+ var minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9
134
+ kids.forEach(function(k){
135
+ var p = dg.node(k.id)
136
+ minX = Math.min(minX, p.x - p.width/2); minY = Math.min(minY, p.y - p.height/2)
137
+ maxX = Math.max(maxX, p.x + p.width/2); maxY = Math.max(maxY, p.y + p.height/2)
138
+ })
139
+ if (kids.length === 0){ minX = minY = maxX = maxY = 0 }
140
+ kids.forEach(function(k){
141
+ var p = dg.node(k.id)
142
+ innerPos[k.id + '@' + containerId] = { x: p.x - p.width/2 - minX, y: p.y - p.height/2 - minY }
143
+ })
144
+ var cw = (maxX - minX), ch = (maxY - minY)
145
+ if (containerId !== '__root'){
146
+ cw += GPAD * 2
147
+ // 標題依群組內寬換行,動態決定標題列高度
148
+ var gp = grpById[containerId]
149
+ var twr = wrapText(gp.label, FSG, cw - GPAD * 2)
150
+ var th = twr.lines.length * (FSG + 4) + 12
151
+ titleH[containerId] = th; titleLines[containerId] = twr.lines
152
+ ch += GPAD * 2 + th // 群組外框含內距與標題列
153
+ }
154
+ groupContentW[containerId] = cw; groupContentH[containerId] = ch
155
+ return { w: cw, h: ch }
156
+ }
157
+ layoutContainer('__root')
158
+
159
+ // 由 root 內容起,遞迴攤平出每個節點的絕對 (x,y)=中心點
160
+ var pos = {} // pos[id] = {x,y,w,h}(x,y 為中心)
161
+ function place(containerId, originX, originY){
162
+ // originX/originY = 此容器內容區左上角絕對座標
163
+ var kids = (childrenOf[containerId] || [])
164
+ kids.forEach(function(k){
165
+ var off = innerPos[k.id + '@' + containerId]
166
+ var w = size[k.id].w, h = size[k.id].h
167
+ var topLeftX = originX + off.x, topLeftY = originY + off.y
168
+ pos[k.id] = { x: topLeftX + w/2, y: topLeftY + h/2, w: w, h: h }
169
+ if (grpById[k.id]){
170
+ // 群組內容區左上角 = 群組左上角 + 內距 +(標題列)
171
+ place(k.id, topLeftX + GPAD, topLeftY + GPAD + titleH[k.id])
172
+ }
173
+ })
174
+ }
175
+ place('__root', 0, 0)
176
+
177
+ // 建 JointJS graph
178
+ var graph = new joint.dia.Graph({}, { cellNamespace: joint.shapes })
179
+ var cells = []
180
+ var cellById = {}
181
+
182
+ // 群組容器(先畫,置底);標題用獨立 TextBlock 置於頂部中央
183
+ var groupTitleCells = []
184
+ groups.forEach(function(gp){
185
+ var p = pos[gp.id]
186
+ var st = CLS[gp.cls]
187
+ var x = p.x - p.w/2, y = p.y - p.h/2
188
+ var r = new joint.shapes.standard.Rectangle()
189
+ r.position(x, y); r.resize(p.w, p.h)
190
+ r.attr({
191
+ body: { fill: st.fill, stroke: st.stroke, strokeWidth: 2, rx: 8, ry: 8, fillOpacity: 0.5 },
192
+ label: { text: '' }
193
+ })
194
+ cells.push(r); cellById[gp.id] = r
195
+ // 標題(獨立元件,置頂中央)
196
+ var th = titleH[gp.id]
197
+ var tb = new joint.shapes.standard.TextBlock()
198
+ tb.resize(p.w - 16, th)
199
+ tb.position(x + 8, y + 4)
200
+ tb.attr({
201
+ body: { fill: 'none', stroke: 'none' },
202
+ // TextBlock 以 foreignObject<div> 渲染: font-family 屬性對 HTML div 無效(會落回瀏覽器預設 Times New Roman),
203
+ // 字型必須寫進 style(JointJS 將 style keys 複製為 div inline CSS)才會套用 → 群組標題與全圖統一 JhengHei
204
+ label: { text: (titleLines[gp.id] || [gp.label]).join('\\n'), fontFamily: FONT, fontSize: FSG, fontWeight: 'bold', fill: st.color,
205
+ style: { color: st.color, fontWeight: 'bold', fontFamily: FONT } }
206
+ })
207
+ groupTitleCells.push(tb)
208
+ })
209
+
210
+ // leaf 節點
211
+ leaves.forEach(function(nn){
212
+ var p = pos[nn.id]
213
+ var st = CLS[nn.cls] || CLS.blue
214
+ var sz = sizing[nn.id]
215
+ var cell
216
+ if (st.shape === 'diamond'){
217
+ cell = new joint.shapes.standard.Polygon()
218
+ cell.attr('body/refPoints', '50,0 100,50 50,100 0,50')
219
+ } else {
220
+ cell = new joint.shapes.standard.Rectangle()
221
+ cell.attr('body/rx', 6); cell.attr('body/ry', 6)
222
+ }
223
+ cell.position(p.x - p.w/2, p.y - p.h/2)
224
+ cell.resize(p.w, p.h)
225
+ cell.attr('body/fill', st.fill)
226
+ cell.attr('body/stroke', st.stroke)
227
+ cell.attr('body/strokeWidth', 1.8)
228
+ cell.attr('label/text', sz.lines.join('\\n'))
229
+ cell.attr('label/fontFamily', FONT)
230
+ cell.attr('label/fontSize', FS)
231
+ cell.attr('label/fill', st.color)
232
+ cell.attr('label/lineHeight', (FS + 7) + 'px')
233
+ cells.push(cell); cellById[nn.id] = cell
234
+ })
235
+
236
+ // 邊
237
+ edges.forEach(function(ed){
238
+ var s = cellById[ed.source], t = cellById[ed.target]
239
+ if (!s || !t) return
240
+ var l = new joint.shapes.standard.Link()
241
+ l.source(s); l.target(t)
242
+ var dashed = ed.cls === 'dashed'
243
+ l.attr({
244
+ line: {
245
+ stroke: '#44505a', strokeWidth: 2.2,
246
+ strokeDasharray: dashed ? '6,4' : null,
247
+ targetMarker: { type: 'path', d: 'M 10 -5 0 0 10 5 z', fill: '#44505a' }
248
+ }
249
+ })
250
+ l.router('normal'); l.connector('rounded', { radius: 6 })
251
+ if (ed.label){
252
+ // 邊標籤距離: 預設置中(0.5); 虛線(跨群組/驗證類)靠來源端;
253
+ // 跨群組邊(端點落在不同容器)把標籤推向「位於容器外」的一端, 避免疊到群組內節點。
254
+ var srcIn = parentOf[ed.source] && parentOf[ed.source] !== '__root'
255
+ var tgtIn = parentOf[ed.target] && parentOf[ed.target] !== '__root'
256
+ var dist = 0.5
257
+ if (dashed) dist = 0.32
258
+ else if (tgtIn && !srcIn) dist = 0.26 // 進入群組: 標籤靠來源(群組外)
259
+ else if (srcIn && !tgtIn) dist = 0.74 // 離開群組: 標籤靠目標(群組外)
260
+ // 全域修正 A: 背景框透明(不遮轉折線), 文字以白色光暈/描邊保持清晰
261
+ // SVG paint-order:stroke → 先描白邊再填字 → 線段能透出、文字仍清晰
262
+ l.labels([{
263
+ markup: [{ tagName: 'text', selector: 'lbl' }],
264
+ attrs: {
265
+ lbl: { text: ed.label, fontFamily: FONT, fontSize: FSL, fill: '#4a5560',
266
+ stroke: '#ffffff', strokeWidth: 3.5, paintOrder: 'stroke', strokeLinejoin: 'round',
267
+ textAnchor: 'middle', textVerticalAnchor: 'middle' }
268
+ },
269
+ position: { distance: dist }
270
+ }])
271
+ }
272
+ cells.push(l)
273
+ })
274
+
275
+ // 群組標題置於最上層
276
+ groupTitleCells.forEach(function(tb){ cells.push(tb) })
277
+
278
+ var paper = document.getElementById('paper')
279
+ var P = new joint.dia.Paper({
280
+ el: paper, model: graph,
281
+ width: 100, height: 100, background: { color: '#ffffff' },
282
+ cellViewNamespace: joint.shapes
283
+ })
284
+ graph.resetCells(cells)
285
+ // 確保群組在底層
286
+ groups.forEach(function(gp){ cellById[gp.id].toBack() })
287
+ P.fitToContent({ padding: 28, allowNewOrigin: 'any', useModelGeometry: true })
288
+
289
+ // ── 碰撞偵測(通用機制, 對所有圖一致) ─────────────────────────────
290
+ // 量測「節點 bounding box」與「邊標籤 bounding box」, 兩兩偵測重疊(>2px 計碰撞)。
291
+ // 群組容器刻意包住其子節點, 故排除「容器 ↔ 其後代」之合法重疊; 其餘皆計入。
292
+ // 座標一律取 getBoundingClientRect()(同一螢幕座標系, 跨節點/標籤可直接比對, paper 未縮放故相對關係即真實版面)。
293
+ // 容器後代集合(供排除合法包含)
294
+ var descOf = {}
295
+ function collectDesc(gid){
296
+ var set = {}; var stack = (childrenOf[gid] || []).slice()
297
+ while (stack.length){ var c = stack.pop(); set[c.id] = true; if (grpById[c.id]) (childrenOf[c.id] || []).forEach(function(k){ stack.push(k) }) }
298
+ return set
299
+ }
300
+ groups.forEach(function(gp){ descOf[gp.id] = collectDesc(gp.id) })
301
+ function toRect(dom, kind, id){
302
+ var r = dom.getBoundingClientRect()
303
+ return { kind: kind, id: id, x: r.left, y: r.top, w: r.width, h: r.height }
304
+ }
305
+ // 收集矩形: 節點(cellView body 之 client rect), 群組亦納入(偵測容器 ↔ 無關節點/容器)
306
+ var rects = []
307
+ nodes.forEach(function(nn){
308
+ var c = cellById[nn.id]; if (!c) return
309
+ var cv = P.findViewByModel(c); if (!cv) return
310
+ // 群組量「外框 body」, 不含其內子節點; 一般節點量整個 cell
311
+ var dom = grpById[nn.id] ? (cv.el.querySelector('[joint-selector="body"], rect') || cv.el) : cv.el
312
+ rects.push(toRect(dom, grpById[nn.id] ? 'group' : 'node', nn.id))
313
+ })
314
+ // 邊標籤: 取其 label <text>(同一連線之 halo+fill 為單一 text, 不重複計)
315
+ var labelRects = []
316
+ cells.forEach(function(c){
317
+ if (!(c.isLink && c.isLink())) return
318
+ if (!(c.attributes.labels && c.attributes.labels.length)) return
319
+ var lv = P.findViewByModel(c); if (!lv) return
320
+ var tnodes = lv.el.querySelectorAll('[joint-selector="lbl"]')
321
+ if (!tnodes.length) tnodes = lv.el.querySelectorAll('.labels text, .label text')
322
+ for (var ti = 0; ti < tnodes.length; ti++){
323
+ var r = tnodes[ti].getBoundingClientRect()
324
+ if (r.width < 1) continue
325
+ labelRects.push({ kind: 'label', id: c.id + ':' + ti, x: r.left, y: r.top, w: r.width, h: r.height })
326
+ }
327
+ })
328
+ function overlapPx(a, b){
329
+ var ix = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)
330
+ var iy = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)
331
+ return { ix: ix, iy: iy }
332
+ }
333
+ var allRects = rects.concat(labelRects)
334
+ var collisions = 0, minGap = 1e9, details = []
335
+ for (var i = 0; i < allRects.length; i++) for (var j = i + 1; j < allRects.length; j++){
336
+ var A = allRects[i], B = allRects[j]
337
+ // 排除合法包含: 容器 ↔ 其後代節點
338
+ if (A.kind === 'group' && descOf[A.id] && descOf[A.id][B.id]) continue
339
+ if (B.kind === 'group' && descOf[B.id] && descOf[B.id][A.id]) continue
340
+ // 排除「群組外框 ↔ 邊標籤」: 群組為半透明色塊背景(swimlane), 標籤帶白色光暈疊於其上仍清晰,
341
+ // 且跨群組邊必然穿越群組邊界 → 不計為碰撞(真正要避免的是標籤疊到節點文字/其他標籤)
342
+ if ((A.kind === 'group' && B.kind === 'label') || (B.kind === 'group' && A.kind === 'label')) continue
343
+ var ov = overlapPx(A, B)
344
+ if (ov.ix > 2 && ov.iy > 2){
345
+ collisions++
346
+ if (details.length < 4) details.push((A.kind[0]) + A.id.slice(0, 6) + '×' + (B.kind[0]) + B.id.slice(0, 6))
347
+ } else if (ov.ix > -200 && ov.iy > -200){
348
+ // 估「最近可見間隙」: 兩矩形在分離軸上的最小正向距離(僅在另一軸有重疊時才算同列/同行相鄰)
349
+ if (ov.ix <= 2 && ov.iy > 2) minGap = Math.min(minGap, -ov.ix)
350
+ if (ov.iy <= 2 && ov.ix > 2) minGap = Math.min(minGap, -ov.iy)
351
+ }
352
+ }
353
+ if (!isFinite(minGap)) minGap = 999
354
+ resolve({ ok: true, w: Math.round(P.options.width), h: Math.round(P.options.height),
355
+ collisions: collisions, minGap: Math.round(minGap), details: details })
356
+ } catch(e){ resolve({ ok: false, err: String(e.message || e), stack: String(e.stack) }) }
357
+ })
358
+ }
359
+ window.renderFig = renderFig
360
+ window.__ready = true
361
+ </script></body></html>`
362
+
363
+ // genPng(data, opt) — 單張渲染:輸入正規化繪圖數據({dir,nodes,edges}, caller 已先做 label 衍生),
364
+ // 重用上方 translate/HTML/渲染管線, 回傳 PNG 之 Node Buffer(caller 自行決定寫檔或其他用途)。
365
+ export async function genPng(data, opt = {}) {
366
+ const browser = await chromium.launch()
367
+ try {
368
+ const page = await browser.newPage({ deviceScaleFactor: 2 })
369
+ const errs = []
370
+ page.on('pageerror', ex => errs.push(ex.message))
371
+ page.on('console', m => { if (m.type() === 'error') errs.push('c:' + m.text()) })
372
+ await page.setContent(html, { waitUntil: 'load' })
373
+ await page.waitForFunction(() => window.__ready, { timeout: 60000 }).catch(() => {})
374
+ await page.evaluate(() => document.fonts && document.fonts.ready)
375
+
376
+ // 緊湊固定間距(同批次流程基準, 見下方說明)
377
+ const SEP = { nodesep: 32, ranksep: 48 }
378
+ const { els, rankDir } = translate(data)
379
+ const res = await page.evaluate(([e, rd, s]) => window.renderFig(e, rd, s), [els, rankDir, SEP])
380
+ if (!res.ok) throw new Error(res.err + (res.stack ? '\n' + res.stack : ''))
381
+ const el = await page.$('#paper svg')
382
+ return await el.screenshot()
383
+ } finally {
384
+ await browser.close()
385
+ }
386
+ }
@@ -0,0 +1,92 @@
1
+ # p8 — LogicFlow + dagre 產線
2
+
3
+ ## 技術核心
4
+
5
+ - **繪圖庫**:`@logicflow/core`(含 CSS)搭配 `dagre`,由 `common/pkg.mjs` 讀取本機檔案內聯注入 Playwright 頁內(取代 CDN,斷網環境可用):`pkgText('@logicflow/core/dist/index.css')` 內聯進 `<style>`,`pkgScript('@logicflow/core/dist/index.min.js')` 與 `pkgScript('dagre/dist/dagre.min.js')` 各自內聯進 `<script>`。
6
+ - **載入方式**:Node 端以 Playwright 建立 Chromium 頁面,將完整 HTML 字串(含內聯 CSS 與兩段內聯 `<script>`)透過 `page.setContent()` 注入;繪圖邏輯全部在頁內 `<script>` 執行,Node 端僅負責驅動與截圖。
7
+ - **渲染與截圖**:頁內 `window.renderFig(fig)` 依序計算尺寸 → dagre 排版 → `lf.render()` 渲染完後以 `setTimeout(..., 200)` 等待 DOM 穩定,回傳 Promise;Node 端再呼叫 `page.evaluate(() => document.fonts.ready)` 確保字型載入完畢,最後以 `page.locator('#app').screenshot()` 截取 `#app` 元素為 PNG Buffer 回傳(不寫檔)。
8
+ - **解析度**:`browser.newPage({ deviceScaleFactor: 2 })`,輸出為 2× 實體像素密度(HiDPI),寬高由計算所得邏輯像素乘以 2 輸出。
9
+ - **調用方式**:本模組僅匯出 `genPng(data, opt) → Promise<Buffer>`,輸入為正規化繪圖數據 `{ dir, nodes, edges }`,回傳 PNG 之 Node Buffer;由 `src/WFlowchart.mjs` 統一匯入並依 `p8` 鍵值調用,本身不寫檔、不涉及批次流程。
10
+
11
+ ## 產製原理(資料驅動)
12
+
13
+ ### translate(data) 轉換
14
+
15
+ `translate(data)` 把標準數據轉成 `{ rankDir, els }` 結構:
16
+
17
+ - **節點**:每個 `nodes[]` 項目轉為 `{ id, label, cls, parent }`,`cls` 預設為 `'blue'`,`parent` 對應 `nd.group`(群組歸屬)。
18
+ - **邊**:每個 `edges[]` 項目轉為 `{ source, target, label, dashed }`,`dashed` 由 `ed.kind === 'dashed'` 決定。
19
+ - **方向**:`data.dir` 直接映射為 dagre `rankdir`(`'TB'` 或 `'LR'`)。
20
+
21
+ ### 節點上色與形狀
22
+
23
+ - 所有 `cls` 由 `PAL`(`PALETTE` 展平成 `{ fill, stroke, text }` 的 Map)查色;查無則回退 `PAL.blue`。
24
+ - `cls === 'diamond'` → 渲染為 `cDiamond`(DiamondNode 衍生類別,用 `rx/ry` 控制菱形尺寸)。
25
+ - 其餘 → 渲染為 `cRect`(RectNode 衍生類別,圓角半徑 `radius=7`)。
26
+
27
+ ### 群組容器
28
+
29
+ - **偵測**:掃描所有節點的 `parent` 欄位,被引用為 parent 的 id 即為群組容器(`groupIds` Map)。
30
+ - **巢狀**:子群組的 `parent` 若也是群組,dagre 會以 `g.setParent(childGid, parentGid)` 建立巢狀 compound 結構。
31
+ - **渲染**:群組容器以 dagre cluster bbox 取出 `x/y/width/height`,轉為 `cRect` 節點並設 `isGroup:true`,由 `getNodeStyle()` 套用 `fillOpacity=0.45` 半透明淺底;標題文字固定置頂(`y: group.y - group.h/2 + GROUP_TITLE/2 + 4`,`GROUP_TITLE=26`);群組節點先行加入 `graphNodes`(置底),普通節點疊在其上。
32
+
33
+ ### 邊與虛線
34
+
35
+ - 實線邊 → 自訂型別 `cLine`(`PolylineEdgeModel` 衍生,`stroke=EDGE.line`,`strokeWidth=1.8`)。
36
+ - 虛線邊(`dashed:true`)→ 自訂型別 `cDash`(`strokeDasharray='6 4'`,`stroke='#7a8690'`)。
37
+ - 邊文字(`edgeText()`):背景設為 `EDGE.textBg`(透明,`fill:'transparent', stroke:'none'`),不遮轉折線;文字改以白色描邊(`stroke=EDGE.haloColor`、`strokeWidth=EDGE.haloWidth=3.5`、`paintOrder='stroke'`)形成光暈維持清晰(全域修正 A)。
38
+
39
+ ### 跨容器邊的標籤座標處理
40
+
41
+ 端點含群組容器的邊無法直接喂給 dagre(dagre compound 不允許 cluster 當邊端點),處理分兩步:
42
+
43
+ 1. **dagre 排序邊**:以 `repLeaf(gid)`(取群組的第一個 leaf 子節點)建立隱形排序邊,僅用於 dagre 決定群組相對位置,不顯示。
44
+ 2. **標籤位置計算(`labelPos`)**:
45
+ - 一端在群組內、一端在外(跨界邊):以 `LABEL_BIAS=0.62` 權重將標籤偏向群組框邊,遠離外部節點避免壓字;`LR` 方向對齊目標節點 y,`TB` 方向對齊目標節點 x,以分散 hub 節點多條邊的標籤。
46
+ - 兩端均在外/均在內但中點落在某群組 box 內:把標籤移至該 box 左外側(`box.x - box.w/2 - LABEL_GAP`,`LABEL_GAP=28`)。
47
+ - 中點不在任何 box 內:交由 LogicFlow 預設位置。
48
+
49
+ ## 自動化機制
50
+
51
+ ### 版面通用推算(零逐圖魔術數字)
52
+
53
+ 節點尺寸與畫布大小完全由數據推算,所有常數為與圖內容無關的全域預設:
54
+
55
+ | 常數 | 值 | 用途 |
56
+ |---|---|---|
57
+ | `FONT_SIZE` | 14 | 節點字級(px) |
58
+ | `LINE_H` | 22 | 行高(px) |
59
+ | `NODE_MAXW` / `DIAMOND_MAXW` | 230 / 110 | 換行觸發寬度(px) |
60
+ | `PAD_X` / `PAD_Y` | 28 / 22 | 矩形內距 |
61
+ | `DIA_PAD_X` / `DIA_PAD_Y` | 60 / 46 | 菱形內距(對角需較大留白) |
62
+ | `NODE_MINW` / `NODE_MINH` | 120 / 56 | 矩形最小尺寸 |
63
+ | `DIA_MINW` / `DIA_MINH` | 150 / 110 | 菱形最小尺寸 |
64
+ | `NODE_SEP` | 36 | dagre 同層節點間距 |
65
+ | `RANK_SEP_TB` / `RANK_SEP_LR` | 55 / 82 | dagre 層距(LR 因水平標籤需較大) |
66
+ | `MARGIN` | 20 | dagre 邊距 |
67
+ | `CANVAS_MARGIN` | 60 | 畫布外緣留白 |
68
+ | `GROUP_TITLE` | 26 | 群組標題列高 |
69
+ | `LABEL_GAP` / `LABEL_BIAS` | 28 / 0.62 | 跨界邊標籤間隙 / 偏容器側權重 |
70
+
71
+ ### 節點尺寸量測(`sizeOf` + `wrap` + `measure`)
72
+
73
+ 1. `measure(text, font)`:以隱藏 `<canvas>` 的 `2d` context 量測實際字元像素寬度。
74
+ 2. `wrap(label, maxW, font)`:逐字元(`Array.from` 支援中文多位元組)斷行,超過 `maxW` 時換行。
75
+ 3. `sizeOf(label, isDiamond)`:取最寬一行加內距(`padX*2`),行數乘行高加內距(`padY*2`),套最小尺寸下限,傳回 `{ w, h, text }`(`text` 為以 `\n` 連接的斷行後字串)。
76
+
77
+ ### 畫布自動裁切貼邊
78
+
79
+ 所有節點(含群組容器)的 `x±w/2`、`y±h/2` 求 `minX/minY/maxX/maxY` 後,畫布寬高為 `ceil(maxX-minX+CANVAS_MARGIN*2) × ceil(maxY-minY+CANVAS_MARGIN*2)`,再動態設定 `#app` 的 `width/height` style,對 `#app` 截圖即為貼邊結果。
80
+
81
+ ### `rankDir` 自動映射
82
+
83
+ `data.dir` 由各圖數據本身決定(`'TB'` 或 `'LR'`),`translate()` 直接傳入 dagre `rankdir`;`RANK_SEP` 也依 `fig.rankDir === 'LR'` 自動選用對應常數,無需逐圖設定。
84
+
85
+ ## 已知限制 / 回退
86
+
87
+ - **長線性鏈**:線性鏈(節點逐一串接無分支)dagre 會拉長單一 rank,節點間距不受 `NODE_SEP` 調節,可能導致圖面過窄或過寬。
88
+ - **同標籤平行邊重疊**:同一對節點若有多條邊(如同時有實線與虛線),LogicFlow polyline 不自動偏移,標籤會重疊,需數據層避免。
89
+ - **群組容器無法當 dagre 邊端點**:端點為群組的邊以 `repLeaf` 代替 leaf 建立隱形排序邊,LeafNode 選第一個子節點,排版順序可能與預期略有差異。
90
+ - **邊路徑為折線(polyline)**:所有邊強制使用 `polyline` 型別,對複雜拓樸可能出現折角路徑而非平滑曲線;LogicFlow 無內建曲線自動路由。
91
+ - **群組 bbox 精度依賴 dagre compound**:dagre compound cluster 的 bbox 計算在子節點很少時可能偏大,容器框視覺上顯得空曠。
92
+ - **字型量測依賴 Canvas API**:`measure()` 使用 Chromium headless 的 `<canvas>` 2d context,在 headless 環境下字型 fallback 可能影響寬度量測,導致換行點與實際渲染略有差異。