@mark1russell7/highways 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -2
- package/build/index.d.ts +799 -68
- package/build/index.d.ts.map +1 -1
- package/build/index.js +6235 -2128
- package/build/index.js.map +1 -1
- package/package.json +3 -1
- package/src/bench/exact/index.ts +197 -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/forms.ts +11 -5
- package/src/bench/screen/measures.ts +18 -0
- package/src/bench/screen/screen.ts +28 -0
- package/src/channels.ts +56 -0
- package/src/highways/diffusion/index.ts +103 -0
- package/src/highways/edge-paths/index.ts +155 -0
- package/src/highways/fixtures.test-util.ts +71 -0
- package/src/highways/flow-clusters/index.ts +143 -0
- package/src/highways/growth/index.ts +148 -0
- package/src/highways/hdbscan/index.ts +250 -0
- package/src/highways/hough/index.ts +182 -0
- package/src/highways/hub-arcs/index.ts +181 -0
- package/src/highways/index.ts +15 -0
- package/src/highways/lanes/index.ts +189 -0
- package/src/highways/mingle/index.ts +171 -0
- package/src/highways/nested/index.ts +143 -0
- package/src/highways/ridges/index.ts +328 -0
- package/src/highways/snap.ts +110 -0
- package/src/highways/soft-keys/index.ts +114 -0
- package/src/highways/spiral/index.ts +205 -0
- package/src/highways/traclus/index.ts +296 -0
- package/src/index.ts +1 -0
- package/src/routers/shared.ts +56 -3
- package/src/strategies/builtins.ts +155 -1
- package/src/strategies/schemas.ts +180 -0
- package/src/visibility.ts +5 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { boardUnit } from "../../bench/measures/routes.ts";
|
|
2
|
+
import { dist, polylineLength, type Point } from "../../geom.ts";
|
|
3
|
+
import { orderLanes } from "../../segments.ts";
|
|
4
|
+
import { WayFinder } from "../../ways.ts";
|
|
5
|
+
import { firstRoutes, nearestPlace, tidyPlan } from "../base.ts";
|
|
6
|
+
import { lengthBetween, memberAt, pointAt, type Highway, type HighwayInput, type HighwayMember, type HighwayPlan } from "../model.ts";
|
|
7
|
+
import { edgePlugs } from "../stretches.ts";
|
|
8
|
+
|
|
9
|
+
/** The order of the edges in the growth: the longest first route first, or the order of the graph. */
|
|
10
|
+
export type GrowthOrder = "length" | "input";
|
|
11
|
+
|
|
12
|
+
/** The options of the highway strategy `growth` (HWY-055 of the note on highways). */
|
|
13
|
+
export interface GrowthOptions {
|
|
14
|
+
/** The distance from a box to the corners of the graph. The default is 5. */
|
|
15
|
+
readonly margin?: number;
|
|
16
|
+
/** The number of visible neighbours of each corner. The default is 8. */
|
|
17
|
+
readonly k?: number;
|
|
18
|
+
/** The order of the edges. The default is `length`. */
|
|
19
|
+
readonly order: GrowthOrder;
|
|
20
|
+
/** The cost of the part on a spine, as a share of its length. The ramps cost their full length. The default is 0.5. */
|
|
21
|
+
readonly share: number;
|
|
22
|
+
/** An edge joins a highway only when its way along it is this value times its first route or shorter. The default is 1.3. */
|
|
23
|
+
readonly stretch: number;
|
|
24
|
+
/** A spine end extends when this number of its lanes or more go on together. The default is 3. */
|
|
25
|
+
readonly extendLanes: number;
|
|
26
|
+
/** The lanes go on together when their ends are this distance from their center or nearer, in units of the board. The default is 2. */
|
|
27
|
+
readonly extendRadius: number;
|
|
28
|
+
/** A highway with this number of edges or more stays in the plan. The default is 2. */
|
|
29
|
+
readonly minSize: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The default options of the strategy `growth`. */
|
|
33
|
+
export const GROWTH_DEFAULTS: Readonly<GrowthOptions> = Object.freeze({ order: "length", share: 0.5, stretch: 1.3, extendLanes: 3, extendRadius: 2, minSize: 2 });
|
|
34
|
+
|
|
35
|
+
interface Grown {
|
|
36
|
+
spine: Point[];
|
|
37
|
+
members: { edge: string; on: number; off: number }[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Rider {
|
|
41
|
+
readonly edge: string;
|
|
42
|
+
readonly ps: Point;
|
|
43
|
+
readonly pt: Point;
|
|
44
|
+
readonly a: Point;
|
|
45
|
+
readonly b: Point;
|
|
46
|
+
readonly len: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* This function gives the highways of local growth (HWY-055). The edges come one by one, by default the longest first route first.
|
|
51
|
+
* Each edge joins the highway with the largest saving: its first route less its ramps and `share` of its part on the spine. Without a saving, the edge starts a highway on its own way.
|
|
52
|
+
* After the growth, a spine end can go on to the center of the far ends of its lanes.
|
|
53
|
+
* This occurs when `extendLanes` of them or more are near that center and the line is clear.
|
|
54
|
+
*/
|
|
55
|
+
export function growthPlan(input: HighwayInput, o: Readonly<GrowthOptions>): HighwayPlan {
|
|
56
|
+
const graph = input.graph;
|
|
57
|
+
const finder = new WayFinder(graph.nodes, { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) });
|
|
58
|
+
const pad = finder.margin / 2;
|
|
59
|
+
const routes = firstRoutes(input);
|
|
60
|
+
const riders: Rider[] = edgePlugs(graph).map(({ edge, ps, pt }) => {
|
|
61
|
+
const route = routes.get(edge.id);
|
|
62
|
+
const a = finder.out(ps);
|
|
63
|
+
const b = finder.out(pt);
|
|
64
|
+
return { edge: edge.id, ps: ps.point, pt: pt.point, a, b, len: route ? polylineLength(route) : dist(ps.point, pt.point) };
|
|
65
|
+
});
|
|
66
|
+
if (o.order === "length") riders.sort((p, q) => q.len - p.len || (p.edge < q.edge ? -1 : p.edge > q.edge ? 1 : 0));
|
|
67
|
+
const grown: Grown[] = [];
|
|
68
|
+
const short = finder.margin;
|
|
69
|
+
for (const r of riders) {
|
|
70
|
+
let best = -1;
|
|
71
|
+
let bestSave = 0;
|
|
72
|
+
let bestOn = 0;
|
|
73
|
+
let bestOff = 0;
|
|
74
|
+
grown.forEach((h, hi) => {
|
|
75
|
+
const on = nearestPlace(h.spine, r.a);
|
|
76
|
+
const off = nearestPlace(h.spine, r.b);
|
|
77
|
+
const part = lengthBetween(h.spine, Math.min(on, off), Math.max(on, off));
|
|
78
|
+
if (part <= short) return;
|
|
79
|
+
const ramps = dist(r.a, pointAt(h.spine, on)) + dist(pointAt(h.spine, off), r.b);
|
|
80
|
+
if (ramps + part > o.stretch * r.len) return;
|
|
81
|
+
const save = r.len - (ramps + o.share * part);
|
|
82
|
+
if (save > bestSave) [best, bestSave, bestOn, bestOff] = [hi, save, on, off];
|
|
83
|
+
});
|
|
84
|
+
if (best >= 0) {
|
|
85
|
+
grown[best]!.members.push({ edge: r.edge, on: bestOn, off: bestOff });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const raw = finder.corners.path(r.a, r.b);
|
|
89
|
+
if (!raw) continue;
|
|
90
|
+
const spine = finder.corners.pull(raw);
|
|
91
|
+
if (spine.length < 2 || polylineLength(spine) <= short) continue;
|
|
92
|
+
grown.push({ spine, members: [{ edge: r.edge, on: 0, off: spine.length - 1 }] });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A spine end goes on while enough of its lanes go on together.
|
|
96
|
+
const byEdge = new Map(riders.map((r) => [r.edge, r]));
|
|
97
|
+
const radius = o.extendRadius * boardUnit(graph);
|
|
98
|
+
const need = Math.max(2, Math.round(o.extendLanes));
|
|
99
|
+
for (const h of grown) {
|
|
100
|
+
for (const end of ["first", "last"] as const) {
|
|
101
|
+
const last = h.spine.length - 1;
|
|
102
|
+
const at = end === "first" ? 0 : last;
|
|
103
|
+
// The far point of each lane at this end: the point out of the plug that the lane goes to or comes from.
|
|
104
|
+
const far: { m: Grown["members"][number]; side: "on" | "off"; p: Point }[] = [];
|
|
105
|
+
for (const m of h.members) {
|
|
106
|
+
const r = byEdge.get(m.edge)!;
|
|
107
|
+
if (m.on === at) far.push({ m, side: "on", p: r.a });
|
|
108
|
+
else if (m.off === at) far.push({ m, side: "off", p: r.b });
|
|
109
|
+
}
|
|
110
|
+
if (far.length < need) continue;
|
|
111
|
+
const c = { x: far.reduce((s, f) => s + f.p.x, 0) / far.length, y: far.reduce((s, f) => s + f.p.y, 0) / far.length };
|
|
112
|
+
const near = far.filter((f) => dist(f.p, c) <= radius);
|
|
113
|
+
const tip = h.spine[at]!;
|
|
114
|
+
if (near.length < need || dist(tip, c) <= short) continue;
|
|
115
|
+
if (finder.obstacles.inside(c, pad) || !finder.obstacles.clear(tip, c, pad)) continue;
|
|
116
|
+
if (end === "first") {
|
|
117
|
+
h.spine.unshift(c);
|
|
118
|
+
for (const m of h.members) [m.on, m.off] = [m.on + 1, m.off + 1];
|
|
119
|
+
for (const f of near) f.m[f.side] = 0;
|
|
120
|
+
} else {
|
|
121
|
+
h.spine.push(c);
|
|
122
|
+
for (const f of near) f.m[f.side] = last + 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const highways: Highway[] = [];
|
|
128
|
+
grown.forEach((h, hi) => {
|
|
129
|
+
if (h.members.length < Math.max(2, o.minSize)) return;
|
|
130
|
+
const spine = h.spine;
|
|
131
|
+
const back = [...spine].reverse();
|
|
132
|
+
const lanes = orderLanes(
|
|
133
|
+
spine,
|
|
134
|
+
h.members.map((m) => {
|
|
135
|
+
const r = byEdge.get(m.edge)!;
|
|
136
|
+
return { edge: m.edge, points: m.on <= m.off ? [r.ps, r.a, ...spine, r.b, r.pt] : [r.ps, r.a, ...back, r.b, r.pt] };
|
|
137
|
+
}),
|
|
138
|
+
"bundle",
|
|
139
|
+
);
|
|
140
|
+
const byLane = new Map(h.members.map((m) => [m.edge, m]));
|
|
141
|
+
const members: HighwayMember[] = lanes.map((edge) => {
|
|
142
|
+
const m = byLane.get(edge)!;
|
|
143
|
+
return memberAt(spine, edge, m.on, m.off);
|
|
144
|
+
});
|
|
145
|
+
highways.push({ id: `h${hi}`, key: `g${hi}`, spine, lanes, members });
|
|
146
|
+
});
|
|
147
|
+
return tidyPlan({ highways });
|
|
148
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { dist, type Point } from "../../geom.ts";
|
|
2
|
+
import { flowDistance } from "../flow-clusters/index.ts";
|
|
3
|
+
import { groupedPlan, type EdgeGroup, type GroupSpine } from "../grouped.ts";
|
|
4
|
+
import type { HighwayInput, HighwayPlan } from "../model.ts";
|
|
5
|
+
import { edgePlugs } from "../stretches.ts";
|
|
6
|
+
|
|
7
|
+
/** The distance of two edges: 1 less their compatibility of FDEB, or the flow distance in 4D of HWY-023. */
|
|
8
|
+
export type EdgeDistance = "compatibility" | "flow";
|
|
9
|
+
|
|
10
|
+
/** The values of `EdgeDistance`. */
|
|
11
|
+
export const EDGE_DISTANCES: readonly EdgeDistance[] = Object.freeze(["compatibility", "flow"]);
|
|
12
|
+
|
|
13
|
+
/** The options of the highway strategy `hdbscan` (HWY-028 of the note on highways). */
|
|
14
|
+
export interface HdbscanOptions {
|
|
15
|
+
/** A cluster has this number of edges or more. The default is 3. */
|
|
16
|
+
readonly minClusterSize: number;
|
|
17
|
+
/** The core distance of an edge is the distance to its neighbour of this rank. The default is 3. */
|
|
18
|
+
readonly minSamples: number;
|
|
19
|
+
/** The distance of two edges. The default is `compatibility`. */
|
|
20
|
+
readonly distance: EdgeDistance;
|
|
21
|
+
/** The spine of each cluster. The default is `trunk`: one trunk from one area to the other area. */
|
|
22
|
+
readonly spine: GroupSpine;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The default options of the strategy `hdbscan`. */
|
|
26
|
+
export const HDBSCAN_DEFAULTS: Readonly<HdbscanOptions> = Object.freeze({ minClusterSize: 3, minSamples: 3, distance: "compatibility", spine: "trunk" });
|
|
27
|
+
|
|
28
|
+
/** The largest number of edges in one distance matrix. A larger graph splits into blocks of edges in the order of their sources. */
|
|
29
|
+
const BLOCK = 2500;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* This function gives the compatibility of two edges of FDEB (Holten and van Wijk 2009): `Ca Cs Cp Cv`, from 0 to 1.
|
|
33
|
+
* `Ca` is the angle, `Cs` the scale, `Cp` the position and `Cv` the visibility. The direction of an edge does not change the value.
|
|
34
|
+
*/
|
|
35
|
+
export function compatibility(p0: Point, p1: Point, q0: Point, q1: Point): number {
|
|
36
|
+
const lp = dist(p0, p1);
|
|
37
|
+
const lq = dist(q0, q1);
|
|
38
|
+
if (lp <= 1e-9 || lq <= 1e-9) return 0;
|
|
39
|
+
const ca = Math.abs(((p1.x - p0.x) * (q1.x - q0.x) + (p1.y - p0.y) * (q1.y - q0.y)) / (lp * lq));
|
|
40
|
+
const avg = (lp + lq) / 2;
|
|
41
|
+
const cs = 2 / (avg / Math.min(lp, lq) + Math.max(lp, lq) / avg);
|
|
42
|
+
const pm = { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 };
|
|
43
|
+
const qm = { x: (q0.x + q1.x) / 2, y: (q0.y + q1.y) / 2 };
|
|
44
|
+
const cp = avg / (avg + dist(pm, qm));
|
|
45
|
+
const cv = Math.min(visibility(p0, p1, q0, q1), visibility(q0, q1, p0, p1));
|
|
46
|
+
return ca * cs * cp * cv;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** This function gives the visibility of the edge `q` from the edge `p` of FDEB: `max(1 - 2 |pm - im| / |i0 i1|, 0)`. The points `i` are the projection of `q` on the line of `p`. */
|
|
50
|
+
function visibility(p0: Point, p1: Point, q0: Point, q1: Point): number {
|
|
51
|
+
const dx = p1.x - p0.x;
|
|
52
|
+
const dy = p1.y - p0.y;
|
|
53
|
+
const l2 = dx * dx + dy * dy;
|
|
54
|
+
const project = (q: Point): Point => {
|
|
55
|
+
const u = ((q.x - p0.x) * dx + (q.y - p0.y) * dy) / l2;
|
|
56
|
+
return { x: p0.x + u * dx, y: p0.y + u * dy };
|
|
57
|
+
};
|
|
58
|
+
const i0 = project(q0);
|
|
59
|
+
const i1 = project(q1);
|
|
60
|
+
const span = dist(i0, i1);
|
|
61
|
+
if (span <= 1e-9) return 0;
|
|
62
|
+
const im = { x: (i0.x + i1.x) / 2, y: (i0.y + i1.y) / 2 };
|
|
63
|
+
const pm = { x: (p0.x + p1.x) / 2, y: (p0.y + p1.y) / 2 };
|
|
64
|
+
return Math.max(1 - (2 * dist(pm, im)) / span, 0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* This function gives the HDBSCAN clusters of the points of a distance matrix (Campello, Moulavi and Sander 2013). The value -1 is noise.
|
|
69
|
+
* The core distance of a point is the distance to its `minSamples`-th nearest point. The mutual reachability is `max(core(a), core(b), d(a, b))`.
|
|
70
|
+
* The minimum spanning tree of the mutual reachability gives the tree of the clusters. A split with two parts of `minClusterSize` or more gives two clusters.
|
|
71
|
+
* The selection keeps the clusters with the largest stability, `sum (lambda_leave - lambda_birth)`, with `lambda = 1 / distance`. The root is not a cluster.
|
|
72
|
+
*/
|
|
73
|
+
export function hdbscanLabels(n: number, d: Float64Array, minClusterSize: number, minSamples: number): Int32Array {
|
|
74
|
+
const labels = new Int32Array(n).fill(-1);
|
|
75
|
+
const mcs = Math.max(2, Math.round(minClusterSize));
|
|
76
|
+
if (n < mcs || n < 2) return labels;
|
|
77
|
+
const k = Math.max(1, Math.min(n - 1, Math.round(minSamples)));
|
|
78
|
+
// The core distance of each point.
|
|
79
|
+
const core = new Float64Array(n);
|
|
80
|
+
const row = new Float64Array(n - 1);
|
|
81
|
+
for (let i = 0; i < n; i++) {
|
|
82
|
+
let c = 0;
|
|
83
|
+
for (let j = 0; j < n; j++) if (j !== i) row[c++] = d[i * n + j]!;
|
|
84
|
+
row.sort();
|
|
85
|
+
core[i] = row[k - 1]!;
|
|
86
|
+
}
|
|
87
|
+
const reach = (i: number, j: number): number => Math.max(core[i]!, core[j]!, d[i * n + j]!);
|
|
88
|
+
// The minimum spanning tree (Prim), with ties to the lower index.
|
|
89
|
+
const inTree = new Uint8Array(n);
|
|
90
|
+
const best = new Float64Array(n).fill(Infinity);
|
|
91
|
+
const from = new Int32Array(n).fill(-1);
|
|
92
|
+
const links: { a: number; b: number; w: number }[] = [];
|
|
93
|
+
let cur = 0;
|
|
94
|
+
inTree[0] = 1;
|
|
95
|
+
for (let step = 1; step < n; step++) {
|
|
96
|
+
let next = -1;
|
|
97
|
+
for (let j = 0; j < n; j++) {
|
|
98
|
+
if (inTree[j]) continue;
|
|
99
|
+
const w = reach(cur, j);
|
|
100
|
+
if (w < best[j]!) [best[j], from[j]] = [w, cur];
|
|
101
|
+
if (next < 0 || best[j]! < best[next]!) next = j;
|
|
102
|
+
}
|
|
103
|
+
inTree[next] = 1;
|
|
104
|
+
links.push({ a: from[next]!, b: next, w: best[next]! });
|
|
105
|
+
cur = next;
|
|
106
|
+
}
|
|
107
|
+
links.sort((p, q) => p.w - q.w || p.a - q.a || p.b - q.b);
|
|
108
|
+
// The single linkage tree: the node `n + i` is the merge `i`.
|
|
109
|
+
const parent = new Int32Array(2 * n - 1).fill(-1);
|
|
110
|
+
const left = new Int32Array(n - 1);
|
|
111
|
+
const right = new Int32Array(n - 1);
|
|
112
|
+
const height = new Float64Array(n - 1);
|
|
113
|
+
const size = new Int32Array(2 * n - 1).fill(1);
|
|
114
|
+
const uf = new Int32Array(2 * n - 1);
|
|
115
|
+
for (let i = 0; i < uf.length; i++) uf[i] = i;
|
|
116
|
+
const find = (i: number): number => {
|
|
117
|
+
while (uf[i] !== i) i = uf[i] = uf[uf[i]!]!;
|
|
118
|
+
return i;
|
|
119
|
+
};
|
|
120
|
+
links.forEach((l, i) => {
|
|
121
|
+
const ra = find(l.a);
|
|
122
|
+
const rb = find(l.b);
|
|
123
|
+
const node = n + i;
|
|
124
|
+
left[i] = ra;
|
|
125
|
+
right[i] = rb;
|
|
126
|
+
height[i] = l.w;
|
|
127
|
+
size[node] = size[ra]! + size[rb]!;
|
|
128
|
+
parent[ra] = node;
|
|
129
|
+
parent[rb] = node;
|
|
130
|
+
uf[ra] = node;
|
|
131
|
+
uf[rb] = node;
|
|
132
|
+
});
|
|
133
|
+
const root = 2 * n - 2;
|
|
134
|
+
const lambdaOf = (h: number): number => 1 / Math.max(h, 1e-9);
|
|
135
|
+
const leaves = (node: number): number[] => {
|
|
136
|
+
const out: number[] = [];
|
|
137
|
+
const stack = [node];
|
|
138
|
+
while (stack.length > 0) {
|
|
139
|
+
const x = stack.pop()!;
|
|
140
|
+
if (x < n) out.push(x);
|
|
141
|
+
else stack.push(left[x - n]!, right[x - n]!);
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
};
|
|
145
|
+
// The condensed tree. Each cluster has a birth lambda, a parent cluster, its stability and the points that leave it at their lambda.
|
|
146
|
+
const birth: number[] = [0];
|
|
147
|
+
const up: number[] = [-1];
|
|
148
|
+
const stability: number[] = [0];
|
|
149
|
+
const fall = new Int32Array(n).fill(0);
|
|
150
|
+
const queue: [node: number, cluster: number][] = [[root, 0]];
|
|
151
|
+
while (queue.length > 0) {
|
|
152
|
+
const [node, c] = queue.shift()!;
|
|
153
|
+
const i = node - n;
|
|
154
|
+
const lambda = lambdaOf(height[i]!);
|
|
155
|
+
const kids = [left[i]!, right[i]!];
|
|
156
|
+
const big = kids.filter((x) => size[x]! >= mcs);
|
|
157
|
+
if (big.length === 2) {
|
|
158
|
+
for (const x of kids) {
|
|
159
|
+
const id = birth.length;
|
|
160
|
+
birth.push(lambda);
|
|
161
|
+
up.push(c);
|
|
162
|
+
stability.push(0);
|
|
163
|
+
stability[c] = stability[c]! + size[x]! * (lambda - birth[c]!);
|
|
164
|
+
queue.push([x, id]);
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
for (const x of kids) {
|
|
169
|
+
if (big.includes(x)) {
|
|
170
|
+
if (x >= n) queue.push([x, c]);
|
|
171
|
+
else {
|
|
172
|
+
fall[x] = c;
|
|
173
|
+
stability[c] = stability[c]! + (lambda - birth[c]!);
|
|
174
|
+
}
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
for (const p of leaves(x)) {
|
|
178
|
+
fall[p] = c;
|
|
179
|
+
stability[c] = stability[c]! + (lambda - birth[c]!);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// The selection by the excess of mass, from the last cluster to the first. A child cluster always has a larger id than its parent.
|
|
184
|
+
const count = birth.length;
|
|
185
|
+
const chosen = new Uint8Array(count);
|
|
186
|
+
const value = [...stability];
|
|
187
|
+
const children: number[][] = Array.from({ length: count }, () => []);
|
|
188
|
+
for (let c = 1; c < count; c++) children[up[c]!]!.push(c);
|
|
189
|
+
for (let c = count - 1; c >= 1; c--) {
|
|
190
|
+
const sub = children[c]!.reduce((s, x) => s + value[x]!, 0);
|
|
191
|
+
if (children[c]!.length > 0 && sub > value[c]!) value[c] = sub;
|
|
192
|
+
else {
|
|
193
|
+
chosen[c] = 1;
|
|
194
|
+
const stack = [...children[c]!];
|
|
195
|
+
while (stack.length > 0) {
|
|
196
|
+
const x = stack.pop()!;
|
|
197
|
+
chosen[x] = 0;
|
|
198
|
+
stack.push(...children[x]!);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (let p = 0; p < n; p++) {
|
|
203
|
+
let c = fall[p]!;
|
|
204
|
+
while (c > 0 && !chosen[c]) c = up[c]!;
|
|
205
|
+
labels[p] = c > 0 ? c : -1;
|
|
206
|
+
}
|
|
207
|
+
return labels;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* This function gives the HDBSCAN clusters of the edges (HWY-028). The edges that are noise get no group.
|
|
212
|
+
* Each edge of a cluster gets `flip` when it goes opposite to the first edge of the cluster.
|
|
213
|
+
*/
|
|
214
|
+
export function hdbscanClusters(input: HighwayInput, o: Readonly<HdbscanOptions>): Map<string, EdgeGroup> {
|
|
215
|
+
const ends = edgePlugs(input.graph).map(({ edge, ps, pt }) => ({ edge: edge.id, s: ps.point, t: pt.point }));
|
|
216
|
+
const groups = new Map<string, EdgeGroup>();
|
|
217
|
+
const order = ends.length > BLOCK ? [...ends].sort((a, b) => a.s.x - b.s.x || a.s.y - b.s.y || (a.edge < b.edge ? -1 : 1)) : ends;
|
|
218
|
+
const between = (a: (typeof ends)[number], b: (typeof ends)[number]): number =>
|
|
219
|
+
o.distance === "flow" ? flowDistance(a.s, a.t, b.s, b.t, false).d : 1 - compatibility(a.s, a.t, b.s, b.t);
|
|
220
|
+
for (let from = 0; from < order.length; from += BLOCK) {
|
|
221
|
+
const block = order.slice(from, from + BLOCK);
|
|
222
|
+
const n = block.length;
|
|
223
|
+
const d = new Float64Array(n * n);
|
|
224
|
+
for (let i = 0; i < n; i++)
|
|
225
|
+
for (let j = i + 1; j < n; j++) {
|
|
226
|
+
const v = between(block[i]!, block[j]!);
|
|
227
|
+
d[i * n + j] = v;
|
|
228
|
+
d[j * n + i] = v;
|
|
229
|
+
}
|
|
230
|
+
const labels = hdbscanLabels(n, d, o.minClusterSize, o.minSamples);
|
|
231
|
+
const lead = new Map<number, number>();
|
|
232
|
+
block.forEach((e, i) => {
|
|
233
|
+
const c = labels[i]!;
|
|
234
|
+
if (c < 0) return;
|
|
235
|
+
if (!lead.has(c)) lead.set(c, i);
|
|
236
|
+
const l = block[lead.get(c)!]!;
|
|
237
|
+
const flip = (e.t.x - e.s.x) * (l.t.x - l.s.x) + (e.t.y - e.s.y) * (l.t.y - l.s.y) < 0;
|
|
238
|
+
groups.set(e.edge, { key: `c${from}:${c}`, flip });
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return groups;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* This function gives the highways of the HDBSCAN clusters (HWY-028). The clusters that live over a long range of densities are the highways.
|
|
246
|
+
* The number of highways is not fixed, and the noise edges stay alone.
|
|
247
|
+
*/
|
|
248
|
+
export function hdbscanPlan(input: HighwayInput, o: Readonly<HdbscanOptions>): HighwayPlan {
|
|
249
|
+
return groupedPlan(input, hdbscanClusters(input, o), { spine: o.spine, minSize: Math.max(2, Math.round(o.minClusterSize)) });
|
|
250
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { dist, type Point } from "../../geom.ts";
|
|
2
|
+
import type { WayOptions } from "../../ways.ts";
|
|
3
|
+
import type { HighwayInput, HighwayPlan } from "../model.ts";
|
|
4
|
+
import { linePoint, snapPlan, type SnapLine } from "../snap.ts";
|
|
5
|
+
import { edgePlugs } from "../stretches.ts";
|
|
6
|
+
|
|
7
|
+
/** The options of the highway strategy `hough` (HWY-024 of the note on highways). */
|
|
8
|
+
export interface HoughOptions extends Pick<WayOptions, "margin" | "k"> {
|
|
9
|
+
/** The width of the votes in the angle, in degrees. An edge joins a peak within this angle. The default is 8. */
|
|
10
|
+
readonly dTheta: number;
|
|
11
|
+
/** The width of the votes in the offset, in units of the board. An edge joins a peak when its middle is this near to the line. The default is 12. */
|
|
12
|
+
readonly dRho: number;
|
|
13
|
+
/** A peak with fewer edges than this number is not a corridor. The default is 3. */
|
|
14
|
+
readonly minVotes: number;
|
|
15
|
+
/** With `true`, the peaks are only horizontal or vertical lines. The default is `false`. */
|
|
16
|
+
readonly orthogonal: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The default options of the strategy `hough`. */
|
|
20
|
+
export const HOUGH_DEFAULTS: Readonly<Required<Omit<HoughOptions, "margin" | "k">>> = Object.freeze({ dTheta: 8, dRho: 12, minVotes: 3, orthogonal: false });
|
|
21
|
+
|
|
22
|
+
/** The line of one edge: its direction angle from 0 to pi, its offset along the normal, its middle and its ends. */
|
|
23
|
+
interface EdgeLine {
|
|
24
|
+
readonly edge: string;
|
|
25
|
+
readonly theta: number;
|
|
26
|
+
readonly s: Point;
|
|
27
|
+
readonly t: Point;
|
|
28
|
+
readonly mid: Point;
|
|
29
|
+
readonly len: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A peak of the votes: its angle, its offset and the sum of its votes. */
|
|
33
|
+
export interface HoughPeak {
|
|
34
|
+
readonly theta: number;
|
|
35
|
+
readonly rho: number;
|
|
36
|
+
readonly votes: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const DEG = Math.PI / 180;
|
|
40
|
+
|
|
41
|
+
/** This function gives the offset of a point from the line through the origin with the direction angle `theta`. */
|
|
42
|
+
const offsetOf = (p: Point, theta: number): number => -p.x * Math.sin(theta) + p.y * Math.cos(theta);
|
|
43
|
+
|
|
44
|
+
/** This function gives the place of a point along the direction angle `theta`. */
|
|
45
|
+
const placeOf = (p: Point, theta: number): number => p.x * Math.cos(theta) + p.y * Math.sin(theta);
|
|
46
|
+
|
|
47
|
+
/** This function gives the difference of two direction angles, from 0 to pi / 2. */
|
|
48
|
+
function angleGap(a: number, b: number): number {
|
|
49
|
+
const d = Math.abs(a - b) % Math.PI;
|
|
50
|
+
return Math.min(d, Math.PI - d);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* This function gives the peaks of the votes of the lines (Duda and Hart 1972). Each line votes for its angle and its offset, with a weight of its length.
|
|
55
|
+
* A vote spreads as a Gaussian with the widths `dTheta / 2` and `dRho / 2`. The accumulator has bins of 1 degree and of `dRho / 4`.
|
|
56
|
+
* A peak is a bin with a larger sum than each other bin within `dTheta` and `dRho`. The peaks come in the order of their votes.
|
|
57
|
+
*/
|
|
58
|
+
export function houghPeaks(lines: readonly { theta: number; mid: Point; len: number }[], o: Readonly<Pick<HoughOptions, "dTheta" | "dRho" | "orthogonal">>): HoughPeak[] {
|
|
59
|
+
if (lines.length === 0) return [];
|
|
60
|
+
const thetas = o.orthogonal ? [0, 90] : Array.from({ length: 180 }, (_, i) => i);
|
|
61
|
+
const T = thetas.length;
|
|
62
|
+
const rhoStep = Math.max(1e-6, o.dRho / 4);
|
|
63
|
+
let maxRho = 0;
|
|
64
|
+
for (const l of lines) maxRho = Math.max(maxRho, Math.abs(l.mid.x) + Math.abs(l.mid.y));
|
|
65
|
+
const R = Math.ceil(maxRho / rhoStep) + 4;
|
|
66
|
+
const rows = 2 * R + 1;
|
|
67
|
+
const acc = new Float64Array(T * rows);
|
|
68
|
+
const wTheta = Math.max(1e-6, o.dTheta);
|
|
69
|
+
const sT = wTheta / 2;
|
|
70
|
+
const sR = o.dRho / 2;
|
|
71
|
+
const reachR = Math.ceil(o.dRho / rhoStep);
|
|
72
|
+
for (const l of lines) {
|
|
73
|
+
for (let ti = 0; ti < T; ti++) {
|
|
74
|
+
const gap = angleGap(thetas[ti]! * DEG, l.theta) / DEG;
|
|
75
|
+
if (gap > wTheta) continue;
|
|
76
|
+
const rho = offsetOf(l.mid, thetas[ti]! * DEG);
|
|
77
|
+
const center = Math.round(rho / rhoStep);
|
|
78
|
+
const wt = Math.exp(-(gap * gap) / (2 * sT * sT)) * l.len;
|
|
79
|
+
for (let r = center - reachR; r <= center + reachR; r++) {
|
|
80
|
+
if (r < -R || r > R) continue;
|
|
81
|
+
const dr = r * rhoStep - rho;
|
|
82
|
+
acc[ti * rows + r + R] = acc[ti * rows + r + R]! + wt * Math.exp(-(dr * dr) / (2 * sR * sR));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const reachT = o.orthogonal ? 0 : Math.ceil(wTheta);
|
|
87
|
+
const peaks: HoughPeak[] = [];
|
|
88
|
+
for (let ti = 0; ti < T; ti++)
|
|
89
|
+
for (let r = 0; r < rows; r++) {
|
|
90
|
+
const v = acc[ti * rows + r]!;
|
|
91
|
+
if (v <= 0) continue;
|
|
92
|
+
let top = true;
|
|
93
|
+
for (let dt = -reachT; dt <= reachT && top; dt++)
|
|
94
|
+
for (let dr = -reachR; dr <= reachR && top; dr++) {
|
|
95
|
+
if (dt === 0 && dr === 0) continue;
|
|
96
|
+
let tj = ti + dt;
|
|
97
|
+
let rj = r - R;
|
|
98
|
+
// The angle wraps at 180 degrees, and the offset changes its sign there.
|
|
99
|
+
if (tj < 0) [tj, rj] = [tj + T, -rj];
|
|
100
|
+
else if (tj >= T) [tj, rj] = [tj - T, -rj];
|
|
101
|
+
rj += dr;
|
|
102
|
+
if (rj < -R || rj > R) continue;
|
|
103
|
+
const w = acc[tj * rows + rj + R]!;
|
|
104
|
+
// A tie goes to the first bin in the order of the accumulator.
|
|
105
|
+
if (w > v || (w === v && tj * rows + rj + R < ti * rows + r)) top = false;
|
|
106
|
+
}
|
|
107
|
+
if (top) peaks.push({ theta: thetas[ti]! * DEG, rho: (r - R) * rhoStep, votes: v });
|
|
108
|
+
}
|
|
109
|
+
peaks.sort((a, b) => b.votes - a.votes || a.theta - b.theta || a.rho - b.rho);
|
|
110
|
+
return peaks;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* This function gives the corridors of the votes (HWY-024). Each edge joins the first peak with an angle within `dTheta` and its middle within `dRho` of the line.
|
|
115
|
+
* The corridor runs over the common span of the projections of its edges on the line. Each edge snaps to the ends of that span.
|
|
116
|
+
* A member that makes the span shorter than four margins leaves, while the peak keeps `minVotes` edges.
|
|
117
|
+
*/
|
|
118
|
+
export function houghLines(input: HighwayInput, o: Readonly<HoughOptions>): SnapLine[] {
|
|
119
|
+
const margin = o.margin ?? 5;
|
|
120
|
+
const lines: EdgeLine[] = [];
|
|
121
|
+
for (const { edge, ps, pt } of edgePlugs(input.graph)) {
|
|
122
|
+
const s = ps.point;
|
|
123
|
+
const t = pt.point;
|
|
124
|
+
const len = dist(s, t);
|
|
125
|
+
if (len <= 1e-9) continue;
|
|
126
|
+
const theta = (((Math.atan2(t.y - s.y, t.x - s.x) % Math.PI) + Math.PI) % Math.PI);
|
|
127
|
+
lines.push({ edge: edge.id, theta, s, t, mid: { x: (s.x + t.x) / 2, y: (s.y + t.y) / 2 }, len });
|
|
128
|
+
}
|
|
129
|
+
const peaks = houghPeaks(lines, o);
|
|
130
|
+
const taken = new Set<string>();
|
|
131
|
+
const out: SnapLine[] = [];
|
|
132
|
+
const need = Math.max(2, Math.round(o.minVotes));
|
|
133
|
+
for (const [pi, p] of peaks.entries()) {
|
|
134
|
+
let members = lines.filter((l) => !taken.has(l.edge) && angleGap(l.theta, p.theta) <= o.dTheta * DEG && Math.abs(offsetOf(l.mid, p.theta) - p.rho) <= o.dRho);
|
|
135
|
+
const span = (list: readonly EdgeLine[]): [number, number] => {
|
|
136
|
+
let lo = -Infinity;
|
|
137
|
+
let hi = Infinity;
|
|
138
|
+
for (const l of list) {
|
|
139
|
+
const a = placeOf(l.s, p.theta);
|
|
140
|
+
const b = placeOf(l.t, p.theta);
|
|
141
|
+
lo = Math.max(lo, Math.min(a, b));
|
|
142
|
+
hi = Math.min(hi, Math.max(a, b));
|
|
143
|
+
}
|
|
144
|
+
return [lo, hi];
|
|
145
|
+
};
|
|
146
|
+
let [lo, hi] = span(members);
|
|
147
|
+
while (members.length >= need && hi - lo < 4 * margin) {
|
|
148
|
+
// The member that limits the span the most leaves.
|
|
149
|
+
let best = -1;
|
|
150
|
+
let gain = -Infinity;
|
|
151
|
+
members.forEach((_, i) => {
|
|
152
|
+
const [l2, h2] = span(members.filter((__, j) => j !== i));
|
|
153
|
+
if (h2 - l2 > gain) [gain, best] = [h2 - l2, i];
|
|
154
|
+
});
|
|
155
|
+
members = members.filter((_, j) => j !== best);
|
|
156
|
+
[lo, hi] = span(members);
|
|
157
|
+
}
|
|
158
|
+
if (members.length < need || hi - lo < 4 * margin) continue;
|
|
159
|
+
for (const l of members) taken.add(l.edge);
|
|
160
|
+
const a = linePoint(p.theta, p.rho, lo);
|
|
161
|
+
const b = linePoint(p.theta, p.rho, hi);
|
|
162
|
+
out.push({
|
|
163
|
+
key: `p${pi}`,
|
|
164
|
+
a,
|
|
165
|
+
b,
|
|
166
|
+
members: members.map((l) => {
|
|
167
|
+
const forward = placeOf(l.s, p.theta) <= placeOf(l.t, p.theta);
|
|
168
|
+
return { edge: l.edge, from: forward ? a : b, to: forward ? b : a, at: 0 };
|
|
169
|
+
}),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* This function gives the highways of the Hough votes (HWY-024). The peaks of the votes of the straight lines of the edges are corridors.
|
|
177
|
+
* Each corridor is a spine through the corner graph, and its edges ride it over their common span.
|
|
178
|
+
*/
|
|
179
|
+
export function houghPlan(input: HighwayInput, o: Readonly<HoughOptions>): HighwayPlan {
|
|
180
|
+
const way = { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) };
|
|
181
|
+
return snapPlan(input, houghLines(input, o), { ...way, minSize: Math.max(2, Math.round(o.minVotes)) });
|
|
182
|
+
}
|