@mark1russell7/highways 0.4.2 → 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 (78) hide show
  1. package/README.md +59 -2
  2. package/build/index.d.ts +2333 -193
  3. package/build/index.d.ts.map +1 -1
  4. package/build/index.js +13042 -2334
  5. package/build/index.js.map +1 -1
  6. package/package.json +12 -1
  7. package/src/bench/exact/index.ts +258 -0
  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/measures/index.ts +1 -0
  12. package/src/bench/measures/lanes.ts +304 -0
  13. package/src/bench/measures/levels.ts +86 -2
  14. package/src/bench/screen/activation.ts +3 -0
  15. package/src/bench/screen/bounds.ts +140 -0
  16. package/src/bench/screen/compare.ts +26 -0
  17. package/src/bench/screen/exact-set.ts +100 -0
  18. package/src/bench/screen/exercise.ts +290 -0
  19. package/src/bench/screen/forms.ts +25 -5
  20. package/src/bench/screen/gap.ts +316 -0
  21. package/src/bench/screen/hash.ts +7 -76
  22. package/src/bench/screen/index.ts +6 -0
  23. package/src/bench/screen/measures.ts +89 -1
  24. package/src/bench/screen/portfolio-pick.ts +20 -0
  25. package/src/bench/screen/screen.ts +78 -2
  26. package/src/bench/screen/set.ts +3 -2
  27. package/src/channels.ts +56 -0
  28. package/src/highways/areas/index.ts +160 -0
  29. package/src/highways/confluent/index.ts +154 -0
  30. package/src/highways/confluent/k23.test-util.ts +8 -0
  31. package/src/highways/consensus/index.ts +129 -0
  32. package/src/highways/crossbars/index.ts +184 -0
  33. package/src/highways/cubu/index.ts +26 -0
  34. package/src/highways/diffusion/index.ts +103 -0
  35. package/src/highways/divided/index.ts +111 -0
  36. package/src/highways/edge-paths/index.ts +155 -0
  37. package/src/highways/fdeb/index.ts +193 -0
  38. package/src/highways/fixtures.test-util.ts +71 -0
  39. package/src/highways/flow-clusters/index.ts +143 -0
  40. package/src/highways/frechet/index.ts +232 -0
  41. package/src/highways/growth/index.ts +148 -0
  42. package/src/highways/hdbscan/index.ts +250 -0
  43. package/src/highways/hierarchy/index.ts +140 -0
  44. package/src/highways/hough/index.ts +182 -0
  45. package/src/highways/hub-arcs/index.ts +181 -0
  46. package/src/highways/index.ts +79 -0
  47. package/src/highways/kdeeb/index.ts +355 -0
  48. package/src/highways/lanes/index.ts +189 -0
  49. package/src/highways/link-communities/index.ts +189 -0
  50. package/src/highways/mesh/index.ts +209 -0
  51. package/src/highways/mingle/index.ts +171 -0
  52. package/src/highways/mlseb/index.ts +204 -0
  53. package/src/highways/nested/index.ts +143 -0
  54. package/src/highways/p-hub/index.ts +248 -0
  55. package/src/highways/power/index.ts +117 -0
  56. package/src/highways/resistance/index.ts +159 -0
  57. package/src/highways/ridges/index.ts +328 -0
  58. package/src/highways/sbeb/index.ts +303 -0
  59. package/src/highways/scale-space/index.ts +135 -0
  60. package/src/highways/snap.ts +110 -0
  61. package/src/highways/soft-keys/index.ts +114 -0
  62. package/src/highways/spectral/index.ts +391 -0
  63. package/src/highways/spiral/index.ts +205 -0
  64. package/src/highways/territories/index.ts +150 -0
  65. package/src/highways/trace.ts +153 -0
  66. package/src/highways/traclus/index.ts +296 -0
  67. package/src/index.ts +2 -0
  68. package/src/portfolio/bands.ts +33 -0
  69. package/src/portfolio/fit.ts +29 -0
  70. package/src/portfolio/index.ts +6 -0
  71. package/src/portfolio/key.ts +98 -0
  72. package/src/portfolio/members.ts +171 -0
  73. package/src/portfolio/router.ts +133 -0
  74. package/src/portfolio/sets.ts +28 -0
  75. package/src/routers/shared.ts +56 -3
  76. package/src/strategies/builtins.ts +376 -1
  77. package/src/strategies/schemas.ts +460 -1
  78. package/src/visibility.ts +5 -0
@@ -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,143 @@
1
+ import { boardUnit } from "../../bench/measures/routes.ts";
2
+ import { polylineLength, type Point } from "../../geom.ts";
3
+ import { firstRoutes } from "../base.ts";
4
+ import { groupedPlan, type EdgeGroup, type GroupSpine } from "../grouped.ts";
5
+ import type { HighwayInput, HighwayPlan } from "../model.ts";
6
+ import { mdf, resample, type BundleLine, type BundleOrder } from "../quickbundles/index.ts";
7
+ import { edgePlugs } from "../stretches.ts";
8
+
9
+ /** The options of the highway strategy `nested` (HWY-052 of the note on highways). */
10
+ export interface NestedOptions {
11
+ /** The scale of the closest level, in units of the board. An edge joins a cluster when its distance to the centroid is this value or less. The default is 3. */
12
+ readonly theta: number;
13
+ /** The scale of each level is this value times the scale of the next closer level. The default is 2. */
14
+ readonly ratio: number;
15
+ /** The number of levels. The default is 3. */
16
+ readonly levels: number;
17
+ /** The level of the plan. Level 0 is the closest level, and level `levels - 1` is the far level. The default is 0. */
18
+ readonly level: number;
19
+ /** The number of points of each line after the resampling. The default is 12. */
20
+ readonly points: number;
21
+ /** The line of each edge. The default is `routes`. */
22
+ readonly line: BundleLine;
23
+ /** The order of the edges. The default is `input`. */
24
+ readonly order: BundleOrder;
25
+ /** A cluster with this number of edges or more gets highways. The default is 2. */
26
+ readonly minSize: number;
27
+ /** The spine of each cluster. The default is `shared`. */
28
+ readonly spine: GroupSpine;
29
+ }
30
+
31
+ /** The default options of the strategy `nested`. */
32
+ export const NESTED_DEFAULTS: Readonly<NestedOptions> = Object.freeze({
33
+ theta: 3,
34
+ ratio: 2,
35
+ levels: 3,
36
+ level: 0,
37
+ points: 12,
38
+ line: "routes",
39
+ order: "input",
40
+ minSize: 2,
41
+ spine: "shared",
42
+ });
43
+
44
+ interface Line {
45
+ readonly edge: string;
46
+ readonly line: readonly Point[];
47
+ }
48
+
49
+ /**
50
+ * This function puts lines in clusters in one pass, as QuickBundles does. Each line joins the cluster with the nearest centroid when the distance is `limit` or less.
51
+ * It gives the index of the cluster of each line, and `flip` when the line goes against the centroid.
52
+ */
53
+ function clusterLines(lines: readonly Line[], limit: number): { cluster: number; flip: boolean }[] {
54
+ const clusters: { sum: { x: number; y: number }[]; n: number; centroid: Point[] }[] = [];
55
+ return lines.map(({ line }) => {
56
+ let best = -1;
57
+ let bestD = Infinity;
58
+ let bestFlip = false;
59
+ clusters.forEach((c, ci) => {
60
+ const m = mdf(line, c.centroid);
61
+ if (m.d < bestD) [best, bestD, bestFlip] = [ci, m.d, m.flip];
62
+ });
63
+ if (best < 0 || bestD > limit) {
64
+ clusters.push({ sum: line.map((p) => ({ ...p })), n: 1, centroid: [...line] });
65
+ return { cluster: clusters.length - 1, flip: false };
66
+ }
67
+ const c = clusters[best]!;
68
+ const oriented = bestFlip ? [...line].reverse() : line;
69
+ oriented.forEach((p, i) => {
70
+ c.sum[i]!.x += p.x;
71
+ c.sum[i]!.y += p.y;
72
+ });
73
+ c.n++;
74
+ c.centroid = c.sum.map((p) => ({ x: p.x / c.n, y: p.y / c.n }));
75
+ return { cluster: best, flip: bestFlip };
76
+ });
77
+ }
78
+
79
+ /**
80
+ * This function gives the groups of edges of each level, from the closest level (index 0) to the far level.
81
+ * The far level puts all the edges in clusters at the scale `theta × ratio^(levels - 1)`.
82
+ * Each closer level puts the edges of each cluster of the level above in clusters, at its own scale. Thus each group of a level is in one group of the level above.
83
+ */
84
+ export function nestedGroups(input: HighwayInput, o: Readonly<NestedOptions>): Map<string, EdgeGroup>[] {
85
+ const graph = input.graph;
86
+ const unit = boardUnit(graph);
87
+ const levels = Math.max(1, Math.round(o.levels));
88
+ const k = Math.max(2, Math.round(o.points));
89
+ const routes = o.line === "routes" ? firstRoutes(input) : undefined;
90
+ const lines: Line[] = edgePlugs(graph).map(({ edge, ps, pt }) => ({ edge: edge.id, line: resample(routes?.get(edge.id) ?? [ps.point, pt.point], k) }));
91
+ if (o.order === "length") lines.sort((a, b) => polylineLength(b.line) - polylineLength(a.line) || (a.edge < b.edge ? -1 : a.edge > b.edge ? 1 : 0));
92
+ const out: Map<string, EdgeGroup>[] = [];
93
+ // Each part is a list of lines with one key of the level above. The far level has one part with all the lines.
94
+ let parts: { key: string; lines: Line[] }[] = [{ key: "n", lines }];
95
+ for (let d = levels - 1; d >= 0; d--) {
96
+ const limit = o.theta * o.ratio ** d * unit;
97
+ const groups = new Map<string, EdgeGroup>();
98
+ const next: { key: string; lines: Line[] }[] = [];
99
+ for (const part of parts) {
100
+ const found = clusterLines(part.lines, limit);
101
+ const children = new Map<number, Line[]>();
102
+ found.forEach(({ cluster, flip }, i) => {
103
+ const key = `${part.key}.${cluster}`;
104
+ groups.set(part.lines[i]!.edge, { key, flip });
105
+ const list = children.get(cluster);
106
+ if (list) list.push(part.lines[i]!);
107
+ else children.set(cluster, [part.lines[i]!]);
108
+ });
109
+ for (const [cluster, list] of children) next.push({ key: `${part.key}.${cluster}`, lines: list });
110
+ }
111
+ out.unshift(groups);
112
+ parts = next;
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /**
118
+ * This function gives the number of groups of the closer level `fine` with edges in two groups or more of the level `coarse`.
119
+ * An edge that is not in `coarse` does not count. The groups of `nestedGroups` give 0.
120
+ */
121
+ export function nestingViolations(coarse: ReadonlyMap<string, EdgeGroup>, fine: ReadonlyMap<string, EdgeGroup>): number {
122
+ const parents = new Map<string, Set<string>>();
123
+ for (const [edge, g] of fine) {
124
+ const up = coarse.get(edge);
125
+ if (!up) continue;
126
+ const set = parents.get(g.key);
127
+ if (set) set.add(up.key);
128
+ else parents.set(g.key, new Set([up.key]));
129
+ }
130
+ let n = 0;
131
+ for (const set of parents.values()) if (set.size > 1) n++;
132
+ return n;
133
+ }
134
+
135
+ /**
136
+ * This function gives the highways of one level of the nested clusters (HWY-052). The level is `o.level`, with 0 as the closest level.
137
+ * The clusters of that level with `minSize` edges or more get highways, as in the strategy `quickbundles`.
138
+ */
139
+ export function nestedPlan(input: HighwayInput, o: Readonly<NestedOptions>): HighwayPlan {
140
+ const levels = nestedGroups(input, o);
141
+ const level = Math.max(0, Math.min(levels.length - 1, Math.round(o.level)));
142
+ return groupedPlan(input, levels[level]!, { spine: o.spine, minSize: o.minSize });
143
+ }
@@ -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
+ }