@nowline/layout 0.2.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 (183) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +103 -0
  3. package/dist/band-scale.d.ts +56 -0
  4. package/dist/band-scale.d.ts.map +1 -0
  5. package/dist/band-scale.js +86 -0
  6. package/dist/band-scale.js.map +1 -0
  7. package/dist/calendar.d.ts +79 -0
  8. package/dist/calendar.d.ts.map +1 -0
  9. package/dist/calendar.js +210 -0
  10. package/dist/calendar.js.map +1 -0
  11. package/dist/capacity.d.ts +72 -0
  12. package/dist/capacity.d.ts.map +1 -0
  13. package/dist/capacity.js +163 -0
  14. package/dist/capacity.js.map +1 -0
  15. package/dist/dsl-utils.d.ts +5 -0
  16. package/dist/dsl-utils.d.ts.map +1 -0
  17. package/dist/dsl-utils.js +28 -0
  18. package/dist/dsl-utils.js.map +1 -0
  19. package/dist/edge-routing.d.ts +89 -0
  20. package/dist/edge-routing.d.ts.map +1 -0
  21. package/dist/edge-routing.js +435 -0
  22. package/dist/edge-routing.js.map +1 -0
  23. package/dist/frame-tab-geometry.d.ts +78 -0
  24. package/dist/frame-tab-geometry.d.ts.map +1 -0
  25. package/dist/frame-tab-geometry.js +115 -0
  26. package/dist/frame-tab-geometry.js.map +1 -0
  27. package/dist/header-card-geometry.d.ts +29 -0
  28. package/dist/header-card-geometry.d.ts.map +1 -0
  29. package/dist/header-card-geometry.js +41 -0
  30. package/dist/header-card-geometry.js.map +1 -0
  31. package/dist/i18n.d.ts +48 -0
  32. package/dist/i18n.d.ts.map +1 -0
  33. package/dist/i18n.js +114 -0
  34. package/dist/i18n.js.map +1 -0
  35. package/dist/include-chrome-geometry.d.ts +86 -0
  36. package/dist/include-chrome-geometry.d.ts.map +1 -0
  37. package/dist/include-chrome-geometry.js +104 -0
  38. package/dist/include-chrome-geometry.js.map +1 -0
  39. package/dist/index.d.ts +11 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +10 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/item-bar-geometry.d.ts +127 -0
  44. package/dist/item-bar-geometry.d.ts.map +1 -0
  45. package/dist/item-bar-geometry.js +173 -0
  46. package/dist/item-bar-geometry.js.map +1 -0
  47. package/dist/lane-utilization.d.ts +90 -0
  48. package/dist/lane-utilization.d.ts.map +1 -0
  49. package/dist/lane-utilization.js +206 -0
  50. package/dist/lane-utilization.js.map +1 -0
  51. package/dist/layout-context.d.ts +143 -0
  52. package/dist/layout-context.d.ts.map +1 -0
  53. package/dist/layout-context.js +8 -0
  54. package/dist/layout-context.js.map +1 -0
  55. package/dist/layout.d.ts +18 -0
  56. package/dist/layout.d.ts.map +1 -0
  57. package/dist/layout.js +1213 -0
  58. package/dist/layout.js.map +1 -0
  59. package/dist/nodes/anchor-node.d.ts +16 -0
  60. package/dist/nodes/anchor-node.d.ts.map +1 -0
  61. package/dist/nodes/anchor-node.js +68 -0
  62. package/dist/nodes/anchor-node.js.map +1 -0
  63. package/dist/nodes/footnote-node.d.ts +10 -0
  64. package/dist/nodes/footnote-node.d.ts.map +1 -0
  65. package/dist/nodes/footnote-node.js +41 -0
  66. package/dist/nodes/footnote-node.js.map +1 -0
  67. package/dist/nodes/group-node.d.ts +29 -0
  68. package/dist/nodes/group-node.d.ts.map +1 -0
  69. package/dist/nodes/group-node.js +187 -0
  70. package/dist/nodes/group-node.js.map +1 -0
  71. package/dist/nodes/include-node.d.ts +16 -0
  72. package/dist/nodes/include-node.d.ts.map +1 -0
  73. package/dist/nodes/include-node.js +117 -0
  74. package/dist/nodes/include-node.js.map +1 -0
  75. package/dist/nodes/item-node.d.ts +51 -0
  76. package/dist/nodes/item-node.d.ts.map +1 -0
  77. package/dist/nodes/item-node.js +108 -0
  78. package/dist/nodes/item-node.js.map +1 -0
  79. package/dist/nodes/marker-geometry.d.ts +22 -0
  80. package/dist/nodes/marker-geometry.d.ts.map +1 -0
  81. package/dist/nodes/marker-geometry.js +38 -0
  82. package/dist/nodes/marker-geometry.js.map +1 -0
  83. package/dist/nodes/milestone-node.d.ts +48 -0
  84. package/dist/nodes/milestone-node.d.ts.map +1 -0
  85. package/dist/nodes/milestone-node.js +210 -0
  86. package/dist/nodes/milestone-node.js.map +1 -0
  87. package/dist/nodes/parallel-node.d.ts +21 -0
  88. package/dist/nodes/parallel-node.d.ts.map +1 -0
  89. package/dist/nodes/parallel-node.js +80 -0
  90. package/dist/nodes/parallel-node.js.map +1 -0
  91. package/dist/nodes/roadmap-node.d.ts +76 -0
  92. package/dist/nodes/roadmap-node.d.ts.map +1 -0
  93. package/dist/nodes/roadmap-node.js +788 -0
  94. package/dist/nodes/roadmap-node.js.map +1 -0
  95. package/dist/nodes/swimlane-node.d.ts +38 -0
  96. package/dist/nodes/swimlane-node.d.ts.map +1 -0
  97. package/dist/nodes/swimlane-node.js +308 -0
  98. package/dist/nodes/swimlane-node.js.map +1 -0
  99. package/dist/renderable.d.ts +44 -0
  100. package/dist/renderable.d.ts.map +1 -0
  101. package/dist/renderable.js +21 -0
  102. package/dist/renderable.js.map +1 -0
  103. package/dist/row-packer.d.ts +125 -0
  104. package/dist/row-packer.d.ts.map +1 -0
  105. package/dist/row-packer.js +169 -0
  106. package/dist/row-packer.js.map +1 -0
  107. package/dist/style-resolution.d.ts +14 -0
  108. package/dist/style-resolution.d.ts.map +1 -0
  109. package/dist/style-resolution.js +191 -0
  110. package/dist/style-resolution.js.map +1 -0
  111. package/dist/themes/dark.d.ts +4 -0
  112. package/dist/themes/dark.d.ts.map +1 -0
  113. package/dist/themes/dark.js +241 -0
  114. package/dist/themes/dark.js.map +1 -0
  115. package/dist/themes/index.d.ts +15 -0
  116. package/dist/themes/index.d.ts.map +1 -0
  117. package/dist/themes/index.js +30 -0
  118. package/dist/themes/index.js.map +1 -0
  119. package/dist/themes/light.d.ts +4 -0
  120. package/dist/themes/light.d.ts.map +1 -0
  121. package/dist/themes/light.js +248 -0
  122. package/dist/themes/light.js.map +1 -0
  123. package/dist/themes/shape.d.ts +194 -0
  124. package/dist/themes/shape.d.ts.map +1 -0
  125. package/dist/themes/shape.js +6 -0
  126. package/dist/themes/shape.js.map +1 -0
  127. package/dist/themes/shared.d.ts +145 -0
  128. package/dist/themes/shared.d.ts.map +1 -0
  129. package/dist/themes/shared.js +310 -0
  130. package/dist/themes/shared.js.map +1 -0
  131. package/dist/time-scale.d.ts +39 -0
  132. package/dist/time-scale.d.ts.map +1 -0
  133. package/dist/time-scale.js +62 -0
  134. package/dist/time-scale.js.map +1 -0
  135. package/dist/types.d.ts +483 -0
  136. package/dist/types.d.ts.map +1 -0
  137. package/dist/types.js +6 -0
  138. package/dist/types.js.map +1 -0
  139. package/dist/view-preset.d.ts +23 -0
  140. package/dist/view-preset.d.ts.map +1 -0
  141. package/dist/view-preset.js +146 -0
  142. package/dist/view-preset.js.map +1 -0
  143. package/dist/working-calendar.d.ts +14 -0
  144. package/dist/working-calendar.d.ts.map +1 -0
  145. package/dist/working-calendar.js +74 -0
  146. package/dist/working-calendar.js.map +1 -0
  147. package/package.json +37 -0
  148. package/src/band-scale.ts +115 -0
  149. package/src/calendar.ts +244 -0
  150. package/src/capacity.ts +191 -0
  151. package/src/dsl-utils.ts +30 -0
  152. package/src/edge-routing.ts +550 -0
  153. package/src/frame-tab-geometry.ts +165 -0
  154. package/src/header-card-geometry.ts +48 -0
  155. package/src/i18n.ts +124 -0
  156. package/src/include-chrome-geometry.ts +156 -0
  157. package/src/index.ts +116 -0
  158. package/src/item-bar-geometry.ts +222 -0
  159. package/src/lane-utilization.ts +259 -0
  160. package/src/layout-context.ts +182 -0
  161. package/src/layout.ts +1446 -0
  162. package/src/nodes/anchor-node.ts +77 -0
  163. package/src/nodes/footnote-node.ts +60 -0
  164. package/src/nodes/group-node.ts +252 -0
  165. package/src/nodes/include-node.ts +168 -0
  166. package/src/nodes/item-node.ts +171 -0
  167. package/src/nodes/marker-geometry.ts +43 -0
  168. package/src/nodes/milestone-node.ts +263 -0
  169. package/src/nodes/parallel-node.ts +101 -0
  170. package/src/nodes/roadmap-node.ts +957 -0
  171. package/src/nodes/swimlane-node.ts +423 -0
  172. package/src/renderable.ts +68 -0
  173. package/src/row-packer.ts +271 -0
  174. package/src/style-resolution.ts +243 -0
  175. package/src/themes/dark.ts +244 -0
  176. package/src/themes/index.ts +36 -0
  177. package/src/themes/light.ts +251 -0
  178. package/src/themes/shape.ts +230 -0
  179. package/src/themes/shared.ts +369 -0
  180. package/src/time-scale.ts +78 -0
  181. package/src/types.ts +607 -0
  182. package/src/view-preset.ts +180 -0
  183. package/src/working-calendar.ts +91 -0
@@ -0,0 +1,550 @@
1
+ // Channel-based dependency-arrow router. Replaces the single-elbow
2
+ // `routeEdge` shipped in m2g with one that:
3
+ //
4
+ // - Drops the vertical leg in the cleanest inter-column gutter (item
5
+ // bars are obstacles).
6
+ // - Nudges the elbow X away from any visible parallel/group bracket
7
+ // within `BRACKET_NUDGE_PX` so arrows breathe instead of hugging
8
+ // the bracket line.
9
+ // - Assigns distinct slots when multiple edges share a channel so
10
+ // parallel arrows don't stack on top of one another.
11
+ // - Falls back to under-bar routing (rendered behind the bars with a
12
+ // thinner stroke) when no clean channel exists in the source-target
13
+ // gap.
14
+ //
15
+ // Containers are NOT treated as obstacles. Endpoints inside a parallel
16
+ // or group route directly through the items-only obstacle map and use
17
+ // the under-bar fallback when needed. Looping around container edges
18
+ // to avoid a single intersecting bar produced unsatisfying detours
19
+ // (see `specs/handoffs/handoff-channel-routing-design.md`).
20
+
21
+ import type {
22
+ BoundingBox,
23
+ Point,
24
+ PositionedIncludeRegion,
25
+ PositionedSwimlane,
26
+ PositionedTrackChild,
27
+ } from './types.js';
28
+
29
+ /** A visible parallel or group bracket stroke. Used by the
30
+ * bracket-clearance nudge: arrows whose chosen elbow X falls within
31
+ * `BRACKET_NUDGE_PX` of one of these lines are shifted away. Brackets
32
+ * are NOT obstacles — they're aesthetic preferences, not collisions. */
33
+ export interface BracketLine {
34
+ x: number;
35
+ yTop: number;
36
+ yBottom: number;
37
+ }
38
+
39
+ /** Per-edge routing request. `from` and `to` are the (visualEdge, midY)
40
+ * attach points the rest of the layout already computed; the router
41
+ * produces the orthogonal polyline that connects them. */
42
+ export interface EdgeRouteRequest {
43
+ fromId: string;
44
+ toId: string;
45
+ from: Point;
46
+ to: Point;
47
+ /** Marker → item edges (anchor / milestone source) skip the channel
48
+ * router entirely — the cut line is the visible stem, the path is
49
+ * always a short horizontal stub. Set true to bypass routing. */
50
+ isMarkerSource: boolean;
51
+ }
52
+
53
+ export interface EdgeRouteResult {
54
+ fromId: string;
55
+ toId: string;
56
+ waypoints: Point[];
57
+ /** True when the chosen channel intersected an item bar. The
58
+ * renderer paints these edges BEFORE bar fills with a thinner
59
+ * stroke so the bar still reads as the foreground. */
60
+ underBar: boolean;
61
+ }
62
+
63
+ /** Distance (px) the elbow X must keep from any visible bracket. Picked
64
+ * to leave a small visible gap between the arrow's vertical leg and
65
+ * the bracket stroke without forcing the arrow far from its natural
66
+ * mid-gutter line. */
67
+ export const BRACKET_NUDGE_PX = 4;
68
+
69
+ /** Minimum horizontal source stub (px) — distance from the source's
70
+ * exit point to the vertical-leg elbow. Below this, the source-side
71
+ * arrow body collapses into the bar's right edge with no visible
72
+ * horizontal segment. Treated as a hard constraint when picking
73
+ * the channel X for left-to-right edges. */
74
+ export const MIN_SOURCE_STUB_PX = 6;
75
+
76
+ /** Minimum horizontal target stub (px) — distance from the vertical-leg
77
+ * elbow to the target's left edge. Below this, the arrowhead has no
78
+ * horizontal lead-in: the leg appears to plunge directly into the bar
79
+ * without a visible target-side stub. Treated as a hard constraint
80
+ * when picking the channel X for left-to-right edges; conflicts with
81
+ * obstacle / bracket clearance trigger the under-bar fallback. */
82
+ export const MIN_TARGET_STUB_PX = 6;
83
+
84
+ /** Spacing between slots inside one channel. With a 12 px gutter and
85
+ * this spacing, three slots (-1, 0, +1) fit comfortably; more than
86
+ * three sharing a channel collapses to centerline. */
87
+ const SLOT_SPACING_PX = 3;
88
+
89
+ /** Tolerance (px) when grouping edges by channel X for slot assignment.
90
+ * Edges within this distance share a channel. */
91
+ const SLOT_GROUP_TOLERANCE_PX = 1;
92
+
93
+ /** Stub length leaving each endpoint before the elbow turn for
94
+ * right-to-left edges. The router also inserts a quarter-arc at the
95
+ * elbow corner; the renderer's `roundedOrthogonalPath` rounds the
96
+ * corners. Left-to-right edges use the per-side `MIN_*_STUB_PX`
97
+ * constants instead — they provide tighter, asymmetric control over
98
+ * the source vs target sides. */
99
+ const STUB_OUT_PX = 10;
100
+
101
+ export interface ChannelGridInput {
102
+ /** Every painted item box (visual edges, including chip-spill
103
+ * growth). Treated as hard obstacles: a vertical leg crossing one
104
+ * triggers under-bar fallback. */
105
+ itemBars: BoundingBox[];
106
+ /** Every visible bracket stroke (parallel `bracket:solid|dashed`,
107
+ * filled-style group chiclets are NOT brackets). Used by the
108
+ * clearance nudge only — not obstacles. */
109
+ brackets: BracketLine[];
110
+ }
111
+
112
+ export class ChannelGrid {
113
+ private readonly items: BoundingBox[];
114
+ private readonly brackets: BracketLine[];
115
+
116
+ constructor(input: ChannelGridInput) {
117
+ this.items = input.itemBars;
118
+ this.brackets = input.brackets;
119
+ }
120
+
121
+ /** True when a vertical line at `x` intersects any item bar within
122
+ * the Y span `[yMin, yMax]`. */
123
+ hasObstacle(x: number, yMin: number, yMax: number): boolean {
124
+ const lo = Math.min(yMin, yMax);
125
+ const hi = Math.max(yMin, yMax);
126
+ for (const bar of this.items) {
127
+ if (x <= bar.x || x >= bar.x + bar.width) continue;
128
+ if (bar.y + bar.height <= lo || bar.y >= hi) continue;
129
+ return true;
130
+ }
131
+ return false;
132
+ }
133
+
134
+ /** Visible brackets within `radius` px of `x` whose Y span overlaps
135
+ * `[yMin, yMax]`. The clearance nudge uses these to shift the
136
+ * elbow X away from the bracket. */
137
+ bracketsNear(x: number, yMin: number, yMax: number, radius: number): BracketLine[] {
138
+ const lo = Math.min(yMin, yMax);
139
+ const hi = Math.max(yMin, yMax);
140
+ const out: BracketLine[] = [];
141
+ for (const b of this.brackets) {
142
+ if (Math.abs(b.x - x) > radius) continue;
143
+ if (b.yBottom <= lo || b.yTop >= hi) continue;
144
+ out.push(b);
145
+ }
146
+ return out;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Walk a positioned swimlane tree and collect every painted item bar
152
+ * (visual edges) AND every visible bracket stroke. Output feeds
153
+ * `ChannelGrid` so the router knows what to avoid.
154
+ *
155
+ * Swimlanes contained in include regions count too — items inside an
156
+ * isolated region share the parent timeline so cross-region arrows
157
+ * still need the obstacle/bracket data to route cleanly.
158
+ */
159
+ export function collectRoutingObstacles(
160
+ swimlanes: PositionedSwimlane[],
161
+ includes: PositionedIncludeRegion[],
162
+ ): ChannelGridInput {
163
+ const itemBars: BoundingBox[] = [];
164
+ const brackets: BracketLine[] = [];
165
+
166
+ const visitChild = (child: PositionedTrackChild): void => {
167
+ if (child.kind === 'item') {
168
+ itemBars.push(child.box);
169
+ return;
170
+ }
171
+ if (child.kind === 'parallel') {
172
+ // Parallel `bracket:solid|dashed` paints `[ ]` strokes at
173
+ // the box's left/right edges with 12 px vertical padding
174
+ // (matches `renderParallel`). The bracket has THREE
175
+ // strokes per side — a vertical bar at the box edge, plus
176
+ // top/bottom horizontal "feet" extending 4 px inward
177
+ // toward the parallel's center. We model the verticals as
178
+ // full-height bracket lines AND emit thin-Y-band entries
179
+ // at the foot tips so the clearance nudge sees the foot
180
+ // extent (otherwise an elbow nudged just past the
181
+ // vertical at +4 px would land squarely on the inward
182
+ // foot tip — see issue #2 in the channel-routing handoff).
183
+ if (child.style.bracket === 'solid' || child.style.bracket === 'dashed') {
184
+ const padding = 12;
185
+ const stub = 4;
186
+ const yTop = child.box.y - padding;
187
+ const yBottom = child.box.y + child.box.height + padding;
188
+ const lx = child.box.x;
189
+ const rx = child.box.x + child.box.width;
190
+ brackets.push({ x: lx, yTop, yBottom });
191
+ brackets.push({ x: rx, yTop, yBottom });
192
+ // Foot tips: tiny Y bands centered on each foot row
193
+ // so the entries only fire when the elbow's Y span
194
+ // actually crosses the foot line.
195
+ brackets.push({ x: lx + stub, yTop: yTop - 1, yBottom: yTop + 1 });
196
+ brackets.push({ x: lx + stub, yTop: yBottom - 1, yBottom: yBottom + 1 });
197
+ brackets.push({ x: rx - stub, yTop: yTop - 1, yBottom: yTop + 1 });
198
+ brackets.push({ x: rx - stub, yTop: yBottom - 1, yBottom: yBottom + 1 });
199
+ }
200
+ for (const sub of child.children) visitChild(sub);
201
+ return;
202
+ }
203
+ if (child.kind === 'group') {
204
+ // Bracket-style groups (no fill) paint a left-side `[`
205
+ // glyph along `box.x`. Filled-style groups have no bracket
206
+ // — the chiclet is the visual instead.
207
+ const isFilled = child.style.bg !== 'none' && child.style.bg !== '#ffffff';
208
+ if (!isFilled && child.style.bracket && child.style.bracket !== 'none') {
209
+ brackets.push({
210
+ x: child.box.x,
211
+ yTop: child.box.y,
212
+ yBottom: child.box.y + child.box.height,
213
+ });
214
+ }
215
+ for (const sub of child.children) visitChild(sub);
216
+ return;
217
+ }
218
+ };
219
+
220
+ const visitSwimlane = (lane: PositionedSwimlane): void => {
221
+ for (const child of lane.children) visitChild(child);
222
+ for (const nested of lane.nested) visitSwimlane(nested);
223
+ };
224
+
225
+ for (const lane of swimlanes) visitSwimlane(lane);
226
+ for (const region of includes) {
227
+ for (const lane of region.nestedSwimlanes) visitSwimlane(lane);
228
+ }
229
+
230
+ return { itemBars, brackets };
231
+ }
232
+
233
+ /** Satisfiable range for a left-to-right edge's elbow X — tightest
234
+ * band that still leaves `MIN_SOURCE_STUB_PX` of horizontal lead-out
235
+ * on the source side and `MIN_TARGET_STUB_PX` of horizontal lead-in
236
+ * on the target side. `null` means the gutter is too narrow to honor
237
+ * both stubs (router pins to the target-stub line and forces
238
+ * under-bar). For right-to-left and same-row edges the range is
239
+ * always `null` — those branches don't apply the stub constraints
240
+ * because their geometry is fundamentally different. */
241
+ type StubRange = { minX: number; maxX: number } | null;
242
+
243
+ /**
244
+ * Pick a channel X for one edge (no slot offset yet). Returns the
245
+ * provisional X, an `underBar` flag, and the satisfiable stub range
246
+ * so the bracket-clearance nudge can stay inside it. Same-row edges
247
+ * (`from.y ≈ to.y`) get a degenerate "channel" at midX so the
248
+ * renderer emits a straight line.
249
+ */
250
+ function pickChannelX(
251
+ req: EdgeRouteRequest,
252
+ grid: ChannelGrid,
253
+ ): { x: number; underBar: boolean; range: StubRange } {
254
+ const { from, to } = req;
255
+ if (Math.abs(from.y - to.y) < 0.5) {
256
+ return { x: (from.x + to.x) / 2, underBar: false, range: null };
257
+ }
258
+
259
+ const yMin = Math.min(from.y, to.y);
260
+ const yMax = Math.max(from.y, to.y);
261
+
262
+ if (from.x <= to.x) {
263
+ // Left-to-right edge. The elbow X must satisfy both the
264
+ // min-source-stub and min-target-stub constraints — when the
265
+ // gutter is wider than the sum of the stubs, that's a band;
266
+ // when it's tighter the band collapses or inverts and the
267
+ // arrow drops to under-bar at the target-stub line.
268
+ const minX = from.x + MIN_SOURCE_STUB_PX;
269
+ const maxX = to.x - MIN_TARGET_STUB_PX;
270
+ if (minX > maxX) {
271
+ // Gutter narrower than the combined min stubs — give up
272
+ // and honor the target-stub line so the arrowhead still
273
+ // has its visible lead-in. Source stub absorbs the loss.
274
+ return {
275
+ x: to.x - MIN_TARGET_STUB_PX,
276
+ underBar: true,
277
+ range: null,
278
+ };
279
+ }
280
+ const range: StubRange = { minX, maxX };
281
+ // Try the natural gutter midpoint, clamped into the range.
282
+ const naturalMid = (from.x + to.x) / 2;
283
+ const mid = Math.max(minX, Math.min(maxX, naturalMid));
284
+ if (!grid.hasObstacle(mid, yMin, yMax)) {
285
+ return { x: mid, underBar: false, range };
286
+ }
287
+ // Walk inside the satisfiable range looking for a clear strip.
288
+ const span = maxX - minX;
289
+ for (let step = 1; step <= span; step++) {
290
+ const left = mid - step;
291
+ if (left >= minX && !grid.hasObstacle(left, yMin, yMax)) {
292
+ return { x: left, underBar: false, range };
293
+ }
294
+ const right = mid + step;
295
+ if (right <= maxX && !grid.hasObstacle(right, yMin, yMax)) {
296
+ return { x: right, underBar: false, range };
297
+ }
298
+ }
299
+ return { x: mid, underBar: true, range };
300
+ }
301
+
302
+ // Right-to-left edge: source ends past target's left edge. Try a
303
+ // channel just right of source first; then walk leftward across
304
+ // the source/target span looking for any clear vertical strip.
305
+ const probes = [from.x + STUB_OUT_PX, to.x - STUB_OUT_PX];
306
+ for (const probe of probes) {
307
+ if (probe > 0 && !grid.hasObstacle(probe, yMin, yMax)) {
308
+ return { x: probe, underBar: false, range: null };
309
+ }
310
+ }
311
+ // Fallback to under-bar at the source-side stub.
312
+ return { x: from.x + STUB_OUT_PX, underBar: true, range: null };
313
+ }
314
+
315
+ /**
316
+ * Apply the bracket-clearance nudge to a chosen channel X. Shifts `x`
317
+ * away from the nearest visible bracket within `BRACKET_NUDGE_PX`,
318
+ * preferring the direction that stays inside the satisfiable stub
319
+ * range. When neither direction fits — or the candidate position is
320
+ * still within nudge distance of another bracket — returns the input
321
+ * X clamped to the range and signals `forceUnderBar` so the leg paints
322
+ * BEHIND the bars and bracket strokes (z-order: bracket renders after
323
+ * under-bar edges, so the bracket cleanly covers the colliding
324
+ * portion).
325
+ */
326
+ function nudgeAwayFromBrackets(
327
+ x: number,
328
+ range: StubRange,
329
+ req: EdgeRouteRequest,
330
+ grid: ChannelGrid,
331
+ ): { x: number; forceUnderBar: boolean } {
332
+ const yMin = Math.min(req.from.y, req.to.y);
333
+ const yMax = Math.max(req.from.y, req.to.y);
334
+ const near = grid.bracketsNear(x, yMin, yMax, BRACKET_NUDGE_PX);
335
+ if (near.length === 0) return { x, forceUnderBar: false };
336
+
337
+ // Find the closest bracket — that's the one driving the nudge.
338
+ let closest = near[0];
339
+ let closestDist = Math.abs(near[0].x - x);
340
+ for (let i = 1; i < near.length; i++) {
341
+ const d = Math.abs(near[i].x - x);
342
+ if (d < closestDist) {
343
+ closest = near[i];
344
+ closestDist = d;
345
+ }
346
+ }
347
+
348
+ // Try both sides of the closest bracket. Accept the first
349
+ // candidate that stays within the satisfiable range AND clears
350
+ // every other nearby bracket. The recheck radius is one epsilon
351
+ // tighter than `BRACKET_NUDGE_PX` so the closest bracket itself
352
+ // (now sitting at exactly the nudge distance) doesn't falsely
353
+ // trip the rejection — and so a SECOND bracket exactly at the
354
+ // nudge distance is also accepted as "just clear enough".
355
+ const candidates = [closest.x + BRACKET_NUDGE_PX, closest.x - BRACKET_NUDGE_PX];
356
+ const recheckRadius = BRACKET_NUDGE_PX - 0.01;
357
+ for (const candidate of candidates) {
358
+ if (range && (candidate < range.minX || candidate > range.maxX)) continue;
359
+ // Clamp right-to-left candidates to the natural gap so they
360
+ // don't escape past either endpoint (range is null on that path).
361
+ const clamped = range
362
+ ? candidate
363
+ : Math.max(
364
+ Math.min(req.from.x, req.to.x) + 1,
365
+ Math.min(Math.max(req.from.x, req.to.x) - 1, candidate),
366
+ );
367
+ const stillNear = grid.bracketsNear(clamped, yMin, yMax, recheckRadius);
368
+ if (stillNear.length === 0) {
369
+ return { x: clamped, forceUnderBar: false };
370
+ }
371
+ }
372
+
373
+ // No nudge fits. Keep the channel at its current X (clamped into
374
+ // the range) and force under-bar so item bars + bracket strokes
375
+ // mask the colliding portion of the leg.
376
+ const fallback = range ? Math.min(Math.max(x, range.minX), range.maxX) : x;
377
+ return { x: fallback, forceUnderBar: true };
378
+ }
379
+
380
+ /**
381
+ * Greedy interval coloring per channel. Edges sharing a channel
382
+ * (within `SLOT_GROUP_TOLERANCE_PX`) get distinct slot indices;
383
+ * overlapping Y spans land on different slots. Slot 0 is the
384
+ * centerline; ±1 sit `SLOT_SPACING_PX` to either side; further slots
385
+ * collapse back to the centerline (rare; visual stacking accepted).
386
+ */
387
+ function assignSlots(
388
+ edges: Array<{
389
+ req: EdgeRouteRequest;
390
+ channelX: number;
391
+ underBar: boolean;
392
+ orderIndex: number;
393
+ }>,
394
+ ): Map<number, number> {
395
+ // Group by channel X (sort first so close-X edges collapse).
396
+ const byX = [...edges].sort((a, b) => {
397
+ if (Math.abs(a.channelX - b.channelX) >= SLOT_GROUP_TOLERANCE_PX) {
398
+ return a.channelX - b.channelX;
399
+ }
400
+ // Within the same channel, deterministic by orderIndex.
401
+ return a.orderIndex - b.orderIndex;
402
+ });
403
+
404
+ const slotsByOrderIndex = new Map<number, number>();
405
+ let groupStart = 0;
406
+ while (groupStart < byX.length) {
407
+ let groupEnd = groupStart + 1;
408
+ while (
409
+ groupEnd < byX.length &&
410
+ Math.abs(byX[groupEnd].channelX - byX[groupStart].channelX) < SLOT_GROUP_TOLERANCE_PX
411
+ ) {
412
+ groupEnd++;
413
+ }
414
+ const group = byX.slice(groupStart, groupEnd);
415
+ // Within the group, greedy color by Y span. Sort by min Y then
416
+ // (deterministically) by orderIndex.
417
+ const sortedGroup = [...group].sort((a, b) => {
418
+ const aMin = Math.min(a.req.from.y, a.req.to.y);
419
+ const bMin = Math.min(b.req.from.y, b.req.to.y);
420
+ if (Math.abs(aMin - bMin) > 0.5) return aMin - bMin;
421
+ return a.orderIndex - b.orderIndex;
422
+ });
423
+ // Track per-slot maxY assigned so far.
424
+ const slotMaxY: number[] = [];
425
+ for (const entry of sortedGroup) {
426
+ const eMin = Math.min(entry.req.from.y, entry.req.to.y);
427
+ const eMax = Math.max(entry.req.from.y, entry.req.to.y);
428
+ let assigned = -1;
429
+ for (let s = 0; s < slotMaxY.length; s++) {
430
+ if (slotMaxY[s] <= eMin) {
431
+ assigned = s;
432
+ slotMaxY[s] = eMax;
433
+ break;
434
+ }
435
+ }
436
+ if (assigned === -1) {
437
+ assigned = slotMaxY.length;
438
+ slotMaxY.push(eMax);
439
+ }
440
+ slotsByOrderIndex.set(entry.orderIndex, assigned);
441
+ }
442
+ groupStart = groupEnd;
443
+ }
444
+ return slotsByOrderIndex;
445
+ }
446
+
447
+ /** Convert a slot index to its signed offset around the channel
448
+ * centerline. Slots 0/1/2/... become 0, +SLOT, -SLOT, +2*SLOT,
449
+ * -2*SLOT... so the FIRST edge sits on the centerline (no shift)
450
+ * and additional edges fan out alternately. Past 2 ± slots, slots
451
+ * collapse back to the centerline (visual stacking accepted). */
452
+ function slotOffset(slot: number): number {
453
+ if (slot === 0) return 0;
454
+ if (slot > 4) return 0; // collapse beyond ±2 slots
455
+ const half = Math.ceil(slot / 2);
456
+ const sign = slot % 2 === 1 ? 1 : -1;
457
+ return sign * half * SLOT_SPACING_PX;
458
+ }
459
+
460
+ /**
461
+ * Marker → item stub. Source X sits on the marker's vertical cut
462
+ * line; the stub is a short horizontal segment to the target's left
463
+ * visual edge at the target's row mid-Y. No channel selection, no
464
+ * obstacle check — the cut line is the stem.
465
+ */
466
+ function routeMarkerStub(req: EdgeRouteRequest): EdgeRouteResult {
467
+ // When the cut line sits AT or PAST the target's left edge (item
468
+ // hugs the anchor's date column), nudge the source 1 px back so
469
+ // routeEdge produces a non-degenerate path with a visible
470
+ // arrowhead.
471
+ const from = req.from.x >= req.to.x ? { x: req.to.x - 1, y: req.from.y } : req.from;
472
+ return {
473
+ fromId: req.fromId,
474
+ toId: req.toId,
475
+ waypoints: [from, req.to],
476
+ underBar: false,
477
+ };
478
+ }
479
+
480
+ /**
481
+ * Route every dependency edge in one batch so slot assignment can
482
+ * coordinate across edges that share a channel. Marker → item edges
483
+ * route as direct stubs and bypass the channel router.
484
+ */
485
+ export function routeChannelEdges(
486
+ requests: EdgeRouteRequest[],
487
+ grid: ChannelGrid,
488
+ ): EdgeRouteResult[] {
489
+ const results: EdgeRouteResult[] = new Array(requests.length);
490
+ const channelEdges: Array<{
491
+ req: EdgeRouteRequest;
492
+ channelX: number;
493
+ underBar: boolean;
494
+ orderIndex: number;
495
+ }> = [];
496
+
497
+ for (let i = 0; i < requests.length; i++) {
498
+ const req = requests[i];
499
+ if (req.isMarkerSource) {
500
+ results[i] = routeMarkerStub(req);
501
+ continue;
502
+ }
503
+ // Same-row contiguous chains drop entirely. The caller skips
504
+ // them before passing in, but defend against direct calls.
505
+ if (Math.abs(req.from.y - req.to.y) < 0.5 && req.to.x - req.from.x < 20) {
506
+ results[i] = {
507
+ fromId: req.fromId,
508
+ toId: req.toId,
509
+ waypoints: [req.from, req.to],
510
+ underBar: false,
511
+ };
512
+ continue;
513
+ }
514
+ const { x: provisionalX, underBar: provisionalUnderBar, range } = pickChannelX(req, grid);
515
+ const { x: channelX, forceUnderBar } = nudgeAwayFromBrackets(
516
+ provisionalX,
517
+ range,
518
+ req,
519
+ grid,
520
+ );
521
+ const underBar = provisionalUnderBar || forceUnderBar;
522
+ channelEdges.push({ req, channelX, underBar, orderIndex: i });
523
+ }
524
+
525
+ const slots = assignSlots(channelEdges);
526
+ for (const entry of channelEdges) {
527
+ const offset = slotOffset(slots.get(entry.orderIndex) ?? 0);
528
+ const slotX = entry.channelX + offset;
529
+ results[entry.orderIndex] = {
530
+ fromId: entry.req.fromId,
531
+ toId: entry.req.toId,
532
+ waypoints: buildOrthogonalPath(entry.req.from, entry.req.to, slotX),
533
+ underBar: entry.underBar,
534
+ };
535
+ }
536
+
537
+ return results;
538
+ }
539
+
540
+ /**
541
+ * Build the orthogonal polyline through a chosen vertical channel.
542
+ * Source-out-stub → vertical leg → target-in-stub. Same-row edges
543
+ * collapse to two points (handled by the caller's same-row skip).
544
+ */
545
+ function buildOrthogonalPath(from: Point, to: Point, slotX: number): Point[] {
546
+ if (Math.abs(from.y - to.y) < 0.5) {
547
+ return [from, to];
548
+ }
549
+ return [from, { x: slotX, y: from.y }, { x: slotX, y: to.y }, to];
550
+ }