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.
- package/.next/BUILD_ID +1 -1
- package/.next/build-manifest.json +11 -11
- package/.next/diagnostics/route-bundle-stats.json +10 -10
- package/.next/fallback-build-manifest.json +3 -3
- package/.next/prerender-manifest.json +5 -5
- package/.next/server/app/_global-error/page/build-manifest.json +3 -3
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page/build-manifest.json +3 -3
- package/.next/server/app/_not-found/page/next-font-manifest.json +2 -3
- package/.next/server/app/_not-found/page.js +3 -2
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +27 -20
- package/.next/server/app/_not-found.segments/_full.segment.rsc +27 -20
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +34 -26
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +29 -23
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +33 -24
- package/.next/server/app/index.segments/_full.segment.rsc +29 -23
- package/.next/server/app/index.segments/_tree.segment.rsc +4 -5
- package/.next/server/app/page/build-manifest.json +3 -3
- package/.next/server/app/page/next-font-manifest.json +2 -3
- package/.next/server/app/page.js +4 -4
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/chunks/[root-of-the-server]__06blyow8irfw3._.js +3 -3
- package/.next/server/chunks/[root-of-the-server]__0bmqko-d8fd5k._.js +3 -3
- package/.next/server/chunks/[root-of-the-server]__0cmmlmvta4i0g._.js +3 -3
- package/.next/server/chunks/[root-of-the-server]__0jgr1dt1j95ku._.js +3 -3
- package/.next/server/chunks/[root-of-the-server]__0m0n163465-zb._.js +3 -3
- package/.next/server/chunks/[root-of-the-server]__0p7epsgp2b5m3._.js +3 -3
- package/.next/server/chunks/[root-of-the-server]__1i5e5sqg45ens._.js +3 -3
- package/.next/server/chunks/[root-of-the-server]__1vjt0neva-819._.js +3 -3
- package/.next/server/chunks/_0uqus0ehnijt-._.js +1 -1
- package/.next/server/chunks/lib_background_start_ts_11u_1uu8b9yuy._.js +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__0_3l5tdzqy-td._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__10ockmlli-lun._.js +3 -0
- package/.next/server/chunks/ssr/{[root-of-the-server]__0dyligeswbb47._.js → [root-of-the-server]__16-gclvix9mjd._.js} +1 -1
- package/.next/server/chunks/ssr/[root-of-the-server]__1auodc3c0rppy._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__1cf6qt9agg_9-._.js +3 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__1i9r59yk4i69p._.js +3 -0
- package/.next/server/chunks/ssr/[root-of-the-server]__1l3qp8u9yc_ig._.js +3 -0
- package/.next/server/chunks/ssr/_1p-0wsx9qfm68._.js +13 -0
- package/.next/server/chunks/ssr/app_map_18iu6j19jsqmu._.js +3 -0
- package/.next/server/chunks/ssr/node_modules_next_dist_1r3iqtbm45lqp._.js +3 -0
- package/.next/server/chunks/ssr/node_modules_next_dist_client_components_builtin_global-error_0q-w892nagajq.js +3 -0
- package/.next/server/middleware-build-manifest.js +11 -11
- package/.next/server/next-font-manifest.js +1 -1
- package/.next/server/next-font-manifest.json +4 -6
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/preview-props.json +3 -3
- package/.next/server/server-reference-manifest.js +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/{1rljem7o1jtdo.js → 18kfexnrqek07.js} +1 -1
- package/.next/static/chunks/1hwbgd4nvvhg8.js +1 -0
- package/.next/static/chunks/25r4lkkf1shfo.css +3 -0
- package/.next/static/chunks/35ubvk2ivdjgs.js +1 -0
- package/.next/static/chunks/3ieqvmcg3hofj.js +31 -0
- package/.next/static/chunks/3w00e1w2125pp.js +11 -0
- package/.next/static/media/733da6bd8dac84c8-s.3hlisc-pwq13z.woff2 +0 -0
- package/.next/static/media/784318cea1dfcfa4-s.0-oo9_1x_xmvg.woff2 +0 -0
- package/.next/static/media/7e114457660b62ed-s.36fqqi66-3tjb.woff2 +0 -0
- package/.next/static/media/85e86a88f68a624a-s.357dd_6abeqge.woff2 +0 -0
- package/.next/static/media/9a321a2c78eff0b5-s.p.1bkzbscqrt8rl.woff2 +0 -0
- package/.next/static/media/9fecbd3e5ea9bccb-s.3_z45zcoc-xoz.woff2 +0 -0
- package/.next/static/media/c64053b510252f55-s.1kd-r_s9smv5m.woff2 +0 -0
- package/.next/static/media/c6a5c2fdc12da45c-s.p.17-aodiw50953.woff2 +0 -0
- package/.next/static/media/f263782c6a4bddee-s.1gyfqm5yhhzs7.woff2 +0 -0
- package/.next/trace +1 -1
- package/.next/trace-build +1 -1
- package/README.md +13 -4
- package/app/globals.css +91 -54
- package/app/layout.tsx +20 -11
- package/app/map/BackButton.tsx +5 -5
- package/app/map/BoxActions.tsx +20 -12
- package/app/map/Breadcrumb.tsx +132 -46
- package/app/map/DetailPanel.tsx +408 -297
- package/app/map/Explorer.tsx +268 -0
- package/app/map/Header.tsx +105 -0
- package/app/map/HelpDialog.tsx +37 -34
- package/app/map/Legend.tsx +19 -75
- package/app/map/LineCard.tsx +187 -0
- package/app/map/MapBox.tsx +73 -38
- package/app/map/MapCanvas.tsx +344 -115
- package/app/map/MapToolbar.tsx +377 -0
- package/app/map/MapView.tsx +217 -213
- package/app/map/RepoMenu.tsx +13 -12
- package/app/map/RoutedEdge.tsx +48 -16
- package/app/map/SearchPalette.tsx +6 -6
- package/app/map/SourceViewer.tsx +19 -17
- package/app/map/StatusBar.tsx +166 -0
- package/app/map/embed.ts +22 -2
- package/app/map/emphasis.ts +94 -8
- package/app/map/explorerRows.ts +85 -0
- package/app/map/flowNodes.ts +12 -7
- package/app/map/format.ts +2 -2
- package/app/map/history.ts +4 -3
- package/app/map/inspector.ts +113 -0
- package/app/map/labels.ts +66 -7
- package/app/map/narrow.ts +165 -85
- package/app/map/opening.ts +16 -0
- package/app/map/portals.ts +2 -2
- package/app/map/routes.ts +1060 -55
- package/app/map/runs.ts +108 -0
- package/app/map/shell.ts +91 -0
- package/app/map/shortcuts.ts +2 -1
- package/app/map/summaries.ts +41 -5
- package/app/map/text.ts +9 -1
- package/app/map/theme.ts +197 -0
- package/app/map/useDismiss.ts +20 -6
- package/app/map/useEditorBridge.ts +12 -1
- package/app/map/useThemeKind.ts +28 -0
- package/app/map/useWindowSize.ts +37 -0
- package/app/map/viewport.ts +134 -16
- package/app/map/weights.ts +80 -0
- package/app/page.tsx +3 -5
- package/lib/background/child.ts +2 -0
- package/lib/background/service.ts +26 -10
- package/lib/contracts.ts +25 -2
- package/lib/db/migrations/0011_plain_summaries_prompt_version.sql +1 -0
- package/lib/db/migrations/meta/0011_snapshot.json +1498 -0
- package/lib/db/migrations/meta/_journal.json +7 -0
- package/lib/db/schema.ts +2 -1
- package/lib/db/store/map.ts +6 -0
- package/lib/db/store/summaries.ts +14 -8
- package/lib/engine/describe.ts +2 -0
- package/lib/index/summarize/budget.ts +5 -2
- package/lib/index/summarize/claude.ts +19 -4
- package/lib/index/summarize/compare.ts +2 -2
- package/lib/index/summarize/plan.ts +73 -13
- package/lib/index/summarize/preview.ts +12 -9
- package/lib/index/summarize/prompts.ts +122 -25
- package/lib/index/summarize/run.ts +48 -18
- package/lib/index/summarize/snapshot.ts +7 -2
- package/lib/index/summarize/status.ts +3 -2
- package/lib/tools/text.ts +3 -2
- package/mcp/server.ts +1 -1
- package/package.json +4 -2
- package/scripts/commands/summarize-command.ts +27 -9
- package/.next/server/chunks/ssr/[root-of-the-server]__0ekw8p2ramybv._.js +0 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__0fwbsqt9e2tr-._.js +0 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__17_rqmx62cz59._.js +0 -3
- package/.next/server/chunks/ssr/[root-of-the-server]__1zfp6sltse4hf._.js +0 -3
- package/.next/server/chunks/ssr/_1d2woi6toj_0_._.js +0 -3
- package/.next/server/chunks/ssr/_1wfc1zz26g5ji._.js +0 -9
- package/.next/server/chunks/ssr/app_0gw6epmxyea3q._.js +0 -3
- package/.next/server/chunks/ssr/node_modules_@swc_helpers_cjs__interop_require_default_cjs_1ztp13a5szri_._.js +0 -3
- package/.next/server/chunks/ssr/node_modules_next_dist_1c5t_vsn6u8fr._.js +0 -3
- package/.next/static/chunks/02x9pxyl2d8om.css +0 -3
- package/.next/static/chunks/0p-0bv2dzer18.js +0 -7
- package/.next/static/chunks/10u7p2gj02qay.js +0 -1
- package/.next/static/chunks/31qp8jfkw0x2h.js +0 -31
- package/.next/static/media/123d516c9baeb953-s.1qc6dx89jd2qv.woff2 +0 -0
- package/.next/static/media/41ba59f553543ecf-s.3cb5lpr2l6xud.woff2 +0 -0
- package/.next/static/media/44ed10da57ebfb0e-s.2y65deuc30pmy.woff2 +0 -0
- package/.next/static/media/48eea41d8317494f-s.p.0wiir8udbzjvx.woff2 +0 -0
- package/.next/static/media/49b1d53681a91a99-s.1yd4_i3e35931.woff2 +0 -0
- package/.next/static/media/67bba87670b402d1-s.1u30mmyq6lnsd.woff2 +0 -0
- package/.next/static/media/6fca7e74603dd412-s.02z5rrbg5nt92.woff2 +0 -0
- package/.next/static/media/73263b2957b73100-s.2qt3tdwt-y9x_.woff2 +0 -0
- package/.next/static/media/7ac0b1cd3e695dd0-s.1i-sca43-qwng.woff2 +0 -0
- package/.next/static/media/916cd8a3bde3edf8-s.p.0oace-s_gkfks.woff2 +0 -0
- package/.next/static/media/a45aa1001bf9176f-s.2vemzm0itqzfy.woff2 +0 -0
- package/.next/static/media/a74d2542cc4c775f-s.29yke99gs9qpn.woff2 +0 -0
- package/.next/static/media/ab664c0e419cc841-s.3cvcemwuzoywn.woff2 +0 -0
- package/.next/static/media/c1b11b726b36a5e5-s.2rixgazldzomk.woff2 +0 -0
- package/.next/static/media/d6c1ff7e86974212-s.p.40sczeszzbjw1.woff2 +0 -0
- package/.next/static/media/faab88e4be272d29-s.0rgnxg2b64rzs.woff2 +0 -0
- package/app/map/ActivityBar.tsx +0 -81
- package/app/map/StatusPill.tsx +0 -93
- package/app/map/TopBar.tsx +0 -84
- /package/.next/static/{prDi039MhjlFqDOOwZ4aR → of6twfEZ3j95xtfQB_NSL}/_buildManifest.js +0 -0
- /package/.next/static/{prDi039MhjlFqDOOwZ4aR → of6twfEZ3j95xtfQB_NSL}/_clientMiddlewareManifest.js +0 -0
- /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,
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
/**
|
|
30
|
-
function
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
*
|
|
41
|
-
*
|
|
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
|
-
|
|
48
|
-
const
|
|
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 =
|
|
51
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
start, { x: start.x, y:
|
|
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
|
-
/**
|
|
97
|
-
export
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
1109
|
+
return [...rooms.values()];
|
|
105
1110
|
}
|