@mark1russell7/highways 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +18 -2
  2. package/build/index.d.ts +799 -68
  3. package/build/index.d.ts.map +1 -1
  4. package/build/index.js +6235 -2128
  5. package/build/index.js.map +1 -1
  6. package/package.json +3 -1
  7. package/src/bench/exact/index.ts +197 -0
  8. package/src/bench/measures/index.ts +1 -0
  9. package/src/bench/measures/lanes.ts +304 -0
  10. package/src/bench/measures/levels.ts +86 -2
  11. package/src/bench/screen/forms.ts +11 -5
  12. package/src/bench/screen/measures.ts +18 -0
  13. package/src/bench/screen/screen.ts +28 -0
  14. package/src/channels.ts +56 -0
  15. package/src/highways/diffusion/index.ts +103 -0
  16. package/src/highways/edge-paths/index.ts +155 -0
  17. package/src/highways/fixtures.test-util.ts +71 -0
  18. package/src/highways/flow-clusters/index.ts +143 -0
  19. package/src/highways/growth/index.ts +148 -0
  20. package/src/highways/hdbscan/index.ts +250 -0
  21. package/src/highways/hough/index.ts +182 -0
  22. package/src/highways/hub-arcs/index.ts +181 -0
  23. package/src/highways/index.ts +15 -0
  24. package/src/highways/lanes/index.ts +189 -0
  25. package/src/highways/mingle/index.ts +171 -0
  26. package/src/highways/nested/index.ts +143 -0
  27. package/src/highways/ridges/index.ts +328 -0
  28. package/src/highways/snap.ts +110 -0
  29. package/src/highways/soft-keys/index.ts +114 -0
  30. package/src/highways/spiral/index.ts +205 -0
  31. package/src/highways/traclus/index.ts +296 -0
  32. package/src/index.ts +1 -0
  33. package/src/routers/shared.ts +56 -3
  34. package/src/strategies/builtins.ts +155 -1
  35. package/src/strategies/schemas.ts +180 -0
  36. package/src/visibility.ts +5 -0
@@ -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,328 @@
1
+ import { dist, type Point } from "../../geom.ts";
2
+ import type { WayOptions } from "../../ways.ts";
3
+ import { firstRoutes } from "../base.ts";
4
+ import type { HighwayInput, HighwayPlan } from "../model.ts";
5
+ import { snapPlan, type SnapLine, type SnapMember } from "../snap.ts";
6
+ import { edgePlugs } from "../stretches.ts";
7
+
8
+ /** The lines that make the density: the straight lines of the edges, or their first routes. */
9
+ export type RidgeSource = "lines" | "routes";
10
+
11
+ /** The options of the highway strategy `ridges` (HWY-032 of the note on highways). */
12
+ export interface RidgeOptions extends Pick<WayOptions, "margin" | "k"> {
13
+ /** The lines of the density. The default is `lines`. */
14
+ readonly source: RidgeSource;
15
+ /** The side of a cell of the density image, in units of the board. The default is 8. A large board gets larger cells, up to 1024 cells on a side. */
16
+ readonly cell: number;
17
+ /** The width of the blur, in units of the board. The default is 16. */
18
+ readonly bandwidth: number;
19
+ /** A ridge cell has this share of the largest strength or more. The default is 0.15. */
20
+ readonly strength: number;
21
+ /** A ridge has this length or more, in units of the board. The default is 60. */
22
+ readonly minLength: number;
23
+ /** An edge snaps to a ridge when its line is this near to the ridge, in units of the board. The default is 16. */
24
+ readonly snap: number;
25
+ /** A highway has this number of edges or more. The default is 2. */
26
+ readonly minSize: number;
27
+ }
28
+
29
+ /** The default options of the strategy `ridges`. */
30
+ export const RIDGE_DEFAULTS: Readonly<Required<Omit<RidgeOptions, "margin" | "k">>> = Object.freeze({ source: "lines", cell: 8, bandwidth: 16, strength: 0.15, minLength: 60, snap: 16, minSize: 2 });
31
+
32
+ /** The largest angle between an edge and a ridge that it snaps to, in degrees. */
33
+ const MAX_ANGLE = 30;
34
+
35
+ /** The density image: the cells, its size, and the place of its first cell. */
36
+ export interface DensityImage {
37
+ readonly w: number;
38
+ readonly h: number;
39
+ readonly cell: number;
40
+ readonly x0: number;
41
+ readonly y0: number;
42
+ readonly rho: Float64Array;
43
+ }
44
+
45
+ /**
46
+ * This function gives the density of the polylines on a grid of cells, after a Gaussian blur of the width `bandwidth`.
47
+ * Each polyline adds 1 to each cell that it goes through, one time for each polyline.
48
+ */
49
+ export function lineDensity(lines: readonly (readonly Point[])[], box: { x: number; y: number; w: number; h: number }, cellSize: number, bandwidth: number): DensityImage {
50
+ const cell = Math.max(cellSize, box.w / 1024, box.h / 1024, 1e-6);
51
+ const pad = 3 * bandwidth;
52
+ const x0 = box.x - pad;
53
+ const y0 = box.y - pad;
54
+ const w = Math.max(1, Math.ceil((box.w + 2 * pad) / cell));
55
+ const h = Math.max(1, Math.ceil((box.h + 2 * pad) / cell));
56
+ const raw = new Float64Array(w * h);
57
+ const stamp = new Int32Array(w * h).fill(-1);
58
+ lines.forEach((p, li) => {
59
+ for (let i = 1; i < p.length; i++) {
60
+ const a = p[i - 1]!;
61
+ const b = p[i]!;
62
+ const steps = Math.max(1, Math.ceil((2 * dist(a, b)) / cell));
63
+ for (let k = 0; k <= steps; k++) {
64
+ const x = Math.floor((a.x + ((b.x - a.x) * k) / steps - x0) / cell);
65
+ const y = Math.floor((a.y + ((b.y - a.y) * k) / steps - y0) / cell);
66
+ if (x < 0 || y < 0 || x >= w || y >= h) continue;
67
+ const c = y * w + x;
68
+ if (stamp[c] === li) continue;
69
+ stamp[c] = li;
70
+ raw[c] = raw[c]! + 1;
71
+ }
72
+ }
73
+ });
74
+ // A blur with a Gaussian kernel, first along x, then along y.
75
+ const sigma = Math.max(1e-6, bandwidth / cell);
76
+ const r = Math.ceil(3 * sigma);
77
+ const kernel = Array.from({ length: 2 * r + 1 }, (_, i) => Math.exp(-((i - r) ** 2) / (2 * sigma * sigma)));
78
+ const sum = kernel.reduce((s, v) => s + v, 0);
79
+ const tmp = new Float64Array(w * h);
80
+ for (let y = 0; y < h; y++)
81
+ for (let x = 0; x < w; x++) {
82
+ let v = 0;
83
+ for (let i = -r; i <= r; i++) {
84
+ const xi = x + i;
85
+ if (xi >= 0 && xi < w) v += raw[y * w + xi]! * kernel[i + r]!;
86
+ }
87
+ tmp[y * w + x] = v / sum;
88
+ }
89
+ const rho = new Float64Array(w * h);
90
+ for (let y = 0; y < h; y++)
91
+ for (let x = 0; x < w; x++) {
92
+ let v = 0;
93
+ for (let i = -r; i <= r; i++) {
94
+ const yi = y + i;
95
+ if (yi >= 0 && yi < h) v += tmp[yi * w + x]! * kernel[i + r]!;
96
+ }
97
+ rho[y * w + x] = v / sum;
98
+ }
99
+ return { w, h, cell, x0, y0, rho };
100
+ }
101
+
102
+ /** One ridge: its two ends and the length between them. */
103
+ export interface Ridge {
104
+ readonly a: Point;
105
+ readonly b: Point;
106
+ }
107
+
108
+ /**
109
+ * This function gives the ridges of a density image (Lindeberg 1998). A ridge cell has a negative eigenvalue of the Hessian with the larger size.
110
+ * Its density is a maximum along the eigenvector of that eigenvalue. Its strength is the density times the size of that eigenvalue.
111
+ * The cells with `strength` times the largest strength or more join their neighbours with a direction within 30 degrees.
112
+ * Each group of cells gives the segment of its main axis. A segment of `minLength` or more is a ridge. The ridges come in the order of their total strength.
113
+ */
114
+ export function densityRidges(img: DensityImage, strength: number, minLength: number): Ridge[] {
115
+ const { w, h, rho } = img;
116
+ const at = (x: number, y: number): number => rho[Math.min(h - 1, Math.max(0, y)) * w + Math.min(w - 1, Math.max(0, x))]!;
117
+ const power = new Float64Array(w * h);
118
+ const across = new Float64Array(w * h * 2);
119
+ let top = 0;
120
+ for (let y = 1; y < h - 1; y++)
121
+ for (let x = 1; x < w - 1; x++) {
122
+ const v = at(x, y);
123
+ if (v <= 0) continue;
124
+ const hxx = at(x + 1, y) - 2 * v + at(x - 1, y);
125
+ const hyy = at(x, y + 1) - 2 * v + at(x, y - 1);
126
+ const hxy = (at(x + 1, y + 1) - at(x + 1, y - 1) - at(x - 1, y + 1) + at(x - 1, y - 1)) / 4;
127
+ const mean = (hxx + hyy) / 2;
128
+ const root = Math.sqrt(((hxx - hyy) / 2) ** 2 + hxy * hxy);
129
+ const l1 = mean - root;
130
+ const l2 = mean + root;
131
+ if (!(l1 < 0 && Math.abs(l1) > Math.abs(l2))) continue;
132
+ // The eigenvector of `l1` points across the ridge.
133
+ let vx = hxy;
134
+ let vy = l1 - hxx;
135
+ if (Math.abs(vx) + Math.abs(vy) < 1e-12) [vx, vy] = hxx <= hyy ? [1, 0] : [0, 1];
136
+ const n = Math.hypot(vx, vy);
137
+ vx /= n;
138
+ vy /= n;
139
+ const side = (s: number): number => {
140
+ const px = x + s * vx;
141
+ const py = y + s * vy;
142
+ const ix = Math.floor(px);
143
+ const iy = Math.floor(py);
144
+ const fx = px - ix;
145
+ const fy = py - iy;
146
+ return at(ix, iy) * (1 - fx) * (1 - fy) + at(ix + 1, iy) * fx * (1 - fy) + at(ix, iy + 1) * (1 - fx) * fy + at(ix + 1, iy + 1) * fx * fy;
147
+ };
148
+ if (v < side(1) || v < side(-1)) continue;
149
+ const s = v * -l1;
150
+ power[y * w + x] = s;
151
+ across[2 * (y * w + x)] = vx;
152
+ across[2 * (y * w + x) + 1] = vy;
153
+ top = Math.max(top, s);
154
+ }
155
+ if (top <= 0) return [];
156
+ const floor = strength * top;
157
+ const seen = new Uint8Array(w * h);
158
+ const cos = Math.cos((MAX_ANGLE * Math.PI) / 180);
159
+ const out: { ridge: Ridge; total: number }[] = [];
160
+ for (let c = 0; c < w * h; c++) {
161
+ if (seen[c] || power[c]! < floor || power[c]! <= 0) continue;
162
+ const cells: number[] = [];
163
+ const stack = [c];
164
+ seen[c] = 1;
165
+ while (stack.length > 0) {
166
+ const i = stack.pop()!;
167
+ cells.push(i);
168
+ const x = i % w;
169
+ const y = Math.floor(i / w);
170
+ for (let dy = -1; dy <= 1; dy++)
171
+ for (let dx = -1; dx <= 1; dx++) {
172
+ const xj = x + dx;
173
+ const yj = y + dy;
174
+ if ((dx === 0 && dy === 0) || xj < 0 || yj < 0 || xj >= w || yj >= h) continue;
175
+ const j = yj * w + xj;
176
+ if (seen[j] || power[j]! < floor || power[j]! <= 0) continue;
177
+ if (Math.abs(across[2 * i]! * across[2 * j]! + across[2 * i + 1]! * across[2 * j + 1]!) < cos) continue;
178
+ seen[j] = 1;
179
+ stack.push(j);
180
+ }
181
+ }
182
+ // The main axis of the cells, weighted by their strength.
183
+ let sw = 0;
184
+ let mx = 0;
185
+ let my = 0;
186
+ for (const i of cells) {
187
+ const p = power[i]!;
188
+ sw += p;
189
+ mx += p * (i % w);
190
+ my += p * Math.floor(i / w);
191
+ }
192
+ mx /= sw;
193
+ my /= sw;
194
+ let sxx = 0;
195
+ let syy = 0;
196
+ let sxy = 0;
197
+ for (const i of cells) {
198
+ const p = power[i]!;
199
+ const dx = (i % w) - mx;
200
+ const dy = Math.floor(i / w) - my;
201
+ sxx += p * dx * dx;
202
+ syy += p * dy * dy;
203
+ sxy += p * dx * dy;
204
+ }
205
+ const angle = 0.5 * Math.atan2(2 * sxy, sxx - syy);
206
+ const ux = Math.cos(angle);
207
+ const uy = Math.sin(angle);
208
+ let lo = Infinity;
209
+ let hi = -Infinity;
210
+ for (const i of cells) {
211
+ const u = ((i % w) - mx) * ux + (Math.floor(i / w) - my) * uy;
212
+ lo = Math.min(lo, u);
213
+ hi = Math.max(hi, u);
214
+ }
215
+ const toBoard = (u: number): Point => ({ x: img.x0 + (mx + u * ux + 0.5) * img.cell, y: img.y0 + (my + u * uy + 0.5) * img.cell });
216
+ const ridge = { a: toBoard(lo), b: toBoard(hi) };
217
+ if (dist(ridge.a, ridge.b) >= minLength) out.push({ ridge, total: sw });
218
+ }
219
+ out.sort((p, q) => q.total - p.total || p.ridge.a.x - q.ridge.a.x || p.ridge.a.y - q.ridge.a.y);
220
+ return out.map((x) => x.ridge);
221
+ }
222
+
223
+ /**
224
+ * This function gives the part of the segment `s` to `t` that is within `snap` of the ridge, between the two ends of the ridge.
225
+ * The part is two shares of the segment, from 0 at `s` to 1 at `t`. It gives `undefined` when the segment has no such part.
226
+ */
227
+ export function nearPart(s: Point, t: Point, r: Ridge, snap: number): [number, number] | undefined {
228
+ const len = dist(r.a, r.b);
229
+ if (len <= 1e-9) return undefined;
230
+ const ux = (r.b.x - r.a.x) / len;
231
+ const uy = (r.b.y - r.a.y) / len;
232
+ // The place along the ridge and the offset from it are linear in the share `q` of the segment.
233
+ const u0 = (s.x - r.a.x) * ux + (s.y - r.a.y) * uy;
234
+ const u1 = (t.x - r.a.x) * ux + (t.y - r.a.y) * uy;
235
+ const d0 = -(s.x - r.a.x) * uy + (s.y - r.a.y) * ux;
236
+ const d1 = -(t.x - r.a.x) * uy + (t.y - r.a.y) * ux;
237
+ let lo = 0;
238
+ let hi = 1;
239
+ const clip = (v0: number, v1: number, min: number, max: number): void => {
240
+ // The shares where `v0 + q (v1 - v0)` is from `min` to `max`.
241
+ const dv = v1 - v0;
242
+ if (Math.abs(dv) < 1e-12) {
243
+ if (v0 < min || v0 > max) hi = -1;
244
+ return;
245
+ }
246
+ const qa = (min - v0) / dv;
247
+ const qb = (max - v0) / dv;
248
+ lo = Math.max(lo, Math.min(qa, qb));
249
+ hi = Math.min(hi, Math.max(qa, qb));
250
+ };
251
+ clip(u0, u1, 0, len);
252
+ clip(d0, d1, -snap, snap);
253
+ return hi > lo ? [lo, hi] : undefined;
254
+ }
255
+
256
+ /**
257
+ * This function gives the lines of the ridges (HWY-032), each with the edges that snap to it.
258
+ * An edge snaps to a ridge when the part of its line near the ridge is half of `minLength` or longer.
259
+ * The angle between the edge and the ridge is 30 degrees or less.
260
+ * The edge joins and leaves the ridge at the ends of that part. When two ridges hold one part of an edge, only the longer part rides.
261
+ */
262
+ export function ridgeLines(input: HighwayInput, o: Readonly<RidgeOptions>): SnapLine[] {
263
+ const plugs = edgePlugs(input.graph);
264
+ const routes = o.source === "routes" ? firstRoutes(input) : undefined;
265
+ const lines = plugs.map(({ edge, ps, pt }) => routes?.get(edge.id) ?? [ps.point, pt.point]);
266
+ if (input.graph.nodes.length === 0 || lines.length === 0) return [];
267
+ let x0 = Infinity;
268
+ let y0 = Infinity;
269
+ let x1 = -Infinity;
270
+ let y1 = -Infinity;
271
+ for (const n of input.graph.nodes) {
272
+ x0 = Math.min(x0, n.box.x);
273
+ y0 = Math.min(y0, n.box.y);
274
+ x1 = Math.max(x1, n.box.x + n.box.w);
275
+ y1 = Math.max(y1, n.box.y + n.box.h);
276
+ }
277
+ const img = lineDensity(lines, { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }, o.cell, o.bandwidth);
278
+ const ridges = densityRidges(img, o.strength, o.minLength);
279
+ const cos = Math.cos((MAX_ANGLE * Math.PI) / 180);
280
+ // The parts of each edge near each ridge.
281
+ const parts = new Map<string, { r: number; lo: number; hi: number; long: number }[]>();
282
+ ridges.forEach((r, ri) => {
283
+ const rl = dist(r.a, r.b);
284
+ for (const { edge, ps, pt } of plugs) {
285
+ const s = ps.point;
286
+ const t = pt.point;
287
+ const el = dist(s, t);
288
+ if (el <= 1e-9) continue;
289
+ if (Math.abs(((t.x - s.x) * (r.b.x - r.a.x) + (t.y - s.y) * (r.b.y - r.a.y)) / (el * rl)) < cos) continue;
290
+ const near = nearPart(s, t, r, o.snap);
291
+ if (!near) continue;
292
+ const long = (near[1] - near[0]) * el;
293
+ if (long < o.minLength / 2) continue;
294
+ const list = parts.get(edge.id) ?? [];
295
+ list.push({ r: ri, lo: near[0], hi: near[1], long });
296
+ parts.set(edge.id, list);
297
+ }
298
+ });
299
+ const members = ridges.map((): SnapMember[] => []);
300
+ for (const { edge, ps, pt } of plugs) {
301
+ const list = (parts.get(edge.id) ?? []).sort((p, q) => q.long - p.long || p.r - q.r);
302
+ const kept: typeof list = [];
303
+ for (const p of list) if (!kept.some((k) => p.lo < k.hi && k.lo < p.hi)) kept.push(p);
304
+ for (const p of kept) {
305
+ const r = ridges[p.r]!;
306
+ const s = ps.point;
307
+ const t = pt.point;
308
+ const onRidge = (q: number): Point => {
309
+ const x = s.x + (t.x - s.x) * q;
310
+ const y = s.y + (t.y - s.y) * q;
311
+ const len = dist(r.a, r.b);
312
+ const u = ((x - r.a.x) * (r.b.x - r.a.x) + (y - r.a.y) * (r.b.y - r.a.y)) / (len * len);
313
+ return { x: r.a.x + (r.b.x - r.a.x) * u, y: r.a.y + (r.b.y - r.a.y) * u };
314
+ };
315
+ members[p.r]!.push({ edge: edge.id, from: onRidge(p.lo), to: onRidge(p.hi), at: p.lo });
316
+ }
317
+ }
318
+ return ridges.map((r, ri) => ({ key: `r${ri}`, a: r.a, b: r.b, members: members[ri]! }));
319
+ }
320
+
321
+ /**
322
+ * This function gives the highways of the ridges of the line density (HWY-032). One pass draws the density, with no advection.
323
+ * The ridges of the density are the spines, and the edges near a ridge snap to it between their nearest entry and exit points.
324
+ */
325
+ export function ridgePlan(input: HighwayInput, o: Readonly<RidgeOptions>): HighwayPlan {
326
+ const way = { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) };
327
+ return snapPlan(input, ridgeLines(input, o), { ...way, minSize: o.minSize });
328
+ }
@@ -0,0 +1,110 @@
1
+ import { dist, polylineLength, type Point } from "../geom.ts";
2
+ import { orderLanes } from "../segments.ts";
3
+ import { WayFinder, type WayOptions } from "../ways.ts";
4
+ import { nearestPlace, tidyPlan } from "./base.ts";
5
+ import { lengthBetween, memberAt, pointAt, type Highway, type HighwayInput, type HighwayMember, type HighwayPlan } from "./model.ts";
6
+ import { edgePlugs } from "./stretches.ts";
7
+
8
+ /** One edge that snaps to a line: the points where it joins and leaves the line, and its place along its own way. */
9
+ export interface SnapMember {
10
+ readonly edge: string;
11
+ /** The point on the line where the edge joins it. */
12
+ readonly from: Point;
13
+ /** The point on the line where the edge leaves it. */
14
+ readonly to: Point;
15
+ /** The place of the join along the edge, from 0 at the source to 1 at the target. It orders the highways of one edge. */
16
+ readonly at: number;
17
+ }
18
+
19
+ /** A straight line that a finder gives as the place of a highway, with the edges that snap to it. */
20
+ export interface SnapLine {
21
+ readonly key: string;
22
+ readonly a: Point;
23
+ readonly b: Point;
24
+ readonly members: readonly SnapMember[];
25
+ }
26
+
27
+ /** The options of `snapPlan`. */
28
+ export interface SnapOptions extends Pick<WayOptions, "margin" | "k"> {
29
+ /** A highway has this number of edges or more. The value is 2 or more. */
30
+ readonly minSize: number;
31
+ }
32
+
33
+ /** This function gives the line `u d + rho n` of the direction angle `theta`, with `d = (cos, sin)` and the normal `n = (-sin, cos)`. */
34
+ export function linePoint(theta: number, rho: number, u: number): Point {
35
+ const c = Math.cos(theta);
36
+ const s = Math.sin(theta);
37
+ return { x: u * c - rho * s, y: u * s + rho * c };
38
+ }
39
+
40
+ /**
41
+ * This function gives the highways of straight lines in the free space. Each line becomes a spine through the corner graph between its two ends.
42
+ * An end inside a box moves to the nearest corner of the graph.
43
+ * Each member joins the spine at the place nearest to its point `from`, and leaves it at the place nearest to its point `to`.
44
+ * A member whose part of the spine is shorter than the margin does not ride. A spine with fewer than `minSize` members is not a highway.
45
+ * An edge on several highways takes them in the order of `at`.
46
+ */
47
+ export function snapPlan(input: HighwayInput, lines: readonly SnapLine[], o: Readonly<SnapOptions>): HighwayPlan {
48
+ const min = Math.max(2, o.minSize);
49
+ const finder = new WayFinder(input.graph.nodes, { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) });
50
+ const corners = finder.corners;
51
+ const plugs = new Map(edgePlugs(input.graph).map((p) => [p.edge.id, p] as const));
52
+ const pad = finder.margin / 2;
53
+ const free = (p: Point): Point => {
54
+ if (!finder.obstacles.inside(p, pad)) return p;
55
+ const i = corners.nearest(p);
56
+ return i >= 0 ? corners.points[i]! : p;
57
+ };
58
+ const highways: Highway[] = [];
59
+ const at = new Map<string, number>();
60
+ for (const line of lines) {
61
+ const a = free(line.a);
62
+ const b = free(line.b);
63
+ if (dist(a, b) <= finder.margin) continue;
64
+ const raw = corners.path(a, b);
65
+ if (!raw) continue;
66
+ const spine = corners.pull(raw);
67
+ if (spine.length < 2 || polylineLength(spine) <= 1e-6) continue;
68
+ const riders: { edge: string; on: number; off: number; points: Point[] }[] = [];
69
+ for (const m of line.members) {
70
+ const plug = plugs.get(m.edge);
71
+ if (!plug) continue;
72
+ const on = nearestPlace(spine, m.from);
73
+ const off = nearestPlace(spine, m.to);
74
+ if (lengthBetween(spine, Math.min(on, off), Math.max(on, off)) < finder.margin) continue;
75
+ riders.push({ edge: m.edge, on, off, points: [plug.ps.point, ...spinePart(spine, on, off), plug.pt.point] });
76
+ }
77
+ if (riders.length < min) continue;
78
+ const hi = highways.length;
79
+ for (const m of line.members) at.set(`${hi}\u0000${m.edge}`, m.at);
80
+ const lanes = orderLanes(spine, riders.map((r) => ({ edge: r.edge, points: r.points })), "bundle");
81
+ const byEdge = new Map(riders.map((r) => [r.edge, r]));
82
+ const members: HighwayMember[] = lanes.map((edge) => memberAt(spine, edge, byEdge.get(edge)!.on, byEdge.get(edge)!.off));
83
+ highways.push({ id: `h${hi}`, key: line.key, spine, lanes, members });
84
+ }
85
+ // The `seq` of each member follows `at` along its edge.
86
+ const trips = new Map<string, { h: number; at: number }[]>();
87
+ highways.forEach((h, hi) => {
88
+ for (const m of h.members) {
89
+ const list = trips.get(m.edge) ?? [];
90
+ list.push({ h: hi, at: at.get(`${hi}\u0000${m.edge}`) ?? 0 });
91
+ trips.set(m.edge, list);
92
+ }
93
+ });
94
+ const seq = new Map<string, number>();
95
+ for (const [edge, list] of trips) {
96
+ list.sort((p, q) => p.at - q.at || p.h - q.h);
97
+ list.forEach((x, k) => seq.set(`${x.h}\u0000${edge}`, k));
98
+ }
99
+ return tidyPlan({ highways: highways.map((h, hi) => ({ ...h, members: h.members.map((m) => ({ ...m, seq: seq.get(`${hi}\u0000${m.edge}`) ?? 0 })) })) });
100
+ }
101
+
102
+ /** This function gives the points of a spine from the place `on` to the place `off`, backward when `on` is larger. */
103
+ export function spinePart(spine: readonly Point[], on: number, off: number): Point[] {
104
+ const lo = Math.min(on, off);
105
+ const hi = Math.max(on, off);
106
+ const out = [pointAt(spine, lo)];
107
+ for (let i = Math.floor(lo) + 1; i < hi; i++) out.push(spine[i]!);
108
+ out.push(pointAt(spine, hi));
109
+ return on <= off ? out : out.reverse();
110
+ }