@coldtea/pr-lens-renderer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/dist/bounds.d.ts +23 -0
  4. package/dist/bounds.d.ts.map +1 -0
  5. package/dist/bounds.js +39 -0
  6. package/dist/bounds.js.map +1 -0
  7. package/dist/corrections.d.ts +12 -0
  8. package/dist/corrections.d.ts.map +1 -0
  9. package/dist/corrections.js +122 -0
  10. package/dist/corrections.js.map +1 -0
  11. package/dist/design.d.ts +112 -0
  12. package/dist/design.d.ts.map +1 -0
  13. package/dist/design.js +112 -0
  14. package/dist/design.js.map +1 -0
  15. package/dist/errors.d.ts +11 -0
  16. package/dist/errors.d.ts.map +1 -0
  17. package/dist/errors.js +9 -0
  18. package/dist/errors.js.map +1 -0
  19. package/dist/geometry.d.ts +20 -0
  20. package/dist/geometry.d.ts.map +1 -0
  21. package/dist/geometry.js +15 -0
  22. package/dist/geometry.js.map +1 -0
  23. package/dist/glob.d.ts +7 -0
  24. package/dist/glob.d.ts.map +1 -0
  25. package/dist/glob.js +17 -0
  26. package/dist/glob.js.map +1 -0
  27. package/dist/index.d.ts +8 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +8 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/layout/architecture.d.ts +78 -0
  32. package/dist/layout/architecture.d.ts.map +1 -0
  33. package/dist/layout/architecture.js +207 -0
  34. package/dist/layout/architecture.js.map +1 -0
  35. package/dist/layout/congestion.d.ts +18 -0
  36. package/dist/layout/congestion.d.ts.map +1 -0
  37. package/dist/layout/congestion.js +50 -0
  38. package/dist/layout/congestion.js.map +1 -0
  39. package/dist/layout/dataflow.d.ts +85 -0
  40. package/dist/layout/dataflow.d.ts.map +1 -0
  41. package/dist/layout/dataflow.js +184 -0
  42. package/dist/layout/dataflow.js.map +1 -0
  43. package/dist/layout/edges.d.ts +48 -0
  44. package/dist/layout/edges.d.ts.map +1 -0
  45. package/dist/layout/edges.js +843 -0
  46. package/dist/layout/edges.js.map +1 -0
  47. package/dist/layout/labels.d.ts +9 -0
  48. package/dist/layout/labels.d.ts.map +1 -0
  49. package/dist/layout/labels.js +149 -0
  50. package/dist/layout/labels.js.map +1 -0
  51. package/dist/layout/rank.d.ts +16 -0
  52. package/dist/layout/rank.d.ts.map +1 -0
  53. package/dist/layout/rank.js +80 -0
  54. package/dist/layout/rank.js.map +1 -0
  55. package/dist/layout/seating.d.ts +29 -0
  56. package/dist/layout/seating.d.ts.map +1 -0
  57. package/dist/layout/seating.js +131 -0
  58. package/dist/layout/seating.js.map +1 -0
  59. package/dist/manifest.d.ts +39 -0
  60. package/dist/manifest.d.ts.map +1 -0
  61. package/dist/manifest.js +99 -0
  62. package/dist/manifest.js.map +1 -0
  63. package/dist/render.d.ts +49 -0
  64. package/dist/render.d.ts.map +1 -0
  65. package/dist/render.js +125 -0
  66. package/dist/render.js.map +1 -0
  67. package/dist/scope.d.ts +21 -0
  68. package/dist/scope.d.ts.map +1 -0
  69. package/dist/scope.js +57 -0
  70. package/dist/scope.js.map +1 -0
  71. package/dist/svg/architecture.d.ts +15 -0
  72. package/dist/svg/architecture.d.ts.map +1 -0
  73. package/dist/svg/architecture.js +197 -0
  74. package/dist/svg/architecture.js.map +1 -0
  75. package/dist/svg/dataflow.d.ts +9 -0
  76. package/dist/svg/dataflow.d.ts.map +1 -0
  77. package/dist/svg/dataflow.js +284 -0
  78. package/dist/svg/dataflow.js.map +1 -0
  79. package/dist/svg/document.d.ts +37 -0
  80. package/dist/svg/document.d.ts.map +1 -0
  81. package/dist/svg/document.js +148 -0
  82. package/dist/svg/document.js.map +1 -0
  83. package/dist/svg/icons.d.ts +3 -0
  84. package/dist/svg/icons.d.ts.map +1 -0
  85. package/dist/svg/icons.js +106 -0
  86. package/dist/svg/icons.js.map +1 -0
  87. package/dist/svg/primitives.d.ts +13 -0
  88. package/dist/svg/primitives.d.ts.map +1 -0
  89. package/dist/svg/primitives.js +34 -0
  90. package/dist/svg/primitives.js.map +1 -0
  91. package/dist/svg/pulse.d.ts +24 -0
  92. package/dist/svg/pulse.d.ts.map +1 -0
  93. package/dist/svg/pulse.js +33 -0
  94. package/dist/svg/pulse.js.map +1 -0
  95. package/dist/text.d.ts +18 -0
  96. package/dist/text.d.ts.map +1 -0
  97. package/dist/text.js +70 -0
  98. package/dist/text.js.map +1 -0
  99. package/dist/theme.d.ts +44 -0
  100. package/dist/theme.d.ts.map +1 -0
  101. package/dist/theme.js +72 -0
  102. package/dist/theme.js.map +1 -0
  103. package/dist/version.d.ts +9 -0
  104. package/dist/version.d.ts.map +1 -0
  105. package/dist/version.js +9 -0
  106. package/dist/version.js.map +1 -0
  107. package/package.json +56 -0
  108. package/src/bounds.ts +50 -0
  109. package/src/corrections.ts +166 -0
  110. package/src/design.ts +124 -0
  111. package/src/errors.ts +21 -0
  112. package/src/geometry.ts +22 -0
  113. package/src/glob.ts +20 -0
  114. package/src/index.ts +30 -0
  115. package/src/layout/architecture.ts +324 -0
  116. package/src/layout/congestion.ts +76 -0
  117. package/src/layout/dataflow.ts +307 -0
  118. package/src/layout/edges.ts +1061 -0
  119. package/src/layout/labels.ts +192 -0
  120. package/src/layout/rank.ts +95 -0
  121. package/src/layout/seating.ts +189 -0
  122. package/src/manifest.ts +118 -0
  123. package/src/render.ts +190 -0
  124. package/src/scope.ts +67 -0
  125. package/src/svg/architecture.ts +293 -0
  126. package/src/svg/dataflow.ts +408 -0
  127. package/src/svg/document.ts +198 -0
  128. package/src/svg/icons.ts +121 -0
  129. package/src/svg/primitives.ts +50 -0
  130. package/src/svg/pulse.ts +48 -0
  131. package/src/text.ts +92 -0
  132. package/src/theme.ts +107 -0
  133. package/src/version.ts +9 -0
@@ -0,0 +1,843 @@
1
+ import { assertNever } from "@coldtea/pr-lens-schema";
2
+ import { BEND_RADIUS_MAX, PORT_INSET, PORT_PITCH, TRACK_CLEARANCE, TRACK_PITCH_MAX, } from "../design.js";
3
+ import { boxCentre, coord } from "../geometry.js";
4
+ const segmentStart = (curve, index) => index === 0 ? curve.from : (curve.segments[index - 1]?.to ?? curve.from);
5
+ const pointOnSegment = (from, segment, t) => {
6
+ switch (segment.kind) {
7
+ case "line":
8
+ return { x: from.x + (segment.to.x - from.x) * t, y: from.y + (segment.to.y - from.y) * t };
9
+ case "cubic": {
10
+ const inverse = 1 - t;
11
+ const weights = [inverse ** 3, 3 * inverse ** 2 * t, 3 * inverse * t ** 2, t ** 3];
12
+ const points = [from, segment.first, segment.second, segment.to];
13
+ return {
14
+ x: points.reduce((sum, point, index) => sum + point.x * (weights[index] ?? 0), 0),
15
+ y: points.reduce((sum, point, index) => sum + point.y * (weights[index] ?? 0), 0),
16
+ };
17
+ }
18
+ default:
19
+ return assertNever(segment, "Unhandled path segment");
20
+ }
21
+ };
22
+ /** A point at `t` of the whole route, with every segment weighted equally. */
23
+ const pointAt = (curve, t) => {
24
+ const count = curve.segments.length;
25
+ const scaled = Math.min(Math.max(t, 0), 1) * count;
26
+ const index = Math.min(Math.floor(scaled), count - 1);
27
+ const segment = curve.segments[index];
28
+ if (segment === undefined)
29
+ return curve.from;
30
+ return pointOnSegment(segmentStart(curve, index), segment, scaled - index);
31
+ };
32
+ const pathOf = (curve) => `M${coord(curve.from.x)},${coord(curve.from.y)}` +
33
+ curve.segments
34
+ .map((segment) => {
35
+ switch (segment.kind) {
36
+ case "line":
37
+ return ` L${coord(segment.to.x)},${coord(segment.to.y)}`;
38
+ case "cubic":
39
+ return (` C${coord(segment.first.x)},${coord(segment.first.y)}` +
40
+ ` ${coord(segment.second.x)},${coord(segment.second.y)}` +
41
+ ` ${coord(segment.to.x)},${coord(segment.to.y)}`);
42
+ default:
43
+ return assertNever(segment, "Unhandled path segment");
44
+ }
45
+ })
46
+ .join("");
47
+ /**
48
+ * A box the route cannot leave. A cubic stays inside the hull of its own
49
+ * control points, so taking every point of every segment is a bound rather
50
+ * than an estimate — which is what the canvas needs, since a route that ran
51
+ * off the edge would simply be clipped.
52
+ */
53
+ export const curveBounds = (curve) => {
54
+ const points = [curve.from];
55
+ for (const segment of curve.segments) {
56
+ switch (segment.kind) {
57
+ case "line":
58
+ points.push(segment.to);
59
+ break;
60
+ case "cubic":
61
+ points.push(segment.first, segment.second, segment.to);
62
+ break;
63
+ default:
64
+ assertNever(segment, "Unhandled path segment");
65
+ }
66
+ }
67
+ const xs = points.map((point) => point.x);
68
+ const ys = points.map((point) => point.y);
69
+ const left = Math.min(...xs);
70
+ const top = Math.min(...ys);
71
+ return { x: left, y: top, width: Math.max(...xs) - left, height: Math.max(...ys) - top };
72
+ };
73
+ const SELF_LOOP_REACH = 30;
74
+ const SELF_LOOP_SPREAD = 11;
75
+ /**
76
+ * A node that calls itself gets a loop off its right face. There is no second
77
+ * card to aim at, so the loop is drawn at a fixed size rather than derived
78
+ * from a distance that is zero.
79
+ */
80
+ const selfLoop = (box) => {
81
+ const top = { x: box.x + box.width, y: box.y + box.height / 3 };
82
+ const bottom = { x: box.x + box.width, y: box.y + (box.height * 2) / 3 };
83
+ return {
84
+ from: top,
85
+ segments: [
86
+ {
87
+ kind: "cubic",
88
+ first: { x: top.x + SELF_LOOP_REACH, y: top.y - SELF_LOOP_SPREAD },
89
+ second: { x: bottom.x + SELF_LOOP_REACH, y: bottom.y + SELF_LOOP_SPREAD },
90
+ to: bottom,
91
+ },
92
+ ],
93
+ };
94
+ };
95
+ const isDead = (placed) => placed.node.delta === "removed";
96
+ /**
97
+ * A retired connection with a living end never threads the living graph: it
98
+ * exits past the last lane and comes back. Only a connection wholly between
99
+ * dead cards keeps its short run inside the dead band — that is the internal
100
+ * structure of the retired path.
101
+ */
102
+ const isExiled = (edge, from, to) => !(isDead(from) && isDead(to)) && (edge.delta === "removed" || isDead(from) || isDead(to));
103
+ const clampBand = (index, grid) => Math.min(Math.max(index, 1), grid.rows.length);
104
+ /** The band a route leaves into, or arrives from, on its way up or down. */
105
+ const bandToward = (row, headingDown, grid) => clampBand(headingDown ? row + 1 : row, grid);
106
+ const bandApproaching = (row, fromAbove, grid) => clampBand(fromAbove ? row : row + 1, grid);
107
+ /** Which side faces a pair partner sits against. */
108
+ const blockedFaces = (placed) => {
109
+ const blocked = new Map(placed.map(({ node }) => [node.id, { left: false, right: false }]));
110
+ for (const node of placed)
111
+ for (const other of placed) {
112
+ if (node === other || node.laneIndex !== other.laneIndex || node.row !== other.row)
113
+ continue;
114
+ const entry = blocked.get(node.node.id);
115
+ if (entry === undefined)
116
+ continue;
117
+ if (other.box.x > node.box.x)
118
+ entry.right = true;
119
+ else
120
+ entry.left = true;
121
+ }
122
+ return blocked;
123
+ };
124
+ /**
125
+ * Sides and channels for one route — the whole shape of its journey, decided
126
+ * from lane and row indices alone so that everything downstream (trunks,
127
+ * ports, tracks) has firm ground to stand on.
128
+ */
129
+ const planRoute = (from, to, lanes, grid, blocked) => {
130
+ const dLane = to.laneIndex - from.laneIndex;
131
+ const dRow = to.row - from.row;
132
+ if (dLane === 0 && dRow === 0) {
133
+ const rightward = boxCentre(to.box).x > boxCentre(from.box).x;
134
+ return {
135
+ fromSide: rightward ? "right" : "left",
136
+ toSide: rightward ? "left" : "right",
137
+ channels: [],
138
+ };
139
+ }
140
+ if (dLane === 0 && Math.abs(dRow) === 1)
141
+ return {
142
+ fromSide: dRow > 0 ? "bottom" : "top",
143
+ toSide: dRow > 0 ? "top" : "bottom",
144
+ channels: [{ kind: "band", index: Math.max(from.row, to.row) }],
145
+ };
146
+ if (dLane === 0)
147
+ return planLaneSkip(from, to, lanes, grid, blocked);
148
+ return planCrossLane(from, to, grid, blocked, dLane, dRow);
149
+ };
150
+ /**
151
+ * Down (or up) the corridor beside the route's own lane, past the rows in
152
+ * between: cards fill their lane's width, so a straight drop would vanish
153
+ * behind every one of them. The corridor is picked on the side the endpoints
154
+ * lean toward; a face a pair partner blocks pushes the route to the other
155
+ * side, and a target reachable on neither side is entered from above or
156
+ * below instead.
157
+ */
158
+ const planLaneSkip = (from, to, lanes, grid, blocked) => {
159
+ const laneBox = lanes[from.laneIndex]?.box;
160
+ const laneCentre = laneBox === undefined ? 0 : boxCentre(laneBox).x;
161
+ const lean = (boxCentre(from.box).x + boxCentre(to.box).x) / 2 > laneCentre;
162
+ const free = (placed, side) => !(blocked.get(placed.node.id)?.[side] ?? false);
163
+ const sides = lean ? ["right", "left"] : ["left", "right"];
164
+ for (const side of sides) {
165
+ if (!free(from, side) || !free(to, side))
166
+ continue;
167
+ return {
168
+ fromSide: side,
169
+ toSide: side,
170
+ channels: [{ kind: "corridor", index: from.laneIndex + (side === "right" ? 1 : 0) }],
171
+ };
172
+ }
173
+ // No side serves both cards; leave on the free side and come in over the
174
+ // target's top or bottom instead.
175
+ for (const side of sides) {
176
+ if (!free(from, side))
177
+ continue;
178
+ const fromAbove = to.row > from.row;
179
+ return {
180
+ fromSide: side,
181
+ toSide: fromAbove ? "top" : "bottom",
182
+ channels: [
183
+ { kind: "corridor", index: from.laneIndex + (side === "right" ? 1 : 0) },
184
+ { kind: "band", index: bandApproaching(to.row, fromAbove, grid) },
185
+ ],
186
+ };
187
+ }
188
+ // A pair member always has its outward face free, so both sides blocked
189
+ // cannot happen; the compiler still wants an answer.
190
+ return {
191
+ fromSide: "bottom",
192
+ toSide: "top",
193
+ channels: [{ kind: "band", index: bandToward(from.row, to.row > from.row, grid) }],
194
+ };
195
+ };
196
+ const planCrossLane = (from, to, grid, blocked, dLane, dRow) => {
197
+ const exitSide = dLane > 0 ? "right" : "left";
198
+ const entrySide = dLane > 0 ? "left" : "right";
199
+ const exitBlocked = blocked.get(from.node.id)?.[exitSide] ?? false;
200
+ const entryBlocked = blocked.get(to.node.id)?.[entrySide] ?? false;
201
+ const channels = [];
202
+ let fromSide = exitSide;
203
+ if (exitBlocked) {
204
+ fromSide = dRow < 0 ? "top" : "bottom";
205
+ channels.push({ kind: "band", index: bandToward(from.row, dRow >= 0, grid) });
206
+ }
207
+ channels.push({ kind: "corridor", index: from.laneIndex + (dLane > 0 ? 1 : 0) });
208
+ if (Math.abs(dLane) >= 2) {
209
+ channels.push({ kind: "band", index: bandApproaching(to.row, dRow > 0, grid) });
210
+ channels.push({ kind: "corridor", index: to.laneIndex + (dLane > 0 ? 0 : 1) });
211
+ }
212
+ let toSide = entrySide;
213
+ if (entryBlocked) {
214
+ const fromAbove = dRow > 0 || (dRow === 0 && to.row >= 1);
215
+ toSide = fromAbove ? "top" : "bottom";
216
+ channels.push({ kind: "band", index: bandApproaching(to.row, fromAbove, grid) });
217
+ }
218
+ return { fromSide, toSide, channels };
219
+ };
220
+ /**
221
+ * Out past the last lane, along the exile corridor, and back in — the shape
222
+ * of every retired connection that still touches something alive. Both ends
223
+ * attach to the right face — a route that leaves the graph and returns has
224
+ * no business arriving at whichever face happened to be nearest — except
225
+ * when a pair partner sits against that face: threading the partner would
226
+ * break the one promise this plan exists to keep, so such an end goes over
227
+ * its card's top or bottom into the band instead.
228
+ */
229
+ const planExile = (from, to, grid, laneCount, blocked) => {
230
+ const headingDown = to.row >= from.row;
231
+ const channels = [];
232
+ let fromSide = "right";
233
+ if (blocked.get(from.node.id)?.right ?? false) {
234
+ const band = bandToward(from.row, headingDown, grid);
235
+ fromSide = band <= from.row ? "top" : "bottom";
236
+ channels.push({ kind: "band", index: band });
237
+ }
238
+ else if (from.laneIndex < laneCount - 1) {
239
+ channels.push({ kind: "corridor", index: from.laneIndex + 1 });
240
+ channels.push({ kind: "band", index: bandToward(from.row, headingDown, grid) });
241
+ }
242
+ channels.push({ kind: "corridor", index: laneCount });
243
+ let toSide = "right";
244
+ if (blocked.get(to.node.id)?.right ?? false) {
245
+ const band = bandApproaching(to.row, headingDown, grid);
246
+ toSide = band <= to.row ? "top" : "bottom";
247
+ channels.push({ kind: "band", index: band });
248
+ }
249
+ else if (to.laneIndex < laneCount - 1) {
250
+ channels.push({ kind: "band", index: bandApproaching(to.row, headingDown, grid) });
251
+ channels.push({ kind: "corridor", index: to.laneIndex + 1 });
252
+ }
253
+ return { fromSide, toSide, channels };
254
+ };
255
+ /**
256
+ * Siblings that leave one card in the same direction carrying the same delta
257
+ * share a single departure port. Sharing the port is the whole trick: give
258
+ * each member its own and the group converges before it diverges — a bowtie
259
+ * at the face, which is exactly the junction pinch this design retired. A
260
+ * stem is never mixed-delta — the colour of a line is what it says changed —
261
+ * and it only gathers siblings headed down (or up) the card's own lane: a
262
+ * connection to another lane leaves through a side face and is travelling
263
+ * somewhere else, not fanning out here.
264
+ */
265
+ const trunkKey = (edge, from, to) => {
266
+ if (from.laneIndex !== to.laneIndex)
267
+ return undefined;
268
+ const direction = Math.sign(to.row - from.row);
269
+ return direction === 0 ? undefined : `${edge.from} ${edge.delta} ${direction}`;
270
+ };
271
+ /** A trunk only gathers same-lane siblings, so a member's target is always in its own lane. */
272
+ const planTrunkMember = (from, to, lanes, grid, blocked) => {
273
+ const headingDown = to.row > from.row;
274
+ const home = { kind: "band", index: bandToward(from.row, headingDown, grid) };
275
+ const fromSide = headingDown ? "bottom" : "top";
276
+ if (Math.abs(to.row - from.row) === 1)
277
+ return { fromSide, toSide: headingDown ? "top" : "bottom", channels: [home] };
278
+ const tail = planLaneSkip(from, to, lanes, grid, blocked);
279
+ return { fromSide, toSide: tail.toSide, channels: [home, ...tail.channels] };
280
+ };
281
+ const sideAxis = (side) => {
282
+ switch (side) {
283
+ case "top":
284
+ case "bottom":
285
+ return "x";
286
+ case "left":
287
+ case "right":
288
+ return "y";
289
+ default:
290
+ return assertNever(side, "Unhandled side");
291
+ }
292
+ };
293
+ const faceSpan = (box, side) => {
294
+ switch (side) {
295
+ case "top":
296
+ case "bottom":
297
+ return { from: box.x, to: box.x + box.width };
298
+ case "left":
299
+ case "right":
300
+ return { from: box.y, to: box.y + box.height };
301
+ default:
302
+ return assertNever(side, "Unhandled side");
303
+ }
304
+ };
305
+ const portPoint = (box, side, along) => {
306
+ switch (side) {
307
+ case "top":
308
+ return { x: along, y: box.y };
309
+ case "bottom":
310
+ return { x: along, y: box.y + box.height };
311
+ case "left":
312
+ return { x: box.x, y: along };
313
+ case "right":
314
+ return { x: box.x + box.width, y: along };
315
+ default:
316
+ return assertNever(side, "Unhandled side");
317
+ }
318
+ };
319
+ const corridorSpan = (grid, index) => {
320
+ const corridor = grid.corridors[index] ?? { left: 0, right: 0 };
321
+ return {
322
+ centre: (corridor.left + corridor.right) / 2,
323
+ room: corridor.right - corridor.left - TRACK_CLEARANCE * 2,
324
+ };
325
+ };
326
+ const bandSpan = (grid, index) => {
327
+ const above = grid.rows[index - 1];
328
+ const below = grid.rows[index];
329
+ const top = above === undefined ? 0 : above.top + above.height;
330
+ const bottom = below === undefined ? grid.laneBottom : below.top;
331
+ return { centre: (top + bottom) / 2, room: bottom - top - TRACK_CLEARANCE * 2 };
332
+ };
333
+ const channelSpan = (grid, channel) => {
334
+ switch (channel.kind) {
335
+ case "corridor":
336
+ return corridorSpan(grid, channel.index);
337
+ case "band":
338
+ return bandSpan(grid, channel.index);
339
+ default:
340
+ return assertNever(channel, "Unhandled channel");
341
+ }
342
+ };
343
+ /**
344
+ * Positions spread around a centre. The step divides the room by the traffic
345
+ * rather than growing with it, so a busy gap packs tighter instead of
346
+ * spilling; a quiet one never spreads past the cap.
347
+ */
348
+ const spread = (centre, room, count, cap) => {
349
+ const pitch = count > 1 ? Math.min(cap, room / (count - 1)) : 0;
350
+ return Array.from({ length: count }, (_, index) => centre + (index - (count - 1) / 2) * pitch);
351
+ };
352
+ /**
353
+ * Waypoints for one route: the port, one turn per channel, and a final
354
+ * perpendicular approach to the far port. Consecutive collinear points merge,
355
+ * which is what lets a snapped neighbour pair come out as two points — a
356
+ * dead-straight line — from the same machinery as everything else.
357
+ */
358
+ const buildPoints = (route, fromPort, toPort, trackOf) => {
359
+ const points = [fromPort];
360
+ let x = fromPort.x;
361
+ let y = fromPort.y;
362
+ route.channels.forEach((channel, index) => {
363
+ switch (channel.kind) {
364
+ case "corridor":
365
+ x = trackOf(index);
366
+ break;
367
+ case "band":
368
+ y = trackOf(index);
369
+ break;
370
+ default:
371
+ assertNever(channel, "Unhandled channel");
372
+ }
373
+ points.push({ x, y });
374
+ });
375
+ switch (sideAxis(route.toSide)) {
376
+ case "y":
377
+ points.push({ x, y: toPort.y });
378
+ break;
379
+ case "x":
380
+ points.push({ x: toPort.x, y });
381
+ break;
382
+ }
383
+ points.push(toPort);
384
+ return simplify(points);
385
+ };
386
+ const simplify = (points) => {
387
+ const kept = [];
388
+ for (const point of points) {
389
+ const last = kept[kept.length - 1];
390
+ if (last !== undefined && last.x === point.x && last.y === point.y)
391
+ continue;
392
+ const previous = kept[kept.length - 2];
393
+ if (last !== undefined &&
394
+ previous !== undefined &&
395
+ ((previous.x === last.x && last.x === point.x) ||
396
+ (previous.y === last.y && last.y === point.y)))
397
+ kept.pop();
398
+ kept.push(point);
399
+ }
400
+ return kept;
401
+ };
402
+ const KAPPA = 0.5523;
403
+ /**
404
+ * The polyline as one continuous line: long runs stay dead straight and only
405
+ * the turns curve, each bend sized by the shorter of its two legs. Deriving
406
+ * the radius from the longer leg is the known failure — it balloons a route
407
+ * with one short leg clear out of the corridor the planner put it in.
408
+ */
409
+ const curveThrough = (points) => {
410
+ const first = points[0] ?? { x: 0, y: 0 };
411
+ const segments = [];
412
+ for (let index = 1; index < points.length - 1; index += 1) {
413
+ const previous = points[index - 1];
414
+ const vertex = points[index];
415
+ const next = points[index + 1];
416
+ if (previous === undefined || vertex === undefined || next === undefined)
417
+ continue;
418
+ const inLength = Math.hypot(vertex.x - previous.x, vertex.y - previous.y);
419
+ const outLength = Math.hypot(next.x - vertex.x, next.y - vertex.y);
420
+ const radius = Math.min(BEND_RADIUS_MAX, Math.min(inLength, outLength) / 2);
421
+ const inDir = { x: (vertex.x - previous.x) / inLength, y: (vertex.y - previous.y) / inLength };
422
+ const outDir = { x: (next.x - vertex.x) / outLength, y: (next.y - vertex.y) / outLength };
423
+ const arrive = { x: vertex.x - inDir.x * radius, y: vertex.y - inDir.y * radius };
424
+ const leave = { x: vertex.x + outDir.x * radius, y: vertex.y + outDir.y * radius };
425
+ const start = segments.length === 0 ? first : (segments[segments.length - 1]?.to ?? first);
426
+ if (Math.hypot(arrive.x - start.x, arrive.y - start.y) > 0.01)
427
+ segments.push({ kind: "line", to: arrive });
428
+ segments.push({
429
+ kind: "cubic",
430
+ first: { x: arrive.x + inDir.x * radius * KAPPA, y: arrive.y + inDir.y * radius * KAPPA },
431
+ second: { x: leave.x - outDir.x * radius * KAPPA, y: leave.y - outDir.y * radius * KAPPA },
432
+ to: leave,
433
+ });
434
+ }
435
+ const last = points[points.length - 1];
436
+ if (last !== undefined) {
437
+ const start = segments.length === 0 ? first : (segments[segments.length - 1]?.to ?? first);
438
+ if (segments.length === 0 || Math.hypot(last.x - start.x, last.y - start.y) > 0.01)
439
+ segments.push({ kind: "line", to: last });
440
+ }
441
+ return { from: first, segments };
442
+ };
443
+ /** The midpoint of the longest straight run, or the route's middle if it only bends. */
444
+ const labelAnchorOf = (curve) => {
445
+ let best;
446
+ curve.segments.forEach((segment, index) => {
447
+ if (segment.kind !== "line")
448
+ return;
449
+ const start = segmentStart(curve, index);
450
+ const length = Math.hypot(segment.to.x - start.x, segment.to.y - start.y);
451
+ if (best === undefined || length > best.length)
452
+ best = {
453
+ length,
454
+ middle: { x: (start.x + segment.to.x) / 2, y: (start.y + segment.to.y) / 2 },
455
+ };
456
+ });
457
+ return best?.middle ?? pointAt(curve, 0.5);
458
+ };
459
+ export const routeEdges = (edges, layout) => finalPass(edges, layout).routed;
460
+ /**
461
+ * How many runs each gap of this layout would carry, counted from the same
462
+ * routing that would be drawn — braid guard included. This is what decides
463
+ * whether a gap is wide enough for its traffic before anything is drawn
464
+ * into it.
465
+ */
466
+ export const channelTraffic = (edges, layout) => {
467
+ const corridors = new Map();
468
+ const bands = new Map();
469
+ for (const route of finalPass(edges, layout).plans)
470
+ for (const channel of route.channels)
471
+ switch (channel.kind) {
472
+ case "corridor":
473
+ corridors.set(channel.index, (corridors.get(channel.index) ?? 0) + 1);
474
+ break;
475
+ case "band":
476
+ bands.set(channel.index, (bands.get(channel.index) ?? 0) + 1);
477
+ break;
478
+ default:
479
+ assertNever(channel, "Unhandled channel");
480
+ }
481
+ return { corridors, bands };
482
+ };
483
+ const finalPass = (edges, layout) => {
484
+ const first = routePass(edges, layout, new Set());
485
+ const braiding = braidingTrunks(first.branches);
486
+ return braiding.size === 0 ? first : routePass(edges, layout, braiding);
487
+ };
488
+ const routePass = (edges, layout, blockedTrunks) => {
489
+ const nodes = new Map(layout.nodes.map((node) => [node.node.id, node]));
490
+ const blocked = blockedFaces(layout.nodes);
491
+ const laneCount = layout.lanes.length;
492
+ const grid = layout.grid;
493
+ const drawable = edges.flatMap((edge, order) => {
494
+ const from = nodes.get(edge.from);
495
+ const to = nodes.get(edge.to);
496
+ if (from === undefined || to === undefined)
497
+ return [];
498
+ return [{ edge, order, from, to }];
499
+ });
500
+ const loops = new Set(drawable.filter(({ edge }) => edge.from === edge.to).map(({ edge }) => edge.id));
501
+ const trunkCounts = new Map();
502
+ for (const { edge, from, to } of drawable) {
503
+ if (loops.has(edge.id) || isExiled(edge, from, to) || isDead(from))
504
+ continue;
505
+ const key = trunkKey(edge, from, to);
506
+ if (key === undefined || blockedTrunks.has(key))
507
+ continue;
508
+ trunkCounts.set(key, (trunkCounts.get(key) ?? 0) + 1);
509
+ }
510
+ const routes = drawable
511
+ .filter(({ edge }) => !loops.has(edge.id))
512
+ .map(({ edge, order, from, to }) => {
513
+ if (isExiled(edge, from, to))
514
+ return {
515
+ edge,
516
+ order,
517
+ from,
518
+ to,
519
+ trunk: undefined,
520
+ ...planExile(from, to, grid, laneCount, blocked),
521
+ };
522
+ const key = isDead(from) ? undefined : trunkKey(edge, from, to);
523
+ const trunk = key !== undefined && (trunkCounts.get(key) ?? 0) >= 2 ? key : undefined;
524
+ if (trunk !== undefined)
525
+ return { edge, order, from, to, trunk, ...planTrunkMember(from, to, layout.lanes, grid, blocked) };
526
+ return { edge, order, from, to, trunk: undefined, ...planRoute(from, to, layout.lanes, grid, blocked) };
527
+ });
528
+ const ports = allocatePorts(routes, grid);
529
+ snapNeighbours(routes, ports);
530
+ const tracks = allocateTracks(routes, ports, grid);
531
+ const branches = new Map();
532
+ const routedById = new Map();
533
+ for (const route of routes) {
534
+ const points = buildPoints(route, resolvedPort(ports, fromSlot(route), route.from.box, route.fromSide), resolvedPort(ports, toSlot(route), route.to.box, route.toSide), (index) => tracks.get(`${route.edge.id}#${index}`) ?? 0);
535
+ if (route.trunk !== undefined) {
536
+ const list = branches.get(route.trunk) ?? [];
537
+ list.push(points.slice(1));
538
+ branches.set(route.trunk, list);
539
+ }
540
+ const curve = curveThrough(points);
541
+ routedById.set(route.edge.id, {
542
+ edge: route.edge,
543
+ path: pathOf(curve),
544
+ curve,
545
+ labelAnchor: route.edge.label === undefined ? undefined : labelAnchorOf(curve),
546
+ });
547
+ }
548
+ const routed = drawable.map(({ edge, from }) => {
549
+ const known = routedById.get(edge.id);
550
+ if (known !== undefined)
551
+ return known;
552
+ const curve = selfLoop(from.box);
553
+ return {
554
+ edge,
555
+ path: pathOf(curve),
556
+ curve,
557
+ labelAnchor: edge.label === undefined ? undefined : labelAnchorOf(curve),
558
+ };
559
+ });
560
+ return { routed, plans: routes, branches };
561
+ };
562
+ const resolvedPort = (ports, slot, box, side) => {
563
+ const span = faceSpan(box, side);
564
+ return portPoint(box, side, ports.get(slot)?.along ?? (span.from + span.to) / 2);
565
+ };
566
+ const fromSlot = (route) => `${route.from.node.id} ${route.fromSide} ${route.trunk ?? `${route.edge.id}>`}`;
567
+ const toSlot = (route) => `${route.to.node.id} ${route.toSide} ${route.edge.id}<`;
568
+ /**
569
+ * Where the arrows meet the cards: every face spreads its ports on a fixed
570
+ * pitch in a fixed order — the order of where each route is headed — and a
571
+ * trunk group takes a single slot for all of its members.
572
+ */
573
+ const allocatePorts = (routes, grid) => {
574
+ const byFace = new Map();
575
+ const register = (slot, placed, side, toward, order) => {
576
+ const face = `${placed.node.id} ${side}`;
577
+ const demands = byFace.get(face) ?? new Map();
578
+ const known = demands.get(slot);
579
+ if (known === undefined)
580
+ demands.set(slot, { slot, box: placed.box, side, toward, order });
581
+ else {
582
+ // A trunk slot is asked for once per member; it aims at their middle.
583
+ known.toward = (known.toward + toward) / 2;
584
+ known.order = Math.min(known.order, order);
585
+ }
586
+ byFace.set(face, demands);
587
+ };
588
+ for (const route of routes) {
589
+ register(fromSlot(route), route.from, route.fromSide, departureToward(route, grid), route.order);
590
+ register(toSlot(route), route.to, route.toSide, approachToward(route, grid), route.order);
591
+ }
592
+ const ports = new Map();
593
+ for (const demands of byFace.values()) {
594
+ const ordered = [...demands.values()].sort((a, b) => a.toward - b.toward || a.order - b.order);
595
+ const head = ordered[0];
596
+ if (head === undefined)
597
+ continue;
598
+ const span = faceSpan(head.box, head.side);
599
+ const room = span.to - span.from - PORT_INSET * 2;
600
+ const positions = spread((span.from + span.to) / 2, room, ordered.length, PORT_PITCH);
601
+ ordered.forEach((demand, index) => {
602
+ ports.set(demand.slot, { along: positions[index] ?? (span.from + span.to) / 2 });
603
+ });
604
+ }
605
+ return ports;
606
+ };
607
+ /** The first coordinate along the face's axis this route will head for. */
608
+ const departureToward = (route, grid) => {
609
+ const axis = sideAxis(route.fromSide);
610
+ for (const channel of route.channels) {
611
+ if (axis === "y" && channel.kind === "band")
612
+ return channelSpan(grid, channel).centre;
613
+ if (axis === "x" && channel.kind === "corridor")
614
+ return channelSpan(grid, channel).centre;
615
+ }
616
+ const target = boxCentre(route.to.box);
617
+ return axis === "x" ? target.x : target.y;
618
+ };
619
+ const approachToward = (route, grid) => {
620
+ const axis = sideAxis(route.toSide);
621
+ for (let index = route.channels.length - 1; index >= 0; index -= 1) {
622
+ const channel = route.channels[index];
623
+ if (channel === undefined)
624
+ continue;
625
+ if (axis === "y" && channel.kind === "band")
626
+ return channelSpan(grid, channel).centre;
627
+ if (axis === "x" && channel.kind === "corridor")
628
+ return channelSpan(grid, channel).centre;
629
+ }
630
+ const source = boxCentre(route.from.box);
631
+ return axis === "x" ? source.x : source.y;
632
+ };
633
+ const opposedSide = (side) => {
634
+ switch (side) {
635
+ case "top":
636
+ return "bottom";
637
+ case "bottom":
638
+ return "top";
639
+ case "left":
640
+ return "right";
641
+ case "right":
642
+ return "left";
643
+ default:
644
+ return assertNever(side, "Unhandled side");
645
+ }
646
+ };
647
+ /**
648
+ * Ports fan out on a fixed pitch, so two neighbouring cards almost never
649
+ * produce ports that happen to line up — a straightness test on endpoints
650
+ * would simply never fire. Aligned neighbours get pulled into line instead:
651
+ * both ends move to the mean of their allocated positions, clamped into the
652
+ * overlap of the two faces. Trunked members are left alone — a route sharing
653
+ * a stem is travelling with others by definition — and a pair of faces that
654
+ * barely overlap keeps its allocated ports.
655
+ */
656
+ const snapNeighbours = (routes, ports) => {
657
+ for (const route of routes) {
658
+ if (route.trunk !== undefined)
659
+ continue;
660
+ if (opposedSide(route.fromSide) !== route.toSide)
661
+ continue;
662
+ if (route.channels.length > 1)
663
+ continue;
664
+ const fromSpan = faceSpan(route.from.box, route.fromSide);
665
+ const toSpan = faceSpan(route.to.box, route.toSide);
666
+ const low = Math.max(fromSpan.from, toSpan.from) + PORT_INSET;
667
+ const high = Math.min(fromSpan.to, toSpan.to) - PORT_INSET;
668
+ if (low > high)
669
+ continue;
670
+ const fromPort = ports.get(fromSlot(route));
671
+ const toPort = ports.get(toSlot(route));
672
+ if (fromPort === undefined || toPort === undefined)
673
+ continue;
674
+ const snapped = Math.min(Math.max((fromPort.along + toPort.along) / 2, low), high);
675
+ ports.set(fromSlot(route), { along: snapped });
676
+ ports.set(toSlot(route), { along: snapped });
677
+ }
678
+ };
679
+ /**
680
+ * Every run through a gap gets its own track. Traffic in one gap is spread
681
+ * around its centre, ordered by heading first — outbound before returning —
682
+ * which is what keeps a bidirectional pair, and the labels riding each half,
683
+ * apart.
684
+ */
685
+ const allocateTracks = (routes, ports, grid) => {
686
+ const byChannel = new Map();
687
+ for (const route of routes) {
688
+ const from = resolvedPort(ports, fromSlot(route), route.from.box, route.fromSide);
689
+ const to = resolvedPort(ports, toSlot(route), route.to.box, route.toSide);
690
+ const approx = buildApprox(route, from, to, grid);
691
+ route.channels.forEach((channel, index) => {
692
+ const run = approx[index];
693
+ if (run === undefined)
694
+ return;
695
+ const key = `${channel.kind} ${channel.index}`;
696
+ const entry = byChannel.get(key) ?? { span: channelSpan(grid, channel), demands: [] };
697
+ entry.demands.push({
698
+ key: `${route.edge.id}#${index}`,
699
+ heading: run.heading,
700
+ at: run.at,
701
+ nest: run.nest,
702
+ order: route.order,
703
+ });
704
+ byChannel.set(key, entry);
705
+ });
706
+ }
707
+ const tracks = new Map();
708
+ for (const { span, demands } of byChannel.values()) {
709
+ const ordered = [...demands].sort((a, b) => b.heading - a.heading || a.at - b.at || a.nest - b.nest || a.order - b.order);
710
+ const positions = spread(span.centre, span.room, ordered.length, TRACK_PITCH_MAX);
711
+ ordered.forEach((demand, index) => {
712
+ tracks.set(demand.key, positions[index] ?? span.centre);
713
+ });
714
+ }
715
+ return tracks;
716
+ };
717
+ /**
718
+ * The direction, starting coordinate and nesting key of each channel run,
719
+ * taken from a dry run over channel centres. Only the ordering reads these,
720
+ * so the centre is close enough — the real track offsets shift a run by less
721
+ * than a pitch.
722
+ *
723
+ * The nesting key makes runs that start together — a stem's branches leaving
724
+ * one shared port — come out nested instead of braided: the run travelling
725
+ * farther takes the track on the far side from where its exit turns off, so
726
+ * a short branch's turn is never crossed by a long sibling passing over it.
727
+ */
728
+ const buildApprox = (route, from, to, grid) => {
729
+ const coordinates = [from];
730
+ let x = from.x;
731
+ let y = from.y;
732
+ for (const channel of route.channels) {
733
+ switch (channel.kind) {
734
+ case "corridor":
735
+ x = channelSpan(grid, channel).centre;
736
+ break;
737
+ case "band":
738
+ y = channelSpan(grid, channel).centre;
739
+ break;
740
+ default:
741
+ assertNever(channel, "Unhandled channel");
742
+ }
743
+ coordinates.push({ x, y });
744
+ }
745
+ switch (sideAxis(route.toSide)) {
746
+ case "y":
747
+ coordinates.push({ x, y: to.y });
748
+ break;
749
+ case "x":
750
+ coordinates.push({ x: to.x, y });
751
+ break;
752
+ }
753
+ coordinates.push(to);
754
+ return route.channels.map((channel, index) => {
755
+ const here = coordinates[index + 1] ?? from;
756
+ const next = coordinates[index + 2] ?? to;
757
+ const after = coordinates[index + 3] ?? to;
758
+ switch (channel.kind) {
759
+ case "corridor": {
760
+ const length = Math.abs(next.y - here.y);
761
+ return {
762
+ heading: Math.sign(next.y - here.y),
763
+ at: here.y,
764
+ nest: after.x > next.x ? -length : length,
765
+ };
766
+ }
767
+ case "band": {
768
+ const length = Math.abs(next.x - here.x);
769
+ return {
770
+ heading: Math.sign(next.x - here.x),
771
+ at: here.x,
772
+ nest: after.y > next.y ? -length : length,
773
+ };
774
+ }
775
+ default:
776
+ return assertNever(channel, "Unhandled channel");
777
+ }
778
+ });
779
+ };
780
+ /**
781
+ * Which trunk groups actually braid, judged on the geometry that was drawn
782
+ * rather than predicted from endpoints: a predicate on endpoints either
783
+ * rejects fans whose lines never come near each other, or — once the port is
784
+ * shared — never fires at all. Touching at a shared point is not a crossing;
785
+ * running along one another is.
786
+ */
787
+ const braidingTrunks = (branches) => {
788
+ const braiding = new Set();
789
+ for (const [key, members] of branches) {
790
+ if (members.length < 2)
791
+ continue;
792
+ outer: for (let a = 0; a < members.length; a += 1)
793
+ for (let b = a + 1; b < members.length; b += 1) {
794
+ if (polylinesCross(members[a] ?? [], members[b] ?? [])) {
795
+ braiding.add(key);
796
+ break outer;
797
+ }
798
+ }
799
+ }
800
+ return braiding;
801
+ };
802
+ const polylinesCross = (a, b) => {
803
+ for (let i = 0; i + 1 < a.length; i += 1)
804
+ for (let j = 0; j + 1 < b.length; j += 1) {
805
+ const a1 = a[i];
806
+ const a2 = a[i + 1];
807
+ const b1 = b[j];
808
+ const b2 = b[j + 1];
809
+ if (a1 === undefined || a2 === undefined || b1 === undefined || b2 === undefined)
810
+ continue;
811
+ if (segmentsCross(a1, a2, b1, b2))
812
+ return true;
813
+ }
814
+ return false;
815
+ };
816
+ const EPSILON = 0.01;
817
+ /** Proper crossing or collinear overlap of two axis-aligned segments. */
818
+ const segmentsCross = (a1, a2, b1, b2) => {
819
+ const aHorizontal = Math.abs(a1.y - a2.y) < EPSILON;
820
+ const bHorizontal = Math.abs(b1.y - b2.y) < EPSILON;
821
+ if (aHorizontal !== bHorizontal) {
822
+ const [h1, h2, v1, v2] = aHorizontal ? [a1, a2, b1, b2] : [b1, b2, a1, a2];
823
+ const x = v1.x;
824
+ const y = h1.y;
825
+ return (Math.min(h1.x, h2.x) + EPSILON < x &&
826
+ x < Math.max(h1.x, h2.x) - EPSILON &&
827
+ Math.min(v1.y, v2.y) + EPSILON < y &&
828
+ y < Math.max(v1.y, v2.y) - EPSILON);
829
+ }
830
+ if (aHorizontal) {
831
+ if (Math.abs(a1.y - b1.y) > EPSILON)
832
+ return false;
833
+ return (Math.min(Math.max(a1.x, a2.x), Math.max(b1.x, b2.x)) -
834
+ Math.max(Math.min(a1.x, a2.x), Math.min(b1.x, b2.x)) >
835
+ EPSILON);
836
+ }
837
+ if (Math.abs(a1.x - b1.x) > EPSILON)
838
+ return false;
839
+ return (Math.min(Math.max(a1.y, a2.y), Math.max(b1.y, b2.y)) -
840
+ Math.max(Math.min(a1.y, a2.y), Math.min(b1.y, b2.y)) >
841
+ EPSILON);
842
+ };
843
+ //# sourceMappingURL=edges.js.map