@mark1russell7/highways 0.8.1 → 0.9.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 +112 -8
- package/build/index.d.ts +989 -187
- package/build/index.d.ts.map +1 -1
- package/build/index.js +9761 -6738
- package/build/index.js.map +1 -1
- package/package.json +3 -2
- package/src/bench/compare.ts +174 -5
- package/src/bench/room.ts +1 -1
- package/src/bench/run.ts +38 -3
- package/src/bench/screen/exercise.ts +5 -0
- package/src/bench/screen/forms.ts +5 -1
- package/src/bench/screen/generators.ts +11 -41
- package/src/bench/screen/screen.ts +16 -10
- package/src/bench/screen/set.ts +156 -49
- package/src/bench/suite.ts +110 -12
- package/src/cases.ts +64 -18
- package/src/families/cases.ts +108 -0
- package/src/families/fixtures.ts +101 -0
- package/src/families/index.ts +36 -0
- package/src/families/model.ts +55 -0
- package/src/fixtures.ts +8 -2
- package/src/highways/fixtures.test-util.ts +13 -0
- package/src/highways/follow.ts +3 -2
- package/src/highways/free-lines.ts +141 -0
- package/src/highways/gaps/index.ts +85 -0
- package/src/highways/index.ts +6 -0
- package/src/highways/interchanges/index.ts +231 -0
- package/src/highways/search/index.ts +170 -0
- package/src/highways/snap.ts +4 -2
- package/src/highways/soft-keys/index.ts +8 -4
- package/src/highways/steiner/index.ts +215 -0
- package/src/highways/streets/index.ts +78 -0
- package/src/index.ts +8 -0
- package/src/lane-order.ts +37 -0
- package/src/lane-orders/stretch/index.ts +486 -0
- package/src/lane-orders/stretch/router.ts +22 -0
- package/src/levels/depths/index.ts +57 -0
- package/src/model.ts +60 -4
- package/src/plugs/facing/index.ts +22 -0
- package/src/portfolio/router.ts +44 -0
- package/src/routers/attraction/index.ts +61 -0
- package/src/routers/corners.ts +31 -7
- package/src/routers/index.ts +2 -1
- package/src/routers/plugs.ts +3 -2
- package/src/routers/sections.ts +25 -42
- package/src/routers/shared.ts +96 -14
- package/src/routers/straight.ts +3 -2
- package/src/routers/trunks.ts +24 -5
- package/src/segments.ts +4 -25
- package/src/strategies/builtins.ts +109 -2
- package/src/strategies/model.ts +55 -10
- package/src/strategies/options.ts +36 -4
- package/src/strategies/schemas.ts +124 -5
- package/src/whole/pipeline/index.ts +66 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { boardUnit } from "../../bench/measures/routes.ts";
|
|
2
|
+
import { dist, polylineLength, type Point } from "../../geom.ts";
|
|
3
|
+
import { orderLanes } from "../../segments.ts";
|
|
4
|
+
import { WayFinder } from "../../ways.ts";
|
|
5
|
+
import { basePlan, firstRoutes, nearestPlace, tidyPlan } from "../base.ts";
|
|
6
|
+
import { lengthBetween, memberAt, pointAt, type Highway, type HighwayInput, type HighwayMember, type HighwayPlan } from "../model.ts";
|
|
7
|
+
import { spinePart } from "../snap.ts";
|
|
8
|
+
import { edgePlugs } from "../stretches.ts";
|
|
9
|
+
|
|
10
|
+
/** The plan that gives the highways of the network: the plan of `corridors` or of `shared-ways`, with their defaults. */
|
|
11
|
+
export type InterchangeBase = "corridors" | "shared-ways";
|
|
12
|
+
|
|
13
|
+
/** The values of `InterchangeBase`. */
|
|
14
|
+
export const INTERCHANGE_BASES: readonly InterchangeBase[] = Object.freeze(["corridors", "shared-ways"]);
|
|
15
|
+
|
|
16
|
+
/** The options of the highway strategy `interchanges` (HWY-060 of the note on highways). */
|
|
17
|
+
export interface InterchangeOptions {
|
|
18
|
+
/** The plan that gives the highways. The default is `corridors`. */
|
|
19
|
+
readonly from: InterchangeBase;
|
|
20
|
+
/** The cost of a change between two highways, in units of the board, from 0 to 8. The default is 2. */
|
|
21
|
+
readonly changeCost: number;
|
|
22
|
+
/** The largest number of interchanges, from 1 to 32. The default is 16. */
|
|
23
|
+
readonly interchanges: number;
|
|
24
|
+
/** The largest number of highways on the way of one edge, from 1 to 3. The default is 2. */
|
|
25
|
+
readonly perEdge: number;
|
|
26
|
+
/** Two highways get an interchange where they come this near, in units of the board. The default is 2. */
|
|
27
|
+
readonly radius: number;
|
|
28
|
+
/** The cost of a length on a highway against a length off the highways, from 0.1 to 1. The default is 0.5. */
|
|
29
|
+
readonly discount: number;
|
|
30
|
+
/** An edge rides the network only when its way is this many times its first route or less. The default is 1.5. */
|
|
31
|
+
readonly detour: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The default options of the strategy `interchanges`. */
|
|
35
|
+
export const INTERCHANGE_DEFAULTS: Readonly<InterchangeOptions> = Object.freeze({ from: "corridors", changeCost: 2, interchanges: 16, perEdge: 2, radius: 2, discount: 0.5, detour: 1.5 });
|
|
36
|
+
|
|
37
|
+
/** The largest number of candidate interchanges of the first pass. */
|
|
38
|
+
const MAX_CANDIDATES = 64;
|
|
39
|
+
|
|
40
|
+
/** One interchange: a place on the highway `h`, a place on the highway `g`, and the length of the link between the two places. */
|
|
41
|
+
export interface Interchange {
|
|
42
|
+
readonly h: number;
|
|
43
|
+
readonly hAt: number;
|
|
44
|
+
readonly g: number;
|
|
45
|
+
readonly gAt: number;
|
|
46
|
+
readonly gap: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The nearest points of the segments `a b` and `c d`, as the parameters `s` and `t` from 0 to 1, with their distance. */
|
|
50
|
+
function nearestOfSegments(a: Point, b: Point, c: Point, d: Point): { s: number; t: number; d: number } {
|
|
51
|
+
const ux = b.x - a.x;
|
|
52
|
+
const uy = b.y - a.y;
|
|
53
|
+
const vx = d.x - c.x;
|
|
54
|
+
const vy = d.y - c.y;
|
|
55
|
+
const at = (s: number, t: number): number => {
|
|
56
|
+
const dx = a.x + s * ux - (c.x + t * vx);
|
|
57
|
+
const dy = a.y + s * uy - (c.y + t * vy);
|
|
58
|
+
// `Math.sqrt` is exact on each platform. `Math.hypot` is not.
|
|
59
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
60
|
+
};
|
|
61
|
+
const clamp = (v: number): number => Math.min(1, Math.max(0, v));
|
|
62
|
+
const uu = ux * ux + uy * uy;
|
|
63
|
+
const vv = vx * vx + vy * vy;
|
|
64
|
+
const project = (p: Point, q: Point, x: number, y: number, len: number): number => (len > 0 ? clamp(((p.x - q.x) * x + (p.y - q.y) * y) / len) : 0);
|
|
65
|
+
// The four ends against the other segment, and the crossing point, give the nearest pair.
|
|
66
|
+
const tries: [number, number][] = [
|
|
67
|
+
[0, project(a, c, vx, vy, vv)],
|
|
68
|
+
[1, project(b, c, vx, vy, vv)],
|
|
69
|
+
[project(c, a, ux, uy, uu), 0],
|
|
70
|
+
[project(d, a, ux, uy, uu), 1],
|
|
71
|
+
];
|
|
72
|
+
const den = ux * vy - uy * vx;
|
|
73
|
+
if (Math.abs(den) > 1e-12) {
|
|
74
|
+
const s = ((c.x - a.x) * vy - (c.y - a.y) * vx) / den;
|
|
75
|
+
const t = ((c.x - a.x) * uy - (c.y - a.y) * ux) / den;
|
|
76
|
+
if (s >= 0 && s <= 1 && t >= 0 && t <= 1) tries.push([s, t]);
|
|
77
|
+
}
|
|
78
|
+
let best = { s: 0, t: 0, d: Infinity };
|
|
79
|
+
for (const [s, t] of tries) {
|
|
80
|
+
const v = at(s, t);
|
|
81
|
+
if (v < best.d - 1e-12) best = { s, t, d: v };
|
|
82
|
+
}
|
|
83
|
+
return best;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** This function gives the candidate interchanges: the nearest places of each pair of spines that come within `radius`, with a link in clear view. */
|
|
87
|
+
export function interchangeCandidates(spines: readonly (readonly Point[])[], radius: number, clear: (a: Point, b: Point) => boolean): Interchange[] {
|
|
88
|
+
const out: Interchange[] = [];
|
|
89
|
+
for (let h = 0; h < spines.length; h++) {
|
|
90
|
+
for (let g = h + 1; g < spines.length; g++) {
|
|
91
|
+
const p = spines[h]!;
|
|
92
|
+
const q = spines[g]!;
|
|
93
|
+
let best: Interchange | undefined;
|
|
94
|
+
for (let i = 0; i + 1 < p.length; i++) {
|
|
95
|
+
for (let j = 0; j + 1 < q.length; j++) {
|
|
96
|
+
const n = nearestOfSegments(p[i]!, p[i + 1]!, q[j]!, q[j + 1]!);
|
|
97
|
+
if (n.d <= radius && (!best || n.d < best.gap - 1e-12)) best = { h, hAt: i + n.s, g, gAt: j + n.t, gap: n.d };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (best && (best.gap <= 1e-9 || clear(pointAt(p, best.hAt), pointAt(q, best.gAt)))) out.push(best);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out.sort((x, y) => x.gap - y.gap || x.h - y.h || x.g - y.g).slice(0, MAX_CANDIDATES);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One leg of the way of an edge: the highway and its two places. */
|
|
107
|
+
interface Leg {
|
|
108
|
+
readonly h: number;
|
|
109
|
+
readonly on: number;
|
|
110
|
+
readonly off: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The best way of one edge over the network, with the interchanges that it uses. */
|
|
114
|
+
interface Trip {
|
|
115
|
+
readonly legs: readonly Leg[];
|
|
116
|
+
readonly used: readonly number[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* This function gives the highways of a network with interchanges (HWY-060). The highways come from a plan. Two highways that come near get an interchange.
|
|
121
|
+
* Each edge takes its cheapest way: a ride on one highway, or on up to `perEdge` highways with a change at an interchange.
|
|
122
|
+
* A change costs `changeCost` units. The interchanges that the most edges use stay, up to `interchanges`.
|
|
123
|
+
*/
|
|
124
|
+
export function interchangePlan(input: HighwayInput, o: Readonly<InterchangeOptions>): HighwayPlan {
|
|
125
|
+
const graph = input.graph;
|
|
126
|
+
const base = basePlan(input, o.from);
|
|
127
|
+
const spines = base.highways.map((h) => h.spine);
|
|
128
|
+
if (spines.length === 0) return { highways: [] };
|
|
129
|
+
const finder = new WayFinder(graph.nodes);
|
|
130
|
+
const pad = finder.margin / 2;
|
|
131
|
+
const unit = boardUnit(graph);
|
|
132
|
+
const first = firstRoutes(input);
|
|
133
|
+
const change = Math.max(0, o.changeCost) * unit;
|
|
134
|
+
const perEdge = Math.min(3, Math.max(1, Math.round(o.perEdge)));
|
|
135
|
+
const disc = Math.min(1, Math.max(0.05, o.discount));
|
|
136
|
+
const ride = (h: number, a: number, b: number): number => lengthBetween(spines[h]!, Math.min(a, b), Math.max(a, b));
|
|
137
|
+
const edges = edgePlugs(graph).map((p) => {
|
|
138
|
+
const s = finder.out(p.ps);
|
|
139
|
+
const t = finder.out(p.pt);
|
|
140
|
+
const route = first.get(p.edge.id);
|
|
141
|
+
const ends = spines.map((spine) => {
|
|
142
|
+
const on = nearestPlace(spine, s);
|
|
143
|
+
const off = nearestPlace(spine, t);
|
|
144
|
+
return { on, off, rampIn: dist(s, pointAt(spine, on)), rampOut: dist(pointAt(spine, off), t) };
|
|
145
|
+
});
|
|
146
|
+
return { edge: p.edge.id, s, t, base: route ? polylineLength(route) : dist(s, t), ends };
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// The cheapest way of one edge over the interchanges `list`. The cost counts a ride at `disc` times its length. Each leg is one margin long or more.
|
|
150
|
+
const tripOf = (e: (typeof edges)[number], list: readonly Interchange[]): Trip | undefined => {
|
|
151
|
+
const found: { cost: number; trip: Trip | undefined } = { cost: Infinity, trip: undefined };
|
|
152
|
+
const offer = (cost: number, length: number, trip: Trip): void => {
|
|
153
|
+
if (trip.legs.some((l) => ride(l.h, l.on, l.off) < finder.margin)) return;
|
|
154
|
+
if (cost >= e.base || length > o.detour * e.base) return;
|
|
155
|
+
if (cost < found.cost - 1e-9) [found.cost, found.trip] = [cost, trip];
|
|
156
|
+
};
|
|
157
|
+
spines.forEach((_, h) => {
|
|
158
|
+
const x = e.ends[h]!;
|
|
159
|
+
const r = ride(h, x.on, x.off);
|
|
160
|
+
offer(x.rampIn + disc * r + x.rampOut, x.rampIn + r + x.rampOut, { legs: [{ h, on: x.on, off: x.off }], used: [] });
|
|
161
|
+
});
|
|
162
|
+
if (perEdge < 2) return found.trip;
|
|
163
|
+
// Each interchange, in both directions: on `p` at its place `pAt`, then off at the place `qAt` of `q`.
|
|
164
|
+
const sides = list.flatMap((c, i) => [
|
|
165
|
+
{ i, p: c.h, pAt: c.hAt, q: c.g, qAt: c.gAt, gap: c.gap },
|
|
166
|
+
{ i, p: c.g, pAt: c.gAt, q: c.h, qAt: c.hAt, gap: c.gap },
|
|
167
|
+
]);
|
|
168
|
+
for (const a of sides) {
|
|
169
|
+
const x = e.ends[a.p]!;
|
|
170
|
+
const y = e.ends[a.q]!;
|
|
171
|
+
const r1 = ride(a.p, x.on, a.pAt);
|
|
172
|
+
const r2 = ride(a.q, a.qAt, y.off);
|
|
173
|
+
offer(x.rampIn + disc * (r1 + r2) + a.gap + change + y.rampOut, x.rampIn + r1 + r2 + a.gap + y.rampOut, {
|
|
174
|
+
legs: [
|
|
175
|
+
{ h: a.p, on: x.on, off: a.pAt },
|
|
176
|
+
{ h: a.q, on: a.qAt, off: y.off },
|
|
177
|
+
],
|
|
178
|
+
used: [a.i],
|
|
179
|
+
});
|
|
180
|
+
if (perEdge < 3) continue;
|
|
181
|
+
for (const b of sides) {
|
|
182
|
+
if (b.p !== a.q || b.q === a.p || b.i === a.i) continue;
|
|
183
|
+
const z = e.ends[b.q]!;
|
|
184
|
+
const r3 = ride(a.q, a.qAt, b.pAt);
|
|
185
|
+
const r4 = ride(b.q, b.qAt, z.off);
|
|
186
|
+
offer(x.rampIn + disc * (r1 + r3 + r4) + a.gap + b.gap + 2 * change + z.rampOut, x.rampIn + r1 + r3 + r4 + a.gap + b.gap + z.rampOut, {
|
|
187
|
+
legs: [
|
|
188
|
+
{ h: a.p, on: x.on, off: a.pAt },
|
|
189
|
+
{ h: a.q, on: a.qAt, off: b.pAt },
|
|
190
|
+
{ h: b.q, on: b.qAt, off: z.off },
|
|
191
|
+
],
|
|
192
|
+
used: [a.i, b.i],
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return found.trip;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const clear = (a: Point, b: Point): boolean => finder.obstacles.clear(a, b, pad);
|
|
200
|
+
const candidates = interchangeCandidates(spines, Math.max(0, o.radius) * unit, clear);
|
|
201
|
+
// The first pass counts the edges at each candidate. The busiest candidates stay for the second pass.
|
|
202
|
+
const use = new Array<number>(candidates.length).fill(0);
|
|
203
|
+
for (const e of edges) for (const i of tripOf(e, candidates)?.used ?? []) use[i] = use[i]! + 1;
|
|
204
|
+
const kept = candidates
|
|
205
|
+
.map((c, i) => ({ c, i, n: use[i]! }))
|
|
206
|
+
.filter((x) => x.n > 0)
|
|
207
|
+
.sort((x, y) => y.n - x.n || x.i - y.i)
|
|
208
|
+
.slice(0, Math.max(1, Math.round(o.interchanges)))
|
|
209
|
+
.sort((x, y) => x.i - y.i)
|
|
210
|
+
.map((x) => x.c);
|
|
211
|
+
|
|
212
|
+
const riders = spines.map(() => [] as { edge: string; on: number; off: number; seq: number; points: Point[] }[]);
|
|
213
|
+
for (const e of edges) {
|
|
214
|
+
const trip = tripOf(e, kept);
|
|
215
|
+
if (!trip) continue;
|
|
216
|
+
trip.legs.forEach((l, seq) => riders[l.h]!.push({ edge: e.edge, on: l.on, off: l.off, seq, points: [e.s, ...spinePart(spines[l.h]!, l.on, l.off), e.t] }));
|
|
217
|
+
}
|
|
218
|
+
const highways: Highway[] = [];
|
|
219
|
+
spines.forEach((spine, h) => {
|
|
220
|
+
const list = riders[h]!;
|
|
221
|
+
if (list.length < 2) return;
|
|
222
|
+
const lanes = orderLanes(spine, list.map((r) => ({ edge: r.edge, points: r.points })), "bundle");
|
|
223
|
+
const byEdge = new Map(list.map((r) => [r.edge, r]));
|
|
224
|
+
const members: HighwayMember[] = lanes.map((edge) => {
|
|
225
|
+
const r = byEdge.get(edge)!;
|
|
226
|
+
return memberAt(spine, edge, r.on, r.off, r.seq);
|
|
227
|
+
});
|
|
228
|
+
highways.push({ id: `h${h}`, key: base.highways[h]!.key, spine, lanes, members });
|
|
229
|
+
});
|
|
230
|
+
return tidyPlan({ highways });
|
|
231
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { ambiguity, bundleStretch, inkSaved, mergeAngle } from "../../bench/measures/bundles.ts";
|
|
2
|
+
import type { Point } from "../../geom.ts";
|
|
3
|
+
import type { Graph, Route } from "../../model.ts";
|
|
4
|
+
import { WayFinder } from "../../ways.ts";
|
|
5
|
+
import { nearestPlace, orderAlongRoutes, tidyPlan, firstRoutes } from "../base.ts";
|
|
6
|
+
import { follow } from "../follow.ts";
|
|
7
|
+
import { memberAt, type Highway, type HighwayInput, type HighwayPlan } from "../model.ts";
|
|
8
|
+
import { candidatePlan, type CandidateSource } from "../select/index.ts";
|
|
9
|
+
import { edgePlugs } from "../stretches.ts";
|
|
10
|
+
|
|
11
|
+
/** The options of the highway strategy `search` (HWY-059 of the note on highways). */
|
|
12
|
+
export interface SearchOptions {
|
|
13
|
+
/** The plan where the search starts. The default is `corridors`. */
|
|
14
|
+
readonly from: CandidateSource;
|
|
15
|
+
/** The generators of the highways that the move `add` takes. The default is `quickbundles` and `road-classes`. */
|
|
16
|
+
readonly pool: readonly CandidateSource[];
|
|
17
|
+
/** The number of plans that the search measures, from 0 to 400. The value 0 gives the start plan. The default is 16. */
|
|
18
|
+
readonly evaluations: number;
|
|
19
|
+
/** The first temperature of the annealing, on the scale of the score. Each sweep of 4 moves multiplies it by 0.95. The default is 0.02. */
|
|
20
|
+
readonly temperature: number;
|
|
21
|
+
/** The weight of the ink that the routes save (B1). The default is 1. */
|
|
22
|
+
readonly inkWeight: number;
|
|
23
|
+
/** The weight of the 0.95 quantile of the stretch against the first routes (B3). The default is 0.5. */
|
|
24
|
+
readonly stretchWeight: number;
|
|
25
|
+
/** The weight of the ambiguity (B4). The default is 0.5. */
|
|
26
|
+
readonly ambiguityWeight: number;
|
|
27
|
+
/** The weight of the largest merge angle (B6), divided by 180 degrees. The default is 0.25. */
|
|
28
|
+
readonly angleWeight: number;
|
|
29
|
+
/** The seed of the moves. The default is 1. */
|
|
30
|
+
readonly seed: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The default options of the strategy `search`. */
|
|
34
|
+
export const SEARCH_DEFAULTS: Readonly<SearchOptions> = Object.freeze({
|
|
35
|
+
from: "corridors",
|
|
36
|
+
pool: Object.freeze(["quickbundles", "road-classes"] as CandidateSource[]),
|
|
37
|
+
evaluations: 16,
|
|
38
|
+
temperature: 0.02,
|
|
39
|
+
inkWeight: 1,
|
|
40
|
+
stretchWeight: 0.5,
|
|
41
|
+
ambiguityWeight: 0.5,
|
|
42
|
+
angleWeight: 0.25,
|
|
43
|
+
seed: 1,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** The moves of the search. */
|
|
47
|
+
export type SearchMove = "add" | "drop" | "edge" | "point";
|
|
48
|
+
|
|
49
|
+
/** The values of `SearchMove`, in the order of the draw. */
|
|
50
|
+
export const SEARCH_MOVES: readonly SearchMove[] = Object.freeze(["add", "drop", "edge", "point"]);
|
|
51
|
+
|
|
52
|
+
/** The number of moves of one sweep of the annealing. */
|
|
53
|
+
const SWEEP = 4;
|
|
54
|
+
|
|
55
|
+
/** This function gives a seeded random source with values from 0 to 1 (mulberry32). */
|
|
56
|
+
function random(seed: number): () => number {
|
|
57
|
+
let a = Math.floor(seed) >>> 0;
|
|
58
|
+
return () => {
|
|
59
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
60
|
+
let t = a;
|
|
61
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
62
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
63
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* This function gives the score of the routes of a plan: `w1 B1 - w3 B3 - w4 B4 - w6 B6 / 180`. Higher is better.
|
|
69
|
+
* B3 is the 0.95 quantile of the stretch against the first routes. The score reads only the graph and the routes of the plan.
|
|
70
|
+
*/
|
|
71
|
+
export function searchScore(graph: Graph, routes: readonly Route[], first: readonly Route[], o: Readonly<SearchOptions>): number {
|
|
72
|
+
const ink = inkSaved(routes);
|
|
73
|
+
const stretch = bundleStretch(routes, first).q95;
|
|
74
|
+
const amb = o.ambiguityWeight > 0 ? ambiguity(graph, routes).value : 0;
|
|
75
|
+
const angle = o.angleWeight > 0 ? mergeAngle(routes) / 180 : 0;
|
|
76
|
+
return o.inkWeight * ink - o.stretchWeight * stretch - o.ambiguityWeight * amb - o.angleWeight * angle;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The function `acceptance` gives the chance to keep a worse plan, for a loss `y` of 0 or more on the scale of the temperature.
|
|
81
|
+
* It is `1 / (1 + y + y² / 2)`, near `exp(-y)` for a small loss.
|
|
82
|
+
* It uses only exact arithmetic, so each platform keeps the same plans.
|
|
83
|
+
*/
|
|
84
|
+
function acceptance(y: number): number {
|
|
85
|
+
return 1 / (1 + y + (y * y) / 2);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* This function gives the highways of a local search over the set of highways (HWY-059). The search starts at the plan of `from`.
|
|
90
|
+
* Each move adds a highway of the pool, drops a highway, moves one edge to another highway, or moves one point of a spine.
|
|
91
|
+
* The annealing keeps a worse plan with the chance `acceptance(-delta / T)`. The best plan that the search measures is the result.
|
|
92
|
+
*
|
|
93
|
+
* The score reads only this graph and its routes. Thus the search does not fit to the inputs of a screen.
|
|
94
|
+
*/
|
|
95
|
+
export function searchPlan(input: HighwayInput, o: Readonly<SearchOptions>): HighwayPlan {
|
|
96
|
+
const graph = input.graph;
|
|
97
|
+
const start = tidyPlan(candidatePlan(input, o.from));
|
|
98
|
+
const budget = Math.max(0, Math.floor(o.evaluations));
|
|
99
|
+
if (budget === 0 || graph.edges.length === 0) return start;
|
|
100
|
+
const pool: Highway[] = [];
|
|
101
|
+
for (const source of o.pool) if (source !== o.from) pool.push(...candidatePlan(input, source).highways);
|
|
102
|
+
const finder = new WayFinder(graph.nodes);
|
|
103
|
+
const pad = finder.margin / 2;
|
|
104
|
+
const plugs = new Map(edgePlugs(graph).map((p) => [p.edge.id, p] as const));
|
|
105
|
+
const routes = firstRoutes(input);
|
|
106
|
+
const first = follow(graph, { highways: [] }).routes;
|
|
107
|
+
const scoreOf = (plan: HighwayPlan): number => searchScore(graph, follow(graph, plan).routes, first, o);
|
|
108
|
+
const next = random(o.seed);
|
|
109
|
+
const pick = <T>(list: readonly T[]): T => list[Math.floor(next() * list.length) % list.length]!;
|
|
110
|
+
const tidy = (highways: Highway[]): HighwayPlan => tidyPlan(orderAlongRoutes({ highways }, routes));
|
|
111
|
+
|
|
112
|
+
/** This function gives the plan after one move, or `undefined` when the move does not apply. */
|
|
113
|
+
const moved = (plan: HighwayPlan, move: SearchMove): HighwayPlan | undefined => {
|
|
114
|
+
const list = [...plan.highways];
|
|
115
|
+
if (move === "add") {
|
|
116
|
+
const known = new Set(list.map((h) => h.spine.map((p) => `${p.x},${p.y}`).join(";")));
|
|
117
|
+
const fresh = pool.filter((h) => !known.has(h.spine.map((p) => `${p.x},${p.y}`).join(";")));
|
|
118
|
+
if (fresh.length === 0) return undefined;
|
|
119
|
+
return tidy([...list, pick(fresh)]);
|
|
120
|
+
}
|
|
121
|
+
if (list.length === 0) return undefined;
|
|
122
|
+
const i = Math.floor(next() * list.length) % list.length;
|
|
123
|
+
const h = list[i]!;
|
|
124
|
+
if (move === "drop") return tidy(list.filter((_, j) => j !== i));
|
|
125
|
+
if (move === "edge") {
|
|
126
|
+
// One member leaves its highway and rides the nearest other highway that does not carry it.
|
|
127
|
+
if (list.length < 2 || h.members.length < 3) return undefined;
|
|
128
|
+
const m = pick(h.members);
|
|
129
|
+
const plug = plugs.get(m.edge);
|
|
130
|
+
if (!plug) return undefined;
|
|
131
|
+
const others = list.map((g, j) => ({ g, j })).filter((x) => x.j !== i && !x.g.members.some((y) => y.edge === m.edge));
|
|
132
|
+
if (others.length === 0) return undefined;
|
|
133
|
+
const s = plug.ps.point;
|
|
134
|
+
const t = plug.pt.point;
|
|
135
|
+
const to = pick(others);
|
|
136
|
+
const on = nearestPlace(to.g.spine, s);
|
|
137
|
+
const off = nearestPlace(to.g.spine, t);
|
|
138
|
+
if (Math.abs(on - off) < 1e-6) return undefined;
|
|
139
|
+
list[i] = { ...h, members: h.members.filter((y) => y.edge !== m.edge), lanes: h.lanes.filter((e) => e !== m.edge) };
|
|
140
|
+
list[to.j] = { ...to.g, members: [...to.g.members, memberAt(to.g.spine, m.edge, on, off, m.seq)], lanes: [...to.g.lanes, m.edge] };
|
|
141
|
+
return tidy(list);
|
|
142
|
+
}
|
|
143
|
+
// The move `point`: one point of the spine moves by two margins on one axis, when the spine stays in clear view.
|
|
144
|
+
const k = Math.floor(next() * h.spine.length) % h.spine.length;
|
|
145
|
+
const step = 2 * finder.margin * (next() < 0.5 ? -1 : 1);
|
|
146
|
+
const p = h.spine[k]!;
|
|
147
|
+
const q: Point = next() < 0.5 ? { x: p.x + step, y: p.y } : { x: p.x, y: p.y + step };
|
|
148
|
+
const spine = h.spine.map((x, j) => (j === k ? q : x));
|
|
149
|
+
if (!finder.obstacles.clearPath(spine, pad)) return undefined;
|
|
150
|
+
list[i] = { ...h, spine, members: h.members.map((m) => memberAt(spine, m.edge, m.on, m.off, m.seq)) };
|
|
151
|
+
return tidy(list);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
let current = start;
|
|
155
|
+
let currentScore = scoreOf(start);
|
|
156
|
+
let best = start;
|
|
157
|
+
let bestScore = currentScore;
|
|
158
|
+
let temperature = Math.max(0, o.temperature);
|
|
159
|
+
for (let n = 1, tries = 0; n < budget && tries < 4 * budget; tries++) {
|
|
160
|
+
const plan = moved(current, pick(SEARCH_MOVES));
|
|
161
|
+
if (!plan) continue;
|
|
162
|
+
n++;
|
|
163
|
+
const score = scoreOf(plan);
|
|
164
|
+
const delta = score - currentScore;
|
|
165
|
+
if (delta >= 0 || (temperature > 0 && next() < acceptance(-delta / temperature))) [current, currentScore] = [plan, score];
|
|
166
|
+
if (score > bestScore + 1e-12) [best, bestScore] = [plan, score];
|
|
167
|
+
if (n % SWEEP === 0) temperature *= 0.95;
|
|
168
|
+
}
|
|
169
|
+
return best;
|
|
170
|
+
}
|
package/src/highways/snap.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface SnapLine {
|
|
|
28
28
|
export interface SnapOptions extends Pick<WayOptions, "margin" | "k"> {
|
|
29
29
|
/** A highway has this number of edges or more. The value is 2 or more. */
|
|
30
30
|
readonly minSize: number;
|
|
31
|
+
/** With `true`, a line in clear view is its own spine, with no path through the corner graph. The default is `false`. */
|
|
32
|
+
readonly direct?: boolean;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/** This function gives the line `u d + rho n` of the direction angle `theta`, with `d = (cos, sin)` and the normal `n = (-sin, cos)`. */
|
|
@@ -61,9 +63,9 @@ export function snapPlan(input: HighwayInput, lines: readonly SnapLine[], o: Rea
|
|
|
61
63
|
const a = free(line.a);
|
|
62
64
|
const b = free(line.b);
|
|
63
65
|
if (dist(a, b) <= finder.margin) continue;
|
|
64
|
-
const raw = corners.path(a, b);
|
|
66
|
+
const raw = o.direct === true && finder.obstacles.clear(a, b, pad) ? [a, b] : corners.path(a, b);
|
|
65
67
|
if (!raw) continue;
|
|
66
|
-
const spine = corners.pull(raw);
|
|
68
|
+
const spine = o.direct === true && raw.length === 2 ? raw : corners.pull(raw);
|
|
67
69
|
if (spine.length < 2 || polylineLength(spine) <= 1e-6) continue;
|
|
68
70
|
const riders: { edge: string; on: number; off: number; points: Point[] }[] = [];
|
|
69
71
|
for (const m of line.members) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { GroupRule } from "../../model.ts";
|
|
2
|
-
import { byCollection } from "../../routers/trunks.ts";
|
|
2
|
+
import { byCollection, groupRuleOf, type GroupRuleName } from "../../routers/trunks.ts";
|
|
3
3
|
import { chainClusters, flowDistance, type Linkage } from "../flow-clusters/index.ts";
|
|
4
4
|
import { groupedPlan, type EdgeGroup, type GroupSpine } from "../grouped.ts";
|
|
5
5
|
import type { HighwayInput, HighwayPlan } from "../model.ts";
|
|
@@ -7,8 +7,11 @@ import { edgePlugs } from "../stretches.ts";
|
|
|
7
7
|
|
|
8
8
|
/** The options of the highway strategy `soft-keys` (HWY-019 of the note on highways). */
|
|
9
9
|
export interface SoftKeyOptions {
|
|
10
|
-
/**
|
|
11
|
-
|
|
10
|
+
/**
|
|
11
|
+
* The rule that gives the key of each edge, or the name of a built-in rule (`GROUP_RULE_NAMES`). The default is `byCollection`: the string `data.collection`.
|
|
12
|
+
* With `all`, each edge has one key, so the keys add no distance.
|
|
13
|
+
*/
|
|
14
|
+
readonly rule: GroupRule | GroupRuleName;
|
|
12
15
|
/** The distance that two different keys add. `Infinity` keeps the edges of two keys apart, as the hard keys do. The default is 0.25. */
|
|
13
16
|
readonly lambda: number;
|
|
14
17
|
/** Two clusters join while their distance is this value or less. The default is 0.5. */
|
|
@@ -54,11 +57,12 @@ export function softDistance(geom: number, keyA: string | undefined, keyB: strin
|
|
|
54
57
|
* Each edge of a cluster gets the key of the cluster. It gets `flip` when it goes opposite to the first edge of the cluster.
|
|
55
58
|
*/
|
|
56
59
|
export function softKeyClusters(input: HighwayInput, o: Readonly<SoftKeyOptions>): Map<string, EdgeGroup> {
|
|
60
|
+
const rule = groupRuleOf(o.rule);
|
|
57
61
|
const ends = edgePlugs(input.graph).map(({ edge, ps, pt }) => ({
|
|
58
62
|
edge: edge.id,
|
|
59
63
|
s: ps.point,
|
|
60
64
|
t: pt.point,
|
|
61
|
-
key:
|
|
65
|
+
key: rule(edge),
|
|
62
66
|
values: o.attributes.map((a) => {
|
|
63
67
|
const v = edge.data?.[a];
|
|
64
68
|
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { polylineLength, type Point } from "../../geom.ts";
|
|
2
|
+
import { byCollection } from "../../routers/trunks.ts";
|
|
3
|
+
import { WayFinder } from "../../ways.ts";
|
|
4
|
+
import { firstRoutes } from "../base.ts";
|
|
5
|
+
import type { HighwayInput, HighwayPlan } from "../model.ts";
|
|
6
|
+
import { edgePlugs, sharedStretches, type KeyedWay } from "../stretches.ts";
|
|
7
|
+
|
|
8
|
+
/** The groups of the trees: the edges from one source node, or the edges of one collection. */
|
|
9
|
+
export type SteinerGroup = "source" | "key";
|
|
10
|
+
|
|
11
|
+
/** The values of `SteinerGroup`. */
|
|
12
|
+
export const STEINER_GROUPS: readonly SteinerGroup[] = Object.freeze(["source", "key"]);
|
|
13
|
+
|
|
14
|
+
/** The method of the tree: the iterated 1-Steiner method on the Hanan grid, or the spanning tree of the terminals alone. */
|
|
15
|
+
export type SteinerMethod = "one-steiner" | "spanning";
|
|
16
|
+
|
|
17
|
+
/** The values of `SteinerMethod`. */
|
|
18
|
+
export const STEINER_METHODS: readonly SteinerMethod[] = Object.freeze(["one-steiner", "spanning"]);
|
|
19
|
+
|
|
20
|
+
/** The metric of the tree: rectilinear (two directions) or octilinear (four directions). */
|
|
21
|
+
export type SteinerMetric = "rectilinear" | "octilinear";
|
|
22
|
+
|
|
23
|
+
/** The values of `SteinerMetric`. */
|
|
24
|
+
export const STEINER_METRICS: readonly SteinerMetric[] = Object.freeze(["rectilinear", "octilinear"]);
|
|
25
|
+
|
|
26
|
+
/** The options of the highway strategy `steiner` (HWY-036 of the note on highways, with EDA-015). */
|
|
27
|
+
export interface SteinerOptions {
|
|
28
|
+
/** The groups of the trees. The default is `source`. */
|
|
29
|
+
readonly group: SteinerGroup;
|
|
30
|
+
/** The method of the tree. The default is `one-steiner`. */
|
|
31
|
+
readonly method: SteinerMethod;
|
|
32
|
+
/** A group with more terminals than this number gets the spanning tree alone. The default is 12. */
|
|
33
|
+
readonly maxGroup: number;
|
|
34
|
+
/** The metric of the tree. The default is `rectilinear`. */
|
|
35
|
+
readonly metric: SteinerMetric;
|
|
36
|
+
/** An edge rides the tree only when its path in the tree is this many times its first route or less. The default is 1.5. */
|
|
37
|
+
readonly detour: number;
|
|
38
|
+
/** A stretch of a tree with fewer edges than this number is not a highway. The default is 2. */
|
|
39
|
+
readonly minSize: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The default options of the strategy `steiner`. */
|
|
43
|
+
export const STEINER_DEFAULTS: Readonly<SteinerOptions> = Object.freeze({ group: "source", method: "one-steiner", maxGroup: 12, metric: "rectilinear", detour: 1.5, minSize: 2 });
|
|
44
|
+
|
|
45
|
+
/** This function gives the distance of two points in the metric. */
|
|
46
|
+
function metricDistance(a: Point, b: Point, metric: SteinerMetric): number {
|
|
47
|
+
const dx = Math.abs(a.x - b.x);
|
|
48
|
+
const dy = Math.abs(a.y - b.y);
|
|
49
|
+
return metric === "rectilinear" ? dx + dy : Math.max(dx, dy) + (Math.SQRT2 - 1) * Math.min(dx, dy);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** This function gives the links of the spanning tree of the points (Prim), as pairs of indices, and the sum of their lengths. */
|
|
53
|
+
export function spanningTree(points: readonly Point[], metric: SteinerMetric): { links: [number, number][]; cost: number } {
|
|
54
|
+
const n = points.length;
|
|
55
|
+
if (n < 2) return { links: [], cost: 0 };
|
|
56
|
+
const inTree = new Uint8Array(n);
|
|
57
|
+
const best = new Float64Array(n).fill(Infinity);
|
|
58
|
+
const from = new Int32Array(n).fill(-1);
|
|
59
|
+
best[0] = 0;
|
|
60
|
+
const links: [number, number][] = [];
|
|
61
|
+
let cost = 0;
|
|
62
|
+
for (let k = 0; k < n; k++) {
|
|
63
|
+
let u = -1;
|
|
64
|
+
for (let i = 0; i < n; i++) if (!inTree[i] && (u < 0 || best[i]! < best[u]!)) u = i;
|
|
65
|
+
inTree[u] = 1;
|
|
66
|
+
if (from[u]! >= 0) {
|
|
67
|
+
links.push([from[u]!, u]);
|
|
68
|
+
cost += best[u]!;
|
|
69
|
+
}
|
|
70
|
+
for (let i = 0; i < n; i++) {
|
|
71
|
+
if (inTree[i]) continue;
|
|
72
|
+
const d = metricDistance(points[u]!, points[i]!, metric);
|
|
73
|
+
if (d < best[i]!) {
|
|
74
|
+
best[i] = d;
|
|
75
|
+
from[i] = u;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { links, cost };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* This function gives a Steiner tree of the terminals by the iterated 1-Steiner method (Kahng and Robins). The candidates are the points of the Hanan grid.
|
|
84
|
+
* Each round adds the candidate that makes the spanning tree shortest, and drops each added point with two links or fewer.
|
|
85
|
+
* The test `free` removes the candidates in the boxes. The result has the terminals first, then the added points.
|
|
86
|
+
*/
|
|
87
|
+
export function oneSteiner(terminals: readonly Point[], metric: SteinerMetric, free: (p: Point) => boolean): { points: Point[]; links: [number, number][] } {
|
|
88
|
+
const points = [...terminals];
|
|
89
|
+
const xs = [...new Set(terminals.map((p) => p.x))].sort((a, b) => a - b);
|
|
90
|
+
const ys = [...new Set(terminals.map((p) => p.y))].sort((a, b) => a - b);
|
|
91
|
+
const taken = new Set(terminals.map((p) => `${p.x},${p.y}`));
|
|
92
|
+
const candidates: Point[] = [];
|
|
93
|
+
for (const x of xs) for (const y of ys) if (!taken.has(`${x},${y}`) && free({ x, y })) candidates.push({ x, y });
|
|
94
|
+
let tree = spanningTree(points, metric);
|
|
95
|
+
for (let round = 0; round < terminals.length; round++) {
|
|
96
|
+
let best = -1;
|
|
97
|
+
let bestCost = tree.cost - 1e-9 * Math.max(1, tree.cost);
|
|
98
|
+
candidates.forEach((c, i) => {
|
|
99
|
+
const cost = spanningTree([...points, c], metric).cost;
|
|
100
|
+
if (cost < bestCost) [best, bestCost] = [i, cost];
|
|
101
|
+
});
|
|
102
|
+
if (best < 0) break;
|
|
103
|
+
points.push(candidates[best]!);
|
|
104
|
+
candidates.splice(best, 1);
|
|
105
|
+
tree = spanningTree(points, metric);
|
|
106
|
+
// An added point with two links or fewer saves nothing, so it leaves the tree.
|
|
107
|
+
for (;;) {
|
|
108
|
+
const degree = new Array<number>(points.length).fill(0);
|
|
109
|
+
for (const [a, b] of tree.links) {
|
|
110
|
+
degree[a] = degree[a]! + 1;
|
|
111
|
+
degree[b] = degree[b]! + 1;
|
|
112
|
+
}
|
|
113
|
+
let drop = -1;
|
|
114
|
+
for (let i = terminals.length; i < points.length; i++) if (degree[i]! <= 2) drop = i;
|
|
115
|
+
if (drop < 0) break;
|
|
116
|
+
points.splice(drop, 1);
|
|
117
|
+
tree = spanningTree(points, metric);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { points, links: tree.links };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* This function gives the polyline of one link of the tree. A rectilinear link is an L in clear view, else a path through the corner graph.
|
|
125
|
+
* An octilinear link is a straight piece and a diagonal piece. The same link gives the same points each time.
|
|
126
|
+
*/
|
|
127
|
+
function linkPath(a: Point, b: Point, metric: SteinerMetric, finder: WayFinder): Point[] {
|
|
128
|
+
const pad = finder.margin / 2;
|
|
129
|
+
const dx = b.x - a.x;
|
|
130
|
+
const dy = b.y - a.y;
|
|
131
|
+
const tries: Point[][] = [];
|
|
132
|
+
if (Math.abs(dx) < 1e-9 || Math.abs(dy) < 1e-9) tries.push([a, b]);
|
|
133
|
+
else if (metric === "rectilinear") tries.push([a, { x: b.x, y: a.y }, b], [a, { x: a.x, y: b.y }, b]);
|
|
134
|
+
else {
|
|
135
|
+
const d = Math.min(Math.abs(dx), Math.abs(dy));
|
|
136
|
+
const diag = { x: a.x + Math.sign(dx) * d, y: a.y + Math.sign(dy) * d };
|
|
137
|
+
const late = { x: b.x - Math.sign(dx) * d, y: b.y - Math.sign(dy) * d };
|
|
138
|
+
tries.push([a, diag, b], [a, late, b]);
|
|
139
|
+
}
|
|
140
|
+
for (const t of tries) if (finder.obstacles.clearPath(t, pad)) return t.filter((p, i) => i === 0 || p.x !== t[i - 1]!.x || p.y !== t[i - 1]!.y);
|
|
141
|
+
const way = finder.corners.path(a, b);
|
|
142
|
+
return way ? finder.corners.pull(way) : [a, b];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* This function gives the highways of obstacle-avoiding Steiner trees (HWY-036 with EDA-015). Each group of edges gets one tree over the points out of its plugs.
|
|
147
|
+
* The links of the tree are rectilinear or octilinear, and they go around the boxes. Each edge takes its path in the tree, when that path is short enough.
|
|
148
|
+
* The stretches of the tree that two edges or more share become highways.
|
|
149
|
+
*/
|
|
150
|
+
export function steinerPlan(input: HighwayInput, o: Readonly<SteinerOptions>): HighwayPlan {
|
|
151
|
+
const finder = new WayFinder(input.graph.nodes);
|
|
152
|
+
const pad = finder.margin / 2;
|
|
153
|
+
const first = firstRoutes(input);
|
|
154
|
+
const groups = new Map<string, { edge: string; s: Point; t: Point; base: number }[]>();
|
|
155
|
+
for (const p of edgePlugs(input.graph)) {
|
|
156
|
+
const key = o.group === "key" ? (byCollection(p.edge) ?? `\u0000${p.edge.source.node}`) : p.edge.source.node;
|
|
157
|
+
const s = finder.out(p.ps);
|
|
158
|
+
const t = finder.out(p.pt);
|
|
159
|
+
const route = first.get(p.edge.id);
|
|
160
|
+
const list = groups.get(key) ?? [];
|
|
161
|
+
list.push({ edge: p.edge.id, s, t, base: route ? polylineLength(route) : metricDistance(s, t, "rectilinear") });
|
|
162
|
+
groups.set(key, list);
|
|
163
|
+
}
|
|
164
|
+
const ways: KeyedWay[] = [];
|
|
165
|
+
for (const key of [...groups.keys()].sort()) {
|
|
166
|
+
const list = groups.get(key)!;
|
|
167
|
+
if (list.length < 2) continue;
|
|
168
|
+
const index = new Map<string, number>();
|
|
169
|
+
const terminals: Point[] = [];
|
|
170
|
+
const at = (p: Point): number => {
|
|
171
|
+
const k = `${p.x},${p.y}`;
|
|
172
|
+
let i = index.get(k);
|
|
173
|
+
if (i === undefined) {
|
|
174
|
+
index.set(k, (i = terminals.length));
|
|
175
|
+
terminals.push(p);
|
|
176
|
+
}
|
|
177
|
+
return i;
|
|
178
|
+
};
|
|
179
|
+
const ends = list.map((e) => [at(e.s), at(e.t)] as const);
|
|
180
|
+
const tree = o.method === "one-steiner" && terminals.length <= o.maxGroup ? oneSteiner(terminals, o.metric, (p) => !finder.obstacles.inside(p, pad)) : { points: terminals, ...spanningTree(terminals, o.metric) };
|
|
181
|
+
const n = tree.points.length;
|
|
182
|
+
const next: { to: number; path: Point[] }[][] = Array.from({ length: n }, () => []);
|
|
183
|
+
for (const [a, b] of tree.links) {
|
|
184
|
+
const path = linkPath(tree.points[a]!, tree.points[b]!, o.metric, finder);
|
|
185
|
+
next[a]!.push({ to: b, path });
|
|
186
|
+
next[b]!.push({ to: a, path: [...path].reverse() });
|
|
187
|
+
}
|
|
188
|
+
list.forEach((e, k) => {
|
|
189
|
+
const [s, t] = ends[k]!;
|
|
190
|
+
// The path in the tree from `s` to `t`, by a search from `s`.
|
|
191
|
+
const back = new Int32Array(n).fill(-2);
|
|
192
|
+
const via: (Point[] | undefined)[] = new Array(n);
|
|
193
|
+
back[s] = -1;
|
|
194
|
+
const queue = [s];
|
|
195
|
+
while (queue.length > 0) {
|
|
196
|
+
const u = queue.shift()!;
|
|
197
|
+
if (u === t) break;
|
|
198
|
+
for (const x of next[u]!) {
|
|
199
|
+
if (back[x.to] !== -2) continue;
|
|
200
|
+
back[x.to] = u;
|
|
201
|
+
via[x.to] = x.path;
|
|
202
|
+
queue.push(x.to);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (back[t] === -2 || s === t) return;
|
|
206
|
+
const pieces: Point[][] = [];
|
|
207
|
+
for (let v = t; v !== s; v = back[v]!) pieces.push(via[v]!);
|
|
208
|
+
const points: Point[] = [];
|
|
209
|
+
for (const piece of pieces.reverse()) for (const p of piece) if (points.length === 0 || p.x !== points[points.length - 1]!.x || p.y !== points[points.length - 1]!.y) points.push(p);
|
|
210
|
+
if (polylineLength(points) > o.detour * e.base) return;
|
|
211
|
+
ways.push({ edge: e.edge, key, points });
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return sharedStretches(ways, { minSize: o.minSize });
|
|
215
|
+
}
|