@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,189 @@
|
|
|
1
|
+
import { boardUnit } from "../../bench/measures/routes.ts";
|
|
2
|
+
import { dist, type Point } from "../../geom.ts";
|
|
3
|
+
import { groupedPlan, weberPoint, type EdgeGroup, type GroupSpine } from "../grouped.ts";
|
|
4
|
+
import type { HighwayInput, HighwayPlan } from "../model.ts";
|
|
5
|
+
import { edgePlugs } from "../stretches.ts";
|
|
6
|
+
|
|
7
|
+
/** The options of the highway strategy `link-communities` (HWY-030 of the note on highways). */
|
|
8
|
+
export interface LinkCommunityOptions {
|
|
9
|
+
/** The cut of the tree of the edges. The value 0 cuts at the best partition density. A value above 0 keeps the joins with this similarity or more. The default is 0. */
|
|
10
|
+
readonly threshold: number;
|
|
11
|
+
/** A community with this number of edges or more gets highways, after the check of the geometry. The default is 2. */
|
|
12
|
+
readonly minSize: number;
|
|
13
|
+
/** The largest distance of an end of an edge to the Weber point of its end of the community, in units of the board. The default is 6. */
|
|
14
|
+
readonly spread: number;
|
|
15
|
+
/** The spine of each community. The default is `shared`. */
|
|
16
|
+
readonly spine: GroupSpine;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The default options of the strategy `link-communities`. */
|
|
20
|
+
export const LINK_COMMUNITY_DEFAULTS: Readonly<LinkCommunityOptions> = Object.freeze({ threshold: 0, minSize: 2, spread: 6, spine: "shared" });
|
|
21
|
+
|
|
22
|
+
/** One pair of edges with a common node, and its similarity. */
|
|
23
|
+
interface EdgePair {
|
|
24
|
+
readonly e: number;
|
|
25
|
+
readonly f: number;
|
|
26
|
+
readonly s: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The ends of one edge as node indices. */
|
|
30
|
+
export interface LinkEdge {
|
|
31
|
+
readonly u: number;
|
|
32
|
+
readonly v: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* This function gives the similarity of each pair of edges with a common node `k` (Ahn, Bagrow and Lehmann 2010).
|
|
37
|
+
* For the edges `(i, k)` and `(j, k)`, the value is `|N+(i) ∩ N+(j)| / |N+(i) ∪ N+(j)|`. `N+(i)` is the node `i` and its neighbours.
|
|
38
|
+
* Two parallel edges get the value 1. A loop has no pair.
|
|
39
|
+
*/
|
|
40
|
+
function edgePairs(n: number, edges: readonly LinkEdge[]): EdgePair[] {
|
|
41
|
+
const near: Set<number>[] = Array.from({ length: n }, (_, i) => new Set([i]));
|
|
42
|
+
const at: number[][] = Array.from({ length: n }, () => []);
|
|
43
|
+
edges.forEach(({ u, v }, e) => {
|
|
44
|
+
if (u === v) return;
|
|
45
|
+
near[u]!.add(v);
|
|
46
|
+
near[v]!.add(u);
|
|
47
|
+
at[u]!.push(e);
|
|
48
|
+
at[v]!.push(e);
|
|
49
|
+
});
|
|
50
|
+
const jaccard = (i: number, j: number): number => {
|
|
51
|
+
const a = near[i]!;
|
|
52
|
+
const b = near[j]!;
|
|
53
|
+
let both = 0;
|
|
54
|
+
for (const x of a) if (b.has(x)) both++;
|
|
55
|
+
return both / (a.size + b.size - both);
|
|
56
|
+
};
|
|
57
|
+
const out = new Map<string, EdgePair>();
|
|
58
|
+
for (let k = 0; k < n; k++) {
|
|
59
|
+
const list = at[k]!;
|
|
60
|
+
for (let x = 0; x < list.length; x++)
|
|
61
|
+
for (let y = x + 1; y < list.length; y++) {
|
|
62
|
+
const e = Math.min(list[x]!, list[y]!);
|
|
63
|
+
const f = Math.max(list[x]!, list[y]!);
|
|
64
|
+
if (e === f) continue;
|
|
65
|
+
const key = `${e},${f}`;
|
|
66
|
+
if (out.has(key)) continue;
|
|
67
|
+
const oe = edges[e]!.u === k ? edges[e]!.v : edges[e]!.u;
|
|
68
|
+
const of = edges[f]!.u === k ? edges[f]!.v : edges[f]!.u;
|
|
69
|
+
out.set(key, { e, f, s: oe === of ? 1 : jaccard(oe, of) });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return [...out.values()].sort((p, q) => q.s - p.s || p.e - q.e || p.f - q.f);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* This function gives the link communities of the edges: the single linkage of the edges by their similarity.
|
|
77
|
+
* With `threshold` 0, the cut is at the level of the best partition density `D = (2/M) Σ m_c (m_c - n_c + 1) / ((n_c - 2)(n_c - 1))`.
|
|
78
|
+
* When the best `D` is above 0, the first level with it applies. When it is 0 (a forest), the last level applies.
|
|
79
|
+
* The result is the community of each edge, as the index of an edge of the community. A loop is alone.
|
|
80
|
+
*/
|
|
81
|
+
export function linkCommunities(n: number, edges: readonly LinkEdge[], threshold: number): Int32Array {
|
|
82
|
+
const m = edges.length;
|
|
83
|
+
const pairs = edgePairs(n, edges);
|
|
84
|
+
const parent = new Int32Array(m);
|
|
85
|
+
for (let i = 0; i < m; i++) parent[i] = i;
|
|
86
|
+
const find = (i: number): number => {
|
|
87
|
+
while (parent[i] !== i) i = parent[i] = parent[parent[i]!]!;
|
|
88
|
+
return i;
|
|
89
|
+
};
|
|
90
|
+
let cut = threshold;
|
|
91
|
+
if (!(threshold > 0)) {
|
|
92
|
+
// The partition density after each level of the tree.
|
|
93
|
+
const edgeCount = new Int32Array(m).fill(1);
|
|
94
|
+
const nodes: Set<number>[] = edges.map(({ u, v }) => new Set([u, v]));
|
|
95
|
+
const term = (c: number): number => {
|
|
96
|
+
const mc = edgeCount[c]!;
|
|
97
|
+
const nc = nodes[c]!.size;
|
|
98
|
+
return nc <= 2 ? 0 : (mc * (mc - nc + 1)) / ((nc - 2) * (nc - 1));
|
|
99
|
+
};
|
|
100
|
+
let sum = 0;
|
|
101
|
+
const levels: { s: number; d: number }[] = [];
|
|
102
|
+
for (let i = 0; i < pairs.length; ) {
|
|
103
|
+
const s = pairs[i]!.s;
|
|
104
|
+
if (s <= 0) break;
|
|
105
|
+
for (; i < pairs.length && pairs[i]!.s === s; i++) {
|
|
106
|
+
const a = find(pairs[i]!.e);
|
|
107
|
+
const b = find(pairs[i]!.f);
|
|
108
|
+
if (a === b) continue;
|
|
109
|
+
sum -= term(a) + term(b);
|
|
110
|
+
const [big, small] = nodes[a]!.size >= nodes[b]!.size ? [a, b] : [b, a];
|
|
111
|
+
parent[small] = big;
|
|
112
|
+
edgeCount[big] = edgeCount[big]! + edgeCount[small]!;
|
|
113
|
+
for (const x of nodes[small]!) nodes[big]!.add(x);
|
|
114
|
+
nodes[small] = new Set();
|
|
115
|
+
sum += term(big);
|
|
116
|
+
}
|
|
117
|
+
levels.push({ s, d: (2 / m) * sum });
|
|
118
|
+
}
|
|
119
|
+
const best = levels.reduce((b, l) => Math.max(b, l.d), 0);
|
|
120
|
+
cut = best > 1e-12 ? levels.find((l) => l.d >= best - 1e-12)!.s : (levels[levels.length - 1]?.s ?? Infinity);
|
|
121
|
+
for (let i = 0; i < m; i++) parent[i] = i;
|
|
122
|
+
}
|
|
123
|
+
for (const p of pairs) {
|
|
124
|
+
if (p.s < cut || p.s <= 0) break;
|
|
125
|
+
const a = find(p.e);
|
|
126
|
+
const b = find(p.f);
|
|
127
|
+
if (a !== b) parent[Math.max(a, b)] = Math.min(a, b);
|
|
128
|
+
}
|
|
129
|
+
const out = new Int32Array(m);
|
|
130
|
+
for (let i = 0; i < m; i++) out[i] = find(i);
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* This function gives the edge groups of the link communities (HWY-030). The hub of a community is its node with the most edges.
|
|
136
|
+
* Each edge goes from its end at the hub, or else in the direction of the hub edges. The check of the geometry removes each edge with an end
|
|
137
|
+
* farther than `spread` units from the Weber point of that end of the community. A community with fewer than `minSize` edges after the check gets no group.
|
|
138
|
+
*/
|
|
139
|
+
export function linkCommunityGroups(input: HighwayInput, o: Readonly<LinkCommunityOptions>): Map<string, EdgeGroup> {
|
|
140
|
+
const graph = input.graph;
|
|
141
|
+
const index = new Map(graph.nodes.map((v, i) => [v.id, i] as const));
|
|
142
|
+
const plugs = edgePlugs(graph);
|
|
143
|
+
const edges: LinkEdge[] = plugs.map(({ edge }) => ({ u: index.get(edge.source.node)!, v: index.get(edge.target.node)! }));
|
|
144
|
+
const label = linkCommunities(graph.nodes.length, edges, o.threshold);
|
|
145
|
+
const limit = Math.max(0, o.spread) * boardUnit(graph);
|
|
146
|
+
const min = Math.max(2, Math.round(o.minSize));
|
|
147
|
+
const members = new Map<number, number[]>();
|
|
148
|
+
label.forEach((c, i) => {
|
|
149
|
+
if (edges[i]!.u === edges[i]!.v) return;
|
|
150
|
+
const list = members.get(c);
|
|
151
|
+
if (list) list.push(i);
|
|
152
|
+
else members.set(c, [i]);
|
|
153
|
+
});
|
|
154
|
+
const groups = new Map<string, EdgeGroup>();
|
|
155
|
+
for (const [c, list] of members) {
|
|
156
|
+
if (list.length < min) continue;
|
|
157
|
+
const degree = new Map<number, number>();
|
|
158
|
+
for (const i of list) for (const x of [edges[i]!.u, edges[i]!.v]) degree.set(x, (degree.get(x) ?? 0) + 1);
|
|
159
|
+
let hub = -1;
|
|
160
|
+
for (const [x, d] of degree) if (hub < 0 || d > degree.get(hub)! || (d === degree.get(hub)! && x < hub)) hub = x;
|
|
161
|
+
// The mean direction of the edges at the hub, out of the hub.
|
|
162
|
+
let dx = 0;
|
|
163
|
+
let dy = 0;
|
|
164
|
+
for (const i of list) {
|
|
165
|
+
const { ps, pt } = plugs[i]!;
|
|
166
|
+
const sign = edges[i]!.u === hub ? 1 : edges[i]!.v === hub ? -1 : 0;
|
|
167
|
+
dx += sign * (pt.point.x - ps.point.x);
|
|
168
|
+
dy += sign * (pt.point.y - ps.point.y);
|
|
169
|
+
}
|
|
170
|
+
const oriented = list.map((i) => {
|
|
171
|
+
const { ps, pt } = plugs[i]!;
|
|
172
|
+
const flip = edges[i]!.u === hub ? false : edges[i]!.v === hub ? true : (pt.point.x - ps.point.x) * dx + (pt.point.y - ps.point.y) * dy < 0;
|
|
173
|
+
const a: Point = flip ? pt.point : ps.point;
|
|
174
|
+
const b: Point = flip ? ps.point : pt.point;
|
|
175
|
+
return { i, flip, a, b };
|
|
176
|
+
});
|
|
177
|
+
const wa = weberPoint(oriented.map((x) => x.a));
|
|
178
|
+
const wb = weberPoint(oriented.map((x) => x.b));
|
|
179
|
+
const kept = oriented.filter((x) => dist(x.a, wa) <= limit && dist(x.b, wb) <= limit);
|
|
180
|
+
if (kept.length < min) continue;
|
|
181
|
+
for (const x of kept) groups.set(plugs[x.i]!.edge.id, { key: `lc${c}`, flip: x.flip });
|
|
182
|
+
}
|
|
183
|
+
return groups;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** This function gives the highways of the link communities of the line graph (HWY-030). Each community that passes the check of the geometry gets highways. */
|
|
187
|
+
export function linkCommunityPlan(input: HighwayInput, o: Readonly<LinkCommunityOptions>): HighwayPlan {
|
|
188
|
+
return groupedPlan(input, linkCommunityGroups(input, o), { spine: o.spine, minSize: Math.max(2, Math.round(o.minSize)) });
|
|
189
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { dist, type Point } from "../../geom.ts";
|
|
2
|
+
import type { Graph } from "../../model.ts";
|
|
3
|
+
import { WayFinder, type WayOptions } from "../../ways.ts";
|
|
4
|
+
import type { HighwayInput, HighwayPlan } from "../model.ts";
|
|
5
|
+
import { edgePlugs } from "../stretches.ts";
|
|
6
|
+
import { tracePlan } from "../trace.ts";
|
|
7
|
+
|
|
8
|
+
/** The options of the highway strategy `mesh` (HWY-004 of the notes). */
|
|
9
|
+
export interface MeshOptions extends Pick<WayOptions, "margin" | "k"> {
|
|
10
|
+
/** The side of a cell of the mesh, in units of the boxes: the median of the smaller side of each box. The default is 4. */
|
|
11
|
+
readonly cell: number;
|
|
12
|
+
/** A cluster of crossings on one segment of the mesh has at most about this number of edges. The default is 8. */
|
|
13
|
+
readonly capacity: number;
|
|
14
|
+
/** Two clusters nearer than this value, in units of the boxes, become one. The default is 1. */
|
|
15
|
+
readonly merge: number;
|
|
16
|
+
/** The largest angle in degrees between a line and the main direction of a cell. A line with a larger angle does not cross the mesh there. The default is 45. */
|
|
17
|
+
readonly angle: number;
|
|
18
|
+
/** A highway has this number of edges or more. The default is 2. */
|
|
19
|
+
readonly minSize: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The default options of the strategy `mesh`. */
|
|
23
|
+
export const MESH_DEFAULTS: Readonly<Required<Omit<MeshOptions, "margin" | "k">>> = Object.freeze({
|
|
24
|
+
cell: 4,
|
|
25
|
+
capacity: 8,
|
|
26
|
+
merge: 1,
|
|
27
|
+
angle: 45,
|
|
28
|
+
minSize: 2,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** The number of bins of the histogram of the angles. Each bin is 10 degrees wide. */
|
|
32
|
+
const BINS = 18;
|
|
33
|
+
|
|
34
|
+
/** This function gives the median of the smaller side of each box of the graph, or 1 for a graph without boxes. */
|
|
35
|
+
export function boxUnit(graph: Graph): number {
|
|
36
|
+
const sides = graph.nodes.map((n) => Math.min(n.box.w, n.box.h)).sort((a, b) => a - b);
|
|
37
|
+
return sides.length > 0 ? Math.max(1e-3, sides[Math.floor(sides.length / 2)]!) : 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** This function gives the shares `t` of the segment `a b` and `u` of the segment `c d` at their crossing. Segments that do not cross give `undefined`. */
|
|
41
|
+
function crossing(a: Point, b: Point, c: Point, d: Point): { t: number; u: number } | undefined {
|
|
42
|
+
const rx = b.x - a.x;
|
|
43
|
+
const ry = b.y - a.y;
|
|
44
|
+
const sx = d.x - c.x;
|
|
45
|
+
const sy = d.y - c.y;
|
|
46
|
+
const den = rx * sy - ry * sx;
|
|
47
|
+
if (Math.abs(den) < 1e-12) return undefined;
|
|
48
|
+
const t = ((c.x - a.x) * sy - (c.y - a.y) * sx) / den;
|
|
49
|
+
const u = ((c.x - a.x) * ry - (c.y - a.y) * rx) / den;
|
|
50
|
+
const eps = 1e-9;
|
|
51
|
+
return t >= -eps && t <= 1 + eps && u >= -eps && u <= 1 + eps ? { t: Math.max(0, Math.min(1, t)), u: Math.max(0, Math.min(1, u)) } : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** This function gives the clusters of the values by a 1D k-means with `k` centres, then joins the centres nearer than `merge`. The result gives the centre of each value. */
|
|
55
|
+
export function clusters1d(values: readonly number[], k: number, merge: number): number[] {
|
|
56
|
+
const n = values.length;
|
|
57
|
+
if (n === 0) return [];
|
|
58
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
59
|
+
const count = Math.max(1, Math.min(n, k));
|
|
60
|
+
let centres = Array.from({ length: count }, (_, i) => sorted[Math.min(n - 1, Math.floor(((i + 0.5) * n) / count))]!);
|
|
61
|
+
const nearest = (v: number): number => {
|
|
62
|
+
let best = 0;
|
|
63
|
+
for (let i = 1; i < centres.length; i++) if (Math.abs(centres[i]! - v) < Math.abs(centres[best]! - v)) best = i;
|
|
64
|
+
return best;
|
|
65
|
+
};
|
|
66
|
+
for (let it = 0; it < 20; it++) {
|
|
67
|
+
const sum = centres.map(() => 0);
|
|
68
|
+
const num = centres.map(() => 0);
|
|
69
|
+
for (const v of values) {
|
|
70
|
+
const i = nearest(v);
|
|
71
|
+
sum[i] = sum[i]! + v;
|
|
72
|
+
num[i] = num[i]! + 1;
|
|
73
|
+
}
|
|
74
|
+
const next = centres.map((c, i) => (num[i]! > 0 ? sum[i]! / num[i]! : c));
|
|
75
|
+
const moved = next.some((c, i) => Math.abs(c - centres[i]!) > 1e-9);
|
|
76
|
+
centres = next;
|
|
77
|
+
if (!moved) break;
|
|
78
|
+
}
|
|
79
|
+
// The join of the near centres: the weighted mean of each run of centres closer than `merge`.
|
|
80
|
+
const weight = centres.map(() => 0);
|
|
81
|
+
for (const v of values) {
|
|
82
|
+
const i = nearest(v);
|
|
83
|
+
weight[i] = weight[i]! + 1;
|
|
84
|
+
}
|
|
85
|
+
const order = centres
|
|
86
|
+
.map((c, i) => ({ c, w: weight[i]! }))
|
|
87
|
+
.filter((x) => x.w > 0)
|
|
88
|
+
.sort((p, q) => p.c - q.c);
|
|
89
|
+
const joined: { c: number; w: number }[] = [];
|
|
90
|
+
for (const x of order) {
|
|
91
|
+
const last = joined[joined.length - 1];
|
|
92
|
+
if (last && x.c - last.c < merge) {
|
|
93
|
+
const w = last.w + x.w;
|
|
94
|
+
last.c = (last.c * last.w + x.c * x.w) / w;
|
|
95
|
+
last.w = w;
|
|
96
|
+
} else joined.push({ ...x });
|
|
97
|
+
}
|
|
98
|
+
centres = joined.map((x) => x.c);
|
|
99
|
+
return values.map((v) => centres[nearest(v)]!);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One cell of the mesh: its place in the grid, the histogram of the angles of its lines, and the lines through it. */
|
|
103
|
+
interface MeshCell {
|
|
104
|
+
readonly cx: number;
|
|
105
|
+
readonly cy: number;
|
|
106
|
+
readonly bins: number[];
|
|
107
|
+
/** The sum of the angles of the lines in each bin. */
|
|
108
|
+
readonly angles: number[];
|
|
109
|
+
readonly lines: Set<number>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* This function gives the waypoints of the control mesh (HWY-004, GBEC of Cui, Zhou, Qu, Wong and Li 2008).
|
|
114
|
+
* A grid of cells covers the board. The main direction of each cell is the peak of a histogram of the angles of the straight lines through it.
|
|
115
|
+
* The segment of the mesh in a cell goes through its middle, across that direction. The crossings of the lines with the segment form clusters by a 1D k-means.
|
|
116
|
+
* The centre of the cluster of each crossing is a waypoint of its edge. The waypoints of an edge are in the order of the edge.
|
|
117
|
+
*/
|
|
118
|
+
export function meshWaypoints(input: HighwayInput, finder: WayFinder, o: Readonly<MeshOptions>): Map<string, Point[]> {
|
|
119
|
+
const plugs = edgePlugs(input.graph);
|
|
120
|
+
const out = new Map<string, Point[]>();
|
|
121
|
+
if (plugs.length === 0) return out;
|
|
122
|
+
const unit = boxUnit(input.graph);
|
|
123
|
+
const side = Math.max(1e-3, o.cell * unit);
|
|
124
|
+
const lines = plugs.map(({ ps, pt }) => ({ a: finder.out(ps), b: finder.out(pt) }));
|
|
125
|
+
let x0 = Infinity;
|
|
126
|
+
let y0 = Infinity;
|
|
127
|
+
for (const l of lines) {
|
|
128
|
+
x0 = Math.min(x0, l.a.x, l.b.x);
|
|
129
|
+
y0 = Math.min(y0, l.a.y, l.b.y);
|
|
130
|
+
}
|
|
131
|
+
const cells = new Map<string, MeshCell>();
|
|
132
|
+
lines.forEach((l, e) => {
|
|
133
|
+
const len = dist(l.a, l.b);
|
|
134
|
+
if (len <= 1e-9) return;
|
|
135
|
+
const ang = ((Math.atan2(l.b.y - l.a.y, l.b.x - l.a.x) % Math.PI) + Math.PI) % Math.PI;
|
|
136
|
+
const bin = Math.min(BINS - 1, Math.floor((ang / Math.PI) * BINS));
|
|
137
|
+
const steps = Math.max(1, Math.ceil((2 * len) / side));
|
|
138
|
+
for (let s = 0; s <= steps; s++) {
|
|
139
|
+
const u = s / steps;
|
|
140
|
+
const cx = Math.floor((l.a.x + (l.b.x - l.a.x) * u - x0) / side);
|
|
141
|
+
const cy = Math.floor((l.a.y + (l.b.y - l.a.y) * u - y0) / side);
|
|
142
|
+
const id = `${cx},${cy}`;
|
|
143
|
+
let cell = cells.get(id);
|
|
144
|
+
if (!cell) {
|
|
145
|
+
cell = { cx, cy, bins: new Array<number>(BINS).fill(0), angles: new Array<number>(BINS).fill(0), lines: new Set() };
|
|
146
|
+
cells.set(id, cell);
|
|
147
|
+
}
|
|
148
|
+
if (!cell.lines.has(e)) {
|
|
149
|
+
cell.lines.add(e);
|
|
150
|
+
cell.bins[bin] = cell.bins[bin]! + 1;
|
|
151
|
+
cell.angles[bin] = cell.angles[bin]! + ang;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
const capacity = Math.max(1, o.capacity);
|
|
156
|
+
const maxAngle = (Math.max(0, Math.min(90, o.angle)) * Math.PI) / 180;
|
|
157
|
+
const marks: { t: number; p: Point }[][] = lines.map(() => []);
|
|
158
|
+
for (const id of [...cells.keys()].sort()) {
|
|
159
|
+
const cell = cells.get(id)!;
|
|
160
|
+
if (cell.lines.size < 2) continue;
|
|
161
|
+
let peak = 0;
|
|
162
|
+
for (let i = 1; i < BINS; i++) if (cell.bins[i]! > cell.bins[peak]!) peak = i;
|
|
163
|
+
// The main direction is the mean angle of the lines in the peak bin.
|
|
164
|
+
const theta = cell.angles[peak]! / cell.bins[peak]!;
|
|
165
|
+
const mid = { x: x0 + (cell.cx + 0.5) * side, y: y0 + (cell.cy + 0.5) * side };
|
|
166
|
+
const across = { x: -Math.sin(theta), y: Math.cos(theta) };
|
|
167
|
+
// The segment goes from side to side of the cell.
|
|
168
|
+
const half = side / 2 / Math.max(Math.abs(across.x), Math.abs(across.y));
|
|
169
|
+
const c = { x: mid.x - across.x * half, y: mid.y - across.y * half };
|
|
170
|
+
const d = { x: mid.x + across.x * half, y: mid.y + across.y * half };
|
|
171
|
+
const hits: { e: number; t: number; u: number }[] = [];
|
|
172
|
+
for (const e of [...cell.lines].sort((p, q) => p - q)) {
|
|
173
|
+
const l = lines[e]!;
|
|
174
|
+
const len = dist(l.a, l.b);
|
|
175
|
+
const cos = Math.abs(((l.b.x - l.a.x) * Math.cos(theta) + (l.b.y - l.a.y) * Math.sin(theta)) / len);
|
|
176
|
+
if (Math.acos(Math.min(1, cos)) > maxAngle + 1e-9) continue;
|
|
177
|
+
const x = crossing(l.a, l.b, c, d);
|
|
178
|
+
if (x) hits.push({ e, t: x.t, u: x.u });
|
|
179
|
+
}
|
|
180
|
+
if (hits.length < 2) continue;
|
|
181
|
+
const centres = clusters1d(
|
|
182
|
+
hits.map((h) => h.u * 2 * half),
|
|
183
|
+
Math.ceil(hits.length / capacity),
|
|
184
|
+
Math.max(0, o.merge) * unit,
|
|
185
|
+
);
|
|
186
|
+
const size = new Map<number, number>();
|
|
187
|
+
for (const v of centres) size.set(v, (size.get(v) ?? 0) + 1);
|
|
188
|
+
hits.forEach((h, i) => {
|
|
189
|
+
const v = centres[i]!;
|
|
190
|
+
if ((size.get(v) ?? 0) < 2) return;
|
|
191
|
+
marks[h.e]!.push({ t: h.t, p: { x: c.x + across.x * v, y: c.y + across.y * v } });
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
plugs.forEach(({ edge }, e) => {
|
|
195
|
+
const list = marks[e]!.sort((p, q) => p.t - q.t).map((m) => m.p);
|
|
196
|
+
if (list.length > 0) out.set(edge.id, list);
|
|
197
|
+
});
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* This function gives the highways of the control mesh (HWY-004). Each edge goes through the centres of its clusters on the mesh, along the corner graph.
|
|
203
|
+
* The centres of one cluster are one point, so the edges of a cluster share the ways between them. The stretches that `minSize` ways share become highways.
|
|
204
|
+
*/
|
|
205
|
+
export function meshPlan(input: HighwayInput, o: Readonly<MeshOptions>): HighwayPlan {
|
|
206
|
+
const finder = new WayFinder(input.graph.nodes, { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) });
|
|
207
|
+
const waypoints = meshWaypoints(input, finder, o);
|
|
208
|
+
return tracePlan(input, finder, waypoints, { snap: 0, minSize: o.minSize, key: "mesh" });
|
|
209
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { dist, type Point } from "../../geom.ts";
|
|
2
|
+
import { groupedPlan, type EdgeGroup, type GroupSpine } from "../grouped.ts";
|
|
3
|
+
import type { HighwayInput, HighwayPlan } from "../model.ts";
|
|
4
|
+
import { edgePlugs } from "../stretches.ts";
|
|
5
|
+
import { meetingPoints } from "../weiszfeld/index.ts";
|
|
6
|
+
|
|
7
|
+
/** The options of the highway strategy `mingle` (HWY-003 of the note on highways). */
|
|
8
|
+
export interface MingleOptions {
|
|
9
|
+
/** Each bundle looks for a merge among this number of nearest bundles in 4D. The default is 8. */
|
|
10
|
+
readonly k: number;
|
|
11
|
+
/** The largest turn of an edge at a meeting point, in degrees. A larger turn moves the meeting point along the trunk. The default is 40. */
|
|
12
|
+
readonly maxTurn: number;
|
|
13
|
+
/** The number of levels of merges. The default is 6. */
|
|
14
|
+
readonly levels: number;
|
|
15
|
+
/** The weight of the trunk in the ink. With 1, the trunk costs its length one time. The default is 1. */
|
|
16
|
+
readonly weight: number;
|
|
17
|
+
/** A bundle with this number of edges or more gets highways. The default is 2. */
|
|
18
|
+
readonly minSize: number;
|
|
19
|
+
/** The spine of each bundle. The default is `trunk`: one trunk between the two meeting points. */
|
|
20
|
+
readonly spine: GroupSpine;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The default options of the strategy `mingle`. */
|
|
24
|
+
export const MINGLE_DEFAULTS: Readonly<MingleOptions> = Object.freeze({ k: 8, maxTurn: 40, levels: 6, weight: 1, minSize: 2, spine: "trunk" });
|
|
25
|
+
|
|
26
|
+
/** One bundle of MINGLE: its edges with their direction, the ends on each side, the two meeting points and the ink. */
|
|
27
|
+
interface Bundle {
|
|
28
|
+
readonly edges: readonly { readonly edge: string; readonly flip: boolean }[];
|
|
29
|
+
readonly a: readonly Point[];
|
|
30
|
+
readonly b: readonly Point[];
|
|
31
|
+
readonly m1: Point;
|
|
32
|
+
readonly m2: Point;
|
|
33
|
+
readonly ink: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The number of steps of Weiszfeld for the meeting points of a bundle. */
|
|
37
|
+
const STEPS = 12;
|
|
38
|
+
|
|
39
|
+
/** This function gives the turn in degrees from the direction `p` to `q` to the direction `q` to `r`. A leg of length 0 gives no turn. */
|
|
40
|
+
function turnAt(p: Point, q: Point, r: Point): number {
|
|
41
|
+
const ux = q.x - p.x;
|
|
42
|
+
const uy = q.y - p.y;
|
|
43
|
+
const vx = r.x - q.x;
|
|
44
|
+
const vy = r.y - q.y;
|
|
45
|
+
if (Math.hypot(ux, uy) < 1e-9 || Math.hypot(vx, vy) < 1e-9) return 0;
|
|
46
|
+
return (Math.abs(Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy)) * 180) / Math.PI;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The number of steps of the bisection that moves a meeting point to the turn limit. */
|
|
50
|
+
const SLIDE_STEPS = 24;
|
|
51
|
+
|
|
52
|
+
/** This function gives the point at the share `t` of the way from `p` to `q`. */
|
|
53
|
+
function lerp(p: Point, q: Point, t: number): Point {
|
|
54
|
+
return { x: p.x + t * (q.x - p.x), y: p.y + t * (q.y - p.y) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* This function moves the meeting point `m` along the trunk to the meeting point `far`. It stops when each end of `ends` turns at most `maxTurn` degrees at `m`.
|
|
59
|
+
* The turn of each end gets smaller when `m` moves to `far`, so a bisection finds the smallest move. The point stays at most at the middle of the trunk.
|
|
60
|
+
* The function gives `undefined` when the middle of the trunk does not obey the limit.
|
|
61
|
+
*/
|
|
62
|
+
function slide(m: Point, far: Point, ends: readonly Point[], maxTurn: number): Point | undefined {
|
|
63
|
+
const worst = (p: Point): number => Math.max(...ends.map((e) => turnAt(e, p, far)));
|
|
64
|
+
if (worst(m) <= maxTurn) return m;
|
|
65
|
+
if (worst(lerp(m, far, 0.5)) > maxTurn) return undefined;
|
|
66
|
+
let lo = 0;
|
|
67
|
+
let hi = 0.5;
|
|
68
|
+
for (let s = 0; s < SLIDE_STEPS; s++) {
|
|
69
|
+
const mid = (lo + hi) / 2;
|
|
70
|
+
if (worst(lerp(m, far, mid)) <= maxTurn) hi = mid;
|
|
71
|
+
else lo = mid;
|
|
72
|
+
}
|
|
73
|
+
return lerp(m, far, hi);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* This function gives the bundle of the ends `a` and `b`, with its meeting points and its ink. A turn that is too large gives `undefined`.
|
|
78
|
+
* The ink is `Σ |a_i - M1| + w |M1 - M2| + Σ |M2 - b_i|`. Each edge turns at most `maxTurn` degrees at `M1` and at `M2`.
|
|
79
|
+
* As in MINGLE, a meeting point with a larger turn moves along the trunk to the other meeting point, until the turns obey the limit.
|
|
80
|
+
*/
|
|
81
|
+
function bundleOf(edges: Bundle["edges"], a: readonly Point[], b: readonly Point[], o: Readonly<MingleOptions>): Bundle | undefined {
|
|
82
|
+
const [w1, w2] = meetingPoints(a, b, o.weight, STEPS);
|
|
83
|
+
if (dist(w1, w2) < 1e-6) return undefined;
|
|
84
|
+
const m1 = slide(w1, w2, a, o.maxTurn);
|
|
85
|
+
const m2 = slide(w2, w1, b, o.maxTurn);
|
|
86
|
+
if (!m1 || !m2) return undefined;
|
|
87
|
+
let ink = o.weight * dist(m1, m2);
|
|
88
|
+
for (let i = 0; i < a.length; i++) ink += dist(a[i]!, m1) + dist(m2, b[i]!);
|
|
89
|
+
return { edges, a, b, m1, m2, ink };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** This function gives the bundle of `p` and `q`, with `q` turned around when `flip` is `true`. */
|
|
93
|
+
function merged(p: Bundle, q: Bundle, flip: boolean, o: Readonly<MingleOptions>): Bundle | undefined {
|
|
94
|
+
const edges = [...p.edges, ...q.edges.map((e) => ({ edge: e.edge, flip: e.flip !== flip }))];
|
|
95
|
+
return bundleOf(edges, [...p.a, ...(flip ? q.b : q.a)], [...p.b, ...(flip ? q.a : q.b)], o);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** This function gives the 4D distance of two bundles by their meeting points, and `flip` when the opposite direction is nearer. */
|
|
99
|
+
function distance4(p: Bundle, q: Bundle): { d: number; flip: boolean } {
|
|
100
|
+
const direct = Math.hypot(dist(p.m1, q.m1), dist(p.m2, q.m2));
|
|
101
|
+
const back = Math.hypot(dist(p.m1, q.m2), dist(p.m2, q.m1));
|
|
102
|
+
return back < direct ? { d: back, flip: true } : { d: direct, flip: false };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* This function gives the bundles of MINGLE (Gansner, Hu, North and Scheidegger 2011) as groups of edges.
|
|
107
|
+
* Each edge is a point in 4D: its two plugs. At each level, each bundle examines its `k` nearest bundles in 4D.
|
|
108
|
+
* A merge saves ink when the ink of the joint bundle is less than the sum of the two inks. The merges with the largest gain go first.
|
|
109
|
+
* A bundle merges one time at most in a level. The levels stop after `levels` levels, or when no merge saves ink.
|
|
110
|
+
*/
|
|
111
|
+
export function mingleBundles(input: HighwayInput, o: Readonly<MingleOptions>): Map<string, EdgeGroup> {
|
|
112
|
+
let bundles: Bundle[] = edgePlugs(input.graph).map(({ edge, ps, pt }) => ({
|
|
113
|
+
edges: [{ edge: edge.id, flip: false }],
|
|
114
|
+
a: [ps.point],
|
|
115
|
+
b: [pt.point],
|
|
116
|
+
m1: ps.point,
|
|
117
|
+
m2: pt.point,
|
|
118
|
+
ink: dist(ps.point, pt.point),
|
|
119
|
+
}));
|
|
120
|
+
const k = Math.max(1, Math.round(o.k));
|
|
121
|
+
for (let level = 0; level < Math.round(o.levels); level++) {
|
|
122
|
+
const offers: { i: number; j: number; gain: number; joint: Bundle }[] = [];
|
|
123
|
+
const tried = new Set<number>();
|
|
124
|
+
const n = bundles.length;
|
|
125
|
+
bundles.forEach((p, i) => {
|
|
126
|
+
const near = bundles
|
|
127
|
+
.map((q, j) => ({ j, ...distance4(p, q) }))
|
|
128
|
+
.filter((x) => x.j !== i)
|
|
129
|
+
.sort((x, y) => x.d - y.d || x.j - y.j)
|
|
130
|
+
.slice(0, k);
|
|
131
|
+
for (const { j, flip } of near) {
|
|
132
|
+
const lo = Math.min(i, j);
|
|
133
|
+
const hi = Math.max(i, j);
|
|
134
|
+
if (tried.has(lo * n + hi)) continue;
|
|
135
|
+
tried.add(lo * n + hi);
|
|
136
|
+
const joint = merged(bundles[lo]!, bundles[hi]!, flip, o);
|
|
137
|
+
if (!joint) continue;
|
|
138
|
+
const gain = bundles[lo]!.ink + bundles[hi]!.ink - joint.ink;
|
|
139
|
+
if (gain > 1e-9) offers.push({ i: lo, j: hi, gain, joint });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
if (offers.length === 0) break;
|
|
143
|
+
offers.sort((x, y) => y.gain - x.gain || x.i - y.i || x.j - y.j);
|
|
144
|
+
const used = new Set<number>();
|
|
145
|
+
const next: Bundle[] = [];
|
|
146
|
+
for (const x of offers) {
|
|
147
|
+
if (used.has(x.i) || used.has(x.j)) continue;
|
|
148
|
+
used.add(x.i);
|
|
149
|
+
used.add(x.j);
|
|
150
|
+
next.push(x.joint);
|
|
151
|
+
}
|
|
152
|
+
bundles.forEach((p, i) => {
|
|
153
|
+
if (!used.has(i)) next.push(p);
|
|
154
|
+
});
|
|
155
|
+
bundles = next;
|
|
156
|
+
}
|
|
157
|
+
const groups = new Map<string, EdgeGroup>();
|
|
158
|
+
bundles.forEach((p, i) => {
|
|
159
|
+
for (const e of p.edges) groups.set(e.edge, { key: `m${i}`, flip: e.flip });
|
|
160
|
+
});
|
|
161
|
+
return groups;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* This function gives the highways of MINGLE (HWY-003). The bundles of `mingleBundles` with `minSize` edges or more get highways.
|
|
166
|
+
* With the spine `trunk`, each bundle gets one trunk between its two meeting points, through the corner graph.
|
|
167
|
+
* With `shared`, the edges of each bundle share their ways through the corner graph.
|
|
168
|
+
*/
|
|
169
|
+
export function minglePlan(input: HighwayInput, o: Readonly<MingleOptions>): HighwayPlan {
|
|
170
|
+
return groupedPlan(input, mingleBundles(input, o), { spine: o.spine, minSize: o.minSize });
|
|
171
|
+
}
|