@nac3/forge-cli 1.2.11 → 1.3.1
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/dist/chat/board_artifacts.d.ts.map +1 -1
- package/dist/chat/board_artifacts.js +364 -137
- package/dist/chat/board_artifacts.js.map +1 -1
- package/dist/chat/panel.d.ts.map +1 -1
- package/dist/chat/panel.js +123 -207
- package/dist/chat/panel.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"board_artifacts.d.ts","sourceRoot":"","sources":["../../src/chat/board_artifacts.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"board_artifacts.d.ts","sourceRoot":"","sources":["../../src/chat/board_artifacts.ts"],"names":[],"mappings":"AAwBA,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CASpE;AAQD,wEAAwE;AACxE,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAIlD;AAsGD;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAE5D;AAqND,MAAM,WAAW,aAAa;IAAG,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,KAAK,GAAG,MAAM,CAAA;CAAE;AAErE;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CACxC,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAChE,OAAO,CAAC,MAAM,EAAE,CAAC,CAqCnB"}
|
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
21
21
|
import { join, dirname } from 'node:path';
|
|
22
|
-
import { layoutFlowModel, wrapLabel, FL } from './flow_layout.js';
|
|
23
22
|
/* ---------- helpers ---------- */
|
|
24
23
|
export function slugifyTitle(title, fallback) {
|
|
25
24
|
const base = String(title || '')
|
|
@@ -42,46 +41,109 @@ export function looksLikeHtml(src) {
|
|
|
42
41
|
return /^\s*<(?:!doctype|html|head|body|section|article|main|div|h[1-6]|p|ul|ol|table|header|figure|blockquote)\b/i.test(s)
|
|
43
42
|
|| /<\/(?:h[1-6]|section|article|main|div|p|ul|ol|li|table|thead|tbody|tr|td|th|header|figure|blockquote)\s*>/i.test(s);
|
|
44
43
|
}
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
const FLP = {
|
|
45
|
+
bg: '#f5f0e6', laneStroke: '#b8ad95', laneLabel: '#4f5b87', laneAlt: 'rgba(0,0,0,0.025)',
|
|
46
|
+
edge: '#1a1a1a', loop: '#8a6d3b', taskFill: '#ece6d8', taskStroke: '#4f5b87',
|
|
47
|
+
startStroke: '#6f8b5e', endStroke: '#c8472e', gwStroke: '#c8a04d', text: '#1a1a1a',
|
|
48
|
+
};
|
|
49
|
+
const FC = {
|
|
50
|
+
NODE_W: 150, NODE_MIN_H: 56, LINE_H: 14, FONT_SIZE: 11.5, CHARS_PER_LINE: 20, MAX_LINES: 5,
|
|
51
|
+
EVENT_R: 20, GW_HALF: 25, COL_GAP: 210, ROW_GAP: 28, LEFT_MARGIN: 26, LANE_LABEL_W: 40,
|
|
52
|
+
RIGHT_MARGIN: 40, TOP_MARGIN: 10, BOTTOM_MARGIN: 14, LOOP_EXTRA: 50,
|
|
53
|
+
};
|
|
54
|
+
const FICONS = { serviceTask: '⚙', userTask: '☺', sendTask: '➤', receiveTask: '✉', scriptTask: '≡' };
|
|
55
|
+
function flowWrapLabel(label, maxChars = FC.CHARS_PER_LINE, maxLines = FC.MAX_LINES) {
|
|
56
|
+
const words = String(label == null ? '' : label).split(/\s+/).filter(Boolean);
|
|
47
57
|
const lines = [];
|
|
48
58
|
let cur = '';
|
|
49
59
|
for (const w of words) {
|
|
50
|
-
|
|
60
|
+
const test = cur ? cur + ' ' + w : w;
|
|
61
|
+
if (test.length > maxChars && cur) {
|
|
62
|
+
lines.push(cur);
|
|
51
63
|
cur = w;
|
|
52
64
|
}
|
|
53
|
-
else if ((cur + ' ' + w).length <= maxChars) {
|
|
54
|
-
cur += ' ' + w;
|
|
55
|
-
}
|
|
56
65
|
else {
|
|
57
|
-
|
|
58
|
-
cur = w;
|
|
59
|
-
if (lines.length === maxLines - 1)
|
|
60
|
-
break;
|
|
66
|
+
cur = test;
|
|
61
67
|
}
|
|
62
68
|
}
|
|
63
|
-
if (cur
|
|
69
|
+
if (cur)
|
|
64
70
|
lines.push(cur);
|
|
65
|
-
if (lines.length
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
71
|
+
if (lines.length > maxLines) {
|
|
72
|
+
const kept = lines.slice(0, maxLines - 1);
|
|
73
|
+
const rest = lines.slice(maxLines - 1).join(' ');
|
|
74
|
+
kept.push(rest.length > maxChars ? rest.slice(0, maxChars - 1) + '.' : rest);
|
|
75
|
+
return kept;
|
|
76
|
+
}
|
|
77
|
+
return lines.length ? lines : [''];
|
|
78
|
+
}
|
|
79
|
+
/** DFS: edges pointing back to a node still on the recursion stack are loop-backs
|
|
80
|
+
* (rework). They are kept out of the column/longest-path calc and routed apart. */
|
|
81
|
+
function flowFindBackEdges(nodeIds, adj) {
|
|
82
|
+
const state = new Map(nodeIds.map((id) => [id, 0])); // 0 unvisited 1 in-stack 2 done
|
|
83
|
+
const back = new Set();
|
|
84
|
+
const dfs = (u) => {
|
|
85
|
+
state.set(u, 1);
|
|
86
|
+
for (const v of adj.get(u) || []) {
|
|
87
|
+
if (!state.has(v))
|
|
88
|
+
continue;
|
|
89
|
+
if (state.get(v) === 1)
|
|
90
|
+
back.add(u + '->' + v);
|
|
91
|
+
else if (state.get(v) === 0)
|
|
92
|
+
dfs(v);
|
|
93
|
+
}
|
|
94
|
+
state.set(u, 2);
|
|
95
|
+
};
|
|
96
|
+
for (const id of nodeIds)
|
|
97
|
+
if (state.get(id) === 0)
|
|
98
|
+
dfs(id);
|
|
99
|
+
return back;
|
|
100
|
+
}
|
|
101
|
+
/** Longest-path columns via Kahn over the forward-only DAG (col = max(pred)+1). */
|
|
102
|
+
function flowLayerize(nodeIds, fwdAdj, fwdIndeg) {
|
|
103
|
+
const col = new Map(nodeIds.map((id) => [id, 0]));
|
|
104
|
+
const indeg = new Map(fwdIndeg);
|
|
105
|
+
const queue = nodeIds.filter((id) => (indeg.get(id) || 0) === 0);
|
|
106
|
+
const seen = new Set(queue);
|
|
107
|
+
let head = 0;
|
|
108
|
+
while (head < queue.length) {
|
|
109
|
+
const u = queue[head++];
|
|
110
|
+
for (const v of fwdAdj.get(u) || []) {
|
|
111
|
+
col.set(v, Math.max(col.get(v) || 0, (col.get(u) || 0) + 1));
|
|
112
|
+
indeg.set(v, (indeg.get(v) || 0) - 1);
|
|
113
|
+
if ((indeg.get(v) || 0) === 0 && !seen.has(v)) {
|
|
114
|
+
seen.add(v);
|
|
115
|
+
queue.push(v);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
69
118
|
}
|
|
70
|
-
|
|
119
|
+
/* any node never dequeued (part of a pure cycle with no forward entry) keeps
|
|
120
|
+
* col 0 -- harmless; it still gets a position. */
|
|
121
|
+
return col;
|
|
71
122
|
}
|
|
72
|
-
function
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
return
|
|
78
|
-
|
|
123
|
+
function flowNodeGeom(node) {
|
|
124
|
+
const lines = flowWrapLabel(node.label ?? node.id);
|
|
125
|
+
const t = String(node.type || 'task');
|
|
126
|
+
if (/^start/.test(t) || /^end/.test(t) || /^intermediate/.test(t) || t === 'event') {
|
|
127
|
+
const shape = /^end/.test(t) ? 'endEvent' : 'startEvent';
|
|
128
|
+
return { shape, hasIcon: false, connHalfW: FC.EVENT_R, topExtent: FC.EVENT_R + 6, bottomExtent: FC.EVENT_R + 10 + lines.length * FC.LINE_H, lines };
|
|
129
|
+
}
|
|
130
|
+
if (/^(gateway|decision|fork|join|merge)/.test(t)) {
|
|
131
|
+
return { shape: 'gateway', hasIcon: false, connHalfW: FC.GW_HALF, topExtent: FC.GW_HALF + 6, bottomExtent: FC.GW_HALF + 10 + lines.length * FC.LINE_H, lines };
|
|
132
|
+
}
|
|
133
|
+
const hasIcon = Boolean(FICONS[t]);
|
|
134
|
+
const halfH = Math.max(FC.NODE_MIN_H, lines.length * FC.LINE_H + (hasIcon ? 26 : 18)) / 2;
|
|
135
|
+
return { shape: 'task', hasIcon, connHalfW: FC.NODE_W / 2, topExtent: halfH, bottomExtent: halfH, lines };
|
|
79
136
|
}
|
|
80
137
|
/**
|
|
81
138
|
* Render a flow model to a STANDALONE svg string (with xml prolog + intrinsic
|
|
82
139
|
* width/height). Returns null when the content is not a parseable flow.
|
|
83
140
|
*/
|
|
84
141
|
export function renderFlowSvg(content) {
|
|
142
|
+
return renderFlowSvgPorted(content);
|
|
143
|
+
}
|
|
144
|
+
/* Legacy simple renderer kept for reference/rollback under a flag-less name is
|
|
145
|
+
* intentionally removed; the ported layout below is the sole path. */
|
|
146
|
+
function renderFlowSvgPorted(content) {
|
|
85
147
|
let m;
|
|
86
148
|
try {
|
|
87
149
|
m = JSON.parse(content);
|
|
@@ -91,135 +153,300 @@ export function renderFlowSvg(content) {
|
|
|
91
153
|
}
|
|
92
154
|
if (!m || !Array.isArray(m.nodes) || m.nodes.length === 0)
|
|
93
155
|
return null;
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
const lanes = Array.isArray(m.lanes) ? m.lanes.filter((l) => l && l.id) : [];
|
|
97
|
-
if (nodes.length === 0)
|
|
156
|
+
const allNodes = m.nodes.filter((n) => n && n.id);
|
|
157
|
+
if (!allNodes.length)
|
|
98
158
|
return null;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
159
|
+
const flows0 = Array.isArray(m.flows) ? m.flows.filter((f) => f && f.from && f.to) : [];
|
|
160
|
+
/* prune declared-but-empty lanes; synthesize a single lane when none. */
|
|
161
|
+
const usedLaneIds = new Set();
|
|
162
|
+
for (const n of allNodes)
|
|
163
|
+
if (n.lane != null && String(n.lane))
|
|
164
|
+
usedLaneIds.add(String(n.lane));
|
|
165
|
+
let lanesArr = (Array.isArray(m.lanes) ? m.lanes.filter((l) => l && l.id) : []).filter((l) => usedLaneIds.has(l.id));
|
|
166
|
+
const hasLanes = lanesArr.length > 0;
|
|
167
|
+
if (!hasLanes)
|
|
168
|
+
lanesArr = [{ id: '__main', label: '' }];
|
|
169
|
+
const laneOrder = lanesArr.map((l) => l.id);
|
|
170
|
+
const laneSet = new Set(laneOrder);
|
|
171
|
+
const laneLabel = new Map(lanesArr.map((l) => [l.id, String(l.label ?? l.id)]));
|
|
172
|
+
const LANE_LBL_W = hasLanes ? FC.LANE_LABEL_W : 0;
|
|
173
|
+
/* every node gets a valid lane (unknown/absent -> first lane). */
|
|
174
|
+
const nodeById = new Map();
|
|
175
|
+
for (const n of allNodes) {
|
|
176
|
+
const lane = (n.lane != null && laneSet.has(String(n.lane))) ? String(n.lane) : laneOrder[0];
|
|
177
|
+
nodeById.set(n.id, { ...n, lane });
|
|
178
|
+
}
|
|
179
|
+
const nodeIds = allNodes.map((n) => n.id);
|
|
180
|
+
const flows = flows0.filter((f) => nodeById.has(f.from) && nodeById.has(f.to));
|
|
181
|
+
const adj = new Map(nodeIds.map((id) => [id, []]));
|
|
182
|
+
for (const f of flows)
|
|
183
|
+
adj.get(f.from).push(f.to);
|
|
184
|
+
const backEdges = flowFindBackEdges(nodeIds, adj);
|
|
185
|
+
const fwdAdj = new Map(nodeIds.map((id) => [id, []]));
|
|
186
|
+
const fwdIndeg = new Map(nodeIds.map((id) => [id, 0]));
|
|
187
|
+
const forwardFlows = [];
|
|
188
|
+
const loopFlows = [];
|
|
189
|
+
for (const f of flows) {
|
|
190
|
+
if (backEdges.has(f.from + '->' + f.to)) {
|
|
191
|
+
loopFlows.push(f);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
forwardFlows.push(f);
|
|
195
|
+
fwdAdj.get(f.from).push(f.to);
|
|
196
|
+
fwdIndeg.set(f.to, (fwdIndeg.get(f.to) || 0) + 1);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const col = flowLayerize(nodeIds, fwdAdj, fwdIndeg);
|
|
200
|
+
const geom = new Map(nodeIds.map((id) => [id, flowNodeGeom(nodeById.get(id))]));
|
|
201
|
+
/* bucket nodes per (lane, column). */
|
|
202
|
+
const laneBuckets = new Map(laneOrder.map((l) => [l, new Map()]));
|
|
203
|
+
for (const id of nodeIds) {
|
|
204
|
+
const n = nodeById.get(id);
|
|
205
|
+
const c = col.get(id) || 0;
|
|
206
|
+
const buckets = laneBuckets.get(n.lane);
|
|
207
|
+
if (!buckets.has(c))
|
|
208
|
+
buckets.set(c, []);
|
|
209
|
+
buckets.get(c).push(id);
|
|
210
|
+
}
|
|
211
|
+
const bucketHeight = (ids) => {
|
|
212
|
+
let h = 0;
|
|
213
|
+
ids.forEach((id, i) => { const g = geom.get(id); h += g.topExtent + g.bottomExtent; if (i < ids.length - 1)
|
|
214
|
+
h += FC.ROW_GAP; });
|
|
215
|
+
return h;
|
|
216
|
+
};
|
|
217
|
+
const laneHeight = new Map();
|
|
218
|
+
for (const lane of laneOrder) {
|
|
219
|
+
let mx = 0;
|
|
220
|
+
for (const ids of laneBuckets.get(lane).values())
|
|
221
|
+
mx = Math.max(mx, bucketHeight(ids));
|
|
222
|
+
laneHeight.set(lane, Math.max(mx, FC.NODE_MIN_H) + 2 * FC.ROW_GAP);
|
|
223
|
+
}
|
|
224
|
+
const laneTop = new Map();
|
|
225
|
+
let cursorY = FC.TOP_MARGIN;
|
|
226
|
+
for (const lane of laneOrder) {
|
|
227
|
+
laneTop.set(lane, cursorY);
|
|
228
|
+
cursorY += laneHeight.get(lane);
|
|
229
|
+
}
|
|
230
|
+
const pos = new Map();
|
|
231
|
+
for (const lane of laneOrder) {
|
|
232
|
+
const buckets = laneBuckets.get(lane);
|
|
233
|
+
const top = laneTop.get(lane);
|
|
234
|
+
const total = laneHeight.get(lane) - 2 * FC.ROW_GAP;
|
|
235
|
+
for (const [c, ids] of buckets.entries()) {
|
|
236
|
+
const bh = bucketHeight(ids);
|
|
237
|
+
const offset = (total - bh) / 2;
|
|
238
|
+
let y = top + FC.ROW_GAP + offset;
|
|
239
|
+
const cx = FC.LEFT_MARGIN + LANE_LBL_W + FC.NODE_W / 2 + c * FC.COL_GAP;
|
|
240
|
+
for (const id of ids) {
|
|
241
|
+
const g = geom.get(id);
|
|
242
|
+
pos.set(id, { cx, cy: y + g.topExtent, col: c });
|
|
243
|
+
y += g.topExtent + g.bottomExtent + FC.ROW_GAP;
|
|
244
|
+
}
|
|
122
245
|
}
|
|
123
246
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
*
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
247
|
+
const maxCol = Math.max(0, ...nodeIds.map((id) => col.get(id) || 0));
|
|
248
|
+
const width = FC.LEFT_MARGIN + LANE_LBL_W + FC.NODE_W / 2 + maxCol * FC.COL_GAP + FC.NODE_W / 2 + FC.RIGHT_MARGIN;
|
|
249
|
+
const height = cursorY - FC.ROW_GAP + FC.BOTTOM_MARGIN + (loopFlows.length ? FC.LOOP_EXTRA : 0);
|
|
250
|
+
/* validation (non-throwing in the live path): a broken layout is logged, not
|
|
251
|
+
* hidden, but we still return the SVG (persistBoardArtifact is best-effort). */
|
|
252
|
+
const problems = [];
|
|
253
|
+
for (const id of nodeIds) {
|
|
254
|
+
const n = nodeById.get(id);
|
|
255
|
+
const p = pos.get(id);
|
|
256
|
+
const g = geom.get(id);
|
|
257
|
+
const top = laneTop.get(n.lane);
|
|
258
|
+
const laneH = laneHeight.get(n.lane);
|
|
259
|
+
if (p.cy - g.topExtent < top - 0.01 || p.cy + g.bottomExtent > top + laneH + 0.01)
|
|
260
|
+
problems.push('node ' + id + ' escapes lane ' + n.lane);
|
|
261
|
+
}
|
|
262
|
+
const nodeBox = (id) => {
|
|
263
|
+
const p = pos.get(id);
|
|
264
|
+
const g = geom.get(id);
|
|
265
|
+
const halfW = g.shape === 'task' ? FC.NODE_W / 2 : g.connHalfW;
|
|
266
|
+
return { x0: p.cx - halfW, x1: p.cx + halfW, y0: p.cy - g.topExtent, y1: p.cy + g.bottomExtent };
|
|
267
|
+
};
|
|
268
|
+
const EPS = 0.5;
|
|
269
|
+
const segmentHits = (points, exclude) => {
|
|
270
|
+
const hits = [];
|
|
271
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
272
|
+
const [x1, y1] = points[i];
|
|
273
|
+
const [x2, y2] = points[i + 1];
|
|
274
|
+
const horizontal = Math.abs(y1 - y2) < EPS;
|
|
275
|
+
for (const id of nodeIds) {
|
|
276
|
+
if (exclude.has(id))
|
|
277
|
+
continue;
|
|
278
|
+
const box = nodeBox(id);
|
|
279
|
+
if (horizontal) {
|
|
280
|
+
const y = y1;
|
|
281
|
+
const xLo = Math.min(x1, x2) + EPS;
|
|
282
|
+
const xHi = Math.max(x1, x2) - EPS;
|
|
283
|
+
if (y > box.y0 + EPS && y < box.y1 - EPS && xHi > box.x0 && xLo < box.x1)
|
|
284
|
+
hits.push({ segIndex: i, id, box, horizontal: true });
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
const x = x1;
|
|
288
|
+
const yLo = Math.min(y1, y2) + EPS;
|
|
289
|
+
const yHi = Math.max(y1, y2) - EPS;
|
|
290
|
+
if (x > box.x0 + EPS && x < box.x1 - EPS && yHi > box.y0 && yLo < box.y1)
|
|
291
|
+
hits.push({ segIndex: i, id, box, horizontal: false });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return hits;
|
|
296
|
+
};
|
|
297
|
+
const bumpSegment = (points, hit) => {
|
|
298
|
+
const i = hit.segIndex;
|
|
299
|
+
const [x1, y1] = points[i];
|
|
300
|
+
const [x2, y2] = points[i + 1];
|
|
301
|
+
const GAP = 14;
|
|
302
|
+
if (hit.horizontal) {
|
|
303
|
+
const above = hit.box.y0 - GAP;
|
|
304
|
+
const below = hit.box.y1 + GAP;
|
|
305
|
+
const newY = Math.abs(above - y1) <= Math.abs(below - y1) ? above : below;
|
|
306
|
+
const dir = x2 >= x1 ? 1 : -1;
|
|
307
|
+
const enterX = dir === 1 ? Math.max(x1, hit.box.x0 - GAP) : Math.min(x1, hit.box.x1 + GAP);
|
|
308
|
+
const exitX = dir === 1 ? Math.min(x2, hit.box.x1 + GAP) : Math.max(x2, hit.box.x0 - GAP);
|
|
309
|
+
return [...points.slice(0, i + 1), [enterX, y1], [enterX, newY], [exitX, newY], [exitX, y2], ...points.slice(i + 1)];
|
|
162
310
|
}
|
|
163
|
-
|
|
164
|
-
|
|
311
|
+
const left = hit.box.x0 - GAP;
|
|
312
|
+
const right = hit.box.x1 + GAP;
|
|
313
|
+
const newX = Math.abs(left - x1) <= Math.abs(right - x1) ? left : right;
|
|
314
|
+
const dir = y2 >= y1 ? 1 : -1;
|
|
315
|
+
const enterY = dir === 1 ? Math.max(y1, hit.box.y0 - GAP) : Math.min(y1, hit.box.y1 + GAP);
|
|
316
|
+
const exitY = dir === 1 ? Math.min(y2, hit.box.y1 + GAP) : Math.max(y2, hit.box.y0 - GAP);
|
|
317
|
+
return [...points.slice(0, i + 1), [x1, enterY], [newX, enterY], [newX, exitY], [x2, exitY], ...points.slice(i + 1)];
|
|
318
|
+
};
|
|
319
|
+
const routeAvoiding = (initial, exclude) => {
|
|
320
|
+
let points = initial;
|
|
321
|
+
for (let iter = 0; iter < 8; iter++) {
|
|
322
|
+
const hits = segmentHits(points, exclude);
|
|
323
|
+
if (!hits.length)
|
|
324
|
+
return points;
|
|
325
|
+
points = bumpSegment(points, hits[0]);
|
|
326
|
+
}
|
|
327
|
+
return points;
|
|
328
|
+
};
|
|
329
|
+
const pathFromPoints = (points) => points.map((p, i) => (i === 0 ? 'M' : 'L') + p[0].toFixed(1) + ',' + p[1].toFixed(1)).join(' ');
|
|
330
|
+
const connPoint = (id, side) => { const p = pos.get(id); const g = geom.get(id); return { x: side === 'out' ? p.cx + g.connHalfW : p.cx - g.connHalfW, y: p.cy }; };
|
|
331
|
+
const parts = [];
|
|
332
|
+
parts.push('<?xml version="1.0" encoding="UTF-8"?>');
|
|
333
|
+
parts.push('<svg viewBox="0 0 ' + width.toFixed(0) + ' ' + height.toFixed(0) + '" width="' + width.toFixed(0) + '" height="' + height.toFixed(0) + '" preserveAspectRatio="xMinYMin meet" xmlns="http://www.w3.org/2000/svg">');
|
|
334
|
+
parts.push('<rect x="0" y="0" width="' + width.toFixed(0) + '" height="' + height.toFixed(0) + '" fill="' + FLP.bg + '"/>');
|
|
335
|
+
parts.push('<defs><marker id="bpmn-arrow" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto" markerUnits="strokeWidth"><path d="M0,0 L7,3 L0,6 z" fill="' + FLP.edge + '"/></marker>'
|
|
336
|
+
+ '<marker id="bpmn-arrow-loop" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto" markerUnits="strokeWidth"><path d="M0,0 L7,3 L0,6 z" fill="' + FLP.loop + '"/></marker></defs>');
|
|
337
|
+
if (hasLanes) {
|
|
338
|
+
laneOrder.forEach((lane, i) => {
|
|
339
|
+
const top = laneTop.get(lane);
|
|
340
|
+
const h = laneHeight.get(lane);
|
|
341
|
+
const fill = i % 2 === 1 ? FLP.laneAlt : 'transparent';
|
|
342
|
+
parts.push('<rect x="' + FC.LEFT_MARGIN + '" y="' + top.toFixed(1) + '" width="' + (width - FC.LEFT_MARGIN - FC.RIGHT_MARGIN).toFixed(1) + '" height="' + h.toFixed(1) + '" fill="' + fill + '" stroke="' + FLP.laneStroke + '" stroke-width="1"/>');
|
|
343
|
+
const midY = top + h / 2;
|
|
344
|
+
parts.push('<text x="' + (FC.LEFT_MARGIN + 14).toFixed(1) + '" y="' + midY.toFixed(1) + '" transform="rotate(-90 ' + (FC.LEFT_MARGIN + 14).toFixed(1) + ' ' + midY.toFixed(1) + ')" text-anchor="middle" font-size="11" fill="' + FLP.laneLabel + '" font-weight="600">' + escXml(laneLabel.get(lane) || lane) + '</text>');
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
const edgeChecks = [];
|
|
348
|
+
for (const f of forwardFlows) {
|
|
349
|
+
const a = connPoint(f.from, 'out');
|
|
350
|
+
const b = connPoint(f.to, 'in');
|
|
351
|
+
let points;
|
|
352
|
+
if (Math.abs(a.y - b.y) < 0.5) {
|
|
353
|
+
points = [[a.x, a.y], [b.x, b.y]];
|
|
165
354
|
}
|
|
166
355
|
else {
|
|
167
|
-
const
|
|
168
|
-
|
|
356
|
+
const srcColX = FC.LEFT_MARGIN + LANE_LBL_W + FC.NODE_W / 2 + (pos.get(f.from).col) * FC.COL_GAP;
|
|
357
|
+
const midX = srcColX + FC.COL_GAP / 2;
|
|
358
|
+
points = [[a.x, a.y], [midX, a.y], [midX, b.y], [b.x, b.y]];
|
|
169
359
|
}
|
|
170
|
-
|
|
360
|
+
points = routeAvoiding(points, new Set([f.from, f.to]));
|
|
361
|
+
edgeChecks.push({ points, from: f.from, to: f.to });
|
|
362
|
+
parts.push('<path d="' + pathFromPoints(points) + '" fill="none" stroke="' + FLP.edge + '" stroke-width="1.4" marker-end="url(#bpmn-arrow)" opacity="0.85"/>');
|
|
171
363
|
if (f.label) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
364
|
+
const lx = (a.x + b.x) / 2;
|
|
365
|
+
const ly = Math.abs(a.y - b.y) < 0.5 ? a.y - 6 : (a.y + b.y) / 2;
|
|
366
|
+
const lw = String(f.label).length * 6.2 + 6;
|
|
367
|
+
parts.push('<rect x="' + (lx - lw / 2).toFixed(1) + '" y="' + (ly - 9.5).toFixed(1) + '" width="' + lw.toFixed(1) + '" height="13" fill="' + FLP.bg + '" opacity="0.9"/>');
|
|
368
|
+
parts.push('<text x="' + lx.toFixed(1) + '" y="' + (ly + 0.5).toFixed(1) + '" text-anchor="middle" font-size="10" fill="' + FLP.text + '">' + escXml(String(f.label)) + '</text>');
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const laneBottom = cursorY - FC.ROW_GAP;
|
|
372
|
+
loopFlows.forEach((f, i) => {
|
|
373
|
+
const a = pos.get(f.from);
|
|
374
|
+
const ag = geom.get(f.from);
|
|
375
|
+
const b = pos.get(f.to);
|
|
376
|
+
const bg = geom.get(f.to);
|
|
377
|
+
const loopY = laneBottom + 22 + i * 14;
|
|
378
|
+
const offset = 8;
|
|
379
|
+
const goesLeft = b.col <= a.col;
|
|
380
|
+
const srcGutterX = goesLeft ? Math.max(FC.LEFT_MARGIN + LANE_LBL_W + 12, a.cx - FC.COL_GAP / 2) : a.cx + FC.COL_GAP / 2;
|
|
381
|
+
const dstGutterX = goesLeft ? b.cx + FC.COL_GAP / 2 : Math.max(FC.LEFT_MARGIN + LANE_LBL_W + 12, b.cx - FC.COL_GAP / 2);
|
|
382
|
+
const srcSideX = goesLeft ? a.cx - ag.connHalfW : a.cx + ag.connHalfW;
|
|
383
|
+
const dstSideX = goesLeft ? b.cx + bg.connHalfW : b.cx - bg.connHalfW;
|
|
384
|
+
const srcY = a.cy + offset;
|
|
385
|
+
const dstY = b.cy + offset;
|
|
386
|
+
let points = [[srcSideX, srcY], [srcGutterX, srcY], [srcGutterX, loopY], [dstGutterX, loopY], [dstGutterX, dstY], [dstSideX, dstY]];
|
|
387
|
+
points = routeAvoiding(points, new Set([f.from, f.to]));
|
|
388
|
+
edgeChecks.push({ points, from: f.from, to: f.to });
|
|
389
|
+
parts.push('<path d="' + pathFromPoints(points) + '" fill="none" stroke="' + FLP.loop + '" stroke-width="1.4" stroke-dasharray="5 3" marker-end="url(#bpmn-arrow-loop)" opacity="0.9"/>');
|
|
390
|
+
if (f.label) {
|
|
391
|
+
const lx = (srcGutterX + dstGutterX) / 2;
|
|
392
|
+
const lw = String(f.label).length * 6.2 + 6;
|
|
393
|
+
parts.push('<rect x="' + (lx - lw / 2).toFixed(1) + '" y="' + (loopY - 9.5).toFixed(1) + '" width="' + lw.toFixed(1) + '" height="13" fill="' + FLP.bg + '" opacity="0.9"/>');
|
|
394
|
+
parts.push('<text x="' + lx.toFixed(1) + '" y="' + (loopY + 0.5).toFixed(1) + '" text-anchor="middle" font-size="10" fill="' + FLP.loop + '">' + escXml(String(f.label)) + '</text>');
|
|
177
395
|
}
|
|
178
396
|
});
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (n.label) {
|
|
192
|
-
const els = wrapLabel(String(n.label), FL.LABEL_WRAP_CHARS, FL.LABEL_WRAP_LINES);
|
|
193
|
-
for (let li5 = 0; li5 < els.length; li5++)
|
|
194
|
-
svg += '<text x="' + x + '" y="' + (y + r + FL.LABEL_GAP + li5 * FL.LABEL_LH) + '" text-anchor="middle" font-size="10.5" fill="' + P.ink + '">' + escXml(els[li5] || '') + '</text>';
|
|
397
|
+
for (const id of nodeIds) {
|
|
398
|
+
const n = nodeById.get(id);
|
|
399
|
+
const p = pos.get(id);
|
|
400
|
+
const g = geom.get(id);
|
|
401
|
+
if (g.shape === 'startEvent' || g.shape === 'endEvent') {
|
|
402
|
+
const stroke = g.shape === 'startEvent' ? FLP.startStroke : FLP.endStroke;
|
|
403
|
+
const sw = g.shape === 'startEvent' ? 1.6 : 3.4;
|
|
404
|
+
parts.push('<circle cx="' + p.cx.toFixed(1) + '" cy="' + p.cy.toFixed(1) + '" r="' + FC.EVENT_R + '" fill="' + FLP.bg + '" stroke="' + stroke + '" stroke-width="' + sw + '"/>');
|
|
405
|
+
let ly = p.cy + FC.EVENT_R + 12;
|
|
406
|
+
for (const line of g.lines) {
|
|
407
|
+
parts.push('<text x="' + p.cx.toFixed(1) + '" y="' + ly.toFixed(1) + '" text-anchor="middle" font-size="10.5" fill="' + FLP.text + '">' + escXml(line) + '</text>');
|
|
408
|
+
ly += FC.LINE_H;
|
|
195
409
|
}
|
|
196
410
|
}
|
|
197
|
-
else if (
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
svg += '<text x="' + x + '" y="' + (y + g + FL.LABEL_GAP + li6 * FL.LABEL_LH) + '" text-anchor="middle" font-size="10.5" fill="' + P.ink + '">' + escXml(gls[li6] || '') + '</text>';
|
|
411
|
+
else if (g.shape === 'gateway') {
|
|
412
|
+
const x0 = p.cx - FC.GW_HALF, x1 = p.cx + FC.GW_HALF, y0 = p.cy - FC.GW_HALF, y1 = p.cy + FC.GW_HALF;
|
|
413
|
+
parts.push('<path d="M' + p.cx.toFixed(1) + ',' + y0.toFixed(1) + ' L' + x1.toFixed(1) + ',' + p.cy.toFixed(1) + ' L' + p.cx.toFixed(1) + ',' + y1.toFixed(1) + ' L' + x0.toFixed(1) + ',' + p.cy.toFixed(1) + ' z" fill="' + FLP.bg + '" stroke="' + FLP.gwStroke + '" stroke-width="1.8"/>');
|
|
414
|
+
const mark = n.gateway === 'parallel' ? '+' : n.gateway === 'inclusive' ? 'O' : '×';
|
|
415
|
+
parts.push('<text x="' + p.cx.toFixed(1) + '" y="' + (p.cy + 5).toFixed(1) + '" text-anchor="middle" font-size="16" fill="' + FLP.gwStroke + '" font-weight="700">' + mark + '</text>');
|
|
416
|
+
let ly = p.cy + FC.GW_HALF + 15;
|
|
417
|
+
for (const line of g.lines) {
|
|
418
|
+
parts.push('<text x="' + p.cx.toFixed(1) + '" y="' + ly.toFixed(1) + '" text-anchor="middle" font-size="10.5" fill="' + FLP.text + '">' + escXml(line) + '</text>');
|
|
419
|
+
ly += FC.LINE_H;
|
|
207
420
|
}
|
|
208
421
|
}
|
|
209
422
|
else {
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
423
|
+
const w = FC.NODE_W;
|
|
424
|
+
const h = g.topExtent + g.bottomExtent;
|
|
425
|
+
const x = p.cx - w / 2;
|
|
426
|
+
const y = p.cy - h / 2;
|
|
427
|
+
parts.push('<rect x="' + x.toFixed(1) + '" y="' + y.toFixed(1) + '" width="' + w + '" height="' + h.toFixed(1) + '" rx="9" fill="' + FLP.taskFill + '" stroke="' + FLP.taskStroke + '" stroke-width="1.4"/>');
|
|
428
|
+
const icon = FICONS[String(n.type || '')];
|
|
429
|
+
if (icon)
|
|
430
|
+
parts.push('<text x="' + (x + 9).toFixed(1) + '" y="' + (y + 15).toFixed(1) + '" font-size="11" fill="' + FLP.taskStroke + '">' + icon + '</text>');
|
|
431
|
+
let ty = y + (icon ? 26 : 16);
|
|
432
|
+
for (const line of g.lines) {
|
|
433
|
+
parts.push('<text x="' + p.cx.toFixed(1) + '" y="' + ty.toFixed(1) + '" text-anchor="middle" font-size="' + FC.FONT_SIZE + '" fill="' + FLP.text + '">' + escXml(line) + '</text>');
|
|
434
|
+
ty += FC.LINE_H;
|
|
435
|
+
}
|
|
219
436
|
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
|
|
437
|
+
}
|
|
438
|
+
for (const { points, from, to } of edgeChecks) {
|
|
439
|
+
for (const h of segmentHits(points, new Set([from, to])))
|
|
440
|
+
problems.push('edge ' + from + '->' + to + ' crosses node ' + h.id);
|
|
441
|
+
}
|
|
442
|
+
if (problems.length) {
|
|
443
|
+
try {
|
|
444
|
+
console.warn('[flow-svg] layout issues: ' + problems.join('; '));
|
|
445
|
+
}
|
|
446
|
+
catch { /* */ }
|
|
447
|
+
}
|
|
448
|
+
parts.push('</svg>');
|
|
449
|
+
return parts.join('');
|
|
223
450
|
}
|
|
224
451
|
/* ---------- doc HTML wrapper ---------- */
|
|
225
452
|
function htmlDocPage(title, bodyHtml) {
|