@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.
@@ -1 +1 @@
1
- {"version":3,"file":"board_artifacts.d.ts","sourceRoot":"","sources":["../../src/chat/board_artifacts.ts"],"names":[],"mappings":"AAyBA,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;AAgCD;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA4G5D;AAmBD,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"}
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
- function bpmnWrap(s, maxChars, maxLines) {
46
- const words = String(s == null ? '' : s).split(/\s+/);
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
- if (!cur) {
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
- lines.push(cur);
58
- cur = w;
59
- if (lines.length === maxLines - 1)
60
- break;
66
+ cur = test;
61
67
  }
62
68
  }
63
- if (cur && lines.length < maxLines)
69
+ if (cur)
64
70
  lines.push(cur);
65
- if (lines.length === maxLines && (cur || words.length > 0)) {
66
- const last = lines[maxLines - 1];
67
- if (last && last.length > maxChars)
68
- lines[maxLines - 1] = last.slice(0, maxChars - 1) + '…';
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
- return lines;
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 bpmnHalf(n) {
73
- const type = String(n.type || 'task');
74
- if (/start|^event$|end|intermediate/.test(type))
75
- return 18;
76
- if (/gateway|decision|fork|join|merge/.test(type))
77
- return 23;
78
- return 59;
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 nodes = m.nodes.filter((n) => n && n.id);
95
- const flows = Array.isArray(m.flows) ? m.flows.filter((f) => f && f.from && f.to) : [];
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
- /* Positions + geometry come from the shared layered layout: crossing-reduced
100
- * column ordering and collision-free, auto-sized lane bands (flow_layout.ts).
101
- * The old inline "fixed-height bands + naive stacking" overlapped nodes when a
102
- * (lane, column) cell held more than one. */
103
- const { W, H, laneBands } = layoutFlowModel(nodes, flows, lanes);
104
- const hasLanes = laneBands.length > 0;
105
- const byId = {};
106
- nodes.forEach((n) => { byId[n.id] = n; });
107
- const P = { ink: '#1a1a1a', indigo: '#4f5b87', amber: '#c8a04d', tea: '#6f8b5e', vermilion: '#c8472e', line: '#b8ad95', washi: '#f5f0e6', bg2: '#ece6d8' };
108
- let svg = '<?xml version="1.0" encoding="UTF-8"?>\n'
109
- + '<svg viewBox="0 0 ' + Math.round(W) + ' ' + Math.round(H) + '" width="' + Math.round(W) + '" height="' + Math.round(H) + '"'
110
- + ' preserveAspectRatio="xMinYMin meet" xmlns="http://www.w3.org/2000/svg">';
111
- svg += '<rect x="0" y="0" width="' + Math.round(W) + '" height="' + Math.round(H) + '" fill="' + P.washi + '"/>';
112
- svg += '<defs><marker id="bpmn-arrow" markerWidth="9" markerHeight="9" refX="7" refY="3" orient="auto" markerUnits="strokeWidth">'
113
- + '<path d="M0,0 L7,3 L0,6 z" fill="' + P.ink + '"/></marker></defs>';
114
- if (hasLanes) {
115
- for (let li2 = 0; li2 < laneBands.length; li2++) {
116
- const band = laneBands[li2];
117
- if (!band)
118
- continue;
119
- const ly = band.y;
120
- svg += '<rect x="' + FL.PAD + '" y="' + ly + '" width="' + (W - 2 * FL.PAD) + '" height="' + band.h + '" fill="' + (li2 % 2 ? 'rgba(0,0,0,0.025)' : 'transparent') + '" stroke="' + P.line + '" stroke-width="1"/>';
121
- svg += '<text x="' + (FL.PAD + 14) + '" y="' + (ly + band.h / 2) + '" transform="rotate(-90 ' + (FL.PAD + 14) + ' ' + (ly + band.h / 2) + ')" text-anchor="middle" font-size="11" fill="' + P.indigo + '" font-weight="600">' + escXml(String(band.label)) + '</text>';
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
- /* Branch separation (Pablo 2026-09-08): several sequence flows leaving a
125
- * decision gateway (or converging on a merge) shared the same elbow x, so the
126
- * vertical segments and their Si/No labels overlapped. Give each sibling that
127
- * shares a source (fan-out) or a target (merge) a distinct elbow x, spread
128
- * around the midpoint by its index. Forward edges only (back-edges route
129
- * below). */
130
- const fOutN = {}, fInN = {};
131
- const outSeen = {}, inSeen = {};
132
- const outIdx = [], inIdx = [];
133
- const isFwd = (f) => { const a = byId[f.from], b = byId[f.to]; return !!a && !!b && (b._x || 0) >= (a._x || 0); };
134
- flows.forEach((f) => { if (!isFwd(f))
135
- return; fOutN[f.from] = (fOutN[f.from] || 0) + 1; fInN[f.to] = (fInN[f.to] || 0) + 1; });
136
- flows.forEach((f, i) => { if (!isFwd(f)) {
137
- outIdx[i] = 0;
138
- inIdx[i] = 0;
139
- return;
140
- } outIdx[i] = (outSeen[f.from] = (outSeen[f.from] ?? -1) + 1); inIdx[i] = (inSeen[f.to] = (inSeen[f.to] ?? -1) + 1); });
141
- const SEPX = 16, SEPY = 11;
142
- flows.forEach((f, i) => {
143
- const a = byId[f.from], b = byId[f.to];
144
- if (!a || !b)
145
- return;
146
- const ar = bpmnHalf(a), br = bpmnHalf(b);
147
- const ax = a._x || 0, ay = a._y || 0, bx = b._x || 0, by = b._y || 0;
148
- const forward = (bx >= ax);
149
- /* Sibling separation: branches that share a source (gateway fan-out) or a
150
- * target (merge) leave/arrive at DISTINCT y and turn at DISTINCT x, so they
151
- * read as separate lines and only meet at the node -- no overlapping "double
152
- * line" (Pablo 2026-09-08). */
153
- const oN = fOutN[f.from] || 1, iN = fInN[f.to] || 1;
154
- const outO = oN > 1 ? ((outIdx[i] ?? 0) - (oN - 1) / 2) : 0;
155
- const inO = iN > 1 ? ((inIdx[i] ?? 0) - (iN - 1) / 2) : 0;
156
- const sx = ax + (forward ? ar : -ar), tx = bx - (forward ? br : -br);
157
- const sy = ay + outO * SEPY, ty = by + inO * SEPY;
158
- const midx = Math.max(sx + 10, Math.min(tx - 10, (sx + tx) / 2 + outO * SEPX + inO * (SEPX * 0.6)));
159
- let d;
160
- if (Math.abs(sy - ty) < 2) {
161
- d = 'M' + sx + ',' + sy + ' L' + tx + ',' + ty;
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
- else if (forward) {
164
- d = 'M' + sx + ',' + sy + ' L' + midx + ',' + sy + ' L' + midx + ',' + ty + ' L' + tx + ',' + ty;
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 down = Math.max(ay, by) + 46;
168
- d = 'M' + ax + ',' + (ay + ar) + ' L' + ax + ',' + down + ' L' + bx + ',' + down + ' L' + bx + ',' + (by + br);
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
- svg += '<path d="' + d + '" fill="none" stroke="' + P.ink + '" stroke-width="1.4" marker-end="url(#bpmn-arrow)" opacity="0.8"/>';
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
- /* place the label on the branch's own vertical segment (distinct per
173
- * sibling now) so Si/No never stack on top of each other. */
174
- const lx = forward ? midx : ((ax + bx) / 2), lyy = forward ? (sy + ty) / 2 - 4 : (Math.max(ay, by) + 42);
175
- svg += '<rect x="' + (lx - String(f.label).length * 3.1 - 3) + '" y="' + (lyy - 9) + '" width="' + (String(f.label).length * 6.2 + 6) + '" height="13" fill="' + P.washi + '" opacity="0.9"/>';
176
- svg += '<text x="' + lx + '" y="' + (lyy + 1) + '" text-anchor="middle" font-size="10" fill="' + P.ink + '">' + escXml(String(f.label)) + '</text>';
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
- nodes.forEach((n) => {
180
- const x = n._x || 0, y = n._y || 0, type = String(n.type || 'task');
181
- /* Event shapes match by PREFIX so "sendTask" is not mis-detected as an end
182
- * event (the old /end/ substring matched "sEND"Task -> red circle). */
183
- const isEnd = /^end/.test(type), isStart = /^start/.test(type);
184
- if (isStart || isEnd || /^intermediate/.test(type) || type === 'event') {
185
- const r = 18, sw = isEnd ? 3.4 : 1.6;
186
- const stroke = isStart ? P.tea : (isEnd ? P.vermilion : P.indigo);
187
- svg += '<circle cx="' + x + '" cy="' + y + '" r="' + r + '" fill="' + P.washi + '" stroke="' + stroke + '" stroke-width="' + sw + '"/>';
188
- if (type.indexOf('intermediate') >= 0)
189
- svg += '<circle cx="' + x + '" cy="' + y + '" r="' + (r - 3.5) + '" fill="none" stroke="' + stroke + '" stroke-width="1.2"/>';
190
- /* label WRAPS below the circle (no more 22-char truncation). */
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 (/gateway|decision|fork|join|merge/.test(type)) {
198
- const g = 23, gk = String(n.gateway || (/parallel|fork|join/.test(type) ? 'parallel' : 'exclusive'));
199
- svg += '<path d="M' + x + ',' + (y - g) + ' L' + (x + g) + ',' + y + ' L' + x + ',' + (y + g) + ' L' + (x - g) + ',' + y + ' z" fill="' + P.washi + '" stroke="' + P.amber + '" stroke-width="1.8"/>';
200
- const sym = gk.charAt(0) === 'p' ? '+' : (gk.charAt(0) === 'i' ? 'O' : '×');
201
- svg += '<text x="' + x + '" y="' + (y + 5) + '" text-anchor="middle" font-size="16" fill="' + P.amber + '" font-weight="700">' + sym + '</text>';
202
- /* label WRAPS below the diamond. */
203
- if (n.label) {
204
- const gls = wrapLabel(String(n.label), FL.LABEL_WRAP_CHARS, FL.LABEL_WRAP_LINES);
205
- for (let li6 = 0; li6 < gls.length; li6++)
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 tw = 118, th = 54;
211
- svg += '<rect x="' + (x - tw / 2) + '" y="' + (y - th / 2) + '" width="' + tw + '" height="' + th + '" rx="9" fill="' + P.bg2 + '" stroke="' + P.indigo + '" stroke-width="1.4"/>';
212
- const ic = { userTask: '☺', serviceTask: '⚙', scriptTask: '▸', manualTask: '✋', sendTask: '✉', receiveTask: '✉' }[type];
213
- if (ic)
214
- svg += '<text x="' + (x - tw / 2 + 9) + '" y="' + (y - th / 2 + 14) + '" font-size="11" fill="' + P.indigo + '">' + ic + '</text>';
215
- const ls = bpmnWrap(n.label || n.id, 17, 3);
216
- const ty0 = y - (ls.length - 1) * 7;
217
- for (let li3 = 0; li3 < ls.length; li3++)
218
- svg += '<text x="' + x + '" y="' + (ty0 + li3 * 14 + 4) + '" text-anchor="middle" font-size="11.5" fill="' + P.ink + '">' + escXml(ls[li3] || '') + '</text>';
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
- svg += '</svg>\n';
222
- return svg;
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) {