@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.
Files changed (36) hide show
  1. package/README.md +18 -2
  2. package/build/index.d.ts +799 -68
  3. package/build/index.d.ts.map +1 -1
  4. package/build/index.js +6235 -2128
  5. package/build/index.js.map +1 -1
  6. package/package.json +3 -1
  7. package/src/bench/exact/index.ts +197 -0
  8. package/src/bench/measures/index.ts +1 -0
  9. package/src/bench/measures/lanes.ts +304 -0
  10. package/src/bench/measures/levels.ts +86 -2
  11. package/src/bench/screen/forms.ts +11 -5
  12. package/src/bench/screen/measures.ts +18 -0
  13. package/src/bench/screen/screen.ts +28 -0
  14. package/src/channels.ts +56 -0
  15. package/src/highways/diffusion/index.ts +103 -0
  16. package/src/highways/edge-paths/index.ts +155 -0
  17. package/src/highways/fixtures.test-util.ts +71 -0
  18. package/src/highways/flow-clusters/index.ts +143 -0
  19. package/src/highways/growth/index.ts +148 -0
  20. package/src/highways/hdbscan/index.ts +250 -0
  21. package/src/highways/hough/index.ts +182 -0
  22. package/src/highways/hub-arcs/index.ts +181 -0
  23. package/src/highways/index.ts +15 -0
  24. package/src/highways/lanes/index.ts +189 -0
  25. package/src/highways/mingle/index.ts +171 -0
  26. package/src/highways/nested/index.ts +143 -0
  27. package/src/highways/ridges/index.ts +328 -0
  28. package/src/highways/snap.ts +110 -0
  29. package/src/highways/soft-keys/index.ts +114 -0
  30. package/src/highways/spiral/index.ts +205 -0
  31. package/src/highways/traclus/index.ts +296 -0
  32. package/src/index.ts +1 -0
  33. package/src/routers/shared.ts +56 -3
  34. package/src/strategies/builtins.ts +155 -1
  35. package/src/strategies/schemas.ts +180 -0
  36. package/src/visibility.ts +5 -0
@@ -0,0 +1,114 @@
1
+ import type { GroupRule } from "../../model.ts";
2
+ import { byCollection } from "../../routers/trunks.ts";
3
+ import { chainClusters, flowDistance, type Linkage } from "../flow-clusters/index.ts";
4
+ import { groupedPlan, type EdgeGroup, type GroupSpine } from "../grouped.ts";
5
+ import type { HighwayInput, HighwayPlan } from "../model.ts";
6
+ import { edgePlugs } from "../stretches.ts";
7
+
8
+ /** The options of the highway strategy `soft-keys` (HWY-019 of the note on highways). */
9
+ export interface SoftKeyOptions {
10
+ /** The rule that gives the key of each edge. The default is `byCollection`: the string `data.collection`. */
11
+ readonly rule: GroupRule;
12
+ /** 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
+ readonly lambda: number;
14
+ /** Two clusters join while their distance is this value or less. The default is 0.5. */
15
+ readonly tau: number;
16
+ /** The names of number fields of `data` that add a distance. The default is no field. */
17
+ readonly attributes: readonly string[];
18
+ /** The weight of each field of `attributes`. The distance of a field is its difference over its range. The default is 1. */
19
+ readonly weight: number;
20
+ /** The linkage of the clustering. The default is `average`. */
21
+ readonly linkage: Linkage;
22
+ /** A cluster with this number of edges or more gets highways. The default is 2. */
23
+ readonly minSize: number;
24
+ /** The spine of each cluster. The default is `shared`: the router `shared` with the cluster as the key. */
25
+ readonly spine: GroupSpine;
26
+ }
27
+
28
+ /** The default options of the strategy `soft-keys`. */
29
+ export const SOFT_KEY_DEFAULTS: Readonly<SoftKeyOptions> = Object.freeze({
30
+ rule: byCollection,
31
+ lambda: 0.25,
32
+ tau: 0.5,
33
+ attributes: Object.freeze([]) as readonly string[],
34
+ weight: 1,
35
+ linkage: "average",
36
+ minSize: 2,
37
+ spine: "shared",
38
+ });
39
+
40
+ /** The largest number of edges in one distance matrix. A larger graph splits into blocks of edges in the order of their sources. */
41
+ const BLOCK = 2500;
42
+
43
+ /**
44
+ * This function gives the soft-key distance of two edges (Peysakhovich, Hurter and Telea 2015): `d = d_geom + lambda [key(e) != key(f)] + sum_a w |a(e) - a(f)| / range(a)`.
45
+ * `d_geom` is the flow distance of HWY-023. The key term is 0 when an edge has no key.
46
+ */
47
+ export function softDistance(geom: number, keyA: string | undefined, keyB: string | undefined, lambda: number, attributes: number): number {
48
+ const key = keyA !== undefined && keyB !== undefined && keyA !== keyB ? lambda : 0;
49
+ return geom + key + attributes;
50
+ }
51
+
52
+ /**
53
+ * This function gives the soft-key clusters of the edges (HWY-019). It clusters the edges with `softDistance`, with the cut at `tau`.
54
+ * 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
+ */
56
+ export function softKeyClusters(input: HighwayInput, o: Readonly<SoftKeyOptions>): Map<string, EdgeGroup> {
57
+ const ends = edgePlugs(input.graph).map(({ edge, ps, pt }) => ({
58
+ edge: edge.id,
59
+ s: ps.point,
60
+ t: pt.point,
61
+ key: o.rule(edge),
62
+ values: o.attributes.map((a) => {
63
+ const v = edge.data?.[a];
64
+ return typeof v === "number" && Number.isFinite(v) ? v : undefined;
65
+ }),
66
+ }));
67
+ // The range of each field, so that each field adds a distance from 0 to `weight`.
68
+ const ranges = o.attributes.map((_, ai) => {
69
+ const values = ends.map((e) => e.values[ai]).filter((v): v is number => v !== undefined);
70
+ return values.length > 1 ? Math.max(...values) - Math.min(...values) : 0;
71
+ });
72
+ const extra = (a: (typeof ends)[number], b: (typeof ends)[number]): number => {
73
+ let sum = 0;
74
+ ranges.forEach((range, ai) => {
75
+ const va = a.values[ai];
76
+ const vb = b.values[ai];
77
+ if (range > 0 && va !== undefined && vb !== undefined) sum += (o.weight * Math.abs(va - vb)) / range;
78
+ });
79
+ return sum;
80
+ };
81
+ const groups = new Map<string, EdgeGroup>();
82
+ 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;
83
+ for (let from = 0; from < order.length; from += BLOCK) {
84
+ const block = order.slice(from, from + BLOCK);
85
+ const n = block.length;
86
+ const d = new Float64Array(n * n);
87
+ for (let i = 0; i < n; i++)
88
+ for (let j = i + 1; j < n; j++) {
89
+ const a = block[i]!;
90
+ const b = block[j]!;
91
+ const v = softDistance(flowDistance(a.s, a.t, b.s, b.t, false).d, a.key, b.key, o.lambda, extra(a, b));
92
+ d[i * n + j] = v;
93
+ d[j * n + i] = v;
94
+ }
95
+ const cluster = chainClusters(n, d, o.tau, o.linkage);
96
+ const first = new Map<number, number>();
97
+ block.forEach((e, i) => {
98
+ const c = cluster[i]!;
99
+ if (!first.has(c)) first.set(c, i);
100
+ const lead = block[first.get(c)!]!;
101
+ const flip = i === first.get(c) ? false : flowDistance(lead.s, lead.t, e.s, e.t, false).flip;
102
+ groups.set(e.edge, { key: `k${from + first.get(c)!}`, flip });
103
+ });
104
+ }
105
+ return groups;
106
+ }
107
+
108
+ /**
109
+ * This function gives the highways of the soft keys (HWY-019). The key of an edge is one more term of the distance, not a hard split.
110
+ * Edges of two keys still share a highway when they are very near, and edges of one key do not bundle when they are far.
111
+ */
112
+ export function softKeyPlan(input: HighwayInput, o: Readonly<SoftKeyOptions>): HighwayPlan {
113
+ return groupedPlan(input, softKeyClusters(input, o), { spine: o.spine, minSize: o.minSize });
114
+ }
@@ -0,0 +1,205 @@
1
+ import { center, dist, type Point } from "../../geom.ts";
2
+ import { nodeIndex, type Node } from "../../model.ts";
3
+ import { orderLanes } from "../../segments.ts";
4
+ import { WayFinder, type WayOptions } from "../../ways.ts";
5
+ import { tidyPlan } from "../base.ts";
6
+ import { memberAt, type Highway, type HighwayInput, type HighwayMember, type HighwayPlan } from "../model.ts";
7
+ import { edgePlugs } from "../stretches.ts";
8
+
9
+ /** The options of the highway strategy `spiral` (HWY-017 with EDA-035 of the notes). */
10
+ export interface SpiralOptions extends Pick<WayOptions, "margin" | "k"> {
11
+ /** The spiral angle, in degrees: the largest angle between a branch and the line to the root. The default is 25. */
12
+ readonly alpha: number;
13
+ /** A node with this number of edges or more gets a spiral tree. The default is 4. */
14
+ readonly minTargets: number;
15
+ /** The number of points of the arc of each branch. The default is 4. */
16
+ readonly arc: number;
17
+ }
18
+
19
+ /** The default options of the strategy `spiral`. */
20
+ export const SPIRAL_DEFAULTS: Readonly<Required<Omit<SpiralOptions, "margin" | "k">>> = Object.freeze({ alpha: 25, minTargets: 4, arc: 4 });
21
+
22
+ /** One node of a spiral tree, in polar coordinates around the root. A leaf has an edge, and a join has two children. */
23
+ export interface SpiralNode {
24
+ readonly r: number;
25
+ readonly theta: number;
26
+ readonly point: Point;
27
+ /** A join with this node is this distance or more nearer to the root than the node. A leaf keeps its box clear so. A join has 0. */
28
+ readonly clear: number;
29
+ readonly edge?: string;
30
+ readonly children: readonly SpiralNode[];
31
+ }
32
+
33
+ const TAU = 2 * Math.PI;
34
+
35
+ /** This function gives the angle `t` in the range from 0 to 2 pi. */
36
+ const wrap = (t: number): number => ((t % TAU) + TAU) % TAU;
37
+
38
+ /**
39
+ * This function gives the join of the nodes `p` and `q` of a spiral tree with the angle `alpha`. It gives `undefined` when no join is possible.
40
+ * Each node can reach the points inside its two spirals toward the root: `|theta - theta_p| <= tan(alpha) ln(r_p / r)`.
41
+ * The join is the point farthest from the root that both nodes reach, and that is `clear` nearer to the root than each node.
42
+ * At that radius, the join is at the middle of the angles that both nodes reach.
43
+ */
44
+ export function spiralJoin(p: SpiralNode, q: SpiralNode, alpha: number, root: Point): { r: number; theta: number; point: Point } | undefined {
45
+ const t = Math.tan(alpha);
46
+ // The angle from p to q, counterclockwise, in the range from -pi to pi.
47
+ let delta = wrap(q.theta - p.theta);
48
+ if (delta > Math.PI) delta -= TAU;
49
+ const gap = Math.abs(delta);
50
+ if (gap >= Math.PI || t <= 0) return undefined;
51
+ const near = p.r <= q.r ? p : q;
52
+ const far = p.r <= q.r ? q : p;
53
+ const meet = t * Math.log(far.r / near.r) >= gap ? near.r : Math.exp((Math.log(p.r) + Math.log(q.r) - gap / t) / 2);
54
+ const r = Math.min(meet, p.r - p.clear, q.r - q.clear);
55
+ if (!(r > 0)) return undefined;
56
+ // In the frame where p is at 0 and q is at `gap`, p reaches [-rp, rp] and q reaches [gap - rq, gap + rq].
57
+ const rp = t * Math.log(p.r / r);
58
+ const rq = t * Math.log(q.r / r);
59
+ const lo = Math.max(-rp, gap - rq);
60
+ const hi = Math.min(rp, gap + rq);
61
+ if (lo > hi + 1e-9) return undefined;
62
+ const theta = wrap(p.theta + Math.sign(delta) * ((lo + hi) / 2));
63
+ return { r, theta, point: { x: root.x + r * Math.cos(theta), y: root.y + r * Math.sin(theta) } };
64
+ }
65
+
66
+ /**
67
+ * This function gives the spiral tree of the leaves around the root (Buchin, Speckmann and Verbeek 2011), with the greedy sweep.
68
+ * The sweep goes from the far leaves to the root. A leaf joins the wavefront at its radius. Two nodes that are next to each other in angle join at their join point.
69
+ * The join with the largest radius goes first. No join occurs below the radius `rMin`. The function gives the nodes of the wavefront at the end.
70
+ */
71
+ export function spiralTree(leaves: readonly SpiralNode[], alpha: number, root: Point, rMin: number): SpiralNode[] {
72
+ const waiting = [...leaves].sort((a, b) => b.r - a.r || (a.edge! < b.edge! ? -1 : 1));
73
+ const front: SpiralNode[] = [];
74
+ const insert = (n: SpiralNode): void => {
75
+ let i = 0;
76
+ while (i < front.length && front[i]!.theta <= n.theta) i++;
77
+ front.splice(i, 0, n);
78
+ };
79
+ for (;;) {
80
+ let best: { i: number; r: number; theta: number; point: Point } | undefined;
81
+ if (front.length >= 2)
82
+ for (let i = 0; i < front.length; i++) {
83
+ if (front.length === 2 && i === 1) break;
84
+ const j = (i + 1) % front.length;
85
+ const join = spiralJoin(front[i]!, front[j]!, alpha, root);
86
+ if (join && join.r >= rMin && (!best || join.r > best.r)) best = { i, ...join };
87
+ }
88
+ const next = waiting[0];
89
+ if (next && (!best || next.r >= best.r)) {
90
+ insert(waiting.shift()!);
91
+ continue;
92
+ }
93
+ if (!best) return front;
94
+ const i = best.i;
95
+ const j = (i + 1) % front.length;
96
+ const node: SpiralNode = { r: best.r, theta: best.theta, point: best.point, clear: 0, children: [front[i]!, front[j]!] };
97
+ for (const k of [i, j].sort((a, b) => b - a)) front.splice(k, 1);
98
+ insert(node);
99
+ }
100
+ }
101
+
102
+ /** This function gives the leaves under a node of a spiral tree, in the order of the tree. */
103
+ function leavesOf(n: SpiralNode): string[] {
104
+ return n.edge !== undefined ? [n.edge] : n.children.flatMap(leavesOf);
105
+ }
106
+
107
+ /**
108
+ * This function gives the highways of the spiral trees (HWY-017, EDA-035). Each node with `minTargets` edges or more is the root of a fan.
109
+ * The nodes with more edges go first, and each edge is in one fan at most. The far plug of each edge is a leaf of the tree of its fan.
110
+ * Each branch of a tree with two leaves or more is a highway. Its spine is an arc of a logarithmic spiral, or a way through the corner graph where the arc hits a box.
111
+ * An edge rides the branches from the root to its leaf, or from its leaf to the root when the root is its target.
112
+ */
113
+ export function spiralPlan(input: HighwayInput, o: Readonly<SpiralOptions>): HighwayPlan {
114
+ const graph = input.graph;
115
+ const finder = new WayFinder(graph.nodes, { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) });
116
+ const pad = finder.margin / 2;
117
+ const byId = nodeIndex(graph);
118
+ const plugs = edgePlugs(graph);
119
+ const degree = new Map<string, number>();
120
+ for (const { edge } of plugs) {
121
+ if (edge.source.node === edge.target.node) continue;
122
+ for (const n of [edge.source.node, edge.target.node]) degree.set(n, (degree.get(n) ?? 0) + 1);
123
+ }
124
+ const hubs = [...degree].filter(([, d]) => d >= o.minTargets).sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
125
+ const alpha = (Math.max(1, Math.min(80, o.alpha)) * Math.PI) / 180;
126
+ const arcPoints = Math.max(2, Math.round(o.arc));
127
+ const used = new Set<string>();
128
+ const highways: Highway[] = [];
129
+
130
+ /** This function gives the spine from the point `a` to the point `b`. The spine is a spiral arc in free space, or a way through the corner graph. */
131
+ const spineOf = (root: Point, a: { r: number; theta: number; point: Point }, b: { r: number; theta: number; point: Point }): Point[] | undefined => {
132
+ if (finder.obstacles.inside(a.point, pad) || finder.obstacles.inside(b.point, pad)) return undefined;
133
+ let dt = wrap(b.theta - a.theta);
134
+ if (dt > Math.PI) dt -= TAU;
135
+ const arc: Point[] = [a.point];
136
+ for (let s = 1; s < arcPoints - 1; s++) {
137
+ const u = s / (arcPoints - 1);
138
+ const r = a.r * Math.pow(b.r / a.r, u);
139
+ const theta = a.theta + dt * u;
140
+ arc.push({ x: root.x + r * Math.cos(theta), y: root.y + r * Math.sin(theta) });
141
+ }
142
+ arc.push(b.point);
143
+ if (finder.obstacles.clearPath(arc, pad)) return arc;
144
+ const way = finder.corners.path(a.point, b.point);
145
+ return way ? finder.corners.pull(way) : undefined;
146
+ };
147
+
148
+ for (const [hubId] of hubs) {
149
+ const hub: Node | undefined = byId.get(hubId);
150
+ if (!hub) continue;
151
+ const root = center(hub.box);
152
+ const rMin = Math.hypot(hub.box.w, hub.box.h) / 2 + 2 * finder.margin;
153
+ const fan = plugs.filter(({ edge }) => !used.has(edge.id) && edge.source.node !== edge.target.node && (edge.source.node === hubId || edge.target.node === hubId));
154
+ if (fan.length < o.minTargets) continue;
155
+ const out = new Map<string, boolean>();
156
+ const near = new Map<string, Point>();
157
+ const far = new Map<string, Point>();
158
+ const leaves: SpiralNode[] = fan.map(({ edge, ps, pt }) => {
159
+ const outward = edge.source.node === hubId;
160
+ out.set(edge.id, outward);
161
+ near.set(edge.id, outward ? ps.point : pt.point);
162
+ const p = outward ? pt.point : ps.point;
163
+ far.set(edge.id, p);
164
+ const box = byId.get(outward ? edge.target.node : edge.source.node)!.box;
165
+ const clear = Math.hypot(box.w, box.h) / 2 + finder.margin;
166
+ return { r: Math.max(1e-6, dist(root, p)), theta: wrap(Math.atan2(p.y - root.y, p.x - root.x)), point: p, clear, edge: edge.id, children: [] };
167
+ });
168
+ for (const { edge } of fan) used.add(edge.id);
169
+ const tops = spiralTree(leaves, alpha, root, rMin);
170
+ // The branches: each link from a node to a child with two leaves or more, from the root down.
171
+ interface Branch {
172
+ readonly spine: Point[];
173
+ readonly edges: string[];
174
+ readonly depth: number;
175
+ }
176
+ const branches: Branch[] = [];
177
+ const walk = (parent: { r: number; theta: number; point: Point }, child: SpiralNode, depth: number): void => {
178
+ if (child.edge !== undefined) return;
179
+ const spine = dist(parent.point, child.point) > finder.margin ? spineOf(root, parent, child) : undefined;
180
+ if (spine && spine.length >= 2) branches.push({ spine, edges: leavesOf(child), depth });
181
+ for (const c of child.children) if (dist(c.point, child.point) > 1e-9) walk(child, c, depth + 1);
182
+ // A child at the point of the join (the nearer node of a join) goes on from the same point.
183
+ for (const c of child.children) if (dist(c.point, child.point) <= 1e-9) for (const g of c.children) walk(child, g, depth + 1);
184
+ };
185
+ for (const top of tops) {
186
+ if (top.edge !== undefined) continue;
187
+ // The trunk from the hub: it starts at the radius `rMin` on the line to the top node.
188
+ const start = { r: rMin, theta: top.theta, point: { x: root.x + rMin * Math.cos(top.theta), y: root.y + rMin * Math.sin(top.theta) } };
189
+ walk(top.r > rMin + finder.margin ? start : top, top, 0);
190
+ }
191
+ for (const b of branches) {
192
+ if (b.edges.length < 2) continue;
193
+ const last = b.spine.length - 1;
194
+ const tripOf = (edge: string): Point[] => [near.get(edge)!, ...b.spine, far.get(edge)!];
195
+ const lanes = orderLanes(
196
+ b.spine,
197
+ b.edges.map((edge) => ({ edge, points: tripOf(edge) })),
198
+ "bundle",
199
+ );
200
+ const members: HighwayMember[] = lanes.map((edge) => (out.get(edge) ? memberAt(b.spine, edge, 0, last, b.depth) : memberAt(b.spine, edge, last, 0, -b.depth)));
201
+ highways.push({ id: `h${highways.length}`, key: `spiral:${hubId}`, spine: b.spine, lanes, members });
202
+ }
203
+ }
204
+ return tidyPlan({ highways });
205
+ }
@@ -0,0 +1,296 @@
1
+ import { boardUnit } from "../../bench/measures/routes.ts";
2
+ import { dist, pointSegmentDistance, polylineLength, type Point } from "../../geom.ts";
3
+ import { orderLanes } from "../../segments.ts";
4
+ import { WayFinder, type WayOptions } from "../../ways.ts";
5
+ import { firstRoutes, nearestPlace, orderAlongRoutes, tidyPlan } from "../base.ts";
6
+ import { lengthBetween, memberAt, type Highway, type HighwayInput, type HighwayMember, type HighwayPlan } from "../model.ts";
7
+
8
+ /** The options of the highway strategy `traclus` (HWY-025 of the note on highways). */
9
+ export interface TraclusOptions extends Pick<WayOptions, "margin" | "k"> {
10
+ /** Two segments are neighbours when their distance is this value or less, in units of the board. The default is 2. */
11
+ readonly eps: number;
12
+ /** A cluster has segments of this number of edges or more, and a core segment has this number of neighbours or more. The default is 3. */
13
+ readonly minLns: number;
14
+ /** The weight of the perpendicular distance. The default is 1. */
15
+ readonly wPerp: number;
16
+ /** The weight of the parallel distance. The default is 1. */
17
+ readonly wPar: number;
18
+ /** The weight of the angle distance. The default is 1. */
19
+ readonly wTheta: number;
20
+ /** A route keeps a corner when the cost of the line without it is more than this value times the cost with it. The default is 1. */
21
+ readonly mdl: number;
22
+ }
23
+
24
+ /** The default options of the strategy `traclus`. */
25
+ export const TRACLUS_DEFAULTS: Readonly<Required<Omit<TraclusOptions, "margin" | "k">>> = Object.freeze({ eps: 2, minLns: 3, wPerp: 1, wPar: 1, wTheta: 1, mdl: 1 });
26
+
27
+ /** One segment of a partitioned route: the edge, the index of the segment in the route, and its two ends. */
28
+ export interface RouteSegment {
29
+ readonly edge: string;
30
+ readonly index: number;
31
+ readonly a: Point;
32
+ readonly b: Point;
33
+ }
34
+
35
+ /** This function gives the distance of the point `p` to the line through `a` and `b`. It also gives the place of its projection as a share of `ab`. */
36
+ function project(p: Point, a: Point, b: Point): { perp: number; u: number } {
37
+ const dx = b.x - a.x;
38
+ const dy = b.y - a.y;
39
+ const len2 = dx * dx + dy * dy;
40
+ if (len2 < 1e-12) return { perp: dist(p, a), u: 0 };
41
+ const u = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2;
42
+ return { perp: Math.abs((p.x - a.x) * dy - (p.y - a.y) * dx) / Math.sqrt(len2), u };
43
+ }
44
+
45
+ /**
46
+ * This function gives the TRACLUS distance of two segments (Lee, Han and Whang 2007): `w_perp d_perp + w_par d_par + w_theta d_theta`.
47
+ * `d_perp` is `(l1² + l2²) / (l1 + l2)` with the distances of the ends of the shorter segment to the line of the longer segment.
48
+ * `d_par` is the gap along the longer segment between the two segments, 0 when they overlap. `d_theta` is `|shorter| sin(theta)`.
49
+ * The segments have no direction, so the angle is 90 degrees or less.
50
+ */
51
+ export function segmentDistance(p: { a: Point; b: Point }, q: { a: Point; b: Point }, w: { perp: number; par: number; theta: number }): number {
52
+ const [long, short] = dist(p.a, p.b) >= dist(q.a, q.b) ? [p, q] : [q, p];
53
+ const len = dist(long.a, long.b);
54
+ const s = project(short.a, long.a, long.b);
55
+ const e = project(short.b, long.a, long.b);
56
+ const perp = s.perp + e.perp > 1e-12 ? (s.perp * s.perp + e.perp * e.perp) / (s.perp + e.perp) : 0;
57
+ const lo = Math.min(s.u, e.u);
58
+ const hi = Math.max(s.u, e.u);
59
+ const par = (hi < 0 ? -hi : lo > 1 ? lo - 1 : 0) * len;
60
+ const shortLen = dist(short.a, short.b);
61
+ let theta = 0;
62
+ if (len > 1e-12 && shortLen > 1e-12) {
63
+ const cos = Math.abs(((long.b.x - long.a.x) * (short.b.x - short.a.x) + (long.b.y - long.a.y) * (short.b.y - short.a.y)) / (len * shortLen));
64
+ theta = shortLen * Math.sqrt(Math.max(0, 1 - cos * cos));
65
+ }
66
+ return w.perp * perp + w.par * par + w.theta * theta;
67
+ }
68
+
69
+ /**
70
+ * This function gives the characteristic points of a route by the rule of the minimum description length (MDL) of TRACLUS.
71
+ * A partition from point `i` to point `j` costs `L(H) + L(D|H)`. `L(H)` is the log of its length, and `L(D|H)` the logs of the perpendicular and angle distances of the route parts.
72
+ * The route keeps the point before `j` when that cost is more than `ratio` times the cost of the route parts. The lengths are in units of the board.
73
+ */
74
+ export function characteristicPoints(route: readonly Point[], unit: number, ratio: number): Point[] {
75
+ const n = route.length;
76
+ if (n <= 2) return [...route];
77
+ const lg = (x: number): number => Math.log2(1 + x / unit);
78
+ const costs = (i: number, j: number): { par: number; nopar: number } => {
79
+ const a = route[i]!;
80
+ const b = route[j]!;
81
+ let perp = 0;
82
+ let theta = 0;
83
+ let nopar = 0;
84
+ for (let k = i; k < j; k++) {
85
+ const part = { a: route[k]!, b: route[k + 1]! };
86
+ perp += segmentDistance({ a, b }, part, { perp: 1, par: 0, theta: 0 });
87
+ theta += segmentDistance({ a, b }, part, { perp: 0, par: 0, theta: 1 });
88
+ nopar += lg(dist(part.a, part.b));
89
+ }
90
+ return { par: lg(dist(a, b)) + lg(perp) + lg(theta), nopar };
91
+ };
92
+ const out: Point[] = [route[0]!];
93
+ let start = 0;
94
+ let length = 1;
95
+ while (start + length < n) {
96
+ const curr = start + length;
97
+ const c = costs(start, curr);
98
+ if (c.par > ratio * c.nopar && curr - 1 > start) {
99
+ out.push(route[curr - 1]!);
100
+ start = curr - 1;
101
+ length = 1;
102
+ } else length++;
103
+ }
104
+ out.push(route[n - 1]!);
105
+ return out;
106
+ }
107
+
108
+ /**
109
+ * This function gives the clusters of segments of DBSCAN. A segment with `minLns` neighbours or more within `eps` (itself included) is a core segment.
110
+ * A cluster grows from each core segment through its neighbours. A cluster with segments of fewer than `minLns` edges is dropped.
111
+ */
112
+ export function segmentClusters(segments: readonly RouteSegment[], eps: number, minLns: number, w: { perp: number; par: number; theta: number }): RouteSegment[][] {
113
+ const n = segments.length;
114
+ // A grid of cells of side `eps` prunes the pairs. Two segments within `eps` have boxes within `eps` of each other in each axis.
115
+ const near: number[][] = segments.map(() => []);
116
+ const box = segments.map((s) => ({ x0: Math.min(s.a.x, s.b.x), y0: Math.min(s.a.y, s.b.y), x1: Math.max(s.a.x, s.b.x), y1: Math.max(s.a.y, s.b.y) }));
117
+ for (let i = 0; i < n; i++) {
118
+ near[i]!.push(i);
119
+ for (let j = i + 1; j < n; j++) {
120
+ const a = box[i]!;
121
+ const b = box[j]!;
122
+ if (a.x0 > b.x1 + eps || b.x0 > a.x1 + eps || a.y0 > b.y1 + eps || b.y0 > a.y1 + eps) continue;
123
+ if (segmentDistance(segments[i]!, segments[j]!, w) <= eps) {
124
+ near[i]!.push(j);
125
+ near[j]!.push(i);
126
+ }
127
+ }
128
+ }
129
+ const label = new Int32Array(n).fill(-1);
130
+ const out: RouteSegment[][] = [];
131
+ for (let i = 0; i < n; i++) {
132
+ if (label[i] !== -1 || near[i]!.length < minLns) continue;
133
+ const id = out.length;
134
+ const members: number[] = [];
135
+ const queue = [i];
136
+ label[i] = id;
137
+ while (queue.length > 0) {
138
+ const k = queue.shift()!;
139
+ members.push(k);
140
+ if (near[k]!.length < minLns) continue;
141
+ for (const j of near[k]!) {
142
+ if (label[j] !== -1) continue;
143
+ label[j] = id;
144
+ queue.push(j);
145
+ }
146
+ }
147
+ out.push(members.sort((a, b) => a - b).map((k) => segments[k]!));
148
+ }
149
+ return out.filter((c) => new Set(c.map((s) => s.edge)).size >= minLns);
150
+ }
151
+
152
+ /**
153
+ * This function gives the representative line of a cluster of segments. The line goes along the mean direction of the segments.
154
+ * A sweep along that direction stops at each end of a segment. Where `minLns` segments or more cover the stop, the point is the mean of their points there.
155
+ * Two points of the line are at least `gap` apart.
156
+ */
157
+ export function representative(cluster: readonly RouteSegment[], minLns: number, gap: number): Point[] {
158
+ const first = cluster[0]!;
159
+ const fx = first.b.x - first.a.x;
160
+ const fy = first.b.y - first.a.y;
161
+ let vx = 0;
162
+ let vy = 0;
163
+ for (const s of cluster) {
164
+ let dx = s.b.x - s.a.x;
165
+ let dy = s.b.y - s.a.y;
166
+ const len = Math.hypot(dx, dy);
167
+ if (len < 1e-9) continue;
168
+ if (dx * fx + dy * fy < 0) [dx, dy] = [-dx, -dy];
169
+ vx += dx / len;
170
+ vy += dy / len;
171
+ }
172
+ const vl = Math.hypot(vx, vy);
173
+ if (vl < 1e-9) return [];
174
+ const ux = vx / vl;
175
+ const uy = vy / vl;
176
+ const along = (p: Point): number => p.x * ux + p.y * uy;
177
+ const across = (p: Point): number => -p.x * uy + p.y * ux;
178
+ const spans = cluster.map((s) => {
179
+ const [p, q] = along(s.a) <= along(s.b) ? [s.a, s.b] : [s.b, s.a];
180
+ return { x0: along(p), y0: across(p), x1: along(q), y1: across(q) };
181
+ });
182
+ const stops = [...new Set(spans.flatMap((s) => [s.x0, s.x1]))].sort((a, b) => a - b);
183
+ const out: Point[] = [];
184
+ let lastX = -Infinity;
185
+ for (const x of stops) {
186
+ const cover = spans.filter((s) => s.x0 <= x + 1e-9 && s.x1 >= x - 1e-9);
187
+ if (cover.length < minLns) continue;
188
+ if (x - lastX < gap) continue;
189
+ let y = 0;
190
+ for (const s of cover) y += s.x1 - s.x0 > 1e-9 ? s.y0 + ((s.y1 - s.y0) * (x - s.x0)) / (s.x1 - s.x0) : (s.y0 + s.y1) / 2;
191
+ y /= cover.length;
192
+ out.push({ x: x * ux - y * uy, y: x * uy + y * ux });
193
+ lastX = x;
194
+ }
195
+ return out;
196
+ }
197
+
198
+ /** This function gives the distance of the point `p` to the polyline `line`. */
199
+ function lineDistance(line: readonly Point[], p: Point): number {
200
+ let best = Infinity;
201
+ for (let i = 1; i < line.length; i++) best = Math.min(best, pointSegmentDistance(p, line[i - 1]!, line[i]!));
202
+ return best;
203
+ }
204
+
205
+ /** The largest ratio of the length along the spine of a member to the length of its own route between the same ends. */
206
+ const DETOUR = 1.25;
207
+
208
+ /**
209
+ * This function gives the highways of TRACLUS (HWY-025). Each first route breaks into a few long segments by the rule of MDL.
210
+ * DBSCAN puts the segments that are near in distance and angle into clusters. Each cluster with segments of `minLns` edges or more is one highway.
211
+ * The spine is the representative line of the cluster, or a way through the corner graph between its ends when the line hits a box.
212
+ * Each edge of the cluster joins the spine nearest to the first end of its segments. It leaves the spine nearest to the last end.
213
+ * An edge rides only when both ends of its part are within `eps` of the spine.
214
+ * Its length on the spine is also at most 1.25 times the length of its own route there.
215
+ * Each edge rides one highway at most: the highway where it rides the longest length.
216
+ */
217
+ export function traclusPlan(input: HighwayInput, o: Readonly<TraclusOptions>): HighwayPlan {
218
+ const graph = input.graph;
219
+ const unit = boardUnit(graph);
220
+ const finder = new WayFinder(graph.nodes, { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) });
221
+ const pad = finder.margin / 2;
222
+ const routes = firstRoutes(input);
223
+ const segments: RouteSegment[] = [];
224
+ const corners = new Map<string, Point[]>();
225
+ for (const e of graph.edges) {
226
+ const route = routes.get(e.id);
227
+ if (!route || route.length < 2) continue;
228
+ const cps = characteristicPoints(route, unit, o.mdl);
229
+ corners.set(e.id, cps);
230
+ for (let i = 1; i < cps.length; i++) if (dist(cps[i - 1]!, cps[i]!) > 1e-9) segments.push({ edge: e.id, index: i - 1, a: cps[i - 1]!, b: cps[i]! });
231
+ }
232
+ const minLns = Math.max(2, Math.round(o.minLns));
233
+ const w = { perp: o.wPerp, par: o.wPar, theta: o.wTheta };
234
+ const eps = o.eps * unit;
235
+ const clusters = segmentClusters(segments, eps, minLns, w);
236
+ type Placed = { edge: string; on: number; off: number; ride: number };
237
+ const found: { spine: Point[]; placed: Placed[] }[] = [];
238
+ for (const cluster of clusters) {
239
+ const line = representative(cluster, minLns, finder.margin);
240
+ if (line.length < 2 || polylineLength(line) <= finder.margin) continue;
241
+ let spine: Point[] | undefined = line;
242
+ if (!finder.obstacles.clearPath(line, pad) || line.some((p) => finder.obstacles.inside(p, pad))) {
243
+ const a = line[0]!;
244
+ const b = line[line.length - 1]!;
245
+ const way = finder.obstacles.inside(a, pad) || finder.obstacles.inside(b, pad) ? undefined : finder.corners.path(a, b);
246
+ spine = way ? finder.corners.pull(way) : undefined;
247
+ }
248
+ if (!spine || spine.length < 2 || polylineLength(spine) <= finder.margin) continue;
249
+ // The part of each edge in the cluster: its first and its last segment in the order of its route.
250
+ const parts = new Map<string, { first: RouteSegment; last: RouteSegment }>();
251
+ for (const s of cluster) {
252
+ const p = parts.get(s.edge);
253
+ if (!p) parts.set(s.edge, { first: s, last: s });
254
+ else {
255
+ if (s.index < p.first.index) p.first = s;
256
+ if (s.index > p.last.index) p.last = s;
257
+ }
258
+ }
259
+ const placed: Placed[] = [];
260
+ for (const [edge, p] of parts) {
261
+ if (lineDistance(spine, p.first.a) > eps || lineDistance(spine, p.last.b) > eps) continue;
262
+ const on = nearestPlace(spine, p.first.a);
263
+ const off = nearestPlace(spine, p.last.b);
264
+ const ride = lengthBetween(spine, Math.min(on, off), Math.max(on, off));
265
+ const own = polylineLength(corners.get(edge)!.slice(p.first.index, p.last.index + 2));
266
+ if (ride > finder.margin && ride <= DETOUR * own + finder.margin) placed.push({ edge, on, off, ride });
267
+ }
268
+ if (placed.length >= 2) found.push({ spine, placed });
269
+ }
270
+ // Each edge keeps the highway where it rides the longest length. The first highway wins a tie.
271
+ const best = new Map<string, { at: number; ride: number }>();
272
+ found.forEach((f, at) => {
273
+ for (const x of f.placed) if ((best.get(x.edge)?.ride ?? -1) < x.ride) best.set(x.edge, { at, ride: x.ride });
274
+ });
275
+ const highways: Highway[] = [];
276
+ found.forEach((f, at) => {
277
+ const placed = f.placed.filter((x) => best.get(x.edge)!.at === at);
278
+ if (placed.length < 2) return;
279
+ const route = f.spine;
280
+ // The lane order reads the plugs of each edge on each side of the spine.
281
+ const trip = (x: Placed): Point[] => {
282
+ const own = routes.get(x.edge)!;
283
+ const [from, to] = [own[0]!, own[own.length - 1]!];
284
+ return x.on <= x.off ? [from, ...route, to] : [to, ...route, from];
285
+ };
286
+ const lanes = orderLanes(
287
+ route,
288
+ placed.map((x) => ({ edge: x.edge, points: trip(x) })),
289
+ "bundle",
290
+ );
291
+ const byEdge = new Map(placed.map((x) => [x.edge, x]));
292
+ const members: HighwayMember[] = lanes.map((edge) => memberAt(route, edge, byEdge.get(edge)!.on, byEdge.get(edge)!.off));
293
+ highways.push({ id: `h${highways.length}`, key: `t${highways.length}`, spine: route, lanes, members });
294
+ });
295
+ return tidyPlan(orderAlongRoutes({ highways }, routes));
296
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from "./plugs.ts";
7
7
  export * from "./curves.ts";
8
8
  export * from "./curves/index.ts";
9
9
  export * from "./visibility.ts";
10
+ export * from "./channels.ts";
10
11
  export * from "./ways.ts";
11
12
  export * from "./segments.ts";
12
13
  export * from "./sections.ts";