@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,1508 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
//
|
|
6
|
+
// Grid placement for the semantic diagram: every node takes one cell of a regular lattice, and a
|
|
7
|
+
// scored search fills whatever the author left open. Groups are laid out first, each as a compact
|
|
8
|
+
// unit, then the units are seated around one another, and an annealing pass with a greedy polish
|
|
9
|
+
// refines the whole. The score is a cheap model of what the router will make of the placement —
|
|
10
|
+
// straight edges between neighbours, no crossings, no edge through a box, hubs with one neighbour
|
|
11
|
+
// per side, chains kept straight, flow in the stated direction — so thousands of states can be
|
|
12
|
+
// tried before the few best are routed for real (`semantic-engine.ts`).
|
|
13
|
+
//
|
|
14
|
+
|
|
15
|
+
import type * as Semantic from './semantic.ts';
|
|
16
|
+
|
|
17
|
+
export type Cell = { col: number; row: number };
|
|
18
|
+
|
|
19
|
+
export type PlaceNode = { id: string; group?: string; pin?: Cell };
|
|
20
|
+
|
|
21
|
+
export type PlaceRelation = {
|
|
22
|
+
from: string;
|
|
23
|
+
kind: Semantic.RelationKind;
|
|
24
|
+
target: string;
|
|
25
|
+
soft: boolean;
|
|
26
|
+
/** Cost of breaking a soft relation, in place of the default. */
|
|
27
|
+
weight?: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type PlaceEdge = {
|
|
31
|
+
from: string;
|
|
32
|
+
to: string;
|
|
33
|
+
sides?: { start?: readonly Semantic.Side[]; end?: readonly Semantic.Side[] };
|
|
34
|
+
/** The target is an abstraction that reads best against the flow, above its subtypes. */
|
|
35
|
+
upward?: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** How a group may be shaped: at most `maxWidth` columns, and near-square when `compact`. */
|
|
39
|
+
export type GroupShape = { maxWidth?: number; compact?: boolean };
|
|
40
|
+
|
|
41
|
+
export type PlaceInput = {
|
|
42
|
+
nodes: readonly PlaceNode[];
|
|
43
|
+
/** Group ids in declaration order. */
|
|
44
|
+
groups: readonly string[];
|
|
45
|
+
/** Shape limits by group id. */
|
|
46
|
+
shapes?: ReadonlyMap<string, GroupShape>;
|
|
47
|
+
/** Preferred columns over rows of the whole placement. */
|
|
48
|
+
aspect?: number;
|
|
49
|
+
/** Node relations; `from` and `target` are node ids. */
|
|
50
|
+
relations: readonly PlaceRelation[];
|
|
51
|
+
/** Group relations; `from` and `target` are group ids. */
|
|
52
|
+
groupRelations: readonly PlaceRelation[];
|
|
53
|
+
edges: readonly PlaceEdge[];
|
|
54
|
+
flow: Semantic.Flow;
|
|
55
|
+
/** Box half-extent as a fraction of the cell pitch, for the edge-through-box test. */
|
|
56
|
+
box?: { x: number; y: number };
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type PlaceOptions = {
|
|
60
|
+
/** Independent searches from different random seeds (default 6). */
|
|
61
|
+
restarts?: number;
|
|
62
|
+
/** Starting placements to refine besides the greedy ones (e.g. a layered engine's). */
|
|
63
|
+
seeds?: readonly Map<string, Cell>[];
|
|
64
|
+
/** How many distinct placements to return, best first (default 3). */
|
|
65
|
+
keep?: number;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type Placed = { cells: Map<string, Cell>; cost: number };
|
|
69
|
+
|
|
70
|
+
/** Weights, in units where one bend costs 10. */
|
|
71
|
+
const WEIGHT = {
|
|
72
|
+
bend: 10,
|
|
73
|
+
length: 1,
|
|
74
|
+
backward: 6,
|
|
75
|
+
sideways: 1,
|
|
76
|
+
crossing: 30,
|
|
77
|
+
through: 25,
|
|
78
|
+
hub: 4,
|
|
79
|
+
chain: 3,
|
|
80
|
+
hard: 10_000,
|
|
81
|
+
soft: 15,
|
|
82
|
+
near: 2,
|
|
83
|
+
/** Per cell a compact group's width and height differ by beyond one. */
|
|
84
|
+
square: 8,
|
|
85
|
+
/** Per unit of log ratio the placement strays from the preferred aspect. */
|
|
86
|
+
aspect: 12,
|
|
87
|
+
extent: 1.5,
|
|
88
|
+
slack: 3,
|
|
89
|
+
infeasible: 100_000,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
type Index = {
|
|
93
|
+
count: number;
|
|
94
|
+
ids: string[];
|
|
95
|
+
group: Int32Array;
|
|
96
|
+
pinned: Uint8Array;
|
|
97
|
+
pinCol: Int32Array;
|
|
98
|
+
pinRow: Int32Array;
|
|
99
|
+
members: number[][];
|
|
100
|
+
edges: {
|
|
101
|
+
from: number;
|
|
102
|
+
to: number;
|
|
103
|
+
upward?: boolean;
|
|
104
|
+
/**
|
|
105
|
+
* The best template per displacement, which depends only on the signs of dx and dy: its bends
|
|
106
|
+
* and the indices into `ALL_SIDES` of its sides, indexed by `(sign dx + 1) * 3 + sign dy + 1`.
|
|
107
|
+
*/
|
|
108
|
+
bends: Float64Array;
|
|
109
|
+
starts: Uint8Array;
|
|
110
|
+
ends: Uint8Array;
|
|
111
|
+
}[];
|
|
112
|
+
relations: { from: number; kind: Semantic.RelationKind; target: number; soft: boolean; weight?: number }[];
|
|
113
|
+
groupRelations: { from: number; kind: Semantic.RelationKind; target: number; soft: boolean }[];
|
|
114
|
+
/** Neighbour lists without duplicates, for the hub and chain terms. */
|
|
115
|
+
neighbours: number[][];
|
|
116
|
+
/**
|
|
117
|
+
* Per node, itself and every node placed relative to it by a hard relation, transitively: `X
|
|
118
|
+
* below Y` makes X follow Y, so moving Y with its followers keeps their relations.
|
|
119
|
+
*/
|
|
120
|
+
followers: number[][];
|
|
121
|
+
inDegree: Int32Array;
|
|
122
|
+
outDegree: Int32Array;
|
|
123
|
+
flow: Semantic.Flow;
|
|
124
|
+
box: { x: number; y: number };
|
|
125
|
+
/** Shape per group index. */
|
|
126
|
+
shapes: (GroupShape | undefined)[];
|
|
127
|
+
/** Shape of the whole placement, for a group laid out on its own. */
|
|
128
|
+
whole?: GroupShape;
|
|
129
|
+
aspect?: number;
|
|
130
|
+
/** Every node index, for the bounds of the whole placement. */
|
|
131
|
+
all: number[];
|
|
132
|
+
scratch: Scratch;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const indexOf = (input: PlaceInput): Index => {
|
|
136
|
+
const ids = input.nodes.map((node) => node.id);
|
|
137
|
+
const byId = new Map(ids.map((id, index) => [id, index]));
|
|
138
|
+
const groupIndex = new Map(input.groups.map((id, index) => [id, index]));
|
|
139
|
+
const count = ids.length;
|
|
140
|
+
const group = new Int32Array(count).fill(-1);
|
|
141
|
+
const pinned = new Uint8Array(count);
|
|
142
|
+
const pinCol = new Int32Array(count);
|
|
143
|
+
const pinRow = new Int32Array(count);
|
|
144
|
+
const members: number[][] = input.groups.map(() => []);
|
|
145
|
+
input.nodes.forEach((node, index) => {
|
|
146
|
+
const at = node.group === undefined ? undefined : groupIndex.get(node.group);
|
|
147
|
+
if (at !== undefined) {
|
|
148
|
+
group[index] = at;
|
|
149
|
+
members[at].push(index);
|
|
150
|
+
}
|
|
151
|
+
if (node.pin) {
|
|
152
|
+
pinned[index] = 1;
|
|
153
|
+
pinCol[index] = node.pin.col;
|
|
154
|
+
pinRow[index] = node.pin.row;
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
const edges = input.edges.flatMap((edge) => {
|
|
158
|
+
const from = byId.get(edge.from);
|
|
159
|
+
const to = byId.get(edge.to);
|
|
160
|
+
return from === undefined || to === undefined || from === to
|
|
161
|
+
? []
|
|
162
|
+
: [{ from, to, upward: edge.upward, ...templatesOf(edge.sides?.start, edge.sides?.end) }];
|
|
163
|
+
});
|
|
164
|
+
const neighbours: Set<number>[] = ids.map(() => new Set());
|
|
165
|
+
const inDegree = new Int32Array(count);
|
|
166
|
+
const outDegree = new Int32Array(count);
|
|
167
|
+
for (const edge of edges) {
|
|
168
|
+
neighbours[edge.from].add(edge.to);
|
|
169
|
+
neighbours[edge.to].add(edge.from);
|
|
170
|
+
outDegree[edge.from]++;
|
|
171
|
+
inDegree[edge.to]++;
|
|
172
|
+
}
|
|
173
|
+
const resolve = (relations: readonly PlaceRelation[], lookup: Map<string, number>) =>
|
|
174
|
+
relations.flatMap((relation) => {
|
|
175
|
+
const from = lookup.get(relation.from);
|
|
176
|
+
const target = lookup.get(relation.target);
|
|
177
|
+
return from === undefined || target === undefined || from === target
|
|
178
|
+
? []
|
|
179
|
+
: [{ from, kind: relation.kind, target, soft: relation.soft, weight: relation.weight }];
|
|
180
|
+
});
|
|
181
|
+
const relations = resolve(input.relations, byId);
|
|
182
|
+
const followers = Array.from({ length: count }, (_, node) => {
|
|
183
|
+
const seen = new Set([node]);
|
|
184
|
+
const queue = [node];
|
|
185
|
+
while (queue.length > 0) {
|
|
186
|
+
const leader = queue.shift() ?? node;
|
|
187
|
+
for (const relation of relations) {
|
|
188
|
+
if (!relation.soft && relation.target === leader && !seen.has(relation.from)) {
|
|
189
|
+
seen.add(relation.from);
|
|
190
|
+
queue.push(relation.from);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return [...seen];
|
|
195
|
+
});
|
|
196
|
+
return {
|
|
197
|
+
count,
|
|
198
|
+
ids,
|
|
199
|
+
group,
|
|
200
|
+
followers,
|
|
201
|
+
pinned,
|
|
202
|
+
pinCol,
|
|
203
|
+
pinRow,
|
|
204
|
+
members,
|
|
205
|
+
edges,
|
|
206
|
+
relations,
|
|
207
|
+
groupRelations: resolve(input.groupRelations, groupIndex),
|
|
208
|
+
neighbours: neighbours.map((set) => [...set]),
|
|
209
|
+
inDegree,
|
|
210
|
+
outDegree,
|
|
211
|
+
flow: input.flow,
|
|
212
|
+
box: input.box ?? { x: 0.25, y: 0.22 },
|
|
213
|
+
shapes: input.groups.map((group) => input.shapes?.get(group)),
|
|
214
|
+
all: ids.map((_, node) => node),
|
|
215
|
+
scratch: scratchFor(edges.length, input.groups.length),
|
|
216
|
+
...(input.aspect === undefined ? {} : { aspect: input.aspect }),
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/** Cost of a group's (or the whole placement's) shape against its limits. */
|
|
221
|
+
const shapeCost = (shape: GroupShape | undefined, box: Bounds, hard: number): number => {
|
|
222
|
+
if (!shape) {
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
|
225
|
+
const width = box.maxCol - box.minCol + 1;
|
|
226
|
+
const height = box.maxRow - box.minRow + 1;
|
|
227
|
+
return (
|
|
228
|
+
(shape.maxWidth !== undefined && width > shape.maxWidth ? hard * (width - shape.maxWidth) : 0) +
|
|
229
|
+
(shape.compact ? WEIGHT.square * Math.max(0, Math.abs(width - height) - 1) : 0)
|
|
230
|
+
);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
//
|
|
234
|
+
// Geometry of routes in cell space.
|
|
235
|
+
//
|
|
236
|
+
|
|
237
|
+
type Vector = { x: number; y: number };
|
|
238
|
+
|
|
239
|
+
const OUT: Record<Semantic.Side, Vector> = {
|
|
240
|
+
top: { x: 0, y: -1 },
|
|
241
|
+
bottom: { x: 0, y: 1 },
|
|
242
|
+
left: { x: -1, y: 0 },
|
|
243
|
+
right: { x: 1, y: 0 },
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/** Direction of travel when arriving at a side: into the box. */
|
|
247
|
+
const IN: Record<Semantic.Side, Vector> = {
|
|
248
|
+
top: { x: 0, y: 1 },
|
|
249
|
+
bottom: { x: 0, y: -1 },
|
|
250
|
+
left: { x: 1, y: 0 },
|
|
251
|
+
right: { x: -1, y: 0 },
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const ALL_SIDES: readonly Semantic.Side[] = ['top', 'bottom', 'left', 'right'];
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Fewest bends an orthogonal route takes from side `start` of a box to side `end` of another box
|
|
258
|
+
* displaced by (dx, dy) cells, ignoring every other box: the template the router will try first.
|
|
259
|
+
*/
|
|
260
|
+
export const templateBends = (dx: number, dy: number, start: Semantic.Side, end: Semantic.Side): number => {
|
|
261
|
+
const out = OUT[start];
|
|
262
|
+
const into = IN[end];
|
|
263
|
+
const along = dx * out.x + dy * out.y;
|
|
264
|
+
if (out.x === into.x && out.y === into.y) {
|
|
265
|
+
const across = dx * out.y + dy * out.x;
|
|
266
|
+
return along > 0 ? (across === 0 ? 0 : 2) : 4;
|
|
267
|
+
}
|
|
268
|
+
if (out.x === -into.x && out.y === -into.y) {
|
|
269
|
+
const across = dx * out.y + dy * out.x;
|
|
270
|
+
return across === 0 ? 4 : 2;
|
|
271
|
+
}
|
|
272
|
+
const ahead = dx * into.x + dy * into.y;
|
|
273
|
+
return along > 0 && ahead > 0 ? 1 : 3;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/** The best template for each sign of displacement, first in side order among equals. */
|
|
277
|
+
const templatesOf = (
|
|
278
|
+
start: readonly Semantic.Side[] | undefined,
|
|
279
|
+
end: readonly Semantic.Side[] | undefined,
|
|
280
|
+
): { bends: Float64Array; starts: Uint8Array; ends: Uint8Array } => {
|
|
281
|
+
const bends = new Float64Array(9);
|
|
282
|
+
const starts = new Uint8Array(9);
|
|
283
|
+
const ends = new Uint8Array(9);
|
|
284
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
285
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
286
|
+
const template = (dx + 1) * 3 + dy + 1;
|
|
287
|
+
let best = Infinity;
|
|
288
|
+
let pair: [Semantic.Side, Semantic.Side] = ['bottom', 'top'];
|
|
289
|
+
for (const startSide of start ?? ALL_SIDES) {
|
|
290
|
+
for (const endSide of end ?? ALL_SIDES) {
|
|
291
|
+
const value = templateBends(dx, dy, startSide, endSide);
|
|
292
|
+
if (value < best) {
|
|
293
|
+
best = value;
|
|
294
|
+
pair = [startSide, endSide];
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
bends[template] = best;
|
|
299
|
+
starts[template] = ALL_SIDES.indexOf(pair[0]);
|
|
300
|
+
ends[template] = ALL_SIDES.indexOf(pair[1]);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return { bends, starts, ends };
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
/** A random source reproducible from a seed, so a document always lays out the same way. */
|
|
307
|
+
const random = (seed: number) => {
|
|
308
|
+
let state = (seed * 2654435761) >>> 0 || 1;
|
|
309
|
+
return () => {
|
|
310
|
+
state ^= state << 13;
|
|
311
|
+
state >>>= 0;
|
|
312
|
+
state ^= state >>> 17;
|
|
313
|
+
state ^= state << 5;
|
|
314
|
+
state >>>= 0;
|
|
315
|
+
return state / 4294967296;
|
|
316
|
+
};
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
/** Cell coordinates per node index; NaN marks a node not yet placed. */
|
|
320
|
+
type State = { col: Float64Array; row: Float64Array };
|
|
321
|
+
|
|
322
|
+
// Cells are whole numbers; near the origin one packs into a small integer, which a Map hashes fastest,
|
|
323
|
+
// and anything farther into a disjoint range of exact larger numbers.
|
|
324
|
+
const NEAR = 2 ** 14;
|
|
325
|
+
const FAR_OFFSET = 2 ** 20;
|
|
326
|
+
const keyOf = (col: number, row: number) =>
|
|
327
|
+
col > -NEAR && col < NEAR && row > -NEAR && row < NEAR
|
|
328
|
+
? (col + NEAR) * 2 * NEAR + (row + NEAR)
|
|
329
|
+
: 2 ** 31 + (col + FAR_OFFSET) * 2 * FAR_OFFSET + (row + FAR_OFFSET);
|
|
330
|
+
|
|
331
|
+
const clone = (state: State): State => ({ col: Float64Array.from(state.col), row: Float64Array.from(state.row) });
|
|
332
|
+
|
|
333
|
+
type Bounds = { minCol: number; maxCol: number; minRow: number; maxRow: number };
|
|
334
|
+
|
|
335
|
+
const boundsOf = (state: State, nodes: readonly number[]): Bounds | undefined => {
|
|
336
|
+
let minCol = Infinity;
|
|
337
|
+
let maxCol = -Infinity;
|
|
338
|
+
let minRow = Infinity;
|
|
339
|
+
let maxRow = -Infinity;
|
|
340
|
+
let any = false;
|
|
341
|
+
for (const node of nodes) {
|
|
342
|
+
const col = state.col[node];
|
|
343
|
+
if (Number.isNaN(col)) {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const row = state.row[node];
|
|
347
|
+
any = true;
|
|
348
|
+
minCol = Math.min(minCol, col);
|
|
349
|
+
maxCol = Math.max(maxCol, col);
|
|
350
|
+
minRow = Math.min(minRow, row);
|
|
351
|
+
maxRow = Math.max(maxRow, row);
|
|
352
|
+
}
|
|
353
|
+
return any ? { minCol, maxCol, minRow, maxRow } : undefined;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const holds = (kind: Semantic.RelationKind, from: Cell, target: Cell): boolean => {
|
|
357
|
+
switch (kind) {
|
|
358
|
+
case 'right-of':
|
|
359
|
+
return from.row === target.row && from.col > target.col;
|
|
360
|
+
case 'left-of':
|
|
361
|
+
return from.row === target.row && from.col < target.col;
|
|
362
|
+
case 'below':
|
|
363
|
+
return from.col === target.col && from.row > target.row;
|
|
364
|
+
case 'above':
|
|
365
|
+
return from.col === target.col && from.row < target.row;
|
|
366
|
+
case 'same-row':
|
|
367
|
+
return from.row === target.row;
|
|
368
|
+
case 'same-col':
|
|
369
|
+
return from.col === target.col;
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const holdsBetween = (kind: Semantic.RelationKind, from: Bounds, target: Bounds): boolean => {
|
|
374
|
+
switch (kind) {
|
|
375
|
+
case 'right-of':
|
|
376
|
+
return from.minCol > target.maxCol;
|
|
377
|
+
case 'left-of':
|
|
378
|
+
return from.maxCol < target.minCol;
|
|
379
|
+
case 'below':
|
|
380
|
+
return from.minRow > target.maxRow;
|
|
381
|
+
case 'above':
|
|
382
|
+
return from.maxRow < target.minRow;
|
|
383
|
+
case 'same-row':
|
|
384
|
+
return from.minRow <= target.maxRow && target.minRow <= from.maxRow;
|
|
385
|
+
case 'same-col':
|
|
386
|
+
return from.minCol <= target.maxCol && target.minCol <= from.maxCol;
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
/** Whether the segments (a1x, a1y)–(a2x, a2y) and (b1x, b1y)–(b2x, b2y) cross at a point interior to both. */
|
|
391
|
+
const segmentsCross = (
|
|
392
|
+
a1x: number,
|
|
393
|
+
a1y: number,
|
|
394
|
+
a2x: number,
|
|
395
|
+
a2y: number,
|
|
396
|
+
b1x: number,
|
|
397
|
+
b1y: number,
|
|
398
|
+
b2x: number,
|
|
399
|
+
b2y: number,
|
|
400
|
+
): boolean => {
|
|
401
|
+
const d1 = (a2x - a1x) * (b1y - a1y) - (a2y - a1y) * (b1x - a1x);
|
|
402
|
+
const d2 = (a2x - a1x) * (b2y - a1y) - (a2y - a1y) * (b2x - a1x);
|
|
403
|
+
if (!((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0))) {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
const d3 = (b2x - b1x) * (a1y - b1y) - (b2y - b1y) * (a1x - b1x);
|
|
407
|
+
const d4 = (b2x - b1x) * (a2y - b1y) - (b2y - b1y) * (a2x - b1x);
|
|
408
|
+
return (d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0);
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
/** Whether the segment (ax, ay)–(bx, by) passes through the box centred at (cx, cy) with half-extents (hx, hy). */
|
|
412
|
+
const segmentHitsBox = (
|
|
413
|
+
ax: number,
|
|
414
|
+
ay: number,
|
|
415
|
+
bx: number,
|
|
416
|
+
by: number,
|
|
417
|
+
cx: number,
|
|
418
|
+
cy: number,
|
|
419
|
+
hx: number,
|
|
420
|
+
hy: number,
|
|
421
|
+
): boolean => {
|
|
422
|
+
let enter = 0;
|
|
423
|
+
let exit = 1;
|
|
424
|
+
const dx = bx - ax;
|
|
425
|
+
const dy = by - ay;
|
|
426
|
+
// Liang–Barsky clipping against the four slabs, in the order left, right, top, bottom.
|
|
427
|
+
for (let slab = 0; slab < 4; slab++) {
|
|
428
|
+
const p = slab === 0 ? -dx : slab === 1 ? dx : slab === 2 ? -dy : dy;
|
|
429
|
+
const q = slab === 0 ? ax - (cx - hx) : slab === 1 ? cx + hx - ax : slab === 2 ? ay - (cy - hy) : cy + hy - ay;
|
|
430
|
+
if (p === 0) {
|
|
431
|
+
if (q < 0) {
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
const ratio = q / p;
|
|
437
|
+
if (p < 0) {
|
|
438
|
+
enter = Math.max(enter, ratio);
|
|
439
|
+
} else {
|
|
440
|
+
exit = Math.min(exit, ratio);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return enter < exit;
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
/** Floats per template polyline: at most four points. */
|
|
447
|
+
const LINE_STRIDE = 8;
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Writes the route a template draws, in cell space, into `points` at `offset` and returns its point
|
|
451
|
+
* count: straight, L by its start side, Z through the middle, or U beyond both.
|
|
452
|
+
*/
|
|
453
|
+
const shapeInto = (
|
|
454
|
+
points: Float64Array,
|
|
455
|
+
offset: number,
|
|
456
|
+
fromX: number,
|
|
457
|
+
fromY: number,
|
|
458
|
+
toX: number,
|
|
459
|
+
toY: number,
|
|
460
|
+
bends: number,
|
|
461
|
+
start: Semantic.Side,
|
|
462
|
+
end: Semantic.Side,
|
|
463
|
+
): number => {
|
|
464
|
+
const alongX = start === 'left' || start === 'right';
|
|
465
|
+
points[offset] = fromX;
|
|
466
|
+
points[offset + 1] = fromY;
|
|
467
|
+
if (bends === 1) {
|
|
468
|
+
points[offset + 2] = alongX ? toX : fromX;
|
|
469
|
+
points[offset + 3] = alongX ? fromY : toY;
|
|
470
|
+
points[offset + 4] = toX;
|
|
471
|
+
points[offset + 5] = toY;
|
|
472
|
+
return 3;
|
|
473
|
+
}
|
|
474
|
+
if (bends === 2) {
|
|
475
|
+
const out = OUT[start];
|
|
476
|
+
const into = IN[end];
|
|
477
|
+
const opposed = out.x === -into.x && out.y === -into.y;
|
|
478
|
+
const middle = alongX
|
|
479
|
+
? opposed
|
|
480
|
+
? (out.x > 0 ? Math.max(fromX, toX) : Math.min(fromX, toX)) + out.x * 0.5
|
|
481
|
+
: (fromX + toX) / 2
|
|
482
|
+
: opposed
|
|
483
|
+
? (out.y > 0 ? Math.max(fromY, toY) : Math.min(fromY, toY)) + out.y * 0.5
|
|
484
|
+
: (fromY + toY) / 2;
|
|
485
|
+
points[offset + 2] = alongX ? middle : fromX;
|
|
486
|
+
points[offset + 3] = alongX ? fromY : middle;
|
|
487
|
+
points[offset + 4] = alongX ? middle : toX;
|
|
488
|
+
points[offset + 5] = alongX ? toY : middle;
|
|
489
|
+
points[offset + 6] = toX;
|
|
490
|
+
points[offset + 7] = toY;
|
|
491
|
+
return 4;
|
|
492
|
+
}
|
|
493
|
+
points[offset + 2] = toX;
|
|
494
|
+
points[offset + 3] = toY;
|
|
495
|
+
return 2;
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
/** Whether two template polylines, as written by {@link shapeInto}, cross. */
|
|
499
|
+
const polylinesCross = (points: Float64Array, left: number, leftCount: number, right: number, rightCount: number) => {
|
|
500
|
+
for (let first = 1; first < leftCount; first++) {
|
|
501
|
+
const a = left + first * 2;
|
|
502
|
+
for (let second = 1; second < rightCount; second++) {
|
|
503
|
+
const b = right + second * 2;
|
|
504
|
+
if (
|
|
505
|
+
segmentsCross(
|
|
506
|
+
points[a - 2],
|
|
507
|
+
points[a - 1],
|
|
508
|
+
points[a],
|
|
509
|
+
points[a + 1],
|
|
510
|
+
points[b - 2],
|
|
511
|
+
points[b - 1],
|
|
512
|
+
points[b],
|
|
513
|
+
points[b + 1],
|
|
514
|
+
)
|
|
515
|
+
) {
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return false;
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
/** Per-call working memory for {@link score}, sized to an index so scoring allocates nothing. */
|
|
524
|
+
type Scratch = {
|
|
525
|
+
/** Per drawn line: its edge's endpoints, point count, whether it bends, and its bounding box. */
|
|
526
|
+
lineFrom: Int32Array;
|
|
527
|
+
lineTo: Int32Array;
|
|
528
|
+
lineCount: Uint8Array;
|
|
529
|
+
lineBent: Uint8Array;
|
|
530
|
+
lineBox: Float64Array;
|
|
531
|
+
points: Float64Array;
|
|
532
|
+
sides: Int32Array;
|
|
533
|
+
/** Per group: its bounds as min col, max col, min row, max row, and whether it has any placed member. */
|
|
534
|
+
bounds: Float64Array;
|
|
535
|
+
hasBounds: Uint8Array;
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const scratchFor = (edges: number, groups: number): Scratch => ({
|
|
539
|
+
lineFrom: new Int32Array(edges),
|
|
540
|
+
lineTo: new Int32Array(edges),
|
|
541
|
+
lineCount: new Uint8Array(edges),
|
|
542
|
+
lineBent: new Uint8Array(edges),
|
|
543
|
+
lineBox: new Float64Array(edges * 4),
|
|
544
|
+
points: new Float64Array(edges * LINE_STRIDE),
|
|
545
|
+
sides: new Int32Array(4),
|
|
546
|
+
bounds: new Float64Array(groups * 4),
|
|
547
|
+
hasBounds: new Uint8Array(groups),
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
/** Whether a relation of `kind` holds between two cells, given as coordinates. */
|
|
551
|
+
const holdsAt = (kind: Semantic.RelationKind, fromCol: number, fromRow: number, toCol: number, toRow: number) => {
|
|
552
|
+
switch (kind) {
|
|
553
|
+
case 'right-of':
|
|
554
|
+
return fromRow === toRow && fromCol > toCol;
|
|
555
|
+
case 'left-of':
|
|
556
|
+
return fromRow === toRow && fromCol < toCol;
|
|
557
|
+
case 'below':
|
|
558
|
+
return fromCol === toCol && fromRow > toRow;
|
|
559
|
+
case 'above':
|
|
560
|
+
return fromCol === toCol && fromRow < toRow;
|
|
561
|
+
case 'same-row':
|
|
562
|
+
return fromRow === toRow;
|
|
563
|
+
case 'same-col':
|
|
564
|
+
return fromCol === toCol;
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
/** Cost of a shape against its limits, for bounds given as coordinates. */
|
|
569
|
+
const shapeCostOf = (
|
|
570
|
+
shape: GroupShape | undefined,
|
|
571
|
+
minCol: number,
|
|
572
|
+
maxCol: number,
|
|
573
|
+
minRow: number,
|
|
574
|
+
maxRow: number,
|
|
575
|
+
hard: number,
|
|
576
|
+
): number => (shape ? shapeCost(shape, { minCol, maxCol, minRow, maxRow }, hard) : 0);
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* The placement score: lower is better. Terms involving a node not yet placed are skipped, so the
|
|
580
|
+
* greedy seating can score a partial state. It runs for every state the search tries, so it works
|
|
581
|
+
* in the index's scratch arrays and sums its terms in a fixed order, which keeps a placement
|
|
582
|
+
* reproducible to the last bit.
|
|
583
|
+
*/
|
|
584
|
+
const score = (index: Index, state: State, occupied: Map<number, number>, strictness = 1): number => {
|
|
585
|
+
// Annealing starts with relations and group bounds as strong preferences and tightens them into
|
|
586
|
+
// rules, so an early state that breaks one is not a wall the search can never climb over.
|
|
587
|
+
const hard = WEIGHT.soft * 3 + (WEIGHT.hard - WEIGHT.soft * 3) * strictness;
|
|
588
|
+
const infeasible = WEIGHT.soft * 30 + (WEIGHT.infeasible - WEIGHT.soft * 30) * strictness;
|
|
589
|
+
const { col, row } = state;
|
|
590
|
+
const { scratch } = index;
|
|
591
|
+
const { lineFrom, lineTo, lineCount, lineBent, lineBox, points } = scratch;
|
|
592
|
+
let total = 0;
|
|
593
|
+
|
|
594
|
+
// Edges: the bends of the best template, blocked straight runs, length and flow.
|
|
595
|
+
// Each edge as the polyline of its best template, in cell space, for the crossing and through-box terms.
|
|
596
|
+
let lines = 0;
|
|
597
|
+
for (const edge of index.edges) {
|
|
598
|
+
const fromCol = col[edge.from];
|
|
599
|
+
const toCol = col[edge.to];
|
|
600
|
+
if (Number.isNaN(fromCol) || Number.isNaN(toCol)) {
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
const fromRow = row[edge.from];
|
|
604
|
+
const toRow = row[edge.to];
|
|
605
|
+
const dx = toCol - fromCol;
|
|
606
|
+
const dy = toRow - fromRow;
|
|
607
|
+
const template = (Math.sign(dx) + 1) * 3 + Math.sign(dy) + 1;
|
|
608
|
+
const bends = edge.bends[template];
|
|
609
|
+
const blocked =
|
|
610
|
+
(bends === 0 && blockedStraight(fromCol, fromRow, dx, dy, occupied)) ||
|
|
611
|
+
(bends === 1 && blockedCorners(fromCol, fromRow, dx, dy, occupied));
|
|
612
|
+
total += WEIGHT.bend * (blocked ? 2 : bends) + WEIGHT.length * (Math.abs(dx) + Math.abs(dy));
|
|
613
|
+
const along = index.flow === 'down' ? dy : index.flow === 'up' ? -dy : index.flow === 'right' ? dx : -dx;
|
|
614
|
+
const forward = edge.upward ? -along : along;
|
|
615
|
+
total += forward < 0 ? WEIGHT.backward : forward === 0 ? WEIGHT.sideways : 0;
|
|
616
|
+
lineFrom[lines] = edge.from;
|
|
617
|
+
lineTo[lines] = edge.to;
|
|
618
|
+
lineBent[lines] = bends > 0 ? 1 : 0;
|
|
619
|
+
const count = blocked
|
|
620
|
+
? 0
|
|
621
|
+
: shapeInto(
|
|
622
|
+
points,
|
|
623
|
+
lines * LINE_STRIDE,
|
|
624
|
+
fromCol,
|
|
625
|
+
fromRow,
|
|
626
|
+
toCol,
|
|
627
|
+
toRow,
|
|
628
|
+
bends,
|
|
629
|
+
ALL_SIDES[edge.starts[template]],
|
|
630
|
+
ALL_SIDES[edge.ends[template]],
|
|
631
|
+
);
|
|
632
|
+
lineCount[lines] = count;
|
|
633
|
+
let minX = Infinity;
|
|
634
|
+
let maxX = -Infinity;
|
|
635
|
+
let minY = Infinity;
|
|
636
|
+
let maxY = -Infinity;
|
|
637
|
+
for (let point = 0; point < count; point++) {
|
|
638
|
+
const x = points[lines * LINE_STRIDE + point * 2];
|
|
639
|
+
const y = points[lines * LINE_STRIDE + point * 2 + 1];
|
|
640
|
+
minX = Math.min(minX, x);
|
|
641
|
+
maxX = Math.max(maxX, x);
|
|
642
|
+
minY = Math.min(minY, y);
|
|
643
|
+
maxY = Math.max(maxY, y);
|
|
644
|
+
}
|
|
645
|
+
lineBox[lines * 4] = minX;
|
|
646
|
+
lineBox[lines * 4 + 1] = maxX;
|
|
647
|
+
lineBox[lines * 4 + 2] = minY;
|
|
648
|
+
lineBox[lines * 4 + 3] = maxY;
|
|
649
|
+
lines++;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const half = index.box;
|
|
653
|
+
for (let first = 0; first < lines; first++) {
|
|
654
|
+
const leftFrom = lineFrom[first];
|
|
655
|
+
const leftTo = lineTo[first];
|
|
656
|
+
const leftCount = lineCount[first];
|
|
657
|
+
const leftMinX = lineBox[first * 4];
|
|
658
|
+
const leftMaxX = lineBox[first * 4 + 1];
|
|
659
|
+
const leftMinY = lineBox[first * 4 + 2];
|
|
660
|
+
const leftMaxY = lineBox[first * 4 + 3];
|
|
661
|
+
for (let second = first + 1; second < lines; second++) {
|
|
662
|
+
const rightFrom = lineFrom[second];
|
|
663
|
+
const rightTo = lineTo[second];
|
|
664
|
+
if (leftFrom === rightFrom || leftFrom === rightTo || leftTo === rightFrom || leftTo === rightTo) {
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
// A proper crossing lies inside both bounding boxes, so disjoint boxes cannot cross.
|
|
668
|
+
if (
|
|
669
|
+
lineBox[second * 4] > leftMaxX ||
|
|
670
|
+
lineBox[second * 4 + 1] < leftMinX ||
|
|
671
|
+
lineBox[second * 4 + 2] > leftMaxY ||
|
|
672
|
+
lineBox[second * 4 + 3] < leftMinY
|
|
673
|
+
) {
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
if (polylinesCross(points, first * LINE_STRIDE, leftCount, second * LINE_STRIDE, lineCount[second])) {
|
|
677
|
+
total += WEIGHT.crossing;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
// A straight run is already charged when blocked; a bent one may still cut a box.
|
|
681
|
+
if (lineBent[first] === 1) {
|
|
682
|
+
for (let node = 0; node < index.count; node++) {
|
|
683
|
+
const centreX = col[node];
|
|
684
|
+
if (node === leftFrom || node === leftTo || Number.isNaN(centreX)) {
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
const centreY = row[node];
|
|
688
|
+
// A box clear of the line's bounding box cannot be cut by it.
|
|
689
|
+
if (
|
|
690
|
+
centreX + half.x < leftMinX ||
|
|
691
|
+
centreX - half.x > leftMaxX ||
|
|
692
|
+
centreY + half.y < leftMinY ||
|
|
693
|
+
centreY - half.y > leftMaxY
|
|
694
|
+
) {
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
const base = first * LINE_STRIDE;
|
|
698
|
+
for (let point = 1; point < leftCount; point++) {
|
|
699
|
+
const at = base + point * 2;
|
|
700
|
+
if (
|
|
701
|
+
segmentHitsBox(points[at - 2], points[at - 1], points[at], points[at + 1], centreX, centreY, half.x, half.y)
|
|
702
|
+
) {
|
|
703
|
+
total += WEIGHT.through;
|
|
704
|
+
break;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Hubs: one neighbour per side reads best; chains: keep a pass-through node in line.
|
|
712
|
+
const sides = scratch.sides;
|
|
713
|
+
for (let node = 0; node < index.count; node++) {
|
|
714
|
+
const nodeCol = col[node];
|
|
715
|
+
if (Number.isNaN(nodeCol)) {
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
const nodeRow = row[node];
|
|
719
|
+
const neighbours = index.neighbours[node];
|
|
720
|
+
let around = 0;
|
|
721
|
+
let firstAround = -1;
|
|
722
|
+
let secondAround = -1;
|
|
723
|
+
sides.fill(0);
|
|
724
|
+
for (const other of neighbours) {
|
|
725
|
+
if (Number.isNaN(col[other])) {
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
if (around === 0) {
|
|
729
|
+
firstAround = other;
|
|
730
|
+
} else if (around === 1) {
|
|
731
|
+
secondAround = other;
|
|
732
|
+
}
|
|
733
|
+
around++;
|
|
734
|
+
const dx = col[other] - nodeCol;
|
|
735
|
+
const dy = row[other] - nodeRow;
|
|
736
|
+
sides[Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 0 : 1) : dy > 0 ? 2 : 3]++;
|
|
737
|
+
}
|
|
738
|
+
if (around >= 3) {
|
|
739
|
+
let pairs = 0;
|
|
740
|
+
for (let side = 0; side < 4; side++) {
|
|
741
|
+
pairs += (sides[side] * (sides[side] - 1)) / 2;
|
|
742
|
+
}
|
|
743
|
+
total += WEIGHT.hub * pairs;
|
|
744
|
+
}
|
|
745
|
+
if (around === 2 && index.inDegree[node] === 1 && index.outDegree[node] === 1) {
|
|
746
|
+
const inLine =
|
|
747
|
+
(col[firstAround] === nodeCol && col[secondAround] === nodeCol) ||
|
|
748
|
+
(row[firstAround] === nodeRow && row[secondAround] === nodeRow);
|
|
749
|
+
if (!inLine) {
|
|
750
|
+
total += WEIGHT.chain;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Relations.
|
|
756
|
+
for (const relation of index.relations) {
|
|
757
|
+
const fromCol = col[relation.from];
|
|
758
|
+
const targetCol = col[relation.target];
|
|
759
|
+
if (Number.isNaN(fromCol) || Number.isNaN(targetCol)) {
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
const fromRow = row[relation.from];
|
|
763
|
+
const targetRow = row[relation.target];
|
|
764
|
+
if (holdsAt(relation.kind, fromCol, fromRow, targetCol, targetRow)) {
|
|
765
|
+
total +=
|
|
766
|
+
WEIGHT.near *
|
|
767
|
+
(relation.kind === 'right-of' || relation.kind === 'left-of'
|
|
768
|
+
? Math.abs(fromCol - targetCol) - 1
|
|
769
|
+
: relation.kind === 'above' || relation.kind === 'below'
|
|
770
|
+
? Math.abs(fromRow - targetRow) - 1
|
|
771
|
+
: 0);
|
|
772
|
+
} else {
|
|
773
|
+
total += relation.soft ? (relation.weight ?? WEIGHT.soft) : hard;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Groups: exclusive, disjoint, compact, and related as stated.
|
|
778
|
+
const { bounds, hasBounds } = scratch;
|
|
779
|
+
const groups = index.members.length;
|
|
780
|
+
for (let group = 0; group < groups; group++) {
|
|
781
|
+
let minCol = Infinity;
|
|
782
|
+
let maxCol = -Infinity;
|
|
783
|
+
let minRow = Infinity;
|
|
784
|
+
let maxRow = -Infinity;
|
|
785
|
+
let placedMembers = 0;
|
|
786
|
+
for (const member of index.members[group]) {
|
|
787
|
+
const memberCol = col[member];
|
|
788
|
+
if (Number.isNaN(memberCol)) {
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
791
|
+
const memberRow = row[member];
|
|
792
|
+
placedMembers++;
|
|
793
|
+
minCol = Math.min(minCol, memberCol);
|
|
794
|
+
maxCol = Math.max(maxCol, memberCol);
|
|
795
|
+
minRow = Math.min(minRow, memberRow);
|
|
796
|
+
maxRow = Math.max(maxRow, memberRow);
|
|
797
|
+
}
|
|
798
|
+
hasBounds[group] = placedMembers > 0 ? 1 : 0;
|
|
799
|
+
bounds[group * 4] = minCol;
|
|
800
|
+
bounds[group * 4 + 1] = maxCol;
|
|
801
|
+
bounds[group * 4 + 2] = minRow;
|
|
802
|
+
bounds[group * 4 + 3] = maxRow;
|
|
803
|
+
}
|
|
804
|
+
for (let group = 0; group < groups; group++) {
|
|
805
|
+
if (hasBounds[group] === 0) {
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
const minCol = bounds[group * 4];
|
|
809
|
+
const maxCol = bounds[group * 4 + 1];
|
|
810
|
+
const minRow = bounds[group * 4 + 2];
|
|
811
|
+
const maxRow = bounds[group * 4 + 3];
|
|
812
|
+
total += shapeCostOf(index.shapes[group], minCol, maxCol, minRow, maxRow, hard);
|
|
813
|
+
for (let cellRow = minRow; cellRow <= maxRow; cellRow++) {
|
|
814
|
+
for (let cellCol = minCol; cellCol <= maxCol; cellCol++) {
|
|
815
|
+
const node = occupied.get(keyOf(cellCol, cellRow));
|
|
816
|
+
if (node !== undefined && index.group[node] !== group) {
|
|
817
|
+
total += infeasible;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
let placedMembers = 0;
|
|
822
|
+
for (const member of index.members[group]) {
|
|
823
|
+
if (!Number.isNaN(col[member])) {
|
|
824
|
+
placedMembers++;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
const area = (maxCol - minCol + 1) * (maxRow - minRow + 1);
|
|
828
|
+
total += WEIGHT.slack * (area - placedMembers);
|
|
829
|
+
for (let other = group + 1; other < groups; other++) {
|
|
830
|
+
if (
|
|
831
|
+
hasBounds[other] === 1 &&
|
|
832
|
+
minCol <= bounds[other * 4 + 1] &&
|
|
833
|
+
bounds[other * 4] <= maxCol &&
|
|
834
|
+
minRow <= bounds[other * 4 + 3] &&
|
|
835
|
+
bounds[other * 4 + 2] <= maxRow
|
|
836
|
+
) {
|
|
837
|
+
total += infeasible;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
for (const relation of index.groupRelations) {
|
|
842
|
+
if (hasBounds[relation.from] === 0 || hasBounds[relation.target] === 0) {
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
const from = {
|
|
846
|
+
minCol: bounds[relation.from * 4],
|
|
847
|
+
maxCol: bounds[relation.from * 4 + 1],
|
|
848
|
+
minRow: bounds[relation.from * 4 + 2],
|
|
849
|
+
maxRow: bounds[relation.from * 4 + 3],
|
|
850
|
+
};
|
|
851
|
+
const target = {
|
|
852
|
+
minCol: bounds[relation.target * 4],
|
|
853
|
+
maxCol: bounds[relation.target * 4 + 1],
|
|
854
|
+
minRow: bounds[relation.target * 4 + 2],
|
|
855
|
+
maxRow: bounds[relation.target * 4 + 3],
|
|
856
|
+
};
|
|
857
|
+
if (!holdsBetween(relation.kind, from, target)) {
|
|
858
|
+
total += relation.soft ? WEIGHT.soft : hard;
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
const gap =
|
|
862
|
+
relation.kind === 'right-of'
|
|
863
|
+
? from.minCol - target.maxCol - 1
|
|
864
|
+
: relation.kind === 'left-of'
|
|
865
|
+
? target.minCol - from.maxCol - 1
|
|
866
|
+
: relation.kind === 'below'
|
|
867
|
+
? from.minRow - target.maxRow - 1
|
|
868
|
+
: relation.kind === 'above'
|
|
869
|
+
? target.minRow - from.maxRow - 1
|
|
870
|
+
: 0;
|
|
871
|
+
total += WEIGHT.near * gap;
|
|
872
|
+
// Beside means overlapping across the relation, not merely past it.
|
|
873
|
+
const besideX = relation.kind === 'right-of' || relation.kind === 'left-of';
|
|
874
|
+
const besideY = relation.kind === 'above' || relation.kind === 'below';
|
|
875
|
+
if (
|
|
876
|
+
(besideX && (from.minRow > target.maxRow || target.minRow > from.maxRow)) ||
|
|
877
|
+
(besideY && (from.minCol > target.maxCol || target.minCol > from.maxCol))
|
|
878
|
+
) {
|
|
879
|
+
total += WEIGHT.soft;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// Pins hold by construction; compactness breaks the remaining ties.
|
|
884
|
+
const all = boundsOf(state, index.all);
|
|
885
|
+
if (all) {
|
|
886
|
+
total += WEIGHT.extent * (all.maxCol - all.minCol + 1 + (all.maxRow - all.minRow + 1));
|
|
887
|
+
total += shapeCost(index.whole, all, hard);
|
|
888
|
+
if (index.aspect !== undefined) {
|
|
889
|
+
total +=
|
|
890
|
+
WEIGHT.aspect *
|
|
891
|
+
Math.abs(Math.log((all.maxCol - all.minCol + 1) / (all.maxRow - all.minRow + 1) / index.aspect));
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return total;
|
|
895
|
+
};
|
|
896
|
+
|
|
897
|
+
const blockedStraight = (col: number, row: number, dx: number, dy: number, occupied: Map<number, number>): boolean => {
|
|
898
|
+
const steps = Math.abs(dx) + Math.abs(dy);
|
|
899
|
+
for (let step = 1; step < steps; step++) {
|
|
900
|
+
if (occupied.has(keyOf(col + Math.sign(dx) * step, row + Math.sign(dy) * step))) {
|
|
901
|
+
return true;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return false;
|
|
905
|
+
};
|
|
906
|
+
|
|
907
|
+
/** Whether both L-shaped routes run into a box: along the source row then the target column, or the reverse. */
|
|
908
|
+
const blockedCorners = (col: number, row: number, dx: number, dy: number, occupied: Map<number, number>): boolean => {
|
|
909
|
+
const clear = (horizontalFirst: boolean) => {
|
|
910
|
+
const cornerCol = horizontalFirst ? col + dx : col;
|
|
911
|
+
const cornerRow = horizontalFirst ? row : row + dy;
|
|
912
|
+
if (occupied.has(keyOf(cornerCol, cornerRow))) {
|
|
913
|
+
return false;
|
|
914
|
+
}
|
|
915
|
+
return (
|
|
916
|
+
!blockedStraight(col, row, cornerCol - col, cornerRow - row, occupied) &&
|
|
917
|
+
!blockedStraight(cornerCol, cornerRow, col + dx - cornerCol, row + dy - cornerRow, occupied)
|
|
918
|
+
);
|
|
919
|
+
};
|
|
920
|
+
return !clear(true) && !clear(false);
|
|
921
|
+
};
|
|
922
|
+
|
|
923
|
+
/** The search over one index: greedy seating, annealing, then a greedy polish. */
|
|
924
|
+
class Search {
|
|
925
|
+
readonly #index: Index;
|
|
926
|
+
readonly #random: () => number;
|
|
927
|
+
#state: State;
|
|
928
|
+
readonly #occupied = new Map<number, number>();
|
|
929
|
+
#strictness = 1;
|
|
930
|
+
|
|
931
|
+
constructor(index: Index, seed: number, start?: State) {
|
|
932
|
+
this.#index = index;
|
|
933
|
+
this.#random = random(seed);
|
|
934
|
+
this.#state = start
|
|
935
|
+
? clone(start)
|
|
936
|
+
: { col: new Float64Array(index.count).fill(NaN), row: new Float64Array(index.count).fill(NaN) };
|
|
937
|
+
for (let node = 0; node < index.count; node++) {
|
|
938
|
+
if (!Number.isNaN(this.#state.col[node])) {
|
|
939
|
+
this.#occupied.set(keyOf(this.#state.col[node], this.#state.row[node]), node);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
get state(): State {
|
|
945
|
+
return this.#state;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
cost(): number {
|
|
949
|
+
return score(this.#index, this.#state, this.#occupied, this.#strictness);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
#set(node: number, col: number, row: number) {
|
|
953
|
+
const state = this.#state;
|
|
954
|
+
if (!Number.isNaN(state.col[node]) && this.#occupied.get(keyOf(state.col[node], state.row[node])) === node) {
|
|
955
|
+
this.#occupied.delete(keyOf(state.col[node], state.row[node]));
|
|
956
|
+
}
|
|
957
|
+
state.col[node] = col;
|
|
958
|
+
state.row[node] = row;
|
|
959
|
+
if (!Number.isNaN(col)) {
|
|
960
|
+
this.#occupied.set(keyOf(col, row), node);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** Moves several nodes at once; returns an undo, or undefined when two would share a cell. */
|
|
965
|
+
#moveAll(moves: readonly { node: number; col: number; row: number }[]): (() => void) | undefined {
|
|
966
|
+
// Moves are a handful of nodes, so linear scans beat building sets for every proposal.
|
|
967
|
+
for (let position = 0; position < moves.length; position++) {
|
|
968
|
+
const move = moves[position];
|
|
969
|
+
const key = keyOf(move.col, move.row);
|
|
970
|
+
const holder = this.#occupied.get(key);
|
|
971
|
+
if (holder !== undefined && !moves.some((other) => other.node === holder)) {
|
|
972
|
+
return undefined;
|
|
973
|
+
}
|
|
974
|
+
for (let earlier = 0; earlier < position; earlier++) {
|
|
975
|
+
// `Object.is` so two unplaced targets collide, as equal set keys would.
|
|
976
|
+
if (Object.is(keyOf(moves[earlier].col, moves[earlier].row), key)) {
|
|
977
|
+
return undefined;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
const previous = moves.map(({ node }) => ({ node, col: this.#state.col[node], row: this.#state.row[node] }));
|
|
982
|
+
for (const { node } of moves) {
|
|
983
|
+
this.#set(node, NaN, NaN);
|
|
984
|
+
}
|
|
985
|
+
for (const move of moves) {
|
|
986
|
+
this.#set(move.node, move.col, move.row);
|
|
987
|
+
}
|
|
988
|
+
return () => {
|
|
989
|
+
for (const { node } of moves) {
|
|
990
|
+
this.#set(node, NaN, NaN);
|
|
991
|
+
}
|
|
992
|
+
for (const entry of previous) {
|
|
993
|
+
this.#set(entry.node, entry.col, entry.row);
|
|
994
|
+
}
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
#free(node: number) {
|
|
999
|
+
return !this.#index.pinned[node];
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** Seats units one at a time, each where the partial score is lowest. */
|
|
1003
|
+
seat(units: readonly { nodes: readonly number[]; local: readonly Cell[]; anchored: boolean }[]) {
|
|
1004
|
+
const placedNodes: number[] = [];
|
|
1005
|
+
const order = this.#unitOrder(units);
|
|
1006
|
+
for (const unit of order) {
|
|
1007
|
+
if (unit.anchored) {
|
|
1008
|
+
unit.nodes.forEach((node, position) => this.#set(node, unit.local[position].col, unit.local[position].row));
|
|
1009
|
+
placedNodes.push(...unit.nodes);
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
const width = Math.max(...unit.local.map((cell) => cell.col)) + 1;
|
|
1013
|
+
const height = Math.max(...unit.local.map((cell) => cell.row)) + 1;
|
|
1014
|
+
const around = boundsOf(this.#state, placedNodes) ?? { minCol: 0, maxCol: 0, minRow: 0, maxRow: 0 };
|
|
1015
|
+
// The unit's own layout and its mirror images: a relation to a node outside it may need one.
|
|
1016
|
+
const variants = [0, 1, 2, 3].map((flip) =>
|
|
1017
|
+
unit.local.map((cell) => ({
|
|
1018
|
+
col: flip & 1 ? width - 1 - cell.col : cell.col,
|
|
1019
|
+
row: flip & 2 ? height - 1 - cell.row : cell.row,
|
|
1020
|
+
})),
|
|
1021
|
+
);
|
|
1022
|
+
let best: { cost: number; col: number; row: number; local: readonly Cell[] } | undefined;
|
|
1023
|
+
for (const local of unit.nodes.length > 1 ? variants : [unit.local]) {
|
|
1024
|
+
for (let row = around.minRow - height - 1; row <= around.maxRow + 1; row++) {
|
|
1025
|
+
for (let col = around.minCol - width - 1; col <= around.maxCol + 1; col++) {
|
|
1026
|
+
const undo = this.#moveAll(
|
|
1027
|
+
unit.nodes.map((node, position) => ({
|
|
1028
|
+
node,
|
|
1029
|
+
col: col + local[position].col,
|
|
1030
|
+
row: row + local[position].row,
|
|
1031
|
+
})),
|
|
1032
|
+
);
|
|
1033
|
+
if (!undo) {
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
const cost = this.cost() + this.#random() * 1e-3;
|
|
1037
|
+
if (!best || cost < best.cost) {
|
|
1038
|
+
best = { cost, col, row, local };
|
|
1039
|
+
}
|
|
1040
|
+
undo();
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
const seat = best ?? { col: around.maxCol + 2, row: around.minRow, local: unit.local };
|
|
1045
|
+
unit.nodes.forEach((node, position) =>
|
|
1046
|
+
this.#set(node, seat.col + seat.local[position].col, seat.row + seat.local[position].row),
|
|
1047
|
+
);
|
|
1048
|
+
placedNodes.push(...unit.nodes);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/** Anchored units first, then outward from the best connected by connections to what is placed. */
|
|
1053
|
+
#unitOrder<Unit extends { nodes: readonly number[]; anchored: boolean }>(units: readonly Unit[]): Unit[] {
|
|
1054
|
+
const index = this.#index;
|
|
1055
|
+
const unitOf = new Map<number, number>();
|
|
1056
|
+
units.forEach((unit, position) => unit.nodes.forEach((node) => unitOf.set(node, position)));
|
|
1057
|
+
const weight = (position: number, among: Set<number>) =>
|
|
1058
|
+
index.edges.filter((edge) => {
|
|
1059
|
+
const [from, to] = [unitOf.get(edge.from), unitOf.get(edge.to)];
|
|
1060
|
+
return (
|
|
1061
|
+
(from === position && to !== undefined && among.has(to)) ||
|
|
1062
|
+
(to === position && from !== undefined && among.has(from))
|
|
1063
|
+
);
|
|
1064
|
+
}).length;
|
|
1065
|
+
const all = new Set(units.map((_, position) => position));
|
|
1066
|
+
const done = new Set<number>();
|
|
1067
|
+
const order: Unit[] = [];
|
|
1068
|
+
units.forEach((unit, position) => {
|
|
1069
|
+
if (unit.anchored) {
|
|
1070
|
+
done.add(position);
|
|
1071
|
+
order.push(unit);
|
|
1072
|
+
}
|
|
1073
|
+
});
|
|
1074
|
+
while (done.size < units.length) {
|
|
1075
|
+
let best = -1;
|
|
1076
|
+
let bestScore = -Infinity;
|
|
1077
|
+
for (const position of all) {
|
|
1078
|
+
if (done.has(position)) {
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
// Connections to what is placed dominate; total degree and size seed the first pick.
|
|
1082
|
+
const value =
|
|
1083
|
+
weight(position, done) * 1000 +
|
|
1084
|
+
weight(position, all) * 10 +
|
|
1085
|
+
units[position].nodes.length +
|
|
1086
|
+
this.#random() * 1e-3;
|
|
1087
|
+
if (value > bestScore) {
|
|
1088
|
+
bestScore = value;
|
|
1089
|
+
best = position;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
done.add(best);
|
|
1093
|
+
order.push(units[best]);
|
|
1094
|
+
}
|
|
1095
|
+
return order;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/** Moves a node with its followers by a delta. */
|
|
1099
|
+
#follow(node: number, dx: number, dy: number): (() => void) | undefined {
|
|
1100
|
+
const moving = this.#index.followers[node];
|
|
1101
|
+
if (moving.some((member) => !this.#free(member))) {
|
|
1102
|
+
return undefined;
|
|
1103
|
+
}
|
|
1104
|
+
return this.#moveAll(
|
|
1105
|
+
moving.map((member) => ({ node: member, col: this.#state.col[member] + dx, row: this.#state.row[member] + dy })),
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Moves the subject of a broken hard relation, with its followers, to the nearest cell that
|
|
1111
|
+
* satisfies it; returns the undo, or undefined when nothing is broken or no cell is free.
|
|
1112
|
+
*/
|
|
1113
|
+
repair(): (() => void) | undefined {
|
|
1114
|
+
const { col, row } = this.#state;
|
|
1115
|
+
const broken = this.#index.relations.filter(
|
|
1116
|
+
(relation) =>
|
|
1117
|
+
!relation.soft &&
|
|
1118
|
+
!holds(
|
|
1119
|
+
relation.kind,
|
|
1120
|
+
{ col: col[relation.from], row: row[relation.from] },
|
|
1121
|
+
{ col: col[relation.target], row: row[relation.target] },
|
|
1122
|
+
),
|
|
1123
|
+
);
|
|
1124
|
+
if (broken.length === 0) {
|
|
1125
|
+
return undefined;
|
|
1126
|
+
}
|
|
1127
|
+
const relation = broken[Math.floor(this.#random() * broken.length)];
|
|
1128
|
+
if (this.#index.followers[relation.from].includes(relation.target)) {
|
|
1129
|
+
return undefined;
|
|
1130
|
+
}
|
|
1131
|
+
const target = { col: col[relation.target], row: row[relation.target] };
|
|
1132
|
+
for (let distance = 1; distance <= 3; distance++) {
|
|
1133
|
+
const wanted =
|
|
1134
|
+
relation.kind === 'right-of'
|
|
1135
|
+
? { col: target.col + distance, row: target.row }
|
|
1136
|
+
: relation.kind === 'left-of'
|
|
1137
|
+
? { col: target.col - distance, row: target.row }
|
|
1138
|
+
: relation.kind === 'below'
|
|
1139
|
+
? { col: target.col, row: target.row + distance }
|
|
1140
|
+
: relation.kind === 'above'
|
|
1141
|
+
? { col: target.col, row: target.row - distance }
|
|
1142
|
+
: relation.kind === 'same-row'
|
|
1143
|
+
? { col: col[relation.from], row: target.row }
|
|
1144
|
+
: { col: target.col, row: row[relation.from] };
|
|
1145
|
+
const undo = this.#follow(relation.from, wanted.col - col[relation.from], wanted.row - row[relation.from]);
|
|
1146
|
+
if (undo) {
|
|
1147
|
+
return undo;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
return undefined;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/** One random move; returns its undo, or undefined when the move was impossible. */
|
|
1154
|
+
#propose(window: Bounds): (() => void) | undefined {
|
|
1155
|
+
const index = this.#index;
|
|
1156
|
+
const pick = this.#random();
|
|
1157
|
+
const node = Math.floor(this.#random() * index.count);
|
|
1158
|
+
const cellIn = () => ({
|
|
1159
|
+
col: window.minCol + Math.floor(this.#random() * (window.maxCol - window.minCol + 1)),
|
|
1160
|
+
row: window.minRow + Math.floor(this.#random() * (window.maxRow - window.minRow + 1)),
|
|
1161
|
+
});
|
|
1162
|
+
if (pick >= 0.4 && pick < 0.55) {
|
|
1163
|
+
const direction = Math.floor(this.#random() * 4);
|
|
1164
|
+
return this.repair() ?? this.#follow(node, [1, -1, 0, 0][direction], [0, 0, 1, -1][direction]);
|
|
1165
|
+
}
|
|
1166
|
+
if (pick < 0.4) {
|
|
1167
|
+
if (!this.#free(node)) {
|
|
1168
|
+
return undefined;
|
|
1169
|
+
}
|
|
1170
|
+
const target = cellIn();
|
|
1171
|
+
const holder = this.#occupied.get(keyOf(target.col, target.row));
|
|
1172
|
+
if (holder !== undefined) {
|
|
1173
|
+
if (!this.#free(holder) || index.group[holder] !== index.group[node]) {
|
|
1174
|
+
return undefined;
|
|
1175
|
+
}
|
|
1176
|
+
return this.#moveAll([
|
|
1177
|
+
{ node, ...target },
|
|
1178
|
+
{ node: holder, col: this.#state.col[node], row: this.#state.row[node] },
|
|
1179
|
+
]);
|
|
1180
|
+
}
|
|
1181
|
+
return this.#moveAll([{ node, ...target }]);
|
|
1182
|
+
}
|
|
1183
|
+
const group = index.group[node];
|
|
1184
|
+
if (group < 0 || index.members[group].some((member) => !this.#free(member))) {
|
|
1185
|
+
// A free-standing node: nudge it by one cell.
|
|
1186
|
+
if (!this.#free(node)) {
|
|
1187
|
+
return undefined;
|
|
1188
|
+
}
|
|
1189
|
+
const direction = Math.floor(this.#random() * 4);
|
|
1190
|
+
return this.#moveAll([
|
|
1191
|
+
{
|
|
1192
|
+
node,
|
|
1193
|
+
col: this.#state.col[node] + [1, -1, 0, 0][direction],
|
|
1194
|
+
row: this.#state.row[node] + [0, 0, 1, -1][direction],
|
|
1195
|
+
},
|
|
1196
|
+
]);
|
|
1197
|
+
}
|
|
1198
|
+
const members = index.members[group];
|
|
1199
|
+
const box = boundsOf(this.#state, members);
|
|
1200
|
+
if (!box) {
|
|
1201
|
+
return undefined;
|
|
1202
|
+
}
|
|
1203
|
+
if (pick < 0.8) {
|
|
1204
|
+
// Translate the whole group.
|
|
1205
|
+
const target = cellIn();
|
|
1206
|
+
const dx = pick < 0.65 ? [1, -1, 0, 0][Math.floor(this.#random() * 4)] : target.col - box.minCol;
|
|
1207
|
+
const dy = pick < 0.65 ? (dx === 0 ? (this.#random() < 0.5 ? 1 : -1) : 0) : target.row - box.minRow;
|
|
1208
|
+
return this.#moveAll(
|
|
1209
|
+
members.map((member) => ({
|
|
1210
|
+
node: member,
|
|
1211
|
+
col: this.#state.col[member] + dx,
|
|
1212
|
+
row: this.#state.row[member] + dy,
|
|
1213
|
+
})),
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
// Mirror the group within its box.
|
|
1217
|
+
const horizontal = this.#random() < 0.5;
|
|
1218
|
+
return this.#moveAll(
|
|
1219
|
+
members.map((member) => ({
|
|
1220
|
+
node: member,
|
|
1221
|
+
col: horizontal ? box.minCol + box.maxCol - this.#state.col[member] : this.#state.col[member],
|
|
1222
|
+
row: horizontal ? this.#state.row[member] : box.minRow + box.maxRow - this.#state.row[member],
|
|
1223
|
+
})),
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
#window(): Bounds {
|
|
1228
|
+
const all = boundsOf(this.#state, this.#index.all) ?? { minCol: 0, maxCol: 0, minRow: 0, maxRow: 0 };
|
|
1229
|
+
return { minCol: all.minCol - 1, maxCol: all.maxCol + 1, minRow: all.minRow - 1, maxRow: all.maxRow + 1 };
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
anneal(iterations: number, hot: number, cold: number) {
|
|
1233
|
+
let current = this.cost();
|
|
1234
|
+
let best = { cost: current, state: clone(this.#state) };
|
|
1235
|
+
const ramp = iterations * 0.7;
|
|
1236
|
+
for (let step = 0; step < iterations; step++) {
|
|
1237
|
+
const temperature = hot * Math.pow(cold / hot, step / iterations);
|
|
1238
|
+
const strictness = Math.min(1, (step / ramp) ** 2);
|
|
1239
|
+
if (strictness !== this.#strictness && (step % 64 === 0 || strictness === 1)) {
|
|
1240
|
+
this.#strictness = strictness;
|
|
1241
|
+
current = this.cost();
|
|
1242
|
+
}
|
|
1243
|
+
const undo = this.#propose(this.#window());
|
|
1244
|
+
if (!undo) {
|
|
1245
|
+
continue;
|
|
1246
|
+
}
|
|
1247
|
+
const next = this.cost();
|
|
1248
|
+
if (next <= current || this.#random() < Math.exp((current - next) / temperature)) {
|
|
1249
|
+
current = next;
|
|
1250
|
+
if (this.#strictness === 1 && current < best.cost) {
|
|
1251
|
+
best = { cost: current, state: clone(this.#state) };
|
|
1252
|
+
}
|
|
1253
|
+
} else {
|
|
1254
|
+
undo();
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
this.#strictness = 1;
|
|
1258
|
+
if (this.cost() > best.cost) {
|
|
1259
|
+
this.#restore(best.state);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/** Steepest descent over single moves and swaps until nothing improves. */
|
|
1264
|
+
polish(passes = 4) {
|
|
1265
|
+
const index = this.#index;
|
|
1266
|
+
let current = this.cost();
|
|
1267
|
+
for (let attempt = 0; attempt < index.relations.length * 4; attempt++) {
|
|
1268
|
+
const undo = this.repair();
|
|
1269
|
+
if (!undo) {
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
const next = this.cost();
|
|
1273
|
+
if (next < current) {
|
|
1274
|
+
current = next;
|
|
1275
|
+
} else {
|
|
1276
|
+
undo();
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
for (let pass = 0; pass < passes; pass++) {
|
|
1280
|
+
let improved = false;
|
|
1281
|
+
const window = this.#window();
|
|
1282
|
+
for (let node = 0; node < index.count; node++) {
|
|
1283
|
+
if (!this.#free(node)) {
|
|
1284
|
+
continue;
|
|
1285
|
+
}
|
|
1286
|
+
let best: { cost: number; col: number; row: number } | undefined;
|
|
1287
|
+
for (let row = window.minRow; row <= window.maxRow; row++) {
|
|
1288
|
+
for (let col = window.minCol; col <= window.maxCol; col++) {
|
|
1289
|
+
const holder = this.#occupied.get(keyOf(col, row));
|
|
1290
|
+
if (
|
|
1291
|
+
holder === node ||
|
|
1292
|
+
(holder !== undefined && (!this.#free(holder) || index.group[holder] !== index.group[node]))
|
|
1293
|
+
) {
|
|
1294
|
+
continue;
|
|
1295
|
+
}
|
|
1296
|
+
const origin = { col: this.#state.col[node], row: this.#state.row[node] };
|
|
1297
|
+
const undo = this.#moveAll(
|
|
1298
|
+
holder === undefined
|
|
1299
|
+
? [{ node, col, row }]
|
|
1300
|
+
: [
|
|
1301
|
+
{ node, col, row },
|
|
1302
|
+
{ node: holder, ...origin },
|
|
1303
|
+
],
|
|
1304
|
+
);
|
|
1305
|
+
if (!undo) {
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
const cost = this.cost();
|
|
1309
|
+
if (cost < current - 1e-9 && (!best || cost < best.cost)) {
|
|
1310
|
+
best = { cost, col, row };
|
|
1311
|
+
}
|
|
1312
|
+
undo();
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
if (best) {
|
|
1316
|
+
const holder = this.#occupied.get(keyOf(best.col, best.row));
|
|
1317
|
+
const origin = { col: this.#state.col[node], row: this.#state.row[node] };
|
|
1318
|
+
this.#moveAll(
|
|
1319
|
+
holder === undefined
|
|
1320
|
+
? [{ node, col: best.col, row: best.row }]
|
|
1321
|
+
: [
|
|
1322
|
+
{ node, col: best.col, row: best.row },
|
|
1323
|
+
{ node: holder, ...origin },
|
|
1324
|
+
],
|
|
1325
|
+
);
|
|
1326
|
+
current = best.cost;
|
|
1327
|
+
improved = true;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
if (!improved) {
|
|
1331
|
+
break;
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
#restore(state: State) {
|
|
1337
|
+
for (let node = 0; node < this.#index.count; node++) {
|
|
1338
|
+
this.#set(node, NaN, NaN);
|
|
1339
|
+
}
|
|
1340
|
+
for (let node = 0; node < this.#index.count; node++) {
|
|
1341
|
+
this.#set(node, state.col[node], state.row[node]);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const iterationsFor = (count: number) => 1500 + 400 * count;
|
|
1347
|
+
|
|
1348
|
+
/** A group's own layout, as cells relative to its top-left, or absolute when a member is pinned. */
|
|
1349
|
+
const layoutGroup = (
|
|
1350
|
+
input: PlaceInput,
|
|
1351
|
+
group: string,
|
|
1352
|
+
seed: number,
|
|
1353
|
+
): { nodes: string[]; cells: Cell[]; anchored: boolean } => {
|
|
1354
|
+
const members = new Set(input.nodes.filter((node) => node.group === group).map((node) => node.id));
|
|
1355
|
+
const sub: PlaceInput = {
|
|
1356
|
+
flow: input.flow,
|
|
1357
|
+
...(input.box ? { box: input.box } : {}),
|
|
1358
|
+
nodes: input.nodes.filter((node) => members.has(node.id)).map(({ id, pin }) => ({ id, pin })),
|
|
1359
|
+
groups: [],
|
|
1360
|
+
relations: input.relations.filter((relation) => members.has(relation.from) && members.has(relation.target)),
|
|
1361
|
+
groupRelations: [],
|
|
1362
|
+
edges: input.edges.filter((edge) => members.has(edge.from) && members.has(edge.to)),
|
|
1363
|
+
};
|
|
1364
|
+
const shape = input.shapes?.get(group);
|
|
1365
|
+
const index = { ...indexOf(sub), ...(shape ? { whole: shape } : {}) };
|
|
1366
|
+
const anchored = index.pinned.some((pinned) => pinned === 1);
|
|
1367
|
+
const search = new Search(index, seed);
|
|
1368
|
+
search.seat(
|
|
1369
|
+
index.ids.map((_, node) => ({
|
|
1370
|
+
nodes: [node],
|
|
1371
|
+
local: [{ col: index.pinned[node] ? index.pinCol[node] : 0, row: index.pinned[node] ? index.pinRow[node] : 0 }],
|
|
1372
|
+
anchored: index.pinned[node] === 1,
|
|
1373
|
+
})),
|
|
1374
|
+
);
|
|
1375
|
+
search.anneal(iterationsFor(index.count), 8, 0.1);
|
|
1376
|
+
search.polish();
|
|
1377
|
+
const cells = index.ids.map((_, node) => ({ col: search.state.col[node], row: search.state.row[node] }));
|
|
1378
|
+
if (anchored) {
|
|
1379
|
+
return { nodes: index.ids, cells, anchored };
|
|
1380
|
+
}
|
|
1381
|
+
const minCol = Math.min(...cells.map((cell) => cell.col));
|
|
1382
|
+
const minRow = Math.min(...cells.map((cell) => cell.row));
|
|
1383
|
+
return {
|
|
1384
|
+
nodes: index.ids,
|
|
1385
|
+
cells: cells.map((cell) => ({ col: cell.col - minCol, row: cell.row - minRow })),
|
|
1386
|
+
anchored,
|
|
1387
|
+
};
|
|
1388
|
+
};
|
|
1389
|
+
|
|
1390
|
+
/** Drops empty rows and columns and moves the top-left to 0,0 — only when nothing is pinned. */
|
|
1391
|
+
const tidy = (index: Index, state: State): Map<string, Cell> => {
|
|
1392
|
+
const cells = new Map<string, Cell>();
|
|
1393
|
+
const pinned = index.pinned.some((value) => value === 1);
|
|
1394
|
+
const cols = [...new Set(state.col)].sort((left, right) => left - right);
|
|
1395
|
+
const rows = [...new Set(state.row)].sort((left, right) => left - right);
|
|
1396
|
+
index.ids.forEach((id, node) =>
|
|
1397
|
+
cells.set(
|
|
1398
|
+
id,
|
|
1399
|
+
pinned
|
|
1400
|
+
? { col: state.col[node], row: state.row[node] }
|
|
1401
|
+
: { col: cols.indexOf(state.col[node]), row: rows.indexOf(state.row[node]) },
|
|
1402
|
+
),
|
|
1403
|
+
);
|
|
1404
|
+
return cells;
|
|
1405
|
+
};
|
|
1406
|
+
|
|
1407
|
+
/** Hard relations the placement could not satisfy, as indices into `input.relations` and `input.groupRelations`. */
|
|
1408
|
+
export const unmet = (
|
|
1409
|
+
input: PlaceInput,
|
|
1410
|
+
cells: Map<string, Cell>,
|
|
1411
|
+
): { relations: number[]; groupRelations: number[] } => {
|
|
1412
|
+
const groupBounds = (group: string): Bounds | undefined => {
|
|
1413
|
+
const members = input.nodes.filter((node) => node.group === group).flatMap((node) => cells.get(node.id) ?? []);
|
|
1414
|
+
return members.length === 0
|
|
1415
|
+
? undefined
|
|
1416
|
+
: {
|
|
1417
|
+
minCol: Math.min(...members.map((cell) => cell.col)),
|
|
1418
|
+
maxCol: Math.max(...members.map((cell) => cell.col)),
|
|
1419
|
+
minRow: Math.min(...members.map((cell) => cell.row)),
|
|
1420
|
+
maxRow: Math.max(...members.map((cell) => cell.row)),
|
|
1421
|
+
};
|
|
1422
|
+
};
|
|
1423
|
+
return {
|
|
1424
|
+
relations: input.relations.flatMap((relation, position) => {
|
|
1425
|
+
const from = cells.get(relation.from);
|
|
1426
|
+
const target = cells.get(relation.target);
|
|
1427
|
+
return !relation.soft && from && target && !holds(relation.kind, from, target) ? [position] : [];
|
|
1428
|
+
}),
|
|
1429
|
+
groupRelations: input.groupRelations.flatMap((relation, position) => {
|
|
1430
|
+
const from = groupBounds(relation.from);
|
|
1431
|
+
const target = groupBounds(relation.target);
|
|
1432
|
+
return !relation.soft && from && target && !holdsBetween(relation.kind, from, target) ? [position] : [];
|
|
1433
|
+
}),
|
|
1434
|
+
};
|
|
1435
|
+
};
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* Places every node on the grid. Returns up to `keep` distinct placements, best first, so the caller
|
|
1439
|
+
* can route each and let the real measurements choose.
|
|
1440
|
+
*/
|
|
1441
|
+
export const place = (input: PlaceInput, { restarts = 6, seeds = [], keep = 3 }: PlaceOptions = {}): Placed[] => {
|
|
1442
|
+
const index = indexOf(input);
|
|
1443
|
+
if (index.count === 0) {
|
|
1444
|
+
return [{ cells: new Map(), cost: 0 }];
|
|
1445
|
+
}
|
|
1446
|
+
const results: { state: State; cost: number }[] = [];
|
|
1447
|
+
for (let attempt = 0; attempt < restarts; attempt++) {
|
|
1448
|
+
const seed = attempt + 1;
|
|
1449
|
+
const units = [
|
|
1450
|
+
...input.groups.map((group, position) => {
|
|
1451
|
+
const local = layoutGroup(input, group, seed * 31 + position);
|
|
1452
|
+
const byId = new Map(index.ids.map((id, node) => [id, node]));
|
|
1453
|
+
return { nodes: local.nodes.flatMap((id) => byId.get(id) ?? []), local: local.cells, anchored: local.anchored };
|
|
1454
|
+
}),
|
|
1455
|
+
...index.ids.flatMap((_, node) =>
|
|
1456
|
+
index.group[node] >= 0
|
|
1457
|
+
? []
|
|
1458
|
+
: [
|
|
1459
|
+
{
|
|
1460
|
+
nodes: [node],
|
|
1461
|
+
local: [
|
|
1462
|
+
{
|
|
1463
|
+
col: index.pinned[node] ? index.pinCol[node] : 0,
|
|
1464
|
+
row: index.pinned[node] ? index.pinRow[node] : 0,
|
|
1465
|
+
},
|
|
1466
|
+
],
|
|
1467
|
+
anchored: index.pinned[node] === 1,
|
|
1468
|
+
},
|
|
1469
|
+
],
|
|
1470
|
+
),
|
|
1471
|
+
].filter((unit) => unit.nodes.length > 0);
|
|
1472
|
+
const search = new Search(index, seed);
|
|
1473
|
+
search.seat(units);
|
|
1474
|
+
search.anneal(iterationsFor(index.count), 8, 0.1);
|
|
1475
|
+
search.polish();
|
|
1476
|
+
results.push({ state: clone(search.state), cost: search.cost() });
|
|
1477
|
+
}
|
|
1478
|
+
seeds.forEach((cells, position) => {
|
|
1479
|
+
const start: State = {
|
|
1480
|
+
col: Float64Array.from(index.ids, (id, node) =>
|
|
1481
|
+
index.pinned[node] ? index.pinCol[node] : (cells.get(id)?.col ?? NaN),
|
|
1482
|
+
),
|
|
1483
|
+
row: Float64Array.from(index.ids, (id, node) =>
|
|
1484
|
+
index.pinned[node] ? index.pinRow[node] : (cells.get(id)?.row ?? NaN),
|
|
1485
|
+
),
|
|
1486
|
+
};
|
|
1487
|
+
// A seed that stacks two nodes is unusable as given; the free search covers it.
|
|
1488
|
+
const keys = new Set(index.ids.map((_, node) => keyOf(start.col[node], start.row[node])));
|
|
1489
|
+
if (keys.size !== index.count || start.col.some(Number.isNaN)) {
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
const search = new Search(index, 1000 + position, start);
|
|
1493
|
+
results.push({ state: clone(search.state), cost: search.cost() });
|
|
1494
|
+
search.anneal(iterationsFor(index.count), 2, 0.05);
|
|
1495
|
+
search.polish();
|
|
1496
|
+
results.push({ state: clone(search.state), cost: search.cost() });
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
const distinct = new Map<string, Placed>();
|
|
1500
|
+
for (const { state, cost } of results.sort((left, right) => left.cost - right.cost)) {
|
|
1501
|
+
const cells = tidy(index, state);
|
|
1502
|
+
const key = [...cells].map(([id, cell]) => `${id}@${cell.col},${cell.row}`).join(' ');
|
|
1503
|
+
if (!distinct.has(key)) {
|
|
1504
|
+
distinct.set(key, { cells, cost });
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
return [...distinct.values()].slice(0, keep);
|
|
1508
|
+
};
|