@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,232 @@
1
+ import { boardUnit } from "../../bench/measures/routes.ts";
2
+ import { dist, polylineLength, type Point } from "../../geom.ts";
3
+ import { orderLanes } from "../../segments.ts";
4
+ import { WayFinder, type WayOptions } from "../../ways.ts";
5
+ import { arcPlace, firstRoutes, orderAlongRoutes, tidyPlan } from "../base.ts";
6
+ import { memberAt, type Highway, type HighwayInput, type HighwayMember, type HighwayPlan } from "../model.ts";
7
+
8
+ /** The options of the highway strategy `frechet` (HWY-027 of the notes). */
9
+ export interface FrechetOptions extends Pick<WayOptions, "margin" | "k"> {
10
+ /** Two parts of routes are one bundle when their discrete Fréchet distance is this value or less, as a multiple of `boardUnit`. The default is 1. */
11
+ readonly eps: number;
12
+ /** A bundle has this number of edges or more, with the reference edge. The default is 2. */
13
+ readonly minSize: number;
14
+ /** The reference part of a bundle is this length or longer, as a multiple of `boardUnit`. The default is 2. */
15
+ readonly minLength: number;
16
+ /** The distance between two samples of a route, as a multiple of `boardUnit`. The default is 0.5. */
17
+ readonly step: number;
18
+ /** A search starts at each sample of this number along each route. The default is 2. */
19
+ readonly stride: number;
20
+ }
21
+
22
+ /** The default options of the strategy `frechet`. */
23
+ export const FRECHET_DEFAULTS: Readonly<Required<Omit<FrechetOptions, "margin" | "k">>> = Object.freeze({
24
+ eps: 1,
25
+ minSize: 2,
26
+ minLength: 2,
27
+ step: 0.5,
28
+ stride: 2,
29
+ });
30
+
31
+ /** This function gives the points of a polyline at equal distances of `step` or less, with its first and its last point. */
32
+ export function samplesOf(line: readonly Point[], step: number): Point[] {
33
+ if (line.length < 2) return [...line];
34
+ const out: Point[] = [line[0]!];
35
+ for (let i = 1; i < line.length; i++) {
36
+ const a = line[i - 1]!;
37
+ const b = line[i]!;
38
+ const n = Math.max(1, Math.ceil(dist(a, b) / Math.max(1e-9, step)));
39
+ for (let s = 1; s <= n; s++) out.push({ x: a.x + ((b.x - a.x) * s) / n, y: a.y + ((b.y - a.y) * s) / n });
40
+ }
41
+ return out;
42
+ }
43
+
44
+ /** The cells of one row of the free space that a monotone path reaches. Each cell is a sorted index of the other curve. */
45
+ type Row = readonly number[];
46
+
47
+ /**
48
+ * This function gives the next row of the free space of the discrete Fréchet distance. The curve `q` has a free start and a free end.
49
+ * A cell `(j, b)` is free when the distance of `p[j]` to `q[b]` is `eps` or less.
50
+ * A path reaches a free cell from `(j - 1, b)`, `(j - 1, b - 1)` or `(j, b - 1)`.
51
+ */
52
+ export function nextRow(prev: Row, pj: Point, q: readonly Point[], eps: number): number[] {
53
+ const out: number[] = [];
54
+ if (prev.length === 0) return out;
55
+ const has = new Set(prev);
56
+ const last = prev[prev.length - 1]!;
57
+ let left = false;
58
+ for (let b = prev[0]!; b < q.length; b++) {
59
+ const reach: boolean = dist(pj, q[b]!) <= eps && (has.has(b) || has.has(b - 1) || left);
60
+ if (reach) out.push(b);
61
+ left = reach;
62
+ if (!reach && b > last) break;
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * This function tells if a part of `q` has a discrete Fréchet distance of `eps` or less to the whole of `p`. The part of `q` is free to start and to end anywhere.
69
+ * It uses the rows of `nextRow`.
70
+ */
71
+ export function partWithin(p: readonly Point[], q: readonly Point[], eps: number): boolean {
72
+ if (p.length === 0) return true;
73
+ let row: number[] = [];
74
+ for (let b = 0; b < q.length; b++) if (dist(p[0]!, q[b]!) <= eps) row.push(b);
75
+ for (let j = 1; j < p.length && row.length > 0; j++) row = nextRow(row, p[j]!, q, eps);
76
+ return row.length > 0;
77
+ }
78
+
79
+ /** One bundle of the search: the reference edge, the part of its samples, and the other edges with their sense. */
80
+ export interface FrechetBundle {
81
+ readonly edge: string;
82
+ readonly from: number;
83
+ readonly to: number;
84
+ readonly length: number;
85
+ /** The other edges. `back` is true when the edge goes along the reference part in the reverse sense. */
86
+ readonly members: readonly { readonly edge: string; readonly back: boolean }[];
87
+ }
88
+
89
+ /**
90
+ * This function gives the bundles of the search (HWY-027, after Buchin, Buchin, Gudmundsson, Löffler and Luo 2011, in a simple exact form).
91
+ * For each reference route and each start sample (at each `stride`), it grows the reference part one sample at a time.
92
+ * It keeps the other routes, in both senses, with a part at a discrete Fréchet distance of `eps` or less.
93
+ * It stops when fewer than `minSize - 1` routes remain.
94
+ * Each start gives the part with the best score: the number of edges times the length.
95
+ * The search uses the discrete distance on samples only. It does not use the continuous distance or the full free space diagram.
96
+ */
97
+ export function frechetBundles(samples: ReadonlyMap<string, readonly Point[]>, eps: number, minSize: number, minLength: number, stride: number): FrechetBundle[] {
98
+ const edges = [...samples.keys()];
99
+ const curves = new Map<string, readonly Point[]>();
100
+ for (const e of edges) {
101
+ curves.set(`${e}\u0000f`, samples.get(e)!);
102
+ curves.set(`${e}\u0000b`, [...samples.get(e)!].reverse());
103
+ }
104
+ // A grid of the samples of each curve, with cells of the side `eps`, finds the first row of each start.
105
+ const cell = Math.max(1e-6, eps);
106
+ const grid = new Map<string, Set<string>>();
107
+ const cellKey = (p: Point): string => `${Math.floor(p.x / cell)},${Math.floor(p.y / cell)}`;
108
+ for (const [key, c] of curves)
109
+ for (const p of c) {
110
+ const k = cellKey(p);
111
+ let set = grid.get(k);
112
+ if (!set) grid.set(k, (set = new Set()));
113
+ set.add(key);
114
+ }
115
+ const near = (p: Point): string[] => {
116
+ const cx = Math.floor(p.x / cell);
117
+ const cy = Math.floor(p.y / cell);
118
+ const out = new Set<string>();
119
+ for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) for (const k of grid.get(`${cx + dx},${cy + dy}`) ?? []) out.add(k);
120
+ return [...out].sort();
121
+ };
122
+ const need = Math.max(1, minSize - 1);
123
+ const out: FrechetBundle[] = [];
124
+ for (const r of edges) {
125
+ const p = samples.get(r)!;
126
+ const lengths = [0];
127
+ for (let i = 1; i < p.length; i++) lengths.push(lengths[i - 1]! + dist(p[i - 1]!, p[i]!));
128
+ for (let i = 0; i < p.length - 1; i += Math.max(1, Math.round(stride))) {
129
+ let rows = new Map<string, number[]>();
130
+ for (const key of near(p[i]!)) {
131
+ if (key.startsWith(`${r}\u0000`)) continue;
132
+ const q = curves.get(key)!;
133
+ const row: number[] = [];
134
+ q.forEach((s, b) => {
135
+ if (dist(p[i]!, s) <= eps) row.push(b);
136
+ });
137
+ if (row.length > 0) rows.set(key, row);
138
+ }
139
+ let best: FrechetBundle | undefined;
140
+ let bestScore = 0;
141
+ for (let j = i + 1; j < p.length; j++) {
142
+ const next = new Map<string, number[]>();
143
+ for (const [key, row] of rows) {
144
+ const n = nextRow(row, p[j]!, curves.get(key)!, eps);
145
+ if (n.length > 0) next.set(key, n);
146
+ }
147
+ rows = next;
148
+ // One edge counts once: its forward sense wins over its reverse sense.
149
+ const alive = new Map<string, boolean>();
150
+ for (const key of rows.keys()) {
151
+ const [edge, sense] = key.split("\u0000") as [string, string];
152
+ if (!alive.has(edge) || sense === "f") alive.set(edge, sense === "b");
153
+ }
154
+ if (alive.size < need) break;
155
+ const length = lengths[j]! - lengths[i]!;
156
+ const score = (alive.size + 1) * length;
157
+ if (length >= minLength && score > bestScore) {
158
+ bestScore = score;
159
+ best = { edge: r, from: i, to: j, length, members: [...alive].sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([edge, back]) => ({ edge, back })) };
160
+ }
161
+ }
162
+ if (best) out.push(best);
163
+ }
164
+ }
165
+ return out;
166
+ }
167
+
168
+ /**
169
+ * This function gives the highways of the subtrajectory clusters of the first routes (HWY-027). The samples of each route leave out its two end points.
170
+ * The bundles go in the order of their score, the best first. A bundle keeps the members whose part of the route is not on a bundle already.
171
+ * The spine of a highway is the reference part. Each member rides the whole spine, in its own sense.
172
+ */
173
+ export function frechetPlan(input: HighwayInput, o: Readonly<FrechetOptions>): HighwayPlan {
174
+ const graph = input.graph;
175
+ const unit = boardUnit(graph);
176
+ const finder = new WayFinder(graph.nodes, { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) });
177
+ const routes = firstRoutes(input);
178
+ const step = Math.max(1e-3, o.step * unit);
179
+ const samples = new Map<string, Point[]>();
180
+ for (const e of graph.edges) {
181
+ const route = routes.get(e.id);
182
+ if (!route || route.length < 4) continue;
183
+ const s = samplesOf(route.slice(1, -1), step);
184
+ if (s.length >= 2) samples.set(e.id, s);
185
+ }
186
+ const minSize = Math.max(2, Math.round(o.minSize));
187
+ const bundles = frechetBundles(samples, Math.max(1e-3, o.eps * unit), minSize, Math.max(finder.margin, o.minLength * unit), o.stride);
188
+ bundles.sort((a, b) => (b.members.length + 1) * b.length - (a.members.length + 1) * a.length || (a.edge < b.edge ? -1 : a.edge > b.edge ? 1 : a.from - b.from));
189
+ // The used parts of each route, as places along the route.
190
+ const used = new Map<string, [number, number][]>();
191
+ const free = (edge: string, lo: number, hi: number): boolean => (used.get(edge) ?? []).every(([a, b]) => hi <= a || lo >= b);
192
+ const take = (edge: string, lo: number, hi: number): void => {
193
+ const list = used.get(edge);
194
+ if (list) list.push([lo, hi]);
195
+ else used.set(edge, [[lo, hi]]);
196
+ };
197
+ const highways: Highway[] = [];
198
+ for (const bundle of bundles) {
199
+ const spine = samples.get(bundle.edge)!.slice(bundle.from, bundle.to + 1);
200
+ if (polylineLength(spine) <= finder.margin) continue;
201
+ const own = routes.get(bundle.edge)!;
202
+ const span = (route: readonly Point[]): [number, number] => {
203
+ const a = arcPlace(route, spine[0]!);
204
+ const b = arcPlace(route, spine[spine.length - 1]!);
205
+ return [Math.min(a, b), Math.max(a, b)];
206
+ };
207
+ const [rlo, rhi] = span(own);
208
+ if (!free(bundle.edge, rlo, rhi)) continue;
209
+ const riders = bundle.members.filter((m) => {
210
+ const [lo, hi] = span(routes.get(m.edge)!);
211
+ return free(m.edge, lo, hi);
212
+ });
213
+ if (riders.length + 1 < minSize) continue;
214
+ const all = [{ edge: bundle.edge, back: false }, ...riders];
215
+ for (const m of all) take(m.edge, ...span(routes.get(m.edge)!));
216
+ const last = spine.length - 1;
217
+ const trip = (m: { edge: string; back: boolean }): Point[] => {
218
+ const r = routes.get(m.edge)!;
219
+ const [from, to] = [r[0]!, r[r.length - 1]!];
220
+ return m.back ? [to, ...spine, from] : [from, ...spine, to];
221
+ };
222
+ const lanes = orderLanes(
223
+ spine,
224
+ all.map((m) => ({ edge: m.edge, points: trip(m) })),
225
+ "bundle",
226
+ );
227
+ const back = new Map(all.map((m) => [m.edge, m.back]));
228
+ const members: HighwayMember[] = lanes.map((edge) => (back.get(edge) ? memberAt(spine, edge, last, 0) : memberAt(spine, edge, 0, last)));
229
+ highways.push({ id: `h${highways.length}`, key: `f${highways.length}`, spine, lanes, members });
230
+ }
231
+ return tidyPlan(orderAlongRoutes({ highways }, routes));
232
+ }
@@ -0,0 +1,148 @@
1
+ import { boardUnit } from "../../bench/measures/routes.ts";
2
+ import { dist, polylineLength, type Point } from "../../geom.ts";
3
+ import { orderLanes } from "../../segments.ts";
4
+ import { WayFinder } from "../../ways.ts";
5
+ import { firstRoutes, nearestPlace, tidyPlan } from "../base.ts";
6
+ import { lengthBetween, memberAt, pointAt, type Highway, type HighwayInput, type HighwayMember, type HighwayPlan } from "../model.ts";
7
+ import { edgePlugs } from "../stretches.ts";
8
+
9
+ /** The order of the edges in the growth: the longest first route first, or the order of the graph. */
10
+ export type GrowthOrder = "length" | "input";
11
+
12
+ /** The options of the highway strategy `growth` (HWY-055 of the note on highways). */
13
+ export interface GrowthOptions {
14
+ /** The distance from a box to the corners of the graph. The default is 5. */
15
+ readonly margin?: number;
16
+ /** The number of visible neighbours of each corner. The default is 8. */
17
+ readonly k?: number;
18
+ /** The order of the edges. The default is `length`. */
19
+ readonly order: GrowthOrder;
20
+ /** The cost of the part on a spine, as a share of its length. The ramps cost their full length. The default is 0.5. */
21
+ readonly share: number;
22
+ /** An edge joins a highway only when its way along it is this value times its first route or shorter. The default is 1.3. */
23
+ readonly stretch: number;
24
+ /** A spine end extends when this number of its lanes or more go on together. The default is 3. */
25
+ readonly extendLanes: number;
26
+ /** The lanes go on together when their ends are this distance from their center or nearer, in units of the board. The default is 2. */
27
+ readonly extendRadius: number;
28
+ /** A highway with this number of edges or more stays in the plan. The default is 2. */
29
+ readonly minSize: number;
30
+ }
31
+
32
+ /** The default options of the strategy `growth`. */
33
+ export const GROWTH_DEFAULTS: Readonly<GrowthOptions> = Object.freeze({ order: "length", share: 0.5, stretch: 1.3, extendLanes: 3, extendRadius: 2, minSize: 2 });
34
+
35
+ interface Grown {
36
+ spine: Point[];
37
+ members: { edge: string; on: number; off: number }[];
38
+ }
39
+
40
+ interface Rider {
41
+ readonly edge: string;
42
+ readonly ps: Point;
43
+ readonly pt: Point;
44
+ readonly a: Point;
45
+ readonly b: Point;
46
+ readonly len: number;
47
+ }
48
+
49
+ /**
50
+ * This function gives the highways of local growth (HWY-055). The edges come one by one, by default the longest first route first.
51
+ * Each edge joins the highway with the largest saving: its first route less its ramps and `share` of its part on the spine. Without a saving, the edge starts a highway on its own way.
52
+ * After the growth, a spine end can go on to the center of the far ends of its lanes.
53
+ * This occurs when `extendLanes` of them or more are near that center and the line is clear.
54
+ */
55
+ export function growthPlan(input: HighwayInput, o: Readonly<GrowthOptions>): HighwayPlan {
56
+ const graph = input.graph;
57
+ const finder = new WayFinder(graph.nodes, { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) });
58
+ const pad = finder.margin / 2;
59
+ const routes = firstRoutes(input);
60
+ const riders: Rider[] = edgePlugs(graph).map(({ edge, ps, pt }) => {
61
+ const route = routes.get(edge.id);
62
+ const a = finder.out(ps);
63
+ const b = finder.out(pt);
64
+ return { edge: edge.id, ps: ps.point, pt: pt.point, a, b, len: route ? polylineLength(route) : dist(ps.point, pt.point) };
65
+ });
66
+ if (o.order === "length") riders.sort((p, q) => q.len - p.len || (p.edge < q.edge ? -1 : p.edge > q.edge ? 1 : 0));
67
+ const grown: Grown[] = [];
68
+ const short = finder.margin;
69
+ for (const r of riders) {
70
+ let best = -1;
71
+ let bestSave = 0;
72
+ let bestOn = 0;
73
+ let bestOff = 0;
74
+ grown.forEach((h, hi) => {
75
+ const on = nearestPlace(h.spine, r.a);
76
+ const off = nearestPlace(h.spine, r.b);
77
+ const part = lengthBetween(h.spine, Math.min(on, off), Math.max(on, off));
78
+ if (part <= short) return;
79
+ const ramps = dist(r.a, pointAt(h.spine, on)) + dist(pointAt(h.spine, off), r.b);
80
+ if (ramps + part > o.stretch * r.len) return;
81
+ const save = r.len - (ramps + o.share * part);
82
+ if (save > bestSave) [best, bestSave, bestOn, bestOff] = [hi, save, on, off];
83
+ });
84
+ if (best >= 0) {
85
+ grown[best]!.members.push({ edge: r.edge, on: bestOn, off: bestOff });
86
+ continue;
87
+ }
88
+ const raw = finder.corners.path(r.a, r.b);
89
+ if (!raw) continue;
90
+ const spine = finder.corners.pull(raw);
91
+ if (spine.length < 2 || polylineLength(spine) <= short) continue;
92
+ grown.push({ spine, members: [{ edge: r.edge, on: 0, off: spine.length - 1 }] });
93
+ }
94
+
95
+ // A spine end goes on while enough of its lanes go on together.
96
+ const byEdge = new Map(riders.map((r) => [r.edge, r]));
97
+ const radius = o.extendRadius * boardUnit(graph);
98
+ const need = Math.max(2, Math.round(o.extendLanes));
99
+ for (const h of grown) {
100
+ for (const end of ["first", "last"] as const) {
101
+ const last = h.spine.length - 1;
102
+ const at = end === "first" ? 0 : last;
103
+ // The far point of each lane at this end: the point out of the plug that the lane goes to or comes from.
104
+ const far: { m: Grown["members"][number]; side: "on" | "off"; p: Point }[] = [];
105
+ for (const m of h.members) {
106
+ const r = byEdge.get(m.edge)!;
107
+ if (m.on === at) far.push({ m, side: "on", p: r.a });
108
+ else if (m.off === at) far.push({ m, side: "off", p: r.b });
109
+ }
110
+ if (far.length < need) continue;
111
+ const c = { x: far.reduce((s, f) => s + f.p.x, 0) / far.length, y: far.reduce((s, f) => s + f.p.y, 0) / far.length };
112
+ const near = far.filter((f) => dist(f.p, c) <= radius);
113
+ const tip = h.spine[at]!;
114
+ if (near.length < need || dist(tip, c) <= short) continue;
115
+ if (finder.obstacles.inside(c, pad) || !finder.obstacles.clear(tip, c, pad)) continue;
116
+ if (end === "first") {
117
+ h.spine.unshift(c);
118
+ for (const m of h.members) [m.on, m.off] = [m.on + 1, m.off + 1];
119
+ for (const f of near) f.m[f.side] = 0;
120
+ } else {
121
+ h.spine.push(c);
122
+ for (const f of near) f.m[f.side] = last + 1;
123
+ }
124
+ }
125
+ }
126
+
127
+ const highways: Highway[] = [];
128
+ grown.forEach((h, hi) => {
129
+ if (h.members.length < Math.max(2, o.minSize)) return;
130
+ const spine = h.spine;
131
+ const back = [...spine].reverse();
132
+ const lanes = orderLanes(
133
+ spine,
134
+ h.members.map((m) => {
135
+ const r = byEdge.get(m.edge)!;
136
+ return { edge: m.edge, points: m.on <= m.off ? [r.ps, r.a, ...spine, r.b, r.pt] : [r.ps, r.a, ...back, r.b, r.pt] };
137
+ }),
138
+ "bundle",
139
+ );
140
+ const byLane = new Map(h.members.map((m) => [m.edge, m]));
141
+ const members: HighwayMember[] = lanes.map((edge) => {
142
+ const m = byLane.get(edge)!;
143
+ return memberAt(spine, edge, m.on, m.off);
144
+ });
145
+ highways.push({ id: `h${hi}`, key: `g${hi}`, spine, lanes, members });
146
+ });
147
+ return tidyPlan({ highways });
148
+ }
@@ -0,0 +1,250 @@
1
+ import { dist, type Point } from "../../geom.ts";
2
+ import { flowDistance } from "../flow-clusters/index.ts";
3
+ import { groupedPlan, type EdgeGroup, type GroupSpine } from "../grouped.ts";
4
+ import type { HighwayInput, HighwayPlan } from "../model.ts";
5
+ import { edgePlugs } from "../stretches.ts";
6
+
7
+ /** The distance of two edges: 1 less their compatibility of FDEB, or the flow distance in 4D of HWY-023. */
8
+ export type EdgeDistance = "compatibility" | "flow";
9
+
10
+ /** The values of `EdgeDistance`. */
11
+ export const EDGE_DISTANCES: readonly EdgeDistance[] = Object.freeze(["compatibility", "flow"]);
12
+
13
+ /** The options of the highway strategy `hdbscan` (HWY-028 of the note on highways). */
14
+ export interface HdbscanOptions {
15
+ /** A cluster has this number of edges or more. The default is 3. */
16
+ readonly minClusterSize: number;
17
+ /** The core distance of an edge is the distance to its neighbour of this rank. The default is 3. */
18
+ readonly minSamples: number;
19
+ /** The distance of two edges. The default is `compatibility`. */
20
+ readonly distance: EdgeDistance;
21
+ /** The spine of each cluster. The default is `trunk`: one trunk from one area to the other area. */
22
+ readonly spine: GroupSpine;
23
+ }
24
+
25
+ /** The default options of the strategy `hdbscan`. */
26
+ export const HDBSCAN_DEFAULTS: Readonly<HdbscanOptions> = Object.freeze({ minClusterSize: 3, minSamples: 3, distance: "compatibility", spine: "trunk" });
27
+
28
+ /** The largest number of edges in one distance matrix. A larger graph splits into blocks of edges in the order of their sources. */
29
+ const BLOCK = 2500;
30
+
31
+ /**
32
+ * This function gives the compatibility of two edges of FDEB (Holten and van Wijk 2009): `Ca Cs Cp Cv`, from 0 to 1.
33
+ * `Ca` is the angle, `Cs` the scale, `Cp` the position and `Cv` the visibility. The direction of an edge does not change the value.
34
+ */
35
+ export function compatibility(p0: Point, p1: Point, q0: Point, q1: Point): number {
36
+ const lp = dist(p0, p1);
37
+ const lq = dist(q0, q1);
38
+ if (lp <= 1e-9 || lq <= 1e-9) return 0;
39
+ const ca = Math.abs(((p1.x - p0.x) * (q1.x - q0.x) + (p1.y - p0.y) * (q1.y - q0.y)) / (lp * lq));
40
+ const avg = (lp + lq) / 2;
41
+ const cs = 2 / (avg / Math.min(lp, lq) + Math.max(lp, lq) / avg);
42
+ const pm = { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 };
43
+ const qm = { x: (q0.x + q1.x) / 2, y: (q0.y + q1.y) / 2 };
44
+ const cp = avg / (avg + dist(pm, qm));
45
+ const cv = Math.min(visibility(p0, p1, q0, q1), visibility(q0, q1, p0, p1));
46
+ return ca * cs * cp * cv;
47
+ }
48
+
49
+ /** This function gives the visibility of the edge `q` from the edge `p` of FDEB: `max(1 - 2 |pm - im| / |i0 i1|, 0)`. The points `i` are the projection of `q` on the line of `p`. */
50
+ function visibility(p0: Point, p1: Point, q0: Point, q1: Point): number {
51
+ const dx = p1.x - p0.x;
52
+ const dy = p1.y - p0.y;
53
+ const l2 = dx * dx + dy * dy;
54
+ const project = (q: Point): Point => {
55
+ const u = ((q.x - p0.x) * dx + (q.y - p0.y) * dy) / l2;
56
+ return { x: p0.x + u * dx, y: p0.y + u * dy };
57
+ };
58
+ const i0 = project(q0);
59
+ const i1 = project(q1);
60
+ const span = dist(i0, i1);
61
+ if (span <= 1e-9) return 0;
62
+ const im = { x: (i0.x + i1.x) / 2, y: (i0.y + i1.y) / 2 };
63
+ const pm = { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 };
64
+ return Math.max(1 - (2 * dist(pm, im)) / span, 0);
65
+ }
66
+
67
+ /**
68
+ * This function gives the HDBSCAN clusters of the points of a distance matrix (Campello, Moulavi and Sander 2013). The value -1 is noise.
69
+ * The core distance of a point is the distance to its `minSamples`-th nearest point. The mutual reachability is `max(core(a), core(b), d(a, b))`.
70
+ * The minimum spanning tree of the mutual reachability gives the tree of the clusters. A split with two parts of `minClusterSize` or more gives two clusters.
71
+ * The selection keeps the clusters with the largest stability, `sum (lambda_leave - lambda_birth)`, with `lambda = 1 / distance`. The root is not a cluster.
72
+ */
73
+ export function hdbscanLabels(n: number, d: Float64Array, minClusterSize: number, minSamples: number): Int32Array {
74
+ const labels = new Int32Array(n).fill(-1);
75
+ const mcs = Math.max(2, Math.round(minClusterSize));
76
+ if (n < mcs || n < 2) return labels;
77
+ const k = Math.max(1, Math.min(n - 1, Math.round(minSamples)));
78
+ // The core distance of each point.
79
+ const core = new Float64Array(n);
80
+ const row = new Float64Array(n - 1);
81
+ for (let i = 0; i < n; i++) {
82
+ let c = 0;
83
+ for (let j = 0; j < n; j++) if (j !== i) row[c++] = d[i * n + j]!;
84
+ row.sort();
85
+ core[i] = row[k - 1]!;
86
+ }
87
+ const reach = (i: number, j: number): number => Math.max(core[i]!, core[j]!, d[i * n + j]!);
88
+ // The minimum spanning tree (Prim), with ties to the lower index.
89
+ const inTree = new Uint8Array(n);
90
+ const best = new Float64Array(n).fill(Infinity);
91
+ const from = new Int32Array(n).fill(-1);
92
+ const links: { a: number; b: number; w: number }[] = [];
93
+ let cur = 0;
94
+ inTree[0] = 1;
95
+ for (let step = 1; step < n; step++) {
96
+ let next = -1;
97
+ for (let j = 0; j < n; j++) {
98
+ if (inTree[j]) continue;
99
+ const w = reach(cur, j);
100
+ if (w < best[j]!) [best[j], from[j]] = [w, cur];
101
+ if (next < 0 || best[j]! < best[next]!) next = j;
102
+ }
103
+ inTree[next] = 1;
104
+ links.push({ a: from[next]!, b: next, w: best[next]! });
105
+ cur = next;
106
+ }
107
+ links.sort((p, q) => p.w - q.w || p.a - q.a || p.b - q.b);
108
+ // The single linkage tree: the node `n + i` is the merge `i`.
109
+ const parent = new Int32Array(2 * n - 1).fill(-1);
110
+ const left = new Int32Array(n - 1);
111
+ const right = new Int32Array(n - 1);
112
+ const height = new Float64Array(n - 1);
113
+ const size = new Int32Array(2 * n - 1).fill(1);
114
+ const uf = new Int32Array(2 * n - 1);
115
+ for (let i = 0; i < uf.length; i++) uf[i] = i;
116
+ const find = (i: number): number => {
117
+ while (uf[i] !== i) i = uf[i] = uf[uf[i]!]!;
118
+ return i;
119
+ };
120
+ links.forEach((l, i) => {
121
+ const ra = find(l.a);
122
+ const rb = find(l.b);
123
+ const node = n + i;
124
+ left[i] = ra;
125
+ right[i] = rb;
126
+ height[i] = l.w;
127
+ size[node] = size[ra]! + size[rb]!;
128
+ parent[ra] = node;
129
+ parent[rb] = node;
130
+ uf[ra] = node;
131
+ uf[rb] = node;
132
+ });
133
+ const root = 2 * n - 2;
134
+ const lambdaOf = (h: number): number => 1 / Math.max(h, 1e-9);
135
+ const leaves = (node: number): number[] => {
136
+ const out: number[] = [];
137
+ const stack = [node];
138
+ while (stack.length > 0) {
139
+ const x = stack.pop()!;
140
+ if (x < n) out.push(x);
141
+ else stack.push(left[x - n]!, right[x - n]!);
142
+ }
143
+ return out;
144
+ };
145
+ // The condensed tree. Each cluster has a birth lambda, a parent cluster, its stability and the points that leave it at their lambda.
146
+ const birth: number[] = [0];
147
+ const up: number[] = [-1];
148
+ const stability: number[] = [0];
149
+ const fall = new Int32Array(n).fill(0);
150
+ const queue: [node: number, cluster: number][] = [[root, 0]];
151
+ while (queue.length > 0) {
152
+ const [node, c] = queue.shift()!;
153
+ const i = node - n;
154
+ const lambda = lambdaOf(height[i]!);
155
+ const kids = [left[i]!, right[i]!];
156
+ const big = kids.filter((x) => size[x]! >= mcs);
157
+ if (big.length === 2) {
158
+ for (const x of kids) {
159
+ const id = birth.length;
160
+ birth.push(lambda);
161
+ up.push(c);
162
+ stability.push(0);
163
+ stability[c] = stability[c]! + size[x]! * (lambda - birth[c]!);
164
+ queue.push([x, id]);
165
+ }
166
+ continue;
167
+ }
168
+ for (const x of kids) {
169
+ if (big.includes(x)) {
170
+ if (x >= n) queue.push([x, c]);
171
+ else {
172
+ fall[x] = c;
173
+ stability[c] = stability[c]! + (lambda - birth[c]!);
174
+ }
175
+ continue;
176
+ }
177
+ for (const p of leaves(x)) {
178
+ fall[p] = c;
179
+ stability[c] = stability[c]! + (lambda - birth[c]!);
180
+ }
181
+ }
182
+ }
183
+ // The selection by the excess of mass, from the last cluster to the first. A child cluster always has a larger id than its parent.
184
+ const count = birth.length;
185
+ const chosen = new Uint8Array(count);
186
+ const value = [...stability];
187
+ const children: number[][] = Array.from({ length: count }, () => []);
188
+ for (let c = 1; c < count; c++) children[up[c]!]!.push(c);
189
+ for (let c = count - 1; c >= 1; c--) {
190
+ const sub = children[c]!.reduce((s, x) => s + value[x]!, 0);
191
+ if (children[c]!.length > 0 && sub > value[c]!) value[c] = sub;
192
+ else {
193
+ chosen[c] = 1;
194
+ const stack = [...children[c]!];
195
+ while (stack.length > 0) {
196
+ const x = stack.pop()!;
197
+ chosen[x] = 0;
198
+ stack.push(...children[x]!);
199
+ }
200
+ }
201
+ }
202
+ for (let p = 0; p < n; p++) {
203
+ let c = fall[p]!;
204
+ while (c > 0 && !chosen[c]) c = up[c]!;
205
+ labels[p] = c > 0 ? c : -1;
206
+ }
207
+ return labels;
208
+ }
209
+
210
+ /**
211
+ * This function gives the HDBSCAN clusters of the edges (HWY-028). The edges that are noise get no group.
212
+ * Each edge of a cluster gets `flip` when it goes opposite to the first edge of the cluster.
213
+ */
214
+ export function hdbscanClusters(input: HighwayInput, o: Readonly<HdbscanOptions>): Map<string, EdgeGroup> {
215
+ const ends = edgePlugs(input.graph).map(({ edge, ps, pt }) => ({ edge: edge.id, s: ps.point, t: pt.point }));
216
+ const groups = new Map<string, EdgeGroup>();
217
+ const order = ends.length > BLOCK ? [...ends].sort((a, b) => a.s.x - b.s.x || a.s.y - b.s.y || (a.edge < b.edge ? -1 : 1)) : ends;
218
+ const between = (a: (typeof ends)[number], b: (typeof ends)[number]): number =>
219
+ o.distance === "flow" ? flowDistance(a.s, a.t, b.s, b.t, false).d : 1 - compatibility(a.s, a.t, b.s, b.t);
220
+ for (let from = 0; from < order.length; from += BLOCK) {
221
+ const block = order.slice(from, from + BLOCK);
222
+ const n = block.length;
223
+ const d = new Float64Array(n * n);
224
+ for (let i = 0; i < n; i++)
225
+ for (let j = i + 1; j < n; j++) {
226
+ const v = between(block[i]!, block[j]!);
227
+ d[i * n + j] = v;
228
+ d[j * n + i] = v;
229
+ }
230
+ const labels = hdbscanLabels(n, d, o.minClusterSize, o.minSamples);
231
+ const lead = new Map<number, number>();
232
+ block.forEach((e, i) => {
233
+ const c = labels[i]!;
234
+ if (c < 0) return;
235
+ if (!lead.has(c)) lead.set(c, i);
236
+ const l = block[lead.get(c)!]!;
237
+ const flip = (e.t.x - e.s.x) * (l.t.x - l.s.x) + (e.t.y - e.s.y) * (l.t.y - l.s.y) < 0;
238
+ groups.set(e.edge, { key: `c${from}:${c}`, flip });
239
+ });
240
+ }
241
+ return groups;
242
+ }
243
+
244
+ /**
245
+ * This function gives the highways of the HDBSCAN clusters (HWY-028). The clusters that live over a long range of densities are the highways.
246
+ * The number of highways is not fixed, and the noise edges stay alone.
247
+ */
248
+ export function hdbscanPlan(input: HighwayInput, o: Readonly<HdbscanOptions>): HighwayPlan {
249
+ return groupedPlan(input, hdbscanClusters(input, o), { spine: o.spine, minSize: Math.max(2, Math.round(o.minClusterSize)) });
250
+ }