@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.
- package/README.md +2 -1
- package/dist/bounds.d.ts +6 -1
- package/dist/bounds.d.ts.map +1 -1
- package/dist/bounds.js +21 -0
- package/dist/bounds.js.map +1 -1
- package/dist/design.d.ts +5 -0
- package/dist/design.d.ts.map +1 -1
- package/dist/design.js +5 -0
- package/dist/design.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/layout/architecture.d.ts +2 -0
- package/dist/layout/architecture.d.ts.map +1 -1
- package/dist/layout/architecture.js +5 -0
- package/dist/layout/architecture.js.map +1 -1
- package/dist/layout/congestion.d.ts +5 -0
- package/dist/layout/congestion.d.ts.map +1 -1
- package/dist/layout/congestion.js +50 -12
- package/dist/layout/congestion.js.map +1 -1
- package/dist/layout/edges.d.ts +18 -3
- package/dist/layout/edges.d.ts.map +1 -1
- package/dist/layout/edges.js +93 -23
- package/dist/layout/edges.js.map +1 -1
- package/dist/layout/labels.d.ts +15 -4
- package/dist/layout/labels.d.ts.map +1 -1
- package/dist/layout/labels.js +404 -119
- package/dist/layout/labels.js.map +1 -1
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +3 -1
- package/dist/manifest.js.map +1 -1
- package/dist/render.d.ts +12 -2
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +2 -2
- package/dist/render.js.map +1 -1
- package/dist/svg/architecture.d.ts.map +1 -1
- package/dist/svg/architecture.js +3 -8
- package/dist/svg/architecture.js.map +1 -1
- package/dist/theme.d.ts +9 -8
- package/dist/theme.d.ts.map +1 -1
- package/dist/theme.js +61 -8
- package/dist/theme.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/src/bounds.ts +29 -1
- package/src/design.ts +5 -0
- package/src/index.ts +2 -1
- package/src/layout/architecture.ts +7 -0
- package/src/layout/congestion.ts +71 -12
- package/src/layout/edges.ts +126 -21
- package/src/layout/labels.ts +495 -136
- package/src/manifest.ts +3 -2
- package/src/render.ts +19 -4
- package/src/svg/architecture.ts +3 -7
- package/src/theme.ts +62 -8
- package/src/version.ts +1 -1
package/src/layout/labels.ts
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
import { assertNever } from "@coldtea/pr-lens-schema";
|
|
2
|
-
import {
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
|
133
|
-
*
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
settled:
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
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 = (
|
|
177
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
boxes.set(
|
|
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
|
-
|
|
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
|
};
|