qgraphflow 0.0.6 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/.qoder-plugin/plugin.json +1 -1
  6. package/README.md +119 -70
  7. package/docs/clients.de.md +15 -24
  8. package/docs/clients.es.md +15 -24
  9. package/docs/clients.ja.md +15 -24
  10. package/docs/clients.md +15 -24
  11. package/docs/clients.pt.md +15 -24
  12. package/docs/clients.ru.md +15 -24
  13. package/docs/clients.zh-CN.md +15 -24
  14. package/docs/readme/README.de.md +120 -71
  15. package/docs/readme/README.es.md +120 -71
  16. package/docs/readme/README.ja.md +120 -71
  17. package/docs/readme/README.pt.md +120 -71
  18. package/docs/readme/README.ru.md +120 -71
  19. package/docs/readme/README.zh-CN.md +106 -59
  20. package/examples/jeepay/README.md +23 -0
  21. package/examples/jeepay/capabilities.graph.json +270 -0
  22. package/examples/jeepay/class.graph.json +237 -0
  23. package/examples/jeepay/collection.graph.json +3057 -0
  24. package/examples/jeepay/dataflow.graph.json +212 -0
  25. package/examples/jeepay/deployment.graph.json +222 -0
  26. package/examples/jeepay/engineering.graph.json +277 -0
  27. package/examples/jeepay/er.graph.json +482 -0
  28. package/examples/jeepay/flowchart.graph.json +312 -0
  29. package/examples/jeepay/relations.graph.json +289 -0
  30. package/examples/jeepay/sequence.graph.json +355 -0
  31. package/examples/jeepay/source.json +95 -0
  32. package/examples/jeepay/state.graph.json +175 -0
  33. package/examples/jeepay/usecase.graph.json +222 -0
  34. package/package.json +14 -3
  35. package/skills/q-flow/SKILL.md +28 -20
  36. package/skills/q-flow/agents/openai.yaml +1 -1
  37. package/skills/q-flow/assets/viewer/package.json +1 -1
  38. package/skills/q-flow/assets/viewer/src/architecture-overview-theme.js +22 -0
  39. package/skills/q-flow/assets/viewer/src/architecture-overview.js +340 -0
  40. package/skills/q-flow/assets/viewer/src/diagrams/architecture.js +8 -5
  41. package/skills/q-flow/assets/viewer/src/diagrams/card.js +35 -17
  42. package/skills/q-flow/assets/viewer/src/diagrams/deployment.js +7 -5
  43. package/skills/q-flow/assets/viewer/src/diagrams/drawing.js +5 -2
  44. package/skills/q-flow/assets/viewer/src/diagrams/registry.js +10 -0
  45. package/skills/q-flow/assets/viewer/src/diagrams/sequence.js +13 -7
  46. package/skills/q-flow/assets/viewer/src/edge-routing.js +43 -22
  47. package/skills/q-flow/assets/viewer/src/export-svg.js +27 -5
  48. package/skills/q-flow/assets/viewer/src/graph-validation.js +72 -15
  49. package/skills/q-flow/assets/viewer/src/i18n-messages.json +184 -8
  50. package/skills/q-flow/assets/viewer/src/layout-compaction.js +123 -0
  51. package/skills/q-flow/assets/viewer/src/layout-measure.js +14 -8
  52. package/skills/q-flow/assets/viewer/src/layout-policy.js +6 -0
  53. package/skills/q-flow/assets/viewer/src/layout-quality.js +61 -15
  54. package/skills/q-flow/assets/viewer/src/layout-refinement.js +271 -0
  55. package/skills/q-flow/assets/viewer/src/layout-semantics.js +8 -0
  56. package/skills/q-flow/assets/viewer/src/layout-spacing.js +23 -4
  57. package/skills/q-flow/assets/viewer/src/layout-templates.js +298 -0
  58. package/skills/q-flow/assets/viewer/src/node-svg.js +1 -1
  59. package/skills/q-flow/assets/viewer/src/orthogonal-routing.js +475 -0
  60. package/skills/q-flow/assets/viewer/src/presentation-graph.js +31 -0
  61. package/skills/q-flow/assets/viewer/src/route-clearance.js +144 -0
  62. package/skills/q-flow/assets/viewer/src/sequence-executions.js +22 -0
  63. package/skills/q-flow/assets/viewer/src/sequence-fragments.js +20 -2
  64. package/skills/q-flow/assets/viewer/src/session-graph.js +46 -3
  65. package/skills/q-flow/assets/viewer/src/text-layout.js +33 -6
  66. package/skills/q-flow/assets/viewer/src/view-identity.js +26 -0
  67. package/skills/q-flow/assets/viewer/src/visual-style.js +13 -5
  68. package/skills/q-flow/assets/viewer-dist/index.html +30 -28
  69. package/skills/q-flow/references/evidence-sources.md +7 -5
  70. package/skills/q-flow/references/graph-common.md +34 -34
  71. package/skills/q-flow/references/graph-schema.md +28 -7
  72. package/skills/q-flow/references/guided-intake.md +51 -71
  73. package/skills/q-flow/references/layout-routing.md +47 -0
  74. package/skills/q-flow/references/types/architecture.md +42 -22
  75. package/skills/q-flow/references/types/class.md +9 -2
  76. package/skills/q-flow/references/types/dataflow.md +11 -4
  77. package/skills/q-flow/references/types/deployment.md +11 -3
  78. package/skills/q-flow/references/types/er.md +8 -1
  79. package/skills/q-flow/references/types/flowchart.md +12 -5
  80. package/skills/q-flow/references/types/sequence.md +20 -16
  81. package/skills/q-flow/references/types/state.md +10 -3
  82. package/skills/q-flow/references/types/usecase.md +6 -0
  83. package/skills/q-flow/references/viewer-development.md +37 -24
  84. package/skills/q-flow/references/visual-contract.md +12 -6
  85. package/skills/q-flow/scripts/compile-layout.mjs +85 -102
  86. package/skills/q-flow/scripts/compile-sequence.mjs +4 -21
  87. package/skills/q-flow/scripts/generate-viewer.mjs +18 -9
  88. package/skills/q-flow/scripts/validate-graph.mjs +38 -21
  89. package/examples/order-flow.graph.json +0 -94
@@ -0,0 +1,475 @@
1
+ import { layoutWeights } from './layout-policy.js';
2
+ import { clearanceProbe, clearanceLimits } from './route-clearance.js';
3
+ import { diagramTypeOf, getDiagram } from './diagrams/registry.js';
4
+ import { createEdgeRoutes, nodeAnchor, occupiedBox, routingBounds, segmentCrossesNode, segmentCrossesBox, groupHeadingBoxes, groupBorders, sharedSegmentLength, visibleEdgeLabel, routeCrossings } from './edge-routing.js';
5
+ import { overviewSections, sectionTextBoxes } from './architecture-overview.js';
6
+ import { isArchitectureOverview } from './view-identity.js';
7
+ import { estimateLabelSize } from './text-layout.js';
8
+ import { layoutLimits } from './layout-spacing.js';
9
+
10
+ export const ROUTING_BUDGET = 60000;
11
+ const sides = ['top', 'right', 'bottom', 'left'];
12
+ const vector = { top: [0, -1], right: [1, 0], bottom: [0, 1], left: [-1, 0] };
13
+ const distance = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
14
+ const box = item => ({ ...item.position, ...item.size });
15
+ const expand = (r, p) => ({ x: r.x - p, y: r.y - p, width: r.width + p * 2, height: r.height + p * 2 });
16
+ const overlap = (a, b) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
17
+ const parts = points => points.slice(1).map((b, i) => [points[i], b]);
18
+ const key = p => p.x + ',' + p.y;
19
+ const stable = (a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
20
+
21
+ // Same evaluation order in Node and the browser; no wall-clock scoring.
22
+ class Heap {
23
+ items = [];
24
+ less(a, b) { return a.priority < b.priority || a.priority === b.priority && a.serial < b.serial; }
25
+ push(value) { const a = this.items; a.push(value); let i = a.length - 1; while (i) { const p = (i - 1) >> 1; if (!this.less(a[i], a[p])) break; [a[i], a[p]] = [a[p], a[i]]; i = p; } }
26
+ pop() { const a = this.items, first = a[0], last = a.pop(); if (a.length) { a[0] = last; let i = 0; for (;;) { let j = i, l = i * 2 + 1; for (const n of [l, l + 1]) if (n < a.length && this.less(a[n], a[j])) j = n; if (j === i) break; [a[i], a[j]] = [a[j], a[i]]; i = j; } } return first; }
27
+ }
28
+
29
+ export function simplifyPath(points) {
30
+ const result = [];
31
+ for (const p of points) if (!result.length || distance(p, result.at(-1))) result.push(p);
32
+ for (let i = 1; i < result.length - 1;) {
33
+ const [a, b, c] = result.slice(i - 1, i + 2);
34
+ if ((a.x === b.x && b.x === c.x && (b.y - a.y) * (c.y - b.y) >= 0) || (a.y === b.y && b.y === c.y && (b.x - a.x) * (c.x - b.x) >= 0)) result.splice(i, 1);
35
+ else i++;
36
+ }
37
+ return result;
38
+ }
39
+
40
+ // Replace stair steps/backtracks only when the whole replacement passes the caller's endpoint,
41
+ // label, obstacle and other-route checks. The bounded search also handles equal-endpoint Z detours.
42
+ export function simplifyCorridor(points, score, budget = 32) {
43
+ let best = simplifyPath(points), cost = score(best), evaluations = 0;
44
+ let improved = true;
45
+ while (improved && evaluations < budget) {
46
+ improved = false;
47
+ search: for (let i = 1; i < best.length - 2; i++) for (let j = best.length - 2; j > i + 1; j--) {
48
+ const a = best[i], b = best[j];
49
+ for (const via of [[{x:b.x,y:a.y}], [{x:a.x,y:b.y}]]) {
50
+ if (evaluations++ >= budget) break search;
51
+ const candidate = simplifyPath([...best.slice(0,i+1), ...via, ...best.slice(j)]), next = score(candidate);
52
+ if (next < cost - .001) { best = candidate; cost = next; improved = true; break search; }
53
+ }
54
+ }
55
+ }
56
+ return best;
57
+ }
58
+
59
+ export function routingLimits(graph) {
60
+ const limits = { ...layoutLimits(getDiagram(diagramTypeOf(graph))), preferredClearance: clearanceLimits(graph).preferred };
61
+ const unit = Math.sqrt(graph.nodes.reduce((sum, n) => { const r=occupiedBox(n,diagramTypeOf(graph)); return sum+r.width*r.height; }, 0) / Math.max(1, graph.nodes.length));
62
+ const crossingCost = layoutWeights.crossings * unit, bendCost = layoutWeights.bends * unit;
63
+ return isArchitectureOverview(graph) ? { ...limits, labelGap: 6, parallelGap: 8, nodeGap: 14, crossingCost, bendCost } : { ...limits, crossingCost, bendCost };
64
+ }
65
+
66
+ function ports(node, other, side, type, limits, graph, edge) {
67
+ const horizontal = side === 'top' || side === 'bottom';
68
+ const axis = horizontal ? 'x' : 'y', extent = horizontal ? 'width' : 'height', outline = routingBounds(node, type);
69
+ const nodeMiddle = node.position[axis] + node.size[extent] / 2, middle = outline[axis] + outline[extent] / 2;
70
+ const limit = Math.max(0, outline[extent] / 2 - 20);
71
+ const projected = other.position[horizontal ? 'x' : 'y'] + other.size[horizontal ? 'width' : 'height'] / 2 - middle;
72
+ const peers = graph.edges.filter(e => e.source === node.id || e.target === node.id).map(e => { const target=graph.nodes.find(n=>n.id===(e.source===node.id?e.target:e.source)); return {edge:e, target}; }).filter(p=>p.target.id!==node.id);
73
+ peers.sort((a,b)=>a.target.position[axis]+a.target.size[extent]/2-b.target.position[axis]-b.target.size[extent]/2||stable(a.edge,b.edge));
74
+ const ordinal=peers.findIndex(p=>p.edge.id===edge?.id), allocated=ordinal<0?0:(ordinal-(peers.length-1)/2)*limits.parallelGap;
75
+ const offsets = ['decision', 'choice'].includes(node.kind) ? [0] : [allocated, Math.max(-limit, Math.min(limit, projected)), 0, -limits.parallelGap, limits.parallelGap, -limit * .6, limit * .6];
76
+ return [...new Set(offsets.map(o => Math.max(-limit, Math.min(limit, o))))].map(offset => {
77
+ return portAt(node,side,offset + middle - nodeMiddle,type,limits);
78
+ });
79
+ }
80
+
81
+ function portAt(node,side,offset,type,limits) {
82
+ const point = nodeAnchor(node, side, offset, type), [dx, dy] = vector[side], r = box(node);
83
+ // Get outside the whole text box, even for a cylinder, actor or inset symbol.
84
+ const reach = type === 'usecase' && node.kind === 'actor' ? limits.endpoint : Math.max(getDiagram(type).endpointStub ?? limits.endpoint, side === 'left' ? point.x - r.x + 1 : side === 'right' ? r.x + r.width - point.x + 1 : side === 'top' ? point.y - r.y + 1 : r.y + r.height - point.y + 1);
85
+ return { point, side, stub: { x: point.x + dx * reach, y: point.y + dy * reach } };
86
+ }
87
+
88
+ // Rank the actual outlines, rather than the centres of bounding rectangles.
89
+ export function nearestPortPairs(source, target, graph, sourceSide = null, edge = null) {
90
+ const type = diagramTypeOf(graph), limits = routingLimits(graph);
91
+ const pairs = [];
92
+ for (const a of sourceSide ? [sourceSide] : sides) for (const b of sides) {
93
+ const starts = ports(source, target, a, type, limits, graph, edge), ends = ports(target, source, b, type, limits, graph, edge);
94
+ // Project actual outline ports onto the facing outline, including inset symbols and actor arms.
95
+ // Centre projections alone can leave a one-unit stair even though a straight connection exists.
96
+ if ((a==='left'||a==='right') === (b==='left'||b==='right')) {
97
+ const axis=a==='left'||a==='right'?'y':'x',extent=axis==='x'?'width':'height';
98
+ const align=(node,side,ports,opposite)=>{
99
+ const bounds=routingBounds(node,type),middle=node.position[axis]+node.size[extent]/2;
100
+ for(const peer of opposite.slice(0,3)) {
101
+ const coordinate=peer.point[axis];if(coordinate<bounds[axis]+20||coordinate>bounds[axis]+bounds[extent]-20)continue;
102
+ const port=portAt(node,side,coordinate-middle,type,limits);
103
+ if(!ports.some(p=>distance(p.point,port.point)<.001))ports.push(port);
104
+ }
105
+ };
106
+ const originals=[...starts];align(source,a,starts,ends);align(target,b,ends,originals);
107
+ }
108
+ const nearest = Math.min(...starts.flatMap(s => ends.map(t => distance(s.point, t.point))));
109
+ const sameAxis = (a==='left'||a==='right') === (b==='left'||b==='right');
110
+ const direct = sameAxis && a !== b && starts.some(s=>ends.some(t=>(s.point.x===t.point.x||s.point.y===t.point.y) && (t.point.x-s.point.x)*vector[a][0]+(t.point.y-s.point.y)*vector[a][1]>0));
111
+ const minimumBends = direct ? 0 : sameAxis ? 2 : 1;
112
+ pairs.push({ sourceSide: a, targetSide: b, starts, ends, nearest, minimumBends, sourceId: source.id, targetId: target.id });
113
+ }
114
+ return pairs.sort((a, b) => a.nearest+a.minimumBends*limits.bendCost-b.nearest-b.minimumBends*limits.bendCost || a.nearest - b.nearest || sides.indexOf(a.sourceSide) - sides.indexOf(b.sourceSide) || sides.indexOf(a.targetSide) - sides.indexOf(b.targetSide));
115
+ }
116
+
117
+ function lineConflict(a, b, c, d, gap) {
118
+ const h = a.y === b.y, k = c.y === d.y;
119
+ if (h === k) {
120
+ const axis = h ? 'x' : 'y', across = h ? 'y' : 'x';
121
+ return Math.abs(a[across] - c[across]) + .001 < gap && Math.min(Math.max(a[axis], b[axis]), Math.max(c[axis], d[axis])) - Math.max(Math.min(a[axis], b[axis]), Math.min(c[axis], d[axis])) > .001 ? Infinity : 0;
122
+ }
123
+ const [left, right, top, bottom] = h ? [a, b, c, d] : [c, d, a, b];
124
+ return top.x >= Math.min(left.x, right.x) && top.x <= Math.max(left.x, right.x) && left.y >= Math.min(top.y, bottom.y) && left.y <= Math.max(top.y, bottom.y) ? 1 : 0;
125
+ }
126
+
127
+ // Use the audit's unique intersection points, including intersections at bends. Coincident
128
+ // terminal anchors are the only shared points exempted from the crossing score.
129
+ function pathCrossings(a, b) {
130
+ const edge = points => ({ source: key(points[0]), target: key(points.at(-1)) });
131
+ return routeCrossings(edge(a), a, edge(b), b).length;
132
+ }
133
+
134
+ // segmentCrossesNode rebuilds a node's outline on every call, yet most grid segments miss its band outright. The same
135
+ // band test runs first; nodes with an extra text area (state symbols) always take the full check.
136
+ function nodeProbe(graph, type) {
137
+ const entries = graph.nodes.map(node => ({ node, r: routingBounds(node, type), always: type === 'usecase' && node.kind === 'actor' || type === 'state' && ['initial', 'final'].includes(node.kind) && node.subtitle }));
138
+ return (a, b) => entries.some(({ node, r, always }) => {
139
+ if (!always && (a.y === b.y ? a.y <= r.y || a.y >= r.y + r.height : a.x === b.x && (a.x <= r.x || a.x >= r.x + r.width))) return false;
140
+ return segmentCrossesNode(a, b, node, type);
141
+ });
142
+ }
143
+
144
+ // Adds a segment's crossings × crossingCost to `cost`, or returns Infinity on a shared lane. Segments farther than the
145
+ // lane gap from a route's bounds cannot conflict, so only near ones reach lineConflict; the sum keeps its order.
146
+ export function priorCost(previous, limits) {
147
+ const gap = limits.parallelGap, entries = previous.flatMap((points, index) => parts(points).map(([c, d]) => ({ index, c, d, l: Math.min(c.x, d.x) - gap, r: Math.max(c.x, d.x) + gap, t: Math.min(c.y, d.y) - gap, b: Math.max(c.y, d.y) + gap })));
148
+ return (a, b, cost, countCrossings = true) => {
149
+ const x0 = Math.min(a.x, b.x), x1 = Math.max(a.x, b.x), y0 = Math.min(a.y, b.y), y1 = Math.max(a.y, b.y);
150
+ const crossings = new Map();
151
+ for (const { index, c, d, l, r, t, b: bottom } of entries) {
152
+ if (x1 < l || x0 > r || y1 < t || y0 > bottom) continue;
153
+ const conflict = lineConflict(a, b, c, d, gap);
154
+ if (conflict === Infinity) {
155
+ const shared = sharedSegmentLength(a, b, c, d);
156
+ const commonTerminal = previous.some(points => [points[0], points.at(-1)].some(point => (point.x === a.x && point.y === a.y || point.x === b.x && point.y === b.y) && (point.x === c.x && point.y === c.y || point.x === d.x && point.y === d.y)));
157
+ if (shared > 0 && shared <= limits.endpoint + .001 && commonTerminal) continue;
158
+ return Infinity;
159
+ }
160
+ if (countCrossings && conflict) {
161
+ const point = a.y === b.y ? { x: c.x, y: a.y } : { x: a.x, y: c.y };
162
+ const endpoint = distance(point,a)<.001 || distance(point,b)<.001;
163
+ if (endpoint && [previous[index][0],previous[index].at(-1)].some(p=>distance(p,point)<.001)) continue;
164
+ // Adjacent grid steps share half of a vertex intersection; bends in the prior path
165
+ // contribute only once. This is a lower bound on the final unique-crossing score.
166
+ crossings.set(index+':'+key(point), endpoint ? .5 : 1);
167
+ }
168
+ }
169
+ return cost + [...crossings.values()].reduce((sum,value)=>sum+value,0) * limits.crossingCost;
170
+ };
171
+ }
172
+
173
+ function corridor(pair, graph, obstacles, previous, limits, budget, label, ceiling = Infinity) {
174
+ const starts = pair.starts, ends = pair.ends, type = diagramTypeOf(graph), labelSize = estimateLabelSize(label);
175
+ const labelNodes = graph.nodes.map(n => occupiedBox(n, type)), labelLines = previous.flatMap(parts);
176
+ const borders = (graph.groups ?? []).flatMap(groupBorders), crossesNode = nodeProbe(graph, type), conflictWith = priorCost(previous, limits);
177
+ const lacksClearance = clearanceProbe(graph, pair.sourceId, pair.targetId, starts.map(p=>p.point), ends.map(p=>p.point));
178
+ const overlapsBoundary = (a, b) => lacksClearance(a,b) || borders.some(([c, d]) => sharedSegmentLength(a, b, c, d) > limits.endpoint);
179
+ // Crossing costs only add, so a path whose length and bends already reach `ceiling` cannot win and is not checked.
180
+ const clearCost = (points, ceiling = Infinity) => {
181
+ if (points.length < 2) return Infinity;
182
+ const first = points[0], second = points[1], last = points.at(-1), penultimate = points.at(-2);
183
+ const outward = (a,b,side) => (b.x-a.x)*vector[side][0]+(b.y-a.y)*vector[side][1] > 0;
184
+ if (!outward(first,second,pair.sourceSide) || !outward(last,penultimate,pair.targetSide)) return Infinity;
185
+ if (points.length > 2 && (distance(first,second) < limits.endpoint || distance(last,penultimate) < limits.endpoint)) return Infinity;
186
+ let cost = parts(points).reduce((sum, [a, b]) => sum + distance(a, b), 0) + Math.max(0, points.length - 2) * limits.bendCost;
187
+ if (cost >= ceiling) return Infinity;
188
+ for (const [a, b] of parts(points)) {
189
+ if (crossesNode(a, b) || obstacles.some(r => segmentCrossesBox(a, b, r)) || overlapsBoundary(a, b)) return Infinity;
190
+ cost = conflictWith(a, b, cost, false);
191
+ if (cost === Infinity) return Infinity;
192
+ }
193
+ cost += previous.reduce((sum, prior) => sum + pathCrossings(points, prior), 0) * limits.crossingCost;
194
+ if (label && !inlineLabelCandidates(points, labelSize, labelNodes, obstacles, labelLines, limits).length) return Infinity;
195
+ return cost;
196
+ };
197
+ const extent = [...graph.nodes.map(n => occupiedBox(n, type)), ...obstacles, ...previous.flat().map(p => ({ ...p, width: 0, height: 0 }))];
198
+ const xGap = Math.max(limits.preferredClearance, labelSize.width / 2 + limits.labelGap + 4), yGap = Math.max(limits.preferredClearance, labelSize.height / 2 + limits.labelGap + 4);
199
+ const left = Math.min(...extent.map(r => r.x)) - xGap, right = Math.max(...extent.map(r => r.x + r.width)) + xGap;
200
+ const top = Math.min(...extent.map(r => r.y)) - yGap, bottom = Math.max(...extent.map(r => r.y + r.height)) + yGap;
201
+ let fast;
202
+ for (const start of starts) for (const end of ends) {
203
+ const a = start.stub, b = end.stub;
204
+ const candidates = [
205
+ [a, { x: b.x, y: a.y }, b], [a, { x: a.x, y: b.y }, b],
206
+ [a, { x: (a.x + b.x) / 2, y: a.y }, { x: (a.x + b.x) / 2, y: b.y }, b],
207
+ [a, { x: a.x, y: (a.y + b.y) / 2 }, { x: b.x, y: (a.y + b.y) / 2 }, b],
208
+ ...[left, right].map(x => [a, { x, y: a.y }, { x, y: b.y }, b]),
209
+ ...[top, bottom].map(y => [a, { x: a.x, y }, { x: b.x, y }, b])
210
+ ];
211
+ if ((start.point.x === end.point.x || start.point.y === end.point.y) && distance(start.point, end.point) >= (getDiagram(type).endpointStub ?? limits.endpoint)) {
212
+ const middle = { x: (start.point.x + end.point.x) / 2, y: (start.point.y + end.point.y) / 2 };
213
+ // Collinear facing anchors need no overlapping stubs in a narrow grid gap.
214
+ if (vector[start.side][0] * (end.point.x - start.point.x) + vector[start.side][1] * (end.point.y - start.point.y) > 0
215
+ && vector[end.side][0] * (start.point.x - end.point.x) + vector[end.side][1] * (start.point.y - end.point.y) > 0) candidates.push([middle]);
216
+ }
217
+ for (const via of candidates) {
218
+ const points = simplifyPath([start.point, ...via, end.point]), cost = clearCost(points, Math.min(ceiling, fast?.cost ?? Infinity));
219
+ if (Number.isFinite(cost) && (!fast || cost < fast.cost)) fast = { cost, points, via: simplifyPath(via), sourceSide: start.side, targetSide: end.side };
220
+ }
221
+ }
222
+ // Clear short corridors need no visibility-grid search. Crossed or blocked corridors still search alternatives.
223
+ if (fast && fast.cost <= pair.nearest + pair.minimumBends * limits.bendCost + .001) return fast;
224
+ if (budget.remaining <= 0) return fast ?? null;
225
+ const boxes = graph.nodes.map(n => occupiedBox(n, type));
226
+ const xs = new Set([...starts, ...ends].map(p => p.stub.x)), ys = new Set([...starts, ...ends].map(p => p.stub.y));
227
+ for (const r of [...boxes, ...obstacles, ...previous.flatMap(p => parts(p).map(([a, b]) => ({ x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), width: Math.abs(a.x - b.x), height: Math.abs(a.y - b.y) })))]) {
228
+ xs.add(r.x - limits.preferredClearance); xs.add(r.x + r.width + limits.preferredClearance);
229
+ ys.add(r.y - limits.preferredClearance); ys.add(r.y + r.height + limits.preferredClearance);
230
+ }
231
+ const xValues = [...xs].sort((a, b) => a - b), yValues = [...ys].sort((a, b) => a - b), nx = xValues.length;
232
+ const position = id => ({ x: xValues[id % nx], y: yValues[Math.floor(id / nx)] });
233
+ const pointId = p => yValues.indexOf(p.y) * nx + xValues.indexOf(p.x);
234
+ const cache = new Map();
235
+ const segmentCost = (a, b) => {
236
+ const ka = key(a), kb = key(b), k = ka < kb ? ka + ':' + kb : kb + ':' + ka;
237
+ if (cache.has(k)) return cache.get(k);
238
+ let cost = Infinity;
239
+ if (!crossesNode(a, b) && !obstacles.some(r => segmentCrossesBox(a, b, r)) && !overlapsBoundary(a, b)) cost = conflictWith(a, b, distance(a, b));
240
+ cache.set(k, cost); return cost;
241
+ };
242
+ const heuristic = p => Math.min(...ends.map(end => distance(p, end.stub)));
243
+ const heap = new Heap(), visited = new Map(), endMap = new Map(); let serial = 0;
244
+ for (const end of ends) { const id = pointId(end.stub); if (!endMap.has(id)) endMap.set(id, []); endMap.get(id).push(end); }
245
+ for (const start of starts) {
246
+ if (obstacles.some(r => segmentCrossesBox(start.point, start.stub, r)) || !Number.isFinite(segmentCost(start.point, start.stub))) continue;
247
+ const id = pointId(start.stub), direction = ['left', 'right'].includes(start.side) ? 0 : 1;
248
+ const cost = distance(start.point, start.stub);
249
+ const item = { id, direction, cost, priority: cost + heuristic(start.stub), start, serial: serial++ };
250
+ const k = id * 2 + direction;
251
+ if (!visited.has(k) || visited.get(k).cost > item.cost) { visited.set(k, item); heap.push(item); }
252
+ }
253
+ let localVisits = 0;
254
+ while (heap.items.length && budget.remaining > 0 && localVisits++ < 4000) {
255
+ budget.remaining--;
256
+ const here = heap.pop(); if (visited.get(here.id * 2 + here.direction) !== here) continue;
257
+ if (here.priority >= Math.min(ceiling, fast?.cost ?? Infinity)) break;
258
+ const p = position(here.id);
259
+ for (const end of endMap.get(here.id) ?? []) {
260
+ if (!Number.isFinite(segmentCost(end.stub, end.point))) continue;
261
+ const path = []; for (let h = here; h; h = h.before) path.push(position(h.id)); path.reverse();
262
+ const complete = simplifyCorridor([here.start.point, ...path, end.point], clearCost);
263
+ if (label && !inlineLabelCandidates(complete, labelSize, labelNodes, obstacles, labelLines, limits).length) continue;
264
+ const cost = clearCost(complete);
265
+ if (!Number.isFinite(cost)) continue;
266
+ if (fast && fast.cost <= cost) return fast;
267
+ return { points: complete, via: complete.length > 2 ? complete.slice(1,-1) : [here.start.stub, end.stub], sourceSide: here.start.side, targetSide: end.side, cost };
268
+ }
269
+ const x = here.id % nx, y = Math.floor(here.id / nx);
270
+ for (const [xx, yy] of [[x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]]) {
271
+ if (xx < 0 || yy < 0 || xx >= nx || yy >= yValues.length) continue;
272
+ const id = yy * nx + xx, q = position(id), step = segmentCost(p, q);
273
+ if (!Number.isFinite(step)) continue;
274
+ const direction = x === xx ? 1 : 0, cost = here.cost + step + (direction === here.direction ? 0 : limits.bendCost), k = id * 2 + direction;
275
+ if (visited.has(k) && visited.get(k).cost <= cost) continue;
276
+ const item = { id, direction, cost, priority: cost + heuristic(q), before: here, start: here.start, serial: serial++ }; visited.set(k, item); heap.push(item);
277
+ }
278
+ }
279
+ return fast ?? null;
280
+ }
281
+
282
+ // Search only along an edge's own straight segments, with complete text and endpoint clearance.
283
+ function inlineLabelCandidates(points, size, nodeBoxes, textObstacles, otherParts, limits) {
284
+ const candidates = [];
285
+ for (const [index, [a, b]] of parts(points).entries()) {
286
+ const horizontal = a.y === b.y, axis = horizontal ? 'x' : 'y', extent = horizontal ? 'width' : 'height';
287
+ const half = size[extent] / 2, lower = Math.min(a[axis], b[axis]) + half + 4, upper = Math.max(a[axis], b[axis]) - half - 4;
288
+ if (lower > upper) continue;
289
+ const middle = (a[axis] + b[axis]) / 2;
290
+ const positions = [...new Set([middle, lower, upper, ...[...nodeBoxes, ...textObstacles].flatMap(r => [
291
+ r[axis] - half - limits.labelGap - .01, r[axis] + r[extent] + half + limits.labelGap + .01
292
+ ]), ...otherParts.flatMap(([c, d]) => [
293
+ Math.min(c[axis], d[axis]) - half - limits.labelEdgeGap - .01,
294
+ Math.max(c[axis], d[axis]) + half + limits.labelEdgeGap + .01
295
+ ])])].filter(value => value >= lower && value <= upper);
296
+ for (const value of positions) {
297
+ const point = { ...a, [axis]: value };
298
+ if (point.x < 0 || point.y < 0) continue;
299
+ const r = { x: point.x - size.width / 2, y: point.y - size.height / 2, ...size };
300
+ if ([...nodeBoxes, ...textObstacles].some(o => overlap(r, expand(o, limits.labelGap)))) continue;
301
+ if (otherParts.some(([c, d]) => segmentCrossesBox(c, d, expand(r, limits.labelEdgeGap)))) continue;
302
+ if ([points[0], points.at(-1)].some(tip => overlap(r, { x: tip.x - 12, y: tip.y - 12, width: 24, height: 24 }))) continue;
303
+ candidates.push({ point, r, score: Math.abs(value - middle) + index * .001 });
304
+ }
305
+ }
306
+ return candidates.sort((a, b) => a.score - b.score);
307
+ }
308
+
309
+ export function placeEdgeLabels(graph, edgeIds = null) {
310
+ const type = diagramTypeOf(graph), limits = routingLimits(graph), routes = createEdgeRoutes(graph);
311
+ const labels = edgeIds ? [...routes].filter(([id]) => !edgeIds.has(id)).flatMap(([, r]) => r.label ? [r.labelBox] : []) : [];
312
+ const borders = (graph.groups ?? []).flatMap(group => {
313
+ const r = box(group);
314
+ return [{ x: r.x, y: r.y, width: r.width, height: 1 }, { x: r.x, y: r.y + r.height, width: r.width, height: 1 },
315
+ { x: r.x, y: r.y, width: 1, height: r.height }, { x: r.x + r.width, y: r.y, width: 1, height: r.height }];
316
+ });
317
+ const headings = [...borders, ...(graph.groups ?? []).flatMap(groupHeadingBoxes), ...overviewSections(graph).flatMap(sectionTextBoxes)];
318
+ const nodeBoxes = graph.nodes.map(n => occupiedBox(n, type));
319
+ for (const edge of [...graph.edges].filter(e => !edgeIds || edgeIds.has(e.id)).sort(stable)) {
320
+ const route = routes.get(edge.id); if (!route.label) continue;
321
+ const otherParts = [...routes].filter(([id]) => id !== edge.id).flatMap(([, other]) => parts(other.points));
322
+ const best = inlineLabelCandidates(route.points, estimateLabelSize(route.label), nodeBoxes, [...headings, ...labels], otherParts, limits)[0];
323
+ if (!best) throw Object.assign(new Error('No clear label position for ' + edge.id), { elementIds: [edge.id, edge.source, edge.target], routingReason: 'label-space' });
324
+ edge.route = { ...edge.route, labelAt: best.point }; labels.push(best.r);
325
+ }
326
+ return graph;
327
+ }
328
+
329
+ function selfCorridor(node, graph, obstacles, previous, limits, ordinal, edge, sideOffset = 0) {
330
+ const type = diagramTypeOf(graph), label = estimateLabelSize(visibleEdgeLabel(edge, type, graph.meta.locale));
331
+ for (const [sideIndex, side] of ['right', 'bottom', 'left', 'top'].entries()) {
332
+ if (sideIndex < sideOffset) continue;
333
+ const horizontal = ['left', 'right'].includes(side), span = horizontal ? node.size.height : node.size.width;
334
+ const offset = Math.min(span / 2 - 12, Math.max(24, (horizontal ? label.height : label.width) / 2 + 8));
335
+ const a = nodeAnchor(node, side, -offset, type), b = nodeAnchor(node, side, offset, type), [dx, dy] = vector[side];
336
+ const reach = Math.max(48, (horizontal ? label.width : label.height) / 2 + limits.labelGap + 8) + ordinal * limits.parallelGap;
337
+ const points = [a, { x: a.x + dx * reach, y: a.y + dy * reach }, { x: b.x + dx * reach, y: b.y + dy * reach }, b];
338
+ const lacksClearance = clearanceProbe(graph,node.id,node.id,[a],[b]);
339
+ if (parts(points).some(([c, d]) => lacksClearance(c,d) || graph.nodes.some(n => n.id !== node.id && segmentCrossesNode(c, d, n, type)) || obstacles.some(r => segmentCrossesBox(c, d, r)) || previous.flatMap(parts).some(([e, f]) => lineConflict(c, d, e, f, limits.parallelGap) === Infinity))) continue;
340
+ return { via: points.slice(1, -1), sideIndex };
341
+ }
342
+ return null;
343
+ }
344
+
345
+ function routingCost(graph) {
346
+ const limits = routingLimits(graph);
347
+ const routes = [...createEdgeRoutes(graph).values()], length = routes.reduce((sum, r) => sum + parts(r.points).reduce((s, [a, b]) => s + distance(a, b), 0), 0);
348
+ let crossings = 0;
349
+ for (let i = 0; i < routes.length; i++) for (const other of routes.slice(i + 1)) crossings += pathCrossings(routes[i].points, other.points);
350
+ return length + routes.reduce((sum, r) => sum + Math.max(0, r.points.length - 2) * limits.bendCost, 0) + crossings * limits.crossingCost;
351
+ }
352
+
353
+ export function routeOrthogonal(input, { passes = 3, accept = null, edgeIds = null, searchBudget = null } = {}) {
354
+ const type = diagramTypeOf(input);
355
+ // Sequence messages have fixed time/order, activation endpoints and fragment scopes.
356
+ if (getDiagram(type).sequence) {
357
+ const graph = structuredClone(input), routes = createEdgeRoutes(graph), heads = graph.nodes.map(n => occupiedBox(n, type));
358
+ const firstLabel = Math.min(Infinity, ...[...routes.values()].filter(r => r.label).map(r => r.labelBox.y));
359
+ const shift = Math.max(0, Math.max(...heads.map(r => r.y + r.height)) + layoutLimits(getDiagram(type)).labelGap - firstLabel);
360
+ if (shift) {
361
+ for (const edge of graph.edges) {
362
+ edge.route = { ...edge.route, messageY: routes.get(edge.id).points[0].y + shift };
363
+ if (edge.route.via) edge.route.via = edge.route.via.map(p => ({ ...p, y: p.y + shift }));
364
+ if (edge.route.labelAt) edge.route.labelAt = { ...edge.route.labelAt, y: edge.route.labelAt.y + shift };
365
+ }
366
+ for (const group of graph.groups ?? []) group.position.y += shift;
367
+ for (const node of graph.nodes) node.size.height += shift;
368
+ }
369
+ if (accept) accept(graph);
370
+ return { graph, report: { strategy: 'sequence-span-constraints', evaluations: 1, headerClearanceShift: shift, termination: 'notation-specific' } };
371
+ }
372
+ const limits = routingLimits(input), initial = createEdgeRoutes(input);
373
+ const frozen = edgeIds ? [...initial].filter(([id]) => !edgeIds.has(id)).map(([, r]) => r) : [];
374
+ const headings = [...frozen.filter(r => r.label).map(r => expand(r.labelBox, limits.labelEdgeGap)), ...(input.groups ?? []).flatMap(groupHeadingBoxes), ...overviewSections(input).flatMap(sectionTextBoxes)].map(r => expand(r, 8));
375
+ const primary = input.layout?.primaryPath ?? [];
376
+ const span = edge => {
377
+ const a = input.nodes.find(n => n.id === edge.source), b = input.nodes.find(n => n.id === edge.target);
378
+ return Math.abs(a.position.x + a.size.width / 2 - b.position.x - b.size.width / 2) + Math.abs(a.position.y + a.size.height / 2 - b.position.y - b.size.height / 2);
379
+ };
380
+ const ordered = [...input.edges].filter(e => !edgeIds || edgeIds.has(e.id)).sort((a, b) => {
381
+ const main = e => primary.some((id, i) => id === e.source && primary[i + 1] === e.target);
382
+ const constrained = e => ['decision', 'choice'].includes(input.nodes.find(n => n.id === e.source)?.kind);
383
+ return Number(main(b)) - Number(main(a)) || Number(constrained(b)) - Number(constrained(a)) || span(a) - span(b) || stable(a, b);
384
+ });
385
+ let best, last; const attempts = [];
386
+ const budgetLimit = ROUTING_BUDGET * Math.min(4, Math.max(1, Math.ceil(ordered.length / 16)));
387
+ // An already legal route is also a candidate. This prevents a greedy retry from losing a
388
+ // narrow but valid labelled corridor and gives the final pass a stable starting geometry.
389
+ if (accept) try {
390
+ accept(input, { strictEdgeIds: new Set(ordered.map(edge => edge.id)) });
391
+ best = { graph: structuredClone(input), cost: routingCost(input), pass: 'preserved' };
392
+ } catch { /* Search must repair the invalid input. */ }
393
+ for (let pass = 0; pass < passes; pass++) {
394
+ const pairOffsets = new Map(), budget = searchBudget ?? { remaining: budgetLimit };
395
+ // Revisit ports against all other completed paths, not just the earlier greedy paths.
396
+ // This uses the third pass's existing grid budget and one stable sweep, never extra retries.
397
+ if (pass === 2 && best) {
398
+ const routes = createEdgeRoutes(best.graph), complexity = edge => {
399
+ const route = routes.get(edge.id);
400
+ const crossings = [...routes].filter(([id]) => id !== edge.id).reduce((sum, [,other]) => sum + pathCrossings(route.points, other.points), 0);
401
+ return crossings * layoutWeights.crossings + Math.max(0, route.points.length - 2) * layoutWeights.bends;
402
+ };
403
+ const revisit = [...ordered].sort((a,b) => complexity(b) - complexity(a) || stable(a,b));
404
+ for (const [index, edge] of revisit.entries()) {
405
+ if (budget.remaining <= 0) break;
406
+ const quota = Math.ceil(budget.remaining / (revisit.length - index)), edgeBudget = { remaining: quota };
407
+ try {
408
+ const result = routeOrthogonal(best.graph, { passes: 1, accept, edgeIds: new Set([edge.id]), searchBudget: edgeBudget });
409
+ const cost = routingCost(result.graph);
410
+ attempts.push({ pass, edgeId: edge.id, cost, visited: quota - edgeBudget.remaining });
411
+ if (cost < best.cost - .001) best = { graph: result.graph, cost, pass };
412
+ } catch (error) { attempts.push({ pass, edgeId: edge.id, reason: error.routingReason ?? 'quality', visited: quota - edgeBudget.remaining }); }
413
+ budget.remaining -= quota - edgeBudget.remaining;
414
+ }
415
+ break;
416
+ }
417
+ let order = pass === 0 ? ordered : pass === 1 ? [...ordered].reverse() : [...ordered].sort(stable);
418
+ for (let retry = 0; retry < 16; retry++) {
419
+ const graph = structuredClone(input), previous = frozen.map(r => r.points), fallbacks = [], selectedPairs = new Map();
420
+ const selectedLabels = [], completed = new Set(edgeIds ? [...initial.keys()].filter(id => !edgeIds.has(id)) : []);
421
+ const labelEdge = edge => {
422
+ completed.add(edge.id);
423
+ placeEdgeLabels({ ...graph, edges: graph.edges.filter(item => completed.has(item.id)) }, new Set([edge.id]));
424
+ const route = createEdgeRoutes(graph).get(edge.id);
425
+ if (route.label) selectedLabels.push(expand(route.labelBox, limits.labelEdgeGap));
426
+ previous.push(route.points);
427
+ };
428
+ try {
429
+ for (const item of order) {
430
+ const edge = graph.edges.find(e => e.id === item.id), source = graph.nodes.find(n => n.id === edge.source), target = graph.nodes.find(n => n.id === edge.target);
431
+ if (source === target) {
432
+ const ordinal = [...graph.edges].filter(e => e.source === source.id && e.target === source.id).sort(stable).findIndex(e => e.id === edge.id);
433
+ const loop = selfCorridor(source, graph, [...headings, ...selectedLabels], previous, limits, ordinal, edge, pairOffsets.get(edge.id) ?? 0);
434
+ if (!loop) throw Object.assign(new Error('No clear self corridor for ' + edge.id), { elementIds: [edge.id], routingReason: 'corridor-space' });
435
+ selectedPairs.set(edge.id, loop.sideIndex);
436
+ edge.route = { via: loop.via }; labelEdge(edge); continue;
437
+ }
438
+ const sourceSide = ['decision', 'choice'].includes(source.kind) && graph.edges.filter(e => e.source === source.id && e.target !== source.id).length >= 2 ? initial.get(edge.id).sourceSide : null;
439
+ const pairs = nearestPortPairs(source, target, graph, sourceSide, edge); let chosen, chosenIndex;
440
+ for (let i = pairOffsets.get(edge.id) ?? 0; i < pairs.length; i++) {
441
+ if (chosen && pairs[i].nearest + pairs[i].minimumBends * limits.bendCost >= chosen.cost - .001) continue;
442
+ const route = corridor(pairs[i], graph, [...headings, ...selectedLabels], previous, limits, budget, visibleEdgeLabel(edge, type, graph.meta.locale), chosen?.cost);
443
+ if (route) {
444
+ const cost = r => r.cost;
445
+ if (!chosen || cost(route) < cost(chosen)) { chosen = route; chosenIndex = i; }
446
+ if (cost(chosen) <= (pairs[i + 1] ? pairs[i+1].nearest+pairs[i+1].minimumBends*limits.bendCost : Infinity) || budget.remaining <= 0) break;
447
+ }
448
+ }
449
+ if (!chosen) throw Object.assign(new Error('No clear orthogonal corridor for ' + edge.id), { elementIds: [edge.id, edge.source, edge.target], routingReason: budget.remaining <= 0 ? 'evaluation-budget' : 'corridor-space' });
450
+ selectedPairs.set(edge.id, chosenIndex);
451
+ if (chosenIndex) fallbacks.push({ edgeId: edge.id, nearest: pairs[0].nearest, selected: pairs[chosenIndex].nearest, reason: 'minimum-cost-clear-corridor' });
452
+ edge.route = { ...edge.route, via: chosen.via }; delete edge.route.labelAt;
453
+ labelEdge(edge);
454
+ }
455
+ if (accept) accept(graph, { strictEdgeIds: new Set(order.map(edge => edge.id)) });
456
+ const cost = routingCost(graph); attempts.push({ pass, cost, fallbacks, visited: budgetLimit - budget.remaining });
457
+ if (!best || cost < best.cost) best = { graph, cost, pass };
458
+ if (!graph.edges.length || passes === 1) break;
459
+ } catch (error) {
460
+ last = error; attempts.push({ pass, retry, reason: error.routingReason ?? 'quality', elementIds: error.elementIds ?? [], visited: budgetLimit - budget.remaining });
461
+ if (error.routingReason === 'corridor-space' && budget.remaining > 0) {
462
+ const id = error.elementIds[0], index = order.findIndex(edge => edge.id === id);
463
+ if (index > 0) { order = [order[index], ...order.slice(0, index), ...order.slice(index + 1)]; continue; }
464
+ }
465
+ if (error.routingReason === 'label-space') {
466
+ const id = error.elementIds[0]; pairOffsets.set(id, (selectedPairs.get(id) ?? pairOffsets.get(id) ?? 0) + 1);
467
+ if (budget.remaining > 0) continue;
468
+ }
469
+ }
470
+ break;
471
+ }
472
+ }
473
+ if (!best) throw Object.assign(last ?? new Error('No routing candidate'), { routingReport: { attempts, termination: 'no-valid-candidate', provenImpossible: false } });
474
+ return { graph: best.graph, report: { strategy: 'nearest-outline-orthogonal', budget: budgetLimit, evaluations: attempts.length, selected: best.pass, attempts, termination: 'bounded-search', provenImpossible: false } };
475
+ }
@@ -0,0 +1,31 @@
1
+ import { isArchitectureOverview } from './view-identity.js';
2
+
3
+ // Presentation categories do not change ownership or remove evidence from the saved model.
4
+ export function overviewCategoryMap(graph) {
5
+ const categories = new Map(), groups = graph.groups ?? [];
6
+ const groupNodes = (id, seen = new Set()) => {
7
+ if (seen.has(id)) return [];
8
+ const next = new Set([...seen, id]);
9
+ return [...graph.nodes.filter(node => node.groupId === id).map(node => node.id), ...groups.filter(group => group.parentId === id).flatMap(group => groupNodes(group.id, next))];
10
+ };
11
+ const visit = section => {
12
+ for (const item of section.items ?? []) {
13
+ if (item.mode) visit(item);
14
+ else for (const id of item.nodeId ? [item.nodeId] : groupNodes(item.groupId)) categories.set(id, section.id);
15
+ }
16
+ };
17
+ (graph.layout?.sections ?? []).forEach(visit);
18
+ return categories;
19
+ }
20
+ export function visibleEdges(graph) {
21
+ if (!isArchitectureOverview(graph) || graph.layout?.overviewConnections === 'all') return graph.edges;
22
+ const categories = overviewCategoryMap(graph);
23
+ return graph.edges.filter(edge => categories.has(edge.source) && categories.get(edge.source) === categories.get(edge.target));
24
+ }
25
+ export const presentationGraph = graph => ({ ...graph, edges: visibleEdges(graph) });
26
+
27
+ // Reinstall routed visible edges in their original semantic order; hidden facts remain available in details and JSON.
28
+ export function mergePresentationEdges(graph, routed) {
29
+ const byId = new Map(routed.edges.map(edge => [edge.id, edge]));
30
+ return graph.edges.map(edge => byId.get(edge.id) ?? edge);
31
+ }