@mark1russell7/highways 0.0.0-bootstrap.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,214 @@
1
+ import {
2
+ bendCount,
3
+ dist,
4
+ frechet,
5
+ inflectionCount,
6
+ polylineLength,
7
+ segmentBoxDistance,
8
+ segmentsCross,
9
+ type Box,
10
+ type Point,
11
+ } from "../geom.ts";
12
+ import { rng } from "../fixtures.ts";
13
+ import { Grid } from "../grid.ts";
14
+ import { nodeIndex, type Graph, type Node, type Result } from "../model.ts";
15
+
16
+ /** The quality of a result. Lower is better for each value except `clearance`. */
17
+ export interface Quality {
18
+ /** The length of all drawn lines. A segment that several routes share counts one time. */
19
+ readonly ink: number;
20
+ /** The number of points where two drawn segments cross. */
21
+ readonly crossings: number;
22
+ /** This value is true when `crossings` is an estimate from a sample of the segments. */
23
+ readonly crossingsEstimated: boolean;
24
+ /** The mean number of bends of a route. */
25
+ readonly bends: number;
26
+ /** The mean number of inflections of a route. */
27
+ readonly inflections: number;
28
+ /** The mean distance from a route to the nearest node that is not one of its ends, up to `CLEARANCE_CAP`. */
29
+ readonly clearance: number;
30
+ /** The share of the routes that come closer than 4 units to a node that is not one of their ends. */
31
+ readonly tight: number;
32
+ /** The mean ratio of the length of a route to the distance between its ends. */
33
+ readonly detour: number;
34
+ /** The mean angle, in degrees, between the first or last segment of a route and the normal of the side of its node. */
35
+ readonly plugAngle: number;
36
+ }
37
+
38
+ /** The clearance stops at this distance. */
39
+ export const CLEARANCE_CAP = 40;
40
+ const TIGHT = 4;
41
+ const EXACT_CROSSINGS = 20_000;
42
+ const SAMPLE = 2_000;
43
+
44
+ const key = (p: Point): string => `${Math.round(p.x * 8)},${Math.round(p.y * 8)}`;
45
+
46
+ /** This function measures the quality of a result. */
47
+ export function measureQuality(graph: Graph, result: Result): Quality {
48
+ const byId = nodeIndex(graph);
49
+ const nodes = new Grid<Node>(180);
50
+ for (const n of graph.nodes) nodes.add(n, n.box);
51
+
52
+ // The drawn segments: one for each distinct pair of ends.
53
+ const segs: [Point, Point][] = [];
54
+ const seen = new Set<string>();
55
+ let bends = 0;
56
+ let infl = 0;
57
+ let clear = 0;
58
+ let tight = 0;
59
+ let detour = 0;
60
+ let detourN = 0;
61
+ let angle = 0;
62
+ let angleN = 0;
63
+ for (const r of result.routes) {
64
+ const p = r.points;
65
+ for (let i = 1; i < p.length; i++) {
66
+ const ka = key(p[i - 1]!);
67
+ const kb = key(p[i]!);
68
+ if (ka === kb) continue;
69
+ const k = ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`;
70
+ if (seen.has(k)) continue;
71
+ seen.add(k);
72
+ segs.push([p[i - 1]!, p[i]!]);
73
+ }
74
+ bends += bendCount(p);
75
+ infl += inflectionCount(p);
76
+ const ends = endNodes(graph, byId, r.edge);
77
+ let c = CLEARANCE_CAP;
78
+ for (let i = 1; i < p.length; i++) {
79
+ const a = p[i - 1]!;
80
+ const b = p[i]!;
81
+ nodes.querySegment(a, b, c, (n) => {
82
+ if (n !== ends.source && n !== ends.target) c = Math.min(c, segmentBoxDistance(a, b, n.box));
83
+ });
84
+ }
85
+ clear += c;
86
+ if (c < TIGHT) tight++;
87
+ if (p.length >= 2) {
88
+ const d = dist(p[0]!, p[p.length - 1]!);
89
+ if (d > 1) {
90
+ detour += polylineLength(p) / d;
91
+ detourN++;
92
+ }
93
+ for (const [at, next, node] of [
94
+ [p[0]!, p[1]!, ends.source],
95
+ [p[p.length - 1]!, p[p.length - 2]!, ends.target],
96
+ ] as const) {
97
+ if (!node) continue;
98
+ const a = sideAngle(node.box, at, next);
99
+ if (a !== undefined) {
100
+ angle += a;
101
+ angleN++;
102
+ }
103
+ }
104
+ }
105
+ }
106
+ const n = Math.max(1, result.routes.length);
107
+ let ink = 0;
108
+ for (const [a, b] of segs) ink += dist(a, b);
109
+ const [crossings, crossingsEstimated] = countCrossings(segs);
110
+ return {
111
+ ink,
112
+ crossings,
113
+ crossingsEstimated,
114
+ bends: bends / n,
115
+ inflections: infl / n,
116
+ clearance: clear / n,
117
+ tight: tight / n,
118
+ detour: detourN ? detour / detourN : 1,
119
+ plugAngle: angleN ? angle / angleN : 0,
120
+ };
121
+ }
122
+
123
+ interface Ends {
124
+ readonly source: Node | undefined;
125
+ readonly target: Node | undefined;
126
+ }
127
+
128
+ function endNodes(graph: Graph, byId: Map<string, Node>, edgeId: string): Ends {
129
+ const e = edgeIndex(graph).get(edgeId);
130
+ return { source: e && byId.get(e.source.node), target: e && byId.get(e.target.node) };
131
+ }
132
+
133
+ const edgeIndexes = new WeakMap<Graph, Map<string, Graph["edges"][number]>>();
134
+ function edgeIndex(graph: Graph): Map<string, Graph["edges"][number]> {
135
+ let m = edgeIndexes.get(graph);
136
+ if (!m) {
137
+ m = new Map();
138
+ for (const e of graph.edges) m.set(e.id, e);
139
+ edgeIndexes.set(graph, m);
140
+ }
141
+ return m;
142
+ }
143
+
144
+ /**
145
+ * This function gives an angle in degrees: the angle between the segment from `at` to `next` and the outward normal.
146
+ * The normal is the normal of the side of the box nearest to `at`. A route that starts inside the box (an interior route) gives no angle.
147
+ */
148
+ export function sideAngle(box: Box, at: Point, next: Point): number | undefined {
149
+ const d = [
150
+ [Math.abs(at.y - box.y), 0, -1],
151
+ [Math.abs(at.x - (box.x + box.w)), 1, 0],
152
+ [Math.abs(at.y - (box.y + box.h)), 0, 1],
153
+ [Math.abs(at.x - box.x), -1, 0],
154
+ ].sort((a, b) => a[0]! - b[0]!)[0]!;
155
+ if (d[0]! > 1) return undefined;
156
+ const vx = next.x - at.x;
157
+ const vy = next.y - at.y;
158
+ const l = Math.hypot(vx, vy);
159
+ if (l === 0) return undefined;
160
+ const cos = Math.max(-1, Math.min(1, (vx * d[1]! + vy * d[2]!) / l));
161
+ return (Math.acos(cos) * 180) / Math.PI;
162
+ }
163
+
164
+ /**
165
+ * This function counts the crossings of a set of segments with a grid of buckets.
166
+ * Above 20 000 segments, it counts the crossings of a seeded sample of 2 000 segments and scales the count.
167
+ */
168
+ export function countCrossings(segs: readonly (readonly [Point, Point])[]): [number, boolean] {
169
+ if (segs.length < 2) return [0, false];
170
+ const len = segs.reduce((s, [a, b]) => s + dist(a, b), 0) / segs.length;
171
+ const grid = new Grid<number>(Math.max(20, len));
172
+ segs.forEach(([a, b], i) => grid.addSegment(i, a, b));
173
+ const exact = segs.length <= EXACT_CROSSINGS;
174
+ const R = rng(segs.length);
175
+ const pick = exact ? segs.map((_, i) => i) : Array.from({ length: SAMPLE }, () => Math.floor(R() * segs.length));
176
+ let count = 0;
177
+ for (const i of pick) {
178
+ const [a, b] = segs[i]!;
179
+ grid.querySegment(a, b, 0, (j) => {
180
+ if (exact && j <= i) return;
181
+ if (j === i) return;
182
+ const [c, d] = segs[j]!;
183
+ if (segmentsCross(a, b, c, d)) count++;
184
+ });
185
+ }
186
+ return exact ? [count, false] : [Math.round((count * segs.length) / (2 * pick.length)), true];
187
+ }
188
+
189
+ /** The change between two results of the same graph: the Fréchet distance between the two routes of each edge. */
190
+ export interface Stability {
191
+ readonly mean: number;
192
+ readonly max: number;
193
+ /** The share of the routes that moved more than 1 unit. */
194
+ readonly moved: number;
195
+ }
196
+
197
+ /** This function measures how much the routes changed between two results. */
198
+ export function measureStability(before: Result, after: Result): Stability {
199
+ const prev = new Map(before.routes.map((r) => [r.edge, r.points] as const));
200
+ let sum = 0;
201
+ let max = 0;
202
+ let moved = 0;
203
+ let n = 0;
204
+ for (const r of after.routes) {
205
+ const p = prev.get(r.edge);
206
+ if (!p) continue;
207
+ const f = frechet(p, r.points);
208
+ sum += f;
209
+ max = Math.max(max, f);
210
+ if (f > 1) moved++;
211
+ n++;
212
+ }
213
+ return { mean: n ? sum / n : 0, max, moved: n ? moved / n : 0 };
214
+ }
@@ -0,0 +1,103 @@
1
+ import { makeFixture, type FixtureSpec } from "../fixtures.ts";
2
+ import type { Graph, Result, Router } from "../model.ts";
3
+ import { checkProperties, type Violation } from "./properties.ts";
4
+ import { measureQuality, type Quality } from "./quality.ts";
5
+
6
+ // The core runs in any host, so it reads the clock without the types of Node or the browser.
7
+ const now = (): number => (globalThis as { performance?: { now(): number } }).performance?.now() ?? Date.now();
8
+
9
+ /** One row of the benchmark: one router on one fixture. */
10
+ export interface BenchRow {
11
+ readonly router: string;
12
+ readonly fixture: string;
13
+ readonly nodes: number;
14
+ readonly edges: number;
15
+ /** The median time of one run, in milliseconds. */
16
+ readonly ms: number;
17
+ /** This value is false when two runs on the same input gave different results. */
18
+ readonly deterministic: boolean;
19
+ readonly violations: number;
20
+ /** The first violations, for the report. */
21
+ readonly sample: readonly Violation[];
22
+ readonly quality: Quality;
23
+ }
24
+
25
+ /** The options of a benchmark run. */
26
+ export interface BenchOptions {
27
+ /** The number of timed routes of each fixture by each router. The default is 3. */
28
+ readonly repeat?: number;
29
+ }
30
+
31
+ /** This function routes each fixture with each router, checks the hard rules and measures the quality. */
32
+ export function runBenchmark(routers: readonly Router[], fixtures: readonly FixtureSpec[], options: BenchOptions = {}): BenchRow[] {
33
+ const rows: BenchRow[] = [];
34
+ for (const spec of fixtures) {
35
+ const graph = makeFixture(spec);
36
+ for (const router of routers) if (graph.nodes.length <= (router.maxNodes ?? Infinity)) rows.push(benchOne(router, spec.name, graph, options.repeat ?? 3));
37
+ }
38
+ return rows;
39
+ }
40
+
41
+ /**
42
+ * This function tells if two values hold the same data, as JSON sees it. It does not make a string,
43
+ * because the result of a large graph can be longer than the longest string.
44
+ */
45
+ export function sameData(a: unknown, b: unknown): boolean {
46
+ if (a === b) return true;
47
+ if (typeof a === "number" && typeof b === "number") return Number.isNaN(a) && Number.isNaN(b);
48
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
49
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
50
+ if (Array.isArray(a)) {
51
+ const y = b as unknown[];
52
+ if (a.length !== y.length) return false;
53
+ for (let i = 0; i < a.length; i++) if (!sameData(a[i], y[i])) return false;
54
+ return true;
55
+ }
56
+ const x = a as Record<string, unknown>;
57
+ const y = b as Record<string, unknown>;
58
+ const kx = Object.keys(x).filter((k) => x[k] !== undefined);
59
+ const ky = Object.keys(y).filter((k) => y[k] !== undefined);
60
+ if (kx.length !== ky.length) return false;
61
+ for (const k of kx) if (!sameData(x[k], y[k])) return false;
62
+ return true;
63
+ }
64
+
65
+ /** This function routes one graph with one router and measures the result. */
66
+ export function benchOne(router: Router, fixture: string, graph: Graph, repeat = 3): BenchRow {
67
+ const times: number[] = [];
68
+ let first: Result | undefined;
69
+ let deterministic = true;
70
+ for (let i = 0; i < Math.max(2, repeat); i++) {
71
+ const t0 = now();
72
+ const r = router.route(graph);
73
+ times.push(now() - t0);
74
+ if (!first) first = r;
75
+ else if (i === 1 && !sameData(r, first)) deterministic = false;
76
+ }
77
+ times.sort((a, b) => a - b);
78
+ const result = first!;
79
+ const all = checkProperties(graph, result, 1_000_000);
80
+ return {
81
+ router: router.name,
82
+ fixture,
83
+ nodes: graph.nodes.length,
84
+ edges: graph.edges.length,
85
+ ms: times[Math.floor(times.length / 2)]!,
86
+ deterministic,
87
+ violations: all.length,
88
+ sample: all.slice(0, 5),
89
+ quality: measureQuality(graph, result),
90
+ };
91
+ }
92
+
93
+ /** This function writes the rows as a Markdown table. */
94
+ export function formatTable(rows: readonly BenchRow[]): string {
95
+ const head = "| router | fixture | nodes | edges | ms | same twice | violations | ink | crossings | bends | inflections | clearance | tight | detour | plug angle |";
96
+ const line = "|---|---|---:|---:|---:|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|";
97
+ const f = (v: number, d = 2): string => (Number.isInteger(v) ? String(v) : v.toFixed(d));
98
+ const body = rows.map((r) => {
99
+ const q = r.quality;
100
+ return `| ${r.router} | ${r.fixture} | ${r.nodes} | ${r.edges} | ${f(r.ms, 1)} | ${r.deterministic ? "yes" : "NO"} | ${r.violations} | ${Math.round(q.ink)} | ${q.crossingsEstimated ? "≈" : ""}${q.crossings} | ${f(q.bends)} | ${f(q.inflections)} | ${f(q.clearance, 1)} | ${f(q.tight)} | ${f(q.detour)} | ${f(q.plugAngle, 1)} |`;
101
+ });
102
+ return [head, line, ...body].join("\n");
103
+ }
package/src/curves.ts ADDED
@@ -0,0 +1,89 @@
1
+ import { dist, type Point } from "./geom.ts";
2
+
3
+ /** The options of the inflection rule. */
4
+ export interface InflectionOptions {
5
+ /** The half width of the axis and diagonal bands, in radians. The default is 7°. */
6
+ readonly tol?: number;
7
+ /** The number of segments of the curve. The default is 24. */
8
+ readonly samples?: number;
9
+ /** The largest angle between the tangent and the chord, in radians. The default is 1.35 (approximately 77°). */
10
+ readonly maxAngle?: number;
11
+ }
12
+
13
+ /**
14
+ * This function gives the number of inflections that the rule asks for a chord at the angle `angle` (radians).
15
+ * It gives 0 near an axis, 1 near a diagonal and 2 between them. The bands are `tol` wide on each side.
16
+ */
17
+ export function inflectionSector(angle: number, tol: number = (7 * Math.PI) / 180): 0 | 1 | 2 {
18
+ const q = Math.PI / 2;
19
+ const mod = ((angle % q) + q) % q;
20
+ if (mod < tol || q - mod < tol) return 0;
21
+ if (Math.abs(mod - q / 2) < tol) return 1;
22
+ return 2;
23
+ }
24
+
25
+ /**
26
+ * This function draws the curve of the inflection rule from the plug `p0` to `target`.
27
+ * The curve leaves `p0` along `normal`. In the frame of the chord, its side offset f has f(0) = f(L) = 0 and f'(0) = tan θ.
28
+ * Here θ is the angle from the chord to the normal. A polynomial of degree 2, 3 or 4 gives 0, 1 or 2 inflections,
29
+ * at 1/2, or at 1/3 and 2/3, of the chord.
30
+ */
31
+ export function inflectionCurve(p0: Point, normal: Point, target: Point, options: InflectionOptions = {}): Point[] {
32
+ const L = dist(p0, target);
33
+ if (L === 0) return [p0, target];
34
+ const ux = (target.x - p0.x) / L;
35
+ const uy = (target.y - p0.y) / L;
36
+ const k = inflectionSector(Math.atan2(uy, ux), options.tol);
37
+ const maxAngle = options.maxAngle ?? 1.35;
38
+ const th = -Math.atan2(normal.x * uy - normal.y * ux, normal.x * ux + normal.y * uy);
39
+ const d = Math.tan(Math.max(-maxAngle, Math.min(maxAngle, th)));
40
+ const f =
41
+ k === 0
42
+ ? (s: number): number => (-d / L) * s * s + d * s
43
+ : k === 1
44
+ ? (s: number): number => ((12 * d) / (L * L)) * (s ** 3 / 6 - (L * s * s) / 4) + d * s
45
+ : (s: number): number => ((-36 * d) / L ** 3) * (s ** 4 / 12 - (L * s ** 3) / 6 + (L * L * s * s) / 9) + d * s;
46
+ const n = Math.max(2, options.samples ?? 24);
47
+ const out: Point[] = [];
48
+ for (let i = 0; i <= n; i++) {
49
+ if (i === n) {
50
+ out.push(target);
51
+ break;
52
+ }
53
+ const s = (L * i) / n;
54
+ const o = f(s);
55
+ out.push({ x: p0.x + ux * s - uy * o, y: p0.y + uy * s + ux * o });
56
+ }
57
+ out[0] = p0;
58
+ return out;
59
+ }
60
+
61
+ /**
62
+ * This function rounds each inner vertex of a polyline with a quadratic Bézier curve.
63
+ * The curve starts and ends at most `radius` from the vertex, and at most half of each segment. The ends of the polyline stay.
64
+ */
65
+ export function roundCorners(p: readonly Point[], radius: number, steps = 4): Point[] {
66
+ if (p.length < 3 || radius <= 0) return [...p];
67
+ const out: Point[] = [p[0]!];
68
+ for (let i = 1; i + 1 < p.length; i++) {
69
+ const a = p[i - 1]!;
70
+ const v = p[i]!;
71
+ const b = p[i + 1]!;
72
+ const la = dist(a, v);
73
+ const lb = dist(v, b);
74
+ const r = Math.min(radius, la / 2, lb / 2);
75
+ if (r <= 0) {
76
+ out.push(v);
77
+ continue;
78
+ }
79
+ const p1 = { x: v.x + ((a.x - v.x) * r) / la, y: v.y + ((a.y - v.y) * r) / la };
80
+ const p2 = { x: v.x + ((b.x - v.x) * r) / lb, y: v.y + ((b.y - v.y) * r) / lb };
81
+ for (let k = 0; k <= steps; k++) {
82
+ const t = k / steps;
83
+ const u = 1 - t;
84
+ out.push({ x: u * u * p1.x + 2 * u * t * v.x + t * t * p2.x, y: u * u * p1.y + 2 * u * t * v.y + t * t * p2.y });
85
+ }
86
+ }
87
+ out.push(p[p.length - 1]!);
88
+ return out;
89
+ }
@@ -0,0 +1,181 @@
1
+ import type { Box, Point } from "./geom.ts";
2
+ import { Grid } from "./grid.ts";
3
+ import type { Edge, Graph, Node, Port, Side } from "./model.ts";
4
+
5
+ /** This function gives a seeded random source with values in [0, 1) (mulberry32). */
6
+ export function rng(seed: number): () => number {
7
+ let a = seed >>> 0;
8
+ return () => {
9
+ a = (a + 0x6d2b79f5) >>> 0;
10
+ let t = a;
11
+ t = Math.imul(t ^ (t >>> 15), t | 1);
12
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
13
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
14
+ };
15
+ }
16
+
17
+ /**
18
+ * The ways a fixture puts its nodes and picks its edges. In `collections`, each cluster is one collection,
19
+ * and some nodes link to many nodes of one other collection (a fan-out).
20
+ */
21
+ export type FixtureKind = "uniform" | "clustered" | "grid" | "hubs" | "collections";
22
+
23
+ /** The parameters of a generated graph. */
24
+ export interface FixtureSpec {
25
+ readonly name: string;
26
+ readonly kind: FixtureKind;
27
+ readonly nodes: number;
28
+ /** The number of edges is about `nodes × edgesPerNode`. */
29
+ readonly edgesPerNode: number;
30
+ readonly seed: number;
31
+ /** This share of the edges, from 0 to 1, joins a node near the source. */
32
+ readonly locality?: number;
33
+ /** This share of the nodes, from 0 to 1, has one fixed plug that its outgoing edges use. */
34
+ readonly fixedPorts?: number;
35
+ }
36
+
37
+ /** The fixtures of the benchmark, from 10 to 100 000 nodes. */
38
+ export const FIXTURES: readonly FixtureSpec[] = [
39
+ { name: "tiny-uniform", kind: "uniform", nodes: 10, edgesPerNode: 1.5, seed: 1 },
40
+ { name: "small-clustered", kind: "clustered", nodes: 100, edgesPerNode: 2, seed: 2, fixedPorts: 0.2 },
41
+ { name: "grid-400", kind: "grid", nodes: 400, edgesPerNode: 2, seed: 3 },
42
+ { name: "mid-uniform", kind: "uniform", nodes: 1000, edgesPerNode: 2, seed: 4 },
43
+ { name: "mid-clustered", kind: "clustered", nodes: 1000, edgesPerNode: 3, seed: 5, fixedPorts: 0.2 },
44
+ { name: "mid-hubs", kind: "hubs", nodes: 1000, edgesPerNode: 2, seed: 6 },
45
+ { name: "mid-fanout", kind: "collections", nodes: 1000, edgesPerNode: 2, seed: 9 },
46
+ { name: "large-clustered", kind: "clustered", nodes: 10_000, edgesPerNode: 3, seed: 7 },
47
+ { name: "huge-clustered", kind: "clustered", nodes: 100_000, edgesPerNode: 2, seed: 8 },
48
+ ];
49
+
50
+ const COLLECTIONS = ["users", "modes", "speeds", "scores", "teams"] as const;
51
+ const SIDES: readonly Side[] = ["north", "east", "south", "west"];
52
+ const GAP = 12;
53
+ const SPACING = 90;
54
+
55
+ /**
56
+ * This function makes the graph of a fixture. The same spec always gives the same graph.
57
+ * Node boxes do not overlap, and a gap of 12 units stays between them.
58
+ */
59
+ export function makeFixture(spec: FixtureSpec): Graph {
60
+ const R = rng(spec.seed * 2654435761 + spec.nodes);
61
+ const gauss = (): number => Math.sqrt(-2 * Math.log(R() + 1e-12)) * Math.cos(2 * Math.PI * R());
62
+ const side = Math.sqrt(spec.nodes) * SPACING;
63
+ const grid = new Grid<Box>(SPACING);
64
+ const nodes: Node[] = [];
65
+ const clusterOf: number[] = [];
66
+ const free = (b: Box): boolean => {
67
+ let ok = true;
68
+ const g: Box = { x: b.x - GAP, y: b.y - GAP, w: b.w + 2 * GAP, h: b.h + 2 * GAP };
69
+ grid.query(g, (o) => {
70
+ if (ok && o.x < g.x + g.w && g.x < o.x + o.w && o.y < g.y + g.h && g.y < o.y + o.h) ok = false;
71
+ });
72
+ return ok;
73
+ };
74
+ const centers: Point[] = Array.from({ length: Math.max(3, Math.round(Math.sqrt(spec.nodes) / 3)) }, () => ({
75
+ x: side * (0.1 + 0.8 * R()),
76
+ y: side * (0.1 + 0.8 * R()),
77
+ }));
78
+ const spread = side / (2.5 * Math.sqrt(centers.length));
79
+ const cols = Math.ceil(Math.sqrt(spec.nodes));
80
+ let tries = 0;
81
+ while (nodes.length < spec.nodes && tries++ < spec.nodes * 200) {
82
+ const w = 30 + Math.floor(R() * 30);
83
+ const h = 18 + Math.floor(R() * 12);
84
+ let cx: number;
85
+ let cy: number;
86
+ let cluster = -1;
87
+ if (spec.kind === "grid") {
88
+ const i = nodes.length;
89
+ cx = ((i % cols) + 0.5) * SPACING;
90
+ cy = (Math.floor(i / cols) + 0.5) * SPACING;
91
+ } else if (spec.kind === "clustered" || spec.kind === "collections") {
92
+ cluster = Math.floor(R() * centers.length);
93
+ const c = centers[cluster]!;
94
+ cx = c.x + gauss() * spread;
95
+ cy = c.y + gauss() * spread;
96
+ } else {
97
+ cx = R() * side;
98
+ cy = R() * side;
99
+ }
100
+ const box: Box = { x: Math.round(cx - w / 2), y: Math.round(cy - h / 2), w, h };
101
+ if (spec.kind !== "grid" && !free(box)) continue;
102
+ grid.add(box, box);
103
+ clusterOf.push(cluster);
104
+ const id = `n${nodes.length}`;
105
+ if (R() < (spec.fixedPorts ?? 0)) {
106
+ const s = SIDES[Math.floor(R() * 4)]!;
107
+ const t = 0.2 + 0.6 * R();
108
+ const at: Point =
109
+ s === "north" ? { x: w * t, y: 0 } : s === "south" ? { x: w * t, y: h } : s === "west" ? { x: 0, y: h * t } : { x: w, y: h * t };
110
+ const ports: Port[] = [{ id: "out", at: { x: Math.round(at.x), y: Math.round(at.y) } }];
111
+ nodes.push({ id, box, ports });
112
+ } else nodes.push({ id, box });
113
+ }
114
+ return { nodes, edges: makeEdges(spec, nodes, clusterOf, R) };
115
+ }
116
+
117
+ function makeEdges(spec: FixtureSpec, nodes: readonly Node[], clusterOf: readonly number[], R: () => number): Edge[] {
118
+ const n = nodes.length;
119
+ if (n < 2) return [];
120
+ const locality = spec.locality ?? 0.65;
121
+ const near = new Grid<number>(SPACING * 2);
122
+ nodes.forEach((v, i) => near.add(i, v.box));
123
+ const kindOf =
124
+ spec.kind === "collections"
125
+ ? clusterOf.map((c) => `${COLLECTIONS[c % COLLECTIONS.length]!}-${c}`)
126
+ : nodes.map(() => COLLECTIONS[Math.floor(R() * COLLECTIONS.length)]!);
127
+ // Hubs: a few nodes get most of the far edges (preferential attachment by a power law).
128
+ const pickFar = (): number => (spec.kind === "hubs" ? Math.floor(n * R() ** 3) : Math.floor(R() * n));
129
+ const target = Math.round(n * spec.edgesPerNode);
130
+ const seen = new Set<string>();
131
+ const edges: Edge[] = [];
132
+ const add = (u: number, v: number): void => {
133
+ const key = u < v ? `${u}-${v}` : `${v}-${u}`;
134
+ if (u === v || seen.has(key)) return;
135
+ seen.add(key);
136
+ const src = nodes[u]!;
137
+ edges.push({
138
+ id: `e${edges.length}`,
139
+ source: src.ports ? { node: src.id, port: "out" } : { node: src.id },
140
+ target: { node: nodes[v]!.id },
141
+ data: { collection: kindOf[v]! },
142
+ });
143
+ };
144
+ if (spec.kind === "collections") {
145
+ // Six of ten edges are fan-outs: a node links to 4 to 15 nodes of one other cluster.
146
+ const members = new Map<number, number[]>();
147
+ clusterOf.forEach((c, i) => {
148
+ const list = members.get(c);
149
+ if (list) list.push(i);
150
+ else members.set(c, [i]);
151
+ });
152
+ const clusters = [...members.keys()].sort((a, c) => a - c);
153
+ let rounds = 0;
154
+ while (edges.length < target * 0.6 && clusters.length > 1 && rounds++ < target) {
155
+ const u = Math.floor(R() * n);
156
+ const others = clusters.filter((c) => c !== clusterOf[u]);
157
+ const pool = members.get(others[Math.floor(R() * others.length)]!)!;
158
+ const fan = 4 + Math.floor(R() * 12);
159
+ for (let i = 0; i < fan; i++) add(u, pool[Math.floor(R() * pool.length)]!);
160
+ }
161
+ }
162
+ let tries = 0;
163
+ while (edges.length < target && tries++ < target * 30) {
164
+ const u = Math.floor(R() * n);
165
+ let v = -1;
166
+ if (R() < locality) {
167
+ const b = nodes[u]!.box;
168
+ const cand: number[] = [];
169
+ for (let r = SPACING; cand.length < 8 && r < SPACING * 64; r *= 2) {
170
+ cand.length = 0;
171
+ near.query({ x: b.x - r, y: b.y - r, w: b.w + 2 * r, h: b.h + 2 * r }, (i) => {
172
+ if (i !== u) cand.push(i);
173
+ });
174
+ }
175
+ cand.sort((a, c) => a - c);
176
+ if (cand.length) v = cand[Math.floor(R() * cand.length)]!;
177
+ } else v = pickFar();
178
+ if (v >= 0) add(u, v);
179
+ }
180
+ return edges;
181
+ }