@mark1russell7/highways 0.4.2 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +59 -2
  2. package/build/index.d.ts +2333 -193
  3. package/build/index.d.ts.map +1 -1
  4. package/build/index.js +13042 -2334
  5. package/build/index.js.map +1 -1
  6. package/package.json +12 -1
  7. package/src/bench/exact/index.ts +258 -0
  8. package/src/bench/exact/optima-exact-v1-bounds.json +14409 -0
  9. package/src/bench/exact/optima-exact-v1.json +4809 -0
  10. package/src/bench/exact/solve.ts +129 -0
  11. package/src/bench/measures/index.ts +1 -0
  12. package/src/bench/measures/lanes.ts +304 -0
  13. package/src/bench/measures/levels.ts +86 -2
  14. package/src/bench/screen/activation.ts +3 -0
  15. package/src/bench/screen/bounds.ts +140 -0
  16. package/src/bench/screen/compare.ts +26 -0
  17. package/src/bench/screen/exact-set.ts +100 -0
  18. package/src/bench/screen/exercise.ts +290 -0
  19. package/src/bench/screen/forms.ts +25 -5
  20. package/src/bench/screen/gap.ts +316 -0
  21. package/src/bench/screen/hash.ts +7 -76
  22. package/src/bench/screen/index.ts +6 -0
  23. package/src/bench/screen/measures.ts +89 -1
  24. package/src/bench/screen/portfolio-pick.ts +20 -0
  25. package/src/bench/screen/screen.ts +78 -2
  26. package/src/bench/screen/set.ts +3 -2
  27. package/src/channels.ts +56 -0
  28. package/src/highways/areas/index.ts +160 -0
  29. package/src/highways/confluent/index.ts +154 -0
  30. package/src/highways/confluent/k23.test-util.ts +8 -0
  31. package/src/highways/consensus/index.ts +129 -0
  32. package/src/highways/crossbars/index.ts +184 -0
  33. package/src/highways/cubu/index.ts +26 -0
  34. package/src/highways/diffusion/index.ts +103 -0
  35. package/src/highways/divided/index.ts +111 -0
  36. package/src/highways/edge-paths/index.ts +155 -0
  37. package/src/highways/fdeb/index.ts +193 -0
  38. package/src/highways/fixtures.test-util.ts +71 -0
  39. package/src/highways/flow-clusters/index.ts +143 -0
  40. package/src/highways/frechet/index.ts +232 -0
  41. package/src/highways/growth/index.ts +148 -0
  42. package/src/highways/hdbscan/index.ts +250 -0
  43. package/src/highways/hierarchy/index.ts +140 -0
  44. package/src/highways/hough/index.ts +182 -0
  45. package/src/highways/hub-arcs/index.ts +181 -0
  46. package/src/highways/index.ts +79 -0
  47. package/src/highways/kdeeb/index.ts +355 -0
  48. package/src/highways/lanes/index.ts +189 -0
  49. package/src/highways/link-communities/index.ts +189 -0
  50. package/src/highways/mesh/index.ts +209 -0
  51. package/src/highways/mingle/index.ts +171 -0
  52. package/src/highways/mlseb/index.ts +204 -0
  53. package/src/highways/nested/index.ts +143 -0
  54. package/src/highways/p-hub/index.ts +248 -0
  55. package/src/highways/power/index.ts +117 -0
  56. package/src/highways/resistance/index.ts +159 -0
  57. package/src/highways/ridges/index.ts +328 -0
  58. package/src/highways/sbeb/index.ts +303 -0
  59. package/src/highways/scale-space/index.ts +135 -0
  60. package/src/highways/snap.ts +110 -0
  61. package/src/highways/soft-keys/index.ts +114 -0
  62. package/src/highways/spectral/index.ts +391 -0
  63. package/src/highways/spiral/index.ts +205 -0
  64. package/src/highways/territories/index.ts +150 -0
  65. package/src/highways/trace.ts +153 -0
  66. package/src/highways/traclus/index.ts +296 -0
  67. package/src/index.ts +2 -0
  68. package/src/portfolio/bands.ts +33 -0
  69. package/src/portfolio/fit.ts +29 -0
  70. package/src/portfolio/index.ts +6 -0
  71. package/src/portfolio/key.ts +98 -0
  72. package/src/portfolio/members.ts +171 -0
  73. package/src/portfolio/router.ts +133 -0
  74. package/src/portfolio/sets.ts +28 -0
  75. package/src/routers/shared.ts +56 -3
  76. package/src/strategies/builtins.ts +376 -1
  77. package/src/strategies/schemas.ts +460 -1
  78. package/src/visibility.ts +5 -0
@@ -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,193 @@
1
+ import { dist, type Point } from "../../geom.ts";
2
+ import { WayFinder, type WayOptions } from "../../ways.ts";
3
+ import { compatibility } from "../hdbscan/index.ts";
4
+ import type { HighwayInput, HighwayPlan } from "../model.ts";
5
+ import { edgePlugs } from "../stretches.ts";
6
+ import { boxPush, outOfBoxes, tracePlan } from "../trace.ts";
7
+
8
+ /** The options of the highway strategy `fdeb` (HWY-002 with RULE-002 of the notes). */
9
+ export interface FdebOptions extends Pick<WayOptions, "margin" | "k"> {
10
+ /** Two edges attract each other when their compatibility is this value or more. The default is 0.6. */
11
+ readonly compatibility: number;
12
+ /** The spring constant that keeps each edge smooth. The default is 0.1. */
13
+ readonly spring: number;
14
+ /** The number of cycles. Each cycle doubles the points of each edge and halves the step. The default is 5. */
15
+ readonly cycles: number;
16
+ /** The number of steps of the first cycle. Each later cycle has two thirds of the steps of the cycle before it. The default is 50. */
17
+ readonly iterations: number;
18
+ /** The weight of the push of the boxes (RULE-002). The value 0 turns it off. The default is 1. */
19
+ readonly repulsion: number;
20
+ /**
21
+ * The stretch budget of each edge (RULE-002). An edge whose polyline is longer than this value times its straight line gets no pull.
22
+ * `Infinity` sets no budget. The default is 1.25.
23
+ */
24
+ readonly pullLimit: number;
25
+ /** The side of the grid that the points of the edges snap to after the cycles, in units of the board. The default is 8. */
26
+ readonly snap: number;
27
+ /** A highway has this number of edges or more. The default is 2. */
28
+ readonly minSize: number;
29
+ }
30
+
31
+ /** The default options of the strategy `fdeb`. */
32
+ export const FDEB_DEFAULTS: Readonly<Required<Omit<FdebOptions, "margin" | "k">>> = Object.freeze({
33
+ compatibility: 0.6,
34
+ spring: 0.1,
35
+ cycles: 5,
36
+ iterations: 50,
37
+ repulsion: 1,
38
+ pullLimit: 1.25,
39
+ snap: 8,
40
+ minSize: 2,
41
+ });
42
+
43
+ /** The largest number of partners of one edge. The partners with the largest compatibility stay. */
44
+ const PARTNERS = 32;
45
+
46
+ /** The first step of the points, as a share of the median length of the edges. */
47
+ const STEP = 0.04;
48
+
49
+ /** One straight edge: its two ends. */
50
+ interface Segment {
51
+ readonly a: Point;
52
+ readonly b: Point;
53
+ }
54
+
55
+ /** One partner of an edge: its index, its compatibility, and `flip` when it goes the other way. */
56
+ interface Partner {
57
+ readonly j: number;
58
+ readonly c: number;
59
+ readonly flip: boolean;
60
+ }
61
+
62
+ /**
63
+ * This function gives the partners of each edge: the edges with a compatibility of `threshold` or more, at most 32, the largest first.
64
+ * The search reads only the edges whose middles are near enough in x, so it does not test each pair of a large graph.
65
+ */
66
+ function partnersOf(segments: readonly Segment[], threshold: number): Partner[][] {
67
+ const n = segments.length;
68
+ const len = segments.map((s) => dist(s.a, s.b));
69
+ const mid = segments.map((s) => ({ x: (s.a.x + s.b.x) / 2, y: (s.a.y + s.b.y) / 2 }));
70
+ const longest = len.reduce((m, l) => Math.max(m, l), 0);
71
+ const t = Math.max(1e-3, Math.min(1, threshold));
72
+ const order = Array.from({ length: n }, (_, i) => i).sort((i, j) => mid[i]!.x - mid[j]!.x || i - j);
73
+ const found: Partner[][] = Array.from({ length: n }, () => []);
74
+ for (let a = 0; a < n; a++) {
75
+ const i = order[a]!;
76
+ // `Cp >= t` needs a distance of the middles of `avg (1 / t - 1)` or less.
77
+ const reach = ((len[i]! + longest) / 2) * (1 / t - 1);
78
+ for (let b = a + 1; b < n; b++) {
79
+ const j = order[b]!;
80
+ if (mid[j]!.x - mid[i]!.x > reach) break;
81
+ const c = compatibility(segments[i]!.a, segments[i]!.b, segments[j]!.a, segments[j]!.b);
82
+ if (c < t) continue;
83
+ const si = segments[i]!;
84
+ const sj = segments[j]!;
85
+ const flip = (si.b.x - si.a.x) * (sj.b.x - sj.a.x) + (si.b.y - si.a.y) * (sj.b.y - sj.a.y) < 0;
86
+ found[i]!.push({ j, c, flip });
87
+ found[j]!.push({ j: i, c, flip });
88
+ }
89
+ }
90
+ return found.map((list) => list.sort((p, q) => q.c - p.c || p.j - q.j).slice(0, PARTNERS));
91
+ }
92
+
93
+ /** This function gives the length of a polyline. */
94
+ const lengthOf = (p: readonly Point[]): number => {
95
+ let s = 0;
96
+ for (let i = 1; i < p.length; i++) s += dist(p[i - 1]!, p[i]!);
97
+ return s;
98
+ };
99
+
100
+ /** This function gives a polyline with a new point in the middle of each leg. */
101
+ function subdivide(p: readonly Point[]): Point[] {
102
+ const out: Point[] = [p[0]!];
103
+ for (let i = 1; i < p.length; i++) {
104
+ const a = p[i - 1]!;
105
+ const b = p[i]!;
106
+ out.push({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, b);
107
+ }
108
+ return out;
109
+ }
110
+
111
+ /**
112
+ * This function gives the polylines of the force-directed edge bundling (HWY-002, Holten and van Wijk 2009), with the box field of RULE-002.
113
+ * Each edge starts as the straight line between its two points out of the plugs. Each cycle doubles its points.
114
+ * In each step, a point moves by `step` times the mean pull to the matching points of its partners. The springs to its two neighbours also move it.
115
+ * The boxes push the points, and after each cycle a point in a box moves out of it. An edge over its stretch budget gets no pull in that step.
116
+ */
117
+ export function fdebLines(input: HighwayInput, finder: WayFinder, o: Readonly<FdebOptions>): Map<string, Point[]> {
118
+ const plugs = edgePlugs(input.graph);
119
+ const segments = plugs.map(({ ps, pt }) => ({ a: finder.out(ps), b: finder.out(pt) }));
120
+ const n = segments.length;
121
+ const out = new Map<string, Point[]>();
122
+ if (n === 0) return out;
123
+ const partners = partnersOf(segments, o.compatibility);
124
+ const lengths = segments.map((s) => dist(s.a, s.b));
125
+ const sorted = [...lengths].sort((a, b) => a - b);
126
+ const median = sorted[Math.floor(sorted.length / 2)]!;
127
+ const pad = finder.margin / 2;
128
+ const reach = finder.margin * 2;
129
+ const budget = o.pullLimit > 0 ? o.pullLimit : Infinity;
130
+ let lines: Point[][] = segments.map((s) => [s.a, s.b]);
131
+ let step = STEP * median;
132
+ let iterations = Math.max(0, Math.round(o.iterations));
133
+ for (let cycle = 0; cycle < Math.max(0, Math.round(o.cycles)); cycle++) {
134
+ lines = lines.map(subdivide);
135
+ const P = lines[0]!.length;
136
+ for (let it = 0; it < iterations; it++) {
137
+ const next = lines.map((l) => [...l]);
138
+ for (let e = 0; e < n; e++) {
139
+ const line = lines[e]!;
140
+ const pull = lengths[e]! > 1e-9 && lengthOf(line) <= budget * lengths[e]!;
141
+ for (let i = 1; i < P - 1; i++) {
142
+ const p = line[i]!;
143
+ let fx = 0;
144
+ let fy = 0;
145
+ if (pull) {
146
+ let w = 0;
147
+ for (const q of partners[e]!) {
148
+ const m = lines[q.j]![q.flip ? P - 1 - i : i]!;
149
+ const d = dist(p, m);
150
+ if (d < 1e-9) continue;
151
+ fx += (q.c * (m.x - p.x)) / d;
152
+ fy += (q.c * (m.y - p.y)) / d;
153
+ w += q.c;
154
+ }
155
+ if (w > 0) {
156
+ fx /= w;
157
+ fy /= w;
158
+ }
159
+ }
160
+ if (o.repulsion > 0) {
161
+ const push = boxPush(finder.obstacles, p, reach);
162
+ fx += o.repulsion * push.x;
163
+ fy += o.repulsion * push.y;
164
+ }
165
+ const a = line[i - 1]!;
166
+ const b = line[i + 1]!;
167
+ const sx = o.spring * ((a.x + b.x) / 2 - p.x);
168
+ const sy = o.spring * ((a.y + b.y) / 2 - p.y);
169
+ next[e]![i] = { x: p.x + step * fx + sx, y: p.y + step * fy + sy };
170
+ }
171
+ }
172
+ lines = next;
173
+ }
174
+ // The hard projection of RULE-002: no point stays in a box.
175
+ lines = lines.map((l) => l.map((p, i) => (i === 0 || i === l.length - 1 ? p : outOfBoxes(finder.obstacles, p, pad))));
176
+ step /= 2;
177
+ iterations = Math.round((iterations * 2) / 3);
178
+ }
179
+ plugs.forEach(({ edge }, e) => out.set(edge.id, lines[e]!));
180
+ return out;
181
+ }
182
+
183
+ /**
184
+ * This function gives the highways of the force-directed edge bundling (HWY-002 with RULE-002).
185
+ * The points of the bundled polylines snap to a grid of side `snap`, so the points of one bundle come together.
186
+ * Each edge then goes through its points along the corner graph, and the stretches that `minSize` edges share become highways.
187
+ */
188
+ export function fdebPlan(input: HighwayInput, o: Readonly<FdebOptions>): HighwayPlan {
189
+ const finder = new WayFinder(input.graph.nodes, { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) });
190
+ const lines = fdebLines(input, finder, o);
191
+ const waypoints = new Map([...lines].map(([edge, l]) => [edge, l.slice(1, -1)] as const));
192
+ return tracePlan(input, finder, waypoints, { snap: o.snap, minSize: o.minSize, key: "fdeb" });
193
+ }
@@ -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
+ }