@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,117 @@
1
+ import type { WayOptions } from "../../ways.ts";
2
+ import { nodeLinks, type GroupScope } from "../confluent/index.ts";
3
+ import { groupedPlan, type EdgeGroup, type GroupSpine } from "../grouped.ts";
4
+ import type { HighwayInput, HighwayPlan } from "../model.ts";
5
+
6
+ /** The rule of a module: the nodes with the same neighbours (`exact`), or with near neighbours by the Jaccard index (`near`). */
7
+ export type ModuleRule = "exact" | "near";
8
+
9
+ /** The values of `ModuleRule`. */
10
+ export const MODULE_RULES: readonly ModuleRule[] = Object.freeze(["exact", "near"]);
11
+
12
+ /** The options of the highway strategy `power` (HWY-014 of the notes). */
13
+ export interface PowerOptions extends Pick<WayOptions, "margin" | "k"> {
14
+ /** The rule of the modules. The default is `exact`. */
15
+ readonly modules: ModuleRule;
16
+ /** With the rule `near`, a node joins a module at this Jaccard index or more with the first node of the module. The default is 0.8. */
17
+ readonly jaccard: number;
18
+ /** A power edge saves the number of its edges less 1. It becomes a highway when it saves this number of edges or more. The default is 2. */
19
+ readonly minSaved: number;
20
+ /** The edges of one power edge. With `collection`, a power edge holds the edges of one collection. The default is `all`. */
21
+ readonly scope: GroupScope;
22
+ /** The spine of each power edge. The default is `trunk`. */
23
+ readonly spine: GroupSpine;
24
+ /** An edge rides the trunk only when each of its legs is this share of the trunk or shorter. `Infinity` sets no limit. The default is 1. */
25
+ readonly legMax: number;
26
+ }
27
+
28
+ /** The default options of the strategy `power`. */
29
+ export const POWER_DEFAULTS: Readonly<Required<Omit<PowerOptions, "margin" | "k">>> = Object.freeze({
30
+ modules: "exact",
31
+ jaccard: 0.8,
32
+ minSaved: 2,
33
+ scope: "all",
34
+ spine: "trunk",
35
+ legMax: 1,
36
+ });
37
+
38
+ /** This function gives the Jaccard index of two sets: the size of their common part over the size of their union. Two empty sets give 1. */
39
+ export function jaccardIndex(a: ReadonlySet<string>, b: ReadonlySet<string>): number {
40
+ let common = 0;
41
+ for (const x of a) if (b.has(x)) common++;
42
+ const union = a.size + b.size - common;
43
+ return union === 0 ? 1 : common / union;
44
+ }
45
+
46
+ /**
47
+ * This function gives the module of each node. With `exact`, the nodes with the same set of neighbours are one module.
48
+ * With `near`, the nodes go in the order of their degree, the largest first. A node joins the first module whose first node has a Jaccard index of `jaccard` or more with it.
49
+ * The function gives the modules as lists of nodes, each in the order of the ids.
50
+ */
51
+ export function modulesOf(links: ReadonlyMap<string, ReadonlyMap<string, readonly string[]>>, rule: ModuleRule, jaccard: number): string[][] {
52
+ const nodes = [...links.keys()].sort();
53
+ const sets = new Map(nodes.map((u) => [u, new Set(links.get(u)!.keys())] as const));
54
+ if (rule === "exact") {
55
+ const byKey = new Map<string, string[]>();
56
+ for (const u of nodes) {
57
+ const key = [...sets.get(u)!].sort().join("\u0000");
58
+ const list = byKey.get(key);
59
+ if (list) list.push(u);
60
+ else byKey.set(key, [u]);
61
+ }
62
+ return [...byKey.values()];
63
+ }
64
+ const order = [...nodes].sort((u, v) => sets.get(v)!.size - sets.get(u)!.size || (u < v ? -1 : u > v ? 1 : 0));
65
+ const modules: string[][] = [];
66
+ for (const u of order) {
67
+ const home = modules.find((m) => !sets.get(m[0]!)!.has(u) && jaccardIndex(sets.get(m[0]!)!, sets.get(u)!) >= jaccard);
68
+ if (home) home.push(u);
69
+ else modules.push([u]);
70
+ }
71
+ return modules.map((m) => [...m].sort());
72
+ }
73
+
74
+ /**
75
+ * This function gives the highways of the edge compression with modules and power graphs (HWY-014, Dwyer, Henry Riche, Marriott and Mears 2013).
76
+ * The edges between two modules are one power edge. With modules of the rule `exact`, each node of one module links to each node of the other.
77
+ * A power edge that saves `minSaved` edges or more is one highway from module to module.
78
+ * Its trunk goes between the Weber points of the two ends.
79
+ * The search does not nest modules, so it finds no power edge inside a power edge.
80
+ */
81
+ export function powerPlan(input: HighwayInput, o: Readonly<PowerOptions>): HighwayPlan {
82
+ const groups = new Map<string, EdgeGroup>();
83
+ const sourceOf = new Map(input.graph.edges.map((e) => [e.id, e.source.node] as const));
84
+ for (const scope of nodeLinks(input.graph, o.scope)) {
85
+ const modules = modulesOf(scope.links, o.modules, o.jaccard);
86
+ const moduleOf = new Map<string, number>();
87
+ modules.forEach((m, i) => {
88
+ for (const u of m) moduleOf.set(u, i);
89
+ });
90
+ // The edges between each pair of modules, with the smaller index first.
91
+ const between = new Map<string, { i: number; j: number; edges: string[] }>();
92
+ for (const [u, m] of scope.links)
93
+ for (const [v, edges] of m) {
94
+ const i = moduleOf.get(u)!;
95
+ const j = moduleOf.get(v)!;
96
+ if (i >= j) continue;
97
+ const key = `${i},${j}`;
98
+ let p = between.get(key);
99
+ if (!p) between.set(key, (p = { i, j, edges: [] }));
100
+ for (const e of edges) if (!p.edges.includes(e)) p.edges.push(e);
101
+ }
102
+ const pairs = [...between.values()].sort((p, q) => q.edges.length - p.edges.length || p.i - q.i || p.j - q.j);
103
+ for (const p of pairs) {
104
+ if (p.edges.length - 1 < Math.max(1, o.minSaved)) continue;
105
+ const first = new Set(modules[p.i]);
106
+ const key = `p${scope.key}/${p.i}-${p.j}`;
107
+ for (const e of p.edges) if (!groups.has(e)) groups.set(e, { key, flip: !first.has(sourceOf.get(e)!) });
108
+ }
109
+ }
110
+ return groupedPlan(input, groups, {
111
+ ...(o.margin === undefined ? {} : { margin: o.margin }),
112
+ ...(o.k === undefined ? {} : { k: o.k }),
113
+ spine: o.spine,
114
+ minSize: 2,
115
+ legMax: o.legMax,
116
+ });
117
+ }
@@ -0,0 +1,159 @@
1
+ import { rng } from "../../fixtures.ts";
2
+ import type { Graph } from "../../model.ts";
3
+ import { groupedPlan, type EdgeGroup } from "../grouped.ts";
4
+ import type { HighwayInput, HighwayPlan } from "../model.ts";
5
+ import { compatibilityMatrix, edgeBlocks, edgeEnds, labelGroups, spectralLabels, type SpectralOptions } from "../spectral/index.ts";
6
+
7
+ /** The options of the highway strategy `resistance` (HWY-021 of the note on highways). */
8
+ export interface ResistanceOptions extends SpectralOptions {
9
+ /** The number of random projections of the estimate of the resistance. The default is 16. */
10
+ readonly projections: number;
11
+ /** The weight of the resistance, from 0 to 1. A pair with `C >= c0` gets the weight `C (1 - weight R_e R_f)`. The default is 0.5. */
12
+ readonly weight: number;
13
+ /** With `true`, an edge with a resistance of 0.99 or more (a bridge) gets no pair. The default is `false`. */
14
+ readonly sparsify: boolean;
15
+ }
16
+
17
+ /** The default options of the strategy `resistance`. */
18
+ export const RESISTANCE_DEFAULTS: Readonly<ResistanceOptions> = Object.freeze({
19
+ c0: 0.5,
20
+ clusters: 0,
21
+ kMax: 16,
22
+ minSize: 2,
23
+ spine: "trunk",
24
+ projections: 16,
25
+ weight: 0.5,
26
+ sparsify: false,
27
+ });
28
+
29
+ /** The seed of the random projections. One seed gives the same estimate each time. */
30
+ const SEED = 21;
31
+
32
+ /** A resistance of this value or more marks a bridge. */
33
+ const BRIDGE = 0.99;
34
+
35
+ /**
36
+ * This function solves `L z = y` with the method of conjugate gradients. `L` is the Laplacian of the graph, and `y` has a sum of 0 on each connected part.
37
+ * The result has a mean of 0 on each connected part.
38
+ */
39
+ function solveLaplacian(n: number, adj: readonly (readonly number[])[], y: Float64Array, part: Int32Array, parts: number): Float64Array {
40
+ const mul = (v: Float64Array, out: Float64Array): void => {
41
+ for (let i = 0; i < n; i++) {
42
+ let s = adj[i]!.length * v[i]!;
43
+ for (const j of adj[i]!) s -= v[j]!;
44
+ out[i] = s;
45
+ }
46
+ };
47
+ const centre = (v: Float64Array): void => {
48
+ const sum = new Float64Array(parts);
49
+ const size = new Float64Array(parts);
50
+ for (let i = 0; i < n; i++) {
51
+ sum[part[i]!] = sum[part[i]!]! + v[i]!;
52
+ size[part[i]!] = size[part[i]!]! + 1;
53
+ }
54
+ for (let i = 0; i < n; i++) v[i] = v[i]! - sum[part[i]!]! / size[part[i]!]!;
55
+ };
56
+ const z = new Float64Array(n);
57
+ const r = Float64Array.from(y);
58
+ centre(r);
59
+ const p = Float64Array.from(r);
60
+ const lp = new Float64Array(n);
61
+ let rr = r.reduce((s, v) => s + v * v, 0);
62
+ const stop = 1e-20 * Math.max(1, rr);
63
+ for (let iter = 0; iter < 4 * n + 20 && rr > stop; iter++) {
64
+ mul(p, lp);
65
+ const pl = p.reduce((s, v, i) => s + v * lp[i]!, 0);
66
+ if (pl <= 0) break;
67
+ const a = rr / pl;
68
+ for (let i = 0; i < n; i++) {
69
+ z[i] = z[i]! + a * p[i]!;
70
+ r[i] = r[i]! - a * lp[i]!;
71
+ }
72
+ const next = r.reduce((s, v) => s + v * v, 0);
73
+ const b = next / rr;
74
+ rr = next;
75
+ for (let i = 0; i < n; i++) p[i] = r[i]! + b * p[i]!;
76
+ }
77
+ centre(z);
78
+ return z;
79
+ }
80
+
81
+ /**
82
+ * This function gives an estimate of the effective resistance of each edge of the graph (Spielman and Srivastava 2011).
83
+ * It takes `k` random vectors `q` of the values `±1/√k` on the edges. It solves `L z = Bᵀ q` for each vector. Then `R(u, v) ≈ Σ (z_u - z_v)²`.
84
+ * The value is from 0 to 1. A loop and an edge with an unknown node get 0. Parallel edges share the current, so each gets a lower value.
85
+ */
86
+ export function edgeResistance(graph: Graph, projections: number): Map<string, number> {
87
+ const index = new Map(graph.nodes.map((v, i) => [v.id, i] as const));
88
+ const n = graph.nodes.length;
89
+ const adj: number[][] = graph.nodes.map(() => []);
90
+ const ends: { id: string; u: number; v: number }[] = [];
91
+ for (const e of graph.edges) {
92
+ const u = index.get(e.source.node);
93
+ const v = index.get(e.target.node);
94
+ if (u === undefined || v === undefined || u === v) continue;
95
+ adj[u]!.push(v);
96
+ adj[v]!.push(u);
97
+ ends.push({ id: e.id, u, v });
98
+ }
99
+ // The connected parts of the graph.
100
+ const part = new Int32Array(n).fill(-1);
101
+ let parts = 0;
102
+ for (let i = 0; i < n; i++) {
103
+ if (part[i]! >= 0) continue;
104
+ const stack = [i];
105
+ part[i] = parts;
106
+ while (stack.length > 0) {
107
+ const a = stack.pop()!;
108
+ for (const b of adj[a]!)
109
+ if (part[b]! < 0) {
110
+ part[b] = parts;
111
+ stack.push(b);
112
+ }
113
+ }
114
+ parts++;
115
+ }
116
+ const k = Math.max(1, Math.round(projections));
117
+ const random = rng(SEED);
118
+ const sum = new Float64Array(ends.length);
119
+ for (let r = 0; r < k; r++) {
120
+ const y = new Float64Array(n);
121
+ for (const { u, v } of ends) {
122
+ const q = (random() < 0.5 ? -1 : 1) / Math.sqrt(k);
123
+ y[u] = y[u]! + q;
124
+ y[v] = y[v]! - q;
125
+ }
126
+ const z = solveLaplacian(n, adj, y, part, parts);
127
+ ends.forEach(({ u, v }, i) => {
128
+ sum[i] = sum[i]! + (z[u]! - z[v]!) ** 2;
129
+ });
130
+ }
131
+ const out = new Map<string, number>(graph.edges.map((e) => [e.id, 0] as const));
132
+ ends.forEach(({ id }, i) => out.set(id, Math.max(0, Math.min(1, sum[i]!))));
133
+ return out;
134
+ }
135
+
136
+ /**
137
+ * This function gives the clusters of the edges with the compatibility scaled by the resistance (HWY-021).
138
+ * The compatibility of a pair is `C (1 - weight R_e R_f)`. A pair of two bridges thus gets a lower value. Then the spectral clusters of `spectral` apply.
139
+ */
140
+ export function resistanceClusters(input: HighwayInput, o: Readonly<ResistanceOptions>): Map<string, EdgeGroup> {
141
+ const resistance = edgeResistance(input.graph, o.projections);
142
+ const weight = Math.max(0, Math.min(1, o.weight));
143
+ const groups = new Map<string, EdgeGroup>();
144
+ edgeBlocks(edgeEnds(input)).forEach((block, b) => {
145
+ const r = block.map((e) => resistance.get(e.edge) ?? 0);
146
+ const scale = (i: number, j: number): number => (o.sparsify && (r[i]! >= BRIDGE || r[j]! >= BRIDGE) ? 0 : 1 - weight * r[i]! * r[j]!);
147
+ const labels = spectralLabels(block.length, compatibilityMatrix(block, o.c0, scale), o);
148
+ labelGroups(block, labels, `p${b}:`, groups);
149
+ });
150
+ return groups;
151
+ }
152
+
153
+ /**
154
+ * This function gives the highways of the spectral clusters of the compatibility graph with the weights of the effective resistance (HWY-021).
155
+ * The clusters with `minSize` edges or more get highways.
156
+ */
157
+ export function resistancePlan(input: HighwayInput, o: Readonly<ResistanceOptions>): HighwayPlan {
158
+ return groupedPlan(input, resistanceClusters(input, o), { spine: o.spine, minSize: Math.max(2, Math.round(o.minSize)) });
159
+ }
@@ -0,0 +1,303 @@
1
+ import { boardUnit } from "../../bench/measures/routes.ts";
2
+ import { dist, type Point } from "../../geom.ts";
3
+ import { byCollection } from "../../routers/trunks.ts";
4
+ import { WayFinder, type WayOptions } from "../../ways.ts";
5
+ import { firstRoutes } from "../base.ts";
6
+ import type { EdgeGroup } from "../grouped.ts";
7
+ import type { DensityGrid, DensitySource } from "../kdeeb/index.ts";
8
+ import type { HighwayInput, HighwayPlan } from "../model.ts";
9
+ import { QUICKBUNDLES_DEFAULTS, quickBundles, resample } from "../quickbundles/index.ts";
10
+ import { edgePlugs } from "../stretches.ts";
11
+ import { outOfBoxes, tracePlan } from "../trace.ts";
12
+
13
+ /** The clusterer that gives the groups of edges: QuickBundles (HWY-026) with its defaults, or the collection of each edge. */
14
+ export type SkeletonGroups = "quickbundles" | "keys";
15
+
16
+ /** The values of `SkeletonGroups`. */
17
+ export const SKELETON_GROUPS: readonly SkeletonGroups[] = Object.freeze(["quickbundles", "keys"]);
18
+
19
+ /** The point that each sample point moves toward: the nearest point of the skeleton of its group, or of the mean polyline of its group. */
20
+ export type SkeletonTarget = "skeleton" | "mean";
21
+
22
+ /** The values of `SkeletonTarget`. */
23
+ export const SKELETON_TARGETS: readonly SkeletonTarget[] = Object.freeze(["skeleton", "mean"]);
24
+
25
+ /** The options of the highway strategy `sbeb` (HWY-009 of the notes). */
26
+ export interface SbebOptions extends Pick<WayOptions, "margin" | "k"> {
27
+ /** The clusterer of the groups. The default is `quickbundles`. */
28
+ readonly group: SkeletonGroups;
29
+ /** The first lines of the edges: their straight lines, or their first routes. The default is `lines`. */
30
+ readonly source: DensitySource;
31
+ /** The radius of the shape of a group, as a multiple of `boardUnit`. The default is 1.5. */
32
+ readonly radius: number;
33
+ /** The share of the distance to the target that a point moves in one step, from 0 to 1. The default is 0.5. */
34
+ readonly attraction: number;
35
+ /** The number of steps. The default is 4. */
36
+ readonly steps: number;
37
+ /** The share of the Laplacian smoothing of each polyline after each step, from 0 to 1. The default is 0.5. */
38
+ readonly smoothing: number;
39
+ /** The target of the points. The default is `skeleton`. */
40
+ readonly target: SkeletonTarget;
41
+ /** The side of a cell of the image of the shape, in units of the board. A large shape gets larger cells, up to 256 cells on a side. The default is 4. */
42
+ readonly cell: number;
43
+ /** 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. */
44
+ readonly snap: number;
45
+ /** A group with this number of edges or more moves, and a highway has this number of edges or more. The default is 2. */
46
+ readonly minSize: number;
47
+ }
48
+
49
+ /** The default options of the strategy `sbeb`. */
50
+ export const SBEB_DEFAULTS: Readonly<Required<Omit<SbebOptions, "margin" | "k">>> = Object.freeze({
51
+ group: "quickbundles",
52
+ source: "lines",
53
+ radius: 1.5,
54
+ attraction: 0.5,
55
+ steps: 4,
56
+ smoothing: 0.5,
57
+ target: "skeleton",
58
+ cell: 4,
59
+ snap: 8,
60
+ minSize: 2,
61
+ });
62
+
63
+ /** This function gives the group of each edge: the clusters of QuickBundles, or the collection of the edge. An edge without a collection has no group with `keys`. */
64
+ export function skeletonGroups(input: HighwayInput, group: SkeletonGroups): Map<string, EdgeGroup> {
65
+ if (group === "quickbundles") return quickBundles(input, QUICKBUNDLES_DEFAULTS);
66
+ const out = new Map<string, EdgeGroup>();
67
+ for (const e of input.graph.edges) {
68
+ const key = byCollection(e);
69
+ if (key !== undefined) out.set(e.id, { key, flip: false });
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** This function gives the first line of each edge. The line goes from a point at `margin` out of the source plug to a point at `margin` out of the target plug. */
75
+ export function firstLines(input: HighwayInput, finder: WayFinder, source: DensitySource, spacing: number): Map<string, Point[]> {
76
+ const routes = source === "routes" ? firstRoutes(input) : undefined;
77
+ const out = new Map<string, Point[]>();
78
+ for (const { edge, ps, pt } of edgePlugs(input.graph)) {
79
+ const a = finder.out(ps);
80
+ const b = finder.out(pt);
81
+ const route = routes?.get(edge.id);
82
+ const base = route && route.length > 2 ? [a, ...route.slice(1, -1), b] : [a, b];
83
+ let len = 0;
84
+ for (let i = 1; i < base.length; i++) len += dist(base[i - 1]!, base[i]!);
85
+ out.set(edge.id, resample(base, Math.max(2, Math.min(129, Math.ceil(len / Math.max(1e-3, spacing)) + 1))));
86
+ }
87
+ return out;
88
+ }
89
+
90
+ /** The image of the shape of a group and its skeleton. */
91
+ export interface ShapeSkeleton {
92
+ readonly grid: DensityGrid;
93
+ /** The distance of each cell to the nearest cell outside the shape, in cells. A cell outside the shape has 0. */
94
+ readonly depth: Float64Array;
95
+ /** The value is 1 for each cell of the skeleton. */
96
+ readonly skeleton: Uint8Array;
97
+ }
98
+
99
+ /** This function gives the distance from the point `p` to the segment from `a` to `b`. */
100
+ function segDist(p: Point, a: Point, b: Point): number {
101
+ const dx = b.x - a.x;
102
+ const dy = b.y - a.y;
103
+ const l2 = dx * dx + dy * dy;
104
+ const u = l2 > 0 ? Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / l2)) : 0;
105
+ return Math.hypot(p.x - a.x - u * dx, p.y - a.y - u * dy);
106
+ }
107
+
108
+ /** The steps to the 8 neighbours of a cell, as pairs of opposite steps: along x, along y and along the two diagonals. */
109
+ const PAIRS: readonly (readonly [number, number])[] = [
110
+ [1, 0],
111
+ [0, 1],
112
+ [1, 1],
113
+ [1, -1],
114
+ ];
115
+
116
+ /**
117
+ * This function gives the shape of a group and its skeleton (its medial axis). The shape is the union of the disks of radius `radius` along the lines, minus the boxes.
118
+ * The depth is a chamfer distance transform with the steps 1 and √2. A cell is on the skeleton when two conditions are true.
119
+ * Its depth is half of `radius` or more. Its depth is a ridge along one of the four pairs of opposite neighbours.
120
+ * A ridge is not lower than the two neighbours, and it is higher than one of them.
121
+ */
122
+ export function shapeSkeleton(lines: readonly (readonly Point[])[], finder: WayFinder, radius: number, cell: number): ShapeSkeleton {
123
+ let x0 = Infinity;
124
+ let y0 = Infinity;
125
+ let x1 = -Infinity;
126
+ let y1 = -Infinity;
127
+ for (const l of lines)
128
+ for (const p of l) {
129
+ x0 = Math.min(x0, p.x);
130
+ y0 = Math.min(y0, p.y);
131
+ x1 = Math.max(x1, p.x);
132
+ y1 = Math.max(y1, p.y);
133
+ }
134
+ const pad = radius + 2 * Math.max(1e-3, cell);
135
+ const c = Math.max(1e-3, cell, (x1 - x0 + 2 * pad) / 256, (y1 - y0 + 2 * pad) / 256);
136
+ const grid: DensityGrid = { cell: c, x0: x0 - pad, y0: y0 - pad, w: Math.max(1, Math.ceil((x1 - x0 + 2 * pad) / c)), h: Math.max(1, Math.ceil((y1 - y0 + 2 * pad) / c)) };
137
+ const { w, h } = grid;
138
+ const inside = new Uint8Array(w * h);
139
+ const mid = (x: number, y: number): Point => ({ x: grid.x0 + (x + 0.5) * c, y: grid.y0 + (y + 0.5) * c });
140
+ for (const l of lines)
141
+ for (let i = 1; i < l.length; i++) {
142
+ const a = l[i - 1]!;
143
+ const b = l[i]!;
144
+ const gx0 = Math.max(0, Math.floor((Math.min(a.x, b.x) - radius - grid.x0) / c));
145
+ const gx1 = Math.min(w - 1, Math.floor((Math.max(a.x, b.x) + radius - grid.x0) / c));
146
+ const gy0 = Math.max(0, Math.floor((Math.min(a.y, b.y) - radius - grid.y0) / c));
147
+ const gy1 = Math.min(h - 1, Math.floor((Math.max(a.y, b.y) + radius - grid.y0) / c));
148
+ for (let y = gy0; y <= gy1; y++) for (let x = gx0; x <= gx1; x++) if (!inside[y * w + x] && segDist(mid(x, y), a, b) <= radius) inside[y * w + x] = 1;
149
+ }
150
+ const boxPad = finder.margin / 2;
151
+ for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (inside[y * w + x] && finder.obstacles.inside(mid(x, y), boxPad)) inside[y * w + x] = 0;
152
+ // The chamfer distance transform: one pass forward and one pass backward.
153
+ const depth = new Float64Array(w * h);
154
+ for (let i = 0; i < w * h; i++) depth[i] = inside[i] ? Infinity : 0;
155
+ const at = (x: number, y: number): number => (x < 0 || y < 0 || x >= w || y >= h ? 0 : depth[y * w + x]!);
156
+ const D = Math.SQRT2;
157
+ for (let y = 0; y < h; y++)
158
+ for (let x = 0; x < w; x++) {
159
+ const i = y * w + x;
160
+ if (!inside[i]) continue;
161
+ depth[i] = Math.min(depth[i]!, at(x - 1, y) + 1, at(x, y - 1) + 1, at(x - 1, y - 1) + D, at(x + 1, y - 1) + D);
162
+ }
163
+ for (let y = h - 1; y >= 0; y--)
164
+ for (let x = w - 1; x >= 0; x--) {
165
+ const i = y * w + x;
166
+ if (!inside[i]) continue;
167
+ depth[i] = Math.min(depth[i]!, at(x + 1, y) + 1, at(x, y + 1) + 1, at(x + 1, y + 1) + D, at(x - 1, y + 1) + D);
168
+ }
169
+ const skeleton = new Uint8Array(w * h);
170
+ const least = Math.max(1, (0.5 * radius) / c);
171
+ for (let y = 0; y < h; y++)
172
+ for (let x = 0; x < w; x++) {
173
+ const v = depth[y * w + x]!;
174
+ if (v < least) continue;
175
+ for (const [dx, dy] of PAIRS) {
176
+ const p = at(x + dx, y + dy);
177
+ const q = at(x - dx, y - dy);
178
+ if (v >= p && v >= q && (v > p || v > q)) {
179
+ skeleton[y * w + x] = 1;
180
+ break;
181
+ }
182
+ }
183
+ }
184
+ return { grid, depth, skeleton };
185
+ }
186
+
187
+ /** This function gives the middle of the skeleton cell nearest to `p`, within `reach` in units of the board, or `undefined`. */
188
+ export function nearestSkeleton(s: ShapeSkeleton, p: Point, reach: number): Point | undefined {
189
+ const { grid: g, skeleton } = s;
190
+ const cx = Math.floor((p.x - g.x0) / g.cell);
191
+ const cy = Math.floor((p.y - g.y0) / g.cell);
192
+ const r = Math.ceil(reach / g.cell);
193
+ let best: Point | undefined;
194
+ let bestD = Infinity;
195
+ for (let y = Math.max(0, cy - r); y <= Math.min(g.h - 1, cy + r); y++)
196
+ for (let x = Math.max(0, cx - r); x <= Math.min(g.w - 1, cx + r); x++) {
197
+ if (!skeleton[y * g.w + x]) continue;
198
+ const q = { x: g.x0 + (x + 0.5) * g.cell, y: g.y0 + (y + 0.5) * g.cell };
199
+ const d = dist(p, q);
200
+ if (d < bestD && d <= reach) [best, bestD] = [q, d];
201
+ }
202
+ return best;
203
+ }
204
+
205
+ /** This function gives the mean polyline of lines in one direction, with `k` points. */
206
+ export function meanLine(lines: readonly (readonly Point[])[], k: number): Point[] {
207
+ const n = Math.max(2, Math.round(k));
208
+ const sum = Array.from({ length: n }, () => ({ x: 0, y: 0 }));
209
+ for (const l of lines)
210
+ resample(l, n).forEach((p, i) => {
211
+ sum[i]!.x += p.x;
212
+ sum[i]!.y += p.y;
213
+ });
214
+ return sum.map((p) => ({ x: p.x / Math.max(1, lines.length), y: p.y / Math.max(1, lines.length) }));
215
+ }
216
+
217
+ /** This function gives the point of a polyline nearest to `p`. */
218
+ export function nearestOnLine(line: readonly Point[], p: Point): Point {
219
+ let best = line[0]!;
220
+ let bestD = dist(p, best);
221
+ for (let i = 1; i < line.length; i++) {
222
+ const a = line[i - 1]!;
223
+ const b = line[i]!;
224
+ const dx = b.x - a.x;
225
+ const dy = b.y - a.y;
226
+ const l2 = dx * dx + dy * dy;
227
+ const u = l2 > 0 ? Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / l2)) : 0;
228
+ const q = { x: a.x + u * dx, y: a.y + u * dy };
229
+ const d = dist(p, q);
230
+ if (d < bestD) [best, bestD] = [q, d];
231
+ }
232
+ return best;
233
+ }
234
+
235
+ /** This function gives the groups with `min` edges or more, as lists of edges. The groups are in the order of their first edges. */
236
+ export function bigGroups(groups: ReadonlyMap<string, EdgeGroup>, min: number): { key: string; edges: { edge: string; flip: boolean }[] }[] {
237
+ const byKey = new Map<string, { edge: string; flip: boolean }[]>();
238
+ for (const [edge, g] of groups) {
239
+ const list = byKey.get(g.key);
240
+ if (list) list.push({ edge, flip: g.flip });
241
+ else byKey.set(g.key, [{ edge, flip: g.flip }]);
242
+ }
243
+ return [...byKey]
244
+ .filter(([, list]) => list.length >= Math.max(2, min))
245
+ .map(([key, edges]) => ({ key, edges }));
246
+ }
247
+
248
+ /**
249
+ * This function gives the polylines of the skeleton-based edge bundling (SBEB, Ersoy, Hurter, Paulovich, Cantareiro and Telea 2011). The ends of each polyline stay.
250
+ * In each step, each group of `minSize` edges or more draws its shape, and each inner point moves the share `attraction` toward its target.
251
+ * The share falls to 0 at the distance `2 radius`. A Laplacian smoothing keeps each polyline smooth, and a point in a box moves out of it.
252
+ * The edges outside such a group do not move.
253
+ */
254
+ export function sbebLines(input: HighwayInput, finder: WayFinder, o: Readonly<SbebOptions>): Map<string, Point[]> {
255
+ const radius = Math.max(1e-3, o.radius * boardUnit(input.graph));
256
+ const lines = firstLines(input, finder, o.source, Math.max(4, 2 * o.cell));
257
+ const groups = bigGroups(skeletonGroups(input, o.group), o.minSize).map((g) => ({ ...g, edges: g.edges.filter((m) => lines.has(m.edge)) }));
258
+ const pad = finder.margin / 2;
259
+ const a = Math.max(0, Math.min(1, o.attraction));
260
+ const smooth = Math.max(0, Math.min(1, o.smoothing));
261
+ for (let step = 0; step < Math.max(0, Math.round(o.steps)); step++) {
262
+ for (const g of groups) {
263
+ if (g.edges.length < 2) continue;
264
+ const own = g.edges.map((m) => lines.get(m.edge)!);
265
+ const target: (p: Point) => Point | undefined =
266
+ o.target === "mean"
267
+ ? ((mean) => (p: Point) => nearestOnLine(mean, p))(meanLine(g.edges.map((m, i) => (m.flip ? [...own[i]!].reverse() : own[i]!)), 33))
268
+ : ((s) => (p: Point) => nearestSkeleton(s, p, 2 * radius))(shapeSkeleton(own, finder, radius, o.cell));
269
+ g.edges.forEach((m, i) => {
270
+ const l = own[i]!;
271
+ const moved = l.map((p, j) => {
272
+ if (j === 0 || j === l.length - 1) return p;
273
+ const s = target(p);
274
+ if (!s) return p;
275
+ const k = a * Math.max(0, 1 - dist(p, s) / (2 * radius));
276
+ return { x: p.x + k * (s.x - p.x), y: p.y + k * (s.y - p.y) };
277
+ });
278
+ const smoothed = moved.map((p, j) => {
279
+ if (j === 0 || j === moved.length - 1 || smooth === 0) return p;
280
+ const u = moved[j - 1]!;
281
+ const v = moved[j + 1]!;
282
+ return { x: (1 - smooth) * p.x + (smooth * (u.x + v.x)) / 2, y: (1 - smooth) * p.y + (smooth * (u.y + v.y)) / 2 };
283
+ });
284
+ lines.set(
285
+ m.edge,
286
+ smoothed.map((p, j) => (j === 0 || j === smoothed.length - 1 ? p : outOfBoxes(finder.obstacles, p, pad))),
287
+ );
288
+ });
289
+ }
290
+ }
291
+ return lines;
292
+ }
293
+
294
+ /**
295
+ * This function gives the highways of the skeleton-based edge bundling (HWY-009). The points of `sbebLines` snap to a grid of side `snap`.
296
+ * The ways go through the corner graph, and each stretch that `minSize` ways share is a highway.
297
+ */
298
+ export function sbebPlan(input: HighwayInput, o: Readonly<SbebOptions>): HighwayPlan {
299
+ const finder = new WayFinder(input.graph.nodes, { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) });
300
+ const lines = sbebLines(input, finder, o);
301
+ const waypoints = new Map([...lines].map(([edge, l]) => [edge, l.slice(1, -1)] as const));
302
+ return tracePlan(input, finder, waypoints, { snap: o.snap, minSize: o.minSize, key: "sbeb" });
303
+ }