@mark1russell7/highways 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +43 -2
  2. package/build/index.d.ts +1472 -63
  3. package/build/index.d.ts.map +1 -1
  4. package/build/index.js +10007 -3406
  5. package/build/index.js.map +1 -1
  6. package/package.json +11 -2
  7. package/src/bench/exact/index.ts +72 -11
  8. package/src/bench/exact/optima-exact-v1-bounds.json +14409 -0
  9. package/src/bench/exact/optima-exact-v1.json +4809 -0
  10. package/src/bench/exact/solve.ts +129 -0
  11. package/src/bench/screen/activation.ts +3 -0
  12. package/src/bench/screen/bounds.ts +140 -0
  13. package/src/bench/screen/compare.ts +26 -0
  14. package/src/bench/screen/exact-set.ts +100 -0
  15. package/src/bench/screen/exercise.ts +290 -0
  16. package/src/bench/screen/forms.ts +14 -0
  17. package/src/bench/screen/gap.ts +316 -0
  18. package/src/bench/screen/hash.ts +7 -76
  19. package/src/bench/screen/index.ts +6 -0
  20. package/src/bench/screen/measures.ts +71 -1
  21. package/src/bench/screen/portfolio-pick.ts +20 -0
  22. package/src/bench/screen/screen.ts +50 -2
  23. package/src/bench/screen/set.ts +3 -2
  24. package/src/highways/areas/index.ts +160 -0
  25. package/src/highways/confluent/index.ts +154 -0
  26. package/src/highways/confluent/k23.test-util.ts +8 -0
  27. package/src/highways/consensus/index.ts +129 -0
  28. package/src/highways/crossbars/index.ts +184 -0
  29. package/src/highways/cubu/index.ts +26 -0
  30. package/src/highways/divided/index.ts +111 -0
  31. package/src/highways/fdeb/index.ts +193 -0
  32. package/src/highways/frechet/index.ts +232 -0
  33. package/src/highways/hierarchy/index.ts +140 -0
  34. package/src/highways/index.ts +64 -0
  35. package/src/highways/kdeeb/index.ts +355 -0
  36. package/src/highways/link-communities/index.ts +189 -0
  37. package/src/highways/mesh/index.ts +209 -0
  38. package/src/highways/mlseb/index.ts +204 -0
  39. package/src/highways/p-hub/index.ts +248 -0
  40. package/src/highways/power/index.ts +117 -0
  41. package/src/highways/resistance/index.ts +159 -0
  42. package/src/highways/sbeb/index.ts +303 -0
  43. package/src/highways/scale-space/index.ts +135 -0
  44. package/src/highways/spectral/index.ts +391 -0
  45. package/src/highways/territories/index.ts +150 -0
  46. package/src/highways/trace.ts +153 -0
  47. package/src/index.ts +1 -0
  48. package/src/portfolio/bands.ts +33 -0
  49. package/src/portfolio/fit.ts +29 -0
  50. package/src/portfolio/index.ts +6 -0
  51. package/src/portfolio/key.ts +98 -0
  52. package/src/portfolio/members.ts +171 -0
  53. package/src/portfolio/router.ts +133 -0
  54. package/src/portfolio/sets.ts +28 -0
  55. package/src/strategies/builtins.ts +222 -1
  56. package/src/strategies/schemas.ts +280 -1
@@ -0,0 +1,209 @@
1
+ import { dist, type Point } from "../../geom.ts";
2
+ import type { Graph } from "../../model.ts";
3
+ import { WayFinder, type WayOptions } from "../../ways.ts";
4
+ import type { HighwayInput, HighwayPlan } from "../model.ts";
5
+ import { edgePlugs } from "../stretches.ts";
6
+ import { tracePlan } from "../trace.ts";
7
+
8
+ /** The options of the highway strategy `mesh` (HWY-004 of the notes). */
9
+ export interface MeshOptions extends Pick<WayOptions, "margin" | "k"> {
10
+ /** The side of a cell of the mesh, in units of the boxes: the median of the smaller side of each box. The default is 4. */
11
+ readonly cell: number;
12
+ /** A cluster of crossings on one segment of the mesh has at most about this number of edges. The default is 8. */
13
+ readonly capacity: number;
14
+ /** Two clusters nearer than this value, in units of the boxes, become one. The default is 1. */
15
+ readonly merge: number;
16
+ /** The largest angle in degrees between a line and the main direction of a cell. A line with a larger angle does not cross the mesh there. The default is 45. */
17
+ readonly angle: number;
18
+ /** A highway has this number of edges or more. The default is 2. */
19
+ readonly minSize: number;
20
+ }
21
+
22
+ /** The default options of the strategy `mesh`. */
23
+ export const MESH_DEFAULTS: Readonly<Required<Omit<MeshOptions, "margin" | "k">>> = Object.freeze({
24
+ cell: 4,
25
+ capacity: 8,
26
+ merge: 1,
27
+ angle: 45,
28
+ minSize: 2,
29
+ });
30
+
31
+ /** The number of bins of the histogram of the angles. Each bin is 10 degrees wide. */
32
+ const BINS = 18;
33
+
34
+ /** This function gives the median of the smaller side of each box of the graph, or 1 for a graph without boxes. */
35
+ export function boxUnit(graph: Graph): number {
36
+ const sides = graph.nodes.map((n) => Math.min(n.box.w, n.box.h)).sort((a, b) => a - b);
37
+ return sides.length > 0 ? Math.max(1e-3, sides[Math.floor(sides.length / 2)]!) : 1;
38
+ }
39
+
40
+ /** This function gives the shares `t` of the segment `a b` and `u` of the segment `c d` at their crossing. Segments that do not cross give `undefined`. */
41
+ function crossing(a: Point, b: Point, c: Point, d: Point): { t: number; u: number } | undefined {
42
+ const rx = b.x - a.x;
43
+ const ry = b.y - a.y;
44
+ const sx = d.x - c.x;
45
+ const sy = d.y - c.y;
46
+ const den = rx * sy - ry * sx;
47
+ if (Math.abs(den) < 1e-12) return undefined;
48
+ const t = ((c.x - a.x) * sy - (c.y - a.y) * sx) / den;
49
+ const u = ((c.x - a.x) * ry - (c.y - a.y) * rx) / den;
50
+ const eps = 1e-9;
51
+ return t >= -eps && t <= 1 + eps && u >= -eps && u <= 1 + eps ? { t: Math.max(0, Math.min(1, t)), u: Math.max(0, Math.min(1, u)) } : undefined;
52
+ }
53
+
54
+ /** This function gives the clusters of the values by a 1D k-means with `k` centres, then joins the centres nearer than `merge`. The result gives the centre of each value. */
55
+ export function clusters1d(values: readonly number[], k: number, merge: number): number[] {
56
+ const n = values.length;
57
+ if (n === 0) return [];
58
+ const sorted = [...values].sort((a, b) => a - b);
59
+ const count = Math.max(1, Math.min(n, k));
60
+ let centres = Array.from({ length: count }, (_, i) => sorted[Math.min(n - 1, Math.floor(((i + 0.5) * n) / count))]!);
61
+ const nearest = (v: number): number => {
62
+ let best = 0;
63
+ for (let i = 1; i < centres.length; i++) if (Math.abs(centres[i]! - v) < Math.abs(centres[best]! - v)) best = i;
64
+ return best;
65
+ };
66
+ for (let it = 0; it < 20; it++) {
67
+ const sum = centres.map(() => 0);
68
+ const num = centres.map(() => 0);
69
+ for (const v of values) {
70
+ const i = nearest(v);
71
+ sum[i] = sum[i]! + v;
72
+ num[i] = num[i]! + 1;
73
+ }
74
+ const next = centres.map((c, i) => (num[i]! > 0 ? sum[i]! / num[i]! : c));
75
+ const moved = next.some((c, i) => Math.abs(c - centres[i]!) > 1e-9);
76
+ centres = next;
77
+ if (!moved) break;
78
+ }
79
+ // The join of the near centres: the weighted mean of each run of centres closer than `merge`.
80
+ const weight = centres.map(() => 0);
81
+ for (const v of values) {
82
+ const i = nearest(v);
83
+ weight[i] = weight[i]! + 1;
84
+ }
85
+ const order = centres
86
+ .map((c, i) => ({ c, w: weight[i]! }))
87
+ .filter((x) => x.w > 0)
88
+ .sort((p, q) => p.c - q.c);
89
+ const joined: { c: number; w: number }[] = [];
90
+ for (const x of order) {
91
+ const last = joined[joined.length - 1];
92
+ if (last && x.c - last.c < merge) {
93
+ const w = last.w + x.w;
94
+ last.c = (last.c * last.w + x.c * x.w) / w;
95
+ last.w = w;
96
+ } else joined.push({ ...x });
97
+ }
98
+ centres = joined.map((x) => x.c);
99
+ return values.map((v) => centres[nearest(v)]!);
100
+ }
101
+
102
+ /** One cell of the mesh: its place in the grid, the histogram of the angles of its lines, and the lines through it. */
103
+ interface MeshCell {
104
+ readonly cx: number;
105
+ readonly cy: number;
106
+ readonly bins: number[];
107
+ /** The sum of the angles of the lines in each bin. */
108
+ readonly angles: number[];
109
+ readonly lines: Set<number>;
110
+ }
111
+
112
+ /**
113
+ * This function gives the waypoints of the control mesh (HWY-004, GBEC of Cui, Zhou, Qu, Wong and Li 2008).
114
+ * A grid of cells covers the board. The main direction of each cell is the peak of a histogram of the angles of the straight lines through it.
115
+ * The segment of the mesh in a cell goes through its middle, across that direction. The crossings of the lines with the segment form clusters by a 1D k-means.
116
+ * The centre of the cluster of each crossing is a waypoint of its edge. The waypoints of an edge are in the order of the edge.
117
+ */
118
+ export function meshWaypoints(input: HighwayInput, finder: WayFinder, o: Readonly<MeshOptions>): Map<string, Point[]> {
119
+ const plugs = edgePlugs(input.graph);
120
+ const out = new Map<string, Point[]>();
121
+ if (plugs.length === 0) return out;
122
+ const unit = boxUnit(input.graph);
123
+ const side = Math.max(1e-3, o.cell * unit);
124
+ const lines = plugs.map(({ ps, pt }) => ({ a: finder.out(ps), b: finder.out(pt) }));
125
+ let x0 = Infinity;
126
+ let y0 = Infinity;
127
+ for (const l of lines) {
128
+ x0 = Math.min(x0, l.a.x, l.b.x);
129
+ y0 = Math.min(y0, l.a.y, l.b.y);
130
+ }
131
+ const cells = new Map<string, MeshCell>();
132
+ lines.forEach((l, e) => {
133
+ const len = dist(l.a, l.b);
134
+ if (len <= 1e-9) return;
135
+ const ang = ((Math.atan2(l.b.y - l.a.y, l.b.x - l.a.x) % Math.PI) + Math.PI) % Math.PI;
136
+ const bin = Math.min(BINS - 1, Math.floor((ang / Math.PI) * BINS));
137
+ const steps = Math.max(1, Math.ceil((2 * len) / side));
138
+ for (let s = 0; s <= steps; s++) {
139
+ const u = s / steps;
140
+ const cx = Math.floor((l.a.x + (l.b.x - l.a.x) * u - x0) / side);
141
+ const cy = Math.floor((l.a.y + (l.b.y - l.a.y) * u - y0) / side);
142
+ const id = `${cx},${cy}`;
143
+ let cell = cells.get(id);
144
+ if (!cell) {
145
+ cell = { cx, cy, bins: new Array<number>(BINS).fill(0), angles: new Array<number>(BINS).fill(0), lines: new Set() };
146
+ cells.set(id, cell);
147
+ }
148
+ if (!cell.lines.has(e)) {
149
+ cell.lines.add(e);
150
+ cell.bins[bin] = cell.bins[bin]! + 1;
151
+ cell.angles[bin] = cell.angles[bin]! + ang;
152
+ }
153
+ }
154
+ });
155
+ const capacity = Math.max(1, o.capacity);
156
+ const maxAngle = (Math.max(0, Math.min(90, o.angle)) * Math.PI) / 180;
157
+ const marks: { t: number; p: Point }[][] = lines.map(() => []);
158
+ for (const id of [...cells.keys()].sort()) {
159
+ const cell = cells.get(id)!;
160
+ if (cell.lines.size < 2) continue;
161
+ let peak = 0;
162
+ for (let i = 1; i < BINS; i++) if (cell.bins[i]! > cell.bins[peak]!) peak = i;
163
+ // The main direction is the mean angle of the lines in the peak bin.
164
+ const theta = cell.angles[peak]! / cell.bins[peak]!;
165
+ const mid = { x: x0 + (cell.cx + 0.5) * side, y: y0 + (cell.cy + 0.5) * side };
166
+ const across = { x: -Math.sin(theta), y: Math.cos(theta) };
167
+ // The segment goes from side to side of the cell.
168
+ const half = side / 2 / Math.max(Math.abs(across.x), Math.abs(across.y));
169
+ const c = { x: mid.x - across.x * half, y: mid.y - across.y * half };
170
+ const d = { x: mid.x + across.x * half, y: mid.y + across.y * half };
171
+ const hits: { e: number; t: number; u: number }[] = [];
172
+ for (const e of [...cell.lines].sort((p, q) => p - q)) {
173
+ const l = lines[e]!;
174
+ const len = dist(l.a, l.b);
175
+ const cos = Math.abs(((l.b.x - l.a.x) * Math.cos(theta) + (l.b.y - l.a.y) * Math.sin(theta)) / len);
176
+ if (Math.acos(Math.min(1, cos)) > maxAngle + 1e-9) continue;
177
+ const x = crossing(l.a, l.b, c, d);
178
+ if (x) hits.push({ e, t: x.t, u: x.u });
179
+ }
180
+ if (hits.length < 2) continue;
181
+ const centres = clusters1d(
182
+ hits.map((h) => h.u * 2 * half),
183
+ Math.ceil(hits.length / capacity),
184
+ Math.max(0, o.merge) * unit,
185
+ );
186
+ const size = new Map<number, number>();
187
+ for (const v of centres) size.set(v, (size.get(v) ?? 0) + 1);
188
+ hits.forEach((h, i) => {
189
+ const v = centres[i]!;
190
+ if ((size.get(v) ?? 0) < 2) return;
191
+ marks[h.e]!.push({ t: h.t, p: { x: c.x + across.x * v, y: c.y + across.y * v } });
192
+ });
193
+ }
194
+ plugs.forEach(({ edge }, e) => {
195
+ const list = marks[e]!.sort((p, q) => p.t - q.t).map((m) => m.p);
196
+ if (list.length > 0) out.set(edge.id, list);
197
+ });
198
+ return out;
199
+ }
200
+
201
+ /**
202
+ * This function gives the highways of the control mesh (HWY-004). Each edge goes through the centres of its clusters on the mesh, along the corner graph.
203
+ * The centres of one cluster are one point, so the edges of a cluster share the ways between them. The stretches that `minSize` ways share become highways.
204
+ */
205
+ export function meshPlan(input: HighwayInput, o: Readonly<MeshOptions>): HighwayPlan {
206
+ const finder = new WayFinder(input.graph.nodes, { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) });
207
+ const waypoints = meshWaypoints(input, finder, o);
208
+ return tracePlan(input, finder, waypoints, { snap: 0, minSize: o.minSize, key: "mesh" });
209
+ }
@@ -0,0 +1,204 @@
1
+ import { boardUnit } from "../../bench/measures/routes.ts";
2
+ import { dist, type Point } from "../../geom.ts";
3
+ import { WayFinder, type WayOptions } from "../../ways.ts";
4
+ import { firstRoutes } from "../base.ts";
5
+ import type { DensitySource } from "../kdeeb/index.ts";
6
+ import type { HighwayInput, HighwayPlan } from "../model.ts";
7
+ import { resample } from "../quickbundles/index.ts";
8
+ import { edgePlugs } from "../stretches.ts";
9
+ import { outOfBoxes, tracePlan } from "../trace.ts";
10
+
11
+ /** The local fit of the moving least squares: a line, or a parabola in the frame of the line. */
12
+ export type MlsFit = "line" | "parabola";
13
+
14
+ /** The values of `MlsFit`. */
15
+ export const MLS_FITS: readonly MlsFit[] = Object.freeze(["line", "parabola"]);
16
+
17
+ /** The options of the highway strategy `mlseb` (HWY-008 of the notes). */
18
+ export interface MlsebOptions extends Pick<WayOptions, "margin" | "k"> {
19
+ /** The first lines of the edges: their straight lines, or their first routes. The default is `lines`. */
20
+ readonly source: DensitySource;
21
+ /** The radius of the fit at the first step, as a multiple of `boardUnit`: the median short side of the boxes. The default is 2. */
22
+ readonly radius: number;
23
+ /** Each step multiplies the radius by this value. The default is 0.8. */
24
+ readonly decay: number;
25
+ /** The number of steps. The default is 6. */
26
+ readonly steps: number;
27
+ /** The local fit. The default is `line`. */
28
+ readonly fit: MlsFit;
29
+ /** The weight of the angle, from 0 to 1. With 1, a sample at a right angle to the point has no weight. The default is 1. */
30
+ readonly angleWeight: number;
31
+ /** The spacing of the sample points of each edge, in units of the board. The default is 8. */
32
+ readonly spacing: number;
33
+ /** The side of the grid that the points of the edges snap to after the steps, in units of the board. The default is 8. */
34
+ readonly snap: number;
35
+ /** A highway has this number of edges or more. The default is 2. */
36
+ readonly minSize: number;
37
+ }
38
+
39
+ /** The default options of the strategy `mlseb`. */
40
+ export const MLSEB_DEFAULTS: Readonly<Required<Omit<MlsebOptions, "margin" | "k">>> = Object.freeze({
41
+ source: "lines",
42
+ radius: 2,
43
+ decay: 0.8,
44
+ steps: 6,
45
+ fit: "line",
46
+ angleWeight: 1,
47
+ spacing: 8,
48
+ snap: 8,
49
+ minSize: 2,
50
+ });
51
+
52
+ /** One sample point of an edge, with the unit tangent of its edge there. */
53
+ interface Sample {
54
+ readonly p: Point;
55
+ readonly tx: number;
56
+ readonly ty: number;
57
+ }
58
+
59
+ /** This function gives the unit tangent of a polyline at its point `i`, from the point before it to the point after it. */
60
+ function tangent(line: readonly Point[], i: number): { tx: number; ty: number } {
61
+ const a = line[Math.max(0, i - 1)]!;
62
+ const b = line[Math.min(line.length - 1, i + 1)]!;
63
+ const l = Math.hypot(b.x - a.x, b.y - a.y);
64
+ return l > 1e-12 ? { tx: (b.x - a.x) / l, ty: (b.y - a.y) / l } : { tx: 1, ty: 0 };
65
+ }
66
+
67
+ /**
68
+ * This function gives the point of the moving least squares fit at the point `p`. The weight of a sample `q` is `exp(-|q-p|^2 / r^2)`.
69
+ * The angle term multiplies it by `1 - a + a |cos|`, with `cos` the cosine of the angle between the two tangents.
70
+ * The fit `line` gives the projection of `p` on the weighted line of the samples. The fit `parabola` fits `s = c0 + c1 t + c2 t^2` in the frame of that line.
71
+ * Without weight, the function gives `p`.
72
+ */
73
+ export function mlsPoint(p: Point, t: { tx: number; ty: number }, near: readonly Sample[], r: number, angleWeight: number, fit: MlsFit): Point {
74
+ const a = Math.max(0, Math.min(1, angleWeight));
75
+ const ws: number[] = [];
76
+ let sw = 0;
77
+ let cx = 0;
78
+ let cy = 0;
79
+ for (const s of near) {
80
+ const d2 = (s.p.x - p.x) ** 2 + (s.p.y - p.y) ** 2;
81
+ const w = Math.exp(-d2 / (r * r)) * (1 - a + a * Math.abs(s.tx * t.tx + s.ty * t.ty));
82
+ ws.push(w);
83
+ sw += w;
84
+ cx += w * s.p.x;
85
+ cy += w * s.p.y;
86
+ }
87
+ if (sw <= 1e-12) return p;
88
+ cx /= sw;
89
+ cy /= sw;
90
+ let sxx = 0;
91
+ let syy = 0;
92
+ let sxy = 0;
93
+ near.forEach((s, i) => {
94
+ const w = ws[i]!;
95
+ sxx += w * (s.p.x - cx) ** 2;
96
+ syy += w * (s.p.y - cy) ** 2;
97
+ sxy += w * (s.p.x - cx) * (s.p.y - cy);
98
+ });
99
+ const theta = 0.5 * Math.atan2(2 * sxy, sxx - syy);
100
+ const ux = Math.cos(theta);
101
+ const uy = Math.sin(theta);
102
+ const tp = (p.x - cx) * ux + (p.y - cy) * uy;
103
+ if (fit === "line") return { x: cx + tp * ux, y: cy + tp * uy };
104
+ // The parabola: the normal equations of the weighted least squares in the frame (u, n), with n = (-uy, ux).
105
+ const m = [0, 0, 0, 0, 0];
106
+ const v = [0, 0, 0];
107
+ near.forEach((s, i) => {
108
+ const w = ws[i]!;
109
+ const tq = (s.p.x - cx) * ux + (s.p.y - cy) * uy;
110
+ const sq = -(s.p.x - cx) * uy + (s.p.y - cy) * ux;
111
+ let pw = w;
112
+ for (let k = 0; k < 5; k++) {
113
+ m[k] = m[k]! + pw;
114
+ if (k < 3) v[k] = v[k]! + pw * sq;
115
+ pw *= tq;
116
+ }
117
+ });
118
+ const A = [
119
+ [m[0]!, m[1]!, m[2]!],
120
+ [m[1]!, m[2]!, m[3]!],
121
+ [m[2]!, m[3]!, m[4]!],
122
+ ];
123
+ const c = solve3(A, v);
124
+ if (!c) return { x: cx + tp * ux, y: cy + tp * uy };
125
+ const sp = c[0]! + c[1]! * tp + c[2]! * tp * tp;
126
+ return { x: cx + tp * ux - sp * uy, y: cy + tp * uy + sp * ux };
127
+ }
128
+
129
+ /** This function solves a system of three linear equations by Cramer's rule. A system without one solution gives `undefined`. */
130
+ function solve3(A: readonly (readonly number[])[], b: readonly number[]): number[] | undefined {
131
+ const det = (m: readonly (readonly number[])[]): number =>
132
+ m[0]![0]! * (m[1]![1]! * m[2]![2]! - m[1]![2]! * m[2]![1]!) - m[0]![1]! * (m[1]![0]! * m[2]![2]! - m[1]![2]! * m[2]![0]!) + m[0]![2]! * (m[1]![0]! * m[2]![1]! - m[1]![1]! * m[2]![0]!);
133
+ const d = det(A);
134
+ const scale = Math.abs(A[0]![0]!) * Math.abs(A[1]![1]!) * Math.abs(A[2]![2]!);
135
+ if (!Number.isFinite(d) || Math.abs(d) <= 1e-12 * Math.max(1, scale)) return undefined;
136
+ return [0, 1, 2].map((k) => det(A.map((row, i) => row.map((x, j) => (j === k ? b[i]! : x)))) / d);
137
+ }
138
+
139
+ /**
140
+ * This function gives the polylines of the moving least squares bundling (MLSEB, Wu, Zeng, Zhu and Yu 2017). The ends of each polyline stay.
141
+ * In each step, each inner point moves to the local fit of the samples of all edges within the radius. Then the radius shrinks by `decay`.
142
+ * A move is at most the radius. A point in a box moves out of it.
143
+ */
144
+ export function mlsebLines(input: HighwayInput, finder: WayFinder, o: Readonly<MlsebOptions>): Map<string, Point[]> {
145
+ const plugs = edgePlugs(input.graph);
146
+ const out = new Map<string, Point[]>();
147
+ if (plugs.length === 0) return out;
148
+ const routes = o.source === "routes" ? firstRoutes(input) : undefined;
149
+ const spacing = Math.max(1e-3, o.spacing);
150
+ let lines: Point[][] = plugs.map(({ edge, ps, pt }) => {
151
+ const a = finder.out(ps);
152
+ const b = finder.out(pt);
153
+ const route = routes?.get(edge.id);
154
+ const base = route && route.length > 2 ? [a, ...route.slice(1, -1), b] : [a, b];
155
+ let len = 0;
156
+ for (let i = 1; i < base.length; i++) len += dist(base[i - 1]!, base[i]!);
157
+ return resample(base, Math.max(2, Math.min(129, Math.ceil(len / spacing) + 1)));
158
+ });
159
+ const pad = finder.margin / 2;
160
+ let r = Math.max(1e-3, o.radius * boardUnit(input.graph));
161
+ for (let step = 0; step < Math.max(0, Math.round(o.steps)); step++) {
162
+ // The grid of the samples, with cells of side `r`. A sample within `2 r` is in the 5 by 5 cells around a point.
163
+ const grid = new Map<string, Sample[]>();
164
+ const cellOf = (p: Point): [number, number] => [Math.floor(p.x / r), Math.floor(p.y / r)];
165
+ for (const l of lines)
166
+ l.forEach((p, i) => {
167
+ const [gx, gy] = cellOf(p);
168
+ const k = `${gx},${gy}`;
169
+ const s = { p, ...tangent(l, i) };
170
+ const list = grid.get(k);
171
+ if (list) list.push(s);
172
+ else grid.set(k, [s]);
173
+ });
174
+ const reach = 2 * r;
175
+ lines = lines.map((l) =>
176
+ l.map((p, i) => {
177
+ if (i === 0 || i === l.length - 1) return p;
178
+ const [gx, gy] = cellOf(p);
179
+ const near: Sample[] = [];
180
+ for (let dy = -2; dy <= 2; dy++)
181
+ for (let dx = -2; dx <= 2; dx++) for (const s of grid.get(`${gx + dx},${gy + dy}`) ?? []) if (dist(s.p, p) <= reach) near.push(s);
182
+ let q = mlsPoint(p, tangent(l, i), near, r, o.angleWeight, o.fit);
183
+ const d = dist(p, q);
184
+ if (d > r) q = { x: p.x + ((q.x - p.x) * r) / d, y: p.y + ((q.y - p.y) * r) / d };
185
+ return outOfBoxes(finder.obstacles, q, pad);
186
+ }),
187
+ );
188
+ r *= Math.max(0, Math.min(1, o.decay));
189
+ if (r <= 1e-3) break;
190
+ }
191
+ plugs.forEach(({ edge }, e) => out.set(edge.id, lines[e]!));
192
+ return out;
193
+ }
194
+
195
+ /**
196
+ * This function gives the highways of the moving least squares bundling (HWY-008). The points of `mlsebLines` snap to a grid of side `snap`.
197
+ * The ways go through the corner graph, and each stretch that `minSize` ways share is a highway.
198
+ */
199
+ export function mlsebPlan(input: HighwayInput, o: Readonly<MlsebOptions>): HighwayPlan {
200
+ const finder = new WayFinder(input.graph.nodes, { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) });
201
+ const lines = mlsebLines(input, finder, o);
202
+ const waypoints = new Map([...lines].map(([edge, l]) => [edge, l.slice(1, -1)] as const));
203
+ return tracePlan(input, finder, waypoints, { snap: o.snap, minSize: o.minSize, key: "mlseb" });
204
+ }
@@ -0,0 +1,248 @@
1
+ import { center, dist, polylineLength, type Point } from "../../geom.ts";
2
+ import { buildSections, type Section } from "../../sections.ts";
3
+ import { WayFinder } from "../../ways.ts";
4
+ import { firstRoutes } from "../base.ts";
5
+ import type { HighwayInput, HighwayPlan } from "../model.ts";
6
+ import { snapPlan, type SnapLine, type SnapMember } from "../snap.ts";
7
+ import { edgePlugs } from "../stretches.ts";
8
+
9
+ /** The allocation of the edges to the hubs: `single` (each node to its nearest hub) or `multiple` (each edge to its best pair of hubs). */
10
+ export type HubAllocation = "single" | "multiple";
11
+
12
+ /** The values of `HubAllocation`. */
13
+ export const HUB_ALLOCATIONS: readonly HubAllocation[] = Object.freeze(["single", "multiple"]);
14
+
15
+ /** The candidate points of the hubs: the corners of the corner graph, or the centres of the cells of the tree of sections. */
16
+ export type HubCandidates = "corners" | "sections";
17
+
18
+ /** The values of `HubCandidates`. */
19
+ export const HUB_CANDIDATES: readonly HubCandidates[] = Object.freeze(["corners", "sections"]);
20
+
21
+ /** The options of the highway strategy `p-hub` (HWY-041 of the note on highways). */
22
+ export interface PHubOptions {
23
+ /** The number of hubs. The value 0 gives the root of the number of nodes, rounded. The default is 0. */
24
+ readonly p: number;
25
+ /** The cost of a length on a link between two hubs against a length off the links, from 0 to 1. The default is 0.5. */
26
+ readonly alpha: number;
27
+ /** The allocation of the edges to the hubs. The default is `single`. */
28
+ readonly allocation: HubAllocation;
29
+ /** The candidate points of the hubs. The default is `corners`. */
30
+ readonly candidates: HubCandidates;
31
+ /** An edge rides a link only when its way through the two hubs is this many times its first route or less. The default is 1.25. */
32
+ readonly detour: number;
33
+ /** The number of passes of the local search with one swap of a hub. The default is 1. */
34
+ readonly swaps: number;
35
+ /** A link with fewer edges than this number is not a highway. The default is 2. */
36
+ readonly minSize: number;
37
+ }
38
+
39
+ /** The default options of the strategy `p-hub`. */
40
+ export const P_HUB_DEFAULTS: Readonly<PHubOptions> = Object.freeze({ p: 0, alpha: 0.5, allocation: "single", candidates: "corners", detour: 1.25, swaps: 1, minSize: 2 });
41
+
42
+ /** The largest number of candidate points. A larger set keeps each `k`-th point. */
43
+ const MAX_CANDIDATES = 300;
44
+
45
+ /** One edge for the hub location: its two plug points, the centres of its two nodes, and the length of its first route. */
46
+ interface HubEdge {
47
+ readonly edge: string;
48
+ readonly s: Point;
49
+ readonly t: Point;
50
+ readonly u: string;
51
+ readonly v: string;
52
+ readonly base: number;
53
+ }
54
+
55
+ /** This function gives the centres of the cells of each section of the tree. */
56
+ function sectionCentres(root: Section): Point[] {
57
+ const out: Point[] = [];
58
+ const walk = (s: Section): void => {
59
+ out.push(center(s.cell));
60
+ s.children.forEach(walk);
61
+ };
62
+ walk(root);
63
+ return out;
64
+ }
65
+
66
+ /** This function gives the candidate points of the hubs, at most `MAX_CANDIDATES`, without the copies. */
67
+ export function hubCandidates(input: HighwayInput, candidates: HubCandidates): Point[] {
68
+ const raw = candidates === "sections" ? sectionCentres(buildSections(input.graph.nodes)) : [...new WayFinder(input.graph.nodes).corners.points];
69
+ const seen = new Set<string>();
70
+ const points = raw.filter((p) => {
71
+ const key = `${Math.round(p.x)},${Math.round(p.y)}`;
72
+ if (seen.has(key)) return false;
73
+ seen.add(key);
74
+ return true;
75
+ });
76
+ if (points.length <= MAX_CANDIDATES) return points;
77
+ const k = Math.ceil(points.length / MAX_CANDIDATES);
78
+ return points.filter((_, i) => i % k === 0);
79
+ }
80
+
81
+ /**
82
+ * This function chooses `p` points of `candidates` as the p-median of `points`. The greedy step adds the candidate that lowers the sum of the distances most.
83
+ * Then at most 4 passes of swaps lower the sum more. The result is the indices of the chosen candidates.
84
+ */
85
+ export function pMedian(points: readonly Point[], candidates: readonly Point[], p: number): number[] {
86
+ const chosen: number[] = [];
87
+ const near = points.map(() => Infinity);
88
+ while (chosen.length < Math.min(p, candidates.length)) {
89
+ let pick = -1;
90
+ let best = Infinity;
91
+ for (let c = 0; c < candidates.length; c++) {
92
+ if (chosen.includes(c)) continue;
93
+ let sum = 0;
94
+ for (let i = 0; i < points.length; i++) sum += Math.min(near[i]!, dist(points[i]!, candidates[c]!));
95
+ if (sum < best - 1e-9) [best, pick] = [sum, c];
96
+ }
97
+ if (pick < 0) break;
98
+ chosen.push(pick);
99
+ points.forEach((q, i) => {
100
+ near[i] = Math.min(near[i]!, dist(q, candidates[pick]!));
101
+ });
102
+ }
103
+ // The swaps of Teitz and Bart: one chosen candidate changes for one other candidate while the sum falls.
104
+ // The nearest and the second nearest chosen candidate of each point give the sum after a swap in one pass over the points.
105
+ const d = candidates.map((c) => points.map((q) => dist(q, c)));
106
+ let first: number[] = [];
107
+ let second: number[] = [];
108
+ const nearTwo = (): void => {
109
+ first = points.map(() => -1);
110
+ second = points.map(() => -1);
111
+ points.forEach((_, p) => {
112
+ chosen.forEach((_, j) => {
113
+ const v = d[chosen[j]!]![p]!;
114
+ if (first[p]! < 0 || v < d[chosen[first[p]!]!]![p]!) [second[p], first[p]] = [first[p]!, j];
115
+ else if (second[p]! < 0 || v < d[chosen[second[p]!]!]![p]!) second[p] = j;
116
+ });
117
+ });
118
+ };
119
+ const at = (j: number, p: number): number => (j < 0 ? Infinity : d[chosen[j]!]![p]!);
120
+ nearTwo();
121
+ let sum = points.reduce((s, _, p) => s + at(first[p]!, p), 0);
122
+ for (let pass = 0; pass < 4; pass++) {
123
+ let better = false;
124
+ for (let i = 0; i < chosen.length; i++)
125
+ for (let c = 0; c < candidates.length; c++) {
126
+ if (chosen.includes(c)) continue;
127
+ let v = 0;
128
+ for (let p = 0; p < points.length; p++) v += Math.min(d[c]![p]!, first[p] === i ? at(second[p]!, p) : at(first[p]!, p));
129
+ if (v < sum - 1e-9) {
130
+ [sum, better] = [v, true];
131
+ chosen[i] = c;
132
+ nearTwo();
133
+ }
134
+ }
135
+ if (!better) break;
136
+ }
137
+ return chosen;
138
+ }
139
+
140
+ /** The two hubs of one edge, and its cost and its length on them. */
141
+ interface HubTrip {
142
+ readonly h: number;
143
+ readonly k: number;
144
+ readonly cost: number;
145
+ }
146
+
147
+ /**
148
+ * This function gives the trip of each edge through the hubs `hubs`, or `undefined` for an edge that does not ride.
149
+ * The cost of a trip is `d(s, h) + alpha d(h, k) + d(k, t)`. An edge rides only when `h` and `k` differ and the cost is below its first route.
150
+ * Also, its length `d(s, h) + d(h, k) + d(k, t)` must be `detour` times its first route or less.
151
+ */
152
+ function hubTrips(edges: readonly HubEdge[], nodes: ReadonlyMap<string, Point>, hubs: readonly Point[], o: Readonly<PHubOptions>): (HubTrip | undefined)[] {
153
+ const nearest = (p: Point): number => {
154
+ let best = 0;
155
+ for (let i = 1; i < hubs.length; i++) if (dist(p, hubs[i]!) < dist(p, hubs[best]!)) best = i;
156
+ return best;
157
+ };
158
+ const home = new Map<string, number>();
159
+ for (const [id, c] of nodes) home.set(id, nearest(c));
160
+ return edges.map((e) => {
161
+ const trip = (h: number, k: number): HubTrip | undefined => {
162
+ if (h === k) return undefined;
163
+ const a = dist(e.s, hubs[h]!);
164
+ const m = dist(hubs[h]!, hubs[k]!);
165
+ const b = dist(hubs[k]!, e.t);
166
+ const cost = a + o.alpha * m + b;
167
+ if (cost >= e.base || a + m + b > o.detour * e.base) return undefined;
168
+ return { h, k, cost };
169
+ };
170
+ if (o.allocation === "single") return trip(home.get(e.u)!, home.get(e.v)!);
171
+ let best: HubTrip | undefined;
172
+ for (let h = 0; h < hubs.length; h++)
173
+ for (let k = 0; k < hubs.length; k++) {
174
+ const t = trip(h, k);
175
+ if (t && (!best || t.cost < best.cost - 1e-9)) best = t;
176
+ }
177
+ return best;
178
+ });
179
+ }
180
+
181
+ /** This function gives the total cost of the edges: the cost of each trip, or the first route of an edge that does not ride. */
182
+ function totalCost(edges: readonly HubEdge[], trips: readonly (HubTrip | undefined)[]): number {
183
+ return edges.reduce((s, e, i) => s + (trips[i]?.cost ?? e.base), 0);
184
+ }
185
+
186
+ /**
187
+ * This function gives the highways of the hub location (HWY-041, the p-hub median of O'Kelly 1987). The p-median of the plugs chooses `p` hubs from the candidates.
188
+ * Each swap pass changes one hub for one other candidate while the total cost falls. Each link between two hubs with `minSize` edges or more is a highway.
189
+ * The edges that ride a link snap to it, from their first hub to their second hub.
190
+ */
191
+ export function pHubPlan(input: HighwayInput, o: Readonly<PHubOptions>): HighwayPlan {
192
+ const graph = input.graph;
193
+ if (graph.nodes.length === 0) return { highways: [] };
194
+ const routes = firstRoutes(input);
195
+ const edges: HubEdge[] = edgePlugs(graph).map(({ edge, ps, pt }) => {
196
+ const first = routes.get(edge.id);
197
+ return { edge: edge.id, s: ps.point, t: pt.point, u: edge.source.node, v: edge.target.node, base: first ? polylineLength(first) : dist(ps.point, pt.point) };
198
+ });
199
+ const nodes = new Map(graph.nodes.map((n) => [n.id, center(n.box)] as const));
200
+ const candidates = hubCandidates(input, o.candidates);
201
+ const p = Math.round(o.p) > 0 ? Math.round(o.p) : Math.max(2, Math.round(Math.sqrt(graph.nodes.length)));
202
+ if (candidates.length < 2 || edges.length === 0) return { highways: [] };
203
+ const chosen = pMedian(
204
+ edges.flatMap((e) => [e.s, e.t]),
205
+ candidates,
206
+ p,
207
+ );
208
+ const hubsOf = (list: readonly number[]): Point[] => list.map((c) => candidates[c]!);
209
+ let trips = hubTrips(edges, nodes, hubsOf(chosen), o);
210
+ let cost = totalCost(edges, trips);
211
+ for (let pass = 0; pass < Math.max(0, Math.round(o.swaps)); pass++) {
212
+ let better = false;
213
+ for (let i = 0; i < chosen.length; i++) {
214
+ let pick = -1;
215
+ let pickTrips = trips;
216
+ for (let c = 0; c < candidates.length; c++) {
217
+ if (chosen.includes(c)) continue;
218
+ const next = chosen.map((x, j) => (j === i ? c : x));
219
+ const t = hubTrips(edges, nodes, hubsOf(next), o);
220
+ const v = totalCost(edges, t);
221
+ if (v < cost - 1e-9) [cost, pick, pickTrips] = [v, c, t];
222
+ }
223
+ if (pick >= 0) {
224
+ chosen[i] = pick;
225
+ trips = pickTrips;
226
+ better = true;
227
+ }
228
+ }
229
+ if (!better) break;
230
+ }
231
+ const hubs = hubsOf(chosen);
232
+ const links = new Map<string, { a: number; b: number; members: SnapMember[] }>();
233
+ edges.forEach((e, i) => {
234
+ const t = trips[i];
235
+ if (!t) return;
236
+ const [a, b] = t.h < t.k ? [t.h, t.k] : [t.k, t.h];
237
+ const key = `${a}:${b}`;
238
+ const link = links.get(key) ?? { a, b, members: [] };
239
+ link.members.push({ edge: e.edge, from: hubs[t.h]!, to: hubs[t.k]!, at: 0 });
240
+ links.set(key, link);
241
+ });
242
+ const min = Math.max(2, Math.round(o.minSize));
243
+ const lines: SnapLine[] = [...links.values()]
244
+ .filter((l) => l.members.length >= min)
245
+ .sort((x, y) => x.a - y.a || x.b - y.b)
246
+ .map((l) => ({ key: `hub${l.a}-${l.b}`, a: hubs[l.a]!, b: hubs[l.b]!, members: l.members }));
247
+ return snapPlan(input, lines, { minSize: min });
248
+ }