nebulosity 1.0.0 → 1.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 (180) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +11 -11
  3. package/.next/diagnostics/route-bundle-stats.json +10 -10
  4. package/.next/fallback-build-manifest.json +3 -3
  5. package/.next/prerender-manifest.json +5 -5
  6. package/.next/server/app/_global-error/page/build-manifest.json +3 -3
  7. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/server/app/_global-error.html +1 -1
  9. package/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/server/app/_not-found/page/build-manifest.json +3 -3
  14. package/.next/server/app/_not-found/page/next-font-manifest.json +2 -3
  15. package/.next/server/app/_not-found/page.js +3 -2
  16. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  17. package/.next/server/app/_not-found.html +1 -1
  18. package/.next/server/app/_not-found.rsc +27 -20
  19. package/.next/server/app/_not-found.segments/_full.segment.rsc +27 -20
  20. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +34 -26
  21. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  22. package/.next/server/app/index.html +1 -1
  23. package/.next/server/app/index.rsc +29 -23
  24. package/.next/server/app/index.segments/__PAGE__.segment.rsc +33 -24
  25. package/.next/server/app/index.segments/_full.segment.rsc +29 -23
  26. package/.next/server/app/index.segments/_tree.segment.rsc +4 -5
  27. package/.next/server/app/page/build-manifest.json +3 -3
  28. package/.next/server/app/page/next-font-manifest.json +2 -3
  29. package/.next/server/app/page.js +4 -4
  30. package/.next/server/app/page_client-reference-manifest.js +1 -1
  31. package/.next/server/chunks/[root-of-the-server]__06blyow8irfw3._.js +3 -3
  32. package/.next/server/chunks/[root-of-the-server]__0bmqko-d8fd5k._.js +3 -3
  33. package/.next/server/chunks/[root-of-the-server]__0cmmlmvta4i0g._.js +3 -3
  34. package/.next/server/chunks/[root-of-the-server]__0jgr1dt1j95ku._.js +3 -3
  35. package/.next/server/chunks/[root-of-the-server]__0m0n163465-zb._.js +3 -3
  36. package/.next/server/chunks/[root-of-the-server]__0p7epsgp2b5m3._.js +3 -3
  37. package/.next/server/chunks/[root-of-the-server]__1i5e5sqg45ens._.js +3 -3
  38. package/.next/server/chunks/[root-of-the-server]__1vjt0neva-819._.js +3 -3
  39. package/.next/server/chunks/_0uqus0ehnijt-._.js +1 -1
  40. package/.next/server/chunks/lib_background_start_ts_11u_1uu8b9yuy._.js +1 -1
  41. package/.next/server/chunks/ssr/[root-of-the-server]__0_3l5tdzqy-td._.js +3 -0
  42. package/.next/server/chunks/ssr/[root-of-the-server]__10ockmlli-lun._.js +3 -0
  43. package/.next/server/chunks/ssr/{[root-of-the-server]__0dyligeswbb47._.js → [root-of-the-server]__16-gclvix9mjd._.js} +1 -1
  44. package/.next/server/chunks/ssr/[root-of-the-server]__1auodc3c0rppy._.js +3 -0
  45. package/.next/server/chunks/ssr/[root-of-the-server]__1cf6qt9agg_9-._.js +3 -3
  46. package/.next/server/chunks/ssr/[root-of-the-server]__1i9r59yk4i69p._.js +3 -0
  47. package/.next/server/chunks/ssr/[root-of-the-server]__1l3qp8u9yc_ig._.js +3 -0
  48. package/.next/server/chunks/ssr/_1p-0wsx9qfm68._.js +13 -0
  49. package/.next/server/chunks/ssr/app_map_18iu6j19jsqmu._.js +3 -0
  50. package/.next/server/chunks/ssr/node_modules_next_dist_1r3iqtbm45lqp._.js +3 -0
  51. package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_global-error_0q-w892nagajq.js +3 -0
  52. package/.next/server/middleware-build-manifest.js +11 -11
  53. package/.next/server/next-font-manifest.js +1 -1
  54. package/.next/server/next-font-manifest.json +4 -6
  55. package/.next/server/pages/404.html +1 -1
  56. package/.next/server/pages/500.html +1 -1
  57. package/.next/server/preview-props.json +3 -3
  58. package/.next/server/server-reference-manifest.js +1 -1
  59. package/.next/server/server-reference-manifest.json +1 -1
  60. package/.next/static/chunks/{1rljem7o1jtdo.js → 18kfexnrqek07.js} +1 -1
  61. package/.next/static/chunks/1hwbgd4nvvhg8.js +1 -0
  62. package/.next/static/chunks/25r4lkkf1shfo.css +3 -0
  63. package/.next/static/chunks/35ubvk2ivdjgs.js +1 -0
  64. package/.next/static/chunks/3ieqvmcg3hofj.js +31 -0
  65. package/.next/static/chunks/3w00e1w2125pp.js +11 -0
  66. package/.next/static/media/733da6bd8dac84c8-s.3hlisc-pwq13z.woff2 +0 -0
  67. package/.next/static/media/784318cea1dfcfa4-s.0-oo9_1x_xmvg.woff2 +0 -0
  68. package/.next/static/media/7e114457660b62ed-s.36fqqi66-3tjb.woff2 +0 -0
  69. package/.next/static/media/85e86a88f68a624a-s.357dd_6abeqge.woff2 +0 -0
  70. package/.next/static/media/9a321a2c78eff0b5-s.p.1bkzbscqrt8rl.woff2 +0 -0
  71. package/.next/static/media/9fecbd3e5ea9bccb-s.3_z45zcoc-xoz.woff2 +0 -0
  72. package/.next/static/media/c64053b510252f55-s.1kd-r_s9smv5m.woff2 +0 -0
  73. package/.next/static/media/c6a5c2fdc12da45c-s.p.17-aodiw50953.woff2 +0 -0
  74. package/.next/static/media/f263782c6a4bddee-s.1gyfqm5yhhzs7.woff2 +0 -0
  75. package/.next/trace +1 -1
  76. package/.next/trace-build +1 -1
  77. package/README.md +13 -4
  78. package/app/globals.css +91 -54
  79. package/app/layout.tsx +20 -11
  80. package/app/map/BackButton.tsx +5 -5
  81. package/app/map/BoxActions.tsx +20 -12
  82. package/app/map/Breadcrumb.tsx +132 -46
  83. package/app/map/DetailPanel.tsx +408 -297
  84. package/app/map/Explorer.tsx +268 -0
  85. package/app/map/Header.tsx +105 -0
  86. package/app/map/HelpDialog.tsx +37 -34
  87. package/app/map/Legend.tsx +19 -75
  88. package/app/map/LineCard.tsx +187 -0
  89. package/app/map/MapBox.tsx +73 -38
  90. package/app/map/MapCanvas.tsx +344 -115
  91. package/app/map/MapToolbar.tsx +377 -0
  92. package/app/map/MapView.tsx +217 -213
  93. package/app/map/RepoMenu.tsx +13 -12
  94. package/app/map/RoutedEdge.tsx +48 -16
  95. package/app/map/SearchPalette.tsx +6 -6
  96. package/app/map/SourceViewer.tsx +19 -17
  97. package/app/map/StatusBar.tsx +166 -0
  98. package/app/map/embed.ts +22 -2
  99. package/app/map/emphasis.ts +94 -8
  100. package/app/map/explorerRows.ts +85 -0
  101. package/app/map/flowNodes.ts +12 -7
  102. package/app/map/format.ts +2 -2
  103. package/app/map/history.ts +4 -3
  104. package/app/map/inspector.ts +113 -0
  105. package/app/map/labels.ts +66 -7
  106. package/app/map/narrow.ts +165 -85
  107. package/app/map/opening.ts +16 -0
  108. package/app/map/portals.ts +2 -2
  109. package/app/map/routes.ts +1060 -55
  110. package/app/map/runs.ts +108 -0
  111. package/app/map/shell.ts +91 -0
  112. package/app/map/shortcuts.ts +2 -1
  113. package/app/map/summaries.ts +41 -5
  114. package/app/map/text.ts +9 -1
  115. package/app/map/theme.ts +197 -0
  116. package/app/map/useDismiss.ts +20 -6
  117. package/app/map/useEditorBridge.ts +12 -1
  118. package/app/map/useThemeKind.ts +28 -0
  119. package/app/map/useWindowSize.ts +37 -0
  120. package/app/map/viewport.ts +134 -16
  121. package/app/map/weights.ts +80 -0
  122. package/app/page.tsx +3 -5
  123. package/lib/background/child.ts +2 -0
  124. package/lib/background/service.ts +26 -10
  125. package/lib/contracts.ts +25 -2
  126. package/lib/db/migrations/0011_plain_summaries_prompt_version.sql +1 -0
  127. package/lib/db/migrations/meta/0011_snapshot.json +1498 -0
  128. package/lib/db/migrations/meta/_journal.json +7 -0
  129. package/lib/db/schema.ts +2 -1
  130. package/lib/db/store/map.ts +6 -0
  131. package/lib/db/store/summaries.ts +14 -8
  132. package/lib/engine/describe.ts +2 -0
  133. package/lib/index/summarize/budget.ts +5 -2
  134. package/lib/index/summarize/claude.ts +19 -4
  135. package/lib/index/summarize/compare.ts +2 -2
  136. package/lib/index/summarize/plan.ts +73 -13
  137. package/lib/index/summarize/preview.ts +12 -9
  138. package/lib/index/summarize/prompts.ts +122 -25
  139. package/lib/index/summarize/run.ts +48 -18
  140. package/lib/index/summarize/snapshot.ts +7 -2
  141. package/lib/index/summarize/status.ts +3 -2
  142. package/lib/tools/text.ts +3 -2
  143. package/mcp/server.ts +1 -1
  144. package/package.json +4 -2
  145. package/scripts/commands/summarize-command.ts +27 -9
  146. package/.next/server/chunks/ssr/[root-of-the-server]__0ekw8p2ramybv._.js +0 -3
  147. package/.next/server/chunks/ssr/[root-of-the-server]__0fwbsqt9e2tr-._.js +0 -3
  148. package/.next/server/chunks/ssr/[root-of-the-server]__17_rqmx62cz59._.js +0 -3
  149. package/.next/server/chunks/ssr/[root-of-the-server]__1zfp6sltse4hf._.js +0 -3
  150. package/.next/server/chunks/ssr/_1d2woi6toj_0_._.js +0 -3
  151. package/.next/server/chunks/ssr/_1wfc1zz26g5ji._.js +0 -9
  152. package/.next/server/chunks/ssr/app_0gw6epmxyea3q._.js +0 -3
  153. package/.next/server/chunks/ssr/node_modules_@swc_helpers_cjs__interop_require_default_cjs_1ztp13a5szri_._.js +0 -3
  154. package/.next/server/chunks/ssr/node_modules_next_dist_1c5t_vsn6u8fr._.js +0 -3
  155. package/.next/static/chunks/02x9pxyl2d8om.css +0 -3
  156. package/.next/static/chunks/0p-0bv2dzer18.js +0 -7
  157. package/.next/static/chunks/10u7p2gj02qay.js +0 -1
  158. package/.next/static/chunks/31qp8jfkw0x2h.js +0 -31
  159. package/.next/static/media/123d516c9baeb953-s.1qc6dx89jd2qv.woff2 +0 -0
  160. package/.next/static/media/41ba59f553543ecf-s.3cb5lpr2l6xud.woff2 +0 -0
  161. package/.next/static/media/44ed10da57ebfb0e-s.2y65deuc30pmy.woff2 +0 -0
  162. package/.next/static/media/48eea41d8317494f-s.p.0wiir8udbzjvx.woff2 +0 -0
  163. package/.next/static/media/49b1d53681a91a99-s.1yd4_i3e35931.woff2 +0 -0
  164. package/.next/static/media/67bba87670b402d1-s.1u30mmyq6lnsd.woff2 +0 -0
  165. package/.next/static/media/6fca7e74603dd412-s.02z5rrbg5nt92.woff2 +0 -0
  166. package/.next/static/media/73263b2957b73100-s.2qt3tdwt-y9x_.woff2 +0 -0
  167. package/.next/static/media/7ac0b1cd3e695dd0-s.1i-sca43-qwng.woff2 +0 -0
  168. package/.next/static/media/916cd8a3bde3edf8-s.p.0oace-s_gkfks.woff2 +0 -0
  169. package/.next/static/media/a45aa1001bf9176f-s.2vemzm0itqzfy.woff2 +0 -0
  170. package/.next/static/media/a74d2542cc4c775f-s.29yke99gs9qpn.woff2 +0 -0
  171. package/.next/static/media/ab664c0e419cc841-s.3cvcemwuzoywn.woff2 +0 -0
  172. package/.next/static/media/c1b11b726b36a5e5-s.2rixgazldzomk.woff2 +0 -0
  173. package/.next/static/media/d6c1ff7e86974212-s.p.40sczeszzbjw1.woff2 +0 -0
  174. package/.next/static/media/faab88e4be272d29-s.0rgnxg2b64rzs.woff2 +0 -0
  175. package/app/map/ActivityBar.tsx +0 -81
  176. package/app/map/StatusPill.tsx +0 -93
  177. package/app/map/TopBar.tsx +0 -84
  178. /package/.next/static/{prDi039MhjlFqDOOwZ4aR → of6twfEZ3j95xtfQB_NSL}/_buildManifest.js +0 -0
  179. /package/.next/static/{prDi039MhjlFqDOOwZ4aR → of6twfEZ3j95xtfQB_NSL}/_clientMiddlewareManifest.js +0 -0
  180. /package/.next/static/{prDi039MhjlFqDOOwZ4aR → of6twfEZ3j95xtfQB_NSL}/_ssgManifest.js +0 -0
package/app/map/routes.ts CHANGED
@@ -1,77 +1,962 @@
1
1
  import type { MapEdge } from "@/lib/contracts";
2
- import { BOX_HEIGHT, BOX_WIDTH, GAP, RANK_GAP, type Position } from "./levelLayout";
2
+ import { BOX_HEIGHT, BOX_WIDTH, RANK_GAP, type Position } from "./levelLayout";
3
+ import { arrowHead, boxHeadWidths, LINE_MAX, lineWidth, maxImportsOf } from "./weights";
3
4
 
4
5
  export type Point = { x: number; y: number };
5
6
 
6
- /** How far a lane keeps from the boxes beside it: half the gap between boxes, so it runs down the middle of a gutter. */
7
- const LANE_CLEARANCE = GAP / 2;
7
+ /*
8
+ * The Map's line routing (VISUAL_REFRESH.md §4; the Workbench redesign, ADR-070 as amended). A line leaves its box's
9
+ * bottom, runs along the empty channel under that row, goes down (or up) a lane in a gutter clear of every box in the
10
+ * rows it passes, runs along the channel above its target, and drops into the target's top. So no line ever crosses a
11
+ * box, which the owner flags on sight.
12
+ *
13
+ * Lines that share an end merge (the owner, 2026-10-28): a box's leaving lines start from one point under it, and its
14
+ * arriving lines end at one point over it, in one arrowhead sized for the heaviest line into it (weights.ts). The lines
15
+ * into one box share a lane down each gutter and a track along each channel, and the lines out of one box share their
16
+ * first drop and, those going past the next row, a track along the channel under it, so each box's lines read as one
17
+ * trunk that forks where they part. Lines that share no end keep their own lanes and tracks, spaced for each line's
18
+ * width and for the arrowheads they pass over. A drawn line never moves (nextRoutes): a line that appears takes its
19
+ * boxes' points where a drawn line holds them, joins the lane and the tracks its group has on screen where it fits on
20
+ * them, and routes around everything else there. Pure and deterministic, like the layout it follows: the same level
21
+ * always routes the same way. Each line's count goes on a stretch of its route clear of everything else, or not at all
22
+ * (placeCounts).
23
+ */
24
+
25
+ /**
26
+ * How far a lane keeps from the boxes beside it, past half its own width, and from other lanes and tracks: room enough
27
+ * that lines running side by side read as separate lines rather than one mass (the owner, 2026-10-27, on src/vs).
28
+ */
29
+ const LANE_CLEARANCE = 16;
30
+ /** Where the first lane outside every box runs: this far from the outermost box's edge. */
31
+ const OUTER_LANE = 28;
32
+ /** The room between two lanes, and between two runs on neighbouring tracks, past half of each one's width. */
33
+ const LANE_GAP = 8;
34
+ const TRACK_GAP = 8;
35
+ /** The room between two runs on one track, end to end, past half of each one's width. */
36
+ const RUN_GAP = 4;
37
+ /** How far a box's points keep from its sides when they settle off its middle. */
38
+ const PORT_MARGIN = 20;
39
+ /** A track keeps this far below the row above it, so the line's corner has room. */
40
+ const TOP_ROOM = 12;
41
+ /** How far above the box's top edge an arrowhead's tip stops (RoutedEdge's TIP_GAP), and the corner above it. */
42
+ const TIP_GAP = 3;
43
+ const CORNER_ROOM = 8;
44
+ /** What's left of TOP_ROOM and CORNER_ROOM in a channel too crowded for them (placeTracks). */
45
+ const CROWDED_ROOM = 3;
8
46
  /** Where a line runs past the first or last row: as far out as half the gap between layers. */
9
47
  const OUTER_CHANNEL = RANK_GAP / 2;
10
48
  /** Corners of a route are rounded this much (VISUAL_REFRESH.md §7's 8 px radius). */
11
49
  const CORNER_RADIUS = 8;
50
+ /**
51
+ * A sideways step shorter than this is a wiggle, not a turn: the line drops straight instead, or turns at least this
52
+ * far (settle). Three corner radii, so a turn has a straight run between its two curves.
53
+ */
54
+ const LEAST_JOG = 24;
55
+ /**
56
+ * How many moves settle tries on one level before it stops, so a level of hundreds of lines still routes in a frame or
57
+ * two (src/vs/platform's 441 with every line drawn); what it hasn't fixed by then stays.
58
+ */
59
+ const SETTLE_TRIES = 4000;
60
+ /**
61
+ * What a hidden line between neighbouring rows weighs when a point settles, for the wiggle a move would leave between
62
+ * its two points: a tie-breaker, so any drawn line's fault counts for more, but a point that can settle either way
63
+ * settles where a hover drawing that line finds it straight. Without it, a point on src/vs settled 13 px off its box's
64
+ * middle, and selecting editor drew platform → editor with a 13 px wiggle between two held points.
65
+ */
66
+ const HIDDEN_WIGGLE = 0.01;
67
+ /**
68
+ * Spacing is compared this loosely: a place exactly one step from a lane is free, though the sums that find it round.
69
+ * Compared exactly, src/vs's gutters both counted as full, and workbench → base fell back onto a lane already there.
70
+ */
71
+ const EPSILON = 1e-6;
72
+ /**
73
+ * A line into a box joins a lane of the box's down a gutter when its own lane would run within this of it (the spike's
74
+ * cluster rule), so the lines into one box go down it as one rather than side by side.
75
+ */
76
+ export const MERGE_REACH = 60;
77
+ /**
78
+ * The room a count keeps from another line past its width, each side, and from another count, each side, so it never
79
+ * touches either (placeCounts).
80
+ */
81
+ const COUNT_LINE_ROOM = 2;
82
+ const COUNT_GAP = 3;
83
+ /**
84
+ * Where a count is tried along a stretch of its line, as shares of the room the label leaves on it: the middle first,
85
+ * then further out each way, so a count sits as near the middle of its stretch as it clearly can.
86
+ */
87
+ const COUNT_TRIES = [0.5, 0.3, 0.7, 0.15, 0.85, 0, 1];
88
+
89
+ /** One line, with what routing it needs: its width, its rows, and the channels it leaves and enters by. */
90
+ type Line = {
91
+ key: string;
92
+ edge: MapEdge;
93
+ width: number;
94
+ /** Its target's one arrowhead, which every line into that box shares. */
95
+ head: { half: number; length: number };
96
+ s: Position;
97
+ t: Position;
98
+ /** The channel under the source's row, and the one above the target's: channel k runs between rows k - 1 and k. */
99
+ outChannel: number;
100
+ intoChannel: number;
101
+ /** Set when the line passes a row between its channels (or points back up): the x of its vertical run. */
102
+ lane?: number;
103
+ /** The x of its source's leaving point and its target's arriving point, which it shares with every line there. */
104
+ portOut?: number;
105
+ portIn?: number;
106
+ };
107
+
108
+ /** One horizontal run of a line along a channel, with the ends where it turns up or down. */
109
+ type Run = {
110
+ line: Line;
111
+ channel: number;
112
+ /** It ends by dropping into its target's top, in its arrowhead. */
113
+ arrives: boolean;
114
+ /** Where it turns, and which way; `port` when that end is one of its boxes' points rather than its lane. */
115
+ ends: End[];
116
+ track?: number;
117
+ y?: number;
118
+ /** The runs it stands for, when it's their group's (groupRun): each takes its height. */
119
+ members?: Run[];
120
+ };
121
+ type Side = "out" | "in";
122
+ type End = { x: number; dir: "up" | "down"; port?: Side };
12
123
 
124
+ /** A box's point on one side, where every line it has on that side meets: held where it is while any of them is kept. */
125
+ type BoxPoint = { box: Position; side: Side; lines: Line[]; fixed: boolean };
13
126
  /**
14
- * The lane a vertical run takes past some boxes: the x nearest `preferred` that is at least LANE_CLEARANCE from every
15
- * one of them. The candidates are `preferred` itself and each box's two clearance lines; a tie goes to the lower x,
16
- * so the same level always routes the same way.
127
+ * A line on the level that isn't drawn, between neighbouring rows, by where a hover would draw it from and to: at each
128
+ * end its box's point on that side, or the box's middle where no drawn line has a point there, as a fresh one starts.
17
129
  */
18
- function freeLane(boxLefts: readonly number[], preferred: number): number {
19
- const blocked = boxLefts.map((left) => [left - LANE_CLEARANCE, left + BOX_WIDTH + LANE_CLEARANCE] as const);
20
- const free = (x: number): boolean => blocked.every(([lo, hi]) => x <= lo || x >= hi);
21
- const candidates = [preferred, ...blocked.flat()].filter(free);
130
+ type HiddenLine = Record<Side, BoxPoint | number>;
131
+
132
+ /**
133
+ * A lane down a gutter, and the lines on it: the lines into one box, the same way, share one (laneGroupOf). It spans
134
+ * the channels all its lines run between and is as wide as the widest, and it moves only as one, and only while none of
135
+ * its lines is kept. `rows` are the rows it passes, each a list of its boxes' left edges.
136
+ */
137
+ type Lane = { x: number; width: number; span: [number, number]; group: string; lines: Line[]; kept: boolean; rows: (readonly number[])[] };
138
+
139
+ /** A line's runs, from where its points and lane are now: one between neighbouring rows, else one at each end of its lane. */
140
+ function runsOf(line: Line): Run[] {
141
+ const down = line.intoChannel > line.outChannel;
142
+ if (line.lane === undefined){
143
+ return [{ line, channel: line.outChannel, arrives: true, ends: [{ x: line.portOut!, dir: "up", port: "out" }, { x: line.portIn!, dir: "down", port: "in" }] }];
144
+ }
145
+ return [
146
+ { line, channel: line.outChannel, arrives: false, ends: [{ x: line.portOut!, dir: "up", port: "out" }, { x: line.lane, dir: down ? "down" : "up" }] },
147
+ { line, channel: line.intoChannel, arrives: true, ends: [{ x: line.lane, dir: down ? "up" : "down" }, { x: line.portIn!, dir: "down", port: "in" }] },
148
+ ];
149
+ }
150
+
151
+ const centreOf = (box: Position): number => box.x + BOX_WIDTH / 2;
152
+ const byKey = (a: { key: string }, b: { key: string }): number => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
153
+ const spanOf = (run: Run): [number, number] => [Math.min(...run.ends.map((e) => e.x)), Math.max(...run.ends.map((e) => e.x))];
154
+ /** The lines that share a lane: those into one box, going the same way. */
155
+ const laneGroupOf = (line: Line): string => `${line.edge.target} ${line.intoChannel < line.outChannel ? "up" : "down"}`;
156
+ /**
157
+ * The runs that share a track in a channel: those into one box, and those out of one (a laned line's first run). A line
158
+ * between neighbouring rows has one run, which arrives, so it goes with its target's.
159
+ */
160
+ const trackGroupOf = (run: Run): string => (run.arrives ? `in ${run.line.edge.target}` : `out ${run.line.edge.source}`);
161
+ /**
162
+ * Whether a sideways step is a wiggle: under LEAST_JOG, and over half a pixel, since a step that small is rounding,
163
+ * which routing lines up exactly once the points and lanes have settled.
164
+ */
165
+ const isWiggle = (step: number): boolean => step > 0.5 && step < LEAST_JOG - EPSILON;
166
+
167
+ /** Moves a box's point, and with it every line it has on that side. */
168
+ function setPoint(point: BoxPoint, x: number): void {
169
+ for (const line of point.lines){
170
+ if (point.side === "out") line.portOut = x;
171
+ else line.portIn = x;
172
+ }
173
+ }
174
+
175
+ /** Moves a lane, and with it every line on it. */
176
+ function setLane(lane: Lane, x: number): void {
177
+ lane.x = x;
178
+ for (const line of lane.lines) line.lane = x;
179
+ }
180
+
181
+ /**
182
+ * The x a lane takes in the rows `rows` (each a list of its boxes' left edges): the free place nearest `preferred` that
183
+ * keeps LANE_CLEARANCE from every box and LANE_GAP from every lane already there over the same height. Between two
184
+ * boxes a lane starts at the gutter's middle; outside them all, OUTER_LANE out from the outermost box, and further out
185
+ * for each lane already there. A tie goes to the lower x, so the same level always routes the same way.
186
+ */
187
+ function placeLane(
188
+ rows: readonly (readonly number[])[],
189
+ preferred: number,
190
+ width: number,
191
+ span: [number, number],
192
+ taken: readonly { x: number; width: number; span: [number, number] }[],
193
+ ): number {
194
+ const lefts = rows.flat();
195
+ if (lefts.length === 0) return preferred;
196
+ // the x ranges a lane can't enter, merged and sorted: every box in the rows passed, widened by the clearance
197
+ const reach = width / 2 + LANE_CLEARANCE;
198
+ const blocked = lefts.map((left) => [left - reach, left + BOX_WIDTH + reach] as [number, number]).sort((a, b) => a[0] - b[0]);
199
+ const merged: [number, number][] = [];
200
+ for (const [lo, hi] of blocked){
201
+ const last = merged[merged.length - 1];
202
+ if (last && lo <= last[1]) last[1] = Math.max(last[1], hi);
203
+ else merged.push([lo, hi]);
204
+ }
205
+ // the lanes already placed that run over any of this one's height: each keeps its own place
206
+ const near = taken.filter((lane) => sharesHeight(lane.span, span));
207
+ const clear = (x: number): boolean => near.every((lane) => Math.abs(lane.x - x) >= (lane.width + width) / 2 + LANE_GAP - EPSILON);
208
+ const step = (other: number): number => (other + width) / 2 + LANE_GAP;
209
+ // the gutters: between each pair of blocked ranges, and outside them all
210
+ type Gutter = { lo: number; hi: number; start: number };
211
+ const gutters: Gutter[] = [];
212
+ const outerLeft = merged[0][0] + reach - OUTER_LANE;
213
+ gutters.push({ lo: -Infinity, hi: merged[0][0], start: outerLeft });
214
+ for (let i = 0; i + 1 < merged.length; i++){
215
+ const [lo, hi] = [merged[i][1], merged[i + 1][0]];
216
+ if (hi >= lo) gutters.push({ lo, hi, start: (lo + hi) / 2 });
217
+ }
218
+ const outerRight = merged[merged.length - 1][1] - reach + OUTER_LANE;
219
+ gutters.push({ lo: merged[merged.length - 1][1], hi: Infinity, start: outerRight });
220
+
221
+ // each gutter's best free place: its start, else as near it as the lanes there allow (outward, outside the boxes)
222
+ const candidates: number[] = [];
223
+ for (const g of gutters){
224
+ const tries = [g.start, ...near.flatMap((lane) => [lane.x - step(lane.width), lane.x + step(lane.width)])]
225
+ .filter((x) => x >= g.lo && x <= g.hi && clear(x))
226
+ .filter((x) => (g.lo === -Infinity ? x <= g.start : g.hi === Infinity ? x >= g.start : true));
227
+ if (tries.length > 0) candidates.push(tries.reduce((best, x) => (Math.abs(x - g.start) < Math.abs(best - g.start) ? x : best)));
228
+ }
229
+ // a gutter as wide as a lane but full: the lane still goes through, at its start, rather than across a box
230
+ if (candidates.length === 0) return gutters.reduce((best, g) => (Math.abs(g.start - preferred) < Math.abs(best - preferred) ? g.start : best), gutters[0].start);
22
231
  return candidates.reduce((best, x) => {
23
- const d = Math.abs(x - preferred);
24
- const bestD = Math.abs(best - preferred);
232
+ const [d, bestD] = [Math.abs(x - preferred), Math.abs(best - preferred)];
25
233
  return d < bestD || (d === bestD && x < best) ? x : best;
26
234
  });
27
235
  }
28
236
 
29
- /** Drops repeated points, and middle points on a straight run, so a route has only its real corners. */
30
- function simplify(points: readonly Point[]): Point[] {
31
- const distinct = points.filter((p, i) => i === 0 || p.x !== points[i - 1].x || p.y !== points[i - 1].y);
32
- return distinct.filter((p, i) => {
33
- if (i === 0 || i === distinct.length - 1) return true;
34
- const [a, b] = [distinct[i - 1], distinct[i + 1]];
35
- return !((a.x === p.x && p.x === b.x) || (a.y === p.y && p.y === b.y));
36
- });
237
+ /** Whether a lane at `x` keeps LANE_CLEARANCE from every box in `rows` and LANE_GAP from every lane in `taken` over its height. */
238
+ function laneFits(
239
+ rows: readonly (readonly number[])[],
240
+ x: number,
241
+ width: number,
242
+ span: [number, number],
243
+ taken: readonly { x: number; width: number; span: [number, number] }[],
244
+ ): boolean {
245
+ const reach = width / 2 + LANE_CLEARANCE;
246
+ const clearOfBoxes = rows.flat().every((left) => x <= left - reach || x >= left + BOX_WIDTH + reach);
247
+ const clearOfLanes = taken.every((lane) => !sharesHeight(lane.span, span) || Math.abs(lane.x - x) >= (lane.width + width) / 2 + LANE_GAP - EPSILON);
248
+ return clearOfBoxes && clearOfLanes;
249
+ }
250
+
251
+ /**
252
+ * Whether two lanes run over any of the same height, by the channels they run between. Lanes that only meet in one
253
+ * channel count: one comes down into it and the other leaves it going down, or both end there from either side, and at
254
+ * one x they'd read as one line straight through it.
255
+ */
256
+ const sharesHeight = (a: readonly [number, number], b: readonly [number, number]): boolean => a[0] <= b[1] && b[0] <= a[1];
257
+
258
+ /**
259
+ * The points and new lanes settle, a channel at a time, so no line wiggles and no two read as one. A sideways step
260
+ * shorter than LEAST_JOG is a wiggle, not a turn (the owner, 2026-10-27, on src/vs/editor's 460-import line), and a
261
+ * line that stops just above where another starts, at about the same x, reads as one line through the channel, whatever
262
+ * their ends. Two lines that share an end aren't in line where they fork, one turning at that end's point and the other
263
+ * going on through it, and nor are any two that meet where a line drops straight through the channel (while `trunks`
264
+ * is on), since that column is one true line. Each fault is fixed by moving a box's point, every line on that side of
265
+ * the box with it, inside the box's margins, or a new lane, every line on it with it, wherever it still clears the boxes
266
+ * and lanes beside it: onto its line's other end so the line drops straight, else LEAST_JOG from it, or just out of line
267
+ * with the other line. Of the moves at a run's two ends, the one that fixes the most faults its lines take part in is
268
+ * kept, the smallest of those, so the same level always settles the same way. A point a kept line uses, and a lane a
269
+ * kept line is on, stay where they are, and a fault no move fixes stays.
270
+ *
271
+ * The `hidden` lines between neighbouring rows weigh in when a point moves, since a hover may draw one between two
272
+ * points that are held by then: each the move would leave with a wiggle counts HIDDEN_WIGGLE, and its other end, and
273
+ * LEAST_JOG either side of it, are places the point may move to.
274
+ */
275
+ function settle(
276
+ lines: readonly Line[],
277
+ points: readonly BoxPoint[],
278
+ lanes: readonly Lane[],
279
+ fixed: ReadonlySet<Line>,
280
+ lanesClear: (lane: Lane, x: number) => boolean,
281
+ hidden: readonly HiddenLine[],
282
+ trunks: boolean,
283
+ ): void {
284
+ const pointOf: Record<Side, Map<Line, BoxPoint>> = { out: new Map(), in: new Map() };
285
+ for (const point of points) for (const line of point.lines) pointOf[point.side].set(line, point);
286
+ const laneOf = new Map<Line, Lane>();
287
+ for (const lane of lanes) for (const line of lane.lines) laneOf.set(line, lane);
288
+ const xOf = (point: BoxPoint): number => (point.side === "out" ? point.lines[0].portOut! : point.lines[0].portIn!);
289
+ // the hidden lines at each point, and how many of a point's it leaves wiggling where it is now
290
+ const hiddenAt = new Map<BoxPoint, HiddenLine[]>();
291
+ for (const h of hidden) for (const end of [h.out, h.in]) if (typeof end !== "number") hiddenAt.set(end, [...(hiddenAt.get(end) ?? []), h]);
292
+ const xAt = (end: BoxPoint | number): number => (typeof end === "number" ? end : xOf(end));
293
+ const hiddenWiggles = (point: BoxPoint): number => (hiddenAt.get(point) ?? []).filter((h) => isWiggle(Math.abs(xAt(h.out) - xAt(h.in)))).length;
294
+ const inChannel = new Map<number, Line[]>();
295
+ for (const line of lines){
296
+ for (const k of new Set([line.outChannel, line.intoChannel])) inChannel.set(k, [...(inChannel.get(k) ?? []), line]);
297
+ }
298
+ // each channel's runs, made once and brought up to date with their lines' points and lanes when read
299
+ const made = new Map<number, Run[]>();
300
+ const runsIn = (k: number): Run[] => {
301
+ if (!made.has(k)) made.set(k, inChannel.get(k)!.flatMap(runsOf).filter((r) => r.channel === k));
302
+ const runs = made.get(k)!;
303
+ // as runsOf makes them: point to point, else point to lane where it leaves and lane to point where it arrives
304
+ for (const { line, arrives, ends } of runs){
305
+ ends[0].x = line.lane === undefined || !arrives ? line.portOut! : line.lane;
306
+ ends[1].x = line.lane === undefined || arrives ? line.portIn! : line.lane;
307
+ }
308
+ return runs;
309
+ };
310
+ const isJog = (run: Run): boolean => isWiggle(Math.abs(run.ends[0].x - run.ends[1].x));
311
+ // a line turning up at about the x where another turns down: the one stops just above where the other starts
312
+ const nearOf = (a: Run, b: Run): number => (a.line.width + b.line.width) / 2 + LANE_GAP - EPSILON;
313
+ // two lines that share an end, one turning at that end's point and the other going on through it at the same x: a
314
+ // fork in the trunk they share
315
+ const forks = (a: Run, ea: End, b: Run, eb: End): boolean => Math.abs(ea.x - eb.x) < EPSILON
316
+ && ((a.line.edge.source === b.line.edge.source && (ea.port === "out" || eb.port === "out"))
317
+ || (a.line.edge.target === b.line.edge.target && (ea.port === "in" || eb.port === "in")));
318
+ // a line dropping straight through the channel at x: what meets there is on it
319
+ const throughAt = (runs: readonly Run[], x: number): boolean => trunks && runs.some((r) => Math.abs(r.ends[0].x - x) < EPSILON && Math.abs(r.ends[1].x - x) < EPSILON);
320
+ // whether run a's end ea and run b's end eb, turning opposite ways, leave the two lines in line
321
+ const inLine = (runs: readonly Run[], a: Run, ea: End, b: Run, eb: End): boolean => Math.abs(ea.x - eb.x) < nearOf(a, b)
322
+ && !forks(a, ea, b, eb) && !(Math.abs(ea.x - eb.x) < EPSILON && throughAt(runs, ea.x));
323
+ // the faults in channel k that the lines in `among` take part in (every line's, without): a wiggle counts 1, two
324
+ // lines lined up 2. The turns go in CELL-wide cells by x first, so each is only held against those near it.
325
+ const CELL = 16;
326
+ const faults = (k: number, among?: ReadonlySet<Line>): number => {
327
+ const runs = runsIn(k);
328
+ const cells = new Map<number, { run: Run; end: End }[]>();
329
+ for (const run of runs){
330
+ for (const end of run.ends){
331
+ const cell = Math.floor(end.x / CELL);
332
+ if (!cells.has(cell)) cells.set(cell, []);
333
+ cells.get(cell)!.push({ run, end });
334
+ }
335
+ }
336
+ const counted = (run: Run): boolean => among === undefined || among.has(run.line);
337
+ let count = 0;
338
+ for (const run of runs){
339
+ if (!counted(run)) continue;
340
+ if (isJog(run)) count += 1;
341
+ for (const end of run.ends){
342
+ // a counted line's turn up against every other line's turn down near it, and its turn down against the
343
+ // turns up of lines not counted (those of counted ones were met from their side)
344
+ const reach = run.line.width / 2 + LINE_MAX / 2 + LANE_GAP;
345
+ for (let cell = Math.floor((end.x - reach) / CELL); cell <= Math.floor((end.x + reach) / CELL); cell++){
346
+ for (const o of cells.get(cell) ?? []){
347
+ if (o.run.line === run.line || o.end.dir === end.dir || (end.dir === "down" && counted(o.run))) continue;
348
+ if (inLine(runs, run, end, o.run, o.end)) count += 2;
349
+ }
350
+ }
351
+ }
352
+ }
353
+ return count;
354
+ };
355
+
356
+ // a move: the lines it moves, the channels it changes, whether it keeps a lane clear of the boxes and lanes, and the
357
+ // point it moves, if it moves one
358
+ type Move = { cost: number; lines: Set<Line>; channels: number[]; apply: () => void; restore: () => void; valid: () => boolean; point?: BoxPoint };
359
+ // whether the end `i` of `run` is in a fault: its run wiggles, or another line turns the other way in line with it
360
+ const inFault = (runs: readonly Run[], run: Run, i: number): boolean => {
361
+ const end = run.ends[i];
362
+ return isJog(run) || runs.some((other) => other.line !== run.line && other.ends.some((o) => o.dir !== end.dir && inLine(runs, run, end, other, o)));
363
+ };
364
+ // the moves that might fix the fault the end `i` of `run` is in
365
+ const movesFor = (k: number, runs: readonly Run[], run: Run, i: number): Move[] => {
366
+ const end = run.ends[i];
367
+ const partner = run.ends[1 - i].x;
368
+ const line = run.line;
369
+ // onto its line's other end or LEAST_JOG from it, and just clear of each other line's turn the other way, whether
370
+ // that one's in the fault or not
371
+ const targets = new Set([partner, partner - LEAST_JOG, partner + LEAST_JOG]);
372
+ for (const other of runs){
373
+ if (other.line === line) continue;
374
+ const away = nearOf(run, other) + 2 * EPSILON;
375
+ for (const o of other.ends) if (o.dir !== end.dir) targets.add(o.x - away).add(o.x + away);
376
+ }
377
+ const moves: Move[] = [];
378
+ if (end.port !== undefined){
379
+ // its box's point, with every line on it, anywhere inside the box's margins
380
+ const point = pointOf[end.port].get(line)!;
381
+ if (point.fixed) return moves;
382
+ const was = xOf(point);
383
+ // and onto the other end of each hidden line it would start or end, or LEAST_JOG from it
384
+ for (const h of hiddenAt.get(point) ?? []){
385
+ const other = xAt(point.side === "out" ? h.in : h.out);
386
+ targets.add(other).add(other - LEAST_JOG).add(other + LEAST_JOG);
387
+ }
388
+ for (const x of targets){
389
+ if (Math.abs(x - was) < EPSILON || x < point.box.x + PORT_MARGIN - EPSILON || x > point.box.x + BOX_WIDTH - PORT_MARGIN + EPSILON) continue;
390
+ moves.push({ cost: Math.abs(x - was) * point.lines.length, lines: new Set(point.lines), channels: [k], apply: () => setPoint(point, x), restore: () => setPoint(point, was), valid: () => true, point });
391
+ }
392
+ } else {
393
+ // its lane, with every line on it, anywhere it still clears the boxes and lanes beside it
394
+ const lane = laneOf.get(line);
395
+ if (lane === undefined) return moves;
396
+ const from = lane.x;
397
+ const channels = [...new Set(lane.lines.flatMap((l) => [l.outChannel, l.intoChannel]))];
398
+ for (const x of targets){
399
+ if (Math.abs(x - from) < EPSILON) continue;
400
+ moves.push({ cost: Math.abs(x - from) * lane.lines.length, lines: new Set(lane.lines), channels, apply: () => setLane(lane, x), restore: () => setLane(lane, from), valid: () => lanesClear(lane, x) });
401
+ }
402
+ }
403
+ return moves;
404
+ };
405
+ // what a move's lines take part in, over the channels it changes, and a moved point's hidden lines, a little
406
+ const share = (move: Move): number => move.channels.reduce((sum, c) => sum + faults(c, move.lines), 0)
407
+ + (move.point === undefined ? 0 : HIDDEN_WIGGLE * hiddenWiggles(move.point));
408
+
409
+ // a channel at a time, a run at a time, the wiggles first (moving one straight often clears the lines in line with it
410
+ // too): of the moves at either end of a run in a fault, the one that fixes the most is kept, the smallest of those.
411
+ // Up to three passes, and none after SETTLE_TRIES moves have been tried.
412
+ let tried = 0;
413
+ for (let pass = 0; pass < 3 && tried < SETTLE_TRIES; pass++){
414
+ let moved = false;
415
+ for (const k of [...inChannel.keys()].sort((a, b) => a - b)){
416
+ const runs = runsIn(k);
417
+ for (const run of [...runs.filter(isJog), ...runs.filter((r) => !isJog(r))]){
418
+ if (tried > SETTLE_TRIES) break;
419
+ if (fixed.has(run.line)) continue;
420
+ runsIn(k);
421
+ const ends = [0, 1].filter((i) => inFault(runs, run, i));
422
+ if (ends.length === 0) continue;
423
+ let best: { move: Move; gain: number } | null = null;
424
+ for (const move of ends.flatMap((i) => movesFor(k, runs, run, i)).sort((a, b) => a.cost - b.cost)){
425
+ if (++tried > SETTLE_TRIES) break;
426
+ const before = share(move);
427
+ move.apply();
428
+ const gain = move.valid() ? before - share(move) : 0;
429
+ move.restore();
430
+ if (gain > 0 && (best === null || gain > best.gain)) best = { move, gain };
431
+ }
432
+ if (best !== null){
433
+ best.move.apply();
434
+ moved = true;
435
+ }
436
+ }
437
+ }
438
+ if (!moved) break;
439
+ }
440
+ }
441
+
442
+ /**
443
+ * The order runs take down a channel, top to bottom, so their turns cross as few other runs as they can. A run that
444
+ * turns up at an x another run passes over should be above it, and one that turns down there below it (a channel
445
+ * router's vertical constraints); a run that turns up just where another turns down must be above it, or their drops
446
+ * would share a run, which is worse than any crossing. So the musts are kept first, and where the shoulds ask for a
447
+ * loop, the run with the fewest runs that should be above it goes next, the thinner first, so a heavy line sinks to the
448
+ * bottom, nearest its target.
449
+ */
450
+ function trackOrder(runs: readonly Run[]): Run[] {
451
+ // above.get(j) holds every run that should be above j, and mustAbove.get(j) every run that must be
452
+ const above = new Map<Run, Set<Run>>(runs.map((r) => [r, new Set<Run>()]));
453
+ const mustAbove = new Map<Run, Set<Run>>(runs.map((r) => [r, new Set<Run>()]));
454
+ for (const i of runs){
455
+ for (const j of runs){
456
+ if (i === j) continue;
457
+ if (sharesDrop(i, j)) mustAbove.get(j)!.add(i);
458
+ else if (mustBeAbove(i, j)) above.get(j)!.add(i);
459
+ }
460
+ }
461
+ const order: Run[] = [];
462
+ const left = new Set(runs);
463
+ while (left.size > 0){
464
+ const waiting = (r: Run): number => [...above.get(r)!].filter((a) => left.has(a)).length;
465
+ const free = [...left].filter((r) => [...mustAbove.get(r)!].every((a) => !left.has(a)));
466
+ const next = (free.length > 0 ? free : [...left]).sort((a, b) => waiting(a) - waiting(b) || a.line.width - b.line.width || byKey(a.line, b.line))[0];
467
+ order.push(next);
468
+ left.delete(next);
469
+ }
470
+ return order;
471
+ }
472
+
473
+ /**
474
+ * One run standing for a channel's runs of one group (trackGroupOf), so they take one track as one line: every end they
475
+ * have, the widest one's width, and arriving when they arrive. Its line is the widest's, whose head is their box's, as
476
+ * all of theirs are. A group of one is its own run.
477
+ */
478
+ function groupRun(group: readonly Run[]): Run {
479
+ if (group.length === 1) return group[0];
480
+ const widest = group.reduce((best, r) => (r.line.width > best.line.width || (r.line.width === best.line.width && byKey(r.line, best.line) < 0) ? r : best));
481
+ const ends = group.flatMap((r) => r.ends).filter((e, i, all) => all.findIndex((o) => Math.abs(o.x - e.x) < EPSILON && o.dir === e.dir) === i);
482
+ return { line: widest.line, channel: widest.channel, arrives: widest.arrives, ends, members: [...group] };
483
+ }
484
+
485
+ /**
486
+ * Every drawn line's route on a laid-out level, by edge key ("source->target"). Rows are the boxes' tops, and every box
487
+ * with a line on the level stands in the way of a lane, drawn or not; the loose ones sit apart in their grid. `drawn`
488
+ * names the lines to route (all of them when it's left out): points, lanes and tracks are placed for those alone, so a
489
+ * line that isn't drawn leaves no gap. Widths are scaled against the level's heaviest line, drawn or not, and so is
490
+ * each box's arrowhead.
491
+ *
492
+ * `kept` holds routes already on screen, for lines that stay drawn: each keeps its own exactly, and the other lines take
493
+ * the points they hold, join their groups' lanes and tracks where they fit on them, and are routed around the rest, in
494
+ * each gutter and in each channel (nextRoutes).
495
+ *
496
+ * The level is routed with straight trunks first: two lines that meet where a line drops straight through a channel
497
+ * aren't taken for two lines in line, since the column is one line. Where that leaves a new line on one drop with a line
498
+ * it shares no end with, which would read as one line, the level is routed again without that, so where two trunks
499
+ * cross they keep apart (build/azure-pipelines/oss). Either way the same level always routes the same way.
500
+ */
501
+ export function routeEdges(
502
+ positions: ReadonlyMap<string, Position>,
503
+ edges: readonly MapEdge[],
504
+ drawn?: ReadonlySet<string>,
505
+ kept: ReadonlyMap<string, Point[]> = new Map(),
506
+ ): Map<string, Point[]> {
507
+ const routes = route(positions, edges, drawn, kept, true);
508
+ const ends = new Map(edges.map((e) => [`${e.source}->${e.target}`, e]));
509
+ return dropShared(routes, ends, kept) ? route(positions, edges, drawn, kept, false) : routes;
37
510
  }
38
511
 
39
512
  /**
40
- * Every line's route on a laid-out level, by edge key ("source->target"). The owner's review of the first refresh
41
- * build found lines cutting through boxes: React Flow draws a curve straight from one box to the other, whatever lies
42
- * between. These routes can't: a line leaves its box's bottom, runs along the empty channel under that row, goes down
43
- * (or up) a lane clear of every box in the rows it passes, runs along the channel above its target, and drops into the
44
- * target's top. Rows are the boxes' tops; only boxes with lines count, since the loose ones sit apart in their grid.
45
- * Pure and deterministic, like the layout it follows.
513
+ * Whether a new route runs down one x together with a route it shares no end with, for more than a pixel. A kept route
514
+ * comes back as the same object, so any other is new.
46
515
  */
47
- export function routeEdges(positions: ReadonlyMap<string, Position>, edges: readonly MapEdge[]): Map<string, Point[]> {
48
- const linked = [...new Set(edges.flatMap((e) => [e.source, e.target]))].filter((p) => positions.has(p));
516
+ function dropShared(routes: ReadonlyMap<string, Point[]>, ends: ReadonlyMap<string, MapEdge>, kept: ReadonlyMap<string, Point[]>): boolean {
517
+ const byX = new Map<number, { key: string; fresh: boolean; lo: number; hi: number }[]>();
518
+ for (const [key, points] of routes){
519
+ const fresh = points !== kept.get(key);
520
+ for (let i = 1; i < points.length; i++){
521
+ const [p, q] = [points[i - 1], points[i]];
522
+ if (p.x !== q.x) continue;
523
+ if (!byX.has(p.x)) byX.set(p.x, []);
524
+ byX.get(p.x)!.push({ key, fresh, lo: Math.min(p.y, q.y), hi: Math.max(p.y, q.y) });
525
+ }
526
+ }
527
+ for (const drops of byX.values()){
528
+ for (let i = 0; i < drops.length; i++){
529
+ for (let j = i + 1; j < drops.length; j++){
530
+ const [a, b] = [drops[i], drops[j]];
531
+ if (a.key === b.key || (!a.fresh && !b.fresh)) continue;
532
+ const [ea, eb] = [ends.get(a.key)!, ends.get(b.key)!];
533
+ if (ea.source === eb.source || ea.target === eb.target) continue;
534
+ if (Math.min(a.hi, b.hi) - Math.max(a.lo, b.lo) > 1) return true;
535
+ }
536
+ }
537
+ }
538
+ return false;
539
+ }
540
+
541
+ /** routeEdges's routes, with lines that meet on a straight trunk through a channel let off as in line or not (`trunks`). */
542
+ function route(
543
+ positions: ReadonlyMap<string, Position>,
544
+ edges: readonly MapEdge[],
545
+ drawn: ReadonlySet<string> | undefined,
546
+ kept: ReadonlyMap<string, Point[]>,
547
+ trunks: boolean,
548
+ ): Map<string, Point[]> {
549
+ const maxImports = maxImportsOf(edges);
550
+ const heads = boxHeadWidths(edges);
551
+ const onLevel = edges.filter((e) => e.source !== e.target && positions.has(e.source) && positions.has(e.target));
552
+ const routable = drawn === undefined ? onLevel : onLevel.filter((e) => drawn.has(`${e.source}->${e.target}`));
553
+ const linked = [...new Set(onLevel.flatMap((e) => [e.source, e.target]))];
49
554
  const tops = [...new Set(linked.map((p) => positions.get(p)!.y))].sort((a, b) => a - b);
50
- const lefts = new Map(tops.map((y) => [y, linked.filter((p) => positions.get(p)!.y === y).map((p) => positions.get(p)!.x)]));
51
- // the channel under row i runs halfway down the gap to the next row; the last row's runs half a rank gap below it
52
- const below = (i: number): number => (i + 1 < tops.length ? (tops[i] + BOX_HEIGHT + tops[i + 1]) / 2 : tops[i] + BOX_HEIGHT + OUTER_CHANNEL);
53
- const above = (i: number): number => (i > 0 ? below(i - 1) : tops[0] - OUTER_CHANNEL);
555
+ const lefts = tops.map((y) => linked.filter((p) => positions.get(p)!.y === y).map((p) => positions.get(p)!.x));
556
+ const row = (box: Position): number => tops.indexOf(box.y);
54
557
 
558
+ const lines: Line[] = routable.map((edge) => {
559
+ const s = positions.get(edge.source)!;
560
+ const t = positions.get(edge.target)!;
561
+ const width = lineWidth(edge.count, maxImports);
562
+ return { key: `${edge.source}->${edge.target}`, edge, width, head: arrowHead(heads.get(edge.target)!), s, t, outChannel: row(s) + 1, intoChannel: row(t) };
563
+ });
564
+ // the kept lines take their points, lane and tracks from their routes, and stay where they are
565
+ const fixed = new Map<Line, Point[]>();
566
+ for (const line of lines){
567
+ const route = kept.get(line.key);
568
+ if (route !== undefined && takeRoute(line, route, tops)) fixed.set(line, route);
569
+ }
570
+ const fresh = lines.filter((l) => !fixed.has(l));
571
+ // the points the kept lines hold, by box
572
+ const heldOut = new Map<string, number>();
573
+ const heldIn = new Map<string, number>();
574
+ for (const line of fixed.keys()){
575
+ heldOut.set(line.edge.source, line.portOut!);
576
+ heldIn.set(line.edge.target, line.portIn!);
577
+ }
578
+
579
+ // 1. lanes, for the lines that pass a row between their channels or point back up. The kept lines' lanes are there
580
+ // from the start, one to each group and x, and their points stand in their channels as lanes do: a new lane can't line
581
+ // up with one there, since a kept point doesn't move out of its way
582
+ const spanOfLine = (line: Line): [number, number] => [Math.min(line.outChannel, line.intoChannel), Math.max(line.outChannel, line.intoChannel)];
583
+ // the rows a lane passes, by the channels it runs between. A group's lines all end in their target's channel, so the
584
+ // longest's rows are every one's
585
+ const rowsOf = (span: [number, number]): (readonly number[])[] => lefts.slice(span[0], span[1]);
586
+ const lanes: Lane[] = [];
587
+ const widen = (lane: Lane, line: Line, span: [number, number]): void => {
588
+ lane.lines.push(line);
589
+ lane.width = Math.max(lane.width, line.width);
590
+ lane.span = [Math.min(lane.span[0], span[0]), Math.max(lane.span[1], span[1])];
591
+ lane.rows = rowsOf(lane.span);
592
+ };
593
+ const heldPoints: { x: number; width: number; span: [number, number] }[] = [];
594
+ for (const line of fixed.keys()){
595
+ if (line.lane !== undefined){
596
+ const [group, span] = [laneGroupOf(line), spanOfLine(line)];
597
+ const same = lanes.find((l) => l.group === group && Math.abs(l.x - line.lane!) < EPSILON);
598
+ if (same !== undefined) widen(same, line, span);
599
+ else lanes.push({ x: line.lane, width: line.width, span, group, lines: [line], kept: true, rows: rowsOf(span) });
600
+ }
601
+ heldPoints.push({ x: line.portOut!, width: line.width, span: [line.outChannel, line.outChannel] }, { x: line.portIn!, width: line.width, span: [line.intoChannel, line.intoChannel] });
602
+ }
603
+ // what a lane keeps clear of: the kept points, and every lane but `lane` itself and, when `group` is given, its group's
604
+ const others = (lane: Lane | null, group?: string): { x: number; width: number; span: [number, number] }[] =>
605
+ [...heldPoints, ...lanes.filter((l) => l !== lane && (group === undefined || l.group !== group))];
606
+ // the new lines' lanes, each as near the middle of its two boxes as it can be, placed in the order of those middles
607
+ const laned = fresh.filter((l) => l.outChannel !== l.intoChannel)
608
+ .sort((a, b) => (centreOf(a.s) + centreOf(a.t)) - (centreOf(b.s) + centreOf(b.t)) || byKey(a, b));
609
+ for (const line of laned){
610
+ // its height as the channels it runs between: two lanes overlap when they share any of it (sharesHeight)
611
+ const span = spanOfLine(line);
612
+ const rows = rowsOf(span);
613
+ const group = laneGroupOf(line);
614
+ const preferred = (centreOf(line.s) + centreOf(line.t)) / 2;
615
+ // its own lane as if its group had none there, and whether that's clear of the lanes it would keep from
616
+ const own = placeLane(rows, preferred, line.width, span, others(null, group));
617
+ const ownFits = laneFits(rows, own, line.width, span, others(null, group));
618
+ // a lane its kept leaving point would step over to by less than a jog would leave it a wiggle
619
+ const from = heldOut.get(line.edge.source);
620
+ const wiggles = (x: number): boolean => from !== undefined && isWiggle(Math.abs(from - x));
621
+ // it joins its group's lane nearest its own, a kept one or a new one, within reach, when that leaves no wiggle and
622
+ // either the lane's clear over all its rows, its own isn't clear either (a full gutter), or its own would crowd it
623
+ // while the lane clears the boxes and, joined, runs no more than a pixel nearer any other lane than its own would
624
+ const join = lanes.filter((lane) => lane.group === group && Math.abs(lane.x - own) < MERGE_REACH && !wiggles(lane.x)
625
+ && (!ownFits || laneFits(rows, lane.x, line.width, span, others(lane))
626
+ || (Math.abs(lane.x - own) < (lane.width + line.width) / 2 + LANE_GAP && laneFits(rows, lane.x, line.width, span, [])
627
+ && others(lane).every((o) => !sharesHeight(o.span, span) || Math.abs(o.x - lane.x) >= Math.min(Math.abs(o.x - own), (o.width + line.width) / 2 + LANE_GAP) - 1))))
628
+ .sort((a, b) => Math.abs(a.x - own) - Math.abs(b.x - own) || a.x - b.x)[0];
629
+ if (join !== undefined){
630
+ line.lane = join.x;
631
+ widen(join, line, span);
632
+ continue;
633
+ }
634
+ // else a lane of its own, clear of every lane there, its group's too
635
+ line.lane = ownFits && laneFits(rows, own, line.width, span, others(null)) ? own : placeLane(rows, preferred, line.width, span, others(null));
636
+ lanes.push({ x: line.lane, width: line.width, span, group, lines: [line], kept: false, rows });
637
+ }
638
+
639
+ // 2. points: one under each box for all its leaving lines and one over it for all its arriving lines, at its middle,
640
+ // or where a kept line holds it
641
+ for (const line of fresh){
642
+ line.portOut = heldOut.get(line.edge.source) ?? centreOf(line.s);
643
+ line.portIn = heldIn.get(line.edge.target) ?? centreOf(line.t);
644
+ }
645
+ const pointAt = new Map<string, BoxPoint>();
646
+ const pointOf = (line: Line, side: Side): BoxPoint => {
647
+ const path = side === "out" ? line.edge.source : line.edge.target;
648
+ const id = `${side} ${path}`;
649
+ if (!pointAt.has(id)) pointAt.set(id, { box: positions.get(path)!, side, lines: [], fixed: (side === "out" ? heldOut : heldIn).has(path) });
650
+ return pointAt.get(id)!;
651
+ };
652
+ for (const line of lines) for (const side of ["out", "in"] as const) pointOf(line, side).lines.push(line);
653
+
654
+ // a lane still of one new line moves right under its leaving point (or over its arriving one) when that fits, so the
655
+ // line drops straight out of its box (or into it) instead of jogging over to the gutter's middle
656
+ const lanesClear = (lane: Lane, x: number): boolean => laneFits(lane.rows, x, lane.width, lane.span, others(lane));
657
+ const newLanes = lanes.filter((l) => !l.kept);
658
+ for (const lane of newLanes){
659
+ if (lane.lines.length !== 1) continue;
660
+ const [line] = lane.lines;
661
+ const straight = [line.portOut!, line.portIn!].find((x) => lanesClear(lane, x));
662
+ if (straight !== undefined) setLane(lane, straight);
663
+ }
664
+
665
+ // then the new lines' points and lanes settle: no short jogs, and no two lines lined up end to end. The lines not
666
+ // drawn between neighbouring rows weigh in a little, from the points they'd join, so a hover finds them straight
667
+ const hidden: HiddenLine[] = drawn === undefined ? [] : onLevel
668
+ .filter((e) => !drawn.has(`${e.source}->${e.target}`) && row(positions.get(e.target)!) === row(positions.get(e.source)!) + 1)
669
+ .map((e) => ({ out: pointAt.get(`out ${e.source}`) ?? centreOf(positions.get(e.source)!), in: pointAt.get(`in ${e.target}`) ?? centreOf(positions.get(e.target)!) }));
670
+ settle(lines, [...pointAt.values()], newLanes, new Set(fixed.keys()), lanesClear, hidden, trunks);
671
+ // ends that line up to within rounding line up exactly, so a straight drop has no hairline step: a new line between
672
+ // neighbouring rows has its free point take the other's x, and then a new lane takes the x of its lines' point it's
673
+ // that near, unless it's already on one
674
+ for (const line of fresh){
675
+ const step = Math.abs(line.portOut! - line.portIn!);
676
+ if (line.lane !== undefined || step === 0 || step > 0.5) continue;
677
+ const [out, into] = [pointOf(line, "out"), pointOf(line, "in")];
678
+ if (!into.fixed) setPoint(into, line.portOut!);
679
+ else if (!out.fixed) setPoint(out, line.portIn!);
680
+ }
681
+ for (const lane of newLanes){
682
+ const xs = lane.lines.flatMap((l) => [l.portIn!, l.portOut!]);
683
+ const near = xs.find((x) => Math.abs(lane.x - x) <= 0.5);
684
+ if (near !== undefined && !xs.includes(lane.x)) setLane(lane, near);
685
+ }
686
+
687
+ // 3. runs along each channel, and the tracks they take: a group's runs (trackGroupOf) as one, joining its kept tracks
688
+ // or around the kept ones where the channel has any
689
+ const runs = fresh.flatMap(runsOf);
690
+ const keptRuns = [...fixed.keys()].flatMap((line) => runsOf(line).map((run) => ({ ...run, y: trackOf(fixed.get(line)!, run.channel, tops) })));
691
+ const byChannel = new Map<number, Run[]>();
692
+ for (const run of runs){
693
+ // a run whose two ends line up is a straight drop through the channel: it needs no track
694
+ if (Math.abs(run.ends[0].x - run.ends[1].x) < EPSILON) continue;
695
+ byChannel.set(run.channel, [...(byChannel.get(run.channel) ?? []), run]);
696
+ }
697
+ for (const [channel, inChannel] of byChannel){
698
+ const groups = new Map<string, Run[]>();
699
+ for (const run of inChannel) groups.set(trackGroupOf(run), [...(groups.get(trackGroupOf(run)) ?? []), run]);
700
+ const around: Run[] = keptRuns.filter((r) => r.channel === channel && r.y !== undefined);
701
+ // a new run whose group has kept runs here joins one of their tracks where what it runs past them fits (joins),
702
+ // the height it reaches least past them first, then the lowest y, the highest up. The group's other new runs
703
+ // go round them all as one
704
+ const arrivals = lines.filter((l) => l.intoChannel === channel);
705
+ const left: Run[][] = [];
706
+ for (const [group, members] of groups){
707
+ const groupKept = around.filter((r) => trackGroupOf(r) === group);
708
+ const otherRuns = around.filter((r) => trackGroupOf(r) !== group);
709
+ const unjoined: Run[] = [];
710
+ for (const run of members){
711
+ const [lo, hi] = spanOf(run);
712
+ const heights = [...new Set(groupKept.map((r) => r.y!))].map((y) => {
713
+ const spans = groupKept.filter((r) => r.y === y).map(spanOf);
714
+ const covered: [number, number] = [Math.min(...spans.map((s) => s[0])), Math.max(...spans.map((s) => s[1]))];
715
+ return { y, covered, past: Math.max(0, covered[0] - lo) + Math.max(0, hi - covered[1]) };
716
+ }).sort((a, b) => a.past - b.past || a.y - b.y);
717
+ const height = heights.find((h) => joins(run, h.y, h.covered, otherRuns, arrivals, channel, tops));
718
+ if (height === undefined){
719
+ unjoined.push(run);
720
+ continue;
721
+ }
722
+ run.y = height.y;
723
+ around.push(run);
724
+ }
725
+ if (unjoined.length > 0) left.push(unjoined);
726
+ }
727
+ const grouped = left.map(groupRun);
728
+ if (around.length > 0){
729
+ placeRunsAround(channel, grouped, around, tops);
730
+ } else {
731
+ // tracks in order, each run on the first track at or below the ones that must be above it that it fits on
732
+ const tracks: Run[][] = [];
733
+ for (const run of trackOrder(grouped)){
734
+ const [lo, hi] = spanOf(run);
735
+ const fits = (track: Run[]): boolean => track.every((other) => {
736
+ const [olo, ohi] = spanOf(other);
737
+ const room = (other.line.width + run.line.width) / 2 + RUN_GAP;
738
+ return hi + room <= olo || ohi + room <= lo;
739
+ });
740
+ const after = Math.max(-1, ...tracks.flatMap((track, k) => (track.some((r) => mustBeAbove(r, run)) ? [k] : [])));
741
+ let k = after + 1;
742
+ while (k < tracks.length && !fits(tracks[k])) k++;
743
+ if (k === tracks.length) tracks.push([]);
744
+ tracks[k].push(run);
745
+ run.track = k;
746
+ }
747
+ placeTracks(channel, tracks, tops);
748
+ }
749
+ // each line's run takes its group's height
750
+ for (const run of grouped) for (const member of run.members ?? []) member.y = run.y;
751
+ }
752
+
753
+ // 4. every line's corners, in order: a kept line's as they were, the same object, so React Flow keeps it as it is
55
754
  const routes = new Map<string, Point[]>();
56
- for (const edge of edges){
57
- const s = positions.get(edge.source);
58
- const t = positions.get(edge.target);
59
- if (!s || !t) continue;
60
- const start = { x: s.x + BOX_WIDTH / 2, y: s.y + BOX_HEIGHT };
61
- const end = { x: t.x + BOX_WIDTH / 2, y: t.y };
62
- const out = below(tops.indexOf(s.y));
63
- const into = above(tops.indexOf(t.y));
64
- // the rows the lane passes: every row whose boxes lie between the two channels
65
- const [from, to] = [Math.min(out, into), Math.max(out, into)];
66
- const passed = tops.filter((y) => y > from && y < to).flatMap((y) => lefts.get(y)!);
67
- const lane = freeLane(passed, (start.x + end.x) / 2);
68
- routes.set(`${edge.source}->${edge.target}`, simplify([
69
- start, { x: start.x, y: out }, { x: lane, y: out }, { x: lane, y: into }, { x: end.x, y: into }, end,
70
- ]));
755
+ const runsByLine = new Map<Line, Run[]>();
756
+ for (const run of runs) runsByLine.set(run.line, [...(runsByLine.get(run.line) ?? []), run]);
757
+ for (const line of lines){
758
+ if (fixed.has(line)){
759
+ routes.set(line.key, fixed.get(line)!);
760
+ continue;
761
+ }
762
+ const [first, second] = runsByLine.get(line)!;
763
+ const start = { x: line.portOut!, y: line.s.y + BOX_HEIGHT };
764
+ const end = { x: line.portIn!, y: line.t.y };
765
+ const yOf = (run: Run): number => run.y ?? channelMiddle(run.channel, tops);
766
+ const points = line.lane === undefined
767
+ ? [start, { x: start.x, y: yOf(first) }, { x: end.x, y: yOf(first) }, end]
768
+ : [start, { x: start.x, y: yOf(first) }, { x: line.lane, y: yOf(first) }, { x: line.lane, y: yOf(second) }, { x: end.x, y: yOf(second) }, end];
769
+ routes.set(line.key, simplify(points));
71
770
  }
72
771
  return routes;
73
772
  }
74
773
 
774
+ /**
775
+ * The routes to draw when the lines drawn change to `drawn` from what's on screen, `before` (the owner, 2026-10-27:
776
+ * pointing at a box rearranged lines already there). The lines drawn at rest keep their routes from `rest`, every
777
+ * other line still drawn keeps the one it had, and the lines that just appeared join them where they share an end and
778
+ * fit, and are routed around them all elsewhere (routeEdges with them kept). So a line never moves while it's drawn:
779
+ * lines appear and fade, and that's all.
780
+ */
781
+ export function nextRoutes(
782
+ positions: ReadonlyMap<string, Position>,
783
+ edges: readonly MapEdge[],
784
+ rest: ReadonlyMap<string, Point[]>,
785
+ before: ReadonlyMap<string, Point[]>,
786
+ drawn: ReadonlySet<string>,
787
+ ): Map<string, Point[]> {
788
+ const kept = new Map<string, Point[]>();
789
+ for (const [key, route] of before) if (drawn.has(key)) kept.set(key, route);
790
+ for (const [key, route] of rest) if (drawn.has(key)) kept.set(key, route);
791
+ return routeEdges(positions, edges, drawn, kept);
792
+ }
793
+
794
+ /**
795
+ * A kept line's points and lane, read back from its route (routeEdges's kept): its first point is its leaving point,
796
+ * its last its arriving one, and its lane is the drop that passes the middle of the first row between its channels.
797
+ * False when the route isn't one this level could have given it, so the line is routed afresh.
798
+ */
799
+ function takeRoute(line: Line, route: readonly Point[], tops: readonly number[]): boolean {
800
+ if (route.length < 2 || route[0].y !== line.s.y + BOX_HEIGHT || route[route.length - 1].y !== line.t.y) return false;
801
+ line.portOut = route[0].x;
802
+ line.portIn = route[route.length - 1].x;
803
+ if (line.outChannel === line.intoChannel) return true;
804
+ const y = tops[Math.min(line.outChannel, line.intoChannel)] + BOX_HEIGHT / 2;
805
+ const drop = route.slice(1).findIndex((p, i) => p.x === route[i].x && Math.min(p.y, route[i].y) <= y && y <= Math.max(p.y, route[i].y));
806
+ if (drop < 0) return false;
807
+ line.lane = route[drop].x;
808
+ return true;
809
+ }
810
+
811
+ /** The height a kept route runs along channel `k`, or undefined where it drops straight through it. */
812
+ function trackOf(route: readonly Point[], k: number, tops: readonly number[]): number | undefined {
813
+ const channelOf = (y: number): number => tops.filter((t) => t + BOX_HEIGHT <= y).length;
814
+ return route.slice(1).find((p, i) => p.y === route[i].y && channelOf(p.y) === k)?.y;
815
+ }
816
+
817
+ /**
818
+ * Whether `a` must run above `b` or their drops share a run: `a` turns up just where `b` turns down (a line leaving the box
819
+ * above at the very x a line arrives at the box below, between two wrapped rows).
820
+ */
821
+ function sharesDrop(a: Run, b: Run): boolean {
822
+ const touching = (a.line.width + b.line.width) / 2 + 1;
823
+ return a.ends.some((up) => up.dir === "up" && b.ends.some((down) => down.dir === "down" && Math.abs(up.x - down.x) < touching));
824
+ }
825
+
826
+ /**
827
+ * Whether `a` should run above `b` in their channel (trackOrder's rule): it must when their drops would share a run, and
828
+ * it should when `a` turns up where `b` passes, or `b` turns down where `a` passes, so neither turn crosses the other run.
829
+ */
830
+ function mustBeAbove(a: Run, b: Run): boolean {
831
+ const inside = (x: number, run: Run): boolean => {
832
+ const [lo, hi] = spanOf(run);
833
+ return x > lo && x < hi;
834
+ };
835
+ return sharesDrop(a, b)
836
+ || a.ends.some((end) => end.dir === "up" && inside(end.x, b))
837
+ || b.ends.some((end) => end.dir === "down" && inside(end.x, a));
838
+ }
839
+
840
+ /** The middle of channel `k` (between rows k - 1 and k), or half a rank gap past the first or last row. */
841
+ function channelMiddle(k: number, tops: readonly number[]): number {
842
+ if (k <= 0) return tops[0] - OUTER_CHANNEL;
843
+ if (k >= tops.length) return tops[tops.length - 1] + BOX_HEIGHT + OUTER_CHANNEL;
844
+ return (tops[k - 1] + BOX_HEIGHT + tops[k]) / 2;
845
+ }
846
+
847
+ /**
848
+ * The height of each track in channel `k`: the tracks as one block, spaced by their lines' widths, centred on the
849
+ * channel's middle, then kept TOP_ROOM under the row above and, for a run that ends in an arrowhead in the row below,
850
+ * far enough above it for the head and a corner. Past the first or last row the block grows outward from the boxes.
851
+ * A block too tall for its channel is squeezed, down to a pixel between lines. One still too tall takes the corners'
852
+ * room too, down to CROWDED_ROOM under the row above and then over the arrowheads, so every head keeps its full length;
853
+ * past that its lines touch.
854
+ */
855
+ function placeTracks(k: number, tracks: readonly Run[][], tops: readonly number[]): void {
856
+ const widest = tracks.map((track) => Math.max(...track.map((r) => r.line.width)));
857
+ const gaps = widest.slice(1).map((w, i) => (widest[i] + w) / 2 + TRACK_GAP);
858
+ const tightest = widest.slice(1).map((w, i) => (widest[i] + w) / 2 + 1);
859
+ const height = gaps.reduce((sum, g) => sum + g, 0);
860
+ const least = tightest.reduce((sum, g) => sum + g, 0);
861
+ // the lowest a track may run: above the next row by its arrowheads and a corner
862
+ const head = Math.max(0, ...tracks.flat().filter((r) => r.arrives).map((r) => r.line.head.length)) + TIP_GAP;
863
+ let upper = k > 0 ? tops[k - 1] + BOX_HEIGHT + TOP_ROOM : -Infinity;
864
+ let lower = k < tops.length ? tops[k] - head - CORNER_ROOM : Infinity;
865
+ let spacing = gaps;
866
+ if (Number.isFinite(upper) && Number.isFinite(lower) && height > lower - upper){
867
+ // the corner under the row above goes first: the one over an arrowhead is what lets it read as one
868
+ if (least > lower - upper) upper = tops[k - 1] + BOX_HEIGHT + CROWDED_ROOM;
869
+ if (least > lower - upper) lower = tops[k] - head - CROWDED_ROOM;
870
+ const room = Math.max(0, lower - upper);
871
+ const share = height === least ? 0 : Math.max(0, Math.min(1, (room - least) / (height - least)));
872
+ spacing = least > room ? tightest.map((g) => (g * room) / least) : gaps.map((g, i) => tightest[i] + (g - tightest[i]) * share);
873
+ }
874
+ const total = spacing.reduce((sum, g) => sum + g, 0);
875
+ let top = channelMiddle(k, tops) - total / 2;
876
+ if (k <= 0) top = Math.min(channelMiddle(k, tops), lower) - total; // above the first row: grows upward
877
+ else if (k >= tops.length) top = Math.max(channelMiddle(k, tops), upper); // below the last row: grows downward
878
+ else top = Math.max(upper, Math.min(top, lower - total));
879
+ let y = top;
880
+ tracks.forEach((track, i) => {
881
+ if (i > 0) y += spacing[i - 1];
882
+ for (const run of track) run.y = y;
883
+ });
884
+ }
885
+
886
+ /**
887
+ * Whether a new run in channel `k` can join its group's kept track at height `y`, whose kept runs cover `covered`: along
888
+ * that it runs on their track, one line with them, and what it runs past it keeps TRACK_GAP past half of each width
889
+ * from every other run alongside it, keeps the order a shared drop needs (sharesDrop), and passes CROWDED_ROOM above
890
+ * the arrowhead of each line arriving in the channel under it (`arrivals`), but its own box's, which it shares.
891
+ */
892
+ function joins(run: Run, y: number, covered: readonly [number, number], others: readonly Run[], arrivals: readonly Line[], k: number, tops: readonly number[]): boolean {
893
+ const [lo, hi] = spanOf(run);
894
+ const past: [number, number][] = [];
895
+ if (lo < covered[0] - EPSILON) past.push([lo, covered[0]]);
896
+ if (hi > covered[1] + EPSILON) past.push([covered[1], hi]);
897
+ return past.every(([plo, phi]) => others.every((o) => {
898
+ const [olo, ohi] = spanOf(o);
899
+ const room = (o.line.width + run.line.width) / 2 + RUN_GAP;
900
+ if (phi + room <= olo || ohi + room <= plo) return true;
901
+ return Math.abs(y - o.y!) >= (o.line.width + run.line.width) / 2 + TRACK_GAP - EPSILON && !(sharesDrop(run, o) && y > o.y!) && !(sharesDrop(o, run) && y < o.y!);
902
+ }) && arrivals.every((l) => l.edge.target === run.line.edge.target
903
+ || l.portIn! + l.head.half + run.line.width / 2 <= plo || l.portIn! - l.head.half - run.line.width / 2 >= phi
904
+ || y <= tops[k] - l.head.length - TIP_GAP - CROWDED_ROOM + EPSILON));
905
+ }
906
+
907
+ /**
908
+ * The heights for `fresh` runs in channel `k`, which already holds the `kept` runs of lines that stay where they are
909
+ * (routeEdges with routes kept). In trackOrder's order, each takes the free height nearest the channel's middle (past
910
+ * the first or last row, nearest the boxes): TRACK_GAP from every run it overlaps, end to end or alongside, above any it
911
+ * must be above, and inside placeTracks's bounds. A run that should be above another, or below it, and isn't costs
912
+ * more than any distance. Where nothing is free, the bounds widen to CROWDED_ROOM and then the gap to a pixel; past
913
+ * that a run takes the middle.
914
+ */
915
+ function placeRunsAround(k: number, fresh: readonly Run[], kept: readonly Run[], tops: readonly number[]): void {
916
+ const placed: Run[] = [...kept];
917
+ const middle = channelMiddle(k, tops);
918
+ const want = k <= 0 ? Math.min(middle, tops[0] - TOP_ROOM) : k >= tops.length ? Math.max(middle, tops[tops.length - 1] + BOX_HEIGHT + TOP_ROOM) : middle;
919
+ for (const run of trackOrder(fresh)){
920
+ const [lo, hi] = spanOf(run);
921
+ const alongside = placed.filter((other) => {
922
+ const [olo, ohi] = spanOf(other);
923
+ const room = (other.line.width + run.line.width) / 2 + RUN_GAP;
924
+ return !(hi + room <= olo || ohi + room <= lo);
925
+ });
926
+ // above its own arrowhead, and above those of the lines arriving under it, each at its box's arriving point
927
+ const under = placed.filter((o) => o.arrives && o.line.portIn! + o.line.head.half + run.line.width / 2 > lo && o.line.portIn! - o.line.head.half - run.line.width / 2 < hi);
928
+ const head = Math.max(run.arrives ? run.line.head.length : 0, ...under.map((o) => o.line.head.length)) + TIP_GAP;
929
+ const attempts: [number, number, number][] = [[TOP_ROOM, CORNER_ROOM, TRACK_GAP], [CROWDED_ROOM, CROWDED_ROOM, TRACK_GAP], [CROWDED_ROOM, CROWDED_ROOM, 1]];
930
+ let y: number | undefined;
931
+ for (const [topRoom, cornerRoom, trackGap] of attempts){
932
+ const upper = k > 0 ? tops[k - 1] + BOX_HEIGHT + topRoom : -Infinity;
933
+ const lower = k < tops.length ? tops[k] - head - cornerRoom : Infinity;
934
+ const gapTo = (other: Run): number => (other.line.width + run.line.width) / 2 + trackGap;
935
+ const tries = [want, upper, lower, ...alongside.flatMap((o) => [o.y! - gapTo(o), o.y! + gapTo(o)])].filter(Number.isFinite);
936
+ const free = tries.filter((t) => t >= upper - EPSILON && t <= lower + EPSILON
937
+ && alongside.every((o) => Math.abs(t - o.y!) >= gapTo(o) - EPSILON && !(sharesDrop(run, o) && t > o.y!) && !(sharesDrop(o, run) && t < o.y!)));
938
+ const cost = (t: number): number => Math.abs(t - want)
939
+ + 1000 * alongside.filter((o) => (mustBeAbove(run, o) && t > o.y!) || (mustBeAbove(o, run) && t < o.y!)).length;
940
+ if (free.length > 0){
941
+ y = free.reduce((best, t) => (cost(t) < cost(best) || (cost(t) === cost(best) && t < best) ? t : best));
942
+ break;
943
+ }
944
+ }
945
+ run.y = y ?? middle;
946
+ placed.push(run);
947
+ }
948
+ }
949
+
950
+ /** Drops repeated points, and middle points on a straight run, so a route has only its real corners. */
951
+ function simplify(points: readonly Point[]): Point[] {
952
+ const distinct = points.filter((p, i) => i === 0 || p.x !== points[i - 1].x || p.y !== points[i - 1].y);
953
+ return distinct.filter((p, i) => {
954
+ if (i === 0 || i === distinct.length - 1) return true;
955
+ const [a, b] = [distinct[i - 1], distinct[i + 1]];
956
+ return !((a.x === p.x && p.x === b.x) || (a.y === p.y && p.y === b.y));
957
+ });
958
+ }
959
+
75
960
  /** `from` moved `distance` toward `to`. */
76
961
  function toward(from: Point, to: Point, distance: number): Point {
77
962
  const length = Math.hypot(to.x - from.x, to.y - from.y);
@@ -93,13 +978,133 @@ export function roundedPath(points: readonly Point[], radius: number = CORNER_RA
93
978
  return `${d} L ${last.x} ${last.y}`;
94
979
  }
95
980
 
96
- /** The point halfway along a route, where its label goes. */
97
- export function midpointAlong(points: readonly Point[]): Point {
98
- const sides = points.slice(1).map((p, i) => Math.hypot(p.x - points[i].x, p.y - points[i].y));
99
- let left = sides.reduce((sum, length) => sum + length, 0) / 2;
100
- for (let i = 0; i < sides.length; i++){
101
- if (left <= sides[i]) return toward(points[i], points[i + 1], left);
102
- left -= sides[i];
981
+ /** A rectangle by its middle and size, as a count's label or a box takes the canvas. */
982
+ export type Rect = { x: number; y: number; width: number; height: number };
983
+
984
+ /** One side of a route, and the width of the line it's part of. */
985
+ type Segment = { key: string; a: Point; b: Point; width: number };
986
+
987
+ /** Whether two rectangles overlap. Touching isn't overlapping. */
988
+ const overlaps = (r: Rect, s: Rect): boolean => Math.abs(r.x - s.x) * 2 < r.width + s.width && Math.abs(r.y - s.y) * 2 < r.height + s.height;
989
+
990
+ /**
991
+ * The stretches of line `key`'s sides that no other line runs along (two lines along one run are within half of each
992
+ * width and a pixel of each other), longest first: a count on a run two lines share didn't say whose it was.
993
+ */
994
+ function ownStretches(key: string, segments: readonly Segment[]): { a: Point; b: Point; length: number }[] {
995
+ const others = segments.filter((s) => s.key !== key);
996
+ const stretches: { a: Point; b: Point; length: number }[] = [];
997
+ for (const s of segments){
998
+ if (s.key !== key) continue;
999
+ const across = s.a.y === s.b.y;
1000
+ const [lo, hi] = across ? [Math.min(s.a.x, s.b.x), Math.max(s.a.x, s.b.x)] : [Math.min(s.a.y, s.b.y), Math.max(s.a.y, s.b.y)];
1001
+ let free: [number, number][] = [[lo, hi]];
1002
+ for (const o of others){
1003
+ const along = across
1004
+ ? o.a.y === o.b.y && Math.abs(o.a.y - s.a.y) < (o.width + s.width) / 2 + 1
1005
+ : o.a.x === o.b.x && Math.abs(o.a.x - s.a.x) < (o.width + s.width) / 2 + 1;
1006
+ if (!along) continue;
1007
+ const [olo, ohi] = across ? [Math.min(o.a.x, o.b.x), Math.max(o.a.x, o.b.x)] : [Math.min(o.a.y, o.b.y), Math.max(o.a.y, o.b.y)];
1008
+ free = free.flatMap(([f0, f1]): [number, number][] => (ohi <= f0 || olo >= f1
1009
+ ? [[f0, f1]]
1010
+ : ([[f0, Math.max(f0, olo)], [Math.min(f1, ohi), f1]] as [number, number][]).filter(([p, q]) => q - p > 0.5)));
1011
+ }
1012
+ for (const [f0, f1] of free){
1013
+ stretches.push(across ? { a: { x: f0, y: s.a.y }, b: { x: f1, y: s.a.y }, length: f1 - f0 } : { a: { x: s.a.x, y: f0 }, b: { x: s.a.x, y: f1 }, length: f1 - f0 });
1014
+ }
1015
+ }
1016
+ return stretches.sort((p, q) => q.length - p.length);
1017
+ }
1018
+
1019
+ /**
1020
+ * Where each line's count is written, for the lines it fits on (the owner, 2026-10-28: "make the numbers never overlap
1021
+ * with anything"). A label, its size centred on its point, overlaps nothing but its own line, which runs under it: no
1022
+ * other line, grown by COUNT_LINE_ROOM each side past its width, no other count, grown by COUNT_GAP each side, and no
1023
+ * box (`boxes`, each with its back card). Heavier lines go first, ties by key, so the same screen always places the same
1024
+ * way. A `kept` place, where the screen this one grew from had the count, stays while it's clear, so lines that appear
1025
+ * move only the counts they run into. Every other count tries along the stretches its line has to itself (ownStretches),
1026
+ * at COUNT_TRIES of the room, and takes the first clear place. A count with none is left out, and isn't drawn: its number
1027
+ * is still in the inspector and in the line's hover sentence. `labels` gives each counted line its label's size.
1028
+ */
1029
+ export function placeCounts(
1030
+ routes: ReadonlyMap<string, readonly Point[]>,
1031
+ widths: ReadonlyMap<string, number>,
1032
+ labels: ReadonlyMap<string, { width: number; height: number }>,
1033
+ boxes: readonly Rect[],
1034
+ kept: ReadonlyMap<string, Point> = new Map(),
1035
+ ): Map<string, Point> {
1036
+ const segments: Segment[] = [];
1037
+ for (const [key, points] of routes){
1038
+ for (let i = 0; i + 1 < points.length; i++) segments.push({ key, a: points[i], b: points[i + 1], width: widths.get(key) ?? 1 });
1039
+ }
1040
+ // each side as the room a count keeps off it, worked out once rather than for every place tried
1041
+ const rooms = segments.map((s) => ({ key: s.key, room: {
1042
+ x: (s.a.x + s.b.x) / 2, y: (s.a.y + s.b.y) / 2,
1043
+ width: Math.abs(s.b.x - s.a.x) + s.width + 2 * COUNT_LINE_ROOM, height: Math.abs(s.b.y - s.a.y) + s.width + 2 * COUNT_LINE_ROOM,
1044
+ } }));
1045
+ const placed: Rect[] = [];
1046
+ const clear = (key: string, at: Point): boolean => {
1047
+ const label = { x: at.x, y: at.y, ...labels.get(key)! };
1048
+ return !rooms.some((s) => s.key !== key && overlaps(label, s.room))
1049
+ && !placed.some((q) => overlaps(label, { ...q, width: q.width + 2 * COUNT_GAP, height: q.height + 2 * COUNT_GAP }))
1050
+ && !boxes.some((b) => overlaps(label, b));
1051
+ };
1052
+ const result = new Map<string, Point>();
1053
+ const place = (key: string, at: Point): void => {
1054
+ result.set(key, at);
1055
+ placed.push({ x: at.x, y: at.y, ...labels.get(key)! });
1056
+ };
1057
+ const order = [...labels.keys()].filter((key) => routes.has(key))
1058
+ .sort((a, b) => (widths.get(b) ?? 1) - (widths.get(a) ?? 1) || (a < b ? -1 : a > b ? 1 : 0));
1059
+ for (const key of order){
1060
+ const at = kept.get(key);
1061
+ if (at !== undefined && clear(key, at)) place(key, at);
1062
+ }
1063
+ // the first clear place along a stretch, the longest stretch first, where the whole label lies on the stretch
1064
+ const spot = (key: string): Point | undefined => {
1065
+ const size = labels.get(key)!;
1066
+ for (const st of ownStretches(key, segments)){
1067
+ const across = st.a.y === st.b.y;
1068
+ const need = across ? size.width : size.height;
1069
+ const room = st.length - need;
1070
+ if (room < 0) continue;
1071
+ for (const t of COUNT_TRIES){
1072
+ const along = need / 2 + room * t;
1073
+ const at = across ? { x: Math.min(st.a.x, st.b.x) + along, y: st.a.y } : { x: st.a.x, y: Math.min(st.a.y, st.b.y) + along };
1074
+ if (clear(key, at)) return at;
1075
+ }
1076
+ }
1077
+ return undefined;
1078
+ };
1079
+ for (const key of order){
1080
+ if (result.has(key)) continue;
1081
+ const at = spot(key);
1082
+ if (at !== undefined) place(key, at);
1083
+ }
1084
+ return result;
1085
+ }
1086
+
1087
+ /**
1088
+ * The room a count keeps off each drawn arrowhead, to go with the boxes placeCounts keeps counts off: a count over a
1089
+ * head hid it, and an arrowhead is always visible (PR #16). A route ends at its box's top, under the head, so its last
1090
+ * drop alone kept no count off it (review: on src/vs with workbench selected, 3,652 covered editor's whole head). One
1091
+ * for each box a drawn line arrives at, since its lines end at one point under one head (weights.ts's boxHeadWidths),
1092
+ * where RoutedEdge draws it, TIP_GAP above the route's end, and grown by COUNT_LINE_ROOM each side as a line's room is.
1093
+ */
1094
+ export function headRooms(routes: ReadonlyMap<string, readonly Point[]>, edges: readonly MapEdge[]): Rect[] {
1095
+ const maxImports = maxImportsOf(edges);
1096
+ const heads = boxHeadWidths(edges);
1097
+ const edgeOf = new Map(edges.map((e) => [`${e.source}->${e.target}`, e]));
1098
+ const rooms = new Map<string, Rect>();
1099
+ for (const [key, points] of routes){
1100
+ const edge = edgeOf.get(key);
1101
+ if (edge === undefined || points.length < 2 || rooms.has(edge.target)) continue;
1102
+ const end = points[points.length - 1];
1103
+ const { half, length } = arrowHead(heads.get(edge.target) ?? lineWidth(edge.count, maxImports));
1104
+ rooms.set(edge.target, {
1105
+ x: end.x, y: end.y - TIP_GAP - length / 2,
1106
+ width: 2 * half + 2 * COUNT_LINE_ROOM, height: length + 2 * COUNT_LINE_ROOM,
1107
+ });
103
1108
  }
104
- return points[points.length - 1];
1109
+ return [...rooms.values()];
105
1110
  }