@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,205 @@
|
|
|
1
|
+
import { center, dist, type Point } from "../../geom.ts";
|
|
2
|
+
import { nodeIndex, type Node } from "../../model.ts";
|
|
3
|
+
import { orderLanes } from "../../segments.ts";
|
|
4
|
+
import { WayFinder, type WayOptions } from "../../ways.ts";
|
|
5
|
+
import { tidyPlan } from "../base.ts";
|
|
6
|
+
import { memberAt, type Highway, type HighwayInput, type HighwayMember, type HighwayPlan } from "../model.ts";
|
|
7
|
+
import { edgePlugs } from "../stretches.ts";
|
|
8
|
+
|
|
9
|
+
/** The options of the highway strategy `spiral` (HWY-017 with EDA-035 of the notes). */
|
|
10
|
+
export interface SpiralOptions extends Pick<WayOptions, "margin" | "k"> {
|
|
11
|
+
/** The spiral angle, in degrees: the largest angle between a branch and the line to the root. The default is 25. */
|
|
12
|
+
readonly alpha: number;
|
|
13
|
+
/** A node with this number of edges or more gets a spiral tree. The default is 4. */
|
|
14
|
+
readonly minTargets: number;
|
|
15
|
+
/** The number of points of the arc of each branch. The default is 4. */
|
|
16
|
+
readonly arc: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The default options of the strategy `spiral`. */
|
|
20
|
+
export const SPIRAL_DEFAULTS: Readonly<Required<Omit<SpiralOptions, "margin" | "k">>> = Object.freeze({ alpha: 25, minTargets: 4, arc: 4 });
|
|
21
|
+
|
|
22
|
+
/** One node of a spiral tree, in polar coordinates around the root. A leaf has an edge, and a join has two children. */
|
|
23
|
+
export interface SpiralNode {
|
|
24
|
+
readonly r: number;
|
|
25
|
+
readonly theta: number;
|
|
26
|
+
readonly point: Point;
|
|
27
|
+
/** A join with this node is this distance or more nearer to the root than the node. A leaf keeps its box clear so. A join has 0. */
|
|
28
|
+
readonly clear: number;
|
|
29
|
+
readonly edge?: string;
|
|
30
|
+
readonly children: readonly SpiralNode[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const TAU = 2 * Math.PI;
|
|
34
|
+
|
|
35
|
+
/** This function gives the angle `t` in the range from 0 to 2 pi. */
|
|
36
|
+
const wrap = (t: number): number => ((t % TAU) + TAU) % TAU;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* This function gives the join of the nodes `p` and `q` of a spiral tree with the angle `alpha`. It gives `undefined` when no join is possible.
|
|
40
|
+
* Each node can reach the points inside its two spirals toward the root: `|theta - theta_p| <= tan(alpha) ln(r_p / r)`.
|
|
41
|
+
* The join is the point farthest from the root that both nodes reach, and that is `clear` nearer to the root than each node.
|
|
42
|
+
* At that radius, the join is at the middle of the angles that both nodes reach.
|
|
43
|
+
*/
|
|
44
|
+
export function spiralJoin(p: SpiralNode, q: SpiralNode, alpha: number, root: Point): { r: number; theta: number; point: Point } | undefined {
|
|
45
|
+
const t = Math.tan(alpha);
|
|
46
|
+
// The angle from p to q, counterclockwise, in the range from -pi to pi.
|
|
47
|
+
let delta = wrap(q.theta - p.theta);
|
|
48
|
+
if (delta > Math.PI) delta -= TAU;
|
|
49
|
+
const gap = Math.abs(delta);
|
|
50
|
+
if (gap >= Math.PI || t <= 0) return undefined;
|
|
51
|
+
const near = p.r <= q.r ? p : q;
|
|
52
|
+
const far = p.r <= q.r ? q : p;
|
|
53
|
+
const meet = t * Math.log(far.r / near.r) >= gap ? near.r : Math.exp((Math.log(p.r) + Math.log(q.r) - gap / t) / 2);
|
|
54
|
+
const r = Math.min(meet, p.r - p.clear, q.r - q.clear);
|
|
55
|
+
if (!(r > 0)) return undefined;
|
|
56
|
+
// In the frame where p is at 0 and q is at `gap`, p reaches [-rp, rp] and q reaches [gap - rq, gap + rq].
|
|
57
|
+
const rp = t * Math.log(p.r / r);
|
|
58
|
+
const rq = t * Math.log(q.r / r);
|
|
59
|
+
const lo = Math.max(-rp, gap - rq);
|
|
60
|
+
const hi = Math.min(rp, gap + rq);
|
|
61
|
+
if (lo > hi + 1e-9) return undefined;
|
|
62
|
+
const theta = wrap(p.theta + Math.sign(delta) * ((lo + hi) / 2));
|
|
63
|
+
return { r, theta, point: { x: root.x + r * Math.cos(theta), y: root.y + r * Math.sin(theta) } };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* This function gives the spiral tree of the leaves around the root (Buchin, Speckmann and Verbeek 2011), with the greedy sweep.
|
|
68
|
+
* The sweep goes from the far leaves to the root. A leaf joins the wavefront at its radius. Two nodes that are next to each other in angle join at their join point.
|
|
69
|
+
* The join with the largest radius goes first. No join occurs below the radius `rMin`. The function gives the nodes of the wavefront at the end.
|
|
70
|
+
*/
|
|
71
|
+
export function spiralTree(leaves: readonly SpiralNode[], alpha: number, root: Point, rMin: number): SpiralNode[] {
|
|
72
|
+
const waiting = [...leaves].sort((a, b) => b.r - a.r || (a.edge! < b.edge! ? -1 : 1));
|
|
73
|
+
const front: SpiralNode[] = [];
|
|
74
|
+
const insert = (n: SpiralNode): void => {
|
|
75
|
+
let i = 0;
|
|
76
|
+
while (i < front.length && front[i]!.theta <= n.theta) i++;
|
|
77
|
+
front.splice(i, 0, n);
|
|
78
|
+
};
|
|
79
|
+
for (;;) {
|
|
80
|
+
let best: { i: number; r: number; theta: number; point: Point } | undefined;
|
|
81
|
+
if (front.length >= 2)
|
|
82
|
+
for (let i = 0; i < front.length; i++) {
|
|
83
|
+
if (front.length === 2 && i === 1) break;
|
|
84
|
+
const j = (i + 1) % front.length;
|
|
85
|
+
const join = spiralJoin(front[i]!, front[j]!, alpha, root);
|
|
86
|
+
if (join && join.r >= rMin && (!best || join.r > best.r)) best = { i, ...join };
|
|
87
|
+
}
|
|
88
|
+
const next = waiting[0];
|
|
89
|
+
if (next && (!best || next.r >= best.r)) {
|
|
90
|
+
insert(waiting.shift()!);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!best) return front;
|
|
94
|
+
const i = best.i;
|
|
95
|
+
const j = (i + 1) % front.length;
|
|
96
|
+
const node: SpiralNode = { r: best.r, theta: best.theta, point: best.point, clear: 0, children: [front[i]!, front[j]!] };
|
|
97
|
+
for (const k of [i, j].sort((a, b) => b - a)) front.splice(k, 1);
|
|
98
|
+
insert(node);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** This function gives the leaves under a node of a spiral tree, in the order of the tree. */
|
|
103
|
+
function leavesOf(n: SpiralNode): string[] {
|
|
104
|
+
return n.edge !== undefined ? [n.edge] : n.children.flatMap(leavesOf);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* This function gives the highways of the spiral trees (HWY-017, EDA-035). Each node with `minTargets` edges or more is the root of a fan.
|
|
109
|
+
* The nodes with more edges go first, and each edge is in one fan at most. The far plug of each edge is a leaf of the tree of its fan.
|
|
110
|
+
* Each branch of a tree with two leaves or more is a highway. Its spine is an arc of a logarithmic spiral, or a way through the corner graph where the arc hits a box.
|
|
111
|
+
* An edge rides the branches from the root to its leaf, or from its leaf to the root when the root is its target.
|
|
112
|
+
*/
|
|
113
|
+
export function spiralPlan(input: HighwayInput, o: Readonly<SpiralOptions>): HighwayPlan {
|
|
114
|
+
const graph = input.graph;
|
|
115
|
+
const finder = new WayFinder(graph.nodes, { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) });
|
|
116
|
+
const pad = finder.margin / 2;
|
|
117
|
+
const byId = nodeIndex(graph);
|
|
118
|
+
const plugs = edgePlugs(graph);
|
|
119
|
+
const degree = new Map<string, number>();
|
|
120
|
+
for (const { edge } of plugs) {
|
|
121
|
+
if (edge.source.node === edge.target.node) continue;
|
|
122
|
+
for (const n of [edge.source.node, edge.target.node]) degree.set(n, (degree.get(n) ?? 0) + 1);
|
|
123
|
+
}
|
|
124
|
+
const hubs = [...degree].filter(([, d]) => d >= o.minTargets).sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
|
|
125
|
+
const alpha = (Math.max(1, Math.min(80, o.alpha)) * Math.PI) / 180;
|
|
126
|
+
const arcPoints = Math.max(2, Math.round(o.arc));
|
|
127
|
+
const used = new Set<string>();
|
|
128
|
+
const highways: Highway[] = [];
|
|
129
|
+
|
|
130
|
+
/** This function gives the spine from the point `a` to the point `b`. The spine is a spiral arc in free space, or a way through the corner graph. */
|
|
131
|
+
const spineOf = (root: Point, a: { r: number; theta: number; point: Point }, b: { r: number; theta: number; point: Point }): Point[] | undefined => {
|
|
132
|
+
if (finder.obstacles.inside(a.point, pad) || finder.obstacles.inside(b.point, pad)) return undefined;
|
|
133
|
+
let dt = wrap(b.theta - a.theta);
|
|
134
|
+
if (dt > Math.PI) dt -= TAU;
|
|
135
|
+
const arc: Point[] = [a.point];
|
|
136
|
+
for (let s = 1; s < arcPoints - 1; s++) {
|
|
137
|
+
const u = s / (arcPoints - 1);
|
|
138
|
+
const r = a.r * Math.pow(b.r / a.r, u);
|
|
139
|
+
const theta = a.theta + dt * u;
|
|
140
|
+
arc.push({ x: root.x + r * Math.cos(theta), y: root.y + r * Math.sin(theta) });
|
|
141
|
+
}
|
|
142
|
+
arc.push(b.point);
|
|
143
|
+
if (finder.obstacles.clearPath(arc, pad)) return arc;
|
|
144
|
+
const way = finder.corners.path(a.point, b.point);
|
|
145
|
+
return way ? finder.corners.pull(way) : undefined;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
for (const [hubId] of hubs) {
|
|
149
|
+
const hub: Node | undefined = byId.get(hubId);
|
|
150
|
+
if (!hub) continue;
|
|
151
|
+
const root = center(hub.box);
|
|
152
|
+
const rMin = Math.hypot(hub.box.w, hub.box.h) / 2 + 2 * finder.margin;
|
|
153
|
+
const fan = plugs.filter(({ edge }) => !used.has(edge.id) && edge.source.node !== edge.target.node && (edge.source.node === hubId || edge.target.node === hubId));
|
|
154
|
+
if (fan.length < o.minTargets) continue;
|
|
155
|
+
const out = new Map<string, boolean>();
|
|
156
|
+
const near = new Map<string, Point>();
|
|
157
|
+
const far = new Map<string, Point>();
|
|
158
|
+
const leaves: SpiralNode[] = fan.map(({ edge, ps, pt }) => {
|
|
159
|
+
const outward = edge.source.node === hubId;
|
|
160
|
+
out.set(edge.id, outward);
|
|
161
|
+
near.set(edge.id, outward ? ps.point : pt.point);
|
|
162
|
+
const p = outward ? pt.point : ps.point;
|
|
163
|
+
far.set(edge.id, p);
|
|
164
|
+
const box = byId.get(outward ? edge.target.node : edge.source.node)!.box;
|
|
165
|
+
const clear = Math.hypot(box.w, box.h) / 2 + finder.margin;
|
|
166
|
+
return { r: Math.max(1e-6, dist(root, p)), theta: wrap(Math.atan2(p.y - root.y, p.x - root.x)), point: p, clear, edge: edge.id, children: [] };
|
|
167
|
+
});
|
|
168
|
+
for (const { edge } of fan) used.add(edge.id);
|
|
169
|
+
const tops = spiralTree(leaves, alpha, root, rMin);
|
|
170
|
+
// The branches: each link from a node to a child with two leaves or more, from the root down.
|
|
171
|
+
interface Branch {
|
|
172
|
+
readonly spine: Point[];
|
|
173
|
+
readonly edges: string[];
|
|
174
|
+
readonly depth: number;
|
|
175
|
+
}
|
|
176
|
+
const branches: Branch[] = [];
|
|
177
|
+
const walk = (parent: { r: number; theta: number; point: Point }, child: SpiralNode, depth: number): void => {
|
|
178
|
+
if (child.edge !== undefined) return;
|
|
179
|
+
const spine = dist(parent.point, child.point) > finder.margin ? spineOf(root, parent, child) : undefined;
|
|
180
|
+
if (spine && spine.length >= 2) branches.push({ spine, edges: leavesOf(child), depth });
|
|
181
|
+
for (const c of child.children) if (dist(c.point, child.point) > 1e-9) walk(child, c, depth + 1);
|
|
182
|
+
// A child at the point of the join (the nearer node of a join) goes on from the same point.
|
|
183
|
+
for (const c of child.children) if (dist(c.point, child.point) <= 1e-9) for (const g of c.children) walk(child, g, depth + 1);
|
|
184
|
+
};
|
|
185
|
+
for (const top of tops) {
|
|
186
|
+
if (top.edge !== undefined) continue;
|
|
187
|
+
// The trunk from the hub: it starts at the radius `rMin` on the line to the top node.
|
|
188
|
+
const start = { r: rMin, theta: top.theta, point: { x: root.x + rMin * Math.cos(top.theta), y: root.y + rMin * Math.sin(top.theta) } };
|
|
189
|
+
walk(top.r > rMin + finder.margin ? start : top, top, 0);
|
|
190
|
+
}
|
|
191
|
+
for (const b of branches) {
|
|
192
|
+
if (b.edges.length < 2) continue;
|
|
193
|
+
const last = b.spine.length - 1;
|
|
194
|
+
const tripOf = (edge: string): Point[] => [near.get(edge)!, ...b.spine, far.get(edge)!];
|
|
195
|
+
const lanes = orderLanes(
|
|
196
|
+
b.spine,
|
|
197
|
+
b.edges.map((edge) => ({ edge, points: tripOf(edge) })),
|
|
198
|
+
"bundle",
|
|
199
|
+
);
|
|
200
|
+
const members: HighwayMember[] = lanes.map((edge) => (out.get(edge) ? memberAt(b.spine, edge, 0, last, b.depth) : memberAt(b.spine, edge, last, 0, -b.depth)));
|
|
201
|
+
highways.push({ id: `h${highways.length}`, key: `spiral:${hubId}`, spine: b.spine, lanes, members });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return tidyPlan({ highways });
|
|
205
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { center, dist, type Point } from "../geom.ts";
|
|
2
|
+
import { nodeIndex, type Edge, type Graph } from "../model.ts";
|
|
3
|
+
import { placePlug, type Plug } from "../plugs.ts";
|
|
4
|
+
import { orderLanes } from "../segments.ts";
|
|
5
|
+
import { tidyPlan } from "./base.ts";
|
|
6
|
+
import { memberAt, type Highway, type HighwayMember, type HighwayPlan } from "./model.ts";
|
|
7
|
+
|
|
8
|
+
/** An edge with its two plugs, as the router `corners` places them. */
|
|
9
|
+
export interface EdgePlugs {
|
|
10
|
+
readonly edge: Edge;
|
|
11
|
+
readonly ps: Plug;
|
|
12
|
+
readonly pt: Plug;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** This function gives the plugs of each edge of the graph, in the order of the edges. An edge with an unknown node is not in the list. */
|
|
16
|
+
export function edgePlugs(graph: Graph): EdgePlugs[] {
|
|
17
|
+
const byId = nodeIndex(graph);
|
|
18
|
+
const out: EdgePlugs[] = [];
|
|
19
|
+
for (const e of graph.edges) {
|
|
20
|
+
const s = byId.get(e.source.node);
|
|
21
|
+
const t = byId.get(e.target.node);
|
|
22
|
+
if (!s || !t) continue;
|
|
23
|
+
out.push({ edge: e, ps: placePlug(s, e.source, center(t.box)), pt: placePlug(t, e.target, center(s.box)) });
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The way of one edge through the corner graph, with a key. Only the ways with one key share a highway. */
|
|
29
|
+
export interface KeyedWay {
|
|
30
|
+
readonly edge: string;
|
|
31
|
+
readonly key: string;
|
|
32
|
+
/** The points of the way, without the plugs. Two ways share a link when the link has the same two points. */
|
|
33
|
+
readonly points: readonly Point[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The options of `sharedStretches`. */
|
|
37
|
+
export interface SharedStretchOptions {
|
|
38
|
+
/** A highway has this number of edges or more. The value is 2 or more. */
|
|
39
|
+
readonly minSize: number;
|
|
40
|
+
/** This function tells if a link can be part of a highway. Without it, each link can. */
|
|
41
|
+
readonly link?: (a: Point, b: Point) => boolean;
|
|
42
|
+
/** This function removes the needless corners of a spine. Without it, the spine keeps each point of the stretch. */
|
|
43
|
+
readonly pull?: (points: readonly Point[]) => Point[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const pointKey = (p: Point): string => `${p.x},${p.y}`;
|
|
47
|
+
const linkKey = (a: string, b: string): string => (a < b ? `${a}|${b}` : `${b}|${a}`);
|
|
48
|
+
|
|
49
|
+
/** This function removes each point of a polyline that is equal to the point before it. */
|
|
50
|
+
const dedupe = (p: readonly Point[]): Point[] => p.filter((q, i) => i === 0 || dist(q, p[i - 1]!) > 1e-9);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* This function gives a highway for each stretch that two ways or more of one key share. A way is a polyline through the corner graph.
|
|
54
|
+
* Each way breaks into stretches where the set of the ways on its links stays the same. The router `shared` makes its runs in the same way.
|
|
55
|
+
* A stretch with fewer than `minSize` ways is not a highway. Each way joins the spine at one end of the stretch and leaves it at the other end.
|
|
56
|
+
* The `seq` of each member follows the order of the stretches along the way.
|
|
57
|
+
*/
|
|
58
|
+
export function sharedStretches(ways: readonly KeyedWay[], o: Readonly<SharedStretchOptions>): HighwayPlan {
|
|
59
|
+
const min = Math.max(2, o.minSize);
|
|
60
|
+
const clean = ways.map((w) => ({ ...w, points: dedupe(w.points) }));
|
|
61
|
+
const users = new Map<string, string[]>();
|
|
62
|
+
const usable = (a: Point, b: Point): boolean => o.link?.(a, b) ?? true;
|
|
63
|
+
for (const w of clean) {
|
|
64
|
+
const own = new Set<string>();
|
|
65
|
+
for (let i = 1; i < w.points.length; i++) {
|
|
66
|
+
const a = w.points[i - 1]!;
|
|
67
|
+
const b = w.points[i]!;
|
|
68
|
+
if (usable(a, b)) own.add(`${w.key}\u0000${linkKey(pointKey(a), pointKey(b))}`);
|
|
69
|
+
}
|
|
70
|
+
for (const k of own) {
|
|
71
|
+
const list = users.get(k);
|
|
72
|
+
if (list) list.push(w.edge);
|
|
73
|
+
else users.set(k, [w.edge]);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
interface Stretch {
|
|
77
|
+
readonly key: string;
|
|
78
|
+
readonly points: readonly Point[];
|
|
79
|
+
readonly members: { edge: string; forward: boolean; at: number }[];
|
|
80
|
+
}
|
|
81
|
+
const stretches = new Map<string, Stretch>();
|
|
82
|
+
const wayOf = new Map<string, readonly Point[]>();
|
|
83
|
+
for (const w of clean) {
|
|
84
|
+
wayOf.set(w.edge, w.points);
|
|
85
|
+
const sig = (i: number): string => {
|
|
86
|
+
const a = w.points[i]!;
|
|
87
|
+
const b = w.points[i + 1]!;
|
|
88
|
+
if (!usable(a, b)) return "";
|
|
89
|
+
const list = users.get(`${w.key}\u0000${linkKey(pointKey(a), pointKey(b))}`) ?? [];
|
|
90
|
+
return list.length < min ? "" : [...list].sort().join("\u0001");
|
|
91
|
+
};
|
|
92
|
+
let start = 0;
|
|
93
|
+
for (let i = 0; i < w.points.length - 1; i++) {
|
|
94
|
+
const s = sig(i);
|
|
95
|
+
if (i + 1 < w.points.length - 1 && sig(i + 1) === s) continue;
|
|
96
|
+
const from = start;
|
|
97
|
+
start = i + 1;
|
|
98
|
+
if (s === "") continue;
|
|
99
|
+
const a = pointKey(w.points[from]!);
|
|
100
|
+
const b = pointKey(w.points[i + 1]!);
|
|
101
|
+
if (a === b) continue;
|
|
102
|
+
const id = `${w.key}\u0000${s}\u0000${linkKey(a, b)}`;
|
|
103
|
+
const piece = w.points.slice(from, i + 2);
|
|
104
|
+
let found = stretches.get(id);
|
|
105
|
+
if (!found) stretches.set(id, (found = { key: w.key, points: a < b ? piece : [...piece].reverse(), members: [] }));
|
|
106
|
+
if (!found.members.some((m) => m.edge === w.edge)) found.members.push({ edge: w.edge, forward: a < b, at: from });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// The order of the stretches of each way gives the `seq` of its members.
|
|
110
|
+
const kept = [...stretches.values()].filter((s) => s.members.length >= min);
|
|
111
|
+
const order = new Map<string, { s: number; at: number }[]>();
|
|
112
|
+
kept.forEach((s, si) => {
|
|
113
|
+
for (const m of s.members) {
|
|
114
|
+
const list = order.get(m.edge);
|
|
115
|
+
if (list) list.push({ s: si, at: m.at });
|
|
116
|
+
else order.set(m.edge, [{ s: si, at: m.at }]);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
const seq = new Map<string, number>();
|
|
120
|
+
for (const [edge, list] of order) {
|
|
121
|
+
list.sort((p, q) => p.at - q.at || p.s - q.s);
|
|
122
|
+
list.forEach((x, k) => seq.set(`${x.s}\u0000${edge}`, k));
|
|
123
|
+
}
|
|
124
|
+
const highways: Highway[] = kept.map((s, si) => {
|
|
125
|
+
const spine = o.pull ? o.pull(s.points) : [...s.points];
|
|
126
|
+
const last = spine.length - 1;
|
|
127
|
+
const lanes = orderLanes(
|
|
128
|
+
s.points,
|
|
129
|
+
s.members.map((m) => ({ edge: m.edge, points: wayOf.get(m.edge)! })),
|
|
130
|
+
"bundle",
|
|
131
|
+
);
|
|
132
|
+
const byEdge = new Map(s.members.map((m) => [m.edge, m]));
|
|
133
|
+
const members: HighwayMember[] = lanes.map((edge) => {
|
|
134
|
+
const m = byEdge.get(edge)!;
|
|
135
|
+
const k = seq.get(`${si}\u0000${edge}`) ?? 0;
|
|
136
|
+
return m.forward ? memberAt(spine, edge, 0, last, k) : memberAt(spine, edge, last, 0, k);
|
|
137
|
+
});
|
|
138
|
+
return { id: `h${si}`, key: s.key, spine, lanes, members };
|
|
139
|
+
});
|
|
140
|
+
return tidyPlan({ highways });
|
|
141
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { center, dist, pointBoxDistance, type Point } from "../../geom.ts";
|
|
2
|
+
import { nodeIndex, type Node } from "../../model.ts";
|
|
3
|
+
import { pointOnSide, SIDE_NORMAL, sideToward } from "../../plugs.ts";
|
|
4
|
+
import { WayFinder } from "../../ways.ts";
|
|
5
|
+
import { basePlan, tidyPlan, tripsOf, type HighwayBase } from "../base.ts";
|
|
6
|
+
import { memberAt, type Highway, type HighwayInput, type HighwayPlan } from "../model.ts";
|
|
7
|
+
|
|
8
|
+
/** The ends of the edges that get stubs: the source ends, the target ends, or both. */
|
|
9
|
+
export type StubEnds = "source" | "target" | "both";
|
|
10
|
+
|
|
11
|
+
/** The options of the highway strategy `stubs` (HWY-015 with WILD-005 of the notes). */
|
|
12
|
+
export interface StubOptions {
|
|
13
|
+
/** The plan that the stubs go into. The default is `shared-ways`. */
|
|
14
|
+
readonly from: HighwayBase;
|
|
15
|
+
/** The ends of one side of a box whose directions are in a window of this angle share a stub, in degrees. The default is 30. */
|
|
16
|
+
readonly omega: number;
|
|
17
|
+
/** The length of a stub is this share of the distance to the nearest other end. The default is 0.25. */
|
|
18
|
+
readonly sigma: number;
|
|
19
|
+
/** The ends that get stubs. The default is `both`. */
|
|
20
|
+
readonly ends: StubEnds;
|
|
21
|
+
/** A stub has this number of edges or more. The default is 2. */
|
|
22
|
+
readonly minSize: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The default options of the strategy `stubs`. */
|
|
26
|
+
export const STUB_DEFAULTS: Readonly<StubOptions> = Object.freeze({ from: "shared-ways", omega: 30, sigma: 0.25, ends: "both", minSize: 2 });
|
|
27
|
+
|
|
28
|
+
/** One end of an edge that can get a stub. */
|
|
29
|
+
interface StubEnd {
|
|
30
|
+
readonly edge: string;
|
|
31
|
+
readonly node: Node;
|
|
32
|
+
readonly other: Node;
|
|
33
|
+
/** The angle from the normal of the side to the direction of the other node, in radians. A negative angle turns to the left. */
|
|
34
|
+
readonly turn: number;
|
|
35
|
+
/** The first merge point of the edge in the plan, from this end, or `undefined`. */
|
|
36
|
+
readonly merge: Point | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* This function gives the highways of the plan `from`, with a stub at the ends of the edges (HWY-015, WILD-005).
|
|
41
|
+
* The ends on one side of one box are sorted by the direction of their other node. A run of ends in a window of `omega` makes a group.
|
|
42
|
+
* Each group of `minSize` ends or more shares one plug and one stub along the normal of the side. Then the edges fan out.
|
|
43
|
+
*
|
|
44
|
+
* The length of the stub is `sigma` times the distance to the nearest other node, and half that distance at most.
|
|
45
|
+
* It is two times the margin of the way finder at least, and the stub stays out of the other boxes. Else the group gets no stub.
|
|
46
|
+
* An end with a fixed plug or a side of its own gets no stub. An end whose first highway starts on its box, or near it, keeps the plan.
|
|
47
|
+
*/
|
|
48
|
+
export function stubPlan(input: HighwayInput, o: Readonly<StubOptions>): HighwayPlan {
|
|
49
|
+
const graph = input.graph;
|
|
50
|
+
const plan = basePlan(input, o.from);
|
|
51
|
+
const trips = tripsOf(plan);
|
|
52
|
+
const byId = nodeIndex(graph);
|
|
53
|
+
const ways = new WayFinder(graph.nodes);
|
|
54
|
+
const margin = ways.margin;
|
|
55
|
+
const omega = (Math.max(0, o.omega) * Math.PI) / 180;
|
|
56
|
+
const groups = new Map<string, StubEnd[]>();
|
|
57
|
+
const add = (kind: "source" | "target", node: Node, other: Node, edge: string, merge: Point | undefined): void => {
|
|
58
|
+
const side = sideToward(node.box, center(other.box));
|
|
59
|
+
const n = SIDE_NORMAL[side];
|
|
60
|
+
const c = center(node.box);
|
|
61
|
+
const d = center(other.box);
|
|
62
|
+
const dx = d.x - c.x;
|
|
63
|
+
const dy = d.y - c.y;
|
|
64
|
+
const turn = Math.atan2(n.x * dy - n.y * dx, n.x * dx + n.y * dy);
|
|
65
|
+
const key = `${node.id}\u0000${kind}\u0000${side}`;
|
|
66
|
+
const list = groups.get(key);
|
|
67
|
+
const end: StubEnd = { edge, node, other, turn, merge };
|
|
68
|
+
if (list) list.push(end);
|
|
69
|
+
else groups.set(key, [end]);
|
|
70
|
+
};
|
|
71
|
+
for (const e of graph.edges) {
|
|
72
|
+
const s = byId.get(e.source.node);
|
|
73
|
+
const t = byId.get(e.target.node);
|
|
74
|
+
if (!s || !t || s === t) continue;
|
|
75
|
+
const trip = trips.get(e.id);
|
|
76
|
+
if (o.ends !== "target" && e.source.port === undefined) add("source", s, t, e.id, trip?.[0]?.m.merge);
|
|
77
|
+
if (o.ends !== "source" && e.target.port === undefined) add("target", t, s, e.id, trip?.[trip.length - 1]?.m.split);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const stubs: Highway[] = [];
|
|
81
|
+
for (const [key, list] of [...groups].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) {
|
|
82
|
+
const [, kind, side] = key.split("\u0000") as [string, "source" | "target", keyof typeof SIDE_NORMAL];
|
|
83
|
+
list.sort((a, b) => a.turn - b.turn || (a.edge < b.edge ? -1 : 1));
|
|
84
|
+
// The runs of ends in a window of omega.
|
|
85
|
+
const runs: StubEnd[][] = [];
|
|
86
|
+
for (const end of list) {
|
|
87
|
+
const run = runs[runs.length - 1];
|
|
88
|
+
if (run && end.turn - run[0]!.turn <= omega) run.push(end);
|
|
89
|
+
else runs.push([end]);
|
|
90
|
+
}
|
|
91
|
+
for (const run of runs) {
|
|
92
|
+
if (run.length < Math.max(2, o.minSize)) continue;
|
|
93
|
+
const node = run[0]!.node;
|
|
94
|
+
const n = SIDE_NORMAL[side];
|
|
95
|
+
const toward = { x: run.reduce((s, r) => s + center(r.other.box).x, 0) / run.length, y: run.reduce((s, r) => s + center(r.other.box).y, 0) / run.length };
|
|
96
|
+
const plug = pointOnSide(node.box, side, toward);
|
|
97
|
+
const span = Math.min(...run.map((r) => dist(plug, center(r.other.box))));
|
|
98
|
+
const gap = Math.min(...run.map((r) => pointBoxDistance(plug, r.other.box)));
|
|
99
|
+
let len = Math.min(Math.max(0, o.sigma) * span, gap / 2);
|
|
100
|
+
const skip = new Set([node]);
|
|
101
|
+
const tip = (l: number): Point => ({ x: plug.x + n.x * l, y: plug.y + n.y * l });
|
|
102
|
+
while (len >= 2 * margin && !ways.obstacles.clear(tip(margin / 2), tip(len), margin / 2, skip)) len /= 2;
|
|
103
|
+
if (len < 2 * margin) continue;
|
|
104
|
+
// An end whose highway starts on the box, or near the tip, keeps the plan.
|
|
105
|
+
const kept = run.filter((r) => r.merge === undefined || pointBoxDistance(r.merge, node.box) > 2 * len);
|
|
106
|
+
if (kept.length < Math.max(2, o.minSize)) continue;
|
|
107
|
+
const end = tip(len);
|
|
108
|
+
const spine = kind === "source" ? [plug, end] : [end, plug];
|
|
109
|
+
// Lane 0 is on the left of the spine. At a source, the left lane turns most to the left. At a target, the order turns over.
|
|
110
|
+
const lanes = kept.map((r) => r.edge);
|
|
111
|
+
if (kind === "target") lanes.reverse();
|
|
112
|
+
const seq = kind === "source" ? -1 : Number.MAX_SAFE_INTEGER;
|
|
113
|
+
stubs.push({ id: "", key: `stub:${node.id}:${kind}:${side}`, spine, lanes, members: lanes.map((edge) => memberAt(spine, edge, 0, 1, seq)) });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return tidyPlan({ highways: [...plan.highways, ...stubs] });
|
|
117
|
+
}
|