@mark1russell7/highways 0.4.1 → 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 +43 -1
- package/build/index.d.ts +1377 -5
- package/build/index.d.ts.map +1 -1
- package/build/index.js +8202 -1515
- package/build/index.js.map +1 -1
- package/package.json +3 -1
- package/src/bench/exact/index.ts +197 -0
- package/src/bench/measures/highways.ts +93 -0
- package/src/bench/measures/index.ts +2 -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 +23 -0
- package/src/bench/screen/screen.ts +42 -0
- package/src/channels.ts +56 -0
- package/src/highways/base.ts +126 -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/flow-field/index.ts +49 -0
- package/src/highways/gates/ambiguity/index.ts +110 -0
- package/src/highways/gates/breakeven/index.ts +62 -0
- package/src/highways/gates/hysteresis/index.ts +48 -0
- package/src/highways/gates/ids/index.ts +59 -0
- package/src/highways/gates/index.ts +6 -0
- package/src/highways/gates/model.ts +129 -0
- package/src/highways/gates/stretch/index.ts +39 -0
- package/src/highways/grouped.ts +123 -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 +30 -0
- package/src/highways/lanes/index.ts +189 -0
- package/src/highways/level-links/index.ts +114 -0
- package/src/highways/meet/index.ts +84 -0
- package/src/highways/mingle/index.ts +171 -0
- package/src/highways/model.ts +5 -0
- package/src/highways/nested/index.ts +143 -0
- package/src/highways/od-cells/index.ts +50 -0
- package/src/highways/quickbundles/index.ts +117 -0
- package/src/highways/ridges/index.ts +328 -0
- package/src/highways/road-classes/index.ts +74 -0
- package/src/highways/router.ts +1 -1
- package/src/highways/select/index.ts +162 -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/stretches.ts +141 -0
- package/src/highways/stubs/index.ts +117 -0
- package/src/highways/traclus/index.ts +296 -0
- package/src/highways/weiszfeld/index.ts +105 -0
- package/src/highways/wspd/index.ts +130 -0
- package/src/highways/zipper/index.ts +168 -0
- package/src/index.ts +1 -0
- package/src/routers/shared.ts +56 -3
- package/src/strategies/builtins.ts +303 -7
- package/src/strategies/schemas.ts +300 -0
- package/src/visibility.ts +5 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { boardUnit } from "../../bench/measures/routes.ts";
|
|
2
|
+
import { polylineLength, type Point } from "../../geom.ts";
|
|
3
|
+
import { firstRoutes } from "../base.ts";
|
|
4
|
+
import { groupedPlan, type EdgeGroup, type GroupSpine } from "../grouped.ts";
|
|
5
|
+
import type { HighwayInput, HighwayPlan } from "../model.ts";
|
|
6
|
+
import { mdf, resample, type BundleLine, type BundleOrder } from "../quickbundles/index.ts";
|
|
7
|
+
import { edgePlugs } from "../stretches.ts";
|
|
8
|
+
|
|
9
|
+
/** The options of the highway strategy `nested` (HWY-052 of the note on highways). */
|
|
10
|
+
export interface NestedOptions {
|
|
11
|
+
/** The scale of the closest level, in units of the board. An edge joins a cluster when its distance to the centroid is this value or less. The default is 3. */
|
|
12
|
+
readonly theta: number;
|
|
13
|
+
/** The scale of each level is this value times the scale of the next closer level. The default is 2. */
|
|
14
|
+
readonly ratio: number;
|
|
15
|
+
/** The number of levels. The default is 3. */
|
|
16
|
+
readonly levels: number;
|
|
17
|
+
/** The level of the plan. Level 0 is the closest level, and level `levels - 1` is the far level. The default is 0. */
|
|
18
|
+
readonly level: number;
|
|
19
|
+
/** The number of points of each line after the resampling. The default is 12. */
|
|
20
|
+
readonly points: number;
|
|
21
|
+
/** The line of each edge. The default is `routes`. */
|
|
22
|
+
readonly line: BundleLine;
|
|
23
|
+
/** The order of the edges. The default is `input`. */
|
|
24
|
+
readonly order: BundleOrder;
|
|
25
|
+
/** A cluster with this number of edges or more gets highways. The default is 2. */
|
|
26
|
+
readonly minSize: number;
|
|
27
|
+
/** The spine of each cluster. The default is `shared`. */
|
|
28
|
+
readonly spine: GroupSpine;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The default options of the strategy `nested`. */
|
|
32
|
+
export const NESTED_DEFAULTS: Readonly<NestedOptions> = Object.freeze({
|
|
33
|
+
theta: 3,
|
|
34
|
+
ratio: 2,
|
|
35
|
+
levels: 3,
|
|
36
|
+
level: 0,
|
|
37
|
+
points: 12,
|
|
38
|
+
line: "routes",
|
|
39
|
+
order: "input",
|
|
40
|
+
minSize: 2,
|
|
41
|
+
spine: "shared",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
interface Line {
|
|
45
|
+
readonly edge: string;
|
|
46
|
+
readonly line: readonly Point[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* This function puts lines in clusters in one pass, as QuickBundles does. Each line joins the cluster with the nearest centroid when the distance is `limit` or less.
|
|
51
|
+
* It gives the index of the cluster of each line, and `flip` when the line goes against the centroid.
|
|
52
|
+
*/
|
|
53
|
+
function clusterLines(lines: readonly Line[], limit: number): { cluster: number; flip: boolean }[] {
|
|
54
|
+
const clusters: { sum: { x: number; y: number }[]; n: number; centroid: Point[] }[] = [];
|
|
55
|
+
return lines.map(({ line }) => {
|
|
56
|
+
let best = -1;
|
|
57
|
+
let bestD = Infinity;
|
|
58
|
+
let bestFlip = false;
|
|
59
|
+
clusters.forEach((c, ci) => {
|
|
60
|
+
const m = mdf(line, c.centroid);
|
|
61
|
+
if (m.d < bestD) [best, bestD, bestFlip] = [ci, m.d, m.flip];
|
|
62
|
+
});
|
|
63
|
+
if (best < 0 || bestD > limit) {
|
|
64
|
+
clusters.push({ sum: line.map((p) => ({ ...p })), n: 1, centroid: [...line] });
|
|
65
|
+
return { cluster: clusters.length - 1, flip: false };
|
|
66
|
+
}
|
|
67
|
+
const c = clusters[best]!;
|
|
68
|
+
const oriented = bestFlip ? [...line].reverse() : line;
|
|
69
|
+
oriented.forEach((p, i) => {
|
|
70
|
+
c.sum[i]!.x += p.x;
|
|
71
|
+
c.sum[i]!.y += p.y;
|
|
72
|
+
});
|
|
73
|
+
c.n++;
|
|
74
|
+
c.centroid = c.sum.map((p) => ({ x: p.x / c.n, y: p.y / c.n }));
|
|
75
|
+
return { cluster: best, flip: bestFlip };
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* This function gives the groups of edges of each level, from the closest level (index 0) to the far level.
|
|
81
|
+
* The far level puts all the edges in clusters at the scale `theta × ratio^(levels - 1)`.
|
|
82
|
+
* Each closer level puts the edges of each cluster of the level above in clusters, at its own scale. Thus each group of a level is in one group of the level above.
|
|
83
|
+
*/
|
|
84
|
+
export function nestedGroups(input: HighwayInput, o: Readonly<NestedOptions>): Map<string, EdgeGroup>[] {
|
|
85
|
+
const graph = input.graph;
|
|
86
|
+
const unit = boardUnit(graph);
|
|
87
|
+
const levels = Math.max(1, Math.round(o.levels));
|
|
88
|
+
const k = Math.max(2, Math.round(o.points));
|
|
89
|
+
const routes = o.line === "routes" ? firstRoutes(input) : undefined;
|
|
90
|
+
const lines: Line[] = edgePlugs(graph).map(({ edge, ps, pt }) => ({ edge: edge.id, line: resample(routes?.get(edge.id) ?? [ps.point, pt.point], k) }));
|
|
91
|
+
if (o.order === "length") lines.sort((a, b) => polylineLength(b.line) - polylineLength(a.line) || (a.edge < b.edge ? -1 : a.edge > b.edge ? 1 : 0));
|
|
92
|
+
const out: Map<string, EdgeGroup>[] = [];
|
|
93
|
+
// Each part is a list of lines with one key of the level above. The far level has one part with all the lines.
|
|
94
|
+
let parts: { key: string; lines: Line[] }[] = [{ key: "n", lines }];
|
|
95
|
+
for (let d = levels - 1; d >= 0; d--) {
|
|
96
|
+
const limit = o.theta * o.ratio ** d * unit;
|
|
97
|
+
const groups = new Map<string, EdgeGroup>();
|
|
98
|
+
const next: { key: string; lines: Line[] }[] = [];
|
|
99
|
+
for (const part of parts) {
|
|
100
|
+
const found = clusterLines(part.lines, limit);
|
|
101
|
+
const children = new Map<number, Line[]>();
|
|
102
|
+
found.forEach(({ cluster, flip }, i) => {
|
|
103
|
+
const key = `${part.key}.${cluster}`;
|
|
104
|
+
groups.set(part.lines[i]!.edge, { key, flip });
|
|
105
|
+
const list = children.get(cluster);
|
|
106
|
+
if (list) list.push(part.lines[i]!);
|
|
107
|
+
else children.set(cluster, [part.lines[i]!]);
|
|
108
|
+
});
|
|
109
|
+
for (const [cluster, list] of children) next.push({ key: `${part.key}.${cluster}`, lines: list });
|
|
110
|
+
}
|
|
111
|
+
out.unshift(groups);
|
|
112
|
+
parts = next;
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* This function gives the number of groups of the closer level `fine` with edges in two groups or more of the level `coarse`.
|
|
119
|
+
* An edge that is not in `coarse` does not count. The groups of `nestedGroups` give 0.
|
|
120
|
+
*/
|
|
121
|
+
export function nestingViolations(coarse: ReadonlyMap<string, EdgeGroup>, fine: ReadonlyMap<string, EdgeGroup>): number {
|
|
122
|
+
const parents = new Map<string, Set<string>>();
|
|
123
|
+
for (const [edge, g] of fine) {
|
|
124
|
+
const up = coarse.get(edge);
|
|
125
|
+
if (!up) continue;
|
|
126
|
+
const set = parents.get(g.key);
|
|
127
|
+
if (set) set.add(up.key);
|
|
128
|
+
else parents.set(g.key, new Set([up.key]));
|
|
129
|
+
}
|
|
130
|
+
let n = 0;
|
|
131
|
+
for (const set of parents.values()) if (set.size > 1) n++;
|
|
132
|
+
return n;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* This function gives the highways of one level of the nested clusters (HWY-052). The level is `o.level`, with 0 as the closest level.
|
|
137
|
+
* The clusters of that level with `minSize` edges or more get highways, as in the strategy `quickbundles`.
|
|
138
|
+
*/
|
|
139
|
+
export function nestedPlan(input: HighwayInput, o: Readonly<NestedOptions>): HighwayPlan {
|
|
140
|
+
const levels = nestedGroups(input, o);
|
|
141
|
+
const level = Math.max(0, Math.min(levels.length - 1, Math.round(o.level)));
|
|
142
|
+
return groupedPlan(input, levels[level]!, { spine: o.spine, minSize: o.minSize });
|
|
143
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { buildSections, levelCut } from "../../sections.ts";
|
|
2
|
+
import { groupedPlan, type EdgeGroup, type GroupSpine } from "../grouped.ts";
|
|
3
|
+
import type { HighwayInput, HighwayPlan } from "../model.ts";
|
|
4
|
+
|
|
5
|
+
/** The options of the highway strategy `od-cells` (EDA-033 with WILD-032 of the notes). */
|
|
6
|
+
export interface OdCellOptions {
|
|
7
|
+
/** The depth of the cells in the tree of sections. Depth 1 is the level below the root. The default is 2. */
|
|
8
|
+
readonly level: number;
|
|
9
|
+
/** A pair of cells with this number of edges or more gets a trunk. The default is 3. */
|
|
10
|
+
readonly minGroup: number;
|
|
11
|
+
/** An edge rides the trunk only when each of its legs is this share of the span or shorter. The default is 0.3. */
|
|
12
|
+
readonly legMax: number;
|
|
13
|
+
/** The spine of each pair of cells. The default is `trunk`. */
|
|
14
|
+
readonly spine: GroupSpine;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The default options of the strategy `od-cells`. */
|
|
18
|
+
export const OD_CELL_DEFAULTS: Readonly<OdCellOptions> = Object.freeze({ level: 2, minGroup: 3, legMax: 0.3, spine: "trunk" });
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* This function gives the groups of the edges by the pair of the cells of their two nodes (EDA-033, WILD-032).
|
|
22
|
+
* The cells are the items of the zoom level at the depth `level`. The edges of one pair of cells, in either direction, are one group.
|
|
23
|
+
* An edge inside one cell is in no group.
|
|
24
|
+
*/
|
|
25
|
+
export function odGroups(input: HighwayInput, o: Readonly<OdCellOptions>): Map<string, EdgeGroup> {
|
|
26
|
+
const nodes = input.graph.nodes;
|
|
27
|
+
const groups = new Map<string, EdgeGroup>();
|
|
28
|
+
if (nodes.length === 0) return groups;
|
|
29
|
+
const { owner } = levelCut(nodes, buildSections(nodes), Math.max(0, Math.round(o.level)));
|
|
30
|
+
const index = new Map(nodes.map((n, i) => [n.id, i]));
|
|
31
|
+
for (const e of input.graph.edges) {
|
|
32
|
+
const u = index.get(e.source.node);
|
|
33
|
+
const v = index.get(e.target.node);
|
|
34
|
+
if (u === undefined || v === undefined) continue;
|
|
35
|
+
const a = owner[u]!;
|
|
36
|
+
const b = owner[v]!;
|
|
37
|
+
if (a === b) continue;
|
|
38
|
+
groups.set(e.id, a < b ? { key: `od${a}:${b}`, flip: false } : { key: `od${b}:${a}`, flip: true });
|
|
39
|
+
}
|
|
40
|
+
return groups;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* This function gives the highways of the consolidation by origin and destination cells (EDA-033, WILD-032).
|
|
45
|
+
* Carriers merge the loads from one region to another into one truck between two terminals. Thus each pair of cells with `minGroup` edges or more gets one trunk.
|
|
46
|
+
* The terminals are the Weber points of the plugs in each cell. An edge with a leg longer than `legMax` times the span goes alone.
|
|
47
|
+
*/
|
|
48
|
+
export function odCellPlan(input: HighwayInput, o: Readonly<OdCellOptions>): HighwayPlan {
|
|
49
|
+
return groupedPlan(input, odGroups(input, o), { spine: o.spine, minSize: o.minGroup, legMax: o.legMax });
|
|
50
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { boardUnit } from "../../bench/measures/routes.ts";
|
|
2
|
+
import { dist, polylineLength, type Point } from "../../geom.ts";
|
|
3
|
+
import { firstRoutes } from "../base.ts";
|
|
4
|
+
import { groupedPlan, type EdgeGroup, type GroupSpine } from "../grouped.ts";
|
|
5
|
+
import type { HighwayInput, HighwayPlan } from "../model.ts";
|
|
6
|
+
import { edgePlugs } from "../stretches.ts";
|
|
7
|
+
|
|
8
|
+
/** The line of each edge that QuickBundles compares: its first route, or the straight line between its plugs. */
|
|
9
|
+
export type BundleLine = "routes" | "lines";
|
|
10
|
+
|
|
11
|
+
/** The order of the edges in the one pass: the order of the graph, or the longest line first. */
|
|
12
|
+
export type BundleOrder = "input" | "length";
|
|
13
|
+
|
|
14
|
+
/** The options of the highway strategy `quickbundles` (HWY-026 of the note on highways). */
|
|
15
|
+
export interface QuickBundlesOptions {
|
|
16
|
+
/** An edge joins a cluster when its distance to the centroid is this value or less, in units of the board. The default is 3. */
|
|
17
|
+
readonly theta: number;
|
|
18
|
+
/** The number of points of each line after the resampling. The default is 12. */
|
|
19
|
+
readonly points: number;
|
|
20
|
+
/** The line of each edge. The default is `routes`. */
|
|
21
|
+
readonly line: BundleLine;
|
|
22
|
+
/** The order of the edges. The default is `input`, so an added edge does not change the clusters before it. */
|
|
23
|
+
readonly order: BundleOrder;
|
|
24
|
+
/** A cluster with this number of edges or more gets highways. The default is 2. */
|
|
25
|
+
readonly minSize: number;
|
|
26
|
+
/** The spine of each cluster. The default is `shared`. */
|
|
27
|
+
readonly spine: GroupSpine;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The default options of the strategy `quickbundles`. */
|
|
31
|
+
export const QUICKBUNDLES_DEFAULTS: Readonly<QuickBundlesOptions> = Object.freeze({ theta: 3, points: 12, line: "routes", order: "input", minSize: 2, spine: "shared" });
|
|
32
|
+
|
|
33
|
+
/** This function gives `k` points at equal steps of length along a polyline, from its first point to its last point. */
|
|
34
|
+
export function resample(line: readonly Point[], k: number): Point[] {
|
|
35
|
+
const n = Math.max(2, Math.round(k));
|
|
36
|
+
if (line.length === 0) throw new Error("The polyline has no point.");
|
|
37
|
+
if (line.length === 1) return Array.from({ length: n }, () => line[0]!);
|
|
38
|
+
const at: number[] = [0];
|
|
39
|
+
for (let i = 1; i < line.length; i++) at.push(at[i - 1]! + dist(line[i - 1]!, line[i]!));
|
|
40
|
+
const total = at[at.length - 1]!;
|
|
41
|
+
const out: Point[] = [];
|
|
42
|
+
let j = 1;
|
|
43
|
+
for (let i = 0; i < n; i++) {
|
|
44
|
+
const s = (total * i) / (n - 1);
|
|
45
|
+
while (j < line.length - 1 && at[j]! < s) j++;
|
|
46
|
+
const a = line[j - 1]!;
|
|
47
|
+
const b = line[j]!;
|
|
48
|
+
const len = at[j]! - at[j - 1]!;
|
|
49
|
+
const u = len > 0 ? Math.max(0, Math.min(1, (s - at[j - 1]!) / len)) : 0;
|
|
50
|
+
out.push({ x: a.x + (b.x - a.x) * u, y: a.y + (b.y - a.y) * u });
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* This function gives the MDF distance of two lines with the same number of points. It is the mean distance of their points.
|
|
57
|
+
* The function compares the points in the same direction and in the opposite direction, and keeps the smaller mean.
|
|
58
|
+
* `flip` is `true` when the opposite direction is smaller.
|
|
59
|
+
*/
|
|
60
|
+
export function mdf(p: readonly Point[], q: readonly Point[]): { d: number; flip: boolean } {
|
|
61
|
+
const n = p.length;
|
|
62
|
+
let direct = 0;
|
|
63
|
+
let back = 0;
|
|
64
|
+
for (let i = 0; i < n; i++) {
|
|
65
|
+
direct += dist(p[i]!, q[i]!);
|
|
66
|
+
back += dist(p[i]!, q[n - 1 - i]!);
|
|
67
|
+
}
|
|
68
|
+
return back < direct ? { d: back / n, flip: true } : { d: direct / n, flip: false };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* This function gives the clusters of QuickBundles (Garyfallidis 2012) as groups of edges.
|
|
73
|
+
* In one pass, each edge joins the cluster with the nearest centroid when the MDF distance is `theta` units or less. Else it starts a cluster.
|
|
74
|
+
* The centroid is the running mean of the lines of the cluster, each in the direction of the centroid.
|
|
75
|
+
*/
|
|
76
|
+
export function quickBundles(input: HighwayInput, o: Readonly<QuickBundlesOptions>): Map<string, EdgeGroup> {
|
|
77
|
+
const graph = input.graph;
|
|
78
|
+
const limit = o.theta * boardUnit(graph);
|
|
79
|
+
const k = Math.max(2, Math.round(o.points));
|
|
80
|
+
const routes = o.line === "routes" ? firstRoutes(input) : undefined;
|
|
81
|
+
const lines = edgePlugs(graph).map(({ edge, ps, pt }) => ({ edge: edge.id, line: resample(routes?.get(edge.id) ?? [ps.point, pt.point], k) }));
|
|
82
|
+
if (o.order === "length") lines.sort((a, b) => polylineLength(b.line) - polylineLength(a.line) || (a.edge < b.edge ? -1 : a.edge > b.edge ? 1 : 0));
|
|
83
|
+
const clusters: { sum: { x: number; y: number }[]; n: number; centroid: Point[] }[] = [];
|
|
84
|
+
const groups = new Map<string, EdgeGroup>();
|
|
85
|
+
for (const { edge, line } of lines) {
|
|
86
|
+
let best = -1;
|
|
87
|
+
let bestD = Infinity;
|
|
88
|
+
let bestFlip = false;
|
|
89
|
+
clusters.forEach((c, ci) => {
|
|
90
|
+
const m = mdf(line, c.centroid);
|
|
91
|
+
if (m.d < bestD) [best, bestD, bestFlip] = [ci, m.d, m.flip];
|
|
92
|
+
});
|
|
93
|
+
if (best < 0 || bestD > limit) {
|
|
94
|
+
clusters.push({ sum: line.map((p) => ({ ...p })), n: 1, centroid: line });
|
|
95
|
+
groups.set(edge, { key: `q${clusters.length - 1}`, flip: false });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const c = clusters[best]!;
|
|
99
|
+
const oriented = bestFlip ? [...line].reverse() : line;
|
|
100
|
+
oriented.forEach((p, i) => {
|
|
101
|
+
c.sum[i]!.x += p.x;
|
|
102
|
+
c.sum[i]!.y += p.y;
|
|
103
|
+
});
|
|
104
|
+
c.n++;
|
|
105
|
+
c.centroid = c.sum.map((p) => ({ x: p.x / c.n, y: p.y / c.n }));
|
|
106
|
+
groups.set(edge, { key: `q${best}`, flip: bestFlip });
|
|
107
|
+
}
|
|
108
|
+
return groups;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* This function gives the highways of QuickBundles (HWY-026). The clusters of `quickBundles` with `minSize` edges or more get highways.
|
|
113
|
+
* With the spine `shared`, the edges of each cluster share their ways through the corner graph. With `trunk`, each cluster gets one trunk.
|
|
114
|
+
*/
|
|
115
|
+
export function quickBundlesPlan(input: HighwayInput, o: Readonly<QuickBundlesOptions>): HighwayPlan {
|
|
116
|
+
return groupedPlan(input, quickBundles(input, o), { spine: o.spine, minSize: o.minSize });
|
|
117
|
+
}
|
|
@@ -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
|
+
}
|