@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,189 @@
|
|
|
1
|
+
import { channelWidth } from "../../channels.ts";
|
|
2
|
+
import { dist, polylineLength, type Point } from "../../geom.ts";
|
|
3
|
+
import type { Graph } from "../../model.ts";
|
|
4
|
+
import { WayFinder } from "../../ways.ts";
|
|
5
|
+
import { basePlan, nearestPlace, tidyPlan, type HighwayBase } from "../base.ts";
|
|
6
|
+
import { memberAt, pointAt, type Highway, type HighwayInput, type HighwayMember, type HighwayPlan } from "../model.ts";
|
|
7
|
+
|
|
8
|
+
/** The options of the highway strategy `lanes` (HWY-051 of the note on highways). */
|
|
9
|
+
export interface LanesOptions {
|
|
10
|
+
/** The distance from a box to the corners of the graph. The default is 5. */
|
|
11
|
+
readonly margin?: number;
|
|
12
|
+
/** The number of visible neighbours of each corner. The default is 8. */
|
|
13
|
+
readonly k?: number;
|
|
14
|
+
/** The plan that the strategy changes. The default is `shared-ways`. */
|
|
15
|
+
readonly from: HighwayBase;
|
|
16
|
+
/** The gap between two lanes. The default is 4. */
|
|
17
|
+
readonly laneGap: number;
|
|
18
|
+
/** The largest width of a highway. The default is 32. */
|
|
19
|
+
readonly maxWidth: number;
|
|
20
|
+
/** The number of times that a highway can split, one half after the other. The default is 3. */
|
|
21
|
+
readonly depth: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The default options of the strategy `lanes`. */
|
|
25
|
+
export const LANES_DEFAULTS: Readonly<LanesOptions> = Object.freeze({ from: "shared-ways", laneGap: 4, maxWidth: 32, depth: 3 });
|
|
26
|
+
|
|
27
|
+
/** The cost factor of a link that is too narrow for the lanes, for the way of the second half. */
|
|
28
|
+
const NARROW_COST = 8;
|
|
29
|
+
|
|
30
|
+
/** This function gives the number of lanes of a highway on each leg of its spine. A lane is a member whose part on the spine holds the leg. */
|
|
31
|
+
function lanesOnLegs(h: Highway): number[] {
|
|
32
|
+
const legs = new Array<number>(Math.max(0, h.spine.length - 1)).fill(0);
|
|
33
|
+
for (const m of h.members) {
|
|
34
|
+
const lo = Math.min(m.on, m.off);
|
|
35
|
+
const hi = Math.max(m.on, m.off);
|
|
36
|
+
for (let i = 0; i < legs.length; i++) if (lo < i + 1 && hi > i) legs[i]!++;
|
|
37
|
+
}
|
|
38
|
+
return legs;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** This function gives the width of `n` lanes. */
|
|
42
|
+
const widthOf = (n: number, o: Readonly<LanesOptions>): number => Math.min(o.maxWidth, n * o.laneGap);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* This function gives the number of legs of the spines of a plan where the lanes do not fit in the channel.
|
|
46
|
+
* A leg with two lanes or more needs `min(maxWidth, lanes × laneGap)`. The channel is the free space across the leg (`channelWidth`).
|
|
47
|
+
*/
|
|
48
|
+
export function channelOverflows(graph: Graph, plan: HighwayPlan, o: Readonly<Pick<LanesOptions, "laneGap" | "maxWidth" | "margin" | "k">>): number {
|
|
49
|
+
const finder = new WayFinder(graph.nodes, { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) });
|
|
50
|
+
const full = { from: "none" as const, depth: 0, ...o };
|
|
51
|
+
let n = 0;
|
|
52
|
+
for (const h of plan.highways) {
|
|
53
|
+
const legs = lanesOnLegs(h);
|
|
54
|
+
legs.forEach((count, i) => {
|
|
55
|
+
if (count > 1 && channelWidth(finder.obstacles, h.spine[i]!, h.spine[i + 1]!) < widthOf(count, full)) n++;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return n;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* This function gives the plan of `from`, with each highway that does not fit in its channels split in two (HWY-051).
|
|
63
|
+
* The lanes split into two halves by lane order. The first half keeps the spine. The second half gets a new spine through the corners, where the narrow links cost more.
|
|
64
|
+
* A highway with fewer than four lanes moves as one to the new spine.
|
|
65
|
+
* A half that still does not fit splits again, up to `depth` times. A new spine needs a shorter length of narrow legs than the old spine.
|
|
66
|
+
*/
|
|
67
|
+
export function lanesPlan(input: HighwayInput, o: Readonly<LanesOptions>): HighwayPlan {
|
|
68
|
+
const plan = basePlan(input, o.from);
|
|
69
|
+
const finder = new WayFinder(input.graph.nodes, { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) });
|
|
70
|
+
const corners = finder.corners;
|
|
71
|
+
const widths = new Map<string, number>();
|
|
72
|
+
const channel = (a: Point, b: Point): number => {
|
|
73
|
+
const key = a.x < b.x || (a.x === b.x && a.y <= b.y) ? `${a.x},${a.y},${b.x},${b.y}` : `${b.x},${b.y},${a.x},${a.y}`;
|
|
74
|
+
let w = widths.get(key);
|
|
75
|
+
if (w === undefined) widths.set(key, (w = channelWidth(finder.obstacles, a, b)));
|
|
76
|
+
return w;
|
|
77
|
+
};
|
|
78
|
+
const narrowLegs = (h: Highway): number[] => {
|
|
79
|
+
const out: number[] = [];
|
|
80
|
+
lanesOnLegs(h).forEach((count, i) => {
|
|
81
|
+
if (count > 1 && channel(h.spine[i]!, h.spine[i + 1]!) < widthOf(count, o)) out.push(i);
|
|
82
|
+
});
|
|
83
|
+
return out;
|
|
84
|
+
};
|
|
85
|
+
// The length of the legs of a spine that are too narrow for `need`.
|
|
86
|
+
const tight = (spine: readonly Point[], need: number): number => spine.reduce((n, p, i) => (i > 0 && channel(spine[i - 1]!, p) < need ? n + dist(spine[i - 1]!, p) : n), 0);
|
|
87
|
+
|
|
88
|
+
const pad = finder.margin / 2;
|
|
89
|
+
const reach = 2 * (o.k ?? 8);
|
|
90
|
+
/**
|
|
91
|
+
* This function gives the cheapest way from `from` to `to` through the corners (Dijkstra). A link narrower than `need` costs `NARROW_COST` times its length.
|
|
92
|
+
* Unlike `corners.path`, it also prices the first link and the last link, and it does not take the straight line without a price.
|
|
93
|
+
*/
|
|
94
|
+
const detour = (from: Point, to: Point, need: number): Point[] | undefined => {
|
|
95
|
+
const pts: Point[] = [from, to];
|
|
96
|
+
const id = (j: number): number => j + 2;
|
|
97
|
+
const pointOf = (n: number): Point => (n < 2 ? pts[n]! : corners.points[n - 2]!);
|
|
98
|
+
const best = new Map<number, number>([[0, 0]]);
|
|
99
|
+
const parent = new Map<number, number>();
|
|
100
|
+
const done = new Set<number>();
|
|
101
|
+
const heap: [number, number][] = [[0, 0]];
|
|
102
|
+
const push = (c: number, n: number): void => {
|
|
103
|
+
heap.push([c, n]);
|
|
104
|
+
for (let i = heap.length - 1; i > 0; ) {
|
|
105
|
+
const up = (i - 1) >> 1;
|
|
106
|
+
if (heap[up]![0] <= heap[i]![0]) break;
|
|
107
|
+
[heap[up], heap[i]] = [heap[i]!, heap[up]!];
|
|
108
|
+
i = up;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const pop = (): [number, number] => {
|
|
112
|
+
const top = heap[0]!;
|
|
113
|
+
const last = heap.pop()!;
|
|
114
|
+
if (heap.length > 0) {
|
|
115
|
+
heap[0] = last;
|
|
116
|
+
for (let i = 0; ; ) {
|
|
117
|
+
const l = 2 * i + 1;
|
|
118
|
+
const r = l + 1;
|
|
119
|
+
let m = i;
|
|
120
|
+
if (l < heap.length && heap[l]![0] < heap[m]![0]) m = l;
|
|
121
|
+
if (r < heap.length && heap[r]![0] < heap[m]![0]) m = r;
|
|
122
|
+
if (m === i) break;
|
|
123
|
+
[heap[m], heap[i]] = [heap[i]!, heap[m]!];
|
|
124
|
+
i = m;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return top;
|
|
128
|
+
};
|
|
129
|
+
while (heap.length > 0 && done.size < 20_000) {
|
|
130
|
+
const [c, n] = pop();
|
|
131
|
+
if (done.has(n)) continue;
|
|
132
|
+
done.add(n);
|
|
133
|
+
if (n === 1) break;
|
|
134
|
+
const p = pointOf(n);
|
|
135
|
+
const next = corners.nearestVisible(p, reach, n >= 2 ? n - 2 : -1).map(id);
|
|
136
|
+
if (finder.obstacles.clear(p, to, pad)) next.push(1);
|
|
137
|
+
for (const m of next) {
|
|
138
|
+
if (done.has(m)) continue;
|
|
139
|
+
const q = pointOf(m);
|
|
140
|
+
const d = dist(p, q);
|
|
141
|
+
const g = c + (channel(p, q) < need ? d * NARROW_COST : d);
|
|
142
|
+
if (g < (best.get(m) ?? Infinity)) {
|
|
143
|
+
best.set(m, g);
|
|
144
|
+
parent.set(m, n);
|
|
145
|
+
push(g, m);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!done.has(1)) return undefined;
|
|
150
|
+
const out: Point[] = [];
|
|
151
|
+
for (let n: number | undefined = 1; n !== undefined; n = parent.get(n)) out.push(pointOf(n));
|
|
152
|
+
return out.reverse();
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// This function moves the members to a new spine. A member whose two places meet on the new spine leaves the highway.
|
|
156
|
+
const move = (h: Highway, spine: readonly Point[], edges: readonly string[]): Highway => {
|
|
157
|
+
const byEdge = new Map(h.members.map((m) => [m.edge, m]));
|
|
158
|
+
const members: HighwayMember[] = [];
|
|
159
|
+
for (const edge of edges) {
|
|
160
|
+
const m = byEdge.get(edge)!;
|
|
161
|
+
const on = nearestPlace(spine, pointAt(h.spine, m.on));
|
|
162
|
+
const off = nearestPlace(spine, pointAt(h.spine, m.off));
|
|
163
|
+
if (Math.abs(on - off) < 1e-6) continue;
|
|
164
|
+
members.push({ ...memberAt(spine, edge, on, off), seq: m.seq });
|
|
165
|
+
}
|
|
166
|
+
return { ...h, spine, lanes: members.map((m) => m.edge), members };
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const split = (h: Highway, depth: number): Highway[] => {
|
|
170
|
+
if (depth <= 0 || narrowLegs(h).length === 0) return [h];
|
|
171
|
+
// A highway with fewer than four lanes cannot give two halves of two lanes. All its lanes move to the new spine.
|
|
172
|
+
const half = h.lanes.length < 4 ? 0 : Math.ceil(h.lanes.length / 2);
|
|
173
|
+
const first = h.lanes.slice(0, half);
|
|
174
|
+
const second = h.lanes.slice(half);
|
|
175
|
+
const need = widthOf(second.length, o);
|
|
176
|
+
const from = h.spine[0]!;
|
|
177
|
+
const to = h.spine[h.spine.length - 1]!;
|
|
178
|
+
const raw = detour(from, to, need);
|
|
179
|
+
if (!raw) return [h];
|
|
180
|
+
const pulled = corners.pull(raw);
|
|
181
|
+
const spine = tight(pulled, need) <= tight(raw, need) ? pulled : raw;
|
|
182
|
+
if (spine.length < 2 || polylineLength(spine) <= finder.margin || tight(spine, need) >= tight(h.spine, need)) return [h];
|
|
183
|
+
const moved = move(h, spine, second);
|
|
184
|
+
if (first.length === 0) return split(moved, depth - 1);
|
|
185
|
+
return [...split(move(h, h.spine, first), depth - 1), ...split(moved, depth - 1)];
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
return tidyPlan({ highways: plan.highways.flatMap((h) => split(h, Math.max(0, Math.round(o.depth)))) });
|
|
189
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { dist, pointBoxDistance, polylineLength, type Point } from "../../geom.ts";
|
|
2
|
+
import { levelLinks, type ItemPair } from "../../levels.ts";
|
|
3
|
+
import { nodeIndex } from "../../model.ts";
|
|
4
|
+
import { buildSections, levelCut } from "../../sections.ts";
|
|
5
|
+
import { WayFinder } from "../../ways.ts";
|
|
6
|
+
import { basePlan, firstRoutes, tidyPlan, type HighwayBase } from "../base.ts";
|
|
7
|
+
import { memberAt, type Highway, type HighwayInput, type HighwayMember, type HighwayPlan } from "../model.ts";
|
|
8
|
+
|
|
9
|
+
/** The options of the highway strategy `level-links` (HWY-056 with WILD-034 of the notes). */
|
|
10
|
+
export interface LevelHighwayOptions {
|
|
11
|
+
/** The plan of the edges that do not ride a level link. The default is `shared-ways`. */
|
|
12
|
+
readonly from: HighwayBase;
|
|
13
|
+
/** The depth of the level in the tree of sections. Depth 1 is the level below the root. The default is 1. */
|
|
14
|
+
readonly depth: number;
|
|
15
|
+
/** A level link becomes a highway when this number of edges or more ride it. The default is 2. */
|
|
16
|
+
readonly minSize: number;
|
|
17
|
+
/** An edge rides the link only when its way along the link is this many times its first route, or shorter. The default is 1.5. */
|
|
18
|
+
readonly stretch: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The default options of the strategy `level-links`. */
|
|
22
|
+
export const LEVEL_HIGHWAY_DEFAULTS: Readonly<LevelHighwayOptions> = Object.freeze({ from: "shared-ways", depth: 1, minSize: 2, stretch: 1.5 });
|
|
23
|
+
|
|
24
|
+
/** This function gives the stretch of a polyline from the length `from` to the length `to`, along the polyline. */
|
|
25
|
+
function cut(p: readonly Point[], from: number, to: number): Point[] {
|
|
26
|
+
const out: Point[] = [];
|
|
27
|
+
let walked = 0;
|
|
28
|
+
for (let i = 1; i < p.length; i++) {
|
|
29
|
+
const a = p[i - 1]!;
|
|
30
|
+
const b = p[i]!;
|
|
31
|
+
const len = dist(a, b);
|
|
32
|
+
const s = Math.max(from, walked);
|
|
33
|
+
const e = Math.min(to, walked + len);
|
|
34
|
+
if (e > s && len > 0) {
|
|
35
|
+
const at = (l: number): Point => ({ x: a.x + ((b.x - a.x) * (l - walked)) / len, y: a.y + ((b.y - a.y) * (l - walked)) / len });
|
|
36
|
+
if (out.length === 0) out.push(at(s));
|
|
37
|
+
out.push(at(e));
|
|
38
|
+
}
|
|
39
|
+
walked += len;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* This function gives a highway for each link of a zoom level, with the edges between its two items (HWY-056, WILD-034).
|
|
46
|
+
* The level is the cut of the tree of sections at `depth`. The link goes around the boxes of the other items (`levelLinks`).
|
|
47
|
+
* The spine is the link without a margin at each end, so it stays off the boxes of the two items. It must stay out of each box.
|
|
48
|
+
* The edges between the two items, in each direction, ride the spine. The ramps go through the corner graph inside the items.
|
|
49
|
+
*
|
|
50
|
+
* An edge rides only when the ramps (as straight lines) and the spine are `stretch` times its first route, or shorter.
|
|
51
|
+
* A link with fewer than `minSize` such edges is not a highway. The other edges keep their highways of the plan `from`.
|
|
52
|
+
* Thus a zoom from the level to the routes shows the same lines, which open into lanes.
|
|
53
|
+
*/
|
|
54
|
+
export function levelHighwayPlan(input: HighwayInput, o: Readonly<LevelHighwayOptions>): HighwayPlan {
|
|
55
|
+
const graph = input.graph;
|
|
56
|
+
const base = basePlan(input, o.from);
|
|
57
|
+
if (graph.nodes.length === 0) return base;
|
|
58
|
+
const byId = nodeIndex(graph);
|
|
59
|
+
const root = buildSections(graph.nodes);
|
|
60
|
+
const { items, owner } = levelCut(graph.nodes, root, Math.max(0, Math.round(o.depth)));
|
|
61
|
+
const indexOfNode = new Map(graph.nodes.map((n, i) => [n.id, i]));
|
|
62
|
+
// The edges between each pair of items, by the pair in the order of the items.
|
|
63
|
+
const between = new Map<string, { a: number; b: number; edges: { edge: string; forward: boolean }[] }>();
|
|
64
|
+
for (const e of graph.edges) {
|
|
65
|
+
const iu = indexOfNode.get(e.source.node);
|
|
66
|
+
const iv = indexOfNode.get(e.target.node);
|
|
67
|
+
if (iu === undefined || iv === undefined) continue;
|
|
68
|
+
const x = owner[iu]!;
|
|
69
|
+
const y = owner[iv]!;
|
|
70
|
+
if (x === y) continue;
|
|
71
|
+
const [a, b] = x < y ? [x, y] : [y, x];
|
|
72
|
+
const key = `${a}\u0000${b}`;
|
|
73
|
+
const pair = between.get(key) ?? { a, b, edges: [] };
|
|
74
|
+
pair.edges.push({ edge: e.id, forward: x === a });
|
|
75
|
+
between.set(key, pair);
|
|
76
|
+
}
|
|
77
|
+
const pairs = [...between.values()].filter((p) => p.edges.length >= Math.max(2, o.minSize));
|
|
78
|
+
if (pairs.length === 0) return base;
|
|
79
|
+
const itemPairs: ItemPair[] = pairs.map((p) => ({ source: items[p.a]!.id, target: items[p.b]!.id, count: p.edges.length }));
|
|
80
|
+
const links = levelLinks(items, itemPairs);
|
|
81
|
+
const linkOf = new Map(links.map((l) => [`${l.source}\u0000${l.target}`, l.points]));
|
|
82
|
+
const ways = new WayFinder(graph.nodes);
|
|
83
|
+
const first = firstRoutes(input);
|
|
84
|
+
const edgeOf = new Map(graph.edges.map((e) => [e.id, e]));
|
|
85
|
+
const added: Highway[] = [];
|
|
86
|
+
const riders = new Set<string>();
|
|
87
|
+
for (const p of pairs) {
|
|
88
|
+
const points = linkOf.get(`${items[p.a]!.id}\u0000${items[p.b]!.id}`);
|
|
89
|
+
if (!points || points.length < 2) continue;
|
|
90
|
+
const length = polylineLength(points);
|
|
91
|
+
if (length <= 4 * ways.margin) continue;
|
|
92
|
+
const spine = cut(points, ways.margin, length - ways.margin);
|
|
93
|
+
if (spine.length < 2 || !ways.obstacles.clearPath(spine, 1)) continue;
|
|
94
|
+
const head = spine[0]!;
|
|
95
|
+
const tail = spine[spine.length - 1]!;
|
|
96
|
+
const along = polylineLength(spine);
|
|
97
|
+
const members: HighwayMember[] = [];
|
|
98
|
+
for (const { edge, forward } of p.edges) {
|
|
99
|
+
const e = edgeOf.get(edge)!;
|
|
100
|
+
const s = byId.get(e.source.node)!;
|
|
101
|
+
const t = byId.get(e.target.node)!;
|
|
102
|
+
const [enter, leave] = forward ? [head, tail] : [tail, head];
|
|
103
|
+
const way = pointBoxDistance(enter, s.box) + along + pointBoxDistance(leave, t.box);
|
|
104
|
+
const own = polylineLength(first.get(edge) ?? []);
|
|
105
|
+
if (own > 0 && way > o.stretch * own) continue;
|
|
106
|
+
members.push(forward ? memberAt(spine, edge, 0, spine.length - 1) : memberAt(spine, edge, spine.length - 1, 0));
|
|
107
|
+
}
|
|
108
|
+
if (members.length < Math.max(2, o.minSize)) continue;
|
|
109
|
+
for (const m of members) riders.add(m.edge);
|
|
110
|
+
added.push({ id: "", key: `level${o.depth}:${items[p.a]!.id}:${items[p.b]!.id}`, spine, lanes: members.map((m) => m.edge), members });
|
|
111
|
+
}
|
|
112
|
+
const rest = base.highways.map((h) => ({ ...h, members: h.members.filter((m) => !riders.has(m.edge)) }));
|
|
113
|
+
return tidyPlan({ highways: [...rest, ...added] });
|
|
114
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { dist, type Point } from "../../geom.ts";
|
|
2
|
+
import { basePlan, firstRoutes, tidyPlan, type HighwayBase } from "../base.ts";
|
|
3
|
+
import { lengthBetween, memberAt, pointAt, type HighwayInput, type HighwayMember, type HighwayPlan } from "../model.ts";
|
|
4
|
+
|
|
5
|
+
/** The options of the highway strategy `meet` (HWY-050 of the note on highways). */
|
|
6
|
+
export interface MeetOptions {
|
|
7
|
+
/** The plan whose merge points and split points the strategy moves. The default is `shared-ways`. */
|
|
8
|
+
readonly from: HighwayBase;
|
|
9
|
+
/** A first route meets the spine where it is this distance from the spine, or nearer. The default is 8: two lanes of 4. */
|
|
10
|
+
readonly near: number;
|
|
11
|
+
/** A first route meets the spine only where it runs at this angle to the spine, or less, in degrees. The default is 45. */
|
|
12
|
+
readonly maxTurn: number;
|
|
13
|
+
/** A member keeps its places when the stretch where its first route meets the spine is shorter than this length. The default is 10. */
|
|
14
|
+
readonly minRun: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The default options of the strategy `meet`. */
|
|
18
|
+
export const MEET_DEFAULTS: Readonly<MeetOptions> = Object.freeze({ from: "shared-ways", near: 8, maxTurn: 45, minRun: 10 });
|
|
19
|
+
|
|
20
|
+
/** This function gives the distance from a point to a polyline, and the unit direction of the nearest leg. */
|
|
21
|
+
function nearestLeg(line: readonly Point[], p: Point): { d: number; ux: number; uy: number } {
|
|
22
|
+
let best = { d: Infinity, ux: 1, uy: 0 };
|
|
23
|
+
for (let i = 1; i < line.length; i++) {
|
|
24
|
+
const a = line[i - 1]!;
|
|
25
|
+
const b = line[i]!;
|
|
26
|
+
const len = dist(a, b);
|
|
27
|
+
if (len <= 1e-9) continue;
|
|
28
|
+
const u = Math.max(0, Math.min(1, ((p.x - a.x) * (b.x - a.x) + (p.y - a.y) * (b.y - a.y)) / (len * len)));
|
|
29
|
+
const d = dist(p, { x: a.x + (b.x - a.x) * u, y: a.y + (b.y - a.y) * u });
|
|
30
|
+
if (d < best.d) best = { d, ux: (b.x - a.x) / len, uy: (b.y - a.y) / len };
|
|
31
|
+
}
|
|
32
|
+
return best;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* This function gives a member with its merge point and its split point where its first route meets the spine and parts from it (HWY-050).
|
|
37
|
+
* The function samples the stretch of the member on the spine, at steps of `near / 2` or less.
|
|
38
|
+
* A sample meets the route when the route is `near` or nearer, and its nearest leg runs at `maxTurn` or less to the spine.
|
|
39
|
+
* The new merge point is the first sample that meets the route, in the direction of the member. The new split point is the last one.
|
|
40
|
+
* So the edge joins the spine late and leaves it early. The places only move inward.
|
|
41
|
+
* The member keeps its places when no sample meets the route, or when the met stretch is shorter than `minRun`.
|
|
42
|
+
*/
|
|
43
|
+
export function meetMember(spine: readonly Point[], member: HighwayMember, route: readonly Point[] | undefined, o: Readonly<MeetOptions>): HighwayMember {
|
|
44
|
+
if (!route || route.length < 2 || spine.length < 2) return member;
|
|
45
|
+
const lo = Math.min(member.on, member.off);
|
|
46
|
+
const hi = Math.max(member.on, member.off);
|
|
47
|
+
const cos = Math.cos((Math.max(0, Math.min(90, o.maxTurn)) * Math.PI) / 180);
|
|
48
|
+
const step = Math.max(1e-3, o.near / 2);
|
|
49
|
+
let first = Infinity;
|
|
50
|
+
let last = -Infinity;
|
|
51
|
+
for (let i = Math.floor(lo); i < Math.min(spine.length - 1, Math.ceil(hi)); i++) {
|
|
52
|
+
const from = Math.max(lo, i);
|
|
53
|
+
const to = Math.min(hi, i + 1);
|
|
54
|
+
if (to <= from) continue;
|
|
55
|
+
const a = spine[i]!;
|
|
56
|
+
const b = spine[i + 1]!;
|
|
57
|
+
const len = dist(a, b);
|
|
58
|
+
if (len <= 1e-9) continue;
|
|
59
|
+
const tx = (b.x - a.x) / len;
|
|
60
|
+
const ty = (b.y - a.y) / len;
|
|
61
|
+
const n = Math.min(256, Math.max(1, Math.ceil(((to - from) * len) / step)));
|
|
62
|
+
for (let j = 0; j <= n; j++) {
|
|
63
|
+
const at = from + ((to - from) * j) / n;
|
|
64
|
+
const leg = nearestLeg(route, pointAt(spine, at));
|
|
65
|
+
if (leg.d > o.near || Math.abs(leg.ux * tx + leg.uy * ty) < cos - 1e-12) continue;
|
|
66
|
+
first = Math.min(first, at);
|
|
67
|
+
last = Math.max(last, at);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!(last > first) || lengthBetween(spine, first, last) < o.minRun) return member;
|
|
71
|
+
return member.on <= member.off ? memberAt(spine, member.edge, first, last, member.seq) : memberAt(spine, member.edge, last, first, member.seq);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* This function gives the highways of the plan `from`, with merge points and split points where the routes meet and part (HWY-050).
|
|
76
|
+
* The first route of each edge is the route of `input.routes`, or its route without highways.
|
|
77
|
+
* Each member gets the places of `meetMember`. The spines and the trips do not change.
|
|
78
|
+
*/
|
|
79
|
+
export function meetPlan(input: HighwayInput, o: Readonly<MeetOptions>): HighwayPlan {
|
|
80
|
+
const plan = basePlan(input, o.from);
|
|
81
|
+
if (plan.highways.length === 0) return plan;
|
|
82
|
+
const first = firstRoutes(input);
|
|
83
|
+
return tidyPlan({ highways: plan.highways.map((h) => ({ ...h, members: h.members.map((m) => meetMember(h.spine, m, first.get(m.edge), o)) })) });
|
|
84
|
+
}
|
|
@@ -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
|
+
}
|
package/src/highways/model.ts
CHANGED
|
@@ -9,6 +9,11 @@ export interface HighwayInput {
|
|
|
9
9
|
* The strategy `corridors` makes the ways of the corner graph, as the router `corners` finds them.
|
|
10
10
|
*/
|
|
11
11
|
readonly routes?: Result;
|
|
12
|
+
/**
|
|
13
|
+
* This value is the graph before an edit and the plan of the same strategy on it. A strategy that keeps its highways stable over edits reads it.
|
|
14
|
+
* The gates `hysteresis` (HWY-046) and `ids` (HWY-054) read it. The other strategies ignore it.
|
|
15
|
+
*/
|
|
16
|
+
readonly previous?: { readonly graph: Graph; readonly plan: HighwayPlan };
|
|
12
17
|
}
|
|
13
18
|
|
|
14
19
|
/**
|