stdraw 0.7.13__py3-none-any.whl

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.
@@ -0,0 +1,2030 @@
1
+
2
+
3
+ # ═══ Interactive layer script (drag rearrange; re-routing + bend handles;
4
+ # per-edge style cycle + free fold points + apply API) ═══
5
+ # Pure vanilla JS (zero dependencies, zero network). Attached to pages generated
6
+ # by render_auto_html:
7
+ # - drag nodes (SVG pointer events, mouse/touch unified; clamped to the
8
+ # viewBox while dragging);
9
+ # - live "all line kinds re-routed while dragging" (preview caliber):
10
+ # · straight/vg polylines: a JS port of the visibility-graph
11
+ # shortest-path router (same mathematical caliber as
12
+ # stdraw/routing+visibility: straight-line candidates need 16px
13
+ # clearance, otherwise inflated-corner visibility graph Dijkstra +
14
+ # string-pulling shortcuts, inflation 17.5px) — bends recomputed on
15
+ # every drag, no more pinned midpoints (fixes the root cause of
16
+ # the user-reported issue 2: the old reanchor only rebuilt endpoints and
17
+ # waypoints stayed pinned in place);
18
+ # · arcs: quadratic Bezier endpoint re-anchoring + control point
19
+ # translated by the mean endpoint displacement, all vertices follow;
20
+ # · unrelated edges pressed by the moving node (1px margin) also
21
+ # trigger re-routing;
22
+ # - draggable bend handles: each bend of a vg polyline (route.via non-empty)
23
+ # renders as a small circular handle (move cursor on hover); dragging it
24
+ # marks the edge "user-anchored" — afterwards node drags only re-anchor
25
+ # endpoints and keep user waypoints; the exported layout JSON carries
26
+ # edges[].waypoints, and CLI --from-layout recomputes under the same
27
+ # caliber (anchors = box border facing the first/last waypoint);
28
+ # - v0.7.7 per-edge style cycle: double-click a line to cycle
29
+ # auto -> straight -> arc -> free -> auto (>=10px hover hit zone,
30
+ # pointer cursor + highlight; double-click never disturbs node drags);
31
+ # forced states re-route live and the top-bar metrics recompute
32
+ # honestly (red when a hard gate fails — no leniency);
33
+ # - v0.7.7 free fold points: in free mode click the line to insert a
34
+ # handle (exact WYSIWYG polyline, no smoothing; endpoints re-anchored
35
+ # on the box borders facing the first/last fold point); at most
36
+ # MAX_FREE_PTS user fold points per edge — at the cap a click selects
37
+ # the nearest handle with a "max N points per edge" tooltip;
38
+ # - segment drag (drawio/bpmn-io .djs-segment-dragger caliber): on edges
39
+ # with explicit user geometry (free mode with fold points / user-
40
+ # anchored pinned polylines) dragging a segment translates the whole
41
+ # segment; jog bendpoints are created automatically at the dragged
42
+ # segment's pre-drag endpoints on first movement (anchor-adjacent
43
+ # segments insert one, interior segments insert two — the anchors
44
+ # themselves re-anchor on the box borders). Cursor reflects the segment
45
+ # orientation (ns-resize on horizontal, ew-resize on vertical).
46
+ # Auto-layout edges and straight lines are NOT affected: they keep live
47
+ # re-routing (deterministic contract untouched);
48
+ # - floating bendpoints (bpmn-io .djs-bendpoint.floating caliber): a
49
+ # semi-transparent handle parks at the MIDDLE of the hovered segment
50
+ # (mid-parking, not cursor-following — a cursor-following handle would
51
+ # intercept every pointerdown and make segment drag unreachable).
52
+ # Preview-only: hovering never writes user state. Dragging it >=4px
53
+ # away adopts the edge's current geometry as user fold points (free
54
+ # mode) and creates exactly one fold point; a plain click is a no-op;
55
+ # - fold-point lifecycle: a fold point released within 6px (svg units)
56
+ # of the polyline through its neighboring points merges back into the
57
+ # line (removed; pinned edges whose interior empties fall back to
58
+ # auto). Deletion semantics finalized = drag-back-to-line merge.
59
+ # Legacy paths (double-click style cycle / free click-insert / handle
60
+ # drag) unchanged. Drag preview: the live re-route + release-persist
61
+ # model is kept — no ghost layer added (documented no-op);
62
+ # - v0.7.7 persistence: "Export layout" writes edges[].style (+ fold
63
+ # points as waypoints); window.__stdrawApplyLayout({nodes, edge_styles,
64
+ # waypoints}) restores a layout programmatically; every drag release /
65
+ # fold-point insert / style change / reset dispatches the CustomEvent
66
+ # "stdraw:layoutchanged" on window (detail = the current layout), so
67
+ # product integrations can drop pointerup polling;
68
+ # - live metrics in the top bar (truly computed on preview geometry:
69
+ # crossings / 1px card presses / card gap / CV / row alignment);
70
+ # - full metric refresh on release + deterministic replay of R/B polarity
71
+ # markers (same caliber as render._polarity_marker);
72
+ # - v0.7.9 font-environment robustness: after render, the ACTUAL DOM text
73
+ # geometry is measured (getComputedTextLength/getBBox) per card; when the
74
+ # rendered text overflows the planned card rect the rect is grown (never
75
+ # shrunk) to fit and every edge re-anchors on the new border -- when the
76
+ # browser inflates DOM text (minimum font size / user font scale, which
77
+ # the engine-side text metrics cannot see) lines still hug the rendered
78
+ # frame at 0px. No-op when measured geometry matches the plan (initial
79
+ # render stays pixel-identical to the static rendering); public hook:
80
+ # window.__stdrawSyncDomGeometry();
81
+ # - "Reset" restores the initial layout (incl. original path_d rendering,
82
+ # handle removal, style + fold-point clearing).
83
+ # - t_2c2496ca standard anchors + endpoint drag: hover a box shows its 16
84
+ # standard anchors (stdraw/anchors.py caliber, toggleable); every edge's
85
+ # start/arrow endpoint is individually draggable (topmost #stdraw-eps hit
86
+ # circles beat the segment hit band), releasing snaps to the nearest
87
+ # standard anchor of the endpoint's own box, and clicking a visible
88
+ # anchor explicitly assigns it (nearest incident endpoint). Explicit
89
+ # designations (userAnchored) travel with the export (edges[].anchors)
90
+ # and __stdrawApplyLayout edge_anchors; afterwards node drags only
91
+ # RE-PROJECT the endpoint onto the SAME anchor position (never drift).
92
+ # The static layout / auto-routing path is untouched (no default-path
93
+ # behavior change; the 21-input sweep stays byte-identical).
94
+ # Determinism: the generated HTML contains no timestamps/random numbers;
95
+ # without dragging the initial geometry is pixel-identical to the static render.
96
+ _INTERACTIVE_JS = r"""(function () {
97
+ 'use strict';
98
+ var dataEl = document.getElementById('stdraw-data');
99
+ if (!dataEl) return;
100
+ var D = JSON.parse(dataEl.textContent);
101
+ var svg = document.getElementById('stdraw-svg');
102
+ var VB = D.viewBox, cardH = D.cardH;
103
+ var CL = 16, VG_C = 17.5, VEPS = 1e-6; // same caliber as routing.CLEARANCE / +VG_PAD
104
+ var nodes = {};
105
+ D.nodes.forEach(function (n) { nodes[n.id] = {x: n.x, y: n.y, w: n.w, h: cardH, bx: n.x, by: n.y, g: null}; });
106
+ svg.querySelectorAll('[data-node]').forEach(function (g) {
107
+ var id = g.getAttribute('data-node');
108
+ if (nodes[id]) nodes[id].g = g;
109
+ // t_2c2496ca: hovering a box shows its 16 standard anchors (toggleable).
110
+ // The anchor dots layer is pointer-events:none — the dots never steal the
111
+ // cursor, so hovering stays on the node group and the dots cannot flicker.
112
+ g.addEventListener('mouseenter', function () {
113
+ hoverNode = g.getAttribute('data-node');
114
+ renderAnchors();
115
+ });
116
+ g.addEventListener('mouseleave', function () {
117
+ if (hoverNode === g.getAttribute('data-node')) { hoverNode = null; renderAnchors(); }
118
+ });
119
+ });
120
+ function quad(p0, c, p1, n) {
121
+ var out = [];
122
+ for (var k = 0; k <= n; k++) {
123
+ var t = k / n, u = 1 - t;
124
+ out.push([u*u*p0[0] + 2*u*t*c[0] + t*t*p1[0], u*u*p0[1] + 2*u*t*c[1] + t*t*p1[1]]);
125
+ }
126
+ return out;
127
+ }
128
+ function cp(p) { return [p[0], p[1]]; }
129
+ var edges = D.edges.map(function (e) {
130
+ var pts = e.pts, arc = !!e.arc && !!e.ctrl && pts.length >= 2;
131
+ if (arc) pts = quad(pts[0], e.ctrl, pts[pts.length - 1], 12);
132
+ pts = pts.map(cp);
133
+ return {a: e.a, b: e.b, pol: e.pol, arc: arc,
134
+ ctrl0: arc ? cp(e.ctrl) : null,
135
+ pa0: cp(pts[0]), pb0: cp(pts[pts.length - 1]),
136
+ via: (e.via && e.via.length >= 3) ? e.via.map(cp) : null,
137
+ corners: (e.via && e.via.length >= 3) ? e.via.map(cp) : null,
138
+ pts: pts, base: pts.map(cp), d0: null};
139
+ });
140
+ edges.forEach(function (e, i) {
141
+ var g = visG(i);
142
+ if (g) {
143
+ var p0 = g.querySelectorAll('path')[0];
144
+ if (p0) {
145
+ e.d0 = p0.getAttribute('d');
146
+ bindEdgeHover(i, p0); // visible stroke sits on top of the hit band
147
+ }
148
+ }
149
+ });
150
+ var polGroups = {};
151
+ // R/B text badges removed (loop polarity is expressed by edge coloring; the
152
+ // static stroke attributes are kept while dragging); polGroups/refreshMarkers
153
+ // remain as empty implementations for old page-structure compatibility.
154
+ var incidentMap = {};
155
+ Object.keys(nodes).forEach(function (v) {
156
+ incidentMap[v] = [];
157
+ edges.forEach(function (e, i) { if (e.a === v || e.b === v) incidentMap[v].push(i); });
158
+ });
159
+ function edgeD(pts) {
160
+ return 'M ' + pts.map(function (p) { return p[0].toFixed(1) + ' ' + p[1].toFixed(1); }).join(' L ');
161
+ }
162
+ // ── v0.7.7 per-edge hover hit zones (>=10px, pointer cursor, highlight) ──
163
+ function bindEdgeHover(i, el) {
164
+ // Hover wiring shared by the hit path AND the visible line: in the real
165
+ // render the visible stroke sits on top of the 12px hit band, so edge
166
+ // hover events must be bound to both (the ±0.8px sliver on the visible
167
+ // line targets the visible path; the rest of the band targets the hit
168
+ // path). Derived state: highlight + floating bendpoint preview +
169
+ // segment-orientation cursor.
170
+ el.addEventListener('mouseenter', function () { hlEdge(i, true); });
171
+ el.addEventListener('mouseleave', function () {
172
+ hlEdge(i, false);
173
+ // NOTE: the inline segment cursor is deliberately NOT cleared here.
174
+ // Showing the floating handle under a stationary cursor makes the
175
+ // browser fire mouseleave right after mousemove (hover recompute);
176
+ // clearing there would wipe the just-set ns/ew-resize cursor. The
177
+ // cursor self-corrects on the next mousemove over any edge segment.
178
+ // Preview visibility is decided by the elementFromPoint interval
179
+ // below, not by boundary events (measured timing-dependent headless).
180
+ });
181
+ el.addEventListener('mousemove', function (ev) {
182
+ var p = svgPoint(ev);
183
+ if (!p) return;
184
+ lastFloatPos = [ev.clientX, ev.clientY];
185
+ showFloat(i, p);
186
+ if (segDraggable(i)) {
187
+ var sd = segAt(edges[i].pts, p);
188
+ if (sd) el.style.cursor = orientCursor(edges[i].pts, sd.k);
189
+ }
190
+ });
191
+ }
192
+ var SVGNS_EARLY = 'http://www.w3.org/2000/svg';
193
+ var hitLayer = document.createElementNS(SVGNS_EARLY, 'g');
194
+ hitLayer.id = 'stdraw-hits';
195
+ svg.insertBefore(hitLayer, svg.firstChild);
196
+ edges.forEach(function (e, i) {
197
+ var h = document.createElementNS(SVGNS_EARLY, 'path');
198
+ h.setAttribute('class', 'stdraw-hit');
199
+ h.setAttribute('data-edge', i);
200
+ h.setAttribute('d', edgeD(e.pts));
201
+ bindEdgeHover(i, h);
202
+ hitLayer.appendChild(h);
203
+ });
204
+ // ── v0.7.11 edge-element resolution: visible edges are `g[data-edge]`
205
+ // groups (render.py); hit paths are `path.stdraw-hit[data-edge]` inside
206
+ // #stdraw-hits, which sits at svg.firstChild — so a bare attribute-selector
207
+ // lookup on the data-edge attribute resolves to the HIT path first
208
+ // (document order). updateEdge then queried `path` CHILDREN of a path
209
+ // element (always empty) and the visible line never redrew: internal state
210
+ // re-anchored, DOM stale, zero exceptions (2026-11-08 8777/production regression,
211
+ // t_3fb02650). Always resolve the visible group / hit path explicitly.
212
+ function visG(i) { return svg.querySelector('g[data-edge="' + i + '"]'); }
213
+ function hitP(i) { return hitLayer.querySelector('.stdraw-hit[data-edge="' + i + '"]'); }
214
+ // ── v0.7.11 per-edge failure isolation (fail-visible, never abort the round):
215
+ // one edge whose reroute/updateEdge throws (degenerate geometry after card
216
+ // growth: coincident anchors, infeasible route) must not kill the remaining
217
+ // edges of a re-anchor pass — they would stay on stale anchors (jacky's Edge
218
+ // mixed floating-arrow outage, t_3fb02650). Degrade that edge visibly (red
219
+ // dashed stroke) + console WARN, keep going, and self-heal the marker on the
220
+ // edge's next successful pass.
221
+ function markDegraded(i, what, err) {
222
+ try { console.warn('[stdraw] edge #' + i + ' ' + what + ' failed: ' +
223
+ (err && err.message ? err.message : err)); } catch (e0) {}
224
+ var g = visG(i);
225
+ if (g) g.classList.add('stdraw-degraded');
226
+ var h = hitP(i);
227
+ if (h) h.classList.add('stdraw-degraded');
228
+ }
229
+ function clearDegraded(i) {
230
+ var g = visG(i);
231
+ if (g) g.classList.remove('stdraw-degraded');
232
+ var h = hitP(i);
233
+ if (h) h.classList.remove('stdraw-degraded');
234
+ }
235
+ function safeRerouteUpdate(i, what) {
236
+ try { reroute(i); updateEdge(i); clearDegraded(i); return true; }
237
+ catch (err) { markDegraded(i, what || 'reroute/update', err); return false; }
238
+ }
239
+ function hlEdge(i, on) {
240
+ var g = visG(i);
241
+ if (g) { if (on) g.classList.add('hl'); else g.classList.remove('hl'); }
242
+ setEpVis(i, on); // t_51392037: endpoint handle dots show only on hover
243
+ }
244
+ // t_51392037: per-edge endpoint handle visibility. Default hidden (visual
245
+ // noise in the rendered graph); visible while the edge is hovered or its
246
+ // endpoint is being dragged (drag hover is intercepted by the topmost hit
247
+ // circle, so the drag start must show them explicitly).
248
+ function setEpVis(i, on) {
249
+ var rec = epEls[i];
250
+ if (!rec) return;
251
+ [0, 1].forEach(function (end) {
252
+ (rec[end] || []).forEach(function (c) {
253
+ if (c.getAttribute('class').indexOf('stdraw-eps-hit') === -1)
254
+ c.classList.toggle('vis', !!on);
255
+ });
256
+ });
257
+ }
258
+ function updateEdge(i) {
259
+ var e = edges[i], g = visG(i);
260
+ var h = hitP(i);
261
+ if (h) h.setAttribute('d', edgeD(e.pts));
262
+ if (!g) return;
263
+ var paths = g.querySelectorAll('path');
264
+ if (paths[0]) paths[0].setAttribute('d', edgeD(e.pts));
265
+ if (paths[1]) {
266
+ var n = e.pts.length, a = e.pts[n - 2], b = e.pts[n - 1];
267
+ var ang = Math.atan2(b[1] - a[1], b[0] - a[0]) * 180 / Math.PI;
268
+ paths[1].setAttribute('transform', 'translate(' + b[0].toFixed(1) + ' ' + b[1].toFixed(1) + ') rotate(' + ang.toFixed(1) + ')');
269
+ }
270
+ updateEndpointHandles(i); // t_2c2496ca: endpoint handles track every redraw
271
+ }
272
+ function borderPoint(c, hw, hh, toward) {
273
+ // Connector delivery caliber: ray-determined border exit -> the anchor is
274
+ // clamped along that border's tangent into the edge-midpoint +/-1/4 edge
275
+ // length band (orthogonal exits land on the midpoint, diagonal exits are
276
+ // pulled back into the band, corners excluded).
277
+ // Same caliber as Python routing.border_point + Router.midband_reanchor.
278
+ var dx = toward[0] - c[0], dy = toward[1] - c[1];
279
+ if (!dx && !dy) return [c[0], c[1]];
280
+ if (!dx) return [c[0], c[1] + (dy > 0 ? hh : -hh)];
281
+ if (!dy) return [c[0] + (dx > 0 ? hw : -hw), c[1]];
282
+ var t = Math.min(hw / Math.abs(dx), hh / Math.abs(dy));
283
+ var px = c[0] + dx * t, py = c[1] + dy * t;
284
+ var onX = Math.abs(Math.abs(px - c[0]) - hw) <= Math.abs(Math.abs(py - c[1]) - hh);
285
+ if (onX) {
286
+ var limY = hh / 2;
287
+ return [px, c[1] + Math.max(-limY, Math.min(limY, py - c[1]))];
288
+ }
289
+ var limX = hw / 2;
290
+ return [c[0] + Math.max(-limX, Math.min(limX, px - c[0])), py];
291
+ }
292
+ // v0.7.9 per-card height: every border/obstacle/metrics site below uses the
293
+ // node's own h (synced to the rendered DOM geometry by syncDomGeometry)
294
+ // instead of the global planned cardH, so anchors always match the frame the
295
+ // user actually sees.
296
+ function nHH(n) { return (n.h || cardH) / 2; }
297
+ function anchor(n, toward) { return borderPoint([n.x, n.y], n.w / 2, nHH(n), toward); }
298
+ // ── t_2c2496ca standard per-box anchor set (16 points) ──
299
+ // Mirror of stdraw/anchors.py (authoritative Python caliber; the probe
300
+ // cross-checks JS vs Python equality). All 16 points sit on the border
301
+ // rectangle inset by ANCHOR_INSET (the routing fan-slot -3 inner-offset
302
+ // caliber): 4 edge midpoints + 4 corner points (never the geometric tip /
303
+ // the rounded rx=7 frame corner) + 2 golden-section points per edge
304
+ // (0.382 / 0.618, clockwise g1/g2 direction — see anchors.py docstring).
305
+ var ANCHOR_INSET = 3.0, GOLDEN1 = 0.382, GOLDEN2 = 0.618;
306
+ var ANCHOR_IDS = ['n', 'e', 's', 'w',
307
+ 'ne', 'se', 'sw', 'nw',
308
+ 'n-g1', 'n-g2', 'e-g1', 'e-g2',
309
+ 's-g1', 's-g2', 'w-g1', 'w-g2'];
310
+ function stdAnchors(n) {
311
+ var a = n.w / 2 - ANCHOR_INSET, b = nHH(n) - ANCHOR_INSET;
312
+ var cx = n.x, cy = n.y;
313
+ function r6(v) { return Math.round(v * 1e6) / 1e6; }
314
+ return [['n', cx, cy - b], ['e', cx + a, cy], ['s', cx, cy + b], ['w', cx - a, cy],
315
+ ['ne', cx + a, cy - b], ['se', cx + a, cy + b],
316
+ ['sw', cx - a, cy + b], ['nw', cx - a, cy - b],
317
+ ['n-g1', cx - a + 2 * a * GOLDEN1, cy - b],
318
+ ['n-g2', cx - a + 2 * a * GOLDEN2, cy - b],
319
+ ['e-g1', cx + a, cy - b + 2 * b * GOLDEN1],
320
+ ['e-g2', cx + a, cy - b + 2 * b * GOLDEN2],
321
+ ['s-g1', cx + a - 2 * a * GOLDEN1, cy + b],
322
+ ['s-g2', cx + a - 2 * a * GOLDEN2, cy + b],
323
+ ['w-g1', cx - a, cy + b - 2 * b * GOLDEN1],
324
+ ['w-g2', cx - a, cy + b - 2 * b * GOLDEN2]
325
+ ].map(function (p) { return [p[0], r6(p[1]), r6(p[2])]; });
326
+ }
327
+ function nearestAnchorId(v, px, py) {
328
+ var n = nodes[v];
329
+ if (!n) return null;
330
+ var pts = stdAnchors(n), best = null;
331
+ for (var k = 0; k < pts.length; k++) {
332
+ var d = Math.hypot(pts[k][1] - px, pts[k][2] - py);
333
+ if (best === null || d < best[0] - 1e-9) best = [d, pts[k][0]];
334
+ }
335
+ return best ? best[1] : null;
336
+ }
337
+ // userAnchored: edgeIdx -> {0: aid | 1: aid} — explicitly designated
338
+ // standard anchors (endpoint drag release / anchor click / applyLayout).
339
+ // user-anchored semantics: after designation, node drags only RE-PROJECT
340
+ // the endpoint onto the SAME anchor position (live geometry), never drift.
341
+ var userAnchored = {};
342
+ function userAnchorPt(i, end) {
343
+ var ua = userAnchored[i];
344
+ if (!ua || !ua[end]) return null;
345
+ var e = edges[i];
346
+ var n = nodes[end === 0 ? e.a : e.b];
347
+ if (!n) return null;
348
+ var pts = stdAnchors(n);
349
+ for (var k = 0; k < pts.length; k++) {
350
+ if (pts[k][0] === ua[end]) return [pts[k][1], pts[k][2]];
351
+ }
352
+ delete ua[end]; // stale designation: fall back gracefully
353
+ return null;
354
+ }
355
+ function endPt(i, end, fallback) {
356
+ var p = userAnchorPt(i, end);
357
+ return p || fallback;
358
+ }
359
+ function reanchor(i) {
360
+ var e = edges[i], A = nodes[e.a], B = nodes[e.b];
361
+ var pts = e.pts.map(cp);
362
+ var inner = pts.length > 2;
363
+ pts[0] = endPt(i, 0, anchor(A, inner ? pts[1] : [B.x, B.y]));
364
+ pts[pts.length - 1] = endPt(i, 1, anchor(B, inner ? pts[pts.length - 2] : [A.x, A.y]));
365
+ e.pts = pts;
366
+ }
367
+
368
+ // ── JS-side preview router (visibility-graph shortest path, same mathematical caliber as visibility.py) ──
369
+ function ptSegDist(px, py, a, b) {
370
+ var vx = b[0] - a[0], vy = b[1] - a[1], L2 = vx * vx + vy * vy;
371
+ if (L2 < VEPS) return Math.hypot(px - a[0], py - a[1]);
372
+ var t = Math.max(0, Math.min(1, ((px - a[0]) * vx + (py - a[1]) * vy) / L2));
373
+ return Math.hypot(px - (a[0] + t * vx), py - (a[1] + t * vy));
374
+ }
375
+ function ornt(a, b, c) {
376
+ var v = (b[0]-a[0])*(c[1]-a[1]) - (b[1]-a[1])*(c[0]-a[0]);
377
+ return Math.abs(v) < VEPS ? 0 : (v > 0 ? 1 : -1);
378
+ }
379
+ function segSegDist(p1, p2, p3, p4) {
380
+ var d1 = ornt(p3, p4, p1), d2 = ornt(p3, p4, p2),
381
+ d3 = ornt(p1, p2, p3), d4 = ornt(p1, p2, p4);
382
+ if (d1 !== d2 && d3 !== d4 && d1 !== 0 && d3 !== 0) return 0.0;
383
+ return Math.min(ptSegDist(p1[0], p1[1], p3, p4),
384
+ ptSegDist(p2[0], p2[1], p3, p4),
385
+ ptSegDist(p3[0], p3[1], p1, p2),
386
+ ptSegDist(p4[0], p4[1], p1, p2));
387
+ }
388
+ function segHitsRect(x1, y1, x2, y2, cx, cy, hw, hh) {
389
+ var dx = x2 - x1, dy = y2 - y1, t0 = 0.0, t1 = 1.0;
390
+ var pq = [[-dx, x1 - (cx - hw)], [dx, (cx + hw) - x1],
391
+ [-dy, y1 - (cy - hh)], [dy, (cy + hh) - y1]];
392
+ for (var k = 0; k < 4; k++) {
393
+ var p = pq[k][0], q = pq[k][1];
394
+ if (Math.abs(p) < VEPS) { if (q < 0) return false; }
395
+ else {
396
+ var r = q / p;
397
+ if (p < 0) { if (r > t1) return false; if (r > t0) t0 = r; }
398
+ else { if (r < t0) return false; if (r < t1) t1 = r; }
399
+ }
400
+ }
401
+ return t1 - t0 > VEPS;
402
+ }
403
+ function segRectDist(p1, p2, r) {
404
+ if (segHitsRect(p1[0], p1[1], p2[0], p2[1], r[0], r[1], r[2], r[3])) return 0.0;
405
+ var cx = r[0], cy = r[1], hw = r[2], hh = r[3];
406
+ var cs = [[cx-hw, cy-hh], [cx+hw, cy-hh], [cx+hw, cy+hh], [cx-hw, cy+hh]];
407
+ var best = Infinity;
408
+ for (var k = 0; k < 4; k++) {
409
+ best = Math.min(best, segSegDist(p1, p2, cs[k], cs[(k + 1) % 4]));
410
+ }
411
+ return best;
412
+ }
413
+ function visible(p1, p2, rects, c) {
414
+ var t = c - VEPS;
415
+ var x1l = Math.min(p1[0], p2[0]), x1h = Math.max(p1[0], p2[0]);
416
+ var y1l = Math.min(p1[1], p2[1]), y1h = Math.max(p1[1], p2[1]);
417
+ for (var i = 0; i < rects.length; i++) {
418
+ var r = rects[i];
419
+ var gx = Math.max(r[0] - r[2] - x1h, x1l - r[0] - r[2], 0.0);
420
+ var gy = Math.max(r[1] - r[3] - y1h, y1l - r[1] - r[3], 0.0);
421
+ if (gx >= t || gy >= t) continue; // bbox distance lower bound >= c: cannot block
422
+ if (gx * gx + gy * gy >= t * t) continue;
423
+ if (segRectDist(p1, p2, r) < t) return false;
424
+ }
425
+ return true;
426
+ }
427
+ function vgShortest(src, dst, rects, c) {
428
+ if (!rects.length) return [cp(src), cp(dst)];
429
+ var nds = [cp(src), cp(dst)];
430
+ rects.forEach(function (r) {
431
+ var gx = r[2] + c, gy = r[3] + c;
432
+ nds.push([r[0]-gx, r[1]-gy], [r[0]+gx, r[1]-gy], [r[0]+gx, r[1]+gy], [r[0]-gx, r[1]+gy]);
433
+ });
434
+ var n = nds.length, adj = [];
435
+ for (var k0 = 0; k0 < n; k0++) adj.push([]);
436
+ for (var i = 0; i < n; i++) {
437
+ for (var j = i + 1; j < n; j++) {
438
+ if (visible(nds[i], nds[j], rects, c)) {
439
+ var w = Math.hypot(nds[i][0]-nds[j][0], nds[i][1]-nds[j][1]);
440
+ adj[i].push([j, w]); adj[j].push([i, w]);
441
+ }
442
+ }
443
+ }
444
+ // Deterministic Dijkstra: array scan for the minimum (ties break to the lower index), same input same output
445
+ var INF = Infinity, dist = [], prev = [], done = [];
446
+ for (var k1 = 0; k1 < n; k1++) { dist.push(INF); prev.push(-1); done.push(false); }
447
+ dist[0] = 0;
448
+ for (var it = 0; it < n; it++) {
449
+ var u = -1, best = INF;
450
+ for (var k2 = 0; k2 < n; k2++) if (!done[k2] && dist[k2] < best - VEPS) { best = dist[k2]; u = k2; }
451
+ if (u < 0) break;
452
+ done[u] = true;
453
+ if (u === 1) break;
454
+ for (var e2 = 0; e2 < adj[u].length; e2++) {
455
+ var v = adj[u][e2][0], nd = dist[u] + adj[u][e2][1];
456
+ if (nd < dist[v] - VEPS) { dist[v] = nd; prev[v] = u; }
457
+ }
458
+ }
459
+ if (dist[1] >= INF) return null;
460
+ var path = [], cur = 1;
461
+ while (cur !== -1) { path.push(cp(nds[cur])); cur = prev[cur]; }
462
+ path.reverse();
463
+ return path;
464
+ }
465
+ function shortcut(path, rects, c) {
466
+ var pts = path.map(cp);
467
+ for (var round = 0; round < 3; round++) {
468
+ var changed = false, i = 1;
469
+ while (i < pts.length - 1) {
470
+ if (visible(pts[i - 1], pts[i + 1], rects, c)) { pts.splice(i, 1); changed = true; }
471
+ else i++;
472
+ }
473
+ if (!changed) break;
474
+ }
475
+ return pts;
476
+ }
477
+ function rectsFor(a, b) {
478
+ var out = [];
479
+ Object.keys(nodes).forEach(function (v) {
480
+ if (v === a || v === b) return;
481
+ var n = nodes[v];
482
+ out.push([n.x, n.y, n.w / 2, nHH(n)]);
483
+ });
484
+ return out;
485
+ }
486
+ function previewRoute(i) {
487
+ // Preview-caliber routing: straight line when visible; otherwise
488
+ // visibility-graph detour (same math as the Python router, without fillet
489
+ // smoothing and mutual-pair separation — the certificate remains
490
+ // authoritative via CLI recomputation).
491
+ var e = edges[i], A = nodes[e.a], B = nodes[e.b];
492
+ var pa = endPt(i, 0, anchor(A, [B.x, B.y]));
493
+ var pb = endPt(i, 1, anchor(B, [A.x, A.y]));
494
+ var rects = rectsFor(e.a, e.b);
495
+ if (visible(pa, pb, rects, CL)) { e.pts = [pa, pb]; e.corners = null; return; }
496
+ var path = vgShortest(pa, pb, rects, VG_C);
497
+ if (path && path.length >= 2) {
498
+ e.pts = shortcut(path, rects, VG_C);
499
+ e.corners = (e.pts.length >= 3) ? e.pts.map(cp) : null;
500
+ return;
501
+ }
502
+ reanchor(i); // fallback (unreachable): shape-preserving re-anchor
503
+ }
504
+ function arcFollow(i) {
505
+ var e = edges[i], A = nodes[e.a], B = nodes[e.b];
506
+ // v0.7.11 defensive: an arc edge missing its frozen control state (partial
507
+ // restore paths) must degrade to a straight line, not throw mid-round.
508
+ if (!e.ctrl0 || !e.pa0 || !e.pb0) { forcedStraight(i); return; }
509
+ var pa = endPt(i, 0, anchor(A, [B.x, B.y]));
510
+ var pb = endPt(i, 1, anchor(B, [A.x, A.y]));
511
+ var ctrl = [e.ctrl0[0] + ((pa[0] - e.pa0[0]) + (pb[0] - e.pb0[0])) / 2,
512
+ e.ctrl0[1] + ((pa[1] - e.pa0[1]) + (pb[1] - e.pb0[1])) / 2];
513
+ e.pts = quad(pa, ctrl, pb, 12);
514
+ e.corners = null;
515
+ }
516
+ // ── v0.7.7 forced geometries (per-edge style cycle; same math caliber as
517
+ // the Python replay in layout_auto._manual_edge_styles/_forced_arc_route) ──
518
+ function forcedStraight(i) {
519
+ var e = edges[i], A = nodes[e.a], B = nodes[e.b];
520
+ var pa = endPt(i, 0, anchor(A, [B.x, B.y]));
521
+ var pb = endPt(i, 1, anchor(B, [A.x, A.y]));
522
+ e.pts = [pa, pb];
523
+ e.corners = null;
524
+ }
525
+ function adaptiveArcK(d) {
526
+ // same parameterization as routing.adaptive_arc_k
527
+ var bulge = Math.min(6.0 + 0.06 * d, 40.0);
528
+ var k = 2.0 * bulge / Math.max(d, 1e-6);
529
+ return Math.max(0.12, Math.min(0.50, k));
530
+ }
531
+ function forcedArc(i) {
532
+ // anchors = box borders facing each other; curvature = chord-adaptive k;
533
+ // side = the sign whose sampled arc keeps more distance from unrelated
534
+ // boxes (deterministic tie -> +1). Same rule as the Python replay.
535
+ var e = edges[i], A = nodes[e.a], B = nodes[e.b];
536
+ var pa = endPt(i, 0, anchor(A, [B.x, B.y]));
537
+ var pb = endPt(i, 1, anchor(B, [A.x, A.y]));
538
+ var vx = pb[0] - pa[0], vy = pb[1] - pa[1], d = Math.hypot(vx, vy);
539
+ var k = adaptiveArcK(d), n = Math.max(d, 1e-6);
540
+ var ux = -vy / n, uy = vx / n;
541
+ var best = null;
542
+ [1, -1].forEach(function (s) {
543
+ var ctrl = [(pa[0] + pb[0]) / 2 + ux * k * d * s,
544
+ (pa[1] + pb[1]) / 2 + uy * k * d * s];
545
+ var pts = quad(pa, ctrl, pb, 12);
546
+ var mclr = null;
547
+ Object.keys(nodes).forEach(function (v) {
548
+ if (v === e.a || v === e.b) return;
549
+ var nn = nodes[v];
550
+ for (var q = 0; q < pts.length; q++) {
551
+ var dd = distRect(pts[q][0], pts[q][1], nn.x, nn.y, nn.w / 2, nHH(nn));
552
+ mclr = (mclr === null) ? dd : Math.min(mclr, dd);
553
+ }
554
+ });
555
+ var key = (-(mclr === null ? 1e9 : mclr));
556
+ if (best === null || key < best[0]) best = [key, pts, ctrl];
557
+ });
558
+ e.pts = best[1];
559
+ e.corners = null;
560
+ }
561
+ function reanchorFree(i) {
562
+ // free mode: exact polyline over the user fold points; endpoints = box
563
+ // borders facing the first/last fold point (same caliber as waypoints)
564
+ var e = edges[i], A = nodes[e.a], B = nodes[e.b];
565
+ var wps = freeWps[i];
566
+ if (!wps || !wps.length) { forcedStraight(i); return; }
567
+ var pts = [endPt(i, 0, anchor(A, wps[0]))];
568
+ pts = pts.concat(wps.map(cp));
569
+ pts.push(endPt(i, 1, anchor(B, wps[wps.length - 1])));
570
+ e.pts = pts;
571
+ e.corners = null;
572
+ }
573
+ function reroute(i) {
574
+ // v0.7.11 fault-injection hook (probe-only, never set by UI code): lets the
575
+ // DOM-truth regression probe force a single-edge router failure to prove the
576
+ // per-edge isolation + fail-visible degradation (t_3fb02650 acceptance).
577
+ if (edges[i] && edges[i]._probeThrow) {
578
+ throw new Error('probe-injected router failure (edge #' + i + ')');
579
+ }
580
+ var st = styles[i] || 'auto';
581
+ if (st === 'free') { reanchorFree(i); return; } // user fold points: re-anchor endpoints only
582
+ if (pinned[i]) { reanchor(i); return; } // user-anchored: re-anchor endpoints only, keep waypoints
583
+ if (st === 'straight') { forcedStraight(i); return; }
584
+ if (st === 'arc') { forcedArc(i); return; }
585
+ if (edges[i].arc) { arcFollow(i); return; }
586
+ previewRoute(i);
587
+ // v0.7.11 defensive final guard (degenerate geometry after card growth:
588
+ // extreme wide/flat cards, coincident anchors, infeasible route): any
589
+ // non-finite coordinate that slipped through a router path degrades this
590
+ // edge to a straight border-to-border line instead of poisoning e.pts
591
+ // (downstream edgeD/updateEdge/metrics would throw and abort the round).
592
+ var pts = edges[i].pts;
593
+ for (var k = 0; k < pts.length; k++) {
594
+ if (!isFinite(pts[k][0]) || !isFinite(pts[k][1])) { forcedStraight(i); return; }
595
+ }
596
+ }
597
+
598
+ // ── Bend handles (vg polylines / user-anchored edges) ──
599
+ var pinned = {}; // edgeIdx -> true (user dragged a handle = waypoints anchored)
600
+ // v0.7.7 per-edge style cycle: edgeIdx -> 'auto' | 'straight' | 'arc' | 'free'
601
+ // (auto = engine default routing; free = exact polyline on user fold points)
602
+ var styles = {};
603
+ var freeWps = {}; // edgeIdx -> [[x,y], ...] user fold points (free mode, max MAX_FREE_PTS)
604
+ // Segment-drag re-review (2026-11-08): an interior segment drag inserts TWO
605
+ // jog bendpoints per gesture and a realistic orthogonal detour (e.g. jogging
606
+ // around two cards on 8777 P5) needs 4-6 points; drawio/bpmn-io set no hard
607
+ // cap at all. 8 = headroom for two segment-drags on one edge while keeping
608
+ // metrics/metrics sampling and export/applyLayout truncation sane.
609
+ var MAX_FREE_PTS = 8; // per-edge user fold-point cap (creation-time only)
610
+ var NS = 'http://www.w3.org/2000/svg';
611
+ var styleEl = document.createElement('style');
612
+ styleEl.textContent = '#stdraw-handles circle.stdraw-wp{fill:#fff;stroke:#475569;' +
613
+ 'stroke-width:1.4;cursor:move;pointer-events:all}' +
614
+ '#stdraw-handles circle.stdraw-wp:hover{stroke:#dc2626;fill:#fef2f2}' +
615
+ '#stdraw-handles circle.stdraw-wp.sel{stroke:#dc2626;fill:#fef2f2}' +
616
+ // Floating bendpoints (bpmn-io .djs-bendpoint.floating): preview-only
617
+ // handle; creating it never touches user state, so it is safe to render
618
+ // on hover and discard on leave.
619
+ // NOTE (t_b68f21be): the floating preview mounts in the #stdraw-float
620
+ // layer (floatLayer below), NOT #stdraw-handles -- rebuildHandles clears
621
+ // #stdraw-handles on every gesture and would kill the preview. Prefixing
622
+ // these rules with a layer id silently detached them (handle rendered as
623
+ // a black opaque dot on 8777). Keep the selector layer-agnostic: the
624
+ // probe pins computed opacity so a re-layering regression turns red.
625
+ 'circle.stdraw-wp.floating{fill:#94a3b8;stroke:#475569;' +
626
+ 'stroke-width:1.4;cursor:move;pointer-events:all;opacity:0.45}' +
627
+ 'circle.stdraw-wp.floating:hover{opacity:0.85}' +
628
+ // Edge hit paths: the stroke MUST be painted (alpha > 0) for Chromium's
629
+ // pointer-events:stroke to hit-test it -- stroke:transparent is treated as
630
+ // unpainted and the hit path never resolves under the cursor (2026-11-08
631
+ // 8777 regression: all edge interactions dead while node drags worked).
632
+ // Same caliber as diagram-js InteractionEvents createHitStyle (painted
633
+ // near-transparent hit stroke, production-proven). Do NOT switch to
634
+ // pointer-events:all: with fill:none the implicit-closed fill polygon of
635
+ // an open polyline would hit-test its interior and steal empty-canvas
636
+ // clicks. rgba(...,0.01) is visually imperceptible (+<=2.5/255 composite).
637
+ '#stdraw-svg path.stdraw-hit{fill:none;stroke:rgba(255,255,255,0.01);stroke-width:12;cursor:pointer;pointer-events:stroke}' +
638
+ '#stdraw-svg [data-edge].hl > path:not(.stdraw-hit){stroke-width:2.4}' +
639
+ // v0.7.11 per-edge degradation marker (fail-visible): a single edge whose
640
+ // re-route threw keeps its last geometry but is drawn red + dashed so the
641
+ // failure is visible instead of silently floating.
642
+ '#stdraw-svg g[data-edge].stdraw-degraded > path:not(.stdraw-hit){stroke:#dc2626;stroke-dasharray:6 4}' +
643
+ '#stdraw-svg path.stdraw-hit.stdraw-degraded{stroke:rgba(220,38,38,0.01)}' +
644
+ // t_2c2496ca: standard-anchor dots (hover preview). pointer-events:none —
645
+ // the dots never intercept the cursor (click-to-assign is resolved by the
646
+ // svg pointerdown handler against the LIVE anchor coordinates instead, so
647
+ // hovering a dot keeps the node hover alive and the dots cannot flicker).
648
+ '#stdraw-anchors{pointer-events:none}' +
649
+ '#stdraw-anchors circle.stdraw-anchor{fill:#fff;stroke:#0284c7;stroke-width:1.1;opacity:0.9}' +
650
+ '#stdraw-anchors circle.stdraw-anchor.pinned{fill:#0284c7;stroke:#075985}' +
651
+ '#stdraw-anchors circle.stdraw-anchor.near{fill:#fbbf24;stroke:#d97706}' +
652
+ // t_2c2496ca: per-edge ENDPOINT drag handles. The 9px invisible hit circle
653
+ // sits in the TOPMOST layer (#stdraw-eps appended last), so an endpoint
654
+ // press always resolves to the endpoint drag BEFORE the 12px segment hit
655
+ // band (drag-line = whole-segment translation) can claim it — endpoint
656
+ // hit priority is structural (DOM order), pinned by the probe.
657
+ // t_51392037: the VISIBLE handle dot is hidden by default (jacky: dots in
658
+ // the rendered graph are visual noise); it fades in only while its edge is
659
+ // hovered or its endpoint is being dragged (.vis class, see setEpVis).
660
+ '#stdraw-eps circle.stdraw-ep{fill:#fff;stroke:#0284c7;stroke-width:1.6;cursor:move;pointer-events:all;opacity:0;transition:opacity .12s}' +
661
+ '#stdraw-eps circle.stdraw-ep.vis{opacity:1}' +
662
+ '#stdraw-eps circle.stdraw-eps-hit{fill:rgba(255,255,255,0);stroke:none;cursor:move;pointer-events:all}' +
663
+ '.stdraw-tip{position:absolute;z-index:9;background:#1f2937;color:#fff;' +
664
+ 'font:12px/1.4 sans-serif;padding:3px 8px;border-radius:4px;pointer-events:none}';
665
+ document.head.appendChild(styleEl);
666
+ var handleLayer = document.createElementNS(NS, 'g');
667
+ handleLayer.id = 'stdraw-handles';
668
+ svg.appendChild(handleLayer);
669
+ function edgeHasHandles(i) {
670
+ var e = edges[i];
671
+ if ((styles[i] || 'auto') === 'free') return !!(freeWps[i] && freeWps[i].length);
672
+ if (e.arc) return false;
673
+ if (pinned[i]) return e.pts.length >= 3;
674
+ return !!(e.corners && e.corners.length >= 3);
675
+ }
676
+ function handlePts(i) { // handle positions: free = user fold points; unanchored = current corners; anchored = midpoints of pts
677
+ if ((styles[i] || 'auto') === 'free') return freeWps[i] || [];
678
+ return pinned[i] ? edges[i].pts : edges[i].corners;
679
+ }
680
+ function rebuildHandles() {
681
+ while (handleLayer.firstChild) handleLayer.removeChild(handleLayer.firstChild);
682
+ edges.forEach(function (e, i) {
683
+ if (!edgeHasHandles(i)) return;
684
+ var pts = handlePts(i);
685
+ if (!pts || pts.length < 1) return;
686
+ if ((styles[i] || 'auto') === 'free') {
687
+ // free-mode handles: one per user fold point (data-wp = fold index;
688
+ // pts index = fold index + 1)
689
+ for (var kf = 0; kf < pts.length; kf++) {
690
+ var cf = document.createElementNS(NS, 'circle');
691
+ cf.setAttribute('cx', pts[kf][0].toFixed(1));
692
+ cf.setAttribute('cy', pts[kf][1].toFixed(1));
693
+ cf.setAttribute('r', '4');
694
+ cf.setAttribute('class', 'stdraw-wp');
695
+ cf.setAttribute('data-edge', i);
696
+ cf.setAttribute('data-wp', kf + 1);
697
+ cf.setAttribute('data-free', '1');
698
+ handleLayer.appendChild(cf);
699
+ }
700
+ return;
701
+ }
702
+ if (pts.length < 3) return;
703
+ for (var k = 1; k < pts.length - 1; k++) {
704
+ var c = document.createElementNS(NS, 'circle');
705
+ c.setAttribute('cx', pts[k][0].toFixed(1));
706
+ c.setAttribute('cy', pts[k][1].toFixed(1));
707
+ c.setAttribute('r', '4');
708
+ c.setAttribute('class', 'stdraw-wp');
709
+ c.setAttribute('data-edge', i);
710
+ c.setAttribute('data-wp', k);
711
+ handleLayer.appendChild(c);
712
+ }
713
+ });
714
+ }
715
+ function pinEdge(i) {
716
+ if (pinned[i]) return;
717
+ var e = edges[i];
718
+ // User-anchored waypoints = midpoints of the current corners (endpoint
719
+ // anchors are recomputed on the box border by CLI/re-anchoring, and are not
720
+ // exported as waypoints — avoiding redundant bends coinciding with anchors)
721
+ var cs = (e.corners && e.corners.length >= 3) ? e.corners : e.pts;
722
+ var via = cs.slice(1, -1).map(cp);
723
+ if (via.length < 1) return;
724
+ var A = nodes[e.a], B = nodes[e.b];
725
+ // Same caliber as CLI --from-layout: anchor = box border facing the first/last waypoint
726
+ var pts = [endPt(i, 0, anchor(A, via[0]))];
727
+ pts = pts.concat(via);
728
+ pts.push(endPt(i, 1, anchor(B, via[via.length - 1])));
729
+ e.pts = pts;
730
+ e.corners = pts.map(cp);
731
+ pinned[i] = true;
732
+ }
733
+
734
+ function segCross(p1, p2, p3, p4) {
735
+ function or(a, b, c) {
736
+ var v = (b[0]-a[0])*(c[1]-a[1]) - (b[1]-a[1])*(c[0]-a[0]);
737
+ return Math.abs(v) < 1e-9 ? 0 : (v > 0 ? 1 : -1);
738
+ }
739
+ var d1 = or(p3,p4,p1), d2 = or(p3,p4,p2), d3 = or(p1,p2,p3), d4 = or(p1,p2,p4);
740
+ return d1 !== d2 && d3 !== d4 && d1 !== 0 && d3 !== 0;
741
+ }
742
+ function distRect(px, py, cx, cy, hw, hh) {
743
+ var dx = Math.max(Math.abs(px - cx) - hw, 0), dy = Math.max(Math.abs(py - cy) - hh, 0);
744
+ return Math.hypot(dx, dy);
745
+ }
746
+ function samplePts(pts, step) {
747
+ step = step || 4;
748
+ var out = [];
749
+ for (var i = 0; i < pts.length - 1; i++) {
750
+ var a = pts[i], b = pts[i + 1], d = Math.hypot(b[0] - a[0], b[1] - a[1]);
751
+ var n = Math.max(1, Math.floor(d / step));
752
+ for (var k = 0; k < n; k++) { var t = k / n; out.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]); }
753
+ }
754
+ out.push(pts[pts.length - 1].slice());
755
+ return out;
756
+ }
757
+ function metrics() {
758
+ var ids = Object.keys(nodes), crossings = 0;
759
+ for (var i = 0; i < edges.length; i++) {
760
+ for (var j = i + 1; j < edges.length; j++) {
761
+ var e1 = edges[i], e2 = edges[j];
762
+ if (e1.a === e2.a || e1.a === e2.b || e1.b === e2.a || e1.b === e2.b) continue;
763
+ var hit = false;
764
+ for (var s1 = 0; s1 < e1.pts.length - 1 && !hit; s1++) {
765
+ for (var s2 = 0; s2 < e2.pts.length - 1; s2++) {
766
+ if (segCross(e1.pts[s1], e1.pts[s1 + 1], e2.pts[s2], e2.pts[s2 + 1])) { hit = true; break; }
767
+ }
768
+ }
769
+ if (hit) crossings++;
770
+ }
771
+ }
772
+ var press = 0;
773
+ edges.forEach(function (e) {
774
+ var pts = samplePts(e.pts);
775
+ ids.forEach(function (v) {
776
+ if (v === e.a || v === e.b) return;
777
+ var n = nodes[v];
778
+ for (var k = 0; k < pts.length; k++) {
779
+ if (distRect(pts[k][0], pts[k][1], n.x, n.y, n.w / 2 + 1, nHH(n) + 1) <= 0) { press++; break; }
780
+ }
781
+ });
782
+ });
783
+ var minGap = Infinity, nn = [];
784
+ for (var a = 0; a < ids.length; a++) {
785
+ var na = nodes[ids[a]], nna = Infinity;
786
+ for (var b = 0; b < ids.length; b++) {
787
+ if (a === b) continue;
788
+ var nb = nodes[ids[b]];
789
+ var dgx = Math.abs(na.x - nb.x) - (na.w + nb.w) / 2;
790
+ var dgy = Math.abs(na.y - nb.y) - nHH(na) - nHH(nb);
791
+ var gg = Math.hypot(Math.max(dgx, 0), Math.max(dgy, 0));
792
+ if (gg < minGap) minGap = gg;
793
+ var dc = Math.hypot(na.x - nb.x, na.y - nb.y);
794
+ if (dc < nna) nna = dc;
795
+ }
796
+ nn.push(nna);
797
+ }
798
+ var mean = nn.reduce(function (s, x) { return s + x; }, 0) / nn.length;
799
+ var cv = Math.sqrt(nn.reduce(function (s, x) { return s + (x - mean) * (x - mean); }, 0) / nn.length) / mean;
800
+ var ys = ids.map(function (v) { return nodes[v].y; }).sort(function (p, q) { return p - q; });
801
+ var aligned = 0, i2 = 0;
802
+ while (i2 < ys.length) {
803
+ var j2 = i2;
804
+ while (j2 + 1 < ys.length && ys[j2 + 1] - ys[j2] <= 4) j2++;
805
+ if (j2 > i2) aligned += j2 - i2 + 1;
806
+ i2 = j2 + 1;
807
+ }
808
+ return {crossings: crossings, press: press, minGap: Math.round(minGap * 10) / 10,
809
+ cv: Math.round(cv * 1000) / 1000, row: Math.round(aligned / ids.length * 1000) / 1000};
810
+ }
811
+ function refreshMetrics() {
812
+ var el = document.getElementById('stdraw-metrics');
813
+ if (!el) return;
814
+ var m = metrics();
815
+ el.textContent = 'crossings ' + m.crossings + ' · presses(1px) ' + m.press +
816
+ ' · card gap ' + m.minGap + 'px · CV ' + m.cv + ' · row align ' + m.row;
817
+ // honest hard-gate coloring (no leniency): red when a gate clearly fails
818
+ el.style.color = (m.press > 0 || m.crossings > 2 || m.minGap < 16) ? '#dc2626' : '';
819
+ }
820
+ // ── v0.7.9 font-environment robustness: post-render DOM geometry re-anchor ──
821
+ // The planned card geometry is produced by the engine-side text metrics layer
822
+ // (PIL measurement at generation time) and cannot see browser font settings.
823
+ // When the browser inflates the DOM text (minimum font size / user font
824
+ // scale), the rendered label overflows the planned rect while the edges stay
825
+ // anchored to the planned border -> floating arrows (jacky's Edge outage,
826
+ // t_30847af7). Here the ACTUAL rendered text is measured per card
827
+ // (getComputedTextLength + getBBox), the rect is grown (never shrunk) to fit
828
+ // with the same conservative padding discipline as textmetrics.py
829
+ // (>=12px per side + 6% stretch margin), the node geometry every router /
830
+ // metrics path consumes is updated, and all edges re-anchor + re-route on the
831
+ // new borders at 0px. Determinism: when the measured geometry fits inside the
832
+ // plan the function is a strict no-op (the initial render stays pixel-
833
+ // identical to the static rendering; the byte-frozen delivery contract is
834
+ // untouched). Deterministic given the rendered geometry: fixed traversal
835
+ // order, no RNG, no timestamps.
836
+ function syncDomGeometry() {
837
+ var touched = false;
838
+ svg.querySelectorAll('[data-node]').forEach(function (g) {
839
+ // v0.7.11 per-node isolation: a measurement failure on one card must not
840
+ // abort the growth pass for the remaining cards.
841
+ try {
842
+ var id = g.getAttribute('data-node'), n = nodes[id];
843
+ if (!n || !n.g) return;
844
+ var rect = n.g.querySelector('rect');
845
+ if (!rect) return;
846
+ var mw = 0, top = Infinity, bot = -Infinity, texts = g.querySelectorAll('text');
847
+ texts.forEach(function (t) {
848
+ var w = 0;
849
+ try { w = t.getComputedTextLength(); } catch (e1) {}
850
+ try {
851
+ var bb = t.getBBox();
852
+ if (bb.width > w) w = bb.width;
853
+ if (bb.y < top) top = bb.y;
854
+ if (bb.y + bb.height > bot) bot = bb.y + bb.height;
855
+ } catch (e2) {}
856
+ if (w > mw) mw = w;
857
+ });
858
+ if (!(mw > 0)) return;
859
+ // Grow-only + overflow-driven: the planned geometry (engine-side PIL text
860
+ // metrics + conservative padding) is authoritative when the rendered text
861
+ // already fits inside it (any blanket re-padding here would drift the
862
+ // deterministic initial render -- measured, not assumed).
863
+ var needW = n.w, needH = n.h;
864
+ var wNeed = Math.ceil(mw * 1.06 + 24); // textmetrics.py: >=12px/side + 6% stretch
865
+ if (wNeed > needW + 0.5) needW = wNeed;
866
+ if (top < bot && (top < n.by - n.h / 2 - 0.5 || bot > n.by + n.h / 2 + 0.5)) {
867
+ // vertical text escape: fit the union with the plan's padding discipline
868
+ var hNeed = Math.ceil((bot - top) * 1.06 + 14);
869
+ if (hNeed > needH) needH = hNeed;
870
+ }
871
+ if (needW <= n.w + 0.5 && needH <= n.h + 0.5) return; // fits: strict no-op
872
+ var grewH = needH > n.h + 0.5;
873
+ n.w = needW; n.h = needH;
874
+ rect.setAttribute('x', (n.bx - needW / 2).toFixed(1));
875
+ rect.setAttribute('y', (n.by - needH / 2).toFixed(1));
876
+ rect.setAttribute('width', String(needW));
877
+ rect.setAttribute('height', String(needH));
878
+ // keep the secondary (value) line pinned to the new bottom inset when the
879
+ // card grew taller (the label stays centered on the card middle)
880
+ if (grewH && texts.length >= 2) {
881
+ try { texts[texts.length - 1].setAttribute('y', (n.by + needH / 2 - 7).toFixed(1)); } catch (e3) {}
882
+ }
883
+ touched = true;
884
+ } catch (errN) {
885
+ try { console.warn('[stdraw] node #' + g.getAttribute('data-node') +
886
+ ' geometry sync failed: ' + (errN && errN.message ? errN.message : errN)); } catch (e4) {}
887
+ }
888
+ });
889
+ if (touched) {
890
+ // v0.7.11 per-edge isolation: a single edge throwing must never abort the
891
+ // re-anchor round (stale anchors on all subsequent edges = the mixed
892
+ // floating-arrow state jacky saw in Edge). Fail-visible per edge instead.
893
+ edges.forEach(function (e, i) { safeRerouteUpdate(i, 'sync re-anchor'); });
894
+ try { rebuildHandles(); } catch (errH) {
895
+ try { console.warn('[stdraw] rebuildHandles failed: ' +
896
+ (errH && errH.message ? errH.message : errH)); } catch (e5) {}
897
+ }
898
+ try { refreshMetrics(); } catch (errM) {
899
+ try { console.warn('[stdraw] refreshMetrics failed: ' +
900
+ (errM && errM.message ? errM.message : errM)); } catch (e6) {}
901
+ }
902
+ }
903
+ return touched;
904
+ }
905
+ function polyPointAt(pts, t) {
906
+ var segs = [], total = 0, i;
907
+ for (i = 0; i < pts.length - 1; i++) {
908
+ var d = Math.hypot(pts[i+1][0]-pts[i][0], pts[i+1][1]-pts[i][1]);
909
+ segs.push(d); total += d;
910
+ }
911
+ var target = total * Math.max(0, Math.min(1, t)), acc = 0;
912
+ for (i = 0; i < segs.length; i++) {
913
+ if (acc + segs[i] >= target && segs[i] > 0) {
914
+ var f = (target - acc) / segs[i];
915
+ var p = [pts[i][0] + (pts[i+1][0]-pts[i][0]) * f, pts[i][1] + (pts[i+1][1]-pts[i][1]) * f];
916
+ return {p: p, tan: [(pts[i+1][0]-pts[i][0]) / segs[i], (pts[i+1][1]-pts[i][1]) / segs[i]]};
917
+ }
918
+ acc += segs[i];
919
+ }
920
+ return {p: pts[pts.length - 1].slice(), tan: [0, 1]};
921
+ }
922
+ function ptPolyDist(px, py, pts) {
923
+ var best = Infinity;
924
+ for (var i = 0; i < pts.length - 1; i++) {
925
+ var a = pts[i], b = pts[i + 1];
926
+ var vx = b[0]-a[0], vy = b[1]-a[1], L2 = vx*vx + vy*vy;
927
+ var t = L2 < 1e-12 ? 0 : Math.max(0, Math.min(1, ((px-a[0])*vx + (py-a[1])*vy) / L2));
928
+ best = Math.min(best, Math.hypot(px - a[0] - t*vx, py - a[1] - t*vy));
929
+ }
930
+ return best;
931
+ }
932
+ function refreshMarkers() {
933
+ // Badges removed (edge coloring carries R/B polarity) — empty implementation keeps the interface.
934
+ }
935
+ var drag = null, rafPending = false;
936
+ // ── v0.7.7 per-edge style cycle: auto -> straight -> arc -> free -> auto ──
937
+ var STYLE_ORDER = ['auto', 'straight', 'arc', 'free'];
938
+ function setStyle(i, st) {
939
+ if (st === 'auto') {
940
+ delete styles[i];
941
+ delete freeWps[i];
942
+ delete pinned[i]; // back to the engine default state (no pins)
943
+ } else {
944
+ styles[i] = st;
945
+ }
946
+ if (st === 'free' && !freeWps[i]) freeWps[i] = [];
947
+ safeRerouteUpdate(i, 'style change');
948
+ rebuildHandles();
949
+ refreshMetrics();
950
+ fireLayoutChanged();
951
+ }
952
+ function cycleStyle(i) {
953
+ var cur = styles[i] || 'auto';
954
+ setStyle(i, STYLE_ORDER[(STYLE_ORDER.indexOf(cur) + 1) % STYLE_ORDER.length]);
955
+ }
956
+ // manual double-click detection on edge pointerdowns: a fold-point insert
957
+ // puts a handle circle exactly under the cursor, so the second click of a
958
+ // browser dblclick lands on a different target and the native dblclick
959
+ // never fires. Track edge pointerdown timestamps instead (deterministic).
960
+ var lastEdgeDown = {edge: -1, t: 0};
961
+ function edgeDblClickCheck(i) {
962
+ var now = (window.performance && performance.now) ? performance.now() : Date.now();
963
+ if (lastEdgeDown.edge === i && now - lastEdgeDown.t < 450) {
964
+ lastEdgeDown = {edge: -1, t: 0};
965
+ cycleStyle(i);
966
+ return true;
967
+ }
968
+ lastEdgeDown = {edge: i, t: now};
969
+ return false;
970
+ }
971
+ // ── v0.7.7 free-mode fold points: click inserts, max 3 per edge ──
972
+ function projectOnSeg(p, a, b) {
973
+ var vx = b[0] - a[0], vy = b[1] - a[1], L2 = vx * vx + vy * vy;
974
+ var t = L2 < 1e-12 ? 0 : Math.max(0, Math.min(1, ((p[0] - a[0]) * vx + (p[1] - a[1]) * vy) / L2));
975
+ return [a[0] + t * vx, a[1] + t * vy];
976
+ }
977
+ var tipEl = null, tipTimer = null;
978
+ function showTip(text, ev) {
979
+ if (tipEl) tipEl.remove();
980
+ tipEl = document.createElement('div');
981
+ tipEl.className = 'stdraw-tip';
982
+ tipEl.textContent = text;
983
+ document.body.appendChild(tipEl);
984
+ var x = 40, y = 40;
985
+ if (ev && typeof ev.clientX === 'number') { x = ev.clientX + 12; y = ev.clientY - 24; }
986
+ tipEl.style.left = x + 'px';
987
+ tipEl.style.top = y + 'px';
988
+ if (tipTimer) clearTimeout(tipTimer);
989
+ tipTimer = setTimeout(function () { if (tipEl) tipEl.remove(); tipEl = null; }, 1500);
990
+ }
991
+ function selectNearestFreeHandle(i, p) {
992
+ var bestH = null, bestD = null;
993
+ handleLayer.querySelectorAll('.stdraw-wp[data-free="1"]').forEach(function (c) {
994
+ if (+c.getAttribute('data-edge') !== i) { c.classList.remove('sel'); return; }
995
+ var d = Math.hypot(+c.getAttribute('cx') - p[0], +c.getAttribute('cy') - p[1]);
996
+ if (bestD === null || d < bestD) { bestD = d; bestH = c; }
997
+ });
998
+ if (bestH) bestH.classList.add('sel');
999
+ }
1000
+ function insertWaypointAt(i, p) {
1001
+ // Shared fold-point creation (free click-insert + floating-bendpoint
1002
+ // conversion): projected onto the nearest segment, rounded to 0.5.
1003
+ // Returns the inserted freeWps index, or null at the MAX_FREE_PTS cap.
1004
+ var wps = freeWps[i] = freeWps[i] || [];
1005
+ if (wps.length >= MAX_FREE_PTS) return null;
1006
+ var pts = edges[i].pts;
1007
+ var best = null;
1008
+ for (var s = 0; s < pts.length - 1; s++) {
1009
+ var q = projectOnSeg(p, pts[s], pts[s + 1]);
1010
+ var d = Math.hypot(q[0] - p[0], q[1] - p[1]);
1011
+ if (best === null || d < best[0]) best = [d, s, q];
1012
+ }
1013
+ var wi = Math.min(best[1], wps.length);
1014
+ wps.splice(wi, 0, [Math.round(best[2][0] * 2) / 2, Math.round(best[2][1] * 2) / 2]);
1015
+ return wi;
1016
+ }
1017
+ function insertFreePoint(i, p, ev) {
1018
+ var wi = insertWaypointAt(i, p);
1019
+ if (wi === null) {
1020
+ selectNearestFreeHandle(i, p);
1021
+ showTip('max ' + MAX_FREE_PTS + ' points per edge', ev);
1022
+ return;
1023
+ }
1024
+ reanchorFree(i);
1025
+ updateEdge(i);
1026
+ rebuildHandles();
1027
+ refreshMetrics();
1028
+ fireLayoutChanged();
1029
+ }
1030
+ // ── segment drag + floating bendpoints (drawio/bpmn-io caliber) ──
1031
+ var MERGE_TOL = 6; // svg-unit merge tolerance (fold point dropped back onto the line)
1032
+ function segDraggable(i) {
1033
+ // Segment drag applies only to edges with explicit user geometry: free
1034
+ // mode with fold points, or user-anchored pinned polylines. Straight
1035
+ // lines and auto-layout edges keep live re-routing; arcs have no
1036
+ // polyline segments.
1037
+ if (edges[i].arc) return false;
1038
+ if ((styles[i] || 'auto') === 'free') return !!(freeWps[i] && freeWps[i].length);
1039
+ return !!pinned[i];
1040
+ }
1041
+ function segAt(pts, p) {
1042
+ // svgPoint() returns an SVGPoint object (.x/.y), array callers pass
1043
+ // [x, y] — normalize to [x, y] here so every caller is safe
1044
+ if (p && typeof p.x === 'number') p = [p.x, p.y];
1045
+ var best = null;
1046
+ for (var s = 0; s < pts.length - 1; s++) {
1047
+ var q = projectOnSeg(p, pts[s], pts[s + 1]);
1048
+ var d = Math.hypot(q[0] - p[0], q[1] - p[1]);
1049
+ if (best === null || d < best[0]) best = [d, s, q];
1050
+ }
1051
+ return best === null ? null : {k: best[1], proj: best[2], dist: best[0]};
1052
+ }
1053
+ function orientCursor(pts, k) {
1054
+ // diagram-js segment-dragger convention: horizontal segments move
1055
+ // vertically (ns-resize), vertical segments move horizontally
1056
+ // (ew-resize).
1057
+ var a = pts[k], b = pts[k + 1];
1058
+ return Math.abs(b[1] - a[1]) <= Math.abs(b[0] - a[0]) ? 'ns-resize' : 'ew-resize';
1059
+ }
1060
+ function segDragPlan(i, k) {
1061
+ // drawio segment-move semantics: the dragged segment translates; jog
1062
+ // bendpoints are created at its pre-drag endpoints. Segments adjacent to
1063
+ // an edge anchor insert ONE bendpoint (the anchor side re-anchors on the
1064
+ // box border instead — same caliber as CLI --from-layout), interior
1065
+ // segments insert TWO. head/tail = user waypoints that stay put;
1066
+ // mv = the moving points (copies of the dragged segment's waypoint
1067
+ // endpoints); added = bendpoints created (mv.length).
1068
+ var e = edges[i], pts = e.pts;
1069
+ var isFree = (styles[i] || 'auto') === 'free';
1070
+ var W = isFree ? (freeWps[i] || []) : pts.slice(1, -1);
1071
+ var head, tail, mv;
1072
+ if (k === 0) { head = []; tail = W.map(cp); mv = [cp(pts[1])]; }
1073
+ else if (k === pts.length - 2) { head = W.map(cp); tail = []; mv = [cp(pts[k])]; }
1074
+ else {
1075
+ head = W.slice(0, k).map(cp); tail = W.slice(k).map(cp);
1076
+ mv = [cp(pts[k]), cp(pts[k + 1])];
1077
+ }
1078
+ return {head: head, tail: tail, mv: mv, added: mv.length};
1079
+ }
1080
+ function applyUserWps(i, wps) {
1081
+ // Write the effective user waypoints back: free mode via freeWps +
1082
+ // reanchorFree; pinned via the same anchor-first/last caliber.
1083
+ var e = edges[i];
1084
+ if ((styles[i] || 'auto') === 'free') { freeWps[i] = wps; reanchorFree(i); return; }
1085
+ if (!wps.length) {
1086
+ var A = nodes[e.a], B = nodes[e.b];
1087
+ e.pts = [endPt(i, 0, anchor(A, [B.x, B.y])), endPt(i, 1, anchor(B, [A.x, A.y]))];
1088
+ return;
1089
+ }
1090
+ e.pts = [endPt(i, 0, anchor(nodes[e.a], wps[0]))].concat(wps.map(cp))
1091
+ .concat([endPt(i, 1, anchor(nodes[e.b], wps[wps.length - 1]))]);
1092
+ }
1093
+ function effectiveWps(i) {
1094
+ if ((styles[i] || 'auto') === 'free') return (freeWps[i] || []).map(cp);
1095
+ return pinned[i] ? edges[i].pts.slice(1, -1).map(cp) : [];
1096
+ }
1097
+ function adoptFreeAndInsert(i, pos) {
1098
+ // Floating-bendpoint drag-away: adopt the edge's current geometry as
1099
+ // user fold points (free mode) and insert exactly one fold point at the
1100
+ // grab position. Returns the inserted freeWps index, or false at the cap
1101
+ // (in which case NOTHING is written — preview stays dirty-free).
1102
+ var e = edges[i], st = styles[i] || 'auto';
1103
+ var cur = (st === 'free') ? (freeWps[i] || [])
1104
+ : (pinned[i] ? e.pts.slice(1, -1)
1105
+ : (e.corners && e.corners.length >= 3 ? e.corners.slice(1, -1) : []));
1106
+ if (cur.length + 1 > MAX_FREE_PTS) return false;
1107
+ if (st !== 'free') {
1108
+ styles[i] = 'free';
1109
+ delete pinned[i];
1110
+ freeWps[i] = cur.map(function (q) {
1111
+ return [Math.round(q[0] * 2) / 2, Math.round(q[1] * 2) / 2];
1112
+ });
1113
+ }
1114
+ var wi = insertWaypointAt(i, pos);
1115
+ if (wi === null) return false;
1116
+ reanchorFree(i);
1117
+ return wi;
1118
+ }
1119
+ function removeWaypoint(i, idx) {
1120
+ // Merge-back deletion: drop the fold point, then rebuild. Pinned edges
1121
+ // whose interior empties fall back to auto (no empty-pin residue).
1122
+ var e = edges[i];
1123
+ if ((styles[i] || 'auto') === 'free') {
1124
+ if (freeWps[i]) freeWps[i].splice(idx - 1, 1);
1125
+ reanchorFree(i);
1126
+ } else {
1127
+ e.pts.splice(idx, 1);
1128
+ if (e.pts.length <= 2) { delete pinned[i]; reroute(i); }
1129
+ }
1130
+ updateEdge(i);
1131
+ rebuildHandles();
1132
+ refreshMetrics();
1133
+ }
1134
+ // Floating bendpoint handle: a single preview element in its own layer
1135
+ // (rebuildHandles clears #stdraw-handles; the floating preview must
1136
+ // survive every rebuild). Hover-only — never written into user state.
1137
+ var floatLayer = document.createElementNS(NS, 'g');
1138
+ floatLayer.id = 'stdraw-float';
1139
+ svg.appendChild(floatLayer);
1140
+ var floatC = null;
1141
+ // Last cursor client position seen over an edge (drives the deterministic
1142
+ // floating-preview visibility check below)
1143
+ var lastFloatPos = null, lastCursor = null;
1144
+ function edgeFloatable(i) { return !edges[i].arc; }
1145
+ function floatHandle() {
1146
+ if (floatC) return floatC;
1147
+ floatC = document.createElementNS(NS, 'circle');
1148
+ floatC.setAttribute('r', '4');
1149
+ floatC.setAttribute('class', 'stdraw-wp floating');
1150
+ floatC.setAttribute('data-float', '1');
1151
+ floatC.style.display = 'none';
1152
+ floatLayer.appendChild(floatC);
1153
+ return floatC;
1154
+ }
1155
+ // Deterministic floating-preview lifecycle: boundary events (mouseenter/
1156
+ // mouseleave between the line and the handle) fire in a timing-dependent
1157
+ // order when the handle appears directly under a stationary cursor, so
1158
+ // visibility is decided by polling elementFromPoint at the last cursor
1159
+ // position — over the edge or the handle keeps the preview, anywhere else
1160
+ // hides it. Pure DOM geometry, no timers-on-events races.
1161
+ window.addEventListener('mousemove', function (ev) { lastCursor = [ev.clientX, ev.clientY]; });
1162
+ setInterval(function () {
1163
+ if (!floatC || floatC.style.display === 'none' || !lastCursor) return;
1164
+ // Probe the CURRENT cursor position (window-tracked; the parked handle
1165
+ // position would self-lock). Exclude the handle itself from the probe
1166
+ // via pointer-events:none — no visual flicker.
1167
+ var pe = floatC.style.pointerEvents;
1168
+ floatC.style.pointerEvents = 'none';
1169
+ var el = document.elementFromPoint(lastCursor[0], lastCursor[1]);
1170
+ floatC.style.pointerEvents = pe;
1171
+ if (el && el.closest && el.closest('[data-edge]')) return;
1172
+ hideFloat();
1173
+ }, 120);
1174
+ function showFloat(i, p) {
1175
+ if (drag || !edgeFloatable(i)) { hideFloat(); return; }
1176
+ var sd = segAt(edges[i].pts, p);
1177
+ if (!sd) { hideFloat(); return; }
1178
+ // bpmn-io .djs-bendpoint.floating parks the handle at the MIDDLE of the
1179
+ // hovered segment, NOT under the cursor: a cursor-following handle would
1180
+ // intercept every pointerdown on the line and make segment drag
1181
+ // unreachable. Mid-segment parking splits the intents cleanly — press at
1182
+ // mid = create fold point (drag away), press elsewhere = segment drag.
1183
+ var a = edges[i].pts[sd.k], b2 = edges[i].pts[sd.k + 1];
1184
+ var c = floatHandle();
1185
+ c.setAttribute('cx', ((a[0] + b2[0]) / 2).toFixed(1));
1186
+ c.setAttribute('cy', ((a[1] + b2[1]) / 2).toFixed(1));
1187
+ c.setAttribute('data-edge', i);
1188
+ c.style.display = '';
1189
+ }
1190
+ function hideFloat() {
1191
+ if (floatC) floatC.style.display = 'none';
1192
+ }
1193
+ // ══ t_2c2496ca: standard-anchor overlay + per-edge endpoint drag ══
1194
+ // Anchor dots: hover a box -> its 16 standard anchors appear (toggleable via
1195
+ // #stdraw-anchors-toggle; dots layer is pointer-events:none, click-to-assign
1196
+ // is resolved against the LIVE anchor coordinates in onDown). Endpoint drag:
1197
+ // every edge renders two endpoint handles in the topmost #stdraw-eps layer;
1198
+ // release snaps to the nearest standard anchor of the endpoint's own box and
1199
+ // records the explicit designation in userAnchored (re-projection caliber:
1200
+ // node drags afterwards only re-project the endpoint onto the SAME anchor).
1201
+ var anchorLayer = document.createElementNS(NS, 'g');
1202
+ anchorLayer.id = 'stdraw-anchors';
1203
+ svg.appendChild(anchorLayer);
1204
+ var anchorToggle = false, hoverNode = null; // t_51392037: anchor dots OFF by default (jacky: dots are visual noise; hover + toolbar toggle still show them)
1205
+ var ANCHOR_CLICK_TOL = 10; // px: a press within this distance of a visible
1206
+ // standard anchor is an explicit assignment
1207
+ function setAnchorToggle(on) {
1208
+ anchorToggle = !!on;
1209
+ var b = document.getElementById('stdraw-anchors-toggle');
1210
+ if (b) b.textContent = 'Anchors: ' + (anchorToggle ? 'on' : 'off');
1211
+ renderAnchors();
1212
+ }
1213
+ function renderAnchors() {
1214
+ while (anchorLayer.firstChild) anchorLayer.removeChild(anchorLayer.firstChild);
1215
+ if (!anchorToggle || !hoverNode || !nodes[hoverNode]) return;
1216
+ var pinnedAids = {};
1217
+ edges.forEach(function (e, i) {
1218
+ var ua = userAnchored[i];
1219
+ if (!ua) return;
1220
+ if (ua[0] && e.a === hoverNode) pinnedAids[ua[0]] = true;
1221
+ if (ua[1] && e.b === hoverNode) pinnedAids[ua[1]] = true;
1222
+ });
1223
+ stdAnchors(nodes[hoverNode]).forEach(function (p) {
1224
+ var c = document.createElementNS(NS, 'circle');
1225
+ c.setAttribute('cx', p[1].toFixed(1));
1226
+ c.setAttribute('cy', p[2].toFixed(1));
1227
+ c.setAttribute('r', pinnedAids[p[0]] ? '4.5' : '3');
1228
+ c.setAttribute('class', 'stdraw-anchor' + (pinnedAids[p[0]] ? ' pinned' : ''));
1229
+ c.setAttribute('data-node', hoverNode);
1230
+ c.setAttribute('data-aid', p[0]);
1231
+ anchorLayer.appendChild(c);
1232
+ });
1233
+ }
1234
+ function markNearAnchor(v, x, y) {
1235
+ // live snap feedback while dragging an endpoint (works with the dots
1236
+ // hidden too: the ring is drawn unconditionally)
1237
+ renderAnchors();
1238
+ var aid = nearestAnchorId(v, x, y);
1239
+ if (!aid || !nodes[v]) return;
1240
+ stdAnchors(nodes[v]).forEach(function (p) {
1241
+ if (p[0] !== aid) return;
1242
+ var c = document.createElementNS(NS, 'circle');
1243
+ c.setAttribute('cx', p[1].toFixed(1));
1244
+ c.setAttribute('cy', p[2].toFixed(1));
1245
+ c.setAttribute('r', '6');
1246
+ c.setAttribute('class', 'stdraw-anchor near');
1247
+ c.setAttribute('data-node', v);
1248
+ c.setAttribute('data-aid', p[0]);
1249
+ anchorLayer.appendChild(c);
1250
+ });
1251
+ }
1252
+ var epsLayer = document.createElementNS(NS, 'g');
1253
+ epsLayer.id = 'stdraw-eps';
1254
+ svg.appendChild(epsLayer); // appended LAST: endpoint hit priority over the
1255
+ // 12px segment hit band is structural (DOM order)
1256
+ var epEls = {}; // edgeIdx -> {0: [hit, vis], 1: [hit, vis]}
1257
+ function ensureEpEls(i) {
1258
+ if (epEls[i]) return;
1259
+ var rec = {};
1260
+ [0, 1].forEach(function (end) {
1261
+ [true, false].forEach(function (isHit) {
1262
+ var c = document.createElementNS(NS, 'circle');
1263
+ c.setAttribute('r', isHit ? '9' : '3.5');
1264
+ c.setAttribute('class', isHit ? 'stdraw-eps-hit' : 'stdraw-ep');
1265
+ c.setAttribute('data-edge', i);
1266
+ c.setAttribute('data-end', end);
1267
+ epsLayer.appendChild(c);
1268
+ rec[end] = rec[end] || [];
1269
+ rec[end].push(c);
1270
+ });
1271
+ });
1272
+ epEls[i] = rec;
1273
+ }
1274
+ function updateEndpointHandles(i) {
1275
+ if (!edges[i]) return;
1276
+ ensureEpEls(i);
1277
+ var e = edges[i];
1278
+ if (e.pts.length < 2) return;
1279
+ [[0, e.pts[0]], [1, e.pts[e.pts.length - 1]]].forEach(function (pe) {
1280
+ epEls[i][pe[0]].forEach(function (c) {
1281
+ c.setAttribute('cx', pe[1][0].toFixed(1));
1282
+ c.setAttribute('cy', pe[1][1].toFixed(1));
1283
+ });
1284
+ });
1285
+ }
1286
+ function rebuildAllEndpointHandles() {
1287
+ edges.forEach(function (e, i) { updateEndpointHandles(i); });
1288
+ }
1289
+ edges.forEach(function (e, i) { ensureEpEls(i); });
1290
+ function arcEpPts(e, end, pt) {
1291
+ // endpoint moved on an arc: re-derive the sampled Bezier from the frozen
1292
+ // control state, translating ctrl in lockstep with the endpoint delta
1293
+ // (tangent-preserving; same caliber as arcFollow's mean translation)
1294
+ if (!(e.arc && e.ctrl0 && e.pa0 && e.pb0)) return null;
1295
+ var from = end === 0 ? e.pa0 : e.pb0;
1296
+ var other = end === 0 ? e.pb0 : e.pa0;
1297
+ var ctrl = [e.ctrl0[0] + (pt[0] - from[0]), e.ctrl0[1] + (pt[1] - from[1])];
1298
+ var pa = end === 0 ? pt : other;
1299
+ var pb = end === 0 ? other : pt;
1300
+ return quad(pa, ctrl, pb, 12);
1301
+ }
1302
+ function clampVB(x, y) {
1303
+ return [Math.max(4, Math.min(VB[2] - 4, x)), Math.max(4, Math.min(VB[3] - 4, y))];
1304
+ }
1305
+ function assignAnchor(v, aid, ax, ay) {
1306
+ // Explicit designation (anchor click): the clicked standard anchor goes to
1307
+ // the incident edge endpoint nearest to it (deterministic single winner;
1308
+ // only endpoints of box v are candidates).
1309
+ var best = null;
1310
+ edges.forEach(function (e, i) {
1311
+ if (e.a !== v && e.b !== v) return;
1312
+ var end = e.a === v ? 0 : 1;
1313
+ var p = e.pts[end === 0 ? 0 : e.pts.length - 1];
1314
+ var d = Math.hypot(p[0] - ax, p[1] - ay);
1315
+ if (best === null || d < best[0] - 1e-9) best = [d, i, end];
1316
+ });
1317
+ if (!best) { showTip('no edge attached to this box'); return; }
1318
+ var i2 = best[1], end2 = best[2];
1319
+ userAnchored[i2] = userAnchored[i2] || {};
1320
+ userAnchored[i2][end2] = aid;
1321
+ edges[i2].pts[end2 === 0 ? 0 : edges[i2].pts.length - 1] = [ax, ay];
1322
+ updateEdge(i2);
1323
+ updateEndpointHandles(i2);
1324
+ renderAnchors();
1325
+ refreshMetrics();
1326
+ fireLayoutChanged();
1327
+ }
1328
+ function svgPoint(ev) {
1329
+ var m = svg.getScreenCTM();
1330
+ if (!m) return null;
1331
+ var pt = svg.createSVGPoint();
1332
+ pt.x = ev.clientX; pt.y = ev.clientY;
1333
+ return pt.matrixTransform(m.inverse());
1334
+ }
1335
+ function onDown(ev) {
1336
+ var t = ev.target;
1337
+ // ── t_2c2496ca endpoint drag: the topmost #stdraw-eps hit circles claim
1338
+ // the press BEFORE the edge hit band (drag-line = segment translation) and
1339
+ // before the floating preview. Hit priority is structural + asserted by
1340
+ // the probe (drag state keys contain 'ep').
1341
+ var he = t && t.closest ? t.closest('.stdraw-eps-hit, .stdraw-ep') : null;
1342
+ if (he) {
1343
+ var eiE = +he.getAttribute('data-edge'), endE = +he.getAttribute('data-end');
1344
+ if (edges[eiE]) {
1345
+ var peE = svgPoint(ev);
1346
+ if (peE) {
1347
+ drag = {ep: true, edge: eiE, end: endE, start: [peE.x, peE.y], ev: ev};
1348
+ setEpVis(eiE, true); // t_51392037: show the dragged endpoint's handles
1349
+ try { he.setPointerCapture(ev.pointerId); } catch (eE0) {}
1350
+ ev.preventDefault();
1351
+ ev.stopPropagation();
1352
+ return;
1353
+ }
1354
+ }
1355
+ }
1356
+ // ── t_2c2496ca anchor click-to-assign: while a box's anchors are shown, a
1357
+ // press within ANCHOR_CLICK_TOL of one of its standard anchors explicitly
1358
+ // assigns that anchor (nearest incident endpoint wins, deterministic).
1359
+ if (hoverNode && anchorToggle && nodes[hoverNode]) {
1360
+ var pa2 = svgPoint(ev);
1361
+ if (pa2) {
1362
+ var apts = stdAnchors(nodes[hoverNode]), hitA = null;
1363
+ for (var ka = 0; ka < apts.length; ka++) {
1364
+ var da = Math.hypot(apts[ka][1] - pa2.x, apts[ka][2] - pa2.y);
1365
+ if (da <= ANCHOR_CLICK_TOL) { hitA = apts[ka]; break; }
1366
+ }
1367
+ if (hitA) {
1368
+ assignAnchor(hoverNode, hitA[0], hitA[1], hitA[2]);
1369
+ ev.preventDefault();
1370
+ ev.stopPropagation();
1371
+ return;
1372
+ }
1373
+ }
1374
+ }
1375
+ // Floating bendpoint: preview-only until dragged >=4px (conversion in
1376
+ // onMove); a plain click writes nothing.
1377
+ var hf = t && t.closest ? t.closest('.stdraw-wp[data-float="1"]') : null;
1378
+ if (hf) {
1379
+ var fEi = +hf.getAttribute('data-edge');
1380
+ if (!edges[fEi]) return;
1381
+ // The floating handle sits exactly under the cursor (it follows the
1382
+ // hover point), so a double-click whose second press lands on the edge
1383
+ // resolves here — it must participate in the manual dblclick detection
1384
+ // or the style cycle dies whenever the preview is visible (same
1385
+ // caliber as the free-handle branch below).
1386
+ if (edgeDblClickCheck(fEi)) {
1387
+ ev.preventDefault();
1388
+ return;
1389
+ }
1390
+ var fp = svgPoint(ev);
1391
+ if (!fp) return;
1392
+ if ((styles[fEi] || 'auto') === 'free') {
1393
+ // Legacy free-mode click-insert: the parked preview must NOT swallow
1394
+ // it. On free edges the press delegates to the standard insert flow
1395
+ // (click = insert, drag = segment move); floating adoption stays
1396
+ // exclusive to edges without explicit user geometry.
1397
+ drag = {insert: true, edge: fEi, start: [fp.x, fp.y], ev: ev};
1398
+ if (freeWps[fEi] && freeWps[fEi].length) {
1399
+ var sdF = segAt(edges[fEi].pts, fp);
1400
+ if (sdF) drag.segPrep = sdF.k;
1401
+ }
1402
+ hideFloat();
1403
+ ev.preventDefault();
1404
+ ev.stopPropagation();
1405
+ return;
1406
+ }
1407
+ var fsd = segAt(edges[fEi].pts, fp);
1408
+ drag = {floatDrag: true, edge: fEi, start: [fp.x, fp.y],
1409
+ proj: fsd ? fsd.proj : [fp.x, fp.y], ev: ev};
1410
+ try { hf.setPointerCapture(ev.pointerId); } catch (e0) {}
1411
+ hideFloat();
1412
+ ev.preventDefault();
1413
+ ev.stopPropagation();
1414
+ return;
1415
+ }
1416
+ var h = t && t.closest ? t.closest('.stdraw-wp') : null;
1417
+ if (h) {
1418
+ var ei = +h.getAttribute('data-edge'), wi = +h.getAttribute('data-wp');
1419
+ if (!edges[ei]) return;
1420
+ var isFree = h.getAttribute('data-free') === '1';
1421
+ if (isFree && edgeDblClickCheck(ei)) {
1422
+ ev.preventDefault();
1423
+ return;
1424
+ }
1425
+ if (!isFree) {
1426
+ pinEdge(ei);
1427
+ updateEdge(ei);
1428
+ }
1429
+ var p0 = svgPoint(ev);
1430
+ drag = {wp: true, edge: ei, idx: wi, free: isFree, start: [p0.x, p0.y],
1431
+ ox: edges[ei].pts[wi][0], oy: edges[ei].pts[wi][1], g: h};
1432
+ try { h.setPointerCapture(ev.pointerId); } catch (e) {}
1433
+ ev.preventDefault();
1434
+ ev.stopPropagation();
1435
+ return;
1436
+ }
1437
+ var g = t && t.closest ? t.closest('[data-node]') : null;
1438
+ if (g) {
1439
+ var id = g.getAttribute('data-node');
1440
+ var p = svgPoint(ev);
1441
+ if (!p || !(id in nodes)) return;
1442
+ drag = {id: id, g: g, start: [p.x, p.y], ox: nodes[id].x, oy: nodes[id].y};
1443
+ try { g.setPointerCapture(ev.pointerId); } catch (e) {}
1444
+ g.classList.add('dragging');
1445
+ ev.preventDefault();
1446
+ return;
1447
+ }
1448
+ // free-mode fold-point insertion + double-click style cycle: any
1449
+ // pointerdown resolving to an edge (hit path / visible line / free
1450
+ // handle) participates in the double-click detection
1451
+ var eg = t && t.closest ? t.closest('[data-edge]') : null;
1452
+ if (eg) {
1453
+ var ei2 = +eg.getAttribute('data-edge');
1454
+ if (edges[ei2]) {
1455
+ if (edgeDblClickCheck(ei2)) {
1456
+ ev.preventDefault();
1457
+ return;
1458
+ }
1459
+ if ((styles[ei2] || 'auto') === 'free') {
1460
+ var p1 = svgPoint(ev);
1461
+ if (p1) {
1462
+ drag = {insert: true, edge: ei2, start: [p1.x, p1.y], ev: ev};
1463
+ // explicit geometry: arm the segment-drag conversion (first
1464
+ // movement >=4px turns this click-insert into a segment drag)
1465
+ if (freeWps[ei2] && freeWps[ei2].length) {
1466
+ var sd0 = segAt(edges[ei2].pts, p1);
1467
+ if (sd0) drag.segPrep = sd0.k;
1468
+ }
1469
+ ev.preventDefault();
1470
+ }
1471
+ } else if (segDraggable(ei2)) {
1472
+ // pinned (user-anchored) polyline: pointerdown arms a segment drag;
1473
+ // a click without movement writes nothing
1474
+ var p2 = svgPoint(ev);
1475
+ if (p2) {
1476
+ var sd1 = segAt(edges[ei2].pts, p2);
1477
+ if (sd1) {
1478
+ drag = {seg: true, edge: ei2, plan: segDragPlan(ei2, sd1.k),
1479
+ start: [p2.x, p2.y], ev: ev};
1480
+ ev.preventDefault();
1481
+ }
1482
+ }
1483
+ }
1484
+ }
1485
+ }
1486
+ }
1487
+ function onMove(ev) {
1488
+ if (!drag) return;
1489
+ var p = svgPoint(ev);
1490
+ if (!p) return;
1491
+ if (drag.ep) {
1492
+ // t_2c2496ca endpoint drag: the endpoint follows the pointer (clamped to
1493
+ // the viewBox); the nearest standard anchor of the endpoint's own box is
1494
+ // highlighted live; the snap itself happens on release.
1495
+ var eE = edges[drag.edge];
1496
+ var ptE = clampVB(p.x, p.y);
1497
+ var arcPts = arcEpPts(eE, drag.end, ptE);
1498
+ if (arcPts) {
1499
+ eE.pts = arcPts;
1500
+ } else {
1501
+ eE.pts[drag.end === 0 ? 0 : eE.pts.length - 1] = ptE;
1502
+ }
1503
+ markNearAnchor(drag.end === 0 ? eE.a : eE.b, ptE[0], ptE[1]);
1504
+ updateEdge(drag.edge);
1505
+ return;
1506
+ }
1507
+ if (drag.floatDrag) {
1508
+ // floating bendpoint: preview until the pointer moves >=4px, then
1509
+ // adopt the edge geometry + create exactly one fold point and hand
1510
+ // over to the standard fold-point drag; below the threshold = no-op
1511
+ if (!drag.converted) {
1512
+ var mf = Math.hypot(p.x - drag.start[0], p.y - drag.start[1]);
1513
+ if (mf < 4) return;
1514
+ var fi = drag.edge;
1515
+ var wi = adoptFreeAndInsert(fi, drag.proj);
1516
+ if (wi === false) {
1517
+ showTip('max ' + MAX_FREE_PTS + ' points per edge', drag.ev || ev);
1518
+ drag = null;
1519
+ return;
1520
+ }
1521
+ drag = {wp: true, free: true, edge: fi, idx: wi + 1, start: drag.start,
1522
+ ox: freeWps[fi][wi][0], oy: freeWps[fi][wi][1], g: null};
1523
+ rebuildHandles();
1524
+ return;
1525
+ }
1526
+ }
1527
+ if (drag.insert && drag.segPrep !== undefined) {
1528
+ // click-insert upgrades to a segment drag on first real movement
1529
+ var mi = Math.hypot(p.x - drag.start[0], p.y - drag.start[1]);
1530
+ if (mi >= 4) {
1531
+ var ci = drag.edge;
1532
+ var plan = segDragPlan(ci, drag.segPrep);
1533
+ if ((freeWps[ci] || []).length + plan.added > MAX_FREE_PTS) {
1534
+ showTip('max ' + MAX_FREE_PTS + ' points per edge', drag.ev || ev);
1535
+ drag = null;
1536
+ return;
1537
+ }
1538
+ drag = {seg: true, edge: ci, plan: plan, start: drag.start, ev: drag.ev};
1539
+ }
1540
+ }
1541
+ if (drag.seg) {
1542
+ // whole-segment translation: static head/tail waypoints stay, the
1543
+ // moving copies of the dragged segment's endpoints follow the pointer
1544
+ var pl = drag.plan;
1545
+ var dx = p.x - drag.start[0], dy = p.y - drag.start[1];
1546
+ var mvPts = pl.mv.map(function (q) {
1547
+ return [Math.max(4, Math.min(VB[2] - 4, q[0] + dx)),
1548
+ Math.max(4, Math.min(VB[3] - 4, q[1] + dy))];
1549
+ });
1550
+ applyUserWps(drag.edge, pl.head.concat(mvPts, pl.tail));
1551
+ updateEdge(drag.edge);
1552
+ rebuildHandles();
1553
+ if (!rafPending) {
1554
+ rafPending = true;
1555
+ requestAnimationFrame(function () { rafPending = false; refreshMetrics(); });
1556
+ }
1557
+ return;
1558
+ }
1559
+ if (drag.wp) {
1560
+ var e = edges[drag.edge];
1561
+ var wx = drag.ox + (p.x - drag.start[0]);
1562
+ var wy = drag.oy + (p.y - drag.start[1]);
1563
+ wx = Math.max(4, Math.min(VB[2] - 4, wx));
1564
+ wy = Math.max(4, Math.min(VB[3] - 4, wy));
1565
+ e.pts[drag.idx] = [wx, wy];
1566
+ if (drag.free && freeWps[drag.edge]) freeWps[drag.edge][drag.idx - 1] = [wx, wy];
1567
+ updateEdge(drag.edge);
1568
+ rebuildHandles();
1569
+ if (!rafPending) {
1570
+ rafPending = true;
1571
+ requestAnimationFrame(function () { rafPending = false; refreshMetrics(); });
1572
+ }
1573
+ return;
1574
+ }
1575
+ if (drag.insert) {
1576
+ // Armed click-insert (free mode, fold points may be empty): movement is
1577
+ // neither an insert (that is decided on release, <4px) nor a drag —
1578
+ // fall-through here would hit the node branch with drag.id undefined
1579
+ // (latent TypeError, exposed by the segment-drag interaction paths).
1580
+ return;
1581
+ }
1582
+ var n = nodes[drag.id];
1583
+ var nx = drag.ox + (p.x - drag.start[0]);
1584
+ var ny = drag.oy + (p.y - drag.start[1]);
1585
+ nx = Math.max(n.w / 2 + 2, Math.min(VB[2] - n.w / 2 - 2, nx));
1586
+ ny = Math.max(nHH(n) + 2, Math.min(VB[3] - nHH(n) - 2, ny));
1587
+ n.x = nx; n.y = ny;
1588
+ drag.g.setAttribute('transform', 'translate(' + (nx - n.bx).toFixed(1) + ' ' + (ny - n.by).toFixed(1) + ')');
1589
+ var touched = {};
1590
+ incidentMap[drag.id].forEach(function (i) { touched[i] = 1; safeRerouteUpdate(i, 'drag re-anchor'); });
1591
+ renderAnchors(); // t_2c2496ca: anchor dots + pinned markers follow the moved box
1592
+ // Other edges: (1) pressed by the moving node (1px margin) -> re-route;
1593
+ // (2) currently polyline-shaped (detour / dense vg geometry) -> obstacles
1594
+ // changed so recompute (incl. stale detours invalidated when an obstacle is
1595
+ // dragged away). Preview caliber; user-anchored edges excepted (waypoints
1596
+ // preserved, endpoints re-anchored only).
1597
+ var hw = n.w / 2 + 1, hh = nHH(n) + 1;
1598
+ edges.forEach(function (e2, j) {
1599
+ if (touched[j] || pinned[j] || e2.a === drag.id || e2.b === drag.id) return;
1600
+ var need = e2.pts.length > 2;
1601
+ if (!need) {
1602
+ var s = samplePts(e2.pts, 6);
1603
+ for (var k = 0; k < s.length; k++) {
1604
+ if (distRect(s[k][0], s[k][1], nx, ny, hw, hh) <= 0) { need = true; break; }
1605
+ }
1606
+ }
1607
+ if (need) { safeRerouteUpdate(j, 'drag reroute'); }
1608
+ });
1609
+ rebuildHandles();
1610
+ if (!rafPending) {
1611
+ rafPending = true;
1612
+ requestAnimationFrame(function () { rafPending = false; refreshMetrics(); });
1613
+ }
1614
+ }
1615
+ function onUp(ev) {
1616
+ if (!drag) return;
1617
+ if (drag.ep) {
1618
+ // t_2c2496ca endpoint release: snap to the nearest standard anchor of the
1619
+ // endpoint's OWN box (deterministic tie -> lower ANCHOR_IDS index) and
1620
+ // record the explicit designation; subsequent node drags only re-project
1621
+ // this endpoint onto the SAME anchor position.
1622
+ var eiU = drag.edge, endU = drag.end;
1623
+ var eU = edges[eiU];
1624
+ var peU = svgPoint(ev);
1625
+ var vU = endU === 0 ? eU.a : eU.b;
1626
+ var xU, yU;
1627
+ if (peU) { var cU = clampVB(peU.x, peU.y); xU = cU[0]; yU = cU[1]; }
1628
+ else { var qU = eU.pts[endU === 0 ? 0 : eU.pts.length - 1]; xU = qU[0]; yU = qU[1]; }
1629
+ var aidU = nearestAnchorId(vU, xU, yU);
1630
+ var coordU = null;
1631
+ if (aidU) stdAnchors(nodes[vU]).forEach(function (q) {
1632
+ if (q[0] === aidU) coordU = [q[1], q[2]];
1633
+ });
1634
+ if (aidU && coordU) {
1635
+ userAnchored[eiU] = userAnchored[eiU] || {};
1636
+ userAnchored[eiU][endU] = aidU;
1637
+ var arcU = arcEpPts(eU, endU, coordU);
1638
+ if (arcU) eU.pts = arcU;
1639
+ else eU.pts[endU === 0 ? 0 : eU.pts.length - 1] = coordU;
1640
+ }
1641
+ drag = null;
1642
+ setEpVis(eiU, false); // t_51392037: drag over, dots hide again
1643
+ updateEdge(eiU);
1644
+ renderAnchors();
1645
+ refreshMetrics();
1646
+ fireLayoutChanged();
1647
+ return;
1648
+ }
1649
+ // floating bendpoint click (no drag): preview only, nothing persisted
1650
+ if (drag.floatDrag) { drag = null; hideFloat(); return; }
1651
+ if (drag.insert) {
1652
+ var pe = svgPoint(ev);
1653
+ var moved = pe ? Math.hypot(pe.x - drag.start[0], pe.y - drag.start[1]) : 1e9;
1654
+ if (moved < 4 && (styles[drag.edge] || 'auto') === 'free') {
1655
+ insertFreePoint(drag.edge, [pe.x, pe.y], ev);
1656
+ }
1657
+ drag = null;
1658
+ return;
1659
+ }
1660
+ if (drag.seg) {
1661
+ // finalize the segment drag: round the waypoints to the same 0.5
1662
+ // caliber as fold-point insertion, then persist via layoutchanged
1663
+ var se = drag;
1664
+ drag = null;
1665
+ var fw = effectiveWps(se.edge).map(function (q) {
1666
+ return [Math.round(q[0] * 2) / 2, Math.round(q[1] * 2) / 2];
1667
+ });
1668
+ applyUserWps(se.edge, fw);
1669
+ updateEdge(se.edge);
1670
+ rebuildHandles();
1671
+ refreshMarkers();
1672
+ refreshMetrics();
1673
+ fireLayoutChanged();
1674
+ return;
1675
+ }
1676
+ if (drag.wp) {
1677
+ // merge-back deletion (finalized lifecycle semantics): a fold point
1678
+ // released within MERGE_TOL of the polyline through its neighbors is
1679
+ // removed instead of persisted (only after a real drag >=4px, so a
1680
+ // plain handle click never deletes)
1681
+ var eiU = drag.edge;
1682
+ var peU = svgPoint(ev);
1683
+ var movedU = peU ? Math.hypot(peU.x - drag.start[0], peU.y - drag.start[1]) : 1e9;
1684
+ var posU = drag.free ? (freeWps[eiU] || [])[drag.idx - 1]
1685
+ : (edges[eiU] ? edges[eiU].pts[drag.idx] : null);
1686
+ var merged = false;
1687
+ if (posU && movedU >= 4) {
1688
+ var restU = edges[eiU].pts.slice(0, drag.idx)
1689
+ .concat(edges[eiU].pts.slice(drag.idx + 1));
1690
+ if (ptPolyDist(posU[0], posU[1], restU) <= MERGE_TOL) {
1691
+ removeWaypoint(eiU, drag.idx);
1692
+ showTip('fold point merged', ev);
1693
+ merged = true;
1694
+ }
1695
+ }
1696
+ drag = null;
1697
+ if (merged) { refreshMarkers(); fireLayoutChanged(); return; }
1698
+ rebuildHandles();
1699
+ refreshMarkers();
1700
+ refreshMetrics();
1701
+ fireLayoutChanged();
1702
+ return;
1703
+ }
1704
+ drag.g.classList.remove('dragging');
1705
+ var id = drag.id;
1706
+ drag = null;
1707
+ incidentMap[id].forEach(function (i) { reroute(i); updateEdge(i); });
1708
+ renderAnchors(); // t_2c2496ca: pinned-anchor dot markers refresh on release
1709
+ rebuildHandles();
1710
+ refreshMarkers();
1711
+ refreshMetrics();
1712
+ fireLayoutChanged();
1713
+ }
1714
+ svg.addEventListener('pointerdown', onDown);
1715
+ window.addEventListener('pointermove', onMove);
1716
+ window.addEventListener('pointerup', onUp);
1717
+ window.addEventListener('pointercancel', onUp);
1718
+ function buildLayout() {
1719
+ var out = {tool: 'stdraw', kind: 'manual-layout', graph: D.name,
1720
+ viewBox: VB.slice(), nodes: {}};
1721
+ Object.keys(nodes).forEach(function (v) {
1722
+ out.nodes[v] = {x: Math.round(nodes[v].x), y: Math.round(nodes[v].y)};
1723
+ });
1724
+ // v0.7.7: edges[] carries the per-edge style + user fold points
1725
+ // (deterministic: original edge order, rounded coordinates); legacy
1726
+ // user-anchored (pinned) edges keep the waypoints-only form
1727
+ var es = [];
1728
+ edges.forEach(function (e, i) {
1729
+ var st = styles[i] || 'auto';
1730
+ var wps = null;
1731
+ if (st === 'free' && freeWps[i] && freeWps[i].length) {
1732
+ wps = freeWps[i].map(function (p) {
1733
+ return [Math.round(p[0]), Math.round(p[1])];
1734
+ });
1735
+ } else if (pinned[i]) {
1736
+ wps = e.pts.slice(1, -1).map(function (p) {
1737
+ return [Math.round(p[0]), Math.round(p[1])];
1738
+ });
1739
+ }
1740
+ var ua = userAnchored[i];
1741
+ var hasUa = !!(ua && (ua[0] || ua[1]));
1742
+ if (st !== 'auto' || (wps && wps.length) || hasUa) {
1743
+ var it = {a: e.a, b: e.b, style: st};
1744
+ if (wps && wps.length) it.waypoints = wps;
1745
+ // t_2c2496ca: explicit standard-anchor designations travel with the
1746
+ // export (viewer round-trip via __stdrawApplyLayout edge_anchors)
1747
+ if (hasUa) {
1748
+ it.anchors = {};
1749
+ if (ua[0]) it.anchors.src = ua[0];
1750
+ if (ua[1]) it.anchors.dst = ua[1];
1751
+ }
1752
+ es.push(it);
1753
+ }
1754
+ });
1755
+ if (es.length) out.edges = es;
1756
+ return out;
1757
+ }
1758
+ function exportLayout() {
1759
+ var out = buildLayout();
1760
+ var txt = JSON.stringify(out, null, 1);
1761
+ try {
1762
+ var blob = new Blob([txt], {type: 'application/json'});
1763
+ var a = document.createElement('a');
1764
+ a.href = URL.createObjectURL(blob);
1765
+ a.download = (D.name || 'graph') + '-manual-layout.json';
1766
+ document.body.appendChild(a);
1767
+ a.click();
1768
+ document.body.removeChild(a);
1769
+ setTimeout(function () { URL.revokeObjectURL(a.href); }, 2000);
1770
+ } catch (e) { /* sandboxed / no download API: probes go through __stdrawExportJSON */ }
1771
+ return out;
1772
+ }
1773
+ // v0.7.7: layout-changed notification (drag release / fold-point insert /
1774
+ // style cycle / reset / applyLayout) — product integrations can listen on
1775
+ // window 'stdraw:layoutchanged' instead of polling pointerup
1776
+ function fireLayoutChanged() {
1777
+ try {
1778
+ window.dispatchEvent(new CustomEvent('stdraw:layoutchanged',
1779
+ {detail: buildLayout()}));
1780
+ } catch (e) { /* CustomEvent unavailable: silent */ }
1781
+ }
1782
+ function reset() {
1783
+ pinned = {};
1784
+ styles = {}; // v0.7.7: reset clears pins + forced styles -> auto
1785
+ freeWps = {};
1786
+ userAnchored = {}; // t_2c2496ca: reset also clears explicit anchor designations
1787
+ Object.keys(nodes).forEach(function (v) {
1788
+ var n = nodes[v];
1789
+ n.x = n.bx; n.y = n.by;
1790
+ if (n.g) n.g.removeAttribute('transform');
1791
+ });
1792
+ edges.forEach(function (e, i) {
1793
+ // v0.7.11 per-edge isolation on the restore path as well
1794
+ try {
1795
+ e.pts = e.base.map(cp);
1796
+ e.corners = e.via ? e.via.map(cp) : null;
1797
+ var g = visG(i);
1798
+ if (!g) return;
1799
+ var paths = g.querySelectorAll('path');
1800
+ // Original rendering (vg path_d incl. fillet arcs) restored from the initial d strings
1801
+ if (paths[0] && e.d0) paths[0].setAttribute('d', e.d0);
1802
+ else if (paths[0]) paths[0].setAttribute('d', edgeD(e.pts));
1803
+ if (paths[1]) {
1804
+ var n2 = e.pts.length, a = e.pts[n2 - 2], b = e.pts[n2 - 1];
1805
+ var ang = Math.atan2(b[1] - a[1], b[0] - a[0]) * 180 / Math.PI;
1806
+ paths[1].setAttribute('transform', 'translate(' + b[0].toFixed(1) + ' ' + b[1].toFixed(1) + ') rotate(' + ang.toFixed(1) + ')');
1807
+ }
1808
+ var h = hitP(i);
1809
+ if (h) h.setAttribute('d', edgeD(e.pts));
1810
+ clearDegraded(i);
1811
+ } catch (errR) { markDegraded(i, 'reset', errR); }
1812
+ });
1813
+ Object.keys(polGroups).forEach(function (i) {
1814
+ polGroups[i].g.setAttribute('transform', polGroups[i].t);
1815
+ });
1816
+ syncDomGeometry(); // v0.7.9: keep cards/anchors in sync after restore (no-op when in sync)
1817
+ rebuildHandles();
1818
+ rebuildAllEndpointHandles(); // t_2c2496ca
1819
+ renderAnchors(); // t_2c2496ca: clear stale dots / pinned markers
1820
+ refreshMetrics();
1821
+ fireLayoutChanged();
1822
+ }
1823
+ window.__stdrawExportLayout = exportLayout;
1824
+ window.__stdrawExportJSON = function () { return JSON.stringify(buildLayout(), null, 1); };
1825
+ // v0.7.7 public apply API: programmatically restore a layout
1826
+ // ({nodes: {id: {x,y}}, edge_styles: {"a->b": "straight"|"arc"|"free"|"auto"},
1827
+ // waypoints: {"a->b": [[x,y],...]}}) — the inverse of "Export layout"
1828
+ window.__stdrawApplyLayout = function (spec) {
1829
+ if (!spec || typeof spec !== 'object') return false;
1830
+ var ns = spec.nodes || {};
1831
+ Object.keys(ns).forEach(function (v) {
1832
+ if (!nodes[v]) return;
1833
+ var q = ns[v];
1834
+ nodes[v].x = +q.x;
1835
+ nodes[v].y = +q.y;
1836
+ });
1837
+ Object.keys(nodes).forEach(function (v) {
1838
+ var n = nodes[v];
1839
+ if (n.g) n.g.setAttribute('transform',
1840
+ 'translate(' + (n.x - n.bx).toFixed(1) + ' ' + (n.y - n.by).toFixed(1) + ')');
1841
+ });
1842
+ function edgeIdxByKey(k) {
1843
+ var m = String(k).split('->');
1844
+ if (m.length !== 2) return null;
1845
+ for (var i = 0; i < edges.length; i++) {
1846
+ if (edges[i].a === m[0] && edges[i].b === m[1]) return i;
1847
+ }
1848
+ return null;
1849
+ }
1850
+ styles = {};
1851
+ freeWps = {};
1852
+ userAnchored = {};
1853
+ var ess = spec.edge_styles || {};
1854
+ Object.keys(ess).forEach(function (k) {
1855
+ var i = edgeIdxByKey(k);
1856
+ if (i !== null && ['auto', 'straight', 'arc', 'free'].indexOf(ess[k]) >= 0) {
1857
+ if (ess[k] === 'free') freeWps[i] = [];
1858
+ if (ess[k] !== 'auto') styles[i] = ess[k];
1859
+ }
1860
+ });
1861
+ var wss = spec.waypoints || {};
1862
+ Object.keys(wss).forEach(function (k) {
1863
+ var i = edgeIdxByKey(k);
1864
+ if (i === null || !Array.isArray(wss[k])) return;
1865
+ freeWps[i] = wss[k].map(function (p) { return [+p[0], +p[1]]; })
1866
+ .filter(function (p) { return isFinite(p[0]) && isFinite(p[1]); })
1867
+ .slice(0, MAX_FREE_PTS);
1868
+ if (!styles[i]) styles[i] = 'free';
1869
+ });
1870
+ // t_2c2496ca: restore explicit standard-anchor designations (applied before
1871
+ // the re-anchor round so the re-projection caliber sees them)
1872
+ var uas = spec.edge_anchors || {};
1873
+ Object.keys(uas).forEach(function (k) {
1874
+ var i = edgeIdxByKey(k);
1875
+ if (i === null || !uas[k] || typeof uas[k] !== 'object') return;
1876
+ var rec = {};
1877
+ if (ANCHOR_IDS.indexOf(uas[k].src) >= 0) rec[0] = uas[k].src;
1878
+ if (ANCHOR_IDS.indexOf(uas[k].dst) >= 0) rec[1] = uas[k].dst;
1879
+ if (rec[0] !== undefined || rec[1] !== undefined) userAnchored[i] = rec;
1880
+ });
1881
+ edges.forEach(function (e, i) { safeRerouteUpdate(i, 'applyLayout re-anchor'); });
1882
+ rebuildAllEndpointHandles();
1883
+ rebuildHandles();
1884
+ refreshMetrics();
1885
+ fireLayoutChanged();
1886
+ return true;
1887
+ };
1888
+ // v0.7.7 probe hooks: styled-edge list / per-edge style / free fold points
1889
+ window.__stdrawStyles = function () {
1890
+ return Object.keys(styles).map(Number).sort(function (a, b) { return a - b; });
1891
+ };
1892
+ window.__stdrawEdgeStyle = function (i) {
1893
+ return (i === undefined || i === null) ? JSON.parse(JSON.stringify(styles))
1894
+ : (styles[i] || 'auto');
1895
+ };
1896
+ window.__stdrawFreePoints = function (i) {
1897
+ return (i === undefined || i === null)
1898
+ ? JSON.parse(JSON.stringify(freeWps))
1899
+ : (freeWps[i] ? freeWps[i].map(cp) : []);
1900
+ };
1901
+ window.__stdrawMetrics = metrics;
1902
+ window.__stdrawNodePos = function (v) {
1903
+ return v ? [nodes[v].x, nodes[v].y]
1904
+ : Object.keys(nodes).map(function (k) { return [k, nodes[k].x, nodes[k].y]; });
1905
+ };
1906
+ // Probe hooks: current polyline vertices / anchored state / vg edge list / handle list
1907
+ window.__stdrawEdgePts = function (i) {
1908
+ return (i === undefined || i === null)
1909
+ ? edges.map(function (e) { return e.pts; })
1910
+ : (edges[i] ? edges[i].pts : null);
1911
+ };
1912
+ window.__stdrawPinned = function () {
1913
+ return Object.keys(pinned).map(Number);
1914
+ };
1915
+ window.__stdrawVgEdges = function () {
1916
+ var out = [];
1917
+ edges.forEach(function (e, i) { if (e.via) out.push(i); });
1918
+ return out;
1919
+ };
1920
+ window.__stdrawHandles = function () {
1921
+ var out = [];
1922
+ handleLayer.querySelectorAll('.stdraw-wp').forEach(function (c) {
1923
+ out.push([+c.getAttribute('data-edge'), +c.getAttribute('data-wp'),
1924
+ +c.getAttribute('cx'), +c.getAttribute('cy')]);
1925
+ });
1926
+ return out;
1927
+ };
1928
+ // Segment-drag / floating-bendpoint probe hooks: effective user waypoints
1929
+ // per edge (free or pinned interior) + floating preview handle state.
1930
+ window.__stdrawUserWps = function () {
1931
+ var out = {};
1932
+ edges.forEach(function (e, i) {
1933
+ var w = effectiveWps(i);
1934
+ if (w.length) out[i] = w;
1935
+ });
1936
+ return out;
1937
+ };
1938
+ window.__stdrawFloating = function () {
1939
+ if (!floatC || floatC.style.display === 'none') return null;
1940
+ return {edge: +floatC.getAttribute('data-edge'),
1941
+ cx: +floatC.getAttribute('cx'), cy: +floatC.getAttribute('cy')};
1942
+ };
1943
+ window.__stdrawSegDraggable = function (i) { return segDraggable(i); };
1944
+ // v0.7.11 fault-injection hook for the DOM-truth probe (t_3fb02650): force a
1945
+ // single edge's reroute to throw so the probe can assert per-edge isolation +
1946
+ // the visible degradation marker; pass false to clear (self-heal check).
1947
+ window.__stdrawProbeBreakEdge = function (i, on) {
1948
+ if (edges[i]) edges[i]._probeThrow = !!on;
1949
+ return true;
1950
+ };
1951
+ window.__stdrawDragState = function () {
1952
+ if (!drag) return null;
1953
+ var keys = [];
1954
+ for (var k in drag) keys.push(k);
1955
+ return {keys: keys, edge: drag.edge, free: drag.free};
1956
+ };
1957
+ window.__stdrawArcFlag = function (i) { return !!edges[i].arc; };
1958
+ // ── t_2c2496ca probe/integration hooks ──
1959
+ window.__stdrawAnchors = function (v) {
1960
+ return nodes[v] ? stdAnchors(nodes[v]) : null;
1961
+ };
1962
+ window.__stdrawUserAnchors = function () {
1963
+ return JSON.parse(JSON.stringify(userAnchored));
1964
+ };
1965
+ window.__stdrawSetAnchor = function (i, end, aid) {
1966
+ if (!edges[i] || ANCHOR_IDS.indexOf(aid) < 0 || (end !== 0 && end !== 1)) return false;
1967
+ userAnchored[i] = userAnchored[i] || {};
1968
+ userAnchored[i][end] = aid;
1969
+ safeRerouteUpdate(i, 'set anchor');
1970
+ updateEdge(i);
1971
+ renderAnchors();
1972
+ refreshMetrics();
1973
+ fireLayoutChanged();
1974
+ return true;
1975
+ };
1976
+ window.__stdrawAnchorDots = function () {
1977
+ var out = [];
1978
+ anchorLayer.querySelectorAll('circle.stdraw-anchor').forEach(function (c) {
1979
+ out.push([c.getAttribute('data-node'), c.getAttribute('data-aid'),
1980
+ +c.getAttribute('cx'), +c.getAttribute('cy')]);
1981
+ });
1982
+ return out;
1983
+ };
1984
+ window.__stdrawAnchorToggleState = function () { return !!anchorToggle; };
1985
+ window.__stdrawSetAnchorToggle = function (on) { setAnchorToggle(!!on); }; // t_51392037 probe hook
1986
+ window.__stdrawEndpointHandleVis = function () {
1987
+ var out = [];
1988
+ epsLayer.querySelectorAll('circle.stdraw-ep').forEach(function (c) {
1989
+ out.push([+c.getAttribute('data-edge'), +c.getAttribute('data-end'),
1990
+ getComputedStyle(c).opacity]);
1991
+ });
1992
+ return out;
1993
+ };
1994
+ window.__stdrawEndpointHandles = function () {
1995
+ var out = [];
1996
+ epsLayer.querySelectorAll('circle.stdraw-ep').forEach(function (c) {
1997
+ out.push([+c.getAttribute('data-edge'), +c.getAttribute('data-end'),
1998
+ +c.getAttribute('cx'), +c.getAttribute('cy')]);
1999
+ });
2000
+ return out;
2001
+ };
2002
+ // v0.7.9 probe/integration hooks: per-card rendered geometry + manual re-anchor
2003
+ window.__stdrawNodeGeom = function (v) {
2004
+ return v ? {x: nodes[v].x, y: nodes[v].y, w: nodes[v].w, h: nodes[v].h}
2005
+ : Object.keys(nodes).map(function (k) {
2006
+ return [k, nodes[k].x, nodes[k].y, nodes[k].w, nodes[k].h];
2007
+ });
2008
+ };
2009
+ window.__stdrawSyncDomGeometry = syncDomGeometry;
2010
+ var btnE = document.getElementById('stdraw-export');
2011
+ var btnR = document.getElementById('stdraw-reset');
2012
+ if (btnE) btnE.addEventListener('click', exportLayout);
2013
+ if (btnR) btnR.addEventListener('click', reset);
2014
+ var btnA = document.getElementById('stdraw-anchors-toggle');
2015
+ if (btnA) {
2016
+ btnA.textContent = 'Anchors: ' + (anchorToggle ? 'on' : 'off'); // t_51392037: default off
2017
+ btnA.addEventListener('click', function () { setAnchorToggle(!anchorToggle); });
2018
+ }
2019
+ rebuildHandles();
2020
+ rebuildAllEndpointHandles(); // t_2c2496ca: initial endpoint handle placement
2021
+ refreshMetrics();
2022
+ // v0.7.9 initial font-environment sync: run now (the script sits after the
2023
+ // SVG in the DOM, so layout is available) and again on window load (late
2024
+ // font/measure settling). Both calls are strict no-ops when the rendered
2025
+ // text fits the planned cards, so the deterministic initial render is
2026
+ // untouched in matching environments.
2027
+ syncDomGeometry();
2028
+ window.addEventListener('load', function () { syncDomGeometry(); });
2029
+ })();
2030
+ """