@mark1russell7/highways 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +69 -1
  2. package/build/index.d.ts +420 -9
  3. package/build/index.d.ts.map +1 -1
  4. package/build/index.js +4361 -2002
  5. package/build/index.js.map +1 -1
  6. package/package.json +2 -2
  7. package/src/bench/compare.ts +13 -1
  8. package/src/bench/measures/curves.ts +55 -1
  9. package/src/bench/measures/groups.ts +71 -0
  10. package/src/bench/measures/index.ts +1 -0
  11. package/src/bench/measures/levels.ts +24 -0
  12. package/src/bench/measures/room.ts +106 -1
  13. package/src/bench/measures/symmetry.ts +114 -0
  14. package/src/bench/screen/exercise.ts +5 -0
  15. package/src/bench/screen/forms.ts +14 -3
  16. package/src/bench/screen/measures.ts +40 -3
  17. package/src/bench/screen/screen.ts +40 -5
  18. package/src/bench/suite.ts +10 -2
  19. package/src/highways/fixtures.test-util.ts +13 -0
  20. package/src/highways/follow.ts +3 -2
  21. package/src/highways/free-lines.ts +141 -0
  22. package/src/highways/gaps/index.ts +85 -0
  23. package/src/highways/index.ts +6 -0
  24. package/src/highways/interchanges/index.ts +231 -0
  25. package/src/highways/search/index.ts +170 -0
  26. package/src/highways/snap.ts +4 -2
  27. package/src/highways/steiner/index.ts +215 -0
  28. package/src/highways/streets/index.ts +78 -0
  29. package/src/model.ts +127 -4
  30. package/src/routers/attraction/index.ts +61 -0
  31. package/src/routers/corners.ts +15 -8
  32. package/src/routers/frames.ts +136 -0
  33. package/src/routers/index.ts +3 -1
  34. package/src/routers/plugs.ts +3 -2
  35. package/src/routers/sections.ts +3 -2
  36. package/src/routers/shared.ts +70 -7
  37. package/src/routers/straight.ts +3 -2
  38. package/src/routers/trunks.ts +3 -2
  39. package/src/strategies/builtins.ts +56 -1
  40. package/src/strategies/schemas.ts +69 -0
package/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "directory": "packages/core"
10
10
  },
11
11
  "homepage": "https://mark1russell7.github.io/highways/",
12
- "version": "0.9.0",
12
+ "version": "0.9.2",
13
13
  "type": "module",
14
14
  "main": "./build/index.js",
15
15
  "types": "./build/index.d.ts",
@@ -29,7 +29,7 @@
29
29
  "access": "public"
30
30
  },
31
31
  "dependencies": {
32
- "@mark1russell7/layout-contract": "^0.2.14"
32
+ "@mark1russell7/layout-contract": "^0.2.15"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@mark1russell7/cue": "github:mark1russell7/cue",
@@ -235,7 +235,19 @@ export function stepRouterStrategy(strategy: Strategy<RoutedStep>): Strategy<"ro
235
235
  });
236
236
  if (strategy.step === "plugs") return own(strategy.maxNodes ?? 10_000, (graph, o) => cornersRouter({ name, plug: strategy as Strategy<"plugs">, plugOptions: o }).route(graph));
237
237
  if (strategy.step === "levels") return own(strategy.maxNodes ?? 100_000, (graph, o) => sectionsRouter({ name, level: strategy as Strategy<"levels">, levelOptions: o }).route(graph));
238
- return own(strategy.maxNodes, (graph, o) => (strategy as Strategy<"whole">).run(graph, o).result);
238
+ return own(strategy.maxNodes, (graph, o) => {
239
+ const out = (strategy as Strategy<"whole">).run(graph, o);
240
+ WHOLE_BOARDS.set(out.result, out.graph);
241
+ return out.result;
242
+ });
243
+ }
244
+
245
+ /** The board of the output of each whole strategy that `stepRouterStrategy` runs, by its result. The screen reads it for the measures of the moved boxes. */
246
+ const WHOLE_BOARDS = new WeakMap<Result, Graph>();
247
+
248
+ /** This function gives the board of the output of a whole strategy that `stepRouterStrategy` ran, or `undefined` for another result. A room strategy can move the boxes of this board. */
249
+ export function wholeBoard(result: Result): Graph | undefined {
250
+ return WHOLE_BOARDS.get(result);
239
251
  }
240
252
 
241
253
  /** This function gives a router for each strategy of one step of `RoutedStep` in a registry, in the order of the registry (`stepRouterStrategy`). */
@@ -1,6 +1,6 @@
1
1
  import type { CurveSegment } from "../../curves/model.ts";
2
2
  import type { CurveSpace } from "../../curves/space.ts";
3
- import { dist, polylineLength, segmentsCross, turns, type Point } from "../../geom.ts";
3
+ import { dist, frechet, polylineLength, segmentsCross, turns, type Point } from "../../geom.ts";
4
4
  import { Grid } from "../../grid.ts";
5
5
  import { TOLERANCE } from "../properties.ts";
6
6
 
@@ -109,6 +109,60 @@ export function bendingEnergy(points: readonly Point[], tolerance = 0.25): numbe
109
109
  return polylineLength(q) * sum;
110
110
  }
111
111
 
112
+ /**
113
+ * This function gives the curvature variation of a sampled curve (C4 of MEAS0, Moreton 1993): `V = L^3 · ∫ (dκ/ds)^2 ds`.
114
+ * Each inner sample gets the curvature `κ = φ / l` of `bendingEnergy`, with its sign. `L` is the length of the curve.
115
+ * The sum is `L^3 · Σ (κ_(i+1) - κ_i)^2 / d_i`, with `d_i` the distance of two inner samples in sequence.
116
+ *
117
+ * The value has no unit, and lower is better. A straight line and an arc of a circle give 0. Fewer than 4 points give 0. The cost is linear.
118
+ */
119
+ export function curvatureVariation(points: readonly Point[], tolerance = 0.25): number {
120
+ const q: Point[] = [];
121
+ for (const v of points) if (q.length === 0 || dist(q[q.length - 1]!, v) > EPS) q.push(v);
122
+ if (q.length < 4) return 0;
123
+ const phi = turns(q);
124
+ const kappa: number[] = [];
125
+ for (let i = 1; i + 1 < q.length; i++) {
126
+ const t = phi[i - 1]!;
127
+ const l = Math.min((dist(q[i - 1]!, q[i]!) + dist(q[i]!, q[i + 1]!)) / 2, (8 * tolerance) / Math.max(EPS, Math.abs(t)));
128
+ kappa.push(Math.abs(t) < EPS ? 0 : t / l);
129
+ }
130
+ let sum = 0;
131
+ for (let i = 0; i + 1 < kappa.length; i++) sum += (kappa[i + 1]! - kappa[i]!) ** 2 / dist(q[i + 1]!, q[i + 2]!);
132
+ return polylineLength(q) ** 3 * sum;
133
+ }
134
+
135
+ /** This function gives the points of a polyline with no leg longer than `step`. It keeps each vertex. */
136
+ function densify(points: readonly Point[], step: number): Point[] {
137
+ const out: Point[] = [];
138
+ for (let i = 0; i < points.length; i++) {
139
+ const b = points[i]!;
140
+ if (i > 0) {
141
+ const a = points[i - 1]!;
142
+ const n = Math.ceil(dist(a, b) / step);
143
+ for (let k = 1; k < n; k++) out.push({ x: a.x + ((b.x - a.x) * k) / n, y: a.y + ((b.y - a.y) * k) / n });
144
+ }
145
+ out.push(b);
146
+ }
147
+ return out;
148
+ }
149
+
150
+ /** The most points of each of the two lists of `curveDeviation`. More points give a longer step. */
151
+ const DEVIATION_POINTS = 400;
152
+
153
+ /**
154
+ * This function gives the deviation of a curve from its route (C8 of MEAS0).
155
+ * The value is the Fréchet distance of the samples of the curve and the polyline of the route.
156
+ * Each list gets points with no leg longer than `step`, or longer when a list has more than 400 points. Then the discrete Fréchet distance is near the true distance.
157
+ *
158
+ * The value is in units of the board, and lower is better. The caller divides it by the unit of the board. The cost is quadratic for each route.
159
+ */
160
+ export function curveDeviation(samples: readonly Point[], polyline: readonly Point[], step: number): number {
161
+ const long = Math.max(polylineLength(samples), polylineLength(polyline));
162
+ const h = Math.max(step, long / DEVIATION_POINTS, EPS);
163
+ return frechet(densify(samples, h), densify(polyline, h));
164
+ }
165
+
112
166
  /** This function tells if a polyline crosses itself. Two legs next to each other do not count. The cost is `n log n`. */
113
167
  export function crossesItself(p: readonly Point[]): boolean {
114
168
  if (p.length < 4) return false;
@@ -1,4 +1,7 @@
1
+ import { rng } from "../../fixtures.ts";
2
+ import { center, dist, type Point } from "../../geom.ts";
1
3
  import type { Graph, Level } from "../../model.ts";
4
+ import { countOverlaps } from "../links.ts";
2
5
  import type { Section } from "../../sections.ts";
3
6
  import { adjustedRandIndex } from "./stats.ts";
4
7
 
@@ -120,3 +123,71 @@ export function leafGroups(root: Section, count: number): string[] {
120
123
  export function groupStability(before: Section, after: Section, count: number): number {
121
124
  return adjustedRandIndex(leafGroups(before, count), leafGroups(after, count));
122
125
  }
126
+
127
+ /**
128
+ * This function gives the fill of the groups of one level (G4 of MEAS0): the mean of `fill(c) = Σ_(v in c) area(box(v)) / area(box(c))`.
129
+ * `box(c)` is the box of the item. Only an item with 2 or more nodes counts, and a fill above 1 counts as 1.
130
+ * The scale is 0 to 1, and higher is better. A level without such an item gives `undefined`. The cost is linear.
131
+ */
132
+ export function groupFill(graph: Graph, level: Level): number | undefined {
133
+ const area = new Map(graph.nodes.map((n) => [n.id, n.box.w * n.box.h] as const));
134
+ const fills: number[] = [];
135
+ for (const item of level.items) {
136
+ if (item.nodes.length < 2) continue;
137
+ const own = item.box.w * item.box.h;
138
+ const inside = item.nodes.reduce((s, v) => s + (area.get(v) ?? 0), 0);
139
+ fills.push(own > 0 ? Math.min(1, inside / own) : 1);
140
+ }
141
+ return fills.length === 0 ? undefined : fills.reduce((s, f) => s + f, 0) / fills.length;
142
+ }
143
+
144
+ /**
145
+ * This function counts the overlaps of the items of the levels (G5 of MEAS0).
146
+ * An overlap is a pair of items of one level whose boxes overlap by more than `TOLERANCE`.
147
+ * It is the sum over the levels of `countOverlaps`. Lower is better. The cost is `n log n` for each level.
148
+ */
149
+ export function itemOverlaps(levels: readonly Level[]): number {
150
+ return levels.reduce((n, level) => n + countOverlaps(level.items.map((i) => i.box)), 0);
151
+ }
152
+
153
+ /** Up to this number of nodes, `silhouette` examines each node. Above it, two seeded samples of this size give an estimate. */
154
+ const SILHOUETTE_NODES = 2000;
155
+
156
+ /**
157
+ * This function gives the silhouette of the groups of one level (G7 of MEAS0, Rousseeuw 1987): the mean of `s(v) = (b_v - a_v) / max(a_v, b_v)`.
158
+ * `a_v` is the mean distance from the center of `v` to the centers of the other nodes of its item. `b_v` is the smallest mean distance to the nodes of another item.
159
+ * A node alone in its item gets `s(v) = 0`. The scale is -1 to 1, and higher is better.
160
+ * A level with fewer than 2 items gives `undefined`. The cost is quadratic up to 2000 nodes.
161
+ * Above 2000 nodes, a seeded sample of 2000 nodes gets `s(v)`, against a second seeded sample of 2000 nodes. Thus the value is an estimate.
162
+ */
163
+ export function silhouette(graph: Graph, level: Level): number | undefined {
164
+ const at = new Map(graph.nodes.map((n) => [n.id, center(n.box)] as const));
165
+ const all = level.items.flatMap((item, k) => item.nodes.flatMap((v) => (at.has(v) ? [{ p: at.get(v)!, k }] : [])));
166
+ if (new Set(all.map((x) => x.k)).size < 2) return undefined;
167
+ const R = rng(all.length);
168
+ const sample = (): typeof all => (all.length <= SILHOUETTE_NODES ? all : Array.from({ length: SILHOUETTE_NODES }, () => all[Math.floor(R() * all.length)]!));
169
+ const chosen = sample();
170
+ const ref = sample();
171
+ const groups = new Map<number, Point[]>();
172
+ for (const { p, k } of ref) {
173
+ const g = groups.get(k);
174
+ if (g) g.push(p);
175
+ else groups.set(k, [p]);
176
+ }
177
+ let sum = 0;
178
+ for (const { p, k } of chosen) {
179
+ const own = groups.get(k);
180
+ if (!own || own.length < 2) continue;
181
+ let a = 0;
182
+ let b = Number.POSITIVE_INFINITY;
183
+ for (const [j, g] of groups) {
184
+ const d = g.reduce((s, q) => s + dist(p, q), 0);
185
+ if (j === k) a = d / (g.length - 1);
186
+ else b = Math.min(b, d / g.length);
187
+ }
188
+ if (!Number.isFinite(b)) continue;
189
+ const m = Math.max(a, b);
190
+ sum += m > 0 ? (b - a) / m : 0;
191
+ }
192
+ return sum / chosen.length;
193
+ }
@@ -10,3 +10,4 @@ export * from "./pareto.ts";
10
10
  export * from "./order.ts";
11
11
  export * from "./highways.ts";
12
12
  export * from "./lanes.ts";
13
+ export * from "./symmetry.ts";
@@ -185,3 +185,27 @@ export function linkAgreement(graph: Graph, levels: readonly Level[], routes: re
185
185
  }
186
186
  return n > 0 ? sum / n : undefined;
187
187
  }
188
+
189
+ /** The level ratio of a result (Z5 of MEAS0): the largest step and the spread of the steps from level to level. */
190
+ export interface LevelRatio {
191
+ /** The largest `lr_d = items_(d+1) / items_d`. */
192
+ readonly max: number;
193
+ /** The standard deviation of `log(lr_d)` over the steps. A value of 0 is steps of equal ratio. */
194
+ readonly spread: number;
195
+ }
196
+
197
+ /**
198
+ * This function gives the level ratio of the levels (Z5 of MEAS0, Mondal 2018): `lr_d = items_(d+1) / items_d`, in the order of depth.
199
+ * A level without items does not count. Fewer than 2 levels give `undefined`. Lower is better for both values. The cost is linear.
200
+ */
201
+ export function levelRatio(levels: readonly Level[]): LevelRatio | undefined {
202
+ const counts = byDepth(levels)
203
+ .map((l) => l.items.length)
204
+ .filter((n) => n > 0);
205
+ if (counts.length < 2) return undefined;
206
+ const ratios = counts.slice(1).map((n, d) => n / counts[d]!);
207
+ const logs = ratios.map((r) => Math.log(r));
208
+ const mean = logs.reduce((s, x) => s + x, 0) / logs.length;
209
+ const spread = Math.sqrt(logs.reduce((s, x) => s + (x - mean) ** 2, 0) / logs.length);
210
+ return { max: Math.max(...ratios), spread };
211
+ }
@@ -1,5 +1,5 @@
1
1
  import { rng } from "../../fixtures.ts";
2
- import type { Box } from "../../geom.ts";
2
+ import { center, dist, type Box, type Point } from "../../geom.ts";
3
3
  import type { Boxed } from "../../room/index.ts";
4
4
 
5
5
  /** Up to this number of boxes, `roomOrderFaults` examines each pair. */
@@ -41,3 +41,108 @@ export function roomOrderFaults(before: readonly Boxed[], after: readonly Boxed[
41
41
  }
42
42
  return Math.round((faults * (n * (n - 1)) / 2) / Math.max(1, seen));
43
43
  }
44
+
45
+ /**
46
+ * This function gives the displacement of the boxes (M3 of MEAS0, Bridgeman 2000): `mean_v dist(box_open(v), box_packed(v)) / u`.
47
+ * The distance is between the centers of the two boxes of a node, and `u` is the unit of the packed board.
48
+ * The value has no unit, and lower is better. A board where no box moves gives 0. The cost is linear.
49
+ */
50
+ export function boxDisplacement(before: readonly Boxed[], after: readonly Boxed[], unit: number): number {
51
+ const next = new Map(after.map((b) => [b.id, b.box] as const));
52
+ let sum = 0;
53
+ let n = 0;
54
+ for (const b of before) {
55
+ const a = next.get(b.id);
56
+ if (!a) continue;
57
+ sum += dist(center(b.box), center(a));
58
+ n++;
59
+ }
60
+ return n === 0 ? 0 : sum / n / (unit > 0 ? unit : 1);
61
+ }
62
+
63
+ /** The number of nearest boxes of each box that make the neighbour pairs of `distanceDistortion`. */
64
+ export const DISTORTION_NEIGHBOURS = 4;
65
+
66
+ /**
67
+ * This function gives the distance distortion of the boxes (M5 of MEAS0): the Kruskal stress of the centers.
68
+ * `σ = sqrt(Σ (α d'_ij - d_ij)^2 / Σ d_ij^2)`, with `d` the distance on the packed board, `d'` the distance on the open board, and `α = Σ d d' / Σ d'^2` the best scale.
69
+ * The pairs are each box and its 4 nearest boxes on the packed board. The scale is 0 to 1, and lower is better.
70
+ * A board that only changes its scale gives 0. The cost is `n log n` for an even density.
71
+ */
72
+ export function distanceDistortion(before: readonly Boxed[], after: readonly Boxed[]): number {
73
+ const next = new Map(after.map((b) => [b.id, center(b.box)] as const));
74
+ const list = before.flatMap((b) => (next.has(b.id) ? [{ p: center(b.box), q: next.get(b.id)! }] : []));
75
+ const pairs = neighbourPairs(
76
+ list.map((x) => x.p),
77
+ DISTORTION_NEIGHBOURS,
78
+ );
79
+ let dd = 0;
80
+ let qq = 0;
81
+ let pp = 0;
82
+ const ds: [number, number][] = [];
83
+ for (const [i, j] of pairs) {
84
+ const d = dist(list[i]!.p, list[j]!.p);
85
+ const e = dist(list[i]!.q, list[j]!.q);
86
+ ds.push([d, e]);
87
+ dd += d * e;
88
+ qq += e * e;
89
+ pp += d * d;
90
+ }
91
+ if (pp === 0) return 0;
92
+ const alpha = qq > 0 ? dd / qq : 0;
93
+ const res = ds.reduce((s, [d, e]) => s + (alpha * e - d) ** 2, 0);
94
+ return Math.sqrt(res / pp);
95
+ }
96
+
97
+ /** This function gives each pair of a point and one of its `k` nearest points one time, as `[i, j]` with `i < j`. A grid of buckets finds the points. */
98
+ function neighbourPairs(points: readonly Point[], k: number): [number, number][] {
99
+ const n = points.length;
100
+ if (n < 2) return [];
101
+ let x0 = Number.POSITIVE_INFINITY;
102
+ let y0 = Number.POSITIVE_INFINITY;
103
+ let x1 = Number.NEGATIVE_INFINITY;
104
+ let y1 = Number.NEGATIVE_INFINITY;
105
+ for (const p of points) {
106
+ x0 = Math.min(x0, p.x);
107
+ y0 = Math.min(y0, p.y);
108
+ x1 = Math.max(x1, p.x);
109
+ y1 = Math.max(y1, p.y);
110
+ }
111
+ const cell = Math.max(1e-9, Math.sqrt(((x1 - x0) * (y1 - y0) || 1) / n) * 2);
112
+ const key = (cx: number, cy: number): string => `${cx},${cy}`;
113
+ const buckets = new Map<string, number[]>();
114
+ points.forEach((p, i) => {
115
+ const kk = key(Math.floor((p.x - x0) / cell), Math.floor((p.y - y0) / cell));
116
+ const list = buckets.get(kk);
117
+ if (list) list.push(i);
118
+ else buckets.set(kk, [i]);
119
+ });
120
+ const span = Math.ceil(Math.max(x1 - x0, y1 - y0) / cell) + 1;
121
+ const seen = new Set<string>();
122
+ const out: [number, number][] = [];
123
+ points.forEach((p, i) => {
124
+ const cx = Math.floor((p.x - x0) / cell);
125
+ const cy = Math.floor((p.y - y0) / cell);
126
+ let near: { j: number; d: number }[] = [];
127
+ for (let ring = 0; ring <= span; ring++) {
128
+ for (let gx = cx - ring; gx <= cx + ring; gx++)
129
+ for (let gy = cy - ring; gy <= cy + ring; gy++) {
130
+ if (Math.max(Math.abs(gx - cx), Math.abs(gy - cy)) !== ring) continue;
131
+ for (const j of buckets.get(key(gx, gy)) ?? []) if (j !== i) near.push({ j, d: dist(p, points[j]!) });
132
+ }
133
+ // A point outside the rings is at least `ring · cell` away, so the k nearest points are known.
134
+ if (near.length >= Math.min(k, n - 1)) {
135
+ near.sort((a, b) => a.d - b.d || a.j - b.j);
136
+ if (near[Math.min(k, n - 1) - 1]!.d <= ring * cell) break;
137
+ }
138
+ }
139
+ near = near.sort((a, b) => a.d - b.d || a.j - b.j).slice(0, k);
140
+ for (const { j } of near) {
141
+ const pk = i < j ? `${i},${j}` : `${j},${i}`;
142
+ if (seen.has(pk)) continue;
143
+ seen.add(pk);
144
+ out.push(i < j ? [i, j] : [j, i]);
145
+ }
146
+ });
147
+ return out;
148
+ }
@@ -0,0 +1,114 @@
1
+ import { center, dist, type Point } from "../../geom.ts";
2
+ import type { Graph, Route } from "../../model.ts";
3
+ import { boardUnit } from "./routes.ts";
4
+
5
+ const EPS = 1e-9;
6
+
7
+ /** One map of the board onto itself: a mirror on an axis through the center of the board, or the half turn about the center. */
8
+ type Map2 = (p: Point) => Point;
9
+
10
+ /** This function gives the corners of a polyline: the points without repeats and without inner points on a straight line. */
11
+ function cornersOf(points: readonly Point[]): Point[] {
12
+ const q: Point[] = [];
13
+ for (const v of points) if (q.length === 0 || dist(q[q.length - 1]!, v) > EPS) q.push(v);
14
+ const out: Point[] = [];
15
+ for (let i = 0; i < q.length; i++) {
16
+ const v = q[i]!;
17
+ if (i > 0 && i + 1 < q.length) {
18
+ const a = out[out.length - 1]!;
19
+ const c = q[i + 1]!;
20
+ const cr = (v.x - a.x) * (c.y - v.y) - (v.y - a.y) * (c.x - v.x);
21
+ const dt = (v.x - a.x) * (c.x - v.x) + (v.y - a.y) * (c.y - v.y);
22
+ if (Math.abs(cr) <= EPS * Math.max(1, dist(a, v) * dist(v, c)) && dt > 0) continue;
23
+ }
24
+ out.push(v);
25
+ }
26
+ return out;
27
+ }
28
+
29
+ /** This function tells if two lists of corners are the same, point by point, within `tol`. */
30
+ const sameCorners = (a: readonly Point[], b: readonly Point[], tol: number): boolean => a.length === b.length && a.every((p, i) => dist(p, b[i]!) <= tol);
31
+
32
+ /**
33
+ * This function gives the symmetry of the drawing (H3 of MEAS0). The value is the share of the routes that the best map of the board sends onto a route.
34
+ * The maps are the two mirrors on the axes and the half turn. Each one goes through the center of the boxes.
35
+ *
36
+ * A route of the edge `(s, t)` maps when two things are true. The map sends the centers of `s` and `t` to the centers of the nodes `s'` and `t'`, within `u / 4`.
37
+ * A route of an edge between `s'` and `t'` has the same corners as the mapped route, within `u / 4`. `u` is the unit of the board.
38
+ *
39
+ * The scale is 0 to 1, and higher is better. A router keeps the symmetry that the boxes have, or loses it. Routes without points give `undefined`.
40
+ * The cost is linear in the points of the routes.
41
+ */
42
+ export function routeSymmetry(graph: Graph, routes: readonly Route[]): number | undefined {
43
+ const drawn = routes.filter((r) => r.points.length >= 2);
44
+ if (drawn.length === 0 || graph.nodes.length === 0) return undefined;
45
+ const u = boardUnit(graph);
46
+ const tol = u / 4;
47
+ let x0 = Number.POSITIVE_INFINITY;
48
+ let y0 = Number.POSITIVE_INFINITY;
49
+ let x1 = Number.NEGATIVE_INFINITY;
50
+ let y1 = Number.NEGATIVE_INFINITY;
51
+ for (const n of graph.nodes) {
52
+ x0 = Math.min(x0, n.box.x);
53
+ y0 = Math.min(y0, n.box.y);
54
+ x1 = Math.max(x1, n.box.x + n.box.w);
55
+ y1 = Math.max(y1, n.box.y + n.box.h);
56
+ }
57
+ const cx = (x0 + x1) / 2;
58
+ const cy = (y0 + y1) / 2;
59
+ const maps: readonly Map2[] = [(p) => ({ x: 2 * cx - p.x, y: p.y }), (p) => ({ x: p.x, y: 2 * cy - p.y }), (p) => ({ x: 2 * cx - p.x, y: 2 * cy - p.y })];
60
+ // The nodes by a cell of the size `tol`, so the node at a mapped center is a lookup of 9 cells.
61
+ const cellOf = (p: Point): [number, number] => [Math.floor(p.x / tol), Math.floor(p.y / tol)];
62
+ const cells = new Map<string, { id: string; at: Point }[]>();
63
+ const at = new Map<string, Point>();
64
+ for (const n of graph.nodes) {
65
+ const c = center(n.box);
66
+ at.set(n.id, c);
67
+ const [i, j] = cellOf(c);
68
+ const k = `${i},${j}`;
69
+ const list = cells.get(k);
70
+ if (list) list.push({ id: n.id, at: c });
71
+ else cells.set(k, [{ id: n.id, at: c }]);
72
+ }
73
+ const nodeAt = (p: Point): string | undefined => {
74
+ const [i, j] = cellOf(p);
75
+ let best: { id: string; d: number } | undefined;
76
+ for (let a = i - 1; a <= i + 1; a++)
77
+ for (let b = j - 1; b <= j + 1; b++)
78
+ for (const n of cells.get(`${a},${b}`) ?? []) {
79
+ const d = dist(n.at, p);
80
+ if (d <= tol && (best === undefined || d < best.d || (d === best.d && n.id < best.id))) best = { id: n.id, d };
81
+ }
82
+ return best?.id;
83
+ };
84
+ const ends = new Map(graph.edges.map((e) => [e.id, [e.source.node, e.target.node] as const]));
85
+ const byPair = new Map<string, Point[][]>();
86
+ const corners = drawn.map((r) => cornersOf(r.points));
87
+ drawn.forEach((r, i) => {
88
+ const e = ends.get(r.edge);
89
+ if (!e) return;
90
+ const k = `${e[0]}\u0000${e[1]}`;
91
+ const list = byPair.get(k);
92
+ if (list) list.push(corners[i]!);
93
+ else byPair.set(k, [corners[i]!]);
94
+ });
95
+ let best = 0;
96
+ for (const m of maps) {
97
+ let hits = 0;
98
+ drawn.forEach((r, i) => {
99
+ const e = ends.get(r.edge);
100
+ if (!e) return;
101
+ const a = at.get(e[0]);
102
+ const b = at.get(e[1]);
103
+ const s = a && nodeAt(m(a));
104
+ const t = b && nodeAt(m(b));
105
+ if (s === undefined || t === undefined) return;
106
+ const mapped = corners[i]!.map(m);
107
+ const back = [...mapped].reverse();
108
+ const hit = (byPair.get(`${s}\u0000${t}`) ?? []).some((c) => sameCorners(mapped, c, tol)) || (byPair.get(`${t}\u0000${s}`) ?? []).some((c) => sameCorners(back, c, tol));
109
+ if (hit) hits++;
110
+ });
111
+ best = Math.max(best, hits / drawn.length);
112
+ }
113
+ return best;
114
+ }
@@ -77,6 +77,11 @@ export const MOVE_NAMES: Readonly<Record<string, string>> = Object.freeze({
77
77
  consensus: "cluster",
78
78
  "p-hub": "hubLink",
79
79
  "scale-space": "cluster",
80
+ steiner: "tree",
81
+ gaps: "gap",
82
+ streets: "street",
83
+ search: "searched",
84
+ interchanges: "networkRoad",
80
85
  });
81
86
 
82
87
  /**
@@ -12,6 +12,8 @@ export interface LayoutPort {
12
12
  /** The fixed place of the port, from the top-left corner of the box. The router does not move a port with `x` and `y`. */
13
13
  readonly x?: number;
14
14
  readonly y?: number;
15
+ /** The fixed place of the port on its `side`: a fraction from 0 to 1. The start is the top end or the left end of the side. */
16
+ readonly along?: number;
15
17
  }
16
18
 
17
19
  /** A point of a region: `[x, y]`. The y axis points down. */
@@ -238,18 +240,20 @@ function sideAt(p: { x: number; y: number }, w: number, h: number): LayoutSide {
238
240
  const num = round3;
239
241
 
240
242
  function portToFile(p: Port, n: Node): LayoutPort {
243
+ if (p.along !== undefined) return { id: p.id, ...(p.side ? { side: TO_FILE[p.side] } : {}), along: p.along };
241
244
  const side = p.side ? TO_FILE[p.side] : p.at ? sideAt(p.at, n.box.w, n.box.h) : undefined;
242
245
  return { id: p.id, ...(side ? { side } : {}), ...(p.at ? { x: num(p.at.x), y: num(p.at.y) } : {}) };
243
246
  }
244
247
 
245
248
  function portToModel(p: LayoutPort): Port {
249
+ if (p.along !== undefined) return { id: p.id, ...(p.side ? { side: TO_MODEL[p.side] } : {}), along: p.along };
246
250
  if (p.x !== undefined && p.y !== undefined) return { id: p.id, at: { x: p.x, y: p.y } };
247
251
  return { id: p.id, ...(p.side ? { side: TO_MODEL[p.side] } : {}) };
248
252
  }
249
253
 
250
254
  /**
251
255
  * This function writes a graph as an input file of the form `layout-input/1`, with its hash.
252
- * A fixed plug gets `x`, `y` and the side that holds it. `inputGraph` reads the file back.
256
+ * A fixed plug gets `x`, `y` and the side that holds it. A plug with `along` keeps its `side` and `along`. `inputGraph` reads the file back.
253
257
  */
254
258
  export function graphInput(graph: Graph, head: LayoutInputHead): LayoutInput {
255
259
  const boxes = graph.nodes.map(
@@ -259,6 +263,7 @@ export function graphInput(graph: Graph, head: LayoutInputHead): LayoutInput {
259
263
  h: num(n.box.h),
260
264
  x: num(n.box.x),
261
265
  y: num(n.box.y),
266
+ ...(n.parent !== undefined ? { parent: n.parent } : {}),
262
267
  ...(n.ports ? { ports: n.ports.map((p) => portToFile(p, n)) } : {}),
263
268
  ...(n.data ? { data: n.data } : {}),
264
269
  }),
@@ -312,7 +317,7 @@ export function regionBounds(region: Region): { x: number; y: number; w: number;
312
317
  export type RouteInput = Pick<LayoutInput, "id" | "boxes" | "obstacles"> & { readonly edges?: LayoutInput["edges"] };
313
318
 
314
319
  /**
315
- * This function reads the graph of the router from an input file. The router reads the positions, the ports, the edges and the data.
320
+ * This function reads the graph of the router from an input file. The router reads the positions, the ports, the parents, the edges and the data.
316
321
  * Each obstacle becomes a node without edges, with the bounding box of its region, after the boxes.
317
322
  * The router ignores the other fields of the packer: `container`, `gap`, `locked`, `rank`, the `shape` of a box and the `move` of an obstacle.
318
323
  * A box without `x` and `y` is an error.
@@ -320,7 +325,13 @@ export type RouteInput = Pick<LayoutInput, "id" | "boxes" | "obstacles"> & { rea
320
325
  export function inputGraph(input: RouteInput): Graph {
321
326
  const nodes = input.boxes.map((b): Node => {
322
327
  if (b.x === undefined || b.y === undefined) throw new Error(`The box "${b.id}" of the input "${input.id}" has no position. The router needs a placed input.`);
323
- return { id: b.id, box: { x: b.x, y: b.y, w: b.w, h: b.h }, ...(b.ports ? { ports: b.ports.map(portToModel) } : {}), ...(b.data ? { data: b.data } : {}) };
328
+ return {
329
+ id: b.id,
330
+ box: { x: b.x, y: b.y, w: b.w, h: b.h },
331
+ ...(b.ports ? { ports: b.ports.map(portToModel) } : {}),
332
+ ...(b.data ? { data: b.data } : {}),
333
+ ...(b.parent !== undefined ? { parent: b.parent } : {}),
334
+ };
324
335
  });
325
336
  for (const o of input.obstacles ?? []) nodes.push({ id: o.id, box: regionBounds(o.region) });
326
337
  const edges = (input.edges ?? []).map(
@@ -24,6 +24,8 @@ export interface MeasureInfo {
24
24
  * A measure without this field has the threshold `RECORD_EPS`.
25
25
  */
26
26
  readonly minEffect?: MinEffect;
27
+ /** The definition of the measure in one or two sentences: what it counts, and when its value is good. The site shows it. */
28
+ readonly definition?: string;
27
29
  }
28
30
 
29
31
  /** One rule of `MIN_EFFECTS`: the units, the minimum effect of a measure with one of these units, and the reason. */
@@ -69,7 +71,7 @@ export const MIN_EFFECTS: readonly MinEffectRule[] = Object.freeze([
69
71
  const MIN_EFFECT_BY_UNIT: ReadonlyMap<string, MinEffectRule> = new Map(MIN_EFFECTS.flatMap((r) => r.units.map((u) => [u, r] as const)));
70
72
 
71
73
  /** The text of a row of `MEASURES`: the key, the id, the name, the unit, the better side and the placement dependence. */
72
- type Row = readonly [key: string, id: string, name: string, unit: string, better: "lower" | "higher", placement: boolean];
74
+ type Row = readonly [key: string, id: string, name: string, unit: string, better: "lower" | "higher", placement: boolean, definition?: string];
73
75
 
74
76
  const ROWS: readonly Row[] = [
75
77
  // The routes. A placement makes crossings, plugs on short sides and boxes that touch, so these measures depend on it.
@@ -127,6 +129,12 @@ const ROWS: readonly Row[] = [
127
129
  // The step `room` of `runSuite` gives the same keys for the boards that a room strategy opens.
128
130
  ["M1.area", "M1", "route.lane.area", "ratio", "lower", false],
129
131
  ["M4.laneThrough", "M4", "route.lane.through", "count", "lower", true],
132
+ // SCREEN-MEASURES: M2, M3 and M5 of the step `whole` compare the board of the output with the input board. A whole strategy without a room strategy gives 0.
133
+ ["M2.order", "M2", "route.room.order", "count", "lower", false, "The number of pairs of boxes whose order or alignment on an axis changes when the boxes move. 0 is correct."],
134
+ ["M3.displacement", "M3", "route.room.displacement", "no unit", "lower", false, "The mean distance that the center of a box moves, divided by the unit of the board."],
135
+ ["M5.stress", "M5", "route.room.distortion", "0 to 1", "lower", false, "The stress of the distances from each box to its 4 nearest boxes, after the best change of scale. 0 is a board that only changes its scale."],
136
+ // SCREEN-MEASURES: H3 is the symmetry of the routes on a board of fixed boxes.
137
+ ["H3.share", "H3", "route.symmetry", "0 to 1", "higher", true, "The share of the routes that a mirror or a half turn of the board sends onto a route, for the best of the three maps."],
130
138
  // The order fragility (W3 of MEAS3, from WILD-056): the spread of a measure over random orders of the edges. The placement can move it.
131
139
  ["W3.crossings", "W3", "route.order.crossings", "IQR over the median", "lower", true],
132
140
  ["W3.detour", "W3", "route.order.detour", "IQR over the median", "lower", true],
@@ -156,11 +164,35 @@ const ROWS: readonly Row[] = [
156
164
  ["Z4.farItems", "Z4", "route.level.far-items", "count", "lower", true],
157
165
  ["Z7.position", "Z7", "route.level.position", "no unit", "lower", true],
158
166
  ["Z8.order", "Z8", "route.level.order", "-1 to 1", "higher", true],
167
+ // SCREEN-MEASURES: Z1 is the count of the hard rule `counts-sum`, as Z2. Z5 is the ratio of the item counts from level to level.
168
+ ["Z1.countsSum", "Z1", "route.level.counts-sum", "count", "lower", false, "The faults of the hard rule `counts-sum`: a level whose items miss a node, or whose links miss an edge. 0 is correct."],
169
+ ["Z5.max", "Z5", "route.level.ratio.max", "ratio", "lower", true, "The largest ratio of the item counts of two levels in sequence. A large ratio is a sudden jump of the zoom."],
170
+ [
171
+ "Z5.spread",
172
+ "Z5",
173
+ "route.level.ratio.spread",
174
+ "no unit",
175
+ "lower",
176
+ true,
177
+ "The standard deviation of the log of the ratios of the item counts of the levels. 0 is a zoom with steps of equal size.",
178
+ ],
159
179
  ["G1.modularity", "G1", "route.group.modularity", "-0.5 to 1", "higher", true],
160
180
  ["G2.cut", "G2", "route.group.cut", "0 to 1", "lower", true],
161
181
  ["G6.balance", "G6", "route.group.balance", "0 to 1", "higher", true],
162
182
  ["G8.treeCost", "G8", "route.group.tree-cost", "0 to 1", "lower", true],
163
183
  ["G9.ari", "G9", "route.group.stability", "-1 to 1", "higher", true],
184
+ // SCREEN-MEASURES: G4, G5 and G7 read the boxes of the items of the levels.
185
+ ["G4.fill", "G4", "route.group.fill", "0 to 1", "higher", true, "The mean share of the box of a group of the far view that the boxes of its nodes fill. A full group box is 1."],
186
+ ["G5.overlaps", "G5", "route.group.overlaps", "count", "lower", true, "The number of pairs of items of one level whose boxes overlap. 0 is correct."],
187
+ [
188
+ "G7.silhouette",
189
+ "G7",
190
+ "route.group.silhouette",
191
+ "-1 to 1",
192
+ "higher",
193
+ true,
194
+ "The mean silhouette of the nodes of the far view: how much nearer a node is to its group than to the nearest other group.",
195
+ ],
164
196
  // The curves. A curve draws a fixed polyline, so no measure of a curve depends on the placement.
165
197
  ["C1.max", "C1", "route.curve.c1.max", "degrees", "lower", false],
166
198
  ["C1.share", "C1", "route.curve.c1.share", "0 to 1", "lower", false],
@@ -168,6 +200,11 @@ const ROWS: readonly Row[] = [
168
200
  ["C2.max", "C2", "route.curve.c2.max", "no unit", "lower", false],
169
201
  ["C3.median", "C3", "route.curve.c3.median", "no unit", "lower", false],
170
202
  ["C3.q95", "C3", "route.curve.c3.q95", "no unit", "lower", false],
203
+ // SCREEN-MEASURES: C4 is the change of the curvature along a curve. C8 is the distance of a curve from its route.
204
+ ["C4.median", "C4", "route.curve.c4.median", "no unit", "lower", false, "The median curvature variation of the curves, without a unit. A circle or a straight line is 0. A sudden change of the curvature is large."],
205
+ ["C4.q95", "C4", "route.curve.c4.q95", "no unit", "lower", false, "The 0.95 quantile of the curvature variation of the curves. It shows the worst curves."],
206
+ ["C8.median", "C8", "route.curve.c8.median", "no unit", "lower", false, "The median Fréchet distance of a curve from the polyline of its route, divided by the unit of the board."],
207
+ ["C8.max", "C8", "route.curve.c8.max", "no unit", "lower", false, "The largest Fréchet distance of a curve from the polyline of its route, divided by the unit of the board."],
171
208
  ["C5.peak", "C5", "route.curve.c5.peak", "curvature times the unit of the board", "lower", false],
172
209
  ["C6.length", "C6", "route.curve.c6.length", "ratio", "lower", false],
173
210
  ["curve-clear", "C7", "route.curve.rule.clear", "count", "lower", false],
@@ -182,10 +219,10 @@ const ROWS: readonly Row[] = [
182
219
  * It compares a measure with the value `true` only between runs on the same input.
183
220
  */
184
221
  export const MEASURES: readonly MeasureInfo[] = Object.freeze(
185
- ROWS.map(([key, id, name, unit, better, placementDependent]) => {
222
+ ROWS.map(([key, id, name, unit, better, placementDependent, definition]) => {
186
223
  const rule = MIN_EFFECT_BY_UNIT.get(unit);
187
224
  if (!rule) throw new Error(`MEASURES: the unit "${unit}" of ${key} has no rule in MIN_EFFECTS`);
188
- const info: MeasureInfo = { key, id, name, unit, better, placementDependent };
225
+ const info: MeasureInfo = { key, id, name, unit, better, placementDependent, ...(definition === undefined ? {} : { definition }) };
189
226
  return Object.freeze(rule.minEffect === undefined ? info : { ...info, minEffect: rule.minEffect });
190
227
  }),
191
228
  );