@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.
- package/README.md +59 -2
- package/build/index.d.ts +2333 -193
- package/build/index.d.ts.map +1 -1
- package/build/index.js +13042 -2334
- package/build/index.js.map +1 -1
- package/package.json +12 -1
- package/src/bench/exact/index.ts +258 -0
- package/src/bench/exact/optima-exact-v1-bounds.json +14409 -0
- package/src/bench/exact/optima-exact-v1.json +4809 -0
- package/src/bench/exact/solve.ts +129 -0
- package/src/bench/measures/index.ts +1 -0
- package/src/bench/measures/lanes.ts +304 -0
- package/src/bench/measures/levels.ts +86 -2
- package/src/bench/screen/activation.ts +3 -0
- package/src/bench/screen/bounds.ts +140 -0
- package/src/bench/screen/compare.ts +26 -0
- package/src/bench/screen/exact-set.ts +100 -0
- package/src/bench/screen/exercise.ts +290 -0
- package/src/bench/screen/forms.ts +25 -5
- package/src/bench/screen/gap.ts +316 -0
- package/src/bench/screen/hash.ts +7 -76
- package/src/bench/screen/index.ts +6 -0
- package/src/bench/screen/measures.ts +89 -1
- package/src/bench/screen/portfolio-pick.ts +20 -0
- package/src/bench/screen/screen.ts +78 -2
- package/src/bench/screen/set.ts +3 -2
- package/src/channels.ts +56 -0
- package/src/highways/areas/index.ts +160 -0
- package/src/highways/confluent/index.ts +154 -0
- package/src/highways/confluent/k23.test-util.ts +8 -0
- package/src/highways/consensus/index.ts +129 -0
- package/src/highways/crossbars/index.ts +184 -0
- package/src/highways/cubu/index.ts +26 -0
- package/src/highways/diffusion/index.ts +103 -0
- package/src/highways/divided/index.ts +111 -0
- package/src/highways/edge-paths/index.ts +155 -0
- package/src/highways/fdeb/index.ts +193 -0
- package/src/highways/fixtures.test-util.ts +71 -0
- package/src/highways/flow-clusters/index.ts +143 -0
- package/src/highways/frechet/index.ts +232 -0
- package/src/highways/growth/index.ts +148 -0
- package/src/highways/hdbscan/index.ts +250 -0
- package/src/highways/hierarchy/index.ts +140 -0
- package/src/highways/hough/index.ts +182 -0
- package/src/highways/hub-arcs/index.ts +181 -0
- package/src/highways/index.ts +79 -0
- package/src/highways/kdeeb/index.ts +355 -0
- package/src/highways/lanes/index.ts +189 -0
- package/src/highways/link-communities/index.ts +189 -0
- package/src/highways/mesh/index.ts +209 -0
- package/src/highways/mingle/index.ts +171 -0
- package/src/highways/mlseb/index.ts +204 -0
- package/src/highways/nested/index.ts +143 -0
- package/src/highways/p-hub/index.ts +248 -0
- package/src/highways/power/index.ts +117 -0
- package/src/highways/resistance/index.ts +159 -0
- package/src/highways/ridges/index.ts +328 -0
- package/src/highways/sbeb/index.ts +303 -0
- package/src/highways/scale-space/index.ts +135 -0
- package/src/highways/snap.ts +110 -0
- package/src/highways/soft-keys/index.ts +114 -0
- package/src/highways/spectral/index.ts +391 -0
- package/src/highways/spiral/index.ts +205 -0
- package/src/highways/territories/index.ts +150 -0
- package/src/highways/trace.ts +153 -0
- package/src/highways/traclus/index.ts +296 -0
- package/src/index.ts +2 -0
- package/src/portfolio/bands.ts +33 -0
- package/src/portfolio/fit.ts +29 -0
- package/src/portfolio/index.ts +6 -0
- package/src/portfolio/key.ts +98 -0
- package/src/portfolio/members.ts +171 -0
- package/src/portfolio/router.ts +133 -0
- package/src/portfolio/sets.ts +28 -0
- package/src/routers/shared.ts +56 -3
- package/src/strategies/builtins.ts +376 -1
- package/src/strategies/schemas.ts +460 -1
- package/src/visibility.ts +5 -0
|
@@ -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,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
|
+
}
|