@mark1russell7/highways 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +43 -2
  2. package/build/index.d.ts +1472 -63
  3. package/build/index.d.ts.map +1 -1
  4. package/build/index.js +10007 -3406
  5. package/build/index.js.map +1 -1
  6. package/package.json +11 -2
  7. package/src/bench/exact/index.ts +72 -11
  8. package/src/bench/exact/optima-exact-v1-bounds.json +14409 -0
  9. package/src/bench/exact/optima-exact-v1.json +4809 -0
  10. package/src/bench/exact/solve.ts +129 -0
  11. package/src/bench/screen/activation.ts +3 -0
  12. package/src/bench/screen/bounds.ts +140 -0
  13. package/src/bench/screen/compare.ts +26 -0
  14. package/src/bench/screen/exact-set.ts +100 -0
  15. package/src/bench/screen/exercise.ts +290 -0
  16. package/src/bench/screen/forms.ts +14 -0
  17. package/src/bench/screen/gap.ts +316 -0
  18. package/src/bench/screen/hash.ts +7 -76
  19. package/src/bench/screen/index.ts +6 -0
  20. package/src/bench/screen/measures.ts +71 -1
  21. package/src/bench/screen/portfolio-pick.ts +20 -0
  22. package/src/bench/screen/screen.ts +50 -2
  23. package/src/bench/screen/set.ts +3 -2
  24. package/src/highways/areas/index.ts +160 -0
  25. package/src/highways/confluent/index.ts +154 -0
  26. package/src/highways/confluent/k23.test-util.ts +8 -0
  27. package/src/highways/consensus/index.ts +129 -0
  28. package/src/highways/crossbars/index.ts +184 -0
  29. package/src/highways/cubu/index.ts +26 -0
  30. package/src/highways/divided/index.ts +111 -0
  31. package/src/highways/fdeb/index.ts +193 -0
  32. package/src/highways/frechet/index.ts +232 -0
  33. package/src/highways/hierarchy/index.ts +140 -0
  34. package/src/highways/index.ts +64 -0
  35. package/src/highways/kdeeb/index.ts +355 -0
  36. package/src/highways/link-communities/index.ts +189 -0
  37. package/src/highways/mesh/index.ts +209 -0
  38. package/src/highways/mlseb/index.ts +204 -0
  39. package/src/highways/p-hub/index.ts +248 -0
  40. package/src/highways/power/index.ts +117 -0
  41. package/src/highways/resistance/index.ts +159 -0
  42. package/src/highways/sbeb/index.ts +303 -0
  43. package/src/highways/scale-space/index.ts +135 -0
  44. package/src/highways/spectral/index.ts +391 -0
  45. package/src/highways/territories/index.ts +150 -0
  46. package/src/highways/trace.ts +153 -0
  47. package/src/index.ts +1 -0
  48. package/src/portfolio/bands.ts +33 -0
  49. package/src/portfolio/fit.ts +29 -0
  50. package/src/portfolio/index.ts +6 -0
  51. package/src/portfolio/key.ts +98 -0
  52. package/src/portfolio/members.ts +171 -0
  53. package/src/portfolio/router.ts +133 -0
  54. package/src/portfolio/sets.ts +28 -0
  55. package/src/strategies/builtins.ts +222 -1
  56. package/src/strategies/schemas.ts +280 -1
@@ -0,0 +1,355 @@
1
+ import { dist, type Point } from "../../geom.ts";
2
+ import { WayFinder, type WayOptions } from "../../ways.ts";
3
+ import { firstRoutes } from "../base.ts";
4
+ import type { HighwayInput, HighwayPlan } from "../model.ts";
5
+ import { resample } from "../quickbundles/index.ts";
6
+ import { edgePlugs } from "../stretches.ts";
7
+ import { outOfBoxes, tracePlan } from "../trace.ts";
8
+
9
+ /**
10
+ * The way to compute the density from the sample points. `direct` adds a Gaussian at each sample point (KDEEB).
11
+ * `separable` counts the points in the cells and blurs the counts along x, then along y (CUBu). `fft` blurs the counts with a product of Fourier transforms (FFTEB).
12
+ */
13
+ export type DensityKernel = "direct" | "separable" | "fft";
14
+
15
+ /** The values of `DensityKernel`. */
16
+ export const DENSITY_KERNELS: readonly DensityKernel[] = Object.freeze(["direct", "separable", "fft"]);
17
+
18
+ /** The lines that make the first density: the straight lines of the edges, or their first routes. */
19
+ export type DensitySource = "lines" | "routes";
20
+
21
+ /** The options of the highway strategy `kdeeb` (RULE-028 with HWY-006 of the notes). */
22
+ export interface KdeebOptions extends Pick<WayOptions, "margin" | "k"> {
23
+ /** The lines of the first density. The default is `lines`. */
24
+ readonly source: DensitySource;
25
+ /** 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 512 cells on a side. */
26
+ readonly cell: number;
27
+ /** The width of the kernel at the first step, in units of the board. The default is 24. */
28
+ readonly bandwidth: number;
29
+ /** Each step multiplies the width of the kernel by this value. The default is 0.7. */
30
+ readonly decay: number;
31
+ /** The number of steps. The default is 10. */
32
+ readonly steps: number;
33
+ /** The share of the Laplacian smoothing of each polyline after each step, from 0 to 1. The default is 0.5. */
34
+ readonly smoothing: number;
35
+ /** With this option, the density is 0 in the boxes, so no ridge forms over a box (RULE-028). The default is `true`. */
36
+ readonly mask: boolean;
37
+ /** 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. */
38
+ readonly snap: number;
39
+ /** A highway has this number of edges or more. The default is 2. */
40
+ readonly minSize: number;
41
+ }
42
+
43
+ /** The default options of the strategy `kdeeb`. */
44
+ export const KDEEB_DEFAULTS: Readonly<Required<Omit<KdeebOptions, "margin" | "k">>> = Object.freeze({
45
+ source: "lines",
46
+ cell: 8,
47
+ bandwidth: 24,
48
+ decay: 0.7,
49
+ steps: 10,
50
+ smoothing: 0.5,
51
+ mask: true,
52
+ snap: 8,
53
+ minSize: 2,
54
+ });
55
+
56
+ /** The settings of the advection that the strategies `kdeeb` and `cubu` share. */
57
+ export interface AdvectionSettings {
58
+ /** The way to compute the density. */
59
+ readonly kernel: DensityKernel;
60
+ /** The spacing of the sample points after each step, in units of the board. The value 0 keeps the points where they are. */
61
+ readonly resample: number;
62
+ }
63
+
64
+ /** A grid of cells over the board: its size, the side of a cell, and the place of its first cell. */
65
+ export interface DensityGrid {
66
+ readonly w: number;
67
+ readonly h: number;
68
+ readonly cell: number;
69
+ readonly x0: number;
70
+ readonly y0: number;
71
+ }
72
+
73
+ /** This function gives a Gaussian kernel of the deviation `sigma` cells, from `-r` to `r` with `r = ceil(3 sigma)`, with a sum of 1. */
74
+ function kernel1d(sigma: number): Float64Array {
75
+ const s = Math.max(1e-6, sigma);
76
+ const r = Math.ceil(3 * s);
77
+ const k = new Float64Array(2 * r + 1);
78
+ let sum = 0;
79
+ for (let i = -r; i <= r; i++) sum += k[i + r] = Math.exp(-(i * i) / (2 * s * s));
80
+ for (let i = 0; i < k.length; i++) k[i] = k[i]! / sum;
81
+ return k;
82
+ }
83
+
84
+ /** This function counts the points in the cells of the grid. A point adds 1 to the cell that holds it. */
85
+ function splat(points: readonly Point[], g: DensityGrid): Float64Array {
86
+ const raw = new Float64Array(g.w * g.h);
87
+ for (const p of points) {
88
+ const x = Math.floor((p.x - g.x0) / g.cell);
89
+ const y = Math.floor((p.y - g.y0) / g.cell);
90
+ if (x >= 0 && y >= 0 && x < g.w && y < g.h) raw[y * g.w + x] = raw[y * g.w + x]! + 1;
91
+ }
92
+ return raw;
93
+ }
94
+
95
+ /** This function gives the density of the direct kernel: each point adds a Gaussian of the deviation `sigma` cells to the cells near it. */
96
+ function directDensity(points: readonly Point[], g: DensityGrid, sigma: number): Float64Array {
97
+ const rho = new Float64Array(g.w * g.h);
98
+ const s = Math.max(1e-6, sigma);
99
+ const r = Math.ceil(3 * s);
100
+ const norm = 1 / (2 * Math.PI * s * s);
101
+ for (const p of points) {
102
+ const cx = (p.x - g.x0) / g.cell - 0.5;
103
+ const cy = (p.y - g.y0) / g.cell - 0.5;
104
+ const ix = Math.round(cx);
105
+ const iy = Math.round(cy);
106
+ for (let y = Math.max(0, iy - r); y <= Math.min(g.h - 1, iy + r); y++)
107
+ for (let x = Math.max(0, ix - r); x <= Math.min(g.w - 1, ix + r); x++) {
108
+ const d2 = (x - cx) ** 2 + (y - cy) ** 2;
109
+ rho[y * g.w + x] = rho[y * g.w + x]! + norm * Math.exp(-d2 / (2 * s * s));
110
+ }
111
+ }
112
+ return rho;
113
+ }
114
+
115
+ /** This function gives the density of the separable kernel: the counts of the cells blurred along x, then along y. */
116
+ function separableDensity(points: readonly Point[], g: DensityGrid, sigma: number): Float64Array {
117
+ const raw = splat(points, g);
118
+ const k = kernel1d(sigma);
119
+ const r = (k.length - 1) / 2;
120
+ const tmp = new Float64Array(g.w * g.h);
121
+ for (let y = 0; y < g.h; y++)
122
+ for (let x = 0; x < g.w; x++) {
123
+ let v = 0;
124
+ for (let i = -r; i <= r; i++) {
125
+ const xi = x + i;
126
+ if (xi >= 0 && xi < g.w) v += raw[y * g.w + xi]! * k[i + r]!;
127
+ }
128
+ tmp[y * g.w + x] = v;
129
+ }
130
+ const rho = new Float64Array(g.w * g.h);
131
+ for (let y = 0; y < g.h; y++)
132
+ for (let x = 0; x < g.w; x++) {
133
+ let v = 0;
134
+ for (let i = -r; i <= r; i++) {
135
+ const yi = y + i;
136
+ if (yi >= 0 && yi < g.h) v += tmp[yi * g.w + x]! * k[i + r]!;
137
+ }
138
+ rho[y * g.w + x] = v;
139
+ }
140
+ return rho;
141
+ }
142
+
143
+ /** This function gives the fast Fourier transform of a row of `n` complex values in place, with `n` a power of 2. `inverse` gives the inverse without the division by `n`. */
144
+ export function fft(re: Float64Array, im: Float64Array, inverse = false): void {
145
+ const n = re.length;
146
+ for (let i = 1, j = 0; i < n; i++) {
147
+ let bit = n >> 1;
148
+ for (; j & bit; bit >>= 1) j ^= bit;
149
+ j ^= bit;
150
+ if (i < j) {
151
+ [re[i], re[j]] = [re[j]!, re[i]!];
152
+ [im[i], im[j]] = [im[j]!, im[i]!];
153
+ }
154
+ }
155
+ for (let len = 2; len <= n; len <<= 1) {
156
+ const ang = ((inverse ? 2 : -2) * Math.PI) / len;
157
+ const wr = Math.cos(ang);
158
+ const wi = Math.sin(ang);
159
+ for (let i = 0; i < n; i += len) {
160
+ let cr = 1;
161
+ let ci = 0;
162
+ for (let k = 0; k < len / 2; k++) {
163
+ const ar = re[i + k]!;
164
+ const ai = im[i + k]!;
165
+ const br = re[i + k + len / 2]! * cr - im[i + k + len / 2]! * ci;
166
+ const bi = re[i + k + len / 2]! * ci + im[i + k + len / 2]! * cr;
167
+ re[i + k] = ar + br;
168
+ im[i + k] = ai + bi;
169
+ re[i + k + len / 2] = ar - br;
170
+ im[i + k + len / 2] = ai - bi;
171
+ const t = cr * wr - ci * wi;
172
+ ci = cr * wi + ci * wr;
173
+ cr = t;
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ /** This function gives the 2D transform of a grid of `W` by `H` complex values in place. It transforms each row, then each column. */
180
+ function fft2(re: Float64Array, im: Float64Array, W: number, H: number, inverse: boolean): void {
181
+ const rr = new Float64Array(W);
182
+ const ri = new Float64Array(W);
183
+ for (let y = 0; y < H; y++) {
184
+ for (let x = 0; x < W; x++) {
185
+ rr[x] = re[y * W + x]!;
186
+ ri[x] = im[y * W + x]!;
187
+ }
188
+ fft(rr, ri, inverse);
189
+ for (let x = 0; x < W; x++) {
190
+ re[y * W + x] = rr[x]!;
191
+ im[y * W + x] = ri[x]!;
192
+ }
193
+ }
194
+ const cr = new Float64Array(H);
195
+ const ci = new Float64Array(H);
196
+ for (let x = 0; x < W; x++) {
197
+ for (let y = 0; y < H; y++) {
198
+ cr[y] = re[y * W + x]!;
199
+ ci[y] = im[y * W + x]!;
200
+ }
201
+ fft(cr, ci, inverse);
202
+ for (let y = 0; y < H; y++) {
203
+ re[y * W + x] = cr[y]!;
204
+ im[y * W + x] = ci[y]!;
205
+ }
206
+ }
207
+ }
208
+
209
+ /** This function gives the smallest power of 2 that is `n` or more. */
210
+ const pow2 = (n: number): number => {
211
+ let p = 1;
212
+ while (p < n) p <<= 1;
213
+ return p;
214
+ };
215
+
216
+ /**
217
+ * This function gives the density of the kernel `fft`: `rho = IFFT(FFT(counts) FFT(K))` (FFTEB). The grid gets zeros up to a power of 2.
218
+ * The zeros are as wide as the kernel, so the product does not wrap around.
219
+ */
220
+ function fftDensity(points: readonly Point[], g: DensityGrid, sigma: number): Float64Array {
221
+ const raw = splat(points, g);
222
+ const r = Math.ceil(3 * Math.max(1e-6, sigma));
223
+ const W = pow2(g.w + r);
224
+ const H = pow2(g.h + r);
225
+ const are = new Float64Array(W * H);
226
+ const aim = new Float64Array(W * H);
227
+ for (let y = 0; y < g.h; y++) for (let x = 0; x < g.w; x++) are[y * W + x] = raw[y * g.w + x]!;
228
+ const k = kernel1d(sigma);
229
+ const kre = new Float64Array(W * H);
230
+ const kim = new Float64Array(W * H);
231
+ for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) kre[((dy + H) % H) * W + ((dx + W) % W)] = k[dx + r]! * k[dy + r]!;
232
+ fft2(are, aim, W, H, false);
233
+ fft2(kre, kim, W, H, false);
234
+ for (let i = 0; i < W * H; i++) {
235
+ const a = are[i]!;
236
+ const b = aim[i]!;
237
+ are[i] = a * kre[i]! - b * kim[i]!;
238
+ aim[i] = a * kim[i]! + b * kre[i]!;
239
+ }
240
+ fft2(are, aim, W, H, true);
241
+ const rho = new Float64Array(g.w * g.h);
242
+ const scale = 1 / (W * H);
243
+ for (let y = 0; y < g.h; y++) for (let x = 0; x < g.w; x++) rho[y * g.w + x] = Math.max(0, are[y * W + x]! * scale);
244
+ return rho;
245
+ }
246
+
247
+ /** This function gives the density of the points on the grid with the kernel `kernel` of the width `bandwidth` in units of the board. */
248
+ export function pointDensity(points: readonly Point[], g: DensityGrid, bandwidth: number, kernel: DensityKernel): Float64Array {
249
+ const sigma = bandwidth / g.cell;
250
+ if (kernel === "direct") return directDensity(points, g, sigma);
251
+ if (kernel === "fft") return fftDensity(points, g, sigma);
252
+ return separableDensity(points, g, sigma);
253
+ }
254
+
255
+ /** This function gives the value of a grid at a point of the board, with a bilinear mix of the four nearest cells. */
256
+ function sample(rho: Float64Array, g: DensityGrid, p: Point): number {
257
+ const fx = (p.x - g.x0) / g.cell - 0.5;
258
+ const fy = (p.y - g.y0) / g.cell - 0.5;
259
+ const x = Math.floor(fx);
260
+ const y = Math.floor(fy);
261
+ const u = fx - x;
262
+ const v = fy - y;
263
+ const at = (i: number, j: number): number => (i < 0 || j < 0 || i >= g.w || j >= g.h ? 0 : rho[j * g.w + i]!);
264
+ return at(x, y) * (1 - u) * (1 - v) + at(x + 1, y) * u * (1 - v) + at(x, y + 1) * (1 - u) * v + at(x + 1, y + 1) * u * v;
265
+ }
266
+
267
+ /**
268
+ * This function gives the polylines of the density bundling (KDEEB, Hurter, Ersoy and Telea 2012). The ends of each polyline stay.
269
+ * In each step, the sample points of all edges make a density. Each inner point moves `h / 4` up the gradient of the density, with `h` the width of the kernel.
270
+ * The width then shrinks by `decay`, so the edges gather into ridges. A Laplacian smoothing keeps each polyline smooth.
271
+ * With `mask`, the density is 0 in the boxes (RULE-028), and a point in a box moves out of it.
272
+ */
273
+ export function kdeebLines(input: HighwayInput, finder: WayFinder, o: Readonly<KdeebOptions>, settings: Readonly<AdvectionSettings>): Map<string, Point[]> {
274
+ const plugs = edgePlugs(input.graph);
275
+ const out = new Map<string, Point[]>();
276
+ if (plugs.length === 0) return out;
277
+ const routes = o.source === "routes" ? firstRoutes(input) : undefined;
278
+ const spacing = Math.max(1e-3, o.cell);
279
+ let lines: Point[][] = plugs.map(({ edge, ps, pt }) => {
280
+ const a = finder.out(ps);
281
+ const b = finder.out(pt);
282
+ const route = routes?.get(edge.id);
283
+ const base = route && route.length > 2 ? [a, ...route.slice(1, -1), b] : [a, b];
284
+ let len = 0;
285
+ for (let i = 1; i < base.length; i++) len += dist(base[i - 1]!, base[i]!);
286
+ return resample(base, Math.max(2, Math.min(129, Math.ceil(len / spacing) + 1)));
287
+ });
288
+ let x0 = Infinity;
289
+ let y0 = Infinity;
290
+ let x1 = -Infinity;
291
+ let y1 = -Infinity;
292
+ for (const n of input.graph.nodes) {
293
+ x0 = Math.min(x0, n.box.x);
294
+ y0 = Math.min(y0, n.box.y);
295
+ x1 = Math.max(x1, n.box.x + n.box.w);
296
+ y1 = Math.max(y1, n.box.y + n.box.h);
297
+ }
298
+ const pad = 3 * Math.max(0, o.bandwidth);
299
+ const cell = Math.max(spacing, (x1 - x0 + 2 * pad) / 512, (y1 - y0 + 2 * pad) / 512);
300
+ const g: DensityGrid = { cell, x0: x0 - pad, y0: y0 - pad, w: Math.max(1, Math.ceil((x1 - x0 + 2 * pad) / cell)), h: Math.max(1, Math.ceil((y1 - y0 + 2 * pad) / cell)) };
301
+ const boxPad = finder.margin / 2;
302
+ // The mask: the cells whose middle is in a box get no density.
303
+ const masked = new Uint8Array(g.w * g.h);
304
+ if (o.mask)
305
+ for (let y = 0; y < g.h; y++)
306
+ for (let x = 0; x < g.w; x++) if (finder.obstacles.inside({ x: g.x0 + (x + 0.5) * cell, y: g.y0 + (y + 0.5) * cell }, boxPad)) masked[y * g.w + x] = 1;
307
+ const smooth = Math.max(0, Math.min(1, o.smoothing));
308
+ let h = Math.max(1e-3, o.bandwidth);
309
+ for (let step = 0; step < Math.max(0, Math.round(o.steps)); step++) {
310
+ const points = lines.flatMap((l) => l.slice(1, -1));
311
+ const rho = pointDensity(points, g, h, settings.kernel);
312
+ if (o.mask) for (let i = 0; i < rho.length; i++) if (masked[i]) rho[i] = 0;
313
+ const eta = h / 4;
314
+ const d = cell;
315
+ lines = lines.map((l) => {
316
+ const moved = l.map((p, i) => {
317
+ if (i === 0 || i === l.length - 1) return p;
318
+ const gx = sample(rho, g, { x: p.x + d, y: p.y }) - sample(rho, g, { x: p.x - d, y: p.y });
319
+ const gy = sample(rho, g, { x: p.x, y: p.y + d }) - sample(rho, g, { x: p.x, y: p.y - d });
320
+ const n = Math.hypot(gx, gy);
321
+ return n > 1e-12 ? { x: p.x + (eta * gx) / n, y: p.y + (eta * gy) / n } : p;
322
+ });
323
+ const smoothed = moved.map((p, i) => {
324
+ if (i === 0 || i === moved.length - 1 || smooth === 0) return p;
325
+ const a = moved[i - 1]!;
326
+ const b = moved[i + 1]!;
327
+ return { x: (1 - smooth) * p.x + (smooth * (a.x + b.x)) / 2, y: (1 - smooth) * p.y + (smooth * (a.y + b.y)) / 2 };
328
+ });
329
+ const free = o.mask ? smoothed.map((p, i) => (i === 0 || i === smoothed.length - 1 ? p : outOfBoxes(finder.obstacles, p, boxPad))) : smoothed;
330
+ if (settings.resample <= 0) return free;
331
+ let len = 0;
332
+ for (let i = 1; i < free.length; i++) len += dist(free[i - 1]!, free[i]!);
333
+ return resample(free, Math.max(2, Math.min(129, Math.ceil(len / settings.resample) + 1)));
334
+ });
335
+ h *= Math.max(0, o.decay);
336
+ }
337
+ plugs.forEach(({ edge }, e) => out.set(edge.id, lines[e]!));
338
+ return out;
339
+ }
340
+
341
+ /** This function gives the highways of the bundled polylines of `kdeebLines`. The points snap to a grid of side `snap`, and the ways go through the corner graph. */
342
+ export function densityPlan(input: HighwayInput, o: Readonly<KdeebOptions>, settings: Readonly<AdvectionSettings>, key: string): HighwayPlan {
343
+ const finder = new WayFinder(input.graph.nodes, { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) });
344
+ const lines = kdeebLines(input, finder, o, settings);
345
+ const waypoints = new Map([...lines].map(([edge, l]) => [edge, l.slice(1, -1)] as const));
346
+ return tracePlan(input, finder, waypoints, { snap: o.snap, minSize: o.minSize, key });
347
+ }
348
+
349
+ /**
350
+ * This function gives the highways of the density bundling with a mask of the free space (RULE-028 with HWY-006).
351
+ * The kernel is direct: each sample point adds a Gaussian. The final ridges of the polylines become the highways.
352
+ */
353
+ export function kdeebPlan(input: HighwayInput, o: Readonly<KdeebOptions>): HighwayPlan {
354
+ return densityPlan(input, o, { kernel: "direct", resample: 0 }, "kdeeb");
355
+ }
@@ -0,0 +1,189 @@
1
+ import { boardUnit } from "../../bench/measures/routes.ts";
2
+ import { dist, type Point } from "../../geom.ts";
3
+ import { groupedPlan, weberPoint, type EdgeGroup, type GroupSpine } from "../grouped.ts";
4
+ import type { HighwayInput, HighwayPlan } from "../model.ts";
5
+ import { edgePlugs } from "../stretches.ts";
6
+
7
+ /** The options of the highway strategy `link-communities` (HWY-030 of the note on highways). */
8
+ export interface LinkCommunityOptions {
9
+ /** The cut of the tree of the edges. The value 0 cuts at the best partition density. A value above 0 keeps the joins with this similarity or more. The default is 0. */
10
+ readonly threshold: number;
11
+ /** A community with this number of edges or more gets highways, after the check of the geometry. The default is 2. */
12
+ readonly minSize: number;
13
+ /** The largest distance of an end of an edge to the Weber point of its end of the community, in units of the board. The default is 6. */
14
+ readonly spread: number;
15
+ /** The spine of each community. The default is `shared`. */
16
+ readonly spine: GroupSpine;
17
+ }
18
+
19
+ /** The default options of the strategy `link-communities`. */
20
+ export const LINK_COMMUNITY_DEFAULTS: Readonly<LinkCommunityOptions> = Object.freeze({ threshold: 0, minSize: 2, spread: 6, spine: "shared" });
21
+
22
+ /** One pair of edges with a common node, and its similarity. */
23
+ interface EdgePair {
24
+ readonly e: number;
25
+ readonly f: number;
26
+ readonly s: number;
27
+ }
28
+
29
+ /** The ends of one edge as node indices. */
30
+ export interface LinkEdge {
31
+ readonly u: number;
32
+ readonly v: number;
33
+ }
34
+
35
+ /**
36
+ * This function gives the similarity of each pair of edges with a common node `k` (Ahn, Bagrow and Lehmann 2010).
37
+ * For the edges `(i, k)` and `(j, k)`, the value is `|N+(i) ∩ N+(j)| / |N+(i) ∪ N+(j)|`. `N+(i)` is the node `i` and its neighbours.
38
+ * Two parallel edges get the value 1. A loop has no pair.
39
+ */
40
+ function edgePairs(n: number, edges: readonly LinkEdge[]): EdgePair[] {
41
+ const near: Set<number>[] = Array.from({ length: n }, (_, i) => new Set([i]));
42
+ const at: number[][] = Array.from({ length: n }, () => []);
43
+ edges.forEach(({ u, v }, e) => {
44
+ if (u === v) return;
45
+ near[u]!.add(v);
46
+ near[v]!.add(u);
47
+ at[u]!.push(e);
48
+ at[v]!.push(e);
49
+ });
50
+ const jaccard = (i: number, j: number): number => {
51
+ const a = near[i]!;
52
+ const b = near[j]!;
53
+ let both = 0;
54
+ for (const x of a) if (b.has(x)) both++;
55
+ return both / (a.size + b.size - both);
56
+ };
57
+ const out = new Map<string, EdgePair>();
58
+ for (let k = 0; k < n; k++) {
59
+ const list = at[k]!;
60
+ for (let x = 0; x < list.length; x++)
61
+ for (let y = x + 1; y < list.length; y++) {
62
+ const e = Math.min(list[x]!, list[y]!);
63
+ const f = Math.max(list[x]!, list[y]!);
64
+ if (e === f) continue;
65
+ const key = `${e},${f}`;
66
+ if (out.has(key)) continue;
67
+ const oe = edges[e]!.u === k ? edges[e]!.v : edges[e]!.u;
68
+ const of = edges[f]!.u === k ? edges[f]!.v : edges[f]!.u;
69
+ out.set(key, { e, f, s: oe === of ? 1 : jaccard(oe, of) });
70
+ }
71
+ }
72
+ return [...out.values()].sort((p, q) => q.s - p.s || p.e - q.e || p.f - q.f);
73
+ }
74
+
75
+ /**
76
+ * This function gives the link communities of the edges: the single linkage of the edges by their similarity.
77
+ * With `threshold` 0, the cut is at the level of the best partition density `D = (2/M) Σ m_c (m_c - n_c + 1) / ((n_c - 2)(n_c - 1))`.
78
+ * When the best `D` is above 0, the first level with it applies. When it is 0 (a forest), the last level applies.
79
+ * The result is the community of each edge, as the index of an edge of the community. A loop is alone.
80
+ */
81
+ export function linkCommunities(n: number, edges: readonly LinkEdge[], threshold: number): Int32Array {
82
+ const m = edges.length;
83
+ const pairs = edgePairs(n, edges);
84
+ const parent = new Int32Array(m);
85
+ for (let i = 0; i < m; i++) parent[i] = i;
86
+ const find = (i: number): number => {
87
+ while (parent[i] !== i) i = parent[i] = parent[parent[i]!]!;
88
+ return i;
89
+ };
90
+ let cut = threshold;
91
+ if (!(threshold > 0)) {
92
+ // The partition density after each level of the tree.
93
+ const edgeCount = new Int32Array(m).fill(1);
94
+ const nodes: Set<number>[] = edges.map(({ u, v }) => new Set([u, v]));
95
+ const term = (c: number): number => {
96
+ const mc = edgeCount[c]!;
97
+ const nc = nodes[c]!.size;
98
+ return nc <= 2 ? 0 : (mc * (mc - nc + 1)) / ((nc - 2) * (nc - 1));
99
+ };
100
+ let sum = 0;
101
+ const levels: { s: number; d: number }[] = [];
102
+ for (let i = 0; i < pairs.length; ) {
103
+ const s = pairs[i]!.s;
104
+ if (s <= 0) break;
105
+ for (; i < pairs.length && pairs[i]!.s === s; i++) {
106
+ const a = find(pairs[i]!.e);
107
+ const b = find(pairs[i]!.f);
108
+ if (a === b) continue;
109
+ sum -= term(a) + term(b);
110
+ const [big, small] = nodes[a]!.size >= nodes[b]!.size ? [a, b] : [b, a];
111
+ parent[small] = big;
112
+ edgeCount[big] = edgeCount[big]! + edgeCount[small]!;
113
+ for (const x of nodes[small]!) nodes[big]!.add(x);
114
+ nodes[small] = new Set();
115
+ sum += term(big);
116
+ }
117
+ levels.push({ s, d: (2 / m) * sum });
118
+ }
119
+ const best = levels.reduce((b, l) => Math.max(b, l.d), 0);
120
+ cut = best > 1e-12 ? levels.find((l) => l.d >= best - 1e-12)!.s : (levels[levels.length - 1]?.s ?? Infinity);
121
+ for (let i = 0; i < m; i++) parent[i] = i;
122
+ }
123
+ for (const p of pairs) {
124
+ if (p.s < cut || p.s <= 0) break;
125
+ const a = find(p.e);
126
+ const b = find(p.f);
127
+ if (a !== b) parent[Math.max(a, b)] = Math.min(a, b);
128
+ }
129
+ const out = new Int32Array(m);
130
+ for (let i = 0; i < m; i++) out[i] = find(i);
131
+ return out;
132
+ }
133
+
134
+ /**
135
+ * This function gives the edge groups of the link communities (HWY-030). The hub of a community is its node with the most edges.
136
+ * Each edge goes from its end at the hub, or else in the direction of the hub edges. The check of the geometry removes each edge with an end
137
+ * farther than `spread` units from the Weber point of that end of the community. A community with fewer than `minSize` edges after the check gets no group.
138
+ */
139
+ export function linkCommunityGroups(input: HighwayInput, o: Readonly<LinkCommunityOptions>): Map<string, EdgeGroup> {
140
+ const graph = input.graph;
141
+ const index = new Map(graph.nodes.map((v, i) => [v.id, i] as const));
142
+ const plugs = edgePlugs(graph);
143
+ const edges: LinkEdge[] = plugs.map(({ edge }) => ({ u: index.get(edge.source.node)!, v: index.get(edge.target.node)! }));
144
+ const label = linkCommunities(graph.nodes.length, edges, o.threshold);
145
+ const limit = Math.max(0, o.spread) * boardUnit(graph);
146
+ const min = Math.max(2, Math.round(o.minSize));
147
+ const members = new Map<number, number[]>();
148
+ label.forEach((c, i) => {
149
+ if (edges[i]!.u === edges[i]!.v) return;
150
+ const list = members.get(c);
151
+ if (list) list.push(i);
152
+ else members.set(c, [i]);
153
+ });
154
+ const groups = new Map<string, EdgeGroup>();
155
+ for (const [c, list] of members) {
156
+ if (list.length < min) continue;
157
+ const degree = new Map<number, number>();
158
+ for (const i of list) for (const x of [edges[i]!.u, edges[i]!.v]) degree.set(x, (degree.get(x) ?? 0) + 1);
159
+ let hub = -1;
160
+ for (const [x, d] of degree) if (hub < 0 || d > degree.get(hub)! || (d === degree.get(hub)! && x < hub)) hub = x;
161
+ // The mean direction of the edges at the hub, out of the hub.
162
+ let dx = 0;
163
+ let dy = 0;
164
+ for (const i of list) {
165
+ const { ps, pt } = plugs[i]!;
166
+ const sign = edges[i]!.u === hub ? 1 : edges[i]!.v === hub ? -1 : 0;
167
+ dx += sign * (pt.point.x - ps.point.x);
168
+ dy += sign * (pt.point.y - ps.point.y);
169
+ }
170
+ const oriented = list.map((i) => {
171
+ const { ps, pt } = plugs[i]!;
172
+ const flip = edges[i]!.u === hub ? false : edges[i]!.v === hub ? true : (pt.point.x - ps.point.x) * dx + (pt.point.y - ps.point.y) * dy < 0;
173
+ const a: Point = flip ? pt.point : ps.point;
174
+ const b: Point = flip ? ps.point : pt.point;
175
+ return { i, flip, a, b };
176
+ });
177
+ const wa = weberPoint(oriented.map((x) => x.a));
178
+ const wb = weberPoint(oriented.map((x) => x.b));
179
+ const kept = oriented.filter((x) => dist(x.a, wa) <= limit && dist(x.b, wb) <= limit);
180
+ if (kept.length < min) continue;
181
+ for (const x of kept) groups.set(plugs[x.i]!.edge.id, { key: `lc${c}`, flip: x.flip });
182
+ }
183
+ return groups;
184
+ }
185
+
186
+ /** This function gives the highways of the link communities of the line graph (HWY-030). Each community that passes the check of the geometry gets highways. */
187
+ export function linkCommunityPlan(input: HighwayInput, o: Readonly<LinkCommunityOptions>): HighwayPlan {
188
+ return groupedPlan(input, linkCommunityGroups(input, o), { spine: o.spine, minSize: Math.max(2, Math.round(o.minSize)) });
189
+ }