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.
- package/.editorconfig +9 -0
- package/.eslintignore +3 -0
- package/.eslintrc.js +55 -0
- package/.jsdoc +25 -0
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/SECURITY.md +5 -0
- package/babel.config.js +16 -0
- package/dist/w-flowchart.umd.js +7 -0
- package/dist/w-flowchart.umd.js.map +1 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.eot +0 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.ttf +0 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.woff +0 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.woff2 +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.eot +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.ttf +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.woff +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.woff2 +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.eot +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.svg +978 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.ttf +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff2 +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.eot +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.svg +1049 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.ttf +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff2 +0 -0
- package/docs/index.html +84 -0
- package/docs/scripts/collapse.js +39 -0
- package/docs/scripts/commonNav.js +28 -0
- package/docs/scripts/linenumber.js +25 -0
- package/docs/scripts/nav.js +12 -0
- package/docs/scripts/polyfill.js +4 -0
- package/docs/scripts/prettify/Apache-License-2.0.txt +202 -0
- package/docs/scripts/prettify/lang-css.js +2 -0
- package/docs/scripts/prettify/prettify.js +28 -0
- package/docs/scripts/search.js +99 -0
- package/docs/styles/jsdoc.css +776 -0
- package/docs/styles/prettify.css +80 -0
- package/package.json +49 -0
- package/script.txt +17 -0
- package/src/WFlowchart.mjs +0 -0
- package/src/common/derive.mjs +7 -0
- package/src/common/palette.mjs +34 -0
- package/src/common/pkg.mjs +31 -0
- package/src/p1/README.md +65 -0
- package/src/p1/gen.mjs +203 -0
- package/src/p10/README.md +31 -0
- package/src/p10/gen.mjs +228 -0
- package/src/p2/README.md +87 -0
- package/src/p2/gen.mjs +210 -0
- package/src/p3/README.md +99 -0
- package/src/p3/gen.mjs +188 -0
- package/src/p4/README.md +84 -0
- package/src/p4/gen.mjs +82 -0
- package/src/p5/README.md +90 -0
- package/src/p5/gen.mjs +182 -0
- package/src/p6/README.md +87 -0
- package/src/p6/gen.mjs +186 -0
- package/src/p7/README.md +84 -0
- package/src/p7/gen.mjs +386 -0
- package/src/p8/README.md +92 -0
- package/src/p8/gen.mjs +416 -0
- package/src/p9/README.md +90 -0
- package/src/p9/gen.mjs +63 -0
- package/src/p9/page9.html +420 -0
- package/test/cases.mjs +349 -0
- package/test/data//344/275/234/346/245/255/346/265/201/347/250/213/345/234/226.json +92 -0
- 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
- 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
- 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
- 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
- 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
- package/test/data//347/263/273/347/265/261/346/236/266/346/247/213/345/234/226.json +102 -0
- package/test/data//347/263/273/347/265/261/351/227/234/350/201/257/345/234/226.json +117 -0
- 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
- package/test/figures.mjs +10 -0
- package/test/lib.mjs +116 -0
- package/test/p10.test.mjs +89 -0
- package/test/pics/cycle.png +0 -0
- package/test/pics/cycle.svg +1 -0
- package/test/pics/deep-nest.png +0 -0
- package/test/pics/deep-nest.svg +1 -0
- package/test/pics/diamond.png +0 -0
- package/test/pics/diamond.svg +1 -0
- package/test/pics/edges-mix.png +0 -0
- package/test/pics/edges-mix.svg +1 -0
- package/test/pics/english.png +0 -0
- package/test/pics/english.svg +1 -0
- package/test/pics/flat-tb.png +0 -0
- package/test/pics/flat-tb.svg +1 -0
- package/test/pics/fold-tall.png +0 -0
- package/test/pics/fold-tall.svg +1 -0
- package/test/pics/fold-wide.png +0 -0
- package/test/pics/fold-wide.svg +1 -0
- package/test/pics/group-edge.png +0 -0
- package/test/pics/group-edge.svg +1 -0
- package/test/pics/items-node.png +0 -0
- package/test/pics/items-node.svg +1 -0
- package/test/pics/long-label.png +0 -0
- package/test/pics/long-label.svg +1 -0
- package/test/pics/lr-flat.png +0 -0
- package/test/pics/lr-flat.svg +1 -0
- package/test/pics/nested-group.png +0 -0
- package/test/pics/nested-group.svg +1 -0
- package/test/pics/no-edges.png +0 -0
- package/test/pics/no-edges.svg +1 -0
- package/test/pics/title-overflow.png +0 -0
- package/test/pics/title-overflow.svg +1 -0
- package/test/pics/valign.png +0 -0
- package/test/pics/valign.svg +1 -0
- package/test/pics//344/275/234/346/245/255/346/265/201/347/250/213/345/234/226.png +0 -0
- package/test/pics//344/275/234/346/245/255/346/265/201/347/250/213/345/234/226.svg +1 -0
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- package/test/pics//347/263/273/347/265/261/346/236/266/346/247/213/345/234/226.png +0 -0
- package/test/pics//347/263/273/347/265/261/346/236/266/346/247/213/345/234/226.svg +1 -0
- package/test/pics//347/263/273/347/265/261/351/227/234/350/201/257/345/234/226.png +0 -0
- package/test/pics//347/263/273/347/265/261/351/227/234/350/201/257/345/234/226.svg +1 -0
- 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
- 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
- package/test/wflowchart.test.mjs +41 -0
- package/toolg/addVersion.mjs +4 -0
- package/toolg/cleanFolder.mjs +4 -0
- package/toolg/gDistRollup.mjs +51 -0
- package/toolg/modifyReadme.mjs +4 -0
package/src/p8/gen.mjs
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// p8 — LogicFlow + dagre 產線 adapter(資料驅動)
|
|
2
|
+
// genPng(data): 正規化繪圖數據 → translate 轉 LF nodes/edges → dagre 自動排版渲染 → 回傳 PNG Buffer
|
|
3
|
+
// 版面通用化: rankDir 取自數據 dir; 節點尺寸依 label 字寬/行數自動推算; 無逐圖魔術數字。
|
|
4
|
+
// 間距(nodesep/ranksep)由「碰撞偵測自適應」迴圈決定: 渲染→量測節點/群組/邊標籤 bbox→偵測重疊→由小到大掃描間距取首個零碰撞(且留可見間隙)。
|
|
5
|
+
// 邊標籤(全域修正 A): 背景透明(不遮轉折線) + 文字加白色光暈(SVG paint-order:stroke + stroke=白 stroke-width≈3.5)。
|
|
6
|
+
import { chromium } from 'playwright'
|
|
7
|
+
import { PALETTE, EDGE, FONT, colorOf } from '../common/palette.mjs'
|
|
8
|
+
import { pkgScript, pkgText } from '../common/pkg.mjs'
|
|
9
|
+
|
|
10
|
+
// @logicflow/core(js+css)+ dagre 由本機 node_modules 內聯注入(取代 CDN, 斷網環境可用)
|
|
11
|
+
const LOGICFLOW_CSS = pkgText('@logicflow/core/dist/index.css')
|
|
12
|
+
const LOGICFLOW_JS = pkgScript('@logicflow/core/dist/index.min.js')
|
|
13
|
+
const DAGRE_JS = pkgScript('dagre/dist/dagre.min.js')
|
|
14
|
+
|
|
15
|
+
// 正規化數據(dir/nodes/edges) → LF 內部結構: nodes 用 parent 表群組歸屬; edges 用 source/target/dashed。
|
|
16
|
+
// 套色: 由 cls 查 PALETTE(group 類別自動為淺底容器); 形狀: diamond→決策菱形, 其餘→矩形; dir→dagre rankDir; kind:dashed→虛線。
|
|
17
|
+
function translate(data) {
|
|
18
|
+
const els = []
|
|
19
|
+
for (const nd of data.nodes) els.push({ id: nd.id, label: nd.label, cls: nd.cls || 'blue', parent: nd.group })
|
|
20
|
+
for (const ed of data.edges) els.push({ source: ed.from, target: ed.to, label: ed.label || '', dashed: ed.kind === 'dashed' })
|
|
21
|
+
return { rankDir: data.dir, els }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 由標準色票 PALETTE 攤平成 { cls: {fill,stroke,text} } 供瀏覽器端套用(group 類別亦取同三欄, 其淺底由 isGroup 旗標控制透明度)。
|
|
25
|
+
const PAL = Object.fromEntries(Object.entries(PALETTE).map(([cls, p]) => [cls, { fill: p.fill, stroke: p.stroke, text: p.text }]))
|
|
26
|
+
const PAL_JSON = JSON.stringify(PAL)
|
|
27
|
+
const EDGE_JSON = JSON.stringify(EDGE)
|
|
28
|
+
|
|
29
|
+
const html = `<!doctype html><html lang="zh-Hant"><head><meta charset="utf-8">
|
|
30
|
+
<style>${LOGICFLOW_CSS}</style>
|
|
31
|
+
<script>${LOGICFLOW_JS}</script>
|
|
32
|
+
<script>${DAGRE_JS}</script>
|
|
33
|
+
<style>
|
|
34
|
+
* { font-family: ${FONT}; }
|
|
35
|
+
html,body{ margin:0; background:#fff; }
|
|
36
|
+
#app { background:#fff; }
|
|
37
|
+
.lf-grid { display:none !important; }
|
|
38
|
+
</style>
|
|
39
|
+
</head><body>
|
|
40
|
+
<div id="app"></div>
|
|
41
|
+
<script>
|
|
42
|
+
var PAL = ${PAL_JSON}
|
|
43
|
+
var EDGE = ${EDGE_JSON}
|
|
44
|
+
var FONT = ${JSON.stringify(`14px ${FONT}`)} // canvas 量測用之 font 短語
|
|
45
|
+
var FONT_FAMILY = ${JSON.stringify(FONT)} // 純 font-family(供 SVG text 明確指定, 字型全覆蓋)
|
|
46
|
+
|
|
47
|
+
// ---- 通用版面常數(非逐圖; 皆為與內容無關之全域預設) ----
|
|
48
|
+
var FONT_SIZE = 14, LINE_H = 22 // 節點字級 / 行高
|
|
49
|
+
var NODE_MAXW = 230, DIAMOND_MAXW = 110 // 換行最大寬度(矩形 / 菱形)
|
|
50
|
+
var PAD_X = 28, PAD_Y = 22 // 矩形內距
|
|
51
|
+
var DIA_PAD_X = 60, DIA_PAD_Y = 46 // 菱形內距(對角需較大留白)
|
|
52
|
+
var NODE_MINW = 120, NODE_MINH = 56 // 矩形最小尺寸
|
|
53
|
+
var DIA_MINW = 150, DIA_MINH = 110 // 菱形最小尺寸
|
|
54
|
+
var MARGIN = 20 // dagre 邊距
|
|
55
|
+
var GROUP_TITLE = 26 // 群組容器標題列高
|
|
56
|
+
var CANVAS_MARGIN = 60 // 畫布外緣留白
|
|
57
|
+
var LABEL_GAP = 28, LABEL_BIAS = 0.62 // 跨界邊標籤離容器框間隙 / 偏容器側權重
|
|
58
|
+
|
|
59
|
+
// ---- 緊湊固定間距(通用機制, 對所有圖一致; 不做「掃描放大 until 零碰撞」) ----
|
|
60
|
+
// 一律以單一緊湊間距排版, 維持報告可用的緊湊尺寸; 不因避碰撞而把圖撐大。
|
|
61
|
+
// 文字遮蔽改由繪製順序(z-order)解決(群組墊底 / 節點居中 / 邊與邊標籤置頂), 不靠加大間距。
|
|
62
|
+
// LR 層距略大於 TB(水平向標籤較長), 仍為固定值。
|
|
63
|
+
var SEP_TB = [36, 55] // [nodesep, ranksep] — 上下向緊湊間距
|
|
64
|
+
var SEP_LR = [36, 82] // [nodesep, ranksep] — 左右向緊湊間距
|
|
65
|
+
var COLLIDE_TOL = 2 // 重疊 >此像素 才計為碰撞(僅供回報用, 不再驅動間距放大)
|
|
66
|
+
|
|
67
|
+
// 量測文字寬度
|
|
68
|
+
var _cv = document.createElement('canvas'); var _cx = _cv.getContext('2d')
|
|
69
|
+
function measure(t, font){ _cx.font = font; return _cx.measureText(t).width }
|
|
70
|
+
|
|
71
|
+
// 將長 label 依最大寬度斷行: 中文逐字可斷; 連續英數識別字(token)視為不可攔腰斷之整體, 整段超寬才獨佔一行。
|
|
72
|
+
function wrap(label, maxW, font){
|
|
73
|
+
_cx.font = font
|
|
74
|
+
// 先切成 token: 連續英數(含 . _ / 等識別字常見字元)為一個 token, 其餘(中文/標點/空白)逐字
|
|
75
|
+
var chars = Array.from(label)
|
|
76
|
+
var tokens = [], buf = ''
|
|
77
|
+
function isWordChar(c){ return /[0-9A-Za-z._/\\-]/.test(c) }
|
|
78
|
+
for (var i=0;i<chars.length;i++){
|
|
79
|
+
if (isWordChar(chars[i])){ buf += chars[i] }
|
|
80
|
+
else { if (buf){ tokens.push(buf); buf='' } tokens.push(chars[i]) }
|
|
81
|
+
}
|
|
82
|
+
if (buf) tokens.push(buf)
|
|
83
|
+
var lines = [], cur = ''
|
|
84
|
+
for (var k=0;k<tokens.length;k++){
|
|
85
|
+
var tk = tokens[k]
|
|
86
|
+
var test = cur + tk
|
|
87
|
+
if (_cx.measureText(test).width > maxW && cur.length>0){ lines.push(cur); cur = tk }
|
|
88
|
+
else cur = test
|
|
89
|
+
}
|
|
90
|
+
if (cur) lines.push(cur)
|
|
91
|
+
// 去除純空白造成的行首空白
|
|
92
|
+
return lines.map(function(l){ return l.replace(/^\\s+/, '') }).filter(function(l){ return l.length>0 })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 依 label 自動推算節點尺寸(寬: 依最寬一行字寬 + 內距; 高: 依行數 + 內距; 皆套最小值)
|
|
96
|
+
function sizeOf(label, isDiamond){
|
|
97
|
+
var maxW = isDiamond ? DIAMOND_MAXW : NODE_MAXW
|
|
98
|
+
var lines = wrap(label, maxW, FONT)
|
|
99
|
+
if (!lines.length) lines = [label]
|
|
100
|
+
var w = 0
|
|
101
|
+
lines.forEach(function(l){ w = Math.max(w, measure(l, FONT)) })
|
|
102
|
+
var padX = isDiamond ? DIA_PAD_X : PAD_X, padY = isDiamond ? DIA_PAD_Y : PAD_Y
|
|
103
|
+
var W = Math.ceil(w + padX*2)
|
|
104
|
+
var H = Math.ceil(lines.length*LINE_H + padY*2)
|
|
105
|
+
if (isDiamond){ W = Math.max(W, DIA_MINW); H = Math.max(H, DIA_MINH) }
|
|
106
|
+
else { W = Math.max(W, NODE_MINW); H = Math.max(H, NODE_MINH) }
|
|
107
|
+
return { w: W, h: H, text: lines.join('\\n') }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 兩矩形重疊量(回傳 {ix,iy} 為水平/垂直交集像素, 皆>0 才重疊)
|
|
111
|
+
function overlap(a, b){
|
|
112
|
+
var ix = Math.min(a.x+a.w/2, b.x+b.w/2) - Math.max(a.x-a.w/2, b.x-b.w/2)
|
|
113
|
+
var iy = Math.min(a.y+a.h/2, b.y+b.h/2) - Math.max(a.y-a.h/2, b.y-b.h/2)
|
|
114
|
+
return { ix: ix, iy: iy }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---- 解析 fig 為內部結構 + 量測尺寸(只做一次, 不隨間距改變) ----
|
|
118
|
+
function prepare(fig){
|
|
119
|
+
var els = fig.els
|
|
120
|
+
var nodes = els.filter(function(x){ return !('source' in x) })
|
|
121
|
+
var edges = els.filter(function(x){ return ('source' in x) })
|
|
122
|
+
var byId = {}; nodes.forEach(function(nd){ byId[nd.id] = nd })
|
|
123
|
+
var groupIds = {}; nodes.forEach(function(nd){ if (nd.parent) groupIds[nd.parent] = true })
|
|
124
|
+
nodes.forEach(function(nd){
|
|
125
|
+
if (groupIds[nd.id]) return
|
|
126
|
+
var sz = sizeOf(nd.label, nd.cls === 'diamond')
|
|
127
|
+
nd._w = sz.w; nd._h = sz.h; nd._text = sz.text
|
|
128
|
+
})
|
|
129
|
+
return { nodes: nodes, edges: edges, byId: byId, groupIds: groupIds, rankDir: fig.rankDir }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---- 以給定間距做 dagre compound 排版, 回傳座標/群組框/邊標籤座標(供偵測與渲染) ----
|
|
133
|
+
function layout(P, nodeSep, rankSep){
|
|
134
|
+
var nodes = P.nodes, edges = P.edges, byId = P.byId, groupIds = P.groupIds
|
|
135
|
+
var g = new dagre.graphlib.Graph({ compound:true })
|
|
136
|
+
g.setGraph({ rankdir: P.rankDir, nodesep: nodeSep, ranksep: rankSep, marginx: MARGIN, marginy: MARGIN })
|
|
137
|
+
g.setDefaultEdgeLabel(function(){ return {} })
|
|
138
|
+
Object.keys(groupIds).forEach(function(gid){ g.setNode(gid, { label: gid }) })
|
|
139
|
+
Object.keys(groupIds).forEach(function(gid){
|
|
140
|
+
var gp = byId[gid] && byId[gid].parent
|
|
141
|
+
if (gp && groupIds[gp]) g.setParent(gid, gp)
|
|
142
|
+
})
|
|
143
|
+
nodes.forEach(function(nd){
|
|
144
|
+
if (groupIds[nd.id]) return
|
|
145
|
+
g.setNode(nd.id, { width: nd._w, height: nd._h })
|
|
146
|
+
if (nd.parent) g.setParent(nd.id, nd.parent)
|
|
147
|
+
})
|
|
148
|
+
function repLeaf(gid){
|
|
149
|
+
var c = nodes.filter(function(x){ return x.parent === gid && !groupIds[x.id] })
|
|
150
|
+
return c.length ? c[0].id : null
|
|
151
|
+
}
|
|
152
|
+
edges.forEach(function(ed){
|
|
153
|
+
var sG = groupIds[ed.source], tG = groupIds[ed.target]
|
|
154
|
+
if (!sG && !tG){ g.setEdge(ed.source, ed.target); return }
|
|
155
|
+
var s = sG ? repLeaf(ed.source) : ed.source
|
|
156
|
+
var t = tG ? repLeaf(ed.target) : ed.target
|
|
157
|
+
if (s && t) g.setEdge(s, t)
|
|
158
|
+
})
|
|
159
|
+
dagre.layout(g)
|
|
160
|
+
|
|
161
|
+
// leaf 節點座標
|
|
162
|
+
var lfNodes = []
|
|
163
|
+
nodes.forEach(function(nd){
|
|
164
|
+
if (groupIds[nd.id]) return
|
|
165
|
+
var p = g.node(nd.id)
|
|
166
|
+
var pal = PAL[nd.cls] || PAL.blue
|
|
167
|
+
var type = nd.cls === 'diamond' ? 'diamond' : 'rect'
|
|
168
|
+
lfNodes.push({ id: nd.id, type: type, x: Math.round(p.x), y: Math.round(p.y), text: nd._text, _pal: pal, _w: nd._w, _h: nd._h })
|
|
169
|
+
})
|
|
170
|
+
// 群組容器框(dagre cluster bbox)
|
|
171
|
+
function depth(gid){ var d=0,p=byId[gid]&&byId[gid].parent; while(p){ d++; p=byId[p]&&byId[p].parent } return d }
|
|
172
|
+
var lfGroupNodes = []
|
|
173
|
+
Object.keys(groupIds).sort(function(a,b){ return depth(a)-depth(b) }).forEach(function(gid){
|
|
174
|
+
var gn = g.node(gid)
|
|
175
|
+
var pal = PAL[byId[gid].cls] || PAL.blue
|
|
176
|
+
lfGroupNodes.push({ id: gid, label: byId[gid].label, _pal: pal,
|
|
177
|
+
x: Math.round(gn.x), y: Math.round(gn.y), _w: Math.round(gn.width), _h: Math.round(gn.height) })
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
// 幾何查詢
|
|
181
|
+
var geo = {}
|
|
182
|
+
lfNodes.forEach(function(nd){ geo[nd.id]={ x:nd.x, y:nd.y, w:nd._w, h:nd._h } })
|
|
183
|
+
lfGroupNodes.forEach(function(b){ geo[b.id]={ x:b.x, y:b.y, w:b._w, h:b._h } })
|
|
184
|
+
var parentBox = {}
|
|
185
|
+
nodes.forEach(function(nd){ if (nd.parent && geo[nd.parent]) parentBox[nd.id] = geo[nd.parent] })
|
|
186
|
+
function inGroup(id){ return !!parentBox[id] }
|
|
187
|
+
|
|
188
|
+
// 跨界邊標籤座標(落在外部節點與群組框之間淨空)
|
|
189
|
+
function labelPos(ed){
|
|
190
|
+
if (!ed.label) return null
|
|
191
|
+
var sIn = inGroup(ed.source) || groupIds[ed.source]
|
|
192
|
+
var tIn = inGroup(ed.target) || groupIds[ed.target]
|
|
193
|
+
var outId = null, inId = null
|
|
194
|
+
if (sIn && !tIn){ inId=ed.source; outId=ed.target }
|
|
195
|
+
else if (!sIn && tIn){ outId=ed.source; inId=ed.target }
|
|
196
|
+
else {
|
|
197
|
+
var s = geo[ed.source], t = geo[ed.target]
|
|
198
|
+
if (!s || !t) return null
|
|
199
|
+
var mx = (s.x+t.x)/2, my = (s.y+t.y)/2
|
|
200
|
+
var hit = null
|
|
201
|
+
lfGroupNodes.forEach(function(b){
|
|
202
|
+
if (mx > b.x-b._w/2 && mx < b.x+b._w/2 && my > b.y-b._h/2 && my < b.y+b._h/2) hit = b
|
|
203
|
+
})
|
|
204
|
+
if (!hit) return null
|
|
205
|
+
return { x: Math.round(hit.x - hit._w/2 - LABEL_GAP), y: Math.round(my) }
|
|
206
|
+
}
|
|
207
|
+
var o = geo[outId], box = parentBox[inId] || geo[inId]
|
|
208
|
+
var tgt = geo[ed.target] || geo[inId]
|
|
209
|
+
if (!o || !box || !tgt) return null
|
|
210
|
+
var bias = LABEL_BIAS, obias = 1 - LABEL_BIAS
|
|
211
|
+
if (P.rankDir === 'LR'){
|
|
212
|
+
var boxEdgeX = (o.x < box.x) ? (box.x - box.w/2) : (box.x + box.w/2)
|
|
213
|
+
var oInnerX = o.x + (o.x<box.x ? o.w/2 : -o.w/2)
|
|
214
|
+
var lx = boxEdgeX*bias + oInnerX*obias
|
|
215
|
+
return { x: Math.round(lx), y: Math.round(tgt.y) }
|
|
216
|
+
} else {
|
|
217
|
+
var boxEdgeY = (o.y < box.y) ? (box.y - box.h/2) : (box.y + box.h/2)
|
|
218
|
+
var oInnerY = o.y + (o.y<box.y ? o.h/2 : -o.h/2)
|
|
219
|
+
var ly = boxEdgeY*bias + oInnerY*obias
|
|
220
|
+
return { x: Math.round(tgt.x), y: Math.round(ly) }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 邊 + 標籤框(供偵測). 標籤框尺寸依文字量測。
|
|
225
|
+
var lfEdges = [], labelBoxes = []
|
|
226
|
+
edges.forEach(function(ed, i){
|
|
227
|
+
var lp = labelPos(ed)
|
|
228
|
+
var txt = ed.label ? (lp ? { value: ed.label, x: lp.x, y: lp.y } : ed.label) : ''
|
|
229
|
+
lfEdges.push({ id:'e'+i, sourceNodeId: ed.source, targetNodeId: ed.target, text: txt, _dashed: ed.dashed })
|
|
230
|
+
if (ed.label){
|
|
231
|
+
// 標籤未指定座標時, 取兩端中點估其位置(供偵測)
|
|
232
|
+
var lx, ly
|
|
233
|
+
if (lp){ lx = lp.x; ly = lp.y }
|
|
234
|
+
else { var s=geo[ed.source], t=geo[ed.target]; if(!s||!t) return; lx=(s.x+t.x)/2; ly=(s.y+t.y)/2 }
|
|
235
|
+
var lw = measure(ed.label, FONT) + 14, lh = LINE_H
|
|
236
|
+
labelBoxes.push({ x: lx, y: ly, w: lw, h: lh, _label: true })
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
return { lfNodes: lfNodes, lfGroupNodes: lfGroupNodes, lfEdges: lfEdges, labelBoxes: labelBoxes,
|
|
241
|
+
byId: byId, parentBox: parentBox }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---- 偵測重疊: leaf↔leaf、group↔group(非巢狀)、leaf↔group(非歸屬)、邊標籤↔節點/其他標籤 ----
|
|
245
|
+
// 回傳 { collisions, minGap(零碰撞時計區塊間最小可見間隙) }
|
|
246
|
+
function detect(L){
|
|
247
|
+
var byId = L.byId
|
|
248
|
+
// 取某 leaf/group 的祖先群組鏈(含自身), 用於排除「容器內含其成員」的合法重疊
|
|
249
|
+
function ancestors(id){ var arr=[id], p=byId[id]&&byId[id].parent; while(p){ arr.push(p); p=byId[p]&&byId[p].parent } return arr }
|
|
250
|
+
// 判 a 是否為 b 之祖先或同一(容器包含成員 → 非碰撞)
|
|
251
|
+
function related(idA, idB){
|
|
252
|
+
var ca = ancestors(idA), cb = ancestors(idB)
|
|
253
|
+
return ca.indexOf(idB) >= 0 || cb.indexOf(idA) >= 0
|
|
254
|
+
}
|
|
255
|
+
// 待偵測之矩形集合(節點 + 群組框, 各帶 id; 標籤無 id)
|
|
256
|
+
var rects = []
|
|
257
|
+
L.lfNodes.forEach(function(n){ rects.push({ id:n.id, x:n.x, y:n.y, w:n._w, h:n._h, kind:'node' }) })
|
|
258
|
+
L.lfGroupNodes.forEach(function(b){ rects.push({ id:b.id, x:b.x, y:b.y, w:b._w, h:b._h, kind:'group' }) })
|
|
259
|
+
var labels = L.labelBoxes.map(function(l){ return { x:l.x, y:l.y, w:l.w, h:l.h, kind:'label' } })
|
|
260
|
+
|
|
261
|
+
var collisions = 0, minGap = Infinity
|
|
262
|
+
// 節點/群組 兩兩(排除容器-成員關係)
|
|
263
|
+
for (var i=0;i<rects.length;i++){
|
|
264
|
+
for (var j=i+1;j<rects.length;j++){
|
|
265
|
+
var A=rects[i], B=rects[j]
|
|
266
|
+
if (related(A.id, B.id)) continue
|
|
267
|
+
var ov = overlap(A, B)
|
|
268
|
+
if (ov.ix > COLLIDE_TOL && ov.iy > COLLIDE_TOL){ collisions++ }
|
|
269
|
+
else {
|
|
270
|
+
// 未重疊: 取較近軸向之間隙(僅在另一軸有投影重疊時才計, 否則為斜對角不相鄰)
|
|
271
|
+
if (ov.ix > 0) minGap = Math.min(minGap, -ov.iy) // 水平有交集 → 垂直間隙
|
|
272
|
+
else if (ov.iy > 0) minGap = Math.min(minGap, -ov.ix) // 垂直有交集 → 水平間隙
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// 邊標籤 ↔ 節點/群組(群組框為淺底大容器, 標籤落其內屬正常 → 僅與 leaf 節點及其他標籤計碰撞)
|
|
277
|
+
for (var li=0;li<labels.length;li++){
|
|
278
|
+
var lab = labels[li]
|
|
279
|
+
for (var ri=0;ri<rects.length;ri++){
|
|
280
|
+
if (rects[ri].kind !== 'node') continue
|
|
281
|
+
var ovl = overlap(lab, rects[ri])
|
|
282
|
+
if (ovl.ix > COLLIDE_TOL && ovl.iy > COLLIDE_TOL) collisions++
|
|
283
|
+
}
|
|
284
|
+
for (var lj=li+1;lj<labels.length;lj++){
|
|
285
|
+
var ovll = overlap(lab, labels[lj])
|
|
286
|
+
if (ovll.ix > COLLIDE_TOL && ovll.iy > COLLIDE_TOL) collisions++
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (!isFinite(minGap)) minGap = 0 // 無相鄰對(單一節點等)
|
|
290
|
+
return { collisions: collisions, minGap: minGap }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---- 把一份 layout 結果實際畫到 LF 並截圖前準備(回傳畫布尺寸) ----
|
|
294
|
+
function renderLayout(L){
|
|
295
|
+
var lfNodes = L.lfNodes, lfGroupNodes = L.lfGroupNodes, lfEdges = L.lfEdges
|
|
296
|
+
|
|
297
|
+
// 整體 bbox → 畫布尺寸
|
|
298
|
+
var minX=1e9,minY=1e9,maxX=-1e9,maxY=-1e9
|
|
299
|
+
lfGroupNodes.concat(lfNodes).forEach(function(nd){
|
|
300
|
+
minX=Math.min(minX, nd.x-nd._w/2); maxX=Math.max(maxX, nd.x+nd._w/2)
|
|
301
|
+
minY=Math.min(minY, nd.y-nd._h/2); maxY=Math.max(maxY, nd.y+nd._h/2)
|
|
302
|
+
})
|
|
303
|
+
var M = CANVAS_MARGIN
|
|
304
|
+
var W = Math.ceil(maxX-minX+M*2), H = Math.ceil(maxY-minY+M*2)
|
|
305
|
+
var app = document.getElementById('app')
|
|
306
|
+
app.innerHTML = ''
|
|
307
|
+
app.style.width = W+'px'; app.style.height = H+'px'
|
|
308
|
+
|
|
309
|
+
var G = window.Core || window.LogicFlow
|
|
310
|
+
var LF = G.default || G.LogicFlow || G
|
|
311
|
+
var RectNode = G.RectNode, RectNodeModel = G.RectNodeModel
|
|
312
|
+
var DiamondNode = G.DiamondNode, DiamondNodeModel = G.DiamondNodeModel
|
|
313
|
+
|
|
314
|
+
var lf = new LF({ container: app, grid:false, background:false, isSilentMode:true, stopScrollGraph:true, stopZoomGraph:true, adjustEdge:false,
|
|
315
|
+
style: {
|
|
316
|
+
rect: { rx: 7, ry: 7 },
|
|
317
|
+
polyline: { stroke: EDGE.line, strokeWidth: 1.8 },
|
|
318
|
+
nodeText: { color:'#1c2b36', fontFamily: FONT_FAMILY, fontSize: FONT_SIZE, overflowMode:'autoWrap', lineHeight: 1.4 },
|
|
319
|
+
},
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
function makeRect(name){
|
|
323
|
+
lf.register({ type: name, view: RectNode, model: class extends RectNodeModel {
|
|
324
|
+
setAttributes(){ var p=this.properties||{}; if(p.width)this.width=p.width; if(p.height)this.height=p.height; this.radius=7 }
|
|
325
|
+
getNodeStyle(){ var s=super.getNodeStyle(); var pal=this.properties._pal||PAL.blue; s.fill=pal.fill; s.stroke=pal.stroke; s.strokeWidth=this.properties.isGroup?2:1.8; if(this.properties.isGroup){s.fillOpacity=0.45} return s }
|
|
326
|
+
getTextStyle(){ var s=super.getTextStyle(); var pal=this.properties._pal||PAL.blue; s.color=pal.text; s.fontFamily=FONT_FAMILY; s.fontSize=this.properties.isGroup?15:FONT_SIZE; if(this.properties.isGroup){s.fontWeight=700} s.overflowMode='autoWrap'; s.lineHeight=1.35; if(this.width)s.wrapPadding='0px'; return s }
|
|
327
|
+
}})
|
|
328
|
+
}
|
|
329
|
+
function makeDiamond(name){
|
|
330
|
+
lf.register({ type: name, view: DiamondNode, model: class extends DiamondNodeModel {
|
|
331
|
+
setAttributes(){ var p=this.properties||{}; if(p.width)this.rx=p.width/2; if(p.height)this.ry=p.height/2 }
|
|
332
|
+
getNodeStyle(){ var s=super.getNodeStyle(); var pal=this.properties._pal||PAL.diamond; s.fill=pal.fill; s.stroke=pal.stroke; s.strokeWidth=1.8; return s }
|
|
333
|
+
getTextStyle(){ var s=super.getTextStyle(); var pal=this.properties._pal||PAL.diamond; s.color=pal.text; s.fontFamily=FONT_FAMILY; s.fontSize=FONT_SIZE; s.overflowMode='autoWrap'; return s }
|
|
334
|
+
}})
|
|
335
|
+
}
|
|
336
|
+
makeRect('cRect'); makeDiamond('cDiamond')
|
|
337
|
+
|
|
338
|
+
// 邊標籤樣式(全域修正 A): 背景透明(不遮轉折線) + 文字白色光暈(paint-order:stroke + stroke=白)
|
|
339
|
+
function edgeText(s){
|
|
340
|
+
s.color=EDGE.text; s.fontFamily=FONT_FAMILY; s.fontSize=12.5; s.overflowMode='default'; s.textWidth=400
|
|
341
|
+
s.background={ fill:'transparent', stroke:'none' } // 透明底: 線段能透出, 不被白底遮斷
|
|
342
|
+
s.stroke=EDGE.haloColor; s.strokeWidth=EDGE.haloWidth; s.paintOrder='stroke' // 白光暈描邊(描在文字下方)
|
|
343
|
+
return s
|
|
344
|
+
}
|
|
345
|
+
var PolylineEdge=G.PolylineEdge, PolylineEdgeModel=G.PolylineEdgeModel
|
|
346
|
+
lf.register({ type:'cDash', view:PolylineEdge, model: class extends PolylineEdgeModel {
|
|
347
|
+
getEdgeStyle(){ var s=super.getEdgeStyle(); s.stroke='#7a8690'; s.strokeWidth=1.8; s.strokeDasharray='6 4'; return s }
|
|
348
|
+
getTextStyle(){ return edgeText(super.getTextStyle()) }
|
|
349
|
+
}})
|
|
350
|
+
lf.register({ type:'cLine', view:PolylineEdge, model: class extends PolylineEdgeModel {
|
|
351
|
+
getEdgeStyle(){ var s=super.getEdgeStyle(); s.stroke=EDGE.line; s.strokeWidth=1.8; return s }
|
|
352
|
+
getTextStyle(){ return edgeText(super.getTextStyle()) }
|
|
353
|
+
}})
|
|
354
|
+
|
|
355
|
+
var graphNodes = []
|
|
356
|
+
lfGroupNodes.forEach(function(b){
|
|
357
|
+
graphNodes.push({ id:b.id, type:'cRect', x:b.x, y:b.y,
|
|
358
|
+
text:{ value: b.label, x: b.x, y: Math.round(b.y - b._h/2 + GROUP_TITLE/2 + 4) },
|
|
359
|
+
properties:{ width:b._w, height:b._h, isGroup:true, _pal:b._pal } })
|
|
360
|
+
})
|
|
361
|
+
lfNodes.forEach(function(nd){
|
|
362
|
+
graphNodes.push({ id:nd.id, type: nd.type==='diamond'?'cDiamond':'cRect', x:nd.x, y:nd.y, text:nd.text, properties:{ width:nd._w, height:nd._h, _pal:nd._pal } })
|
|
363
|
+
})
|
|
364
|
+
var graphEdges = lfEdges.map(function(ed){
|
|
365
|
+
return { id:ed.id, type: ed._dashed?'cDash':'cLine', sourceNodeId:ed.sourceNodeId, targetNodeId:ed.targetNodeId, text: ed.text||'' }
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
lf.render({ nodes: graphNodes, edges: graphEdges })
|
|
369
|
+
|
|
370
|
+
// ---- 繪製順序(z-order, 原則2) ----
|
|
371
|
+
// LF 預設把邊畫在節點下方, 群組(node)與節點同層; 文字會被節點/群組底色蓋住。
|
|
372
|
+
// 以 setElementZIndex 數值分層強制: 群組容器=底(Z_GROUP) → 節點=中(Z_NODE) → 邊與邊標籤=頂(Z_EDGE),
|
|
373
|
+
// 確保任何文字(節點文字 / 群組標題 / 邊標籤)都不被容器填色或其他區塊遮住。
|
|
374
|
+
var Z_GROUP = 1, Z_NODE = 10, Z_EDGE = 100
|
|
375
|
+
lfGroupNodes.forEach(function(b){ lf.setElementZIndex(b.id, Z_GROUP) }) // 半透明容器墊最底
|
|
376
|
+
lfNodes.forEach(function(nd){ lf.setElementZIndex(nd.id, Z_NODE) }) // 節點居中
|
|
377
|
+
graphEdges.forEach(function(ed){ lf.setElementZIndex(ed.id, Z_EDGE) }) // 邊與邊標籤置頂
|
|
378
|
+
|
|
379
|
+
return { w: W, h: H, nNodes: graphNodes.length, nEdges: graphEdges.length }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ---- 對外: 解析 → 以單一緊湊固定間距排版 → 實際渲染(z-order 處理遮蔽, 不掃描放大間距) ----
|
|
383
|
+
window.renderFig = function(fig){ return new Promise(function(resolve){
|
|
384
|
+
try {
|
|
385
|
+
var P = prepare(fig)
|
|
386
|
+
var sep = (P.rankDir === 'LR') ? SEP_LR : SEP_TB
|
|
387
|
+
var L = layout(P, sep[0], sep[1])
|
|
388
|
+
var d = detect(L) // 僅供回報(碰撞數/最小間隙), 不驅動任何放大
|
|
389
|
+
var info = renderLayout(L)
|
|
390
|
+
setTimeout(function(){ resolve({ ok:true, w:info.w, h:info.h, nNodes:info.nNodes, nEdges:info.nEdges,
|
|
391
|
+
nodeSep:sep[0], rankSep:sep[1], collisions:d.collisions, minGap:Math.round(d.minGap) }) }, 200)
|
|
392
|
+
} catch(e){ resolve({ ok:false, err: String(e && (e.stack||e.message||e)) }) }
|
|
393
|
+
})}
|
|
394
|
+
window.__ready = true
|
|
395
|
+
</script></body></html>`
|
|
396
|
+
|
|
397
|
+
// 對外: 單張正規化數據(同 FIGURES[n].data 結構) → PNG Buffer。重用上方 translate/HTML/渲染管線, 自行開關 browser, 不寫檔。
|
|
398
|
+
export async function genPng(data, opt = {}) {
|
|
399
|
+
const browser = await chromium.launch()
|
|
400
|
+
try {
|
|
401
|
+
const page = await browser.newPage({ deviceScaleFactor: 2 })
|
|
402
|
+
const errs = []
|
|
403
|
+
page.on('pageerror', e => errs.push(e.message))
|
|
404
|
+
page.on('console', m => { if (m.type() === 'error') errs.push('console:' + m.text()) })
|
|
405
|
+
await page.setContent(html, { waitUntil: 'load' })
|
|
406
|
+
await page.waitForFunction(() => window.__ready, { timeout: 30000 }).catch(() => {})
|
|
407
|
+
await page.evaluate(() => document.fonts && document.fonts.ready)
|
|
408
|
+
const fig = translate(data)
|
|
409
|
+
const res = await page.evaluate((fig) => window.renderFig(fig), fig)
|
|
410
|
+
if (!res.ok) throw new Error(res.err || 'renderFig failed')
|
|
411
|
+
await page.evaluate(() => document.fonts && document.fonts.ready)
|
|
412
|
+
return await page.locator('#app').screenshot()
|
|
413
|
+
} finally {
|
|
414
|
+
await browser.close()
|
|
415
|
+
}
|
|
416
|
+
}
|
package/src/p9/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# p9 — React Flow + dagre 產線
|
|
2
|
+
|
|
3
|
+
## 技術核心
|
|
4
|
+
|
|
5
|
+
- **繪圖庫**:`@xyflow/react@12`(React Flow)搭配 `@dagrejs/dagre@1` 自動排版引擎。
|
|
6
|
+
- **載入方式**:`react`、`react-dom`、`@xyflow/react`、`@dagrejs/dagre` 之 UMD 版本與 `@xyflow/react` 樣式表,皆由本機 `node_modules` 讀出後於 `gen.mjs` 模組載入時內聯注入 `page9.html`(取代 CDN `esm.sh`,斷網環境可用):`page9.html` 內以佔位符 `/*__XYFLOW_CSS__*/`、`/*__REACT_JS__*/`、`/*__REACT_DOM_JS__*/`、`/*__XYFLOW_JS__*/`、`/*__DAGRE_JS__*/` 預留插入點,`gen.mjs` 讀入該檔全文後以 `common/pkg.mjs` 的 `pkgText`/`pkgScript`(向上逐層尋找 `node_modules/<rel>`)取代之,`replace` 用函式形式回傳以避免庫碼內 `$` 序列被誤當替換樣板。`@xyflow/react` UMD 之外部依賴含全域 `jsxRuntime`(React 18 UMD 未提供),`page9.html` 內建等價 shim(以 `React.createElement` 實作 `jsx`/`jsxs`/`Fragment`)補上。
|
|
7
|
+
- **渲染流程**:`genPng(data, opt)` 每次呼叫皆啟動一個新的 `chromium` browser 與 page(`finally` 內關閉),以 `page.setContent(html)` 注入內聯好各繪圖庫的 `page9.html` 全文(`html` 於模組載入時讀取並完成佔位符替換一次,跨呼叫重用字串),等待 `document.title === 'READY'` 確認 React 與 dagre 啟動完成後,呼叫 `page.evaluate(s => window.renderFig(s), spec)`(`spec` 為 `translate(data)` 之輸出)觸發單張渲染,渲染函式內 `setTimeout 900ms` 後 resolve,確保 React commit 完成。
|
|
8
|
+
- **截圖目標**:對 `#shot` 元素截圖(`page.locator('#shot').screenshot()`);`#shot` 為 `display:inline-block` 的 div,內含 `#stage`(React Flow 掛載點),外加 `padding:24px` 白邊,截出的 PNG 即為完整含留白之圖片。
|
|
9
|
+
- **解析度**:`browser.newPage({ deviceScaleFactor: 2 })` 開啟 2x 縮放,所有量測與佈局座標仍以 CSS px 計算,截出的 PNG 實際像素為畫布尺寸之 2 倍,確保中文字型清晰。
|
|
10
|
+
- **字型**:`Microsoft JhengHei, sans-serif`,中文主字型一致貫穿節點、標籤、邊標籤。
|
|
11
|
+
- **呼叫入口**:`export async function genPng(data, opt = {})` 為唯一對外介面,回傳 `Promise<Buffer>`(PNG);由 `src/WFlowchart.mjs` 之 `GENS.p9` 統一調用(`mode: 'p9'`)。`data` 為正規化繪圖數據 `{ dir, nodes, edges }`;`opt` 保留擴充,目前原樣接收但未使用。
|
|
12
|
+
|
|
13
|
+
## 產製原理(資料驅動)
|
|
14
|
+
|
|
15
|
+
### translate(data) 轉換流程
|
|
16
|
+
|
|
17
|
+
`gen.mjs` 的 `translate(data)` 將標準數據格式轉為 `page9.html` 的 `renderFig spec`:
|
|
18
|
+
|
|
19
|
+
1. **節點對映**:遍歷 `data.nodes`,每個節點輸出 `{ id, label, kind }`。
|
|
20
|
+
- `kind` 直接取自 `n.cls`(cls 即為色票 key,如 `blue`、`diamond`、`blueG`)。
|
|
21
|
+
- 若節點有 `n.group`,則輸出 `parent: n.group`,作為 dagre compound 的子→容器歸屬。
|
|
22
|
+
- 標籤**不預折**,原樣傳入 `label`;折行完全交由 `page9.html` 的 `measure`/`Label` 以 `max-width` + `overflow-wrap:break-word` 於量測與渲染 div 內自然斷行(中文於字間斷行、英數 token 於空白/標點斷,不會攔腰斷字)。
|
|
23
|
+
- 群組容器(`kind` 結尾為 `G`/`G2`)由 `page9.html` 內 `layoutDagreCompound` 之 `isGrp = n => /G2?$/.test(n.kind || '')` 判斷,非於 `translate()` 階段標記。
|
|
24
|
+
|
|
25
|
+
2. **邊對映**:遍歷 `data.edges`,輸出 `{ s: e.from, t: e.to }`。
|
|
26
|
+
- 若有 `e.label` 則附帶 `l: e.label`。
|
|
27
|
+
- 若 `e.kind === 'dashed'` 則附帶 `dashed: true`,頁內渲染為橘色虛線(`strokeDasharray: '6 4'`,顏色 `#c46e1a`),否則為深灰實線(`#44505a`)。
|
|
28
|
+
|
|
29
|
+
3. **方向對映**:`layout: 'dagrec'` 固定使用 compound dagre 佈局;`rankDir: data.dir || 'TB'` 將數據的 `dir` 欄位直接映射為 dagre 的 `rankdir`(如 `LR` 為左→右,`TB` 為上→下)。
|
|
30
|
+
|
|
31
|
+
4. **節點形狀**:
|
|
32
|
+
- `kind === 'diamond'`:渲染為 `DiamondNode`,以旋轉 45° 的 div 實作菱形外框,填色 `#fcf0e2`、框色 `#c46e1a`,尺寸由對角線方框計算(見自動化機制)。
|
|
33
|
+
- `kind` 結尾為 `G` 或 `G2`:渲染為 `GroupNode`(type `grp`),淡底色加粗框,標題置頂。
|
|
34
|
+
- 其餘:渲染為 `BoxNode`(type `box`),圓角矩形,顏色取自 `COL[kind] || COL.blue`。
|
|
35
|
+
|
|
36
|
+
5. **群組容器(巢狀)**:群組本身在 dagre compound graph 中以 `g.setParent(childId, parentId)` 建立歸屬,支援巢狀(容器亦可有 `parent`);群組尺寸不需手動指定,由 dagre cluster 自動推算子節點排版後的邊界框。
|
|
37
|
+
|
|
38
|
+
6. **跨容器邊(邊端點為容器)**:dagre compound 不允許對容器節點直接連邊(ranking 階段會崩),故排版用「代表葉節點」替代容器端點(取該容器第一個直接葉子後代,存入 `repLeaf` 表),實際繪線仍以容器的絕對 box 邊界計算 handle 位置(`pickHandles`)。此為通用處理,非逐圖特例。
|
|
39
|
+
|
|
40
|
+
## 自動化機制
|
|
41
|
+
|
|
42
|
+
### 標籤尺寸量測(sizeOf + measure)
|
|
43
|
+
|
|
44
|
+
頁內以隱形 `#meas` 容器實際渲染文字後取 `getBoundingClientRect()`,得到以瀏覽器實際字型為準的像素尺寸:
|
|
45
|
+
|
|
46
|
+
- 矩形節點:`measure(label, fontSize=14, padX=18, padY=12, maxW=320)`,回傳 `{w, h}`(各加 2px 安全邊距)。
|
|
47
|
+
- 菱形節點:取量測結果之 `max(w, h) + 56`,正方形展開為對角線方框(使文字置中於菱形中心)。
|
|
48
|
+
- 群組容器:不在此量測(由 dagre cluster 自動撐開)。
|
|
49
|
+
|
|
50
|
+
### 版面通用推算常數
|
|
51
|
+
|
|
52
|
+
| 常數 / 公式 | 值 | 說明 |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `nodesep` | 46 px | 同 rank 節點橫向間距(dagre 預設值) |
|
|
55
|
+
| `ranksep` | 62 px | 跨 rank 層距(dagre 預設值) |
|
|
56
|
+
| `marginx / marginy` | 12 px | 圖形邊緣留白 |
|
|
57
|
+
| 標題列高度 `titleBand` | `round(fontSize * 1.4) + 12` | 群組置頂標題區高度,依字級通用推算 |
|
|
58
|
+
| 畫布尺寸 | `ceil(gg.width) + 48` / `ceil(gg.height) + 48` | dagre 佈局結果加 48 px 緩衝 |
|
|
59
|
+
| 折行寬度 `maxW` | 320 px(矩形節點)/ 160 px(菱形節點) | `measure()` 的 `max-width`,由 CSS `overflow-wrap:break-word` 自然折行,非字元數門檻 |
|
|
60
|
+
|
|
61
|
+
### 畫布尺寸自動推算
|
|
62
|
+
|
|
63
|
+
`layoutDagreCompound` 在 `dagre.layout(g)` 完成後,從 `g.graph().width / height` 取得佈局實際占用尺寸,加 48 px 緩衝後動態設定 `#stage` 的 `width / height`,再以 `fitView: true`(`padding: 0.04`)讓 React Flow 自動縮放填滿容器。無任何逐圖手寫 W/H 常數。
|
|
64
|
+
|
|
65
|
+
### 群組容器頂部擴張
|
|
66
|
+
|
|
67
|
+
dagre 給出群組的中心座標與尺寸後,佈局後自動向上擴張一個 `titleBand` 高度(`y -= band; h += band`),確保置頂標題列不壓蓋第一列子節點,且此擴張量以字級通用計算而非逐圖手調。
|
|
68
|
+
|
|
69
|
+
### 自動 handle 選取(pickHandles)
|
|
70
|
+
|
|
71
|
+
邊繪製前,以兩端節點絕對 box 中心的相對位置(`dx`、`dy`),自動判斷應從哪側 handle 出入:`|dx| >= |dy|` 時走左右(`l`/`r`),否則走上下(`t`/`b`)。每個節點(BoxNode、DiamondNode、GroupNode)四邊各有一組 source + target handle(共 8 個,`opacity:0` 隱藏),確保任意方向的連邊都能正確接合。
|
|
72
|
+
|
|
73
|
+
### 由逐圖手調「一般化」而來的原則
|
|
74
|
+
|
|
75
|
+
此產線以 `layout: 'dagrec'`(compound dagre)取代舊版 `figs9.json` 中的逐圖座標,一般化的核心原則如下:
|
|
76
|
+
|
|
77
|
+
- **節點尺寸不再手寫**:舊版以固定寬高常數,新版以 `sizeOf` 實際量測字型像素,標籤換行前後尺寸自動跟隨。
|
|
78
|
+
- **群組尺寸不再手寫**:舊版逐圖指定容器 w/h,新版以 dagre cluster 由子節點自動撐開。
|
|
79
|
+
- **座標不再手寫**:所有 x/y 由 dagre 排版引擎輸出,`rankDir` 取自數據 `dir` 欄位。
|
|
80
|
+
- **跨容器邊不再指定 handle**:`pickHandles` 依幾何自動計算,舊版需逐邊指定 `sh/th`。
|
|
81
|
+
|
|
82
|
+
## 已知限制 / 回退
|
|
83
|
+
|
|
84
|
+
- **線性鏈被拉長**:dagre 對長直鏈(如流程圖無分支)會在 `ranksep` 作用下縱向拉伸,層距固定不因鏈長自動縮短,適合節點多且有分支的關係圖,純序列流程圖建議改用 `layoutDagre`(非 compound)或調整 `ranksep`。
|
|
85
|
+
- **同標籤平行邊視覺重疊**:React Flow `default` 邊類型不自動分叉,同一對節點若有多條邊(不同 label)會重疊繪製,僅最後一條可見。
|
|
86
|
+
- **邊標籤定位不精確**:React Flow 邊標籤自動置於邊中點,對長距或折線路徑可能覆蓋其他節點或標籤。
|
|
87
|
+
- **無碰撞偵測**:dagre 以 rank/layer 排版,不保證節點間絕對不重疊(特別是跨 rank 的長邊標籤),若出現遮擋需調整 `nodesep`/`ranksep`。
|
|
88
|
+
- **群組代表葉節點選取**:跨容器邊的 ranking 代理節點取「第一個葉子後代」,若該葉子在容器內位置偏,可能影響 dagre 的 rank 分配導致邊繞路,此為設計限制非 bug。
|
|
89
|
+
- **無自動裁切貼邊**:`#shot` 截圖範圍固定為 `#stage` 外加 24 px padding,若個別圖實際內容較小,PNG 會有較多空白邊;若內容超出 stage 則被裁切(`fitView` 會縮放但 stage 尺寸已固定)。
|
|
90
|
+
- **React 渲染非同步延遲**:`renderFig` 以 `setTimeout(900ms)` 等待 React commit,若圖形節點數量極多(>200)或字型載入較慢,900ms 可能不足,截圖會取到未完成畫面。
|
package/src/p9/gen.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// p9 — React Flow + dagre 產線 adapter(資料驅動)
|
|
2
|
+
// genPng(data): 正規化繪圖數據 → translate() 轉成 page9.html 之 renderFig spec(layout:'dagrec')
|
|
3
|
+
// → React Flow 渲染 + compound dagre 自動排版 → 截圖回傳 PNG Buffer
|
|
4
|
+
// 版面通用化: rankDir 取自數據 dir; 節點尺寸由標籤字數/行數量測(page9.html sizeOf);
|
|
5
|
+
// 群組容器尺寸/位置/畫布大小全由 dagre cluster 自動推算; 無逐圖魔術數字。
|
|
6
|
+
import { chromium } from 'playwright'
|
|
7
|
+
import { fileURLToPath } from 'url'
|
|
8
|
+
import { dirname } from 'path'
|
|
9
|
+
import fs from 'fs'
|
|
10
|
+
|
|
11
|
+
import { pkgScript, pkgText } from '../common/pkg.mjs'
|
|
12
|
+
|
|
13
|
+
const __dir = dirname(fileURLToPath(import.meta.url))
|
|
14
|
+
// React / ReactDOM / @xyflow/react(UMD)/ @dagrejs/dagre 由本機 node_modules 內聯注入(取代 esm.sh, 斷網環境可用)
|
|
15
|
+
// replace 用函式形式回傳, 避免庫碼內 $ 序列被當作替換樣板
|
|
16
|
+
const html = fs.readFileSync(`${__dir}/page9.html`, 'utf8')
|
|
17
|
+
.replace('/*__XYFLOW_CSS__*/', () => pkgText('@xyflow/react/dist/style.css'))
|
|
18
|
+
.replace('/*__REACT_JS__*/', () => pkgScript('react/umd/react.production.min.js'))
|
|
19
|
+
.replace('/*__REACT_DOM_JS__*/', () => pkgScript('react-dom/umd/react-dom.production.min.js'))
|
|
20
|
+
.replace('/*__XYFLOW_JS__*/', () => pkgScript('@xyflow/react/dist/umd/index.js'))
|
|
21
|
+
.replace('/*__DAGRE_JS__*/', () => pkgScript('@dagrejs/dagre/dist/dagre.min.js'))
|
|
22
|
+
|
|
23
|
+
// 正規化數據 → page9.html renderFig spec
|
|
24
|
+
// 標籤「不預折」, 原樣傳入, 由 page9.html 量測 div(max-width + overflow-wrap)自然折行:
|
|
25
|
+
// 中文於字間斷行、英數 token 於空白/標點斷, 不會攔腰斷字; diamond 由 kind 自動成菱形; dashed 邊保留; dir → dagre rankDir。
|
|
26
|
+
// nodesep/ranksep 為通用常數(非逐圖客製), 控制節點/層間距。
|
|
27
|
+
function translate(data) {
|
|
28
|
+
const nodes = data.nodes.map((n) => {
|
|
29
|
+
const o = { id: n.id, label: n.label, kind: n.cls }
|
|
30
|
+
if (n.group) o.parent = n.group
|
|
31
|
+
return o
|
|
32
|
+
})
|
|
33
|
+
const edges = data.edges.map((e) => {
|
|
34
|
+
const o = { s: e.from, t: e.to }
|
|
35
|
+
if (e.label) o.l = e.label
|
|
36
|
+
if (e.kind === 'dashed') o.dashed = true
|
|
37
|
+
return o
|
|
38
|
+
})
|
|
39
|
+
return { layout: 'dagrec', rankDir: data.dir || 'TB', nodesep: 38, ranksep: 44, nodes, edges }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 單張渲染: 輸入正規化繪圖數據(結構同 FIGURES[n].data), 回傳 PNG 的 Node Buffer
|
|
43
|
+
export async function genPng(data, opt = {}) {
|
|
44
|
+
const browser = await chromium.launch()
|
|
45
|
+
try {
|
|
46
|
+
const page = await browser.newPage({ deviceScaleFactor: 2 })
|
|
47
|
+
const errs = []
|
|
48
|
+
page.on('pageerror', e => errs.push(e.message))
|
|
49
|
+
page.on('console', m => { if (m.type() === 'error') errs.push('c:' + m.text().slice(0, 200)) })
|
|
50
|
+
await page.setContent(html, { waitUntil: 'load' })
|
|
51
|
+
await page.waitForFunction(() => document.title === 'READY' || document.title === 'FAIL', { timeout: 60000 }).catch(() => {})
|
|
52
|
+
const boot = await page.evaluate(() => ({ t: document.title, err: window.__err }))
|
|
53
|
+
if (boot.t !== 'READY') throw new Error('BOOT FAIL :: ' + boot.err + ' | ' + errs.slice(0, 6).join(' | '))
|
|
54
|
+
const spec = translate(data)
|
|
55
|
+
const res = await page.evaluate(s => window.renderFig(s), spec)
|
|
56
|
+
if (!res.ok) throw new Error('FAIL :: ' + res.err)
|
|
57
|
+
await page.evaluate(() => document.fonts && document.fonts.ready)
|
|
58
|
+
await page.waitForTimeout(250)
|
|
59
|
+
return await page.locator('#shot').screenshot()
|
|
60
|
+
} finally {
|
|
61
|
+
await browser.close()
|
|
62
|
+
}
|
|
63
|
+
}
|