@mark1russell7/highways 0.4.2 → 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 +18 -2
- package/build/index.d.ts +799 -68
- package/build/index.d.ts.map +1 -1
- package/build/index.js +6235 -2128
- package/build/index.js.map +1 -1
- package/package.json +3 -1
- package/src/bench/exact/index.ts +197 -0
- package/src/bench/measures/index.ts +1 -0
- package/src/bench/measures/lanes.ts +304 -0
- package/src/bench/measures/levels.ts +86 -2
- package/src/bench/screen/forms.ts +11 -5
- package/src/bench/screen/measures.ts +18 -0
- package/src/bench/screen/screen.ts +28 -0
- package/src/channels.ts +56 -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/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 +15 -0
- package/src/highways/lanes/index.ts +189 -0
- package/src/highways/mingle/index.ts +171 -0
- package/src/highways/nested/index.ts +143 -0
- package/src/highways/ridges/index.ts +328 -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/traclus/index.ts +296 -0
- package/src/index.ts +1 -0
- package/src/routers/shared.ts +56 -3
- package/src/strategies/builtins.ts +155 -1
- package/src/strategies/schemas.ts +180 -0
- package/src/visibility.ts +5 -0
|
@@ -45,6 +45,9 @@ const ROWS: readonly Row[] = [
|
|
|
45
45
|
["R11.q05", "R11", "route.plug-spacing.q05", "0 to 1", "higher", true],
|
|
46
46
|
["R13.outside", "R13", "route.outside", "count", "lower", false],
|
|
47
47
|
["R15.inflections", "R15", "route.inflections", "count for each route", "lower", false],
|
|
48
|
+
// MEAS3A, for the steps `router` and `highway`. R12 is the share of the ink near a parallel leg of other edges. A narrow channel between boxes moves it.
|
|
49
|
+
["R12.share", "R12", "route.lane.separation", "0 to 1", "lower", true],
|
|
50
|
+
["R14.share", "R14", "route.orthogonality", "0 to 1", "higher", false],
|
|
48
51
|
["ink", "R1", "route.ink", "units", "lower", true],
|
|
49
52
|
// The bundles.
|
|
50
53
|
["B1.inkSaved", "B1", "route.bundle.ink-saved", "0 to 1", "higher", false],
|
|
@@ -54,6 +57,18 @@ const ROWS: readonly Row[] = [
|
|
|
54
57
|
["B3.median", "B3", "route.bundle.stretch.median", "ratio", "lower", false],
|
|
55
58
|
["B3.q95", "B3", "route.bundle.stretch.q95", "ratio", "lower", false],
|
|
56
59
|
["B6.max", "B6", "route.bundle.merge-angle.max", "degrees", "lower", false],
|
|
60
|
+
// MEAS3A, for the steps `router` and `highway`. B5, B8 and B9 read the runs and the trunks of the result. B8 is good or bad only in context, as B2.
|
|
61
|
+
["B5.crossings", "B5", "route.bundle.lane-crossings", "count", "lower", false],
|
|
62
|
+
["B7.ratio", "B7", "route.bundle.occupied-area", "ratio", "lower", false],
|
|
63
|
+
["B8.runs", "B8", "route.bundle.runs", "count", "lower", false],
|
|
64
|
+
["B8.median", "B8", "route.bundle.run-edges.median", "count", "higher", false],
|
|
65
|
+
["B8.max", "B8", "route.bundle.run-edges.max", "count", "higher", false],
|
|
66
|
+
["B9.median", "B9", "route.bundle.lane-fidelity.median", "ratio", "lower", false],
|
|
67
|
+
["B9.max", "B9", "route.bundle.lane-fidelity.max", "ratio", "lower", false],
|
|
68
|
+
// M1 and M4 of the room, for the steps `router` and `highway`: the lanes of each shared segment open at `MEASURE_LANE_WIDTH`.
|
|
69
|
+
// The step `room` of `runSuite` gives the same keys for the boards that a room strategy opens.
|
|
70
|
+
["M1.area", "M1", "route.lane.area", "ratio", "lower", false],
|
|
71
|
+
["M4.laneThrough", "M4", "route.lane.through", "count", "lower", true],
|
|
57
72
|
// The order fragility (W3 of MEAS3, from WILD-056): the spread of a measure over random orders of the edges. The placement can move it.
|
|
58
73
|
["W3.crossings", "W3", "route.order.crossings", "IQR over the median", "lower", true],
|
|
59
74
|
["W3.detour", "W3", "route.order.detour", "IQR over the median", "lower", true],
|
|
@@ -75,6 +90,9 @@ const ROWS: readonly Row[] = [
|
|
|
75
90
|
["Z10.ids", "Z10", "route.stability.highway-ids", "0 to 1", "higher", false],
|
|
76
91
|
// The zoom levels and the groups. The default grouping rule reads the positions, so most of these measures depend on the placement.
|
|
77
92
|
["level-nesting", "Z6", "route.level.nesting", "count", "lower", false],
|
|
93
|
+
// MEAS3A, for the steps `router`, `highway` and `group`. Z2 is the count of the hard rule `link-ends`. Z9 needs a result with zoom levels.
|
|
94
|
+
["Z2.linkEnds", "Z2", "route.level.link-ends", "count", "lower", false],
|
|
95
|
+
["Z9.mean", "Z9", "route.level.link-agreement", "no unit", "lower", true],
|
|
78
96
|
["Z3.through", "Z3", "route.level.through", "count", "lower", true],
|
|
79
97
|
["Z3.length", "Z3", "route.level.link-length", "units", "lower", true],
|
|
80
98
|
["Z4.farItems", "Z4", "route.level.far-items", "count", "lower", true],
|
|
@@ -7,8 +7,11 @@ import { asRouter, createRegistry, getStrategy, listStrategies, register, STEPS,
|
|
|
7
7
|
import { compareStep, type CompareRow, type CompareStep, type StepComparison } from "../compare.ts";
|
|
8
8
|
import { bundledShare, bundleStretch, mergeAngle } from "../measures/bundles.ts";
|
|
9
9
|
import { highwayEdits } from "../measures/highways.ts";
|
|
10
|
+
import { laneArea, laneCrossings, laneFidelity, lanesThrough, occupiedArea, openLanes, orthogonality, routeSeparation, runProfile } from "../measures/lanes.ts";
|
|
11
|
+
import { linkAgreement } from "../measures/levels.ts";
|
|
10
12
|
import { orderFragility } from "../measures/order.ts";
|
|
11
13
|
import { paretoFront, type Objective } from "../measures/pareto.ts";
|
|
14
|
+
import { boardUnit } from "../measures/routes.ts";
|
|
12
15
|
import { routeStability } from "../measures/stability.ts";
|
|
13
16
|
import { quantile } from "../measures/stats.ts";
|
|
14
17
|
import { checkLevels, checkProperties, TOLERANCE } from "../properties.ts";
|
|
@@ -208,6 +211,8 @@ const givesRoutes = (step: ScreenStep): step is "router" | "highway" => step ===
|
|
|
208
211
|
/**
|
|
209
212
|
* This function gives the values of a row of `compareStep`, with the measures of `measureQuality` for a router.
|
|
210
213
|
* A router also gets the bundled share (B2), the merge angle (B6) and the bundle stretch (B3) against the routes `base` of `corners`.
|
|
214
|
+
* It also gets the measures of MEAS3A: R12, R14, B5, B7, B8, B9, M1 and M4. The lane measures open each shared segment at `MEASURE_LANE_WIDTH`.
|
|
215
|
+
* A result with zoom levels also gets the link agreement (Z9), for a router and for a grouping rule.
|
|
211
216
|
*/
|
|
212
217
|
function rowValues(step: ScreenStep, graph: Graph, row: CompareRow, base: () => readonly Route[]): Record<string, number> {
|
|
213
218
|
const values: Record<string, number> = { ...row.values };
|
|
@@ -224,6 +229,27 @@ function rowValues(step: ScreenStep, graph: Graph, row: CompareRow, base: () =>
|
|
|
224
229
|
values["B3.median"] = st.median;
|
|
225
230
|
values["B3.q95"] = st.q95;
|
|
226
231
|
values["B6.max"] = mergeAngle(row.result.routes);
|
|
232
|
+
const routes = row.result.routes;
|
|
233
|
+
const open = openLanes(row.result);
|
|
234
|
+
values["R12.share"] = routeSeparation(routes);
|
|
235
|
+
values["R14.share"] = orthogonality(routes);
|
|
236
|
+
values["B5.crossings"] = laneCrossings(open.graph);
|
|
237
|
+
values["B7.ratio"] = occupiedArea(routes, boardUnit(graph));
|
|
238
|
+
const runs = runProfile(open.graph);
|
|
239
|
+
values["B8.runs"] = runs.runs;
|
|
240
|
+
values["B8.median"] = runs.median;
|
|
241
|
+
values["B8.max"] = runs.max;
|
|
242
|
+
const lf = laneFidelity(routes, open);
|
|
243
|
+
if (lf) {
|
|
244
|
+
values["B9.median"] = lf.median;
|
|
245
|
+
values["B9.max"] = lf.max;
|
|
246
|
+
}
|
|
247
|
+
values["M1.area"] = laneArea(graph, open);
|
|
248
|
+
values["M4.laneThrough"] = lanesThrough(graph, open);
|
|
249
|
+
}
|
|
250
|
+
if ((givesRoutes(step) || step === "group") && row.result?.levels) {
|
|
251
|
+
const la = linkAgreement(graph, row.result.levels, row.result.routes, boardUnit(graph));
|
|
252
|
+
if (la !== undefined) values["Z9.mean"] = la;
|
|
227
253
|
}
|
|
228
254
|
return values;
|
|
229
255
|
}
|
|
@@ -350,6 +376,8 @@ export function screen(step: ScreenStep, strategies: readonly (Strategy<ScreenSt
|
|
|
350
376
|
}
|
|
351
377
|
const { hard, deterministic } = rowRules(step, graph, row, again?.rows[i]);
|
|
352
378
|
const values = rowValues(step, graph, row, cornersBase);
|
|
379
|
+
// Z2 is the count of the hard rule `link-ends`, as a measure. The steps `router`, `highway` and `group` check the rule.
|
|
380
|
+
if (hard["link-ends"] !== undefined) values["Z2.linkEnds"] = hard["link-ends"];
|
|
353
381
|
if (shuffles > 0 && row.result) {
|
|
354
382
|
const router = asRouter(strategy as Strategy<"router">, own[row.strategy]);
|
|
355
383
|
const f = orderFragility(graph, (g) => router.route(g), { orders: shuffles, seed, base: row.result });
|
package/src/channels.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { dist, type Box, type Point } from "./geom.ts";
|
|
2
|
+
import type { Obstacles } from "./visibility.ts";
|
|
3
|
+
|
|
4
|
+
/** The largest distance that `channelWidth` looks on each side of a link. A wider channel counts as this width on that side. */
|
|
5
|
+
export const CHANNEL_REACH = 64;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* This function gives the distance from `p` along the unit direction `d` to the box, or `Infinity` when the ray does not meet the box.
|
|
9
|
+
* A point inside the box gives 0. The function uses the slab test.
|
|
10
|
+
*/
|
|
11
|
+
export function rayToBox(p: Point, d: Point, box: Box): number {
|
|
12
|
+
let lo = 0;
|
|
13
|
+
let hi = Infinity;
|
|
14
|
+
for (const [o, v, min, max] of [
|
|
15
|
+
[p.x, d.x, box.x, box.x + box.w],
|
|
16
|
+
[p.y, d.y, box.y, box.y + box.h],
|
|
17
|
+
] as const) {
|
|
18
|
+
if (Math.abs(v) < 1e-12) {
|
|
19
|
+
if (o < min || o > max) return Infinity;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const t1 = (min - o) / v;
|
|
23
|
+
const t2 = (max - o) / v;
|
|
24
|
+
lo = Math.max(lo, Math.min(t1, t2));
|
|
25
|
+
hi = Math.min(hi, Math.max(t1, t2));
|
|
26
|
+
if (lo > hi) return Infinity;
|
|
27
|
+
}
|
|
28
|
+
return lo;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** This function gives the free distance from `p` along the unit direction `d` to the nearest box, up to `reach`. */
|
|
32
|
+
function freeRay(obstacles: Obstacles, p: Point, d: Point, reach: number): number {
|
|
33
|
+
const q = { x: p.x + d.x * reach, y: p.y + d.y * reach };
|
|
34
|
+
let best = reach;
|
|
35
|
+
obstacles.query({ x: Math.min(p.x, q.x), y: Math.min(p.y, q.y), w: Math.abs(q.x - p.x), h: Math.abs(q.y - p.y) }, (n) => {
|
|
36
|
+
best = Math.min(best, rayToBox(p, d, n.box));
|
|
37
|
+
});
|
|
38
|
+
return best;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* This function gives the width of the channel of the link from `a` to `b`. The width is the free space between the nearest boxes on the two sides.
|
|
43
|
+
* It measures the width at five points of the link, and gives the smallest. Each side counts up to `reach`.
|
|
44
|
+
* A link of length 0 gives the free space across the x axis at its point.
|
|
45
|
+
*/
|
|
46
|
+
export function channelWidth(obstacles: Obstacles, a: Point, b: Point, reach: number = CHANNEL_REACH): number {
|
|
47
|
+
const len = dist(a, b);
|
|
48
|
+
const n = len > 1e-9 ? { x: -(b.y - a.y) / len, y: (b.x - a.x) / len } : { x: 0, y: 1 };
|
|
49
|
+
const back = { x: -n.x, y: -n.y };
|
|
50
|
+
let width = Infinity;
|
|
51
|
+
for (const u of [0.1, 0.3, 0.5, 0.7, 0.9]) {
|
|
52
|
+
const p = { x: a.x + (b.x - a.x) * u, y: a.y + (b.y - a.y) * u };
|
|
53
|
+
width = Math.min(width, freeRay(obstacles, p, n, reach) + freeRay(obstacles, p, back, reach));
|
|
54
|
+
}
|
|
55
|
+
return width;
|
|
56
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { Point } from "../../geom.ts";
|
|
2
|
+
import { WayFinder, type WayOptions } from "../../ways.ts";
|
|
3
|
+
import type { HighwayInput, HighwayPlan } from "../model.ts";
|
|
4
|
+
import { edgePlugs, sharedStretches, type KeyedWay } from "../stretches.ts";
|
|
5
|
+
|
|
6
|
+
/** The options of the highway strategy `diffusion` (HWY-020 of the note on highways). */
|
|
7
|
+
export interface DiffusionOptions extends Pick<WayOptions, "margin" | "k"> {
|
|
8
|
+
/** The share of the density that each step of the diffusion takes from the links next to a link. The default is 0.25. */
|
|
9
|
+
readonly a: number;
|
|
10
|
+
/** The number of steps of the diffusion. The default is 3. */
|
|
11
|
+
readonly steps: number;
|
|
12
|
+
/** The weight of the density in the cost of a link. The default is 1. */
|
|
13
|
+
readonly lambda: number;
|
|
14
|
+
/** The power of the discount of a dense link. The default is 1. */
|
|
15
|
+
readonly gamma: number;
|
|
16
|
+
/** A highway has this number of edges or more. The default is 2. */
|
|
17
|
+
readonly minSize: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The default options of the strategy `diffusion`. */
|
|
21
|
+
export const DIFFUSION_DEFAULTS: Readonly<Required<Omit<DiffusionOptions, "margin" | "k">>> = Object.freeze({ a: 0.25, steps: 3, lambda: 1, gamma: 1, minSize: 2 });
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* This function gives the density of the edges on the links of the corner graph after the diffusion, as a share of the largest density.
|
|
25
|
+
* The density of a link starts as the number of first ways on it. Each step mixes it with the mean density of the links that share a corner with it:
|
|
26
|
+
* `rho_{k+1}(l) = (1 - a) rho_k(l) + a mean(rho_k(l'))`. The key of a link is `i * M + j` for the corners `i < j` of a graph of `M` corners.
|
|
27
|
+
*/
|
|
28
|
+
export function linkDensity(neighbours: (i: number) => readonly number[], corners: number, use: ReadonlyMap<number, number>, a: number, steps: number): Map<number, number> {
|
|
29
|
+
const M = corners;
|
|
30
|
+
const links: number[] = [];
|
|
31
|
+
for (let i = 0; i < M; i++) for (const j of neighbours(i)) if (j > i) links.push(i * M + j);
|
|
32
|
+
let rho = new Map<number, number>(links.map((l) => [l, use.get(l) ?? 0]));
|
|
33
|
+
const share = Math.max(0, Math.min(1, a));
|
|
34
|
+
for (let s = 0; s < Math.round(steps); s++) {
|
|
35
|
+
// The sum of the densities and the number of the links at each corner.
|
|
36
|
+
const sum = new Float64Array(M);
|
|
37
|
+
const deg = new Int32Array(M);
|
|
38
|
+
for (const [l, v] of rho) {
|
|
39
|
+
const i = Math.floor(l / M);
|
|
40
|
+
const j = l % M;
|
|
41
|
+
sum[i] = sum[i]! + v;
|
|
42
|
+
sum[j] = sum[j]! + v;
|
|
43
|
+
deg[i] = deg[i]! + 1;
|
|
44
|
+
deg[j] = deg[j]! + 1;
|
|
45
|
+
}
|
|
46
|
+
const next = new Map<number, number>();
|
|
47
|
+
for (const [l, v] of rho) {
|
|
48
|
+
const i = Math.floor(l / M);
|
|
49
|
+
const j = l % M;
|
|
50
|
+
const n = deg[i]! + deg[j]! - 2;
|
|
51
|
+
const mean = n > 0 ? (sum[i]! + sum[j]! - 2 * v) / n : v;
|
|
52
|
+
next.set(l, (1 - share) * v + share * mean);
|
|
53
|
+
}
|
|
54
|
+
rho = next;
|
|
55
|
+
}
|
|
56
|
+
let max = 0;
|
|
57
|
+
for (const v of rho.values()) max = Math.max(max, v);
|
|
58
|
+
if (max > 0) for (const [l, v] of rho) rho.set(l, v / max);
|
|
59
|
+
return rho;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* This function gives the highways of the route-aware bundling on the corner graph (HWY-020).
|
|
64
|
+
* The first ways of the edges give the density of each link. A diffusion along the graph smooths it.
|
|
65
|
+
* Then each edge takes a shortest way where a dense link costs less: `cost(l) = len(l) / (1 + lambda rho(l))^gamma`, with `rho` from 0 to 1.
|
|
66
|
+
* Each stretch that `minSize` ways or more share after the second pass is one highway. The highways stay on the links of the corner graph.
|
|
67
|
+
*/
|
|
68
|
+
export function diffusionPlan(input: HighwayInput, o: Readonly<DiffusionOptions>): HighwayPlan {
|
|
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 id = new Map<Point, number>();
|
|
72
|
+
corners.points.forEach((p, i) => id.set(p, i));
|
|
73
|
+
const M = corners.points.length;
|
|
74
|
+
const link = (i: number, j: number): number => (i < j ? i * M + j : j * M + i);
|
|
75
|
+
const plugs = edgePlugs(input.graph);
|
|
76
|
+
const ends = plugs.map(({ ps, pt }) => [finder.out(ps), finder.out(pt)] as const);
|
|
77
|
+
// The first pass: the number of first ways on each link.
|
|
78
|
+
const use = new Map<number, number>();
|
|
79
|
+
for (const [a, b] of ends) {
|
|
80
|
+
const way = corners.path(a, b);
|
|
81
|
+
if (!way) continue;
|
|
82
|
+
const own = new Set<number>();
|
|
83
|
+
for (let i = 1; i < way.length; i++) {
|
|
84
|
+
const p = id.get(way[i - 1]!);
|
|
85
|
+
const q = id.get(way[i]!);
|
|
86
|
+
if (p !== undefined && q !== undefined) own.add(link(p, q));
|
|
87
|
+
}
|
|
88
|
+
for (const l of own) use.set(l, (use.get(l) ?? 0) + 1);
|
|
89
|
+
}
|
|
90
|
+
const rho = linkDensity((i) => corners.neighbours(i), M, use, o.a, o.steps);
|
|
91
|
+
const lambda = Math.max(0, o.lambda);
|
|
92
|
+
const gamma = Math.max(0, o.gamma);
|
|
93
|
+
const floor = 1 / Math.pow(1 + lambda, gamma);
|
|
94
|
+
const cost = (i: number, j: number, d: number): number => d / Math.pow(1 + lambda * (rho.get(link(i, j)) ?? 0), gamma);
|
|
95
|
+
// The second pass: the dense links cost less, so the ways come together on them.
|
|
96
|
+
const ways: KeyedWay[] = [];
|
|
97
|
+
plugs.forEach(({ edge }, n) => {
|
|
98
|
+
const [a, b] = ends[n]!;
|
|
99
|
+
const way = corners.path(a, b, cost, floor);
|
|
100
|
+
if (way) ways.push({ edge: edge.id, key: "diffusion", points: way });
|
|
101
|
+
});
|
|
102
|
+
return sharedStretches(ways, { minSize: o.minSize, pull: (p) => corners.pull(p) });
|
|
103
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { center, dist, type Point } from "../../geom.ts";
|
|
2
|
+
import { WayFinder, type WayOptions } from "../../ways.ts";
|
|
3
|
+
import type { HighwayInput, HighwayPlan } from "../model.ts";
|
|
4
|
+
import { edgePlugs, sharedStretches, type KeyedWay } from "../stretches.ts";
|
|
5
|
+
|
|
6
|
+
/** The options of the highway strategy `edge-paths` (HWY-012 of the note on highways). */
|
|
7
|
+
export interface EdgePathOptions extends Pick<WayOptions, "margin" | "k"> {
|
|
8
|
+
/** An edge takes a path of other edges only when the path is this many times its own length or less. The default is 2. */
|
|
9
|
+
readonly distortion: number;
|
|
10
|
+
/** The weight of a link in the search is its length to this power. A larger power prefers paths of many short links. The default is 2. */
|
|
11
|
+
readonly power: number;
|
|
12
|
+
/** A highway has this number of edges or more. The default is 2. */
|
|
13
|
+
readonly minSize: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The default options of the strategy `edge-paths`. */
|
|
17
|
+
export const EDGE_PATH_DEFAULTS: Readonly<Required<Omit<EdgePathOptions, "margin" | "k">>> = Object.freeze({ distortion: 2, power: 2, minSize: 2 });
|
|
18
|
+
|
|
19
|
+
/** One step of the path of an edge: the edge of the step and `true` when the path goes from its target to its source. */
|
|
20
|
+
export interface PathStep {
|
|
21
|
+
readonly edge: string;
|
|
22
|
+
readonly back: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* This function gives the edge paths of the graph (Wallinger and others 2022). The edges go in the order of their lengths, the longest first.
|
|
27
|
+
* An edge looks for the shortest path of other edges between its nodes, with the weight `length ^ power`. A path is good when its length is `distortion` times the length of the edge or less.
|
|
28
|
+
* The edges of a good path lock: they keep their own ways. An edge with a good path leaves the graph of the search. The map holds the path of each such edge.
|
|
29
|
+
*/
|
|
30
|
+
export function edgePaths(input: HighwayInput, o: Readonly<Pick<EdgePathOptions, "distortion" | "power">>): Map<string, PathStep[]> {
|
|
31
|
+
const nodes = new Map(input.graph.nodes.map((n) => [n.id, center(n.box)] as const));
|
|
32
|
+
const links = input.graph.edges
|
|
33
|
+
.filter((e) => e.source.node !== e.target.node && nodes.has(e.source.node) && nodes.has(e.target.node))
|
|
34
|
+
.map((e) => ({ id: e.id, s: e.source.node, t: e.target.node, len: dist(nodes.get(e.source.node)!, nodes.get(e.target.node)!) }));
|
|
35
|
+
const around = new Map<string, number[]>();
|
|
36
|
+
links.forEach((l, i) => {
|
|
37
|
+
for (const n of [l.s, l.t]) {
|
|
38
|
+
const list = around.get(n);
|
|
39
|
+
if (list) list.push(i);
|
|
40
|
+
else around.set(n, [i]);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
const order = links.map((_, i) => i).sort((a, b) => links[b]!.len - links[a]!.len || (links[a]!.id < links[b]!.id ? -1 : 1));
|
|
44
|
+
const skip = new Uint8Array(links.length);
|
|
45
|
+
const lock = new Uint8Array(links.length);
|
|
46
|
+
const paths = new Map<string, PathStep[]>();
|
|
47
|
+
for (const i of order) {
|
|
48
|
+
if (lock[i]) continue;
|
|
49
|
+
const e = links[i]!;
|
|
50
|
+
skip[i] = 1;
|
|
51
|
+
const path = shortest(e.s, e.t, links, around, skip, o.power, o.distortion * e.len);
|
|
52
|
+
if (!path) {
|
|
53
|
+
skip[i] = 0;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
for (const step of path) lock[step.index] = 1;
|
|
57
|
+
paths.set(e.id, path.map((step) => ({ edge: links[step.index]!.id, back: step.back })));
|
|
58
|
+
}
|
|
59
|
+
return paths;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** This function gives the shortest path (Dijkstra) from the node `from` to the node `to` over the links that do not skip. It gives `undefined` when the length is more than `limit`. */
|
|
63
|
+
function shortest(
|
|
64
|
+
from: string,
|
|
65
|
+
to: string,
|
|
66
|
+
links: readonly { s: string; t: string; len: number }[],
|
|
67
|
+
around: ReadonlyMap<string, readonly number[]>,
|
|
68
|
+
skip: Uint8Array,
|
|
69
|
+
power: number,
|
|
70
|
+
limit: number,
|
|
71
|
+
): { index: number; back: boolean }[] | undefined {
|
|
72
|
+
const cost = new Map<string, number>([[from, 0]]);
|
|
73
|
+
const via = new Map<string, { index: number; back: boolean; prev: string }>();
|
|
74
|
+
const done = new Set<string>();
|
|
75
|
+
const open: { node: string; c: number }[] = [{ node: from, c: 0 }];
|
|
76
|
+
while (open.length > 0) {
|
|
77
|
+
let k = 0;
|
|
78
|
+
for (let j = 1; j < open.length; j++) if (open[j]!.c < open[k]!.c || (open[j]!.c === open[k]!.c && open[j]!.node < open[k]!.node)) k = j;
|
|
79
|
+
const { node } = open.splice(k, 1)[0]!;
|
|
80
|
+
if (done.has(node)) continue;
|
|
81
|
+
done.add(node);
|
|
82
|
+
if (node === to) break;
|
|
83
|
+
for (const index of around.get(node) ?? []) {
|
|
84
|
+
if (skip[index]) continue;
|
|
85
|
+
const l = links[index]!;
|
|
86
|
+
const back = l.t === node;
|
|
87
|
+
const next = back ? l.s : l.t;
|
|
88
|
+
if (done.has(next)) continue;
|
|
89
|
+
const c = cost.get(node)! + Math.pow(Math.max(l.len, 1e-9), power);
|
|
90
|
+
if (c < (cost.get(next) ?? Infinity)) {
|
|
91
|
+
cost.set(next, c);
|
|
92
|
+
via.set(next, { index, back, prev: node });
|
|
93
|
+
open.push({ node: next, c });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!via.has(to)) return undefined;
|
|
98
|
+
const out: { index: number; back: boolean }[] = [];
|
|
99
|
+
let length = 0;
|
|
100
|
+
for (let n = to; n !== from; ) {
|
|
101
|
+
const v = via.get(n)!;
|
|
102
|
+
out.push({ index: v.index, back: v.back });
|
|
103
|
+
length += links[v.index]!.len;
|
|
104
|
+
n = v.prev;
|
|
105
|
+
}
|
|
106
|
+
if (length > limit) return undefined;
|
|
107
|
+
return out.reverse();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* This function gives the highways of the edge paths (HWY-012). An edge with a good path follows the ways of the edges of its path.
|
|
112
|
+
* Each edge of the path keeps its own way through the corner graph. So the ways share their links, and each shared stretch is a highway.
|
|
113
|
+
*/
|
|
114
|
+
export function edgePathPlan(input: HighwayInput, o: Readonly<EdgePathOptions>): HighwayPlan {
|
|
115
|
+
const finder = new WayFinder(input.graph.nodes, { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) });
|
|
116
|
+
const corners = finder.corners;
|
|
117
|
+
const plugs = edgePlugs(input.graph);
|
|
118
|
+
const own = new Map<string, Point[]>();
|
|
119
|
+
for (const { edge, ps, pt } of plugs) {
|
|
120
|
+
const a = finder.out(ps);
|
|
121
|
+
const b = finder.out(pt);
|
|
122
|
+
own.set(edge.id, corners.path(a, b) ?? [a, b]);
|
|
123
|
+
}
|
|
124
|
+
const paths = edgePaths(input, o);
|
|
125
|
+
const ways: KeyedWay[] = [];
|
|
126
|
+
for (const { edge, ps, pt } of plugs) {
|
|
127
|
+
const path = paths.get(edge.id);
|
|
128
|
+
if (!path) {
|
|
129
|
+
ways.push({ edge: edge.id, key: "", points: own.get(edge.id)! });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const points: Point[] = [];
|
|
133
|
+
const join = (to: Point): void => {
|
|
134
|
+
const last = points[points.length - 1];
|
|
135
|
+
if (!last) {
|
|
136
|
+
points.push(to);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (dist(last, to) <= 1e-9) return;
|
|
140
|
+
const link = corners.path(last, to) ?? [last, to];
|
|
141
|
+
points.push(...link.slice(1));
|
|
142
|
+
};
|
|
143
|
+
join(finder.out(ps));
|
|
144
|
+
for (const step of path) {
|
|
145
|
+
const way = own.get(step.edge) ?? [];
|
|
146
|
+
const along = step.back ? [...way].reverse() : way;
|
|
147
|
+
if (along.length === 0) continue;
|
|
148
|
+
join(along[0]!);
|
|
149
|
+
points.push(...along.slice(1));
|
|
150
|
+
}
|
|
151
|
+
join(finder.out(pt));
|
|
152
|
+
ways.push({ edge: edge.id, key: "", points });
|
|
153
|
+
}
|
|
154
|
+
return sharedStretches(ways, { minSize: o.minSize, pull: (p) => corners.pull(p) });
|
|
155
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { checkProperties } from "../bench/properties.ts";
|
|
3
|
+
import { inputGraph } from "../bench/screen/forms.ts";
|
|
4
|
+
import { screenInputs } from "../bench/screen/set.ts";
|
|
5
|
+
import { CASES } from "../cases.ts";
|
|
6
|
+
import type { Box } from "../geom.ts";
|
|
7
|
+
import type { Graph } from "../model.ts";
|
|
8
|
+
import { STRATEGIES } from "../strategies/builtins.ts";
|
|
9
|
+
import { getStrategy } from "../strategies/model.ts";
|
|
10
|
+
import { checkPlan, type HighwayInput, type HighwayPlan } from "./model.ts";
|
|
11
|
+
import { highwayRouterStrategy } from "./router.ts";
|
|
12
|
+
|
|
13
|
+
/** This function gives a box at `x`, `y` with the size `w` by `h`. */
|
|
14
|
+
export const box = (x: number, y: number, w = 40, h = 20): Box => ({ x, y, w, h });
|
|
15
|
+
|
|
16
|
+
/** The case `case-hub-chain`. */
|
|
17
|
+
export const hub: Graph = CASES.find((c) => c.name === "case-hub-chain")!.make();
|
|
18
|
+
|
|
19
|
+
/** The input `screen-v1/small-clustered/s0`. */
|
|
20
|
+
export const clustered: Graph = inputGraph(screenInputs().find((i) => i.id === "screen-v1/small-clustered/s0")!);
|
|
21
|
+
|
|
22
|
+
/** Three edges from one area on the left to one area on the right (one of them reversed), and two edges from the top to the bottom. Two keys alternate. */
|
|
23
|
+
export const flows: Graph = {
|
|
24
|
+
nodes: [
|
|
25
|
+
...[0, 1, 2].flatMap((i) => [
|
|
26
|
+
{ id: `l${i}`, box: box(0, 30 * i) },
|
|
27
|
+
{ id: `r${i}`, box: box(600, 30 * i) },
|
|
28
|
+
]),
|
|
29
|
+
...[0, 1].flatMap((i) => [
|
|
30
|
+
{ id: `u${i}`, box: box(250 + 60 * i, -400) },
|
|
31
|
+
{ id: `d${i}`, box: box(250 + 60 * i, 400) },
|
|
32
|
+
]),
|
|
33
|
+
],
|
|
34
|
+
edges: [
|
|
35
|
+
{ id: "a", source: { node: "l0" }, target: { node: "r0" }, data: { collection: "p" } },
|
|
36
|
+
{ id: "b", source: { node: "l1" }, target: { node: "r1" }, data: { collection: "q" } },
|
|
37
|
+
{ id: "c", source: { node: "r2" }, target: { node: "l2" }, data: { collection: "p" } },
|
|
38
|
+
{ id: "x", source: { node: "u0" }, target: { node: "d0" }, data: { collection: "p" } },
|
|
39
|
+
{ id: "y", source: { node: "u1" }, target: { node: "d1" }, data: { collection: "q" } },
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Three thin nodes on the left and three on the right, with three near horizontal edges between them. */
|
|
44
|
+
export const parallel: Graph = {
|
|
45
|
+
nodes: [0, 1, 2].flatMap((i) => [
|
|
46
|
+
{ id: `l${i}`, box: box(0, 8 * i, 40, 6) },
|
|
47
|
+
{ id: `r${i}`, box: box(600, 8 * i, 40, 6) },
|
|
48
|
+
]),
|
|
49
|
+
edges: [0, 1, 2].map((i) => ({ id: `e${i}`, source: { node: `l${i}` }, target: { node: `r${i}` } })),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* This function adds the common tests of a highway strategy. The strategy is in the registry, and it gives a correct plan the same each time.
|
|
54
|
+
* Its routes break no hard rule after `follow`.
|
|
55
|
+
*/
|
|
56
|
+
export function commonTests<O extends object>(name: string, defaults: O, plan: (input: HighwayInput, o: O) => HighwayPlan, graphs: readonly Graph[]): void {
|
|
57
|
+
it("is in the registry, and gives a correct plan, the same each time", () => {
|
|
58
|
+
const strategy = getStrategy(STRATEGIES, "highway", name);
|
|
59
|
+
for (const graph of [...graphs, hub, clustered]) {
|
|
60
|
+
const made = strategy.run({ graph }, { ...defaults });
|
|
61
|
+
expect(checkPlan(graph, made)).toEqual([]);
|
|
62
|
+
expect(plan({ graph }, defaults)).toEqual(made);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("gives routes with no break of a hard rule after follow", () => {
|
|
67
|
+
const router = highwayRouterStrategy(getStrategy(STRATEGIES, "highway", name));
|
|
68
|
+
expect(checkProperties(hub, router.run(hub), 1_000_000)).toEqual([]);
|
|
69
|
+
expect(checkProperties(clustered, router.run(clustered), 1_000_000)).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
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
|
+
|
|
6
|
+
/** The linkage of the hierarchical clustering: the distance of two clusters from the distances of their edges. */
|
|
7
|
+
export type Linkage = "average" | "complete" | "single";
|
|
8
|
+
|
|
9
|
+
/** The values of `Linkage`. */
|
|
10
|
+
export const LINKAGES: readonly Linkage[] = Object.freeze(["average", "complete", "single"]);
|
|
11
|
+
|
|
12
|
+
/** The options of the highway strategy `flow-clusters` (HWY-023 of the note on highways). */
|
|
13
|
+
export interface FlowClusterOptions {
|
|
14
|
+
/** Two clusters join while their distance is this value or less. The distance is a share of the length of the shorter edge. The default is 0.3. */
|
|
15
|
+
readonly tau: number;
|
|
16
|
+
/** The linkage. The default is `average`. */
|
|
17
|
+
readonly linkage: Linkage;
|
|
18
|
+
/** With `true`, the source of one edge only compares with the source of the other edge. The default is `false`. */
|
|
19
|
+
readonly directed: boolean;
|
|
20
|
+
/** A cluster with this number of edges or more gets highways. The default is 2. */
|
|
21
|
+
readonly minSize: number;
|
|
22
|
+
/** The spine of each cluster. The default is `trunk`: one flow line from one area to the other area. */
|
|
23
|
+
readonly spine: GroupSpine;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The default options of the strategy `flow-clusters`. */
|
|
27
|
+
export const FLOW_CLUSTER_DEFAULTS: Readonly<FlowClusterOptions> = Object.freeze({ tau: 0.3, linkage: "average", directed: false, minSize: 2, spine: "trunk" });
|
|
28
|
+
|
|
29
|
+
/** The largest number of edges in one distance matrix. A larger graph splits into blocks of edges in the order of their sources. */
|
|
30
|
+
const BLOCK = 2500;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* This function gives the flow distance of two edges (Zhu and Guo 2014): `max(|s_e - s_f|, |t_e - t_f|) / min(len e, len f)`.
|
|
34
|
+
* Without `directed`, it is the smaller value of the two directions of `f`, and `flip` is `true` when the opposite direction is smaller.
|
|
35
|
+
*/
|
|
36
|
+
export function flowDistance(s1: Point, t1: Point, s2: Point, t2: Point, directed: boolean): { d: number; flip: boolean } {
|
|
37
|
+
const len = Math.max(1e-9, Math.min(dist(s1, t1), dist(s2, t2)));
|
|
38
|
+
const direct = Math.max(dist(s1, s2), dist(t1, t2)) / len;
|
|
39
|
+
if (directed) return { d: direct, flip: false };
|
|
40
|
+
const back = Math.max(dist(s1, t2), dist(t1, s2)) / len;
|
|
41
|
+
return back < direct ? { d: back, flip: true } : { d: direct, flip: false };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* This function clusters the points of a distance matrix with the chain of nearest neighbours. The linkage is reducible, so the chain gives the same tree as the greedy order.
|
|
46
|
+
* The clusters join while their distance is `tau` or less. The function gives the cluster of each point, as the index of a point of the cluster.
|
|
47
|
+
*/
|
|
48
|
+
export function chainClusters(n: number, d: Float64Array, tau: number, linkage: Linkage): Int32Array {
|
|
49
|
+
const size = new Int32Array(n).fill(1);
|
|
50
|
+
const alive = new Uint8Array(n).fill(1);
|
|
51
|
+
const parent = new Int32Array(n);
|
|
52
|
+
for (let i = 0; i < n; i++) parent[i] = i;
|
|
53
|
+
// A cluster that has no neighbour within `tau` is done: under a reducible linkage, its distances only grow.
|
|
54
|
+
const done = new Uint8Array(n);
|
|
55
|
+
const find = (i: number): number => {
|
|
56
|
+
while (parent[i] !== i) i = parent[i] = parent[parent[i]!]!;
|
|
57
|
+
return i;
|
|
58
|
+
};
|
|
59
|
+
const chain: number[] = [];
|
|
60
|
+
for (;;) {
|
|
61
|
+
if (chain.length === 0) {
|
|
62
|
+
let start = -1;
|
|
63
|
+
for (let i = 0; i < n && start < 0; i++) if (alive[i] && !done[i]) start = i;
|
|
64
|
+
if (start < 0) break;
|
|
65
|
+
chain.push(start);
|
|
66
|
+
}
|
|
67
|
+
const a = chain[chain.length - 1]!;
|
|
68
|
+
let b = -1;
|
|
69
|
+
let best = Infinity;
|
|
70
|
+
for (let j = 0; j < n; j++) {
|
|
71
|
+
if (j === a || !alive[j] || done[j]) continue;
|
|
72
|
+
const v = d[a * n + j]!;
|
|
73
|
+
if (v < best || (v === best && j < b)) [best, b] = [v, j];
|
|
74
|
+
}
|
|
75
|
+
const prev = chain.length >= 2 ? chain[chain.length - 2]! : -1;
|
|
76
|
+
if (b < 0 || best > tau) {
|
|
77
|
+
done[a] = 1;
|
|
78
|
+
chain.length = 0;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (prev >= 0 && d[a * n + prev]! <= best) {
|
|
82
|
+
// `a` and `prev` are nearest neighbours of each other: they join.
|
|
83
|
+
chain.length -= 2;
|
|
84
|
+
const [i, j] = a < prev ? [a, prev] : [prev, a];
|
|
85
|
+
for (let k = 0; k < n; k++) {
|
|
86
|
+
if (!alive[k] || k === i || k === j) continue;
|
|
87
|
+
const di = d[i * n + k]!;
|
|
88
|
+
const dj = d[j * n + k]!;
|
|
89
|
+
const v = linkage === "single" ? Math.min(di, dj) : linkage === "complete" ? Math.max(di, dj) : (size[i]! * di + size[j]! * dj) / (size[i]! + size[j]!);
|
|
90
|
+
d[i * n + k] = v;
|
|
91
|
+
d[k * n + i] = v;
|
|
92
|
+
}
|
|
93
|
+
size[i] = size[i]! + size[j]!;
|
|
94
|
+
alive[j] = 0;
|
|
95
|
+
parent[j] = i;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
chain.push(b);
|
|
99
|
+
}
|
|
100
|
+
const out = new Int32Array(n);
|
|
101
|
+
for (let i = 0; i < n; i++) out[i] = find(i);
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* This function gives the flow clusters of the edges (HWY-023). It clusters the edges as points in 4D, with the cut at `tau`.
|
|
107
|
+
* 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.
|
|
108
|
+
*/
|
|
109
|
+
export function flowClusters(input: HighwayInput, o: Readonly<FlowClusterOptions>): Map<string, EdgeGroup> {
|
|
110
|
+
const ends = edgePlugs(input.graph).map(({ edge, ps, pt }) => ({ edge: edge.id, s: ps.point, t: pt.point }));
|
|
111
|
+
const groups = new Map<string, EdgeGroup>();
|
|
112
|
+
// A large graph splits into blocks of `BLOCK` edges, in the order of the x of the sources.
|
|
113
|
+
const order = ends.length > BLOCK ? [...ends].sort((a, b) => a.s.x - b.s.x || a.s.y - b.s.y || (a.edge < b.edge ? -1 : 1)) : ends;
|
|
114
|
+
for (let from = 0; from < order.length; from += BLOCK) {
|
|
115
|
+
const block = order.slice(from, from + BLOCK);
|
|
116
|
+
const n = block.length;
|
|
117
|
+
const d = new Float64Array(n * n);
|
|
118
|
+
for (let i = 0; i < n; i++)
|
|
119
|
+
for (let j = i + 1; j < n; j++) {
|
|
120
|
+
const v = flowDistance(block[i]!.s, block[i]!.t, block[j]!.s, block[j]!.t, o.directed).d;
|
|
121
|
+
d[i * n + j] = v;
|
|
122
|
+
d[j * n + i] = v;
|
|
123
|
+
}
|
|
124
|
+
const cluster = chainClusters(n, d, o.tau, o.linkage);
|
|
125
|
+
const first = new Map<number, number>();
|
|
126
|
+
block.forEach((e, i) => {
|
|
127
|
+
const c = cluster[i]!;
|
|
128
|
+
if (!first.has(c)) first.set(c, i);
|
|
129
|
+
const lead = block[first.get(c)!]!;
|
|
130
|
+
const flip = i === first.get(c) ? false : flowDistance(lead.s, lead.t, e.s, e.t, o.directed).flip;
|
|
131
|
+
groups.set(e.edge, { key: `f${from + first.get(c)!}`, flip });
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return groups;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* This function gives the highways of the flow clusters (HWY-023). The clusters of `flowClusters` with `minSize` edges or more get highways.
|
|
139
|
+
* With the spine `trunk`, each cluster gets one trunk from one area to the other area. With `shared`, the edges of a cluster share their ways.
|
|
140
|
+
*/
|
|
141
|
+
export function flowClusterPlan(input: HighwayInput, o: Readonly<FlowClusterOptions>): HighwayPlan {
|
|
142
|
+
return groupedPlan(input, flowClusters(input, o), { spine: o.spine, minSize: o.minSize });
|
|
143
|
+
}
|