@mark1russell7/highways 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +43 -1
  2. package/build/index.d.ts +1377 -5
  3. package/build/index.d.ts.map +1 -1
  4. package/build/index.js +8202 -1515
  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/highways.ts +93 -0
  9. package/src/bench/measures/index.ts +2 -0
  10. package/src/bench/measures/lanes.ts +304 -0
  11. package/src/bench/measures/levels.ts +86 -2
  12. package/src/bench/screen/forms.ts +11 -5
  13. package/src/bench/screen/measures.ts +23 -0
  14. package/src/bench/screen/screen.ts +42 -0
  15. package/src/channels.ts +56 -0
  16. package/src/highways/base.ts +126 -0
  17. package/src/highways/diffusion/index.ts +103 -0
  18. package/src/highways/edge-paths/index.ts +155 -0
  19. package/src/highways/fixtures.test-util.ts +71 -0
  20. package/src/highways/flow-clusters/index.ts +143 -0
  21. package/src/highways/flow-field/index.ts +49 -0
  22. package/src/highways/gates/ambiguity/index.ts +110 -0
  23. package/src/highways/gates/breakeven/index.ts +62 -0
  24. package/src/highways/gates/hysteresis/index.ts +48 -0
  25. package/src/highways/gates/ids/index.ts +59 -0
  26. package/src/highways/gates/index.ts +6 -0
  27. package/src/highways/gates/model.ts +129 -0
  28. package/src/highways/gates/stretch/index.ts +39 -0
  29. package/src/highways/grouped.ts +123 -0
  30. package/src/highways/growth/index.ts +148 -0
  31. package/src/highways/hdbscan/index.ts +250 -0
  32. package/src/highways/hough/index.ts +182 -0
  33. package/src/highways/hub-arcs/index.ts +181 -0
  34. package/src/highways/index.ts +30 -0
  35. package/src/highways/lanes/index.ts +189 -0
  36. package/src/highways/level-links/index.ts +114 -0
  37. package/src/highways/meet/index.ts +84 -0
  38. package/src/highways/mingle/index.ts +171 -0
  39. package/src/highways/model.ts +5 -0
  40. package/src/highways/nested/index.ts +143 -0
  41. package/src/highways/od-cells/index.ts +50 -0
  42. package/src/highways/quickbundles/index.ts +117 -0
  43. package/src/highways/ridges/index.ts +328 -0
  44. package/src/highways/road-classes/index.ts +74 -0
  45. package/src/highways/router.ts +1 -1
  46. package/src/highways/select/index.ts +162 -0
  47. package/src/highways/snap.ts +110 -0
  48. package/src/highways/soft-keys/index.ts +114 -0
  49. package/src/highways/spiral/index.ts +205 -0
  50. package/src/highways/stretches.ts +141 -0
  51. package/src/highways/stubs/index.ts +117 -0
  52. package/src/highways/traclus/index.ts +296 -0
  53. package/src/highways/weiszfeld/index.ts +105 -0
  54. package/src/highways/wspd/index.ts +130 -0
  55. package/src/highways/zipper/index.ts +168 -0
  56. package/src/index.ts +1 -0
  57. package/src/routers/shared.ts +56 -3
  58. package/src/strategies/builtins.ts +303 -7
  59. package/src/strategies/schemas.ts +300 -0
  60. package/src/visibility.ts +5 -0
@@ -0,0 +1,74 @@
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 `road-classes` (HWY-034 with EDA-050 of the notes). */
7
+ export interface RoadClassOptions extends Pick<WayOptions, "margin" | "k"> {
8
+ /** A link is a highway link when its use is at this quantile of the use of the links, or higher. The default is 0.9. */
9
+ readonly quantile: number;
10
+ /** A highway link has this number of edges or more on it, and a highway has this number of edges or more. The default is 2. */
11
+ readonly minSize: number;
12
+ /** The second pass gives each highway link this discount, as a share of its length. The default is 0.5. */
13
+ readonly discount: number;
14
+ }
15
+
16
+ /** The default options of the strategy `road-classes`. */
17
+ export const ROAD_CLASS_DEFAULTS: Readonly<Required<Omit<RoadClassOptions, "margin" | "k">>> = Object.freeze({ quantile: 0.9, minSize: 2, discount: 0.5 });
18
+
19
+ /** This function gives the quantile `q` of a list of numbers, with the nearest rank. An empty list gives `Infinity`. */
20
+ function rank(values: readonly number[], q: number): number {
21
+ if (values.length === 0) return Infinity;
22
+ const sorted = [...values].sort((a, b) => a - b);
23
+ return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1))]!;
24
+ }
25
+
26
+ /**
27
+ * This function gives the highways of the road classes (HWY-034, EDA-050). It counts how many first ways use each link of the corner graph.
28
+ * The links with a use at the quantile `quantile` or higher, and at least `minSize`, are the highway links.
29
+ * They are the main roads of a road map.
30
+ * A second pass routes each edge again, with the discount on the highway links, so more edges join them.
31
+ * Each stretch of highway links that `minSize` ways or more share after the second pass is one highway.
32
+ */
33
+ export function roadClassPlan(input: HighwayInput, o: Readonly<RoadClassOptions>): HighwayPlan {
34
+ const finder = new WayFinder(input.graph.nodes, { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) });
35
+ const corners = finder.corners;
36
+ const id = new Map<Point, number>();
37
+ corners.points.forEach((p, i) => id.set(p, i));
38
+ const M = corners.points.length;
39
+ const link = (i: number, j: number): number => (i < j ? i * M + j : j * M + i);
40
+ const plugs = edgePlugs(input.graph);
41
+ const ends = plugs.map(({ ps, pt }) => [finder.out(ps), finder.out(pt)] as const);
42
+ // The first pass: the use of each link between two corners.
43
+ const use = new Map<number, number>();
44
+ for (const [a, b] of ends) {
45
+ const way = corners.path(a, b);
46
+ if (!way) continue;
47
+ const own = new Set<number>();
48
+ for (let i = 1; i < way.length; i++) {
49
+ const p = id.get(way[i - 1]!);
50
+ const q = id.get(way[i]!);
51
+ if (p !== undefined && q !== undefined) own.add(link(p, q));
52
+ }
53
+ for (const l of own) use.set(l, (use.get(l) ?? 0) + 1);
54
+ }
55
+ const floor = Math.max(2, o.minSize, rank([...use.values()], o.quantile));
56
+ const highway = new Set<number>();
57
+ for (const [l, n] of use) if (n >= floor) highway.add(l);
58
+ if (highway.size === 0) return { highways: [] };
59
+ // The second pass: the highway links cost less, so the edges near them join them.
60
+ const share = Math.min(1, Math.max(0.05, 1 - o.discount));
61
+ const cost = (i: number, j: number, d: number): number => (highway.has(link(i, j)) ? d * share : d);
62
+ const ways: KeyedWay[] = [];
63
+ plugs.forEach(({ edge }, n) => {
64
+ const [a, b] = ends[n]!;
65
+ const way = corners.path(a, b, cost, share);
66
+ if (way) ways.push({ edge: edge.id, key: "road", points: way });
67
+ });
68
+ const onHighway = (a: Point, b: Point): boolean => {
69
+ const p = id.get(a);
70
+ const q = id.get(b);
71
+ return p !== undefined && q !== undefined && highway.has(link(p, q));
72
+ };
73
+ return sharedStretches(ways, { minSize: o.minSize, link: onHighway, pull: (p) => corners.pull(p) });
74
+ }
@@ -4,7 +4,7 @@ import type { WayOptions } from "../ways.ts";
4
4
  import { follow, type FollowOptions } from "./follow.ts";
5
5
 
6
6
  /** This function gives the options of the way finder in the options of a strategy: `margin` and `k`, when they are present. */
7
- function wayOptionsOf(options: object): WayOptions {
7
+ export function wayOptionsOf(options: object): WayOptions {
8
8
  const o = options as { margin?: unknown; k?: unknown };
9
9
  return { ...(typeof o.margin === "number" ? { margin: o.margin } : {}), ...(typeof o.k === "number" ? { k: o.k } : {}) };
10
10
  }
@@ -0,0 +1,162 @@
1
+ import { dist, type Point } from "../../geom.ts";
2
+ import { byCollection } from "../../routers/trunks.ts";
3
+ import { firstRoutes, tidyPlan } from "../base.ts";
4
+ import { CORRIDOR_DEFAULTS, corridorPlan } from "../corridors/index.ts";
5
+ import { FLOW_FIELD_DEFAULTS, flowFieldPlan } from "../flow-field/index.ts";
6
+ import { lengthBetween, type Highway, type HighwayInput, type HighwayPlan } from "../model.ts";
7
+ import { OD_CELL_DEFAULTS, odCellPlan } from "../od-cells/index.ts";
8
+ import { QUICKBUNDLES_DEFAULTS, quickBundlesPlan } from "../quickbundles/index.ts";
9
+ import { ROAD_CLASS_DEFAULTS, roadClassPlan } from "../road-classes/index.ts";
10
+ import { sharedWaysPlan } from "../shared-ways/index.ts";
11
+ import { WSPD_DEFAULTS, wspdPlan } from "../wspd/index.ts";
12
+
13
+ /** A generator of candidate highways: a highway strategy with its defaults. */
14
+ export type CandidateSource = "corridors" | "quickbundles" | "wspd" | "road-classes" | "flow-field" | "od-cells" | "shared-ways";
15
+
16
+ /** The values of `CandidateSource`. */
17
+ export const CANDIDATE_SOURCES: readonly CandidateSource[] = Object.freeze(["corridors", "quickbundles", "wspd", "road-classes", "flow-field", "od-cells", "shared-ways"]);
18
+
19
+ /** The options of the highway strategy `select` (HWY-045 of the note on highways). */
20
+ export interface SelectOptions {
21
+ /** The generators of the candidates. The default is `corridors`, `quickbundles`, `wspd` and `road-classes`. */
22
+ readonly generators: readonly CandidateSource[];
23
+ /** The weight of the added length of the edges against the ink that a highway saves (lambda 3 of the note). The default is 1. */
24
+ readonly lambda: number;
25
+ /** The number of passes of the local search with one swap. The default is 2. */
26
+ readonly swaps: number;
27
+ }
28
+
29
+ /** The default options of the strategy `select`. */
30
+ export const SELECT_DEFAULTS: Readonly<SelectOptions> = Object.freeze({ generators: Object.freeze(["corridors", "quickbundles", "wspd", "road-classes"] as CandidateSource[]), lambda: 1, swaps: 2 });
31
+
32
+ /** This function gives the plan of a generator, with its defaults. */
33
+ export function candidatePlan(input: HighwayInput, source: CandidateSource): HighwayPlan {
34
+ switch (source) {
35
+ case "corridors":
36
+ return corridorPlan(input, CORRIDOR_DEFAULTS);
37
+ case "quickbundles":
38
+ return quickBundlesPlan(input, QUICKBUNDLES_DEFAULTS);
39
+ case "wspd":
40
+ return wspdPlan(input, WSPD_DEFAULTS);
41
+ case "road-classes":
42
+ return roadClassPlan(input, ROAD_CLASS_DEFAULTS);
43
+ case "flow-field":
44
+ return flowFieldPlan(input, FLOW_FIELD_DEFAULTS);
45
+ case "od-cells":
46
+ return odCellPlan(input, OD_CELL_DEFAULTS);
47
+ case "shared-ways":
48
+ return sharedWaysPlan(input, { rule: byCollection });
49
+ }
50
+ }
51
+
52
+ /** This function gives the place along a polyline (as a length) of the point nearest to `p`, and the distance to it. */
53
+ function project(line: readonly Point[], p: Point): { at: number; d: number } {
54
+ let best = { at: 0, d: line.length > 0 ? dist(line[0]!, p) : Infinity };
55
+ let walked = 0;
56
+ for (let i = 1; i < line.length; i++) {
57
+ const a = line[i - 1]!;
58
+ const b = line[i]!;
59
+ const len = dist(a, b);
60
+ const u = len > 0 ? Math.max(0, Math.min(1, ((p.x - a.x) * (b.x - a.x) + (p.y - a.y) * (b.y - a.y)) / (len * len))) : 0;
61
+ const d = dist(p, { x: a.x + (b.x - a.x) * u, y: a.y + (b.y - a.y) * u });
62
+ if (d < best.d) best = { at: walked + u * len, d };
63
+ walked += len;
64
+ }
65
+ return best;
66
+ }
67
+
68
+ /** One candidate highway, with the index of its generator, its edges and its net gain. */
69
+ interface Candidate {
70
+ readonly highway: Highway;
71
+ readonly source: number;
72
+ readonly edges: readonly string[];
73
+ readonly net: number;
74
+ }
75
+
76
+ /**
77
+ * This function gives the ink that a highway saves. It is the sum of the spine lengths of the members, less the length of their union.
78
+ * The extra length of a member is its detour from its first route. The detour goes to the merge point, along the spine and back from the split point.
79
+ */
80
+ export function candidateScore(h: Highway, routes: ReadonlyMap<string, readonly Point[]>): { gain: number; extra: number } {
81
+ const spans = h.members.map((m) => [Math.min(m.on, m.off), Math.max(m.on, m.off)] as const).sort((a, b) => a[0] - b[0] || a[1] - b[1]);
82
+ let sum = 0;
83
+ for (const [lo, hi] of spans) sum += lengthBetween(h.spine, lo, hi);
84
+ let union = 0;
85
+ let from = -1;
86
+ let to = -1;
87
+ for (const [lo, hi] of spans) {
88
+ if (lo > to) {
89
+ if (to > from) union += lengthBetween(h.spine, from, to);
90
+ [from, to] = [lo, hi];
91
+ } else to = Math.max(to, hi);
92
+ }
93
+ if (to > from) union += lengthBetween(h.spine, from, to);
94
+ let extra = 0;
95
+ for (const m of h.members) {
96
+ const route = routes.get(m.edge);
97
+ if (!route || route.length < 2) continue;
98
+ const a = project(route, m.merge);
99
+ const b = project(route, m.split);
100
+ const along = lengthBetween(h.spine, Math.min(m.on, m.off), Math.max(m.on, m.off));
101
+ extra += Math.max(0, a.d + along + b.d - Math.max(0, b.at - a.at));
102
+ }
103
+ return { gain: sum - union, extra };
104
+ }
105
+
106
+ /**
107
+ * This function gives the highways of the candidate generation and selection (HWY-045). Each generator proposes its highways as candidates.
108
+ * The net gain of a candidate is the ink that it saves, less `lambda` times the length that it adds to its edges.
109
+ * Two candidates of two generators conflict when they share an edge. The highways of one generator do not conflict.
110
+ *
111
+ * The selection takes the candidates by net gain for each edge, without a conflict. Then each pass of the local search
112
+ * swaps in a candidate. The swap occurs when the net gain of the candidate is more than the sum of the selected candidates in conflict with it.
113
+ */
114
+ export function selectPlan(input: HighwayInput, o: Readonly<SelectOptions>): HighwayPlan {
115
+ const routes = firstRoutes(input);
116
+ const candidates: Candidate[] = [];
117
+ [...new Set(o.generators)].forEach((source, si) => {
118
+ for (const highway of candidatePlan(input, source).highways) {
119
+ const { gain, extra } = candidateScore(highway, routes);
120
+ candidates.push({ highway: { ...highway, key: `${source}:${highway.key}` }, source: si, edges: highway.members.map((m) => m.edge), net: gain - o.lambda * extra });
121
+ }
122
+ });
123
+ const onEdge = new Map<string, number[]>();
124
+ candidates.forEach((c, i) => {
125
+ for (const e of c.edges) {
126
+ const list = onEdge.get(e);
127
+ if (list) list.push(i);
128
+ else onEdge.set(e, [i]);
129
+ }
130
+ });
131
+ const conflicts = (i: number): Set<number> => {
132
+ const out = new Set<number>();
133
+ for (const e of candidates[i]!.edges) for (const j of onEdge.get(e) ?? []) if (j !== i && candidates[j]!.source !== candidates[i]!.source) out.add(j);
134
+ return out;
135
+ };
136
+ const order = candidates
137
+ .map((_, i) => i)
138
+ .filter((i) => candidates[i]!.net > 0)
139
+ .sort((a, b) => candidates[b]!.net / candidates[b]!.edges.length - candidates[a]!.net / candidates[a]!.edges.length || a - b);
140
+ const chosen = new Set<number>();
141
+ const fill = (): void => {
142
+ for (const i of order) if (!chosen.has(i) && ![...conflicts(i)].some((j) => chosen.has(j))) chosen.add(i);
143
+ };
144
+ fill();
145
+ for (let pass = 0; pass < Math.max(0, Math.round(o.swaps)); pass++) {
146
+ let changed = false;
147
+ for (const i of order) {
148
+ if (chosen.has(i)) continue;
149
+ const against = [...conflicts(i)].filter((j) => chosen.has(j));
150
+ const lost = against.reduce((s, j) => s + candidates[j]!.net, 0);
151
+ if (against.length > 0 && candidates[i]!.net > lost + 1e-9) {
152
+ for (const j of against) chosen.delete(j);
153
+ chosen.add(i);
154
+ changed = true;
155
+ }
156
+ }
157
+ fill();
158
+ if (!changed) break;
159
+ }
160
+ const highways = [...chosen].sort((a, b) => a - b).map((i) => candidates[i]!.highway);
161
+ return tidyPlan({ highways });
162
+ }
@@ -0,0 +1,110 @@
1
+ import { dist, polylineLength, type Point } from "../geom.ts";
2
+ import { orderLanes } from "../segments.ts";
3
+ import { WayFinder, type WayOptions } from "../ways.ts";
4
+ import { nearestPlace, tidyPlan } from "./base.ts";
5
+ import { lengthBetween, memberAt, pointAt, type Highway, type HighwayInput, type HighwayMember, type HighwayPlan } from "./model.ts";
6
+ import { edgePlugs } from "./stretches.ts";
7
+
8
+ /** One edge that snaps to a line: the points where it joins and leaves the line, and its place along its own way. */
9
+ export interface SnapMember {
10
+ readonly edge: string;
11
+ /** The point on the line where the edge joins it. */
12
+ readonly from: Point;
13
+ /** The point on the line where the edge leaves it. */
14
+ readonly to: Point;
15
+ /** The place of the join along the edge, from 0 at the source to 1 at the target. It orders the highways of one edge. */
16
+ readonly at: number;
17
+ }
18
+
19
+ /** A straight line that a finder gives as the place of a highway, with the edges that snap to it. */
20
+ export interface SnapLine {
21
+ readonly key: string;
22
+ readonly a: Point;
23
+ readonly b: Point;
24
+ readonly members: readonly SnapMember[];
25
+ }
26
+
27
+ /** The options of `snapPlan`. */
28
+ export interface SnapOptions extends Pick<WayOptions, "margin" | "k"> {
29
+ /** A highway has this number of edges or more. The value is 2 or more. */
30
+ readonly minSize: number;
31
+ }
32
+
33
+ /** This function gives the line `u d + rho n` of the direction angle `theta`, with `d = (cos, sin)` and the normal `n = (-sin, cos)`. */
34
+ export function linePoint(theta: number, rho: number, u: number): Point {
35
+ const c = Math.cos(theta);
36
+ const s = Math.sin(theta);
37
+ return { x: u * c - rho * s, y: u * s + rho * c };
38
+ }
39
+
40
+ /**
41
+ * This function gives the highways of straight lines in the free space. Each line becomes a spine through the corner graph between its two ends.
42
+ * An end inside a box moves to the nearest corner of the graph.
43
+ * Each member joins the spine at the place nearest to its point `from`, and leaves it at the place nearest to its point `to`.
44
+ * A member whose part of the spine is shorter than the margin does not ride. A spine with fewer than `minSize` members is not a highway.
45
+ * An edge on several highways takes them in the order of `at`.
46
+ */
47
+ export function snapPlan(input: HighwayInput, lines: readonly SnapLine[], o: Readonly<SnapOptions>): HighwayPlan {
48
+ const min = Math.max(2, o.minSize);
49
+ const finder = new WayFinder(input.graph.nodes, { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) });
50
+ const corners = finder.corners;
51
+ const plugs = new Map(edgePlugs(input.graph).map((p) => [p.edge.id, p] as const));
52
+ const pad = finder.margin / 2;
53
+ const free = (p: Point): Point => {
54
+ if (!finder.obstacles.inside(p, pad)) return p;
55
+ const i = corners.nearest(p);
56
+ return i >= 0 ? corners.points[i]! : p;
57
+ };
58
+ const highways: Highway[] = [];
59
+ const at = new Map<string, number>();
60
+ for (const line of lines) {
61
+ const a = free(line.a);
62
+ const b = free(line.b);
63
+ if (dist(a, b) <= finder.margin) continue;
64
+ const raw = corners.path(a, b);
65
+ if (!raw) continue;
66
+ const spine = corners.pull(raw);
67
+ if (spine.length < 2 || polylineLength(spine) <= 1e-6) continue;
68
+ const riders: { edge: string; on: number; off: number; points: Point[] }[] = [];
69
+ for (const m of line.members) {
70
+ const plug = plugs.get(m.edge);
71
+ if (!plug) continue;
72
+ const on = nearestPlace(spine, m.from);
73
+ const off = nearestPlace(spine, m.to);
74
+ if (lengthBetween(spine, Math.min(on, off), Math.max(on, off)) < finder.margin) continue;
75
+ riders.push({ edge: m.edge, on, off, points: [plug.ps.point, ...spinePart(spine, on, off), plug.pt.point] });
76
+ }
77
+ if (riders.length < min) continue;
78
+ const hi = highways.length;
79
+ for (const m of line.members) at.set(`${hi}\u0000${m.edge}`, m.at);
80
+ const lanes = orderLanes(spine, riders.map((r) => ({ edge: r.edge, points: r.points })), "bundle");
81
+ const byEdge = new Map(riders.map((r) => [r.edge, r]));
82
+ const members: HighwayMember[] = lanes.map((edge) => memberAt(spine, edge, byEdge.get(edge)!.on, byEdge.get(edge)!.off));
83
+ highways.push({ id: `h${hi}`, key: line.key, spine, lanes, members });
84
+ }
85
+ // The `seq` of each member follows `at` along its edge.
86
+ const trips = new Map<string, { h: number; at: number }[]>();
87
+ highways.forEach((h, hi) => {
88
+ for (const m of h.members) {
89
+ const list = trips.get(m.edge) ?? [];
90
+ list.push({ h: hi, at: at.get(`${hi}\u0000${m.edge}`) ?? 0 });
91
+ trips.set(m.edge, list);
92
+ }
93
+ });
94
+ const seq = new Map<string, number>();
95
+ for (const [edge, list] of trips) {
96
+ list.sort((p, q) => p.at - q.at || p.h - q.h);
97
+ list.forEach((x, k) => seq.set(`${x.h}\u0000${edge}`, k));
98
+ }
99
+ return tidyPlan({ highways: highways.map((h, hi) => ({ ...h, members: h.members.map((m) => ({ ...m, seq: seq.get(`${hi}\u0000${m.edge}`) ?? 0 })) })) });
100
+ }
101
+
102
+ /** This function gives the points of a spine from the place `on` to the place `off`, backward when `on` is larger. */
103
+ export function spinePart(spine: readonly Point[], on: number, off: number): Point[] {
104
+ const lo = Math.min(on, off);
105
+ const hi = Math.max(on, off);
106
+ const out = [pointAt(spine, lo)];
107
+ for (let i = Math.floor(lo) + 1; i < hi; i++) out.push(spine[i]!);
108
+ out.push(pointAt(spine, hi));
109
+ return on <= off ? out : out.reverse();
110
+ }
@@ -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
+ }