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