@coldtea/pr-lens-renderer 0.2.6 → 0.3.1

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 (59) hide show
  1. package/README.md +2 -1
  2. package/dist/bounds.d.ts +6 -1
  3. package/dist/bounds.d.ts.map +1 -1
  4. package/dist/bounds.js +21 -0
  5. package/dist/bounds.js.map +1 -1
  6. package/dist/design.d.ts +5 -0
  7. package/dist/design.d.ts.map +1 -1
  8. package/dist/design.js +5 -0
  9. package/dist/design.js.map +1 -1
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/layout/architecture.d.ts +2 -0
  15. package/dist/layout/architecture.d.ts.map +1 -1
  16. package/dist/layout/architecture.js +5 -0
  17. package/dist/layout/architecture.js.map +1 -1
  18. package/dist/layout/congestion.d.ts +5 -0
  19. package/dist/layout/congestion.d.ts.map +1 -1
  20. package/dist/layout/congestion.js +50 -12
  21. package/dist/layout/congestion.js.map +1 -1
  22. package/dist/layout/edges.d.ts +18 -3
  23. package/dist/layout/edges.d.ts.map +1 -1
  24. package/dist/layout/edges.js +93 -23
  25. package/dist/layout/edges.js.map +1 -1
  26. package/dist/layout/labels.d.ts +15 -4
  27. package/dist/layout/labels.d.ts.map +1 -1
  28. package/dist/layout/labels.js +404 -119
  29. package/dist/layout/labels.js.map +1 -1
  30. package/dist/manifest.d.ts +1 -1
  31. package/dist/manifest.d.ts.map +1 -1
  32. package/dist/manifest.js +3 -1
  33. package/dist/manifest.js.map +1 -1
  34. package/dist/render.d.ts +12 -2
  35. package/dist/render.d.ts.map +1 -1
  36. package/dist/render.js +2 -2
  37. package/dist/render.js.map +1 -1
  38. package/dist/svg/architecture.d.ts.map +1 -1
  39. package/dist/svg/architecture.js +3 -8
  40. package/dist/svg/architecture.js.map +1 -1
  41. package/dist/theme.d.ts +9 -8
  42. package/dist/theme.d.ts.map +1 -1
  43. package/dist/theme.js +61 -8
  44. package/dist/theme.js.map +1 -1
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/package.json +2 -2
  48. package/src/bounds.ts +29 -1
  49. package/src/design.ts +5 -0
  50. package/src/index.ts +2 -1
  51. package/src/layout/architecture.ts +7 -0
  52. package/src/layout/congestion.ts +71 -12
  53. package/src/layout/edges.ts +126 -21
  54. package/src/layout/labels.ts +495 -136
  55. package/src/manifest.ts +3 -2
  56. package/src/render.ts +19 -4
  57. package/src/svg/architecture.ts +3 -7
  58. package/src/theme.ts +62 -8
  59. package/src/version.ts +1 -1
@@ -1,20 +1,23 @@
1
1
  import { assertNever } from "@coldtea/pr-lens-schema";
2
- import { PILL_CLEARANCE, PILL_HEIGHT, PILL_PADDING_X, PILL_TEXT_SIZE } from "../design.js";
2
+ import {
3
+ PILL_CARD_CLEARANCE,
4
+ PILL_CLEARANCE,
5
+ PILL_HEIGHT,
6
+ PILL_PADDING_X,
7
+ PILL_TEXT_SIZE,
8
+ } from "../design.js";
9
+ import { boundsOf, gapBetween, inflate } from "../bounds.js";
3
10
  import type { Box, Point } from "../geometry.js";
4
11
  import { measure } from "../text.js";
5
12
  import type { Curve, RoutedEdge } from "./edges.js";
6
13
 
7
- /**
8
- * Tracks separate the lines, but nothing in the router separates the pills
9
- * riding them: when several labelled runs share a gap, track pitch is far
10
- * finer than a pill, and the pills stack. This pass settles every pill so
11
- * that no two intersect, preferring a nudge along the pill's own run — the
12
- * label stays on its own line, just off the crowded spot — over anything
13
- * that would loosen the tie between a label and its line.
14
- */
15
-
16
14
  type Size = { width: number; height: number };
17
15
 
16
+ export const pillSize = (label: string): Size => ({
17
+ width: measure(label, "sans-bold", PILL_TEXT_SIZE) + PILL_PADDING_X * 2,
18
+ height: PILL_HEIGHT,
19
+ });
20
+
18
21
  /** A straight run of a route, described by its slide axis. */
19
22
  type Run = {
20
23
  axis: "x" | "y";
@@ -22,34 +25,140 @@ type Run = {
22
25
  cross: number;
23
26
  lo: number;
24
27
  hi: number;
28
+ /** Distance along the whole route to where the run starts. */
29
+ arcStart: number;
30
+ /** Whether the route travels this run from `lo` to `hi`. */
31
+ forward: boolean;
32
+ };
33
+
34
+ type Leg = { a: Point; b: Point };
35
+
36
+ type Piece = { bounds: Box; legs: Leg[] };
37
+
38
+ type Traced = {
39
+ runs: Run[];
40
+ /** A bend's hull and chords, so a search can discard it in one test. */
41
+ pieces: Piece[];
42
+ /** Arc distance to the middle of the longest run, where a lone label sits. */
43
+ preferred: number;
44
+ end: Point;
25
45
  };
26
46
 
27
47
  const EPSILON = 0.01;
48
+ const CUBIC_STEPS = 8;
28
49
 
29
- /**
30
- * The longest straight run of a route — the run the router pins the label to,
31
- * and the only one collision handling may slide it along. Earlier segments
32
- * win a tie, matching how the anchor itself is chosen.
33
- */
34
- const longestRun = (curve: Curve): Run | undefined => {
35
- let best: { run: Run; length: number } | undefined;
50
+ /** How far inside the pill its own line must pass, clear of the rounded end. */
51
+ const THREAD_INSET = 10;
52
+ const OFFSET_STEP = 10;
53
+ /** A pill that cannot sit on its line may sit beside it, this far off. */
54
+ const BESIDE_GAP = 3;
55
+ /** Closer than this costs, but is allowed. */
56
+ const SOFT_CARD_GAP = 12;
57
+ const SOFT_PILL_GAP = 10;
58
+ /** Spots a cramped label tries when a neighbour makes way. */
59
+ const REPAIR_OPTIONS = 6;
60
+ /** A pill this close to its own route's end is sitting on the arrowhead. */
61
+ const ARROW_REACH = 12;
62
+
63
+ // Costs are pixels of travel from the preferred spot: a pill slides 400px
64
+ // along its line rather than lie across another, 250px rather than leave it.
65
+ const COST_OFFSET = 1.5;
66
+ const COST_BESIDE = 250;
67
+ const COST_FOREIGN_LINE = 400;
68
+ const COST_OWN_ARROW = 150;
69
+ const COST_CARD_NEAR = 4;
70
+ const COST_PILL_NEAR = 3;
71
+ const COST_OVERHANG = 3;
72
+
73
+ const cubicPoint = (from: Point, first: Point, second: Point, to: Point, t: number): Point => {
74
+ const u = 1 - t;
75
+ return {
76
+ x: u ** 3 * from.x + 3 * u * u * t * first.x + 3 * u * t * t * second.x + t ** 3 * to.x,
77
+ y: u ** 3 * from.y + 3 * u * u * t * first.y + 3 * u * t * t * second.y + t ** 3 * to.y,
78
+ };
79
+ };
80
+
81
+ const trace = (curve: Curve): Traced => {
82
+ const runs: Run[] = [];
83
+ const pieces: Piece[] = [];
84
+ let arc = 0;
85
+ let longest: { length: number; middle: number } | undefined;
36
86
  let start = curve.from;
87
+
37
88
  for (const segment of curve.segments) {
38
- const end = segment.to;
39
- if (segment.kind === "line") {
40
- const horizontal = Math.abs(end.y - start.y) < EPSILON;
41
- const [lo, hi] = horizontal
42
- ? [Math.min(start.x, end.x), Math.max(start.x, end.x)]
43
- : [Math.min(start.y, end.y), Math.max(start.y, end.y)];
44
- if (best === undefined || hi - lo > best.length)
45
- best = {
46
- run: { axis: horizontal ? "x" : "y", cross: horizontal ? start.y : start.x, lo, hi },
47
- length: hi - lo,
48
- };
89
+ switch (segment.kind) {
90
+ case "line": {
91
+ const end = segment.to;
92
+ const horizontal = Math.abs(end.y - start.y) < EPSILON;
93
+ const vertical = Math.abs(end.x - start.x) < EPSILON;
94
+ const length = Math.hypot(end.x - start.x, end.y - start.y);
95
+ if (horizontal || vertical) {
96
+ const [from, to] = horizontal ? [start.x, end.x] : [start.y, end.y];
97
+ runs.push({
98
+ axis: horizontal ? "x" : "y",
99
+ cross: horizontal ? start.y : start.x,
100
+ lo: Math.min(from, to),
101
+ hi: Math.max(from, to),
102
+ arcStart: arc,
103
+ forward: to >= from,
104
+ });
105
+ if (longest === undefined || length > longest.length)
106
+ longest = { length, middle: arc + length / 2 };
107
+ }
108
+ pieces.push({ bounds: boundsOf([start, end]), legs: [{ a: start, b: end }] });
109
+ arc += length;
110
+ break;
111
+ }
112
+ case "cubic": {
113
+ const legs: Leg[] = [];
114
+ pieces.push({ bounds: boundsOf([start, segment.first, segment.second, segment.to]), legs });
115
+ let previous = start;
116
+ for (let step = 1; step <= CUBIC_STEPS; step += 1) {
117
+ const t = step / CUBIC_STEPS;
118
+ const point = cubicPoint(start, segment.first, segment.second, segment.to, t);
119
+ legs.push({ a: previous, b: point });
120
+ arc += Math.hypot(point.x - previous.x, point.y - previous.y);
121
+ previous = point;
122
+ }
123
+ break;
124
+ }
125
+ default:
126
+ assertNever(segment, "Unhandled path segment");
49
127
  }
50
- start = end;
128
+ start = segment.to;
129
+ }
130
+
131
+ if (runs.length === 0) {
132
+ const middle = runThroughMiddle(pieces, arc / 2);
133
+ if (middle !== undefined) runs.push(middle);
51
134
  }
52
- return best?.run;
135
+ return { runs, pieces, preferred: longest?.middle ?? arc / 2, end: start };
136
+ };
137
+
138
+ /** A self-loop has no straight run, so it gets a zero-length one at its middle. */
139
+ const runThroughMiddle = (pieces: readonly Piece[], halfway: number): Run | undefined => {
140
+ let travelled = 0;
141
+ for (const { legs } of pieces)
142
+ for (const { a, b } of legs) {
143
+ const length = Math.hypot(b.x - a.x, b.y - a.y);
144
+ if (travelled + length < halfway || length === 0) {
145
+ travelled += length;
146
+ continue;
147
+ }
148
+ const t = (halfway - travelled) / length;
149
+ const point = { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
150
+ const horizontal = Math.abs(b.x - a.x) >= Math.abs(b.y - a.y);
151
+ const along = horizontal ? point.x : point.y;
152
+ return {
153
+ axis: horizontal ? "x" : "y",
154
+ cross: horizontal ? point.y : point.x,
155
+ lo: along,
156
+ hi: along,
157
+ arcStart: halfway,
158
+ forward: true,
159
+ };
160
+ }
161
+ return undefined;
53
162
  };
54
163
 
55
164
  const centred = (centre: Point, size: Size): Box => ({
@@ -59,134 +168,384 @@ const centred = (centre: Point, size: Size): Box => ({
59
168
  height: size.height,
60
169
  });
61
170
 
62
- const centreOn = (run: Run, along: number): Point => {
63
- switch (run.axis) {
64
- case "x":
65
- return { x: along, y: run.cross };
66
- case "y":
67
- return { x: run.cross, y: along };
68
- default:
69
- return assertNever(run.axis, "Unhandled run axis");
171
+ /** Strict, so exactly `clearance` of air is enough. */
172
+ const tooClose = (a: Box, b: Box, clearance: number): boolean =>
173
+ a.x < b.x + b.width + clearance &&
174
+ b.x < a.x + a.width + clearance &&
175
+ a.y < b.y + b.height + clearance &&
176
+ b.y < a.y + a.height + clearance;
177
+
178
+ const overlaps = (a: Box, b: Box): boolean => tooClose(a, b, 0);
179
+
180
+ const legCrosses = ({ a, b }: Leg, box: Box): boolean => {
181
+ let t0 = 0;
182
+ let t1 = 1;
183
+ const dx = b.x - a.x;
184
+ const dy = b.y - a.y;
185
+ const checks: [number, number][] = [
186
+ [-dx, a.x - box.x],
187
+ [dx, box.x + box.width - a.x],
188
+ [-dy, a.y - box.y],
189
+ [dy, box.y + box.height - a.y],
190
+ ];
191
+ for (const [p, q] of checks) {
192
+ if (p === 0) {
193
+ if (q <= 0) return false;
194
+ continue;
195
+ }
196
+ const t = q / p;
197
+ if (p < 0) t0 = Math.max(t0, t);
198
+ else t1 = Math.min(t1, t);
199
+ if (t0 >= t1) return false;
70
200
  }
201
+ return true;
71
202
  };
72
203
 
73
- /** Exactly PILL_CLEARANCE of air is enough, so the comparisons are strict. */
74
- const collides = (a: Box, b: Box): boolean =>
75
- a.x < b.x + b.width + PILL_CLEARANCE &&
76
- b.x < a.x + a.width + PILL_CLEARANCE &&
77
- a.y < b.y + b.height + PILL_CLEARANCE &&
78
- b.y < a.y + a.height + PILL_CLEARANCE;
204
+ type Placement = { box: Box; cost: number };
79
205
 
80
- /**
81
- * The position along the run closest to its midpoint where the pill clears
82
- * everything already settled, or undefined when the run offers no room. A
83
- * pill wider than a short run may only take the midpoint — overhanging both
84
- * ends evenly, which is what the router always did with short runs. `reach`
85
- * lets a second, more desperate pass push the pill up to that far past the
86
- * run's ends — still square on its own line, at the line's foot.
87
- */
88
- const slideAlong = (
89
- run: Run,
90
- size: Size,
91
- settled: readonly Box[],
92
- reach: number,
93
- ): number | undefined => {
94
- const halfAlong = (run.axis === "x" ? size.width : size.height) / 2;
95
- const preferred = (run.lo + run.hi) / 2;
96
- const lo = Math.min(run.lo + halfAlong - reach, preferred);
97
- const hi = Math.max(run.hi - halfAlong + reach, preferred);
98
-
99
- const blocked = settled
100
- .flatMap((box) => {
101
- const [alongLo, alongHi, crossLo, crossHi] =
102
- run.axis === "x"
103
- ? [box.x, box.x + box.width, box.y, box.y + box.height]
104
- : [box.y, box.y + box.height, box.x, box.x + box.width];
105
- const halfCross = (run.axis === "x" ? size.height : size.width) / 2;
106
- if (run.cross - halfCross >= crossHi + PILL_CLEARANCE) return [];
107
- if (run.cross + halfCross <= crossLo - PILL_CLEARANCE) return [];
108
- return [{ from: alongLo - PILL_CLEARANCE - halfAlong, to: alongHi + PILL_CLEARANCE + halfAlong }];
109
- });
206
+ type ForeignPiece = Piece & { id: string };
110
207
 
111
- let best: number | undefined;
112
- const consider = (candidate: number) => {
113
- if (candidate < lo || candidate > hi) return;
114
- if (blocked.some((b) => b.from < candidate && candidate < b.to)) return;
115
- if (
116
- best === undefined ||
117
- Math.abs(candidate - preferred) < Math.abs(best - preferred) ||
118
- (Math.abs(candidate - preferred) === Math.abs(best - preferred) && candidate < best)
119
- )
120
- best = candidate;
121
- };
208
+ /** Where a pill's centre may sit across a run, and what leaving the line costs. */
209
+ const crossOffsets = (run: Run, size: Size): { offset: number; penalty: number }[] => {
210
+ const halfCross = (run.axis === "x" ? size.height : size.width) / 2;
211
+ const reach = Math.max(0, halfCross - THREAD_INSET);
212
+ const beside = halfCross + BESIDE_GAP;
213
+ const both = (distance: number, penalty: number) => [
214
+ { offset: -distance, penalty },
215
+ { offset: distance, penalty },
216
+ ];
217
+ const offsets = [{ offset: 0, penalty: 0 }];
218
+ for (let distance = OFFSET_STEP; distance < reach; distance += OFFSET_STEP)
219
+ offsets.push(...both(distance, distance * COST_OFFSET));
220
+ if (reach > 0) offsets.push(...both(reach, reach * COST_OFFSET));
221
+ offsets.push(...both(beside, COST_BESIDE + beside * COST_OFFSET));
222
+ return offsets;
223
+ };
224
+
225
+ type Span = [alongLo: number, alongHi: number, crossLo: number, crossHi: number];
226
+
227
+ const spanOn = (box: Box, axis: "x" | "y"): Span =>
228
+ axis === "x"
229
+ ? [box.x, box.x + box.width, box.y, box.y + box.height]
230
+ : [box.y, box.y + box.height, box.x, box.x + box.width];
231
+
232
+ const runBounds = (run: Run): Box =>
233
+ run.axis === "x"
234
+ ? { x: run.lo, y: run.cross, width: run.hi - run.lo, height: 0 }
235
+ : { x: run.cross, y: run.lo, width: 0, height: run.hi - run.lo };
236
+
237
+ /** A run and the cards and lines within a pill's reach of it, gathered once. */
238
+ type Surroundings = {
239
+ run: Run;
240
+ region: Box;
241
+ obstacles: Box[];
242
+ pieces: ForeignPiece[];
243
+ };
244
+
245
+ type Label = {
246
+ id: string;
247
+ own: Traced;
248
+ size: Size;
249
+ anchor: Point;
250
+ around: Surroundings[];
251
+ };
122
252
 
123
- consider(preferred);
124
- for (const b of blocked) {
125
- consider(b.from);
126
- consider(b.to);
253
+ type Scene = {
254
+ obstacles: readonly Box[];
255
+ /** The lanes' extent; a pill reaching past it grows the canvas. */
256
+ frame: Box | undefined;
257
+ };
258
+
259
+ const crowding = (
260
+ box: Box,
261
+ label: Label,
262
+ frame: Box | undefined,
263
+ around: Surroundings,
264
+ pills: readonly Box[],
265
+ ): number => {
266
+ let cost = 0;
267
+
268
+ for (const obstacle of around.obstacles) {
269
+ const gap = gapBetween(box, obstacle);
270
+ if (gap < SOFT_CARD_GAP) cost += (SOFT_CARD_GAP - gap) * COST_CARD_NEAR;
127
271
  }
128
- return best;
272
+ for (const other of pills) {
273
+ const gap = gapBetween(box, other);
274
+ if (gap < SOFT_PILL_GAP) cost += (SOFT_PILL_GAP - gap) * COST_PILL_NEAR;
275
+ }
276
+
277
+ // A pill over another route's line reads as that route's label.
278
+ const inner = inflate(box, -1);
279
+ const covered = new Set<string>();
280
+ for (const { id, bounds, legs } of around.pieces)
281
+ if (!covered.has(id) && overlaps(bounds, box) && legs.some((leg) => legCrosses(leg, inner)))
282
+ covered.add(id);
283
+ cost += covered.size * COST_FOREIGN_LINE;
284
+
285
+ if (frame !== undefined)
286
+ cost +=
287
+ (Math.max(0, frame.x - box.x) +
288
+ Math.max(0, box.x + box.width - (frame.x + frame.width)) +
289
+ Math.max(0, frame.y - box.y) +
290
+ Math.max(0, box.y + box.height - (frame.y + frame.height))) *
291
+ COST_OVERHANG;
292
+
293
+ const reach = inflate(box, ARROW_REACH);
294
+ const { end } = label.own;
295
+ const onArrow =
296
+ end.x > reach.x && end.x < reach.x + reach.width && end.y > reach.y && end.y < reach.y + reach.height;
297
+ if (onArrow) cost += COST_OWN_ARROW;
298
+
299
+ return cost;
129
300
  };
130
301
 
302
+ type Candidate = { box: Box; bound: number; around: Surroundings; pills: Box[] };
303
+
131
304
  /**
132
- * The tiebreak for a pill none of its runs can host: hold the anchor's
133
- * position along the line and step square off it, nearer side first, until
134
- * the pill clears — it ends up beside its own line rather than on it, but
135
- * never more than a step or two away, and always in the same place for the
136
- * same document.
305
+ * The `limit` cheapest distinct placements the route offers, cheapest first;
306
+ * empty when it has no room at all.
137
307
  */
138
- const stepAside = (anchor: Point, axis: "x" | "y", size: Size, settled: readonly Box[]): Box => {
139
- const step = (axis === "x" ? size.height : size.width) + PILL_CLEARANCE;
140
- for (let k = 1; ; k += 1)
141
- for (const sign of [-1, 1]) {
142
- const centre =
143
- axis === "x"
144
- ? { x: anchor.x, y: anchor.y + sign * k * step }
145
- : { x: anchor.x + sign * k * step, y: anchor.y };
146
- const box = centred(centre, size);
147
- if (!settled.some((other) => collides(box, other))) return box;
308
+ const rankOnRoute = (
309
+ label: Label,
310
+ scene: Scene,
311
+ settled: ReadonlyMap<string, Box>,
312
+ limit: number,
313
+ ): Placement[] => {
314
+ const { own, size } = label;
315
+ const settledPills = [...settled].filter(([id]) => id !== label.id).map(([, box]) => box);
316
+
317
+ const candidates: Candidate[] = [];
318
+ for (const around of label.around) {
319
+ const { run } = around;
320
+ const pills = settledPills.filter((box) => overlaps(box, around.region));
321
+ const lines = around.pieces.map(({ bounds }) => spanOn(bounds, run.axis));
322
+
323
+ const arcAt = (position: number): number =>
324
+ run.arcStart + (run.forward ? position - run.lo : run.hi - position);
325
+ const intoRun = own.preferred - run.arcStart;
326
+ const preferredAlong = Math.min(
327
+ Math.max(run.forward ? run.lo + intoRun : run.hi - intoRun, run.lo),
328
+ run.hi,
329
+ );
330
+ const halfAlong = (run.axis === "x" ? size.width : size.height) / 2;
331
+ const halfCross = (run.axis === "x" ? size.height : size.width) / 2;
332
+ const blocker = (clearance: number, soft: number) => (box: Box) => ({
333
+ span: spanOn(box, run.axis),
334
+ clearance,
335
+ soft,
336
+ });
337
+ const blockers = [
338
+ ...around.obstacles.map(blocker(PILL_CARD_CLEARANCE, SOFT_CARD_GAP)),
339
+ ...pills.map(blocker(PILL_CLEARANCE, SOFT_PILL_GAP)),
340
+ ];
341
+ const breaks: number[] = [];
342
+ const breakAt = (position: number) => {
343
+ if (position >= run.lo - EPSILON && position <= run.hi + EPSILON) breaks.push(position);
344
+ };
345
+
346
+ for (const { offset, penalty } of crossOffsets(run, size)) {
347
+ const centreCross = run.cross + offset;
348
+ const low = centreCross - halfCross;
349
+ const high = centreCross + halfCross;
350
+ const hard: { from: number; to: number }[] = [];
351
+ breaks.length = 0;
352
+ breakAt(preferredAlong);
353
+ breakAt(run.lo);
354
+ breakAt(run.hi);
355
+
356
+ for (const { span: [alongLo, alongHi, crossLo, crossHi], clearance, soft } of blockers) {
357
+ if (low >= crossHi + soft || high <= crossLo - soft) continue;
358
+ breakAt(alongLo - soft - halfAlong);
359
+ breakAt(alongHi + soft + halfAlong);
360
+ if (low >= crossHi + clearance || high <= crossLo - clearance) continue;
361
+ const from = alongLo - clearance - halfAlong;
362
+ const to = alongHi + clearance + halfAlong;
363
+ hard.push({ from, to });
364
+ breakAt(from);
365
+ breakAt(to);
366
+ }
367
+ for (const [alongLo, alongHi, crossLo, crossHi] of lines) {
368
+ if (low > crossHi || high < crossLo) continue;
369
+ breakAt(alongLo - halfAlong - 1);
370
+ breakAt(alongHi + halfAlong + 1);
371
+ }
372
+
373
+ hard.sort((a, b) => a.from - b.from);
374
+ let reachedTo = Number.NEGATIVE_INFINITY;
375
+ let next = 0;
376
+ let previous: number | undefined;
377
+ for (const position of Float64Array.from(breaks).sort()) {
378
+ if (position === previous) continue;
379
+ previous = position;
380
+ // Intervals starting before this point are folded into `reachedTo`.
381
+ while (next < hard.length && (hard[next]?.from ?? Number.POSITIVE_INFINITY) < position) {
382
+ reachedTo = Math.max(reachedTo, hard[next]?.to ?? reachedTo);
383
+ next += 1;
384
+ }
385
+ if (position < reachedTo) continue;
386
+ const centre =
387
+ run.axis === "x" ? { x: position, y: centreCross } : { x: centreCross, y: position };
388
+ candidates.push({
389
+ box: centred(centre, size),
390
+ bound: Math.abs(arcAt(position) - own.preferred) + penalty,
391
+ around,
392
+ pills,
393
+ });
394
+ }
148
395
  }
396
+ }
397
+
398
+ // Once a bound alone exceeds the worst kept placement, nothing later can win.
399
+ candidates.sort((a, b) => a.bound - b.bound || a.box.y - b.box.y || a.box.x - b.box.x);
400
+ const ranked: Placement[] = [];
401
+ for (const { box, bound, around, pills } of candidates) {
402
+ const worst = ranked[ranked.length - 1];
403
+ if (ranked.length === limit && worst !== undefined && bound >= worst.cost - EPSILON) break;
404
+ if (ranked.some((kept) => kept.box.x === box.x && kept.box.y === box.y)) continue;
405
+ const cost = bound + crowding(box, label, scene.frame, around, pills);
406
+ const at = ranked.findIndex((kept) => cost < kept.cost - EPSILON);
407
+ ranked.splice(at === -1 ? ranked.length : at, 0, { box, cost });
408
+ if (ranked.length > limit) ranked.pop();
409
+ }
410
+ return ranked;
149
411
  };
150
412
 
151
- const settle = (
152
- curve: Curve,
153
- anchor: Point,
154
- size: Size,
155
- settled: readonly Box[],
413
+ /** Last resort, off the line: the layout widens the gap before a drawing lands here. */
414
+ const nearestClear = (
415
+ label: Label,
416
+ scene: Scene,
417
+ settled: ReadonlyMap<string, Box>,
156
418
  ): Box => {
157
- const run = longestRun(curve);
158
- if (run !== undefined)
159
- for (const reach of [0, PILL_HEIGHT]) {
160
- const along = slideAlong(run, size, settled, reach);
161
- if (along !== undefined) return centred(centreOn(run, along), size);
162
- }
419
+ const { anchor, size } = label;
420
+ const pills = [...settled].filter(([id]) => id !== label.id).map(([, box]) => box);
421
+ const clear = (box: Box) =>
422
+ !scene.obstacles.some((obstacle) => tooClose(box, obstacle, PILL_CLEARANCE)) &&
423
+ !pills.some((other) => tooClose(box, other, PILL_CLEARANCE));
424
+ const stepY = size.height + PILL_CLEARANCE;
425
+ const stepX = 8;
426
+ for (let ring = 0; ; ring += 1) {
427
+ const found: { box: Box; distance: number }[] = [];
428
+ for (let j = -ring; j <= ring; j += 1)
429
+ for (let i = -ring * 4; i <= ring * 4; i += 1) {
430
+ if (Math.max(Math.abs(j), Math.ceil(Math.abs(i) / 4)) !== ring) continue;
431
+ const box = centred({ x: anchor.x + i * stepX, y: anchor.y + j * stepY }, size);
432
+ if (clear(box)) found.push({ box, distance: Math.hypot(i * stepX, j * stepY) });
433
+ }
434
+ found.sort((a, b) => a.distance - b.distance || a.box.y - b.box.y || a.box.x - b.box.x);
435
+ const first = found[0];
436
+ if (first !== undefined) return first.box;
437
+ }
438
+ };
163
439
 
164
- // A route that only bends — a self-loop — has no run to slide along; its
165
- // anchor still stands wherever it is clear.
166
- const atAnchor = centred(anchor, size);
167
- if (!settled.some((other) => collides(atAnchor, other))) return atAnchor;
168
- return stepAside(anchor, run?.axis ?? "x", size, settled);
440
+ export type LabelPlacement = {
441
+ boxes: Map<string, Box>;
442
+ /** Pill widths of the labels with no room on or beside their line. */
443
+ cramped: Map<string, number>;
169
444
  };
170
445
 
171
446
  /**
172
- * A pill box for every labelled route, none intersecting any other. Routes
173
- * settle in document order, and a pill whose anchor is already clear stays
174
- * exactly where the router put it, so an uncrowded diagram is untouched.
447
+ * Each label takes the cheapest spot on or beside its own route that keeps
448
+ * clear of cards, badges and other pills. A label with room at the middle of
449
+ * its longest run stays exactly there.
175
450
  */
176
- export const placeLabelPills = (routed: readonly RoutedEdge[]): Map<string, Box> => {
177
- const settled: Box[] = [];
451
+ export const placeLabelPills = (
452
+ routed: readonly RoutedEdge[],
453
+ obstacles: readonly Box[],
454
+ frame?: Box,
455
+ ): LabelPlacement => {
456
+ const traced = routed.map((route) => ({ route, traced: trace(route.curve) }));
457
+ const pieces: ForeignPiece[] = traced.flatMap(({ route, traced: { pieces } }) =>
458
+ pieces.map((piece) => ({ ...piece, id: route.edge.id })),
459
+ );
460
+ const scene: Scene = { obstacles, frame };
461
+
462
+ const labels: Label[] = traced.flatMap(({ route, traced: own }) => {
463
+ const { edge, labelAnchor } = route;
464
+ if (edge.label === undefined || labelAnchor === undefined) return [];
465
+ const size = pillSize(edge.label);
466
+ const reach = Math.max(size.width, size.height) + SOFT_CARD_GAP + BESIDE_GAP;
467
+ const around = own.runs.map((run) => {
468
+ const region = inflate(runBounds(run), reach);
469
+ return {
470
+ run,
471
+ region,
472
+ obstacles: obstacles.filter((box) => overlaps(box, region)),
473
+ pieces: pieces.filter((piece) => piece.id !== edge.id && overlaps(piece.bounds, region)),
474
+ };
475
+ });
476
+ return [{ id: edge.id, own, size, anchor: labelAnchor, around }];
477
+ });
478
+
178
479
  const boxes = new Map<string, Box>();
480
+ const costs = new Map<string, number>();
481
+ /** So a label can tell whether anything near it moved. */
482
+ const moves: { id: string; boxes: Box[] }[] = [];
483
+ const placedAt = new Map<string, number>();
179
484
 
180
- for (const { edge, curve, labelAnchor } of routed) {
181
- if (edge.label === undefined || labelAnchor === undefined) continue;
182
- const size = {
183
- width: measure(edge.label, "sans-bold", PILL_TEXT_SIZE) + PILL_PADDING_X * 2,
184
- height: PILL_HEIGHT,
185
- };
186
- const box = settle(curve, labelAnchor, size, settled);
187
- settled.push(box);
188
- boxes.set(edge.id, box);
485
+ const place = (label: Label): Placement => {
486
+ const [found] = rankOnRoute(label, scene, boxes, 1);
487
+ return found ?? { box: nearestClear(label, scene, boxes), cost: Number.POSITIVE_INFINITY };
488
+ };
489
+ const keep = (id: string, placement: Placement) => {
490
+ const before = boxes.get(id);
491
+ moves.push({ id, boxes: before === undefined ? [placement.box] : [before, placement.box] });
492
+ placedAt.set(id, moves.length);
493
+ boxes.set(id, placement.box);
494
+ costs.set(id, placement.cost);
495
+ };
496
+ const isCramped = (id: string) => costs.get(id) === Number.POSITIVE_INFINITY;
497
+ const unsettled = (label: Label) =>
498
+ moves
499
+ .slice(placedAt.get(label.id) ?? 0)
500
+ .some(
501
+ ({ id, boxes: moved }) =>
502
+ id !== label.id &&
503
+ moved.some((box) => label.around.some(({ region }) => overlaps(box, region))),
504
+ );
505
+
506
+ for (const label of labels) keep(label.id, place(label));
507
+
508
+ // A label placed early could not see the ones after it.
509
+ for (const label of labels) {
510
+ const current = costs.get(label.id) ?? 0;
511
+ if (current < EPSILON || !unsettled(label)) continue;
512
+ const placement = place(label);
513
+ if (placement.cost < current - EPSILON) keep(label.id, placement);
189
514
  }
190
515
 
191
- return boxes;
516
+ // A cramped label was usually beaten to the only room by a neighbour that
517
+ // had somewhere else to go: swap them when both still fit.
518
+ const repair = (label: Label, other: Label, regions: readonly Box[]): boolean => {
519
+ const mine = boxes.get(label.id);
520
+ const theirs = boxes.get(other.id);
521
+ if (mine === undefined || theirs === undefined) return false;
522
+ if (!regions.some((region) => overlaps(theirs, region))) return false;
523
+
524
+ boxes.delete(other.id);
525
+ for (const option of rankOnRoute(label, scene, boxes, REPAIR_OPTIONS)) {
526
+ boxes.set(label.id, option.box);
527
+ const [moved] = rankOnRoute(other, scene, boxes, 1);
528
+ if (moved === undefined) continue;
529
+ keep(label.id, option);
530
+ keep(other.id, moved);
531
+ return true;
532
+ }
533
+ boxes.set(label.id, mine);
534
+ boxes.set(other.id, theirs);
535
+ return false;
536
+ };
537
+ for (const label of labels) {
538
+ if (!isCramped(label.id)) continue;
539
+ const reach = label.size.width + SOFT_CARD_GAP + BESIDE_GAP;
540
+ const regions = label.around.map(({ run }) => inflate(runBounds(run), reach));
541
+ for (const other of labels)
542
+ if (other.id !== label.id && !isCramped(other.id) && repair(label, other, regions)) break;
543
+ }
544
+
545
+ return {
546
+ boxes,
547
+ cramped: new Map(
548
+ labels.filter(({ id }) => isCramped(id)).map(({ id, size }) => [id, size.width]),
549
+ ),
550
+ };
192
551
  };