@dxos/diagram 0.11.1 → 0.14.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/dist/lib/chunk-parse.mjs +9406 -0
- package/dist/lib/chunk-parse.mjs.map +1 -0
- package/dist/lib/extension.mjs +204 -0
- package/dist/lib/extension.mjs.map +1 -0
- package/dist/lib/index.mjs +497 -2846
- package/dist/lib/index.mjs.map +1 -1
- package/dist/types/src/aesthetics.d.ts +12 -0
- package/dist/types/src/aesthetics.d.ts.map +1 -0
- package/dist/types/src/aesthetics.test.d.ts +2 -0
- package/dist/types/src/aesthetics.test.d.ts.map +1 -0
- package/dist/types/src/appeal.d.ts +55 -0
- package/dist/types/src/appeal.d.ts.map +1 -0
- package/dist/types/src/appeal.test.d.ts +2 -0
- package/dist/types/src/appeal.test.d.ts.map +1 -0
- package/dist/types/src/architecture.d.ts +86 -0
- package/dist/types/src/architecture.d.ts.map +1 -0
- package/dist/types/src/architecture.test.d.ts +2 -0
- package/dist/types/src/architecture.test.d.ts.map +1 -0
- package/dist/types/src/compact.d.ts +39 -0
- package/dist/types/src/compact.d.ts.map +1 -0
- package/dist/types/src/compact.test.d.ts +2 -0
- package/dist/types/src/compact.test.d.ts.map +1 -0
- package/dist/types/src/diagnostics.d.ts +70 -3
- package/dist/types/src/diagnostics.d.ts.map +1 -1
- package/dist/types/src/dsl/cli.d.ts +2 -0
- package/dist/types/src/dsl/cli.d.ts.map +1 -0
- package/dist/types/src/dsl/compile.d.ts +12 -0
- package/dist/types/src/dsl/compile.d.ts.map +1 -0
- package/dist/types/src/dsl/convert.d.ts +45 -0
- package/dist/types/src/dsl/convert.d.ts.map +1 -0
- package/dist/types/src/dsl/convert.test.d.ts +2 -0
- package/dist/types/src/dsl/convert.test.d.ts.map +1 -0
- package/dist/types/src/dsl/dsl.test.d.ts +2 -0
- package/dist/types/src/dsl/dsl.test.d.ts.map +1 -0
- package/dist/types/src/dsl/gen/diagram.d.ts +3 -0
- package/dist/types/src/dsl/gen/diagram.d.ts.map +1 -0
- package/dist/types/src/dsl/index.d.ts +7 -0
- package/dist/types/src/dsl/index.d.ts.map +1 -0
- package/dist/types/src/dsl/parse.d.ts +49 -0
- package/dist/types/src/dsl/parse.d.ts.map +1 -0
- package/dist/types/src/dsl/print.d.ts +19 -0
- package/dist/types/src/dsl/print.d.ts.map +1 -0
- package/dist/types/src/dsl/semantic.test.d.ts +2 -0
- package/dist/types/src/dsl/semantic.test.d.ts.map +1 -0
- package/dist/types/src/dsl/vocabulary.d.ts +61 -0
- package/dist/types/src/dsl/vocabulary.d.ts.map +1 -0
- package/dist/types/src/dx-svg.d.ts +12 -0
- package/dist/types/src/dx-svg.d.ts.map +1 -0
- package/dist/types/src/dx-svg.test.d.ts +2 -0
- package/dist/types/src/dx-svg.test.d.ts.map +1 -0
- package/dist/types/src/extension/complete.d.ts +3 -0
- package/dist/types/src/extension/complete.d.ts.map +1 -0
- package/dist/types/src/extension/extension.test.d.ts +2 -0
- package/dist/types/src/extension/extension.test.d.ts.map +1 -0
- package/dist/types/src/extension/highlight.d.ts +14 -0
- package/dist/types/src/extension/highlight.d.ts.map +1 -0
- package/dist/types/src/extension/index.d.ts +6 -0
- package/dist/types/src/extension/index.d.ts.map +1 -0
- package/dist/types/src/extension/language.d.ts +13 -0
- package/dist/types/src/extension/language.d.ts.map +1 -0
- package/dist/types/src/extension/lint.d.ts +21 -0
- package/dist/types/src/extension/lint.d.ts.map +1 -0
- package/dist/types/src/extension/syntax.d.ts +3 -0
- package/dist/types/src/extension/syntax.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +14 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/mermaid-engine.d.ts +40 -1
- package/dist/types/src/mermaid-engine.d.ts.map +1 -1
- package/dist/types/src/mermaid.d.ts +3 -3
- package/dist/types/src/mermaid.d.ts.map +1 -1
- package/dist/types/src/nudge.d.ts +30 -0
- package/dist/types/src/nudge.d.ts.map +1 -0
- package/dist/types/src/nudge.test.d.ts +2 -0
- package/dist/types/src/nudge.test.d.ts.map +1 -0
- package/dist/types/src/objective.d.ts +4 -0
- package/dist/types/src/objective.d.ts.map +1 -1
- package/dist/types/src/ortho-router.d.ts +13 -1
- package/dist/types/src/ortho-router.d.ts.map +1 -1
- package/dist/types/src/ports.d.ts +40 -0
- package/dist/types/src/ports.d.ts.map +1 -0
- package/dist/types/src/ports.test.d.ts +2 -0
- package/dist/types/src/ports.test.d.ts.map +1 -0
- package/dist/types/src/relations.test.d.ts +2 -0
- package/dist/types/src/relations.test.d.ts.map +1 -0
- package/dist/types/src/rules.d.ts +30 -0
- package/dist/types/src/rules.d.ts.map +1 -0
- package/dist/types/src/scene.d.ts +108 -74
- package/dist/types/src/scene.d.ts.map +1 -1
- package/dist/types/src/score.d.ts +54 -0
- package/dist/types/src/score.d.ts.map +1 -0
- package/dist/types/src/score.test.d.ts +2 -0
- package/dist/types/src/score.test.d.ts.map +1 -0
- package/dist/types/src/semantic-engine.d.ts +41 -0
- package/dist/types/src/semantic-engine.d.ts.map +1 -0
- package/dist/types/src/semantic-engine.test.d.ts +2 -0
- package/dist/types/src/semantic-engine.test.d.ts.map +1 -0
- package/dist/types/src/semantic-place.d.ts +81 -0
- package/dist/types/src/semantic-place.d.ts.map +1 -0
- package/dist/types/src/semantic-route.d.ts +133 -0
- package/dist/types/src/semantic-route.d.ts.map +1 -0
- package/dist/types/src/semantic.d.ts +164 -0
- package/dist/types/src/semantic.d.ts.map +1 -0
- package/dist/types/src/uml-grid.d.ts.map +1 -1
- package/dist/types/src/uml.d.ts +3 -5
- package/dist/types/src/uml.d.ts.map +1 -1
- package/dist/types/src/view.d.ts +53 -0
- package/dist/types/src/view.d.ts.map +1 -0
- package/dist/types/src/view.test.d.ts +2 -0
- package/dist/types/src/view.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +23 -5
- package/rules/DIAGRAM.mdl +388 -0
- package/src/aesthetics.test.ts +89 -0
- package/src/aesthetics.ts +148 -0
- package/src/appeal.test.ts +144 -0
- package/src/appeal.ts +586 -0
- package/src/architecture.test.ts +94 -0
- package/src/architecture.ts +215 -0
- package/src/compact.test.ts +67 -0
- package/src/compact.ts +219 -0
- package/src/diagnostics.test.ts +207 -1
- package/src/diagnostics.ts +481 -37
- package/src/dsl/cli.ts +46 -0
- package/src/dsl/compile.ts +28 -0
- package/src/dsl/convert.test.ts +51 -0
- package/src/dsl/convert.ts +98 -0
- package/src/dsl/diagram.grammar +195 -0
- package/src/dsl/dsl.test.ts +286 -0
- package/src/dsl/gen/diagram.ts +22 -0
- package/src/dsl/index.ts +44 -0
- package/src/dsl/parse.ts +1054 -0
- package/src/dsl/print.ts +198 -0
- package/src/dsl/semantic.test.ts +216 -0
- package/src/dsl/vocabulary.ts +150 -0
- package/src/dx-svg.test.ts +44 -0
- package/src/dx-svg.ts +77 -0
- package/src/extension/complete.ts +105 -0
- package/src/extension/extension.test.ts +135 -0
- package/src/extension/highlight.ts +23 -0
- package/src/extension/index.ts +9 -0
- package/src/extension/language.ts +31 -0
- package/src/extension/lint.ts +60 -0
- package/src/extension/syntax.ts +62 -0
- package/src/index.ts +14 -0
- package/src/mermaid-engine.test.ts +6 -6
- package/src/mermaid-engine.ts +569 -119
- package/src/mermaid.test.ts +17 -1
- package/src/mermaid.ts +30 -18
- package/src/nudge.test.ts +186 -0
- package/src/nudge.ts +387 -0
- package/src/objective.test.ts +2 -0
- package/src/objective.ts +17 -1
- package/src/ortho-router.ts +240 -80
- package/src/ports.test.ts +157 -0
- package/src/ports.ts +186 -0
- package/src/relations.test.ts +98 -0
- package/src/rules.ts +117 -0
- package/src/scene.ts +66 -5
- package/src/score.test.ts +64 -0
- package/src/score.ts +130 -0
- package/src/semantic-engine.test.ts +635 -0
- package/src/semantic-engine.ts +891 -0
- package/src/semantic-place.ts +1508 -0
- package/src/semantic-route.ts +1136 -0
- package/src/semantic.ts +278 -0
- package/src/typings.d.ts +8 -0
- package/src/uml-grid.ts +6 -1
- package/src/uml.test.ts +5 -1
- package/src/uml.ts +24 -11
- package/src/view.test.ts +139 -0
- package/src/view.ts +376 -0
|
@@ -0,0 +1,1136 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
//
|
|
6
|
+
// Channel routing for the semantic diagram. Boxes sit on a lattice with gutters between them, so a
|
|
7
|
+
// good connector is one of a few shapes — straight, L, Z or U through a gutter, or a three-bend
|
|
8
|
+
// detour — between some pair of sides. Every allowed side pair and every nearby channel yields a
|
|
9
|
+
// candidate; candidates that clip a box are dropped and the rest are scored by bends, length,
|
|
10
|
+
// crossings and shared runs against the connectors already drawn, with rip-up rounds so an early
|
|
11
|
+
// edge is not stuck with a choice a later one made worse. Ports are then spread along each side in
|
|
12
|
+
// the order their routes leave it (`ports.ts`), parallel runs are separated (`nudge.ts`), and each
|
|
13
|
+
// label goes beside its own longest run. A connector no shape can draw falls back to the A* router.
|
|
14
|
+
//
|
|
15
|
+
|
|
16
|
+
import * as Layout from './layout.ts';
|
|
17
|
+
import * as Nudge from './nudge.ts';
|
|
18
|
+
import { makeAvoidingRouter } from './ortho-router.ts';
|
|
19
|
+
import * as Ports from './ports.ts';
|
|
20
|
+
import type * as Scene from './scene.ts';
|
|
21
|
+
import { type Side, SIDES } from './semantic.ts';
|
|
22
|
+
import { GRID, type Rect, zRouter } from './uml-grid.ts';
|
|
23
|
+
|
|
24
|
+
type Point = Scene.Point;
|
|
25
|
+
|
|
26
|
+
const FINE = GRID / 2;
|
|
27
|
+
/** Clear space a route keeps from a box it does not attach to. */
|
|
28
|
+
const CLEAR = GRID / 4;
|
|
29
|
+
/** Two parallel runs closer than this read as one line. */
|
|
30
|
+
const TOO_CLOSE = GRID / 4;
|
|
31
|
+
const LABEL_FONT = Layout.FONT_METRICS.s;
|
|
32
|
+
|
|
33
|
+
/** Scoring weights; one bend is 10. */
|
|
34
|
+
const COST = {
|
|
35
|
+
bend: 10,
|
|
36
|
+
length: 0.3 / GRID,
|
|
37
|
+
crossing: 30,
|
|
38
|
+
overlap: 20,
|
|
39
|
+
sharedSide: 3,
|
|
40
|
+
opposedSide: 6,
|
|
41
|
+
label: 5,
|
|
42
|
+
/** A side the author only preferred; more than a bend, so it gives way to a crossing but not to a detour. */
|
|
43
|
+
unpreferred: 15,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** One end of a connector: a box (zero-sized for a bus junction), the sides it may use, and a fixed port. */
|
|
47
|
+
export type End = {
|
|
48
|
+
rect: Rect;
|
|
49
|
+
/** Key of the box, for port bookkeeping; undefined for a junction. */
|
|
50
|
+
node?: string;
|
|
51
|
+
sides: readonly Side[];
|
|
52
|
+
/** Sides the author prefers among `sides`; any other costs extra. */
|
|
53
|
+
preferred?: readonly Side[];
|
|
54
|
+
/** Fixed coordinate along the side. */
|
|
55
|
+
port?: number;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** A waypoint in scene units: one coordinate pins that axis of the route, both pin a point. */
|
|
59
|
+
export type Waypoint = { x?: number; y?: number };
|
|
60
|
+
|
|
61
|
+
export type Piece = {
|
|
62
|
+
/** Connector id; pieces of one bus share a `bus` key and may touch. */
|
|
63
|
+
id: string;
|
|
64
|
+
bus?: string;
|
|
65
|
+
start: End;
|
|
66
|
+
end: End;
|
|
67
|
+
via: readonly Waypoint[];
|
|
68
|
+
points: Point[];
|
|
69
|
+
startSide?: Side;
|
|
70
|
+
endSide?: Side;
|
|
71
|
+
/** Drawn by the A* fallback, which ignores sides and waypoints. */
|
|
72
|
+
forced?: boolean;
|
|
73
|
+
/** Rank of the connector's relation; the highest on a shared side takes its centre. */
|
|
74
|
+
significance?: number;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type Context = {
|
|
78
|
+
/** Every box a route must not cross. */
|
|
79
|
+
obstacles: readonly Rect[];
|
|
80
|
+
/** Text a route should avoid (frame titles). */
|
|
81
|
+
avoid: readonly Rect[];
|
|
82
|
+
/** Channel coordinates: x of vertical gutters, y of horizontal ones. */
|
|
83
|
+
channels: { xs: readonly number[]; ys: readonly number[] };
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const horizontalSide = (side: Side) => side === 'left' || side === 'right';
|
|
87
|
+
|
|
88
|
+
const OUT: Record<Side, Point> = {
|
|
89
|
+
top: { x: 0, y: -1 },
|
|
90
|
+
bottom: { x: 0, y: 1 },
|
|
91
|
+
left: { x: -1, y: 0 },
|
|
92
|
+
right: { x: 1, y: 0 },
|
|
93
|
+
};
|
|
94
|
+
const IN: Record<Side, Point> = {
|
|
95
|
+
top: { x: 0, y: 1 },
|
|
96
|
+
bottom: { x: 0, y: -1 },
|
|
97
|
+
left: { x: 1, y: 0 },
|
|
98
|
+
right: { x: -1, y: 0 },
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const isPoint = (rect: Rect) => rect.w === 0 && rect.h === 0;
|
|
102
|
+
|
|
103
|
+
const centreOf = (rect: Rect, side: Side) => (horizontalSide(side) ? rect.y + rect.h / 2 : rect.x + rect.w / 2);
|
|
104
|
+
|
|
105
|
+
const rangeOf = (rect: Rect, side: Side): [number, number] =>
|
|
106
|
+
isPoint(rect)
|
|
107
|
+
? [centreOf(rect, side), centreOf(rect, side)]
|
|
108
|
+
: horizontalSide(side)
|
|
109
|
+
? [rect.y + FINE, rect.y + rect.h - FINE]
|
|
110
|
+
: [rect.x + FINE, rect.x + rect.w - FINE];
|
|
111
|
+
|
|
112
|
+
export const portPoint = (rect: Rect, side: Side, coord: number): Point => {
|
|
113
|
+
if (isPoint(rect)) {
|
|
114
|
+
return { x: rect.x, y: rect.y };
|
|
115
|
+
}
|
|
116
|
+
switch (side) {
|
|
117
|
+
case 'top':
|
|
118
|
+
return { x: coord, y: rect.y };
|
|
119
|
+
case 'bottom':
|
|
120
|
+
return { x: coord, y: rect.y + rect.h };
|
|
121
|
+
case 'left':
|
|
122
|
+
return { x: rect.x, y: coord };
|
|
123
|
+
case 'right':
|
|
124
|
+
return { x: rect.x + rect.w, y: coord };
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const snap = (value: number, step = CLEAR) => Math.round(value / step) * step;
|
|
129
|
+
|
|
130
|
+
const same = (left: Point, right: Point) => Math.abs(left.x - right.x) < 0.5 && Math.abs(left.y - right.y) < 0.5;
|
|
131
|
+
|
|
132
|
+
/** Drops repeated points and collinear midpoints; undefined when the path doubles back on itself. */
|
|
133
|
+
export const simplify = (points: readonly Point[]): Point[] | undefined => {
|
|
134
|
+
const distinct = points.filter((point, index) => index === 0 || !same(point, points[index - 1]));
|
|
135
|
+
const result: Point[] = [];
|
|
136
|
+
for (const point of distinct) {
|
|
137
|
+
if (result.length >= 2) {
|
|
138
|
+
const [previous, last] = [result[result.length - 2], result[result.length - 1]];
|
|
139
|
+
const vertical = Math.abs(previous.x - last.x) < 0.5 && Math.abs(last.x - point.x) < 0.5;
|
|
140
|
+
const horizontal = Math.abs(previous.y - last.y) < 0.5 && Math.abs(last.y - point.y) < 0.5;
|
|
141
|
+
if (vertical || horizontal) {
|
|
142
|
+
const reverses = vertical
|
|
143
|
+
? Math.sign(last.y - previous.y) !== Math.sign(point.y - last.y)
|
|
144
|
+
: Math.sign(last.x - previous.x) !== Math.sign(point.x - last.x);
|
|
145
|
+
if (reverses) {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
result[result.length - 1] = point;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
result.push(point);
|
|
153
|
+
}
|
|
154
|
+
return result;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export const bendsOf = (points: readonly Point[]) => Math.max(0, points.length - 2);
|
|
158
|
+
|
|
159
|
+
const lengthOf = (points: readonly Point[]) =>
|
|
160
|
+
points
|
|
161
|
+
.slice(1)
|
|
162
|
+
.reduce((sum, point, index) => sum + Math.abs(point.x - points[index].x) + Math.abs(point.y - points[index].y), 0);
|
|
163
|
+
|
|
164
|
+
const direction = (from: Point, to: Point): Point => ({ x: Math.sign(to.x - from.x), y: Math.sign(to.y - from.y) });
|
|
165
|
+
|
|
166
|
+
/** Whether an axis-aligned segment passes through the rect's interior. */
|
|
167
|
+
const hits = (from: Point, to: Point, rect: Rect, inflate: number): boolean => {
|
|
168
|
+
const x0 = Math.min(from.x, to.x);
|
|
169
|
+
const x1 = Math.max(from.x, to.x);
|
|
170
|
+
const y0 = Math.min(from.y, to.y);
|
|
171
|
+
const y1 = Math.max(from.y, to.y);
|
|
172
|
+
return (
|
|
173
|
+
x1 > rect.x - inflate + 0.5 &&
|
|
174
|
+
x0 < rect.x + rect.w + inflate - 0.5 &&
|
|
175
|
+
y1 > rect.y - inflate + 0.5 &&
|
|
176
|
+
y0 < rect.y + rect.h + inflate - 0.5
|
|
177
|
+
);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/** Whether a path is drawable: leaves and enters on its sides, never doubles back, and clips no box. */
|
|
181
|
+
const valid = (
|
|
182
|
+
points: readonly Point[],
|
|
183
|
+
piece: { start: End; end: End },
|
|
184
|
+
startSide: Side,
|
|
185
|
+
endSide: Side,
|
|
186
|
+
context: Context,
|
|
187
|
+
) => {
|
|
188
|
+
if (points.length < 2) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
const first = direction(points[0], points[1]);
|
|
192
|
+
const last = direction(points[points.length - 2], points[points.length - 1]);
|
|
193
|
+
if (!isPoint(piece.start.rect) && (first.x !== OUT[startSide].x || first.y !== OUT[startSide].y)) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
if (!isPoint(piece.end.rect) && (last.x !== IN[endSide].x || last.y !== IN[endSide].y)) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
// A stub shorter than the clearance hugs the box it leaves.
|
|
200
|
+
if (!isPoint(piece.start.rect) && points.length > 2 && lengthOf(points.slice(0, 2)) < CLEAR) {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
if (!isPoint(piece.end.rect) && points.length > 2 && lengthOf(points.slice(-2)) < CLEAR) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
for (let index = 0; index < points.length - 1; index++) {
|
|
207
|
+
const [from, to] = [points[index], points[index + 1]];
|
|
208
|
+
for (const rect of context.obstacles) {
|
|
209
|
+
const own = rect === piece.start.rect || rect === piece.end.rect;
|
|
210
|
+
if ((rect === piece.start.rect && index === 0) || (rect === piece.end.rect && index === points.length - 2)) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (hits(from, to, rect, own ? 0 : CLEAR)) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return true;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/** Axis moves through the waypoints, sliding a port to meet a waypoint when the side allows it. */
|
|
222
|
+
const throughWaypoints = (
|
|
223
|
+
piece: { start: End; end: End; via: readonly Waypoint[] },
|
|
224
|
+
startSide: Side,
|
|
225
|
+
endSide: Side,
|
|
226
|
+
): Point[] | undefined => {
|
|
227
|
+
const { start, end } = piece;
|
|
228
|
+
const [startLow, startHigh] = start.port !== undefined ? [start.port, start.port] : rangeOf(start.rect, startSide);
|
|
229
|
+
const startAxis: 'x' | 'y' = horizontalSide(startSide) ? 'y' : 'x';
|
|
230
|
+
let origin = portPoint(start.rect, startSide, start.port ?? centreOf(start.rect, startSide));
|
|
231
|
+
const points: Point[] = [origin];
|
|
232
|
+
const at = () => points[points.length - 1];
|
|
233
|
+
const slide = (value: number) => {
|
|
234
|
+
if (points.length === 1 && value >= startLow && value <= startHigh) {
|
|
235
|
+
origin = portPoint(start.rect, startSide, value);
|
|
236
|
+
points[0] = origin;
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
return false;
|
|
240
|
+
};
|
|
241
|
+
let viaLast = false;
|
|
242
|
+
for (const waypoint of piece.via) {
|
|
243
|
+
viaLast = true;
|
|
244
|
+
const { x, y } = waypoint;
|
|
245
|
+
if (x !== undefined && y !== undefined) {
|
|
246
|
+
if (!slide(startAxis === 'x' ? x : y)) {
|
|
247
|
+
points.push(horizontalSide(startSide) || points.length > 1 ? { x, y: at().y } : { x: at().x, y });
|
|
248
|
+
}
|
|
249
|
+
points.push({ x: x ?? at().x, y: y ?? at().y });
|
|
250
|
+
points.push({ x, y });
|
|
251
|
+
} else if (x !== undefined) {
|
|
252
|
+
if (startAxis === 'x' && slide(x)) {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
points.push({ x, y: at().y });
|
|
256
|
+
} else if (y !== undefined) {
|
|
257
|
+
if (startAxis === 'y' && slide(y)) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
points.push({ x: at().x, y });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const [endLow, endHigh] = end.port !== undefined ? [end.port, end.port] : rangeOf(end.rect, endSide);
|
|
264
|
+
const last = at();
|
|
265
|
+
const endAxis: 'x' | 'y' = horizontalSide(endSide) ? 'y' : 'x';
|
|
266
|
+
const coord =
|
|
267
|
+
last[endAxis] >= endLow && last[endAxis] <= endHigh ? last[endAxis] : (end.port ?? centreOf(end.rect, endSide));
|
|
268
|
+
const target = portPoint(end.rect, endSide, coord);
|
|
269
|
+
if (target[endAxis] !== last[endAxis]) {
|
|
270
|
+
// The last run either already slides along the side's axis, so one turn lines it up, or it
|
|
271
|
+
// runs toward the side, so it steps aside just outside the box first.
|
|
272
|
+
const previous = points.length > 1 ? points[points.length - 2] : undefined;
|
|
273
|
+
const runsAlong = previous ? Math.abs(previous[endAxis] - last[endAxis]) > 0.5 : startAxis !== endAxis;
|
|
274
|
+
// Continuing a waypoint's run would pass its coordinate by, so the route turns there instead.
|
|
275
|
+
if (runsAlong && !(viaLast && previous)) {
|
|
276
|
+
points.push(endAxis === 'x' ? { x: target.x, y: last.y } : { x: last.x, y: target.y });
|
|
277
|
+
} else {
|
|
278
|
+
const outside = endAxis === 'x' ? target.y + OUT[endSide].y * GRID * 2 : target.x + OUT[endSide].x * GRID * 2;
|
|
279
|
+
points.push(endAxis === 'x' ? { x: last.x, y: outside } : { x: outside, y: last.y });
|
|
280
|
+
points.push(endAxis === 'x' ? { x: target.x, y: outside } : { x: outside, y: target.y });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
points.push(target);
|
|
284
|
+
return simplify(points);
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/** Every template route between the two ends on one side pair. */
|
|
288
|
+
const templates = (piece: { start: End; end: End }, startSide: Side, endSide: Side, context: Context): Point[][] => {
|
|
289
|
+
const { start, end } = piece;
|
|
290
|
+
const startCoord = start.port ?? centreOf(start.rect, startSide);
|
|
291
|
+
const endCoord = end.port ?? centreOf(end.rect, endSide);
|
|
292
|
+
const from = portPoint(start.rect, startSide, startCoord);
|
|
293
|
+
const to = portPoint(end.rect, endSide, endCoord);
|
|
294
|
+
const alongX = horizontalSide(startSide);
|
|
295
|
+
const near = (values: readonly number[], low: number, high: number) =>
|
|
296
|
+
values.filter((value) => value >= low - GRID * 8 && value <= high + GRID * 8);
|
|
297
|
+
const xs = near(context.channels.xs, Math.min(from.x, to.x), Math.max(from.x, to.x));
|
|
298
|
+
const ys = near(context.channels.ys, Math.min(from.y, to.y), Math.max(from.y, to.y));
|
|
299
|
+
const paths: Point[][] = [];
|
|
300
|
+
if (alongX === horizontalSide(endSide)) {
|
|
301
|
+
// Straight, with both ports sliding to a shared coordinate inside both sides.
|
|
302
|
+
const [startLow, startHigh] = start.port !== undefined ? [start.port, start.port] : rangeOf(start.rect, startSide);
|
|
303
|
+
const [endLow, endHigh] = end.port !== undefined ? [end.port, end.port] : rangeOf(end.rect, endSide);
|
|
304
|
+
const low = Math.max(startLow, endLow);
|
|
305
|
+
const high = Math.min(startHigh, endHigh);
|
|
306
|
+
if (low <= high) {
|
|
307
|
+
const coord = Math.min(high, Math.max(low, snap((startCoord + endCoord) / 2)));
|
|
308
|
+
paths.push([portPoint(start.rect, startSide, coord), portPoint(end.rect, endSide, coord)]);
|
|
309
|
+
}
|
|
310
|
+
// Z or U through one channel across the travel axis.
|
|
311
|
+
for (const channel of alongX ? xs : ys) {
|
|
312
|
+
paths.push(
|
|
313
|
+
alongX
|
|
314
|
+
? [from, { x: channel, y: from.y }, { x: channel, y: to.y }, to]
|
|
315
|
+
: [from, { x: from.x, y: channel }, { x: to.x, y: channel }, to],
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
} else {
|
|
319
|
+
paths.push([from, alongX ? { x: to.x, y: from.y } : { x: from.x, y: to.y }, to]);
|
|
320
|
+
for (const first of alongX ? xs : ys) {
|
|
321
|
+
for (const second of alongX ? ys : xs) {
|
|
322
|
+
paths.push(
|
|
323
|
+
alongX
|
|
324
|
+
? [from, { x: first, y: from.y }, { x: first, y: second }, { x: to.x, y: second }, to]
|
|
325
|
+
: [from, { x: from.x, y: first }, { x: second, y: first }, { x: second, y: to.y }, to],
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return paths.flatMap((path) => {
|
|
331
|
+
const simple = simplify(path);
|
|
332
|
+
return simple ? [simple] : [];
|
|
333
|
+
});
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
/** A run of a route; `docked` marks an end that sits on a box port, where routes meet by design. */
|
|
337
|
+
type Segment = {
|
|
338
|
+
owner: string;
|
|
339
|
+
bus?: string;
|
|
340
|
+
pair?: string;
|
|
341
|
+
from: Point;
|
|
342
|
+
to: Point;
|
|
343
|
+
docked: [boolean, boolean];
|
|
344
|
+
/** The piece's ports on boxes. */
|
|
345
|
+
ports: readonly Point[];
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
/** The two boxes a piece joins, in either order; pieces between one pair share their sides by design. */
|
|
349
|
+
const pairOf = (piece: { start: End; end: End }): string | undefined =>
|
|
350
|
+
piece.start.node !== undefined && piece.end.node !== undefined
|
|
351
|
+
? [piece.start.node, piece.end.node].sort().join('|')
|
|
352
|
+
: undefined;
|
|
353
|
+
|
|
354
|
+
const segmentsOf = (piece: {
|
|
355
|
+
id: string;
|
|
356
|
+
bus?: string;
|
|
357
|
+
start?: End;
|
|
358
|
+
end?: End;
|
|
359
|
+
points: readonly Point[];
|
|
360
|
+
}): Segment[] => {
|
|
361
|
+
const pair = piece.start && piece.end ? pairOf({ start: piece.start, end: piece.end }) : undefined;
|
|
362
|
+
const last = piece.points.length - 2;
|
|
363
|
+
const startDocked = piece.start !== undefined && !isPoint(piece.start.rect);
|
|
364
|
+
const endDocked = piece.end !== undefined && !isPoint(piece.end.rect);
|
|
365
|
+
const ports = [
|
|
366
|
+
...(startDocked ? [piece.points[0]] : []),
|
|
367
|
+
...(endDocked ? [piece.points[piece.points.length - 1]] : []),
|
|
368
|
+
];
|
|
369
|
+
return piece.points.slice(0, -1).map((from, index): Segment => ({
|
|
370
|
+
owner: piece.id,
|
|
371
|
+
bus: piece.bus,
|
|
372
|
+
pair,
|
|
373
|
+
from,
|
|
374
|
+
to: piece.points[index + 1],
|
|
375
|
+
docked: [index === 0 && startDocked, index === last && endDocked],
|
|
376
|
+
ports,
|
|
377
|
+
}));
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const crosses = (left: Segment, right: Segment): boolean => {
|
|
381
|
+
const leftVertical = Math.abs(left.from.x - left.to.x) < 0.5;
|
|
382
|
+
const rightVertical = Math.abs(right.from.x - right.to.x) < 0.5;
|
|
383
|
+
if (leftVertical === rightVertical) {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
const [vertical, horizontal] = leftVertical ? [left, right] : [right, left];
|
|
387
|
+
const x = vertical.from.x;
|
|
388
|
+
const y = horizontal.from.y;
|
|
389
|
+
const strictly = (value: number, a: number, b: number) =>
|
|
390
|
+
value > Math.min(a, b) + 0.5 && value < Math.max(a, b) - 0.5;
|
|
391
|
+
return strictly(x, horizontal.from.x, horizontal.to.x) && strictly(y, vertical.from.y, vertical.to.y);
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const overlaps = (left: Segment, right: Segment): boolean => {
|
|
395
|
+
const leftVertical = Math.abs(left.from.x - left.to.x) < 0.5;
|
|
396
|
+
const rightVertical = Math.abs(right.from.x - right.to.x) < 0.5;
|
|
397
|
+
if (leftVertical !== rightVertical) {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
const [leftCoord, rightCoord] = leftVertical ? [left.from.x, right.from.x] : [left.from.y, right.from.y];
|
|
401
|
+
if (Math.abs(leftCoord - rightCoord) >= TOO_CLOSE) {
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
const span = (segment: Segment) =>
|
|
405
|
+
leftVertical
|
|
406
|
+
? [Math.min(segment.from.y, segment.to.y), Math.max(segment.from.y, segment.to.y)]
|
|
407
|
+
: [Math.min(segment.from.x, segment.to.x), Math.max(segment.from.x, segment.to.x)];
|
|
408
|
+
const [a0, a1] = span(left);
|
|
409
|
+
const [b0, b1] = span(right);
|
|
410
|
+
return Math.min(a1, b1) - Math.max(a0, b0) > 0.5;
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
/** Whether one segment's end lies on the other, or the two continue each other along one line. */
|
|
414
|
+
const touches = (left: Segment, right: Segment): boolean => {
|
|
415
|
+
const on = (point: Point, segment: Segment) =>
|
|
416
|
+
point.x >= Math.min(segment.from.x, segment.to.x) - 0.5 &&
|
|
417
|
+
point.x <= Math.max(segment.from.x, segment.to.x) + 0.5 &&
|
|
418
|
+
point.y >= Math.min(segment.from.y, segment.to.y) - 0.5 &&
|
|
419
|
+
point.y <= Math.max(segment.from.y, segment.to.y) + 0.5;
|
|
420
|
+
// Two routes leaving one port share it only until the ports are spread along the side.
|
|
421
|
+
if (left.ports.some((point) => right.ports.some((other) => same(point, other)))) {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
const loose = (segment: Segment) => [segment.from, segment.to].filter((_, end) => !segment.docked[end]);
|
|
425
|
+
if (loose(left).some((point) => on(point, right)) || loose(right).some((point) => on(point, left))) {
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
const leftVertical = Math.abs(left.from.x - left.to.x) < 0.5;
|
|
429
|
+
const rightVertical = Math.abs(right.from.x - right.to.x) < 0.5;
|
|
430
|
+
if (leftVertical !== rightVertical) {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
const [leftCoord, rightCoord] = leftVertical ? [left.from.x, right.from.x] : [left.from.y, right.from.y];
|
|
434
|
+
if (Math.abs(leftCoord - rightCoord) >= 0.5) {
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
const span = (segment: Segment) =>
|
|
438
|
+
leftVertical
|
|
439
|
+
? [Math.min(segment.from.y, segment.to.y), Math.max(segment.from.y, segment.to.y)]
|
|
440
|
+
: [Math.min(segment.from.x, segment.to.x), Math.max(segment.from.x, segment.to.x)];
|
|
441
|
+
const [a0, a1] = span(left);
|
|
442
|
+
const [b0, b1] = span(right);
|
|
443
|
+
if (Math.max(a0, b0) - Math.min(a1, b1) >= CLEAR * 2) {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
// End to end: the facing ends must not both sit on ports, as two edges into one side do.
|
|
447
|
+
const [first, second] = a1 <= b0 ? [left, right] : [right, left];
|
|
448
|
+
const along = (point: Point) => (leftVertical ? point.y : point.x);
|
|
449
|
+
const firstEnd = along(first.from) > along(first.to) ? 0 : 1;
|
|
450
|
+
const secondEnd = along(second.from) < along(second.to) ? 0 : 1;
|
|
451
|
+
return !(first.docked[firstEnd] && second.docked[secondEnd]);
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
/** Whether two runs leave one port together, which spreading the ports along the side pulls apart. */
|
|
455
|
+
const fanned = (left: Segment, right: Segment): boolean =>
|
|
456
|
+
[left.from, left.to].some(
|
|
457
|
+
(point, end) =>
|
|
458
|
+
left.docked[end] &&
|
|
459
|
+
[right.from, right.to].some((other, otherEnd) => right.docked[otherEnd] && same(point, other)),
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
/** Ports already taken, per box side, by role: an exit beside an entry reads as a crossing. */
|
|
463
|
+
type Terminals = Map<string, { owner: string; role: 'exit' | 'entry' }[]>;
|
|
464
|
+
|
|
465
|
+
const sideKey = (node: string, side: Side) => `${node}:${side}`;
|
|
466
|
+
|
|
467
|
+
const terminalsOf = (pieces: readonly Piece[], except?: string): Terminals => {
|
|
468
|
+
const terminals: Terminals = new Map();
|
|
469
|
+
for (const piece of pieces) {
|
|
470
|
+
if (piece.id === except || piece.points.length < 2) {
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
for (const [end, side, role] of [
|
|
474
|
+
[piece.start, piece.startSide, 'exit'],
|
|
475
|
+
[piece.end, piece.endSide, 'entry'],
|
|
476
|
+
] as const) {
|
|
477
|
+
if (end.node !== undefined && side) {
|
|
478
|
+
const key = sideKey(end.node, side);
|
|
479
|
+
terminals.set(key, [...(terminals.get(key) ?? []), { owner: piece.id, role }]);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return terminals;
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
/** Cost of drawing `points` for `piece` among the other pieces' segments. */
|
|
487
|
+
const costOf = (
|
|
488
|
+
points: readonly Point[],
|
|
489
|
+
piece: { id: string; bus?: string; start: End; end: End },
|
|
490
|
+
startSide: Side,
|
|
491
|
+
endSide: Side,
|
|
492
|
+
others: readonly Segment[],
|
|
493
|
+
terminals: Terminals,
|
|
494
|
+
context: Context,
|
|
495
|
+
): number => {
|
|
496
|
+
const own = segmentsOf({ ...piece, points });
|
|
497
|
+
const pair = pairOf(piece);
|
|
498
|
+
let total = COST.bend * bendsOf(points) + COST.length * lengthOf(points);
|
|
499
|
+
for (const segment of own) {
|
|
500
|
+
for (const other of others) {
|
|
501
|
+
if (other.owner === piece.id || (piece.bus !== undefined && other.bus === piece.bus)) {
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
if (crosses(segment, other)) {
|
|
505
|
+
total += COST.crossing;
|
|
506
|
+
} else if ((pair === undefined || other.pair !== pair) && touches(segment, other)) {
|
|
507
|
+
// A route that ends on another, or continues it end to end, reads as joining it.
|
|
508
|
+
total += COST.crossing;
|
|
509
|
+
} else if (overlaps(segment, other) && (pair === undefined || other.pair !== pair) && !fanned(segment, other)) {
|
|
510
|
+
total += COST.overlap;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
for (const rect of context.avoid) {
|
|
514
|
+
if (hits(segment.from, segment.to, rect, 0)) {
|
|
515
|
+
total += COST.label;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
for (const [end, side, role] of [
|
|
520
|
+
[piece.start, startSide, 'exit'],
|
|
521
|
+
[piece.end, endSide, 'entry'],
|
|
522
|
+
] as const) {
|
|
523
|
+
if (end.preferred && !end.preferred.includes(side)) {
|
|
524
|
+
total += COST.unpreferred;
|
|
525
|
+
}
|
|
526
|
+
if (end.node === undefined) {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
for (const terminal of terminals.get(sideKey(end.node, side)) ?? []) {
|
|
530
|
+
if (piece.bus === undefined || terminal.owner.split('#')[0] !== piece.bus) {
|
|
531
|
+
total += terminal.role === role ? COST.sharedSide : COST.opposedSide;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return total;
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
type Best = { points: Point[]; startSide: Side; endSide: Side; cost: number };
|
|
539
|
+
|
|
540
|
+
/** The cheapest valid route for a piece over its allowed sides; undefined when no shape fits. */
|
|
541
|
+
export const bestRoute = (
|
|
542
|
+
piece: { id: string; bus?: string; start: End; end: End; via: readonly Waypoint[] },
|
|
543
|
+
others: readonly Segment[],
|
|
544
|
+
terminals: Terminals,
|
|
545
|
+
context: Context,
|
|
546
|
+
): Best | undefined => {
|
|
547
|
+
let best: Best | undefined;
|
|
548
|
+
for (const startSide of piece.start.sides) {
|
|
549
|
+
for (const endSide of piece.end.sides) {
|
|
550
|
+
const candidates =
|
|
551
|
+
piece.via.length > 0
|
|
552
|
+
? [throughWaypoints(piece, startSide, endSide)].flatMap((path) => (path ? [path] : []))
|
|
553
|
+
: templates(piece, startSide, endSide, context);
|
|
554
|
+
for (const points of candidates) {
|
|
555
|
+
if (!valid(points, piece, startSide, endSide, context)) {
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
const cost = costOf(points, piece, startSide, endSide, others, terminals, context);
|
|
559
|
+
if (!best || cost < best.cost) {
|
|
560
|
+
best = { points, startSide, endSide, cost };
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return best;
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
/** The A* router's path when no template fits; it picks its own faces. */
|
|
569
|
+
const fallback = (piece: Piece, context: Context): Best => {
|
|
570
|
+
const router = makeAvoidingRouter([...context.obstacles], zRouter, { step: CLEAR });
|
|
571
|
+
const { start, end } = piece;
|
|
572
|
+
const dx = Math.abs(end.rect.x - start.rect.x);
|
|
573
|
+
const dy = Math.abs(end.rect.y - start.rect.y);
|
|
574
|
+
const points = router({
|
|
575
|
+
relation: { from: piece.start.node ?? piece.id, to: piece.end.node ?? piece.id },
|
|
576
|
+
from: start.rect,
|
|
577
|
+
to: end.rect,
|
|
578
|
+
horizontal: dx > dy,
|
|
579
|
+
offset: 0,
|
|
580
|
+
});
|
|
581
|
+
const sideAt = (point: Point, rect: Rect): Side => Ports.sideOf(point, rect) ?? 'top';
|
|
582
|
+
return {
|
|
583
|
+
points,
|
|
584
|
+
startSide: sideAt(points[0], start.rect),
|
|
585
|
+
endSide: sideAt(points[points.length - 1], end.rect),
|
|
586
|
+
cost: Infinity,
|
|
587
|
+
};
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
/** A bus to route: a hub, its spokes, and the pieces that will be drawn once a junction is chosen. */
|
|
591
|
+
export type BusRequest = {
|
|
592
|
+
id: string;
|
|
593
|
+
hub: End;
|
|
594
|
+
spokes: { id: string; end: End }[];
|
|
595
|
+
direction: 'in' | 'out';
|
|
596
|
+
significance?: number;
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
type Item = { kind: 'piece'; piece: Piece } | { kind: 'bus'; bus: BusRequest; pieces: Piece[] };
|
|
600
|
+
|
|
601
|
+
const piecesOf = (items: readonly Item[]): Piece[] =>
|
|
602
|
+
items.flatMap((item) => (item.kind === 'piece' ? [item.piece] : item.pieces));
|
|
603
|
+
|
|
604
|
+
/** Junctions worth trying for a bus: channel crossings and channel-to-spoke alignments near the spokes. */
|
|
605
|
+
const junctions = (bus: BusRequest, context: Context): Point[] => {
|
|
606
|
+
const rects = [bus.hub.rect, ...bus.spokes.map((spoke) => spoke.end.rect)];
|
|
607
|
+
const x0 = Math.min(...rects.map((rect) => rect.x)) - GRID * 8;
|
|
608
|
+
const x1 = Math.max(...rects.map((rect) => rect.x + rect.w)) + GRID * 8;
|
|
609
|
+
const y0 = Math.min(...rects.map((rect) => rect.y)) - GRID * 8;
|
|
610
|
+
const y1 = Math.max(...rects.map((rect) => rect.y + rect.h)) + GRID * 8;
|
|
611
|
+
const xs = [
|
|
612
|
+
...context.channels.xs.filter((x) => x > x0 && x < x1),
|
|
613
|
+
...bus.spokes.map((spoke) => spoke.end.rect.x + spoke.end.rect.w / 2),
|
|
614
|
+
];
|
|
615
|
+
const ys = [
|
|
616
|
+
...context.channels.ys.filter((y) => y > y0 && y < y1),
|
|
617
|
+
...bus.spokes.map((spoke) => spoke.end.rect.y + spoke.end.rect.h / 2),
|
|
618
|
+
];
|
|
619
|
+
const points = xs.flatMap((x) => ys.map((y) => ({ x, y })));
|
|
620
|
+
// Inside a box is never a junction.
|
|
621
|
+
return points.filter((point) => !context.obstacles.some((rect) => hits(point, point, rect, CLEAR)));
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
/** Routes a bus through its best junction; undefined when no junction serves every spoke. */
|
|
625
|
+
const routeBus = (
|
|
626
|
+
bus: BusRequest,
|
|
627
|
+
others: readonly Segment[],
|
|
628
|
+
terminals: Terminals,
|
|
629
|
+
context: Context,
|
|
630
|
+
): { pieces: Piece[]; cost: number } | undefined => {
|
|
631
|
+
let best: { pieces: Piece[]; cost: number } | undefined;
|
|
632
|
+
for (const junction of junctions(bus, context)) {
|
|
633
|
+
const point: End = { rect: { x: junction.x, y: junction.y, w: 0, h: 0 }, sides: SIDES };
|
|
634
|
+
const shared = {
|
|
635
|
+
bus: bus.id,
|
|
636
|
+
via: [],
|
|
637
|
+
...(bus.significance === undefined ? {} : { significance: bus.significance }),
|
|
638
|
+
};
|
|
639
|
+
const trunkPiece =
|
|
640
|
+
bus.direction === 'out'
|
|
641
|
+
? { id: `${bus.id}#trunk`, ...shared, start: bus.hub, end: point }
|
|
642
|
+
: { id: `${bus.id}#trunk`, ...shared, start: point, end: bus.hub };
|
|
643
|
+
const trunk = bestRoute(trunkPiece, others, terminals, context);
|
|
644
|
+
if (!trunk || (best && trunk.cost >= best.cost)) {
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
const pieces: Piece[] = [
|
|
648
|
+
{ ...trunkPiece, points: trunk.points, startSide: trunk.startSide, endSide: trunk.endSide },
|
|
649
|
+
];
|
|
650
|
+
let cost = trunk.cost;
|
|
651
|
+
for (const spoke of bus.spokes) {
|
|
652
|
+
const spokePiece =
|
|
653
|
+
bus.direction === 'out'
|
|
654
|
+
? { id: `${bus.id}#${spoke.id}`, ...shared, start: point, end: spoke.end }
|
|
655
|
+
: { id: `${bus.id}#${spoke.id}`, ...shared, start: spoke.end, end: point };
|
|
656
|
+
// The trunk may continue into a spoke but must not run back over it.
|
|
657
|
+
const trunkSegments = segmentsOf({ id: `${bus.id}#trunk`, bus: bus.id, points: trunk.points });
|
|
658
|
+
const route = bestRoute(spokePiece, [...others, ...trunkSegments], terminals, context);
|
|
659
|
+
if (!route) {
|
|
660
|
+
cost = Infinity;
|
|
661
|
+
break;
|
|
662
|
+
}
|
|
663
|
+
const backtracks = segmentsOf({ id: spokePiece.id, points: route.points }).some((segment) =>
|
|
664
|
+
trunkSegments.some((other) => overlaps(segment, other)),
|
|
665
|
+
);
|
|
666
|
+
cost += route.cost + (backtracks ? COST.overlap * 4 : 0);
|
|
667
|
+
pieces.push({ ...spokePiece, points: route.points, startSide: route.startSide, endSide: route.endSide });
|
|
668
|
+
if (best && cost >= best.cost) {
|
|
669
|
+
break;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// Gathering spokes that meet head-on along one line, with no arrowhead at either, read as an edge between their boxes.
|
|
673
|
+
const leads = pieces.slice(1).map((piece) => {
|
|
674
|
+
const points = bus.direction === 'out' ? [...piece.points].reverse() : piece.points;
|
|
675
|
+
return points.length >= 2 ? { from: points[0], to: points[1] } : undefined;
|
|
676
|
+
});
|
|
677
|
+
leads.forEach((lead, index) =>
|
|
678
|
+
leads.slice(index + 1).forEach((other) => {
|
|
679
|
+
if (!lead || !other || bus.direction === 'out') {
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
const horizontal = Math.abs(lead.from.y - lead.to.y) < 0.5 && Math.abs(other.from.y - other.to.y) < 0.5;
|
|
683
|
+
const vertical = Math.abs(lead.from.x - lead.to.x) < 0.5 && Math.abs(other.from.x - other.to.x) < 0.5;
|
|
684
|
+
const opposed = horizontal
|
|
685
|
+
? Math.abs(lead.from.y - other.from.y) < 0.5 &&
|
|
686
|
+
Math.sign(lead.to.x - lead.from.x) === -Math.sign(other.to.x - other.from.x)
|
|
687
|
+
: vertical &&
|
|
688
|
+
Math.abs(lead.from.x - other.from.x) < 0.5 &&
|
|
689
|
+
Math.sign(lead.to.y - lead.from.y) === -Math.sign(other.to.y - other.from.y);
|
|
690
|
+
if (opposed) {
|
|
691
|
+
cost += COST.crossing;
|
|
692
|
+
}
|
|
693
|
+
}),
|
|
694
|
+
);
|
|
695
|
+
if (cost < (best?.cost ?? Infinity)) {
|
|
696
|
+
best = { pieces, cost };
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return best;
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
/** How many times every connector is ripped up and re-routed against all the others. */
|
|
703
|
+
const ROUNDS = 3;
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Routes every piece and bus. Pieces with fewer possible bends go first, so straight neighbours
|
|
707
|
+
* claim their direct runs before a long edge looks for a channel.
|
|
708
|
+
*/
|
|
709
|
+
export const routeAll = (pieces: readonly Piece[], buses: readonly BusRequest[], context: Context): Piece[] => {
|
|
710
|
+
const items: Item[] = [
|
|
711
|
+
...pieces.map((piece): Item => ({ kind: 'piece', piece: { ...piece, points: [] } })),
|
|
712
|
+
...buses.map((bus): Item => ({ kind: 'bus', bus, pieces: [] })),
|
|
713
|
+
];
|
|
714
|
+
const distance = (item: Item) =>
|
|
715
|
+
item.kind === 'piece'
|
|
716
|
+
? Math.abs(item.piece.start.rect.x - item.piece.end.rect.x) +
|
|
717
|
+
Math.abs(item.piece.start.rect.y - item.piece.end.rect.y)
|
|
718
|
+
: Infinity;
|
|
719
|
+
const order = [...items].sort((left, right) => distance(left) - distance(right));
|
|
720
|
+
|
|
721
|
+
const others = (except: Item) =>
|
|
722
|
+
piecesOf(items.filter((item) => item !== except)).flatMap((piece) => segmentsOf(piece));
|
|
723
|
+
const costNow = (item: Item): number => {
|
|
724
|
+
const segments = others(item);
|
|
725
|
+
if (item.kind === 'piece') {
|
|
726
|
+
const { piece } = item;
|
|
727
|
+
return piece.points.length < 2 || !piece.startSide || !piece.endSide
|
|
728
|
+
? Infinity
|
|
729
|
+
: costOf(
|
|
730
|
+
piece.points,
|
|
731
|
+
piece,
|
|
732
|
+
piece.startSide,
|
|
733
|
+
piece.endSide,
|
|
734
|
+
segments,
|
|
735
|
+
terminalsOf(piecesOf(items), piece.id),
|
|
736
|
+
context,
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
return item.pieces.length === 0
|
|
740
|
+
? Infinity
|
|
741
|
+
: (routeBus(item.bus, segments, terminalsOf(piecesOf(items.filter((other) => other !== item))), context)?.cost ??
|
|
742
|
+
Infinity);
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
for (let round = 0; round < ROUNDS; round++) {
|
|
746
|
+
let changed = false;
|
|
747
|
+
for (const item of order) {
|
|
748
|
+
const segments = others(item);
|
|
749
|
+
if (item.kind === 'piece') {
|
|
750
|
+
const terminals = terminalsOf(piecesOf(items), item.piece.id);
|
|
751
|
+
const route = bestRoute(item.piece, segments, terminals, context);
|
|
752
|
+
const current = round === 0 ? Infinity : costNow(item);
|
|
753
|
+
if (route && route.cost < current - 1e-6) {
|
|
754
|
+
item.piece = { ...item.piece, points: route.points, startSide: route.startSide, endSide: route.endSide };
|
|
755
|
+
changed = true;
|
|
756
|
+
} else if (!route && item.piece.points.length < 2) {
|
|
757
|
+
const forced = fallback(item.piece, context);
|
|
758
|
+
item.piece = {
|
|
759
|
+
...item.piece,
|
|
760
|
+
points: forced.points,
|
|
761
|
+
startSide: forced.startSide,
|
|
762
|
+
endSide: forced.endSide,
|
|
763
|
+
forced: true,
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
} else {
|
|
767
|
+
const terminals = terminalsOf(piecesOf(items.filter((other) => other !== item)));
|
|
768
|
+
const routed = routeBus(item.bus, segments, terminals, context);
|
|
769
|
+
const current = round === 0 ? Infinity : costNow(item);
|
|
770
|
+
if (routed && routed.cost < current - 1e-6) {
|
|
771
|
+
item.pieces = routed.pieces;
|
|
772
|
+
changed = true;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
if (!changed) {
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
// A bus no junction serves is drawn as its separate edges, under the edges' own ids.
|
|
781
|
+
for (const item of items) {
|
|
782
|
+
if (item.kind !== 'bus' || item.pieces.length > 0) {
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
const { bus } = item;
|
|
786
|
+
// Spokes route one after another against those already drawn, so the separate edges avoid each other.
|
|
787
|
+
const drawnPieces: Piece[] = [];
|
|
788
|
+
for (const spoke of bus.spokes) {
|
|
789
|
+
const [start, end] = bus.direction === 'out' ? [bus.hub, spoke.end] : [spoke.end, bus.hub];
|
|
790
|
+
const piece: Piece = {
|
|
791
|
+
id: spoke.id,
|
|
792
|
+
start,
|
|
793
|
+
end,
|
|
794
|
+
via: [],
|
|
795
|
+
points: [],
|
|
796
|
+
...(bus.significance === undefined ? {} : { significance: bus.significance }),
|
|
797
|
+
};
|
|
798
|
+
const segments = [...others(item), ...drawnPieces.flatMap((drawnPiece) => segmentsOf(drawnPiece))];
|
|
799
|
+
const route = bestRoute(piece, segments, terminalsOf([...piecesOf(items), ...drawnPieces]), context);
|
|
800
|
+
const drawn = route ?? fallback(piece, context);
|
|
801
|
+
drawnPieces.push({
|
|
802
|
+
...piece,
|
|
803
|
+
points: drawn.points,
|
|
804
|
+
startSide: drawn.startSide,
|
|
805
|
+
endSide: drawn.endSide,
|
|
806
|
+
...(route ? {} : { forced: true }),
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
item.pieces = drawnPieces;
|
|
810
|
+
}
|
|
811
|
+
return piecesOf(items);
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
//
|
|
815
|
+
// Ports.
|
|
816
|
+
//
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Spreads the ports on every side that several pieces share, in the order their routes leave it,
|
|
820
|
+
* keeping straight pieces straight; returns the pieces with fixed sides and ports to re-route.
|
|
821
|
+
*/
|
|
822
|
+
export const spreadPorts = (pieces: readonly Piece[]): Piece[] => {
|
|
823
|
+
type Terminal = {
|
|
824
|
+
piece: number;
|
|
825
|
+
end: 'start' | 'end';
|
|
826
|
+
key: number;
|
|
827
|
+
coord: number;
|
|
828
|
+
far: number;
|
|
829
|
+
significance: number;
|
|
830
|
+
};
|
|
831
|
+
const sides = new Map<string, { rect: Rect; side: Side; terminals: Terminal[] }>();
|
|
832
|
+
pieces.forEach((piece, position) => {
|
|
833
|
+
for (const [end, which, side, outward] of [
|
|
834
|
+
[piece.start, 'start', piece.startSide, piece.points],
|
|
835
|
+
[piece.end, 'end', piece.endSide, [...piece.points].reverse()],
|
|
836
|
+
] as const) {
|
|
837
|
+
if (end.node === undefined || !side || outward.length < 2) {
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
const axis = horizontalSide(side) ? 'y' : 'x';
|
|
841
|
+
const key = sideKey(end.node, side);
|
|
842
|
+
const entry = sides.get(key) ?? { rect: end.rect, side, terminals: [] };
|
|
843
|
+
entry.terminals.push({
|
|
844
|
+
piece: position,
|
|
845
|
+
end: which,
|
|
846
|
+
key: Ports.keyOf(outward, side),
|
|
847
|
+
coord: outward[0][axis],
|
|
848
|
+
far: outward[outward.length - 1][axis],
|
|
849
|
+
significance: piece.significance ?? 0,
|
|
850
|
+
});
|
|
851
|
+
sides.set(key, entry);
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
const ports = pieces.map((): { start?: number; end?: number } => ({}));
|
|
855
|
+
for (const { rect, side, terminals } of sides.values()) {
|
|
856
|
+
if (terminals.length < 2) {
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
// Bus pieces leaving one junction side share it; one slot per bus.
|
|
860
|
+
const sorted = [...terminals].sort(
|
|
861
|
+
(left, right) => left.key - right.key || left.far - right.far || left.piece - right.piece,
|
|
862
|
+
);
|
|
863
|
+
const [low, high] = rangeOf(rect, side);
|
|
864
|
+
const span = high - low;
|
|
865
|
+
// The most significant terminals straddle the centre; the key order stays, so no stubs cross.
|
|
866
|
+
const top = Math.max(...sorted.map((terminal) => terminal.significance));
|
|
867
|
+
const firstTop = sorted.findIndex((terminal) => terminal.significance === top);
|
|
868
|
+
const lastTop = sorted.findLastIndex((terminal) => terminal.significance === top);
|
|
869
|
+
const anchor = (firstTop + lastTop) / 2;
|
|
870
|
+
const reach = Math.max(anchor, sorted.length - 1 - anchor);
|
|
871
|
+
// Wide enough apart for a label between two parallel runs, within the side.
|
|
872
|
+
const spacing = Math.max(FINE, Math.min(GRID * 1.5, snap(span / 2 / reach, FINE / 2)));
|
|
873
|
+
const centre = (low + high) / 2;
|
|
874
|
+
sorted.forEach((terminal, index) => {
|
|
875
|
+
const coord = Math.min(high, Math.max(low, snap(centre + (index - anchor) * spacing)));
|
|
876
|
+
ports[terminal.piece][terminal.end] = coord;
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
// A straight piece keeps one shared coordinate for both ends, inside both sides.
|
|
880
|
+
pieces.forEach((piece, position) => {
|
|
881
|
+
if (piece.points.length !== 2 || !piece.startSide || !piece.endSide) {
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
const assigned = ports[position];
|
|
885
|
+
if (assigned.start === undefined && assigned.end === undefined) {
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
const axis = horizontalSide(piece.startSide) ? 'y' : 'x';
|
|
889
|
+
const [startLow, startHigh] = rangeOf(piece.start.rect, piece.startSide);
|
|
890
|
+
const [endLow, endHigh] = rangeOf(piece.end.rect, piece.endSide);
|
|
891
|
+
const low = Math.max(startLow, endLow);
|
|
892
|
+
const high = Math.min(startHigh, endHigh);
|
|
893
|
+
const wanted = [assigned.start, assigned.end, piece.points[0][axis]].flatMap((value) =>
|
|
894
|
+
value === undefined ? [] : [value],
|
|
895
|
+
);
|
|
896
|
+
const coord = wanted.find((value) => value >= low && value <= high) ?? piece.points[0][axis];
|
|
897
|
+
ports[position] = { start: coord, end: coord };
|
|
898
|
+
});
|
|
899
|
+
return pieces.map((piece, position) => ({
|
|
900
|
+
...piece,
|
|
901
|
+
start: {
|
|
902
|
+
...piece.start,
|
|
903
|
+
sides: piece.startSide ? [piece.startSide] : piece.start.sides,
|
|
904
|
+
port:
|
|
905
|
+
ports[position].start ??
|
|
906
|
+
(piece.startSide && piece.start.node !== undefined
|
|
907
|
+
? piece.points[0][horizontalSide(piece.startSide) ? 'y' : 'x']
|
|
908
|
+
: piece.start.port),
|
|
909
|
+
},
|
|
910
|
+
end: {
|
|
911
|
+
...piece.end,
|
|
912
|
+
sides: piece.endSide ? [piece.endSide] : piece.end.sides,
|
|
913
|
+
port:
|
|
914
|
+
ports[position].end ??
|
|
915
|
+
(piece.endSide && piece.end.node !== undefined
|
|
916
|
+
? piece.points[piece.points.length - 1][horizontalSide(piece.endSide) ? 'y' : 'x']
|
|
917
|
+
: piece.end.port),
|
|
918
|
+
},
|
|
919
|
+
}));
|
|
920
|
+
};
|
|
921
|
+
|
|
922
|
+
/** Re-routes pieces whose sides and ports are now fixed; a piece no shape can draw keeps its old route. */
|
|
923
|
+
export const reroute = (fixed: readonly Piece[], previous: readonly Piece[], context: Context): Piece[] => {
|
|
924
|
+
const result: Piece[] = [];
|
|
925
|
+
fixed.forEach((piece, position) => {
|
|
926
|
+
const others = [...result, ...previous.slice(position + 1)].flatMap((other) => segmentsOf(other));
|
|
927
|
+
const route = bestRoute(piece, others, new Map(), context);
|
|
928
|
+
result.push(
|
|
929
|
+
route
|
|
930
|
+
? { ...piece, points: route.points, startSide: route.startSide, endSide: route.endSide }
|
|
931
|
+
: previous[position],
|
|
932
|
+
);
|
|
933
|
+
});
|
|
934
|
+
return result;
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
/** Separates parallel runs; bus pieces stay put, since they meet on purpose. */
|
|
938
|
+
export const separate = (pieces: readonly Piece[], context: Context): Piece[] => {
|
|
939
|
+
const free = pieces.filter((piece) => piece.bus === undefined);
|
|
940
|
+
const nudged = Nudge.nudge(
|
|
941
|
+
free.map((piece) => ({
|
|
942
|
+
points: piece.points,
|
|
943
|
+
...(isPoint(piece.start.rect) ? {} : { source: piece.start.rect }),
|
|
944
|
+
...(isPoint(piece.end.rect) ? {} : { target: piece.end.rect }),
|
|
945
|
+
})),
|
|
946
|
+
{
|
|
947
|
+
spacing: CLEAR,
|
|
948
|
+
obstacles: context.obstacles,
|
|
949
|
+
fixed: pieces.filter((piece) => piece.bus !== undefined).map((piece) => piece.points),
|
|
950
|
+
},
|
|
951
|
+
);
|
|
952
|
+
const byPiece = new Map(free.map((piece, position) => [piece, nudged[position]]));
|
|
953
|
+
return pieces.map((piece) => {
|
|
954
|
+
const points = byPiece.get(piece);
|
|
955
|
+
return points ? { ...piece, points } : piece;
|
|
956
|
+
});
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
//
|
|
960
|
+
// Labels.
|
|
961
|
+
//
|
|
962
|
+
|
|
963
|
+
export type LabelRequest = {
|
|
964
|
+
id: string;
|
|
965
|
+
text: string;
|
|
966
|
+
points: readonly Point[];
|
|
967
|
+
/** A trunk the route shares with others, where its label goes only when its own runs have no room. */
|
|
968
|
+
shared?: readonly Point[];
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
export type LabelOptions = {
|
|
972
|
+
/** Every drawn route, the label's own among them. */
|
|
973
|
+
paths: readonly (readonly Point[])[];
|
|
974
|
+
boxes: readonly Rect[];
|
|
975
|
+
/** Text already on the drawing (frame titles). */
|
|
976
|
+
avoid: readonly Rect[];
|
|
977
|
+
/** Group frames, whose dashed borders a label must not sit on. */
|
|
978
|
+
frames?: readonly Rect[];
|
|
979
|
+
bounds: Rect;
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
/** The SVG renderer draws `s` text at about this share of the tldraw metric, so a label is anchored where its text starts. */
|
|
983
|
+
const VISIBLE = 0.58;
|
|
984
|
+
|
|
985
|
+
const rectsOverlap = (left: Rect, right: Rect) =>
|
|
986
|
+
left.x < right.x + right.w && right.x < left.x + left.w && left.y < right.y + right.h && right.y < left.y + left.h;
|
|
987
|
+
|
|
988
|
+
const inflate = (rect: Rect, by: number): Rect => ({
|
|
989
|
+
x: rect.x - by,
|
|
990
|
+
y: rect.y - by,
|
|
991
|
+
w: rect.w + by * 2,
|
|
992
|
+
h: rect.h + by * 2,
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
const within = (outer: Rect, inner: Rect) =>
|
|
996
|
+
inner.x >= outer.x &&
|
|
997
|
+
inner.y >= outer.y &&
|
|
998
|
+
inner.x + inner.w <= outer.x + outer.w &&
|
|
999
|
+
inner.y + inner.h <= outer.y + outer.h;
|
|
1000
|
+
|
|
1001
|
+
/** Gap between a rect and an axis-aligned segment; zero when they touch. */
|
|
1002
|
+
const gapTo = (rect: Rect, [from, to]: readonly [Point, Point]) => {
|
|
1003
|
+
const dx = Math.max(Math.min(from.x, to.x) - (rect.x + rect.w), rect.x - Math.max(from.x, to.x), 0);
|
|
1004
|
+
const dy = Math.max(Math.min(from.y, to.y) - (rect.y + rect.h), rect.y - Math.max(from.y, to.y), 0);
|
|
1005
|
+
return Math.hypot(dx, dy);
|
|
1006
|
+
};
|
|
1007
|
+
|
|
1008
|
+
const keyOf = ([from, to]: readonly [Point, Point]) => `${from.x},${from.y},${to.x},${to.y}`;
|
|
1009
|
+
|
|
1010
|
+
/** Whether a rect straddles a frame's border, rather than lying wholly inside or outside it. */
|
|
1011
|
+
export const onBorder = (rect: Rect, frame: Rect, clearance = CLEAR): boolean => {
|
|
1012
|
+
const grown = inflate(rect, clearance);
|
|
1013
|
+
return rectsOverlap(grown, frame) && !within(frame, grown);
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
/** Clutter outweighs every preference, so a clear spot anywhere on the route beats a cluttered one at its middle. */
|
|
1017
|
+
const CLUTTER = 10;
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Each label beside a run of its own route, nearer that route than any other, and clear of boxes,
|
|
1021
|
+
* other labels, other routes, frame borders and frame titles: the longest run first, its middle
|
|
1022
|
+
* first, above a horizontal run or right of a vertical one (where the text starts at the line).
|
|
1023
|
+
* When nothing is clear, the least-cluttered spot wins.
|
|
1024
|
+
*/
|
|
1025
|
+
export const placeLabels = (labels: readonly LabelRequest[], options: LabelOptions): Scene.Text[] => {
|
|
1026
|
+
const { paths, boxes, avoid, frames = [] } = options;
|
|
1027
|
+
const segments = paths.flatMap((points) =>
|
|
1028
|
+
points.slice(0, -1).map((from, index): [Point, Point] => [from, points[index + 1]]),
|
|
1029
|
+
);
|
|
1030
|
+
// Routes may leave the frames to go around them, and a label beside such a run is still inside the drawing.
|
|
1031
|
+
const xs = [
|
|
1032
|
+
options.bounds.x,
|
|
1033
|
+
options.bounds.x + options.bounds.w,
|
|
1034
|
+
...paths.flatMap((points) => points.map((point) => point.x)),
|
|
1035
|
+
];
|
|
1036
|
+
const ys = [
|
|
1037
|
+
options.bounds.y,
|
|
1038
|
+
options.bounds.y + options.bounds.h,
|
|
1039
|
+
...paths.flatMap((points) => points.map((point) => point.y)),
|
|
1040
|
+
];
|
|
1041
|
+
const margin = LABEL_FONT.lineH + CLEAR * 2;
|
|
1042
|
+
const bounds = {
|
|
1043
|
+
x: Math.min(...xs) - margin,
|
|
1044
|
+
y: Math.min(...ys) - margin,
|
|
1045
|
+
w: Math.max(...xs) - Math.min(...xs) + margin * 2,
|
|
1046
|
+
h: Math.max(...ys) - Math.min(...ys) + margin * 2,
|
|
1047
|
+
};
|
|
1048
|
+
const placed: Rect[] = [...avoid];
|
|
1049
|
+
const runsOf = (points: readonly Point[]) =>
|
|
1050
|
+
points.slice(0, -1).map((from, index): [Point, Point] => [from, points[index + 1]]);
|
|
1051
|
+
return labels.map(({ id, text, points, shared = [] }) => {
|
|
1052
|
+
const size = { w: text.length * LABEL_FONT.charW, h: LABEL_FONT.lineH };
|
|
1053
|
+
const visible = size.w * VISIBLE;
|
|
1054
|
+
const own = [...runsOf(points), ...runsOf(shared)];
|
|
1055
|
+
const mine = new Set(own.map(keyOf));
|
|
1056
|
+
const foreign = segments.filter((segment) => !mine.has(keyOf(segment)));
|
|
1057
|
+
const byLength = (runs: [Point, Point][], penalty: number) =>
|
|
1058
|
+
runs
|
|
1059
|
+
.map((segment) => ({
|
|
1060
|
+
segment,
|
|
1061
|
+
penalty,
|
|
1062
|
+
length: Math.abs(segment[1].x - segment[0].x) + Math.abs(segment[1].y - segment[0].y),
|
|
1063
|
+
}))
|
|
1064
|
+
.sort((left, right) => right.length - left.length);
|
|
1065
|
+
const runs = [...byLength(runsOf(points), 0), ...byLength(runsOf(shared), 2)];
|
|
1066
|
+
type Candidate = { origin: Point; preference: number };
|
|
1067
|
+
const candidates = runs.flatMap(({ segment: [from, to], length, penalty }, rank): Candidate[] => {
|
|
1068
|
+
const vertical = Math.abs(from.x - to.x) < 0.5;
|
|
1069
|
+
// Stops along the run, middle first, kept far enough from its ends that the text stays beside it.
|
|
1070
|
+
const reach = Math.max(0, length / 2 - (vertical ? size.h : visible / 2));
|
|
1071
|
+
const stops = [
|
|
1072
|
+
0,
|
|
1073
|
+
...Array.from({ length: Math.floor(reach / FINE) }, (_, index) => (index + 1) * FINE).flatMap((step) => [
|
|
1074
|
+
step,
|
|
1075
|
+
-step,
|
|
1076
|
+
]),
|
|
1077
|
+
];
|
|
1078
|
+
const mid = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 };
|
|
1079
|
+
return stops.flatMap((offset) => {
|
|
1080
|
+
const at = vertical ? { x: mid.x, y: mid.y + offset } : { x: mid.x + offset, y: mid.y };
|
|
1081
|
+
const along = Math.abs(offset) / Math.max(length, 1);
|
|
1082
|
+
return [0, CLEAR, CLEAR * 2, CLEAR * 4, CLEAR * 6, CLEAR * 8].flatMap((shift) => {
|
|
1083
|
+
const base = penalty + rank * 0.6 + along + shift / CLEAR / 4;
|
|
1084
|
+
return vertical
|
|
1085
|
+
? [
|
|
1086
|
+
{ origin: { x: at.x + CLEAR + shift, y: at.y - size.h / 2 }, preference: base },
|
|
1087
|
+
{ origin: { x: at.x - CLEAR - visible - shift, y: at.y - size.h / 2 }, preference: base + 0.3 },
|
|
1088
|
+
]
|
|
1089
|
+
: [visible, size.w].flatMap((centred, measure) => [
|
|
1090
|
+
// Centred on the drawn text first, then on its full measure where that is what fits.
|
|
1091
|
+
{
|
|
1092
|
+
origin: { x: at.x - centred / 2, y: at.y - size.h - CLEAR / 2 - shift },
|
|
1093
|
+
preference: base + measure * 0.3,
|
|
1094
|
+
},
|
|
1095
|
+
{
|
|
1096
|
+
origin: { x: at.x - centred / 2, y: at.y + CLEAR / 2 + shift },
|
|
1097
|
+
preference: base + 0.2 + measure * 0.3,
|
|
1098
|
+
},
|
|
1099
|
+
]);
|
|
1100
|
+
});
|
|
1101
|
+
});
|
|
1102
|
+
});
|
|
1103
|
+
// `extent` is the label as the shared metrics measure it, which overlap diagnostics use; `ink` is the drawn text.
|
|
1104
|
+
const clutter = (origin: Point) => {
|
|
1105
|
+
const extent = { ...origin, ...size };
|
|
1106
|
+
const ink = { ...origin, w: visible, h: size.h };
|
|
1107
|
+
const nearest = Math.min(...own.map((segment) => gapTo(ink, segment)));
|
|
1108
|
+
return (
|
|
1109
|
+
(within(bounds, extent) ? 0 : 4) +
|
|
1110
|
+
// Text over a box or another label hides what the reader needs, so it outweighs everything else.
|
|
1111
|
+
boxes.filter((box) => rectsOverlap(extent, box)).length * 8 +
|
|
1112
|
+
boxes.filter((box) => rectsOverlap(ink, inflate(box, 4))).length +
|
|
1113
|
+
placed.filter((other) => rectsOverlap(extent, other)).length * 8 +
|
|
1114
|
+
frames.filter((frame) => onBorder(ink, frame, CLEAR / 2)).length * 3 +
|
|
1115
|
+
segments.filter(([from, to]) => hits(from, to, ink, 0)).length * 2 +
|
|
1116
|
+
// A foreign run hugging the label, or nearer to it than its own, makes it read as that run's.
|
|
1117
|
+
foreign.filter((segment) => gapTo(ink, segment) < CLEAR * 2).length +
|
|
1118
|
+
foreign.filter((segment) => gapTo(ink, segment) < nearest - 0.5).length * 2
|
|
1119
|
+
);
|
|
1120
|
+
};
|
|
1121
|
+
let best = candidates[0]?.origin ?? { x: points[0].x, y: points[0].y };
|
|
1122
|
+
let bestScore = Infinity;
|
|
1123
|
+
for (const { origin, preference } of candidates) {
|
|
1124
|
+
if (preference >= bestScore) {
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1127
|
+
const score = clutter(origin) * CLUTTER + preference;
|
|
1128
|
+
if (score < bestScore) {
|
|
1129
|
+
best = origin;
|
|
1130
|
+
bestScore = score;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
placed.push({ ...best, ...size });
|
|
1134
|
+
return { kind: 'text', id: `${id}-label`, x: Math.round(best.x), y: Math.round(best.y), text, weight: 's' };
|
|
1135
|
+
});
|
|
1136
|
+
};
|