@mark1russell7/highways 0.4.0 → 0.4.2

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 (52) hide show
  1. package/README.md +67 -1
  2. package/build/index.d.ts +954 -2
  3. package/build/index.d.ts.map +1 -1
  4. package/build/index.js +5914 -1419
  5. package/build/index.js.map +1 -1
  6. package/package.json +2 -1
  7. package/src/bench/measures/bundles.ts +101 -2
  8. package/src/bench/measures/highways.ts +93 -0
  9. package/src/bench/measures/index.ts +2 -0
  10. package/src/bench/measures/order.ts +97 -0
  11. package/src/bench/room.ts +126 -9
  12. package/src/bench/screen/measures.ts +17 -0
  13. package/src/bench/screen/screen.ts +87 -9
  14. package/src/highways/base.ts +126 -0
  15. package/src/highways/flow-field/index.ts +49 -0
  16. package/src/highways/gates/ambiguity/index.ts +110 -0
  17. package/src/highways/gates/breakeven/index.ts +62 -0
  18. package/src/highways/gates/hysteresis/index.ts +48 -0
  19. package/src/highways/gates/ids/index.ts +59 -0
  20. package/src/highways/gates/index.ts +6 -0
  21. package/src/highways/gates/model.ts +129 -0
  22. package/src/highways/gates/stretch/index.ts +39 -0
  23. package/src/highways/grouped.ts +123 -0
  24. package/src/highways/index.ts +15 -0
  25. package/src/highways/level-links/index.ts +114 -0
  26. package/src/highways/meet/index.ts +84 -0
  27. package/src/highways/model.ts +5 -0
  28. package/src/highways/od-cells/index.ts +50 -0
  29. package/src/highways/quickbundles/index.ts +117 -0
  30. package/src/highways/road-classes/index.ts +74 -0
  31. package/src/highways/router.ts +1 -1
  32. package/src/highways/select/index.ts +162 -0
  33. package/src/highways/stretches.ts +141 -0
  34. package/src/highways/stubs/index.ts +117 -0
  35. package/src/highways/weiszfeld/index.ts +105 -0
  36. package/src/highways/wspd/index.ts +130 -0
  37. package/src/highways/zipper/index.ts +168 -0
  38. package/src/index.ts +2 -0
  39. package/src/order/index.ts +65 -0
  40. package/src/plugs/justify.ts +48 -0
  41. package/src/plugs/side.ts +138 -0
  42. package/src/repair/index.ts +377 -0
  43. package/src/repair/walk.ts +220 -0
  44. package/src/room/lanes.ts +109 -10
  45. package/src/routers/corners.ts +73 -6
  46. package/src/routers/fan.ts +145 -0
  47. package/src/routers/index.ts +2 -2
  48. package/src/routers/shared.ts +107 -8
  49. package/src/routers/snell.ts +140 -0
  50. package/src/routers/trunks.ts +43 -2
  51. package/src/strategies/builtins.ts +149 -7
  52. package/src/strategies/schemas.ts +173 -2
@@ -0,0 +1,117 @@
1
+ import { boardUnit } from "../../bench/measures/routes.ts";
2
+ import { dist, polylineLength, type Point } from "../../geom.ts";
3
+ import { firstRoutes } from "../base.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 line of each edge that QuickBundles compares: its first route, or the straight line between its plugs. */
9
+ export type BundleLine = "routes" | "lines";
10
+
11
+ /** The order of the edges in the one pass: the order of the graph, or the longest line first. */
12
+ export type BundleOrder = "input" | "length";
13
+
14
+ /** The options of the highway strategy `quickbundles` (HWY-026 of the note on highways). */
15
+ export interface QuickBundlesOptions {
16
+ /** An edge joins a cluster when its distance to the centroid is this value or less, in units of the board. The default is 3. */
17
+ readonly theta: number;
18
+ /** The number of points of each line after the resampling. The default is 12. */
19
+ readonly points: number;
20
+ /** The line of each edge. The default is `routes`. */
21
+ readonly line: BundleLine;
22
+ /** The order of the edges. The default is `input`, so an added edge does not change the clusters before it. */
23
+ readonly order: BundleOrder;
24
+ /** A cluster with this number of edges or more gets highways. The default is 2. */
25
+ readonly minSize: number;
26
+ /** The spine of each cluster. The default is `shared`. */
27
+ readonly spine: GroupSpine;
28
+ }
29
+
30
+ /** The default options of the strategy `quickbundles`. */
31
+ export const QUICKBUNDLES_DEFAULTS: Readonly<QuickBundlesOptions> = Object.freeze({ theta: 3, points: 12, line: "routes", order: "input", minSize: 2, spine: "shared" });
32
+
33
+ /** This function gives `k` points at equal steps of length along a polyline, from its first point to its last point. */
34
+ export function resample(line: readonly Point[], k: number): Point[] {
35
+ const n = Math.max(2, Math.round(k));
36
+ if (line.length === 0) throw new Error("The polyline has no point.");
37
+ if (line.length === 1) return Array.from({ length: n }, () => line[0]!);
38
+ const at: number[] = [0];
39
+ for (let i = 1; i < line.length; i++) at.push(at[i - 1]! + dist(line[i - 1]!, line[i]!));
40
+ const total = at[at.length - 1]!;
41
+ const out: Point[] = [];
42
+ let j = 1;
43
+ for (let i = 0; i < n; i++) {
44
+ const s = (total * i) / (n - 1);
45
+ while (j < line.length - 1 && at[j]! < s) j++;
46
+ const a = line[j - 1]!;
47
+ const b = line[j]!;
48
+ const len = at[j]! - at[j - 1]!;
49
+ const u = len > 0 ? Math.max(0, Math.min(1, (s - at[j - 1]!) / len)) : 0;
50
+ out.push({ x: a.x + (b.x - a.x) * u, y: a.y + (b.y - a.y) * u });
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * This function gives the MDF distance of two lines with the same number of points. It is the mean distance of their points.
57
+ * The function compares the points in the same direction and in the opposite direction, and keeps the smaller mean.
58
+ * `flip` is `true` when the opposite direction is smaller.
59
+ */
60
+ export function mdf(p: readonly Point[], q: readonly Point[]): { d: number; flip: boolean } {
61
+ const n = p.length;
62
+ let direct = 0;
63
+ let back = 0;
64
+ for (let i = 0; i < n; i++) {
65
+ direct += dist(p[i]!, q[i]!);
66
+ back += dist(p[i]!, q[n - 1 - i]!);
67
+ }
68
+ return back < direct ? { d: back / n, flip: true } : { d: direct / n, flip: false };
69
+ }
70
+
71
+ /**
72
+ * This function gives the clusters of QuickBundles (Garyfallidis 2012) as groups of edges.
73
+ * In one pass, each edge joins the cluster with the nearest centroid when the MDF distance is `theta` units or less. Else it starts a cluster.
74
+ * The centroid is the running mean of the lines of the cluster, each in the direction of the centroid.
75
+ */
76
+ export function quickBundles(input: HighwayInput, o: Readonly<QuickBundlesOptions>): Map<string, EdgeGroup> {
77
+ const graph = input.graph;
78
+ const limit = o.theta * boardUnit(graph);
79
+ const k = Math.max(2, Math.round(o.points));
80
+ const routes = o.line === "routes" ? firstRoutes(input) : undefined;
81
+ const lines = edgePlugs(graph).map(({ edge, ps, pt }) => ({ edge: edge.id, line: resample(routes?.get(edge.id) ?? [ps.point, pt.point], k) }));
82
+ if (o.order === "length") lines.sort((a, b) => polylineLength(b.line) - polylineLength(a.line) || (a.edge < b.edge ? -1 : a.edge > b.edge ? 1 : 0));
83
+ const clusters: { sum: { x: number; y: number }[]; n: number; centroid: Point[] }[] = [];
84
+ const groups = new Map<string, EdgeGroup>();
85
+ for (const { edge, line } of lines) {
86
+ let best = -1;
87
+ let bestD = Infinity;
88
+ let bestFlip = false;
89
+ clusters.forEach((c, ci) => {
90
+ const m = mdf(line, c.centroid);
91
+ if (m.d < bestD) [best, bestD, bestFlip] = [ci, m.d, m.flip];
92
+ });
93
+ if (best < 0 || bestD > limit) {
94
+ clusters.push({ sum: line.map((p) => ({ ...p })), n: 1, centroid: line });
95
+ groups.set(edge, { key: `q${clusters.length - 1}`, flip: false });
96
+ continue;
97
+ }
98
+ const c = clusters[best]!;
99
+ const oriented = bestFlip ? [...line].reverse() : line;
100
+ oriented.forEach((p, i) => {
101
+ c.sum[i]!.x += p.x;
102
+ c.sum[i]!.y += p.y;
103
+ });
104
+ c.n++;
105
+ c.centroid = c.sum.map((p) => ({ x: p.x / c.n, y: p.y / c.n }));
106
+ groups.set(edge, { key: `q${best}`, flip: bestFlip });
107
+ }
108
+ return groups;
109
+ }
110
+
111
+ /**
112
+ * This function gives the highways of QuickBundles (HWY-026). The clusters of `quickBundles` with `minSize` edges or more get highways.
113
+ * With the spine `shared`, the edges of each cluster share their ways through the corner graph. With `trunk`, each cluster gets one trunk.
114
+ */
115
+ export function quickBundlesPlan(input: HighwayInput, o: Readonly<QuickBundlesOptions>): HighwayPlan {
116
+ return groupedPlan(input, quickBundles(input, o), { spine: o.spine, minSize: o.minSize });
117
+ }
@@ -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,141 @@
1
+ import { center, dist, type Point } from "../geom.ts";
2
+ import { nodeIndex, type Edge, type Graph } from "../model.ts";
3
+ import { placePlug, type Plug } from "../plugs.ts";
4
+ import { orderLanes } from "../segments.ts";
5
+ import { tidyPlan } from "./base.ts";
6
+ import { memberAt, type Highway, type HighwayMember, type HighwayPlan } from "./model.ts";
7
+
8
+ /** An edge with its two plugs, as the router `corners` places them. */
9
+ export interface EdgePlugs {
10
+ readonly edge: Edge;
11
+ readonly ps: Plug;
12
+ readonly pt: Plug;
13
+ }
14
+
15
+ /** This function gives the plugs of each edge of the graph, in the order of the edges. An edge with an unknown node is not in the list. */
16
+ export function edgePlugs(graph: Graph): EdgePlugs[] {
17
+ const byId = nodeIndex(graph);
18
+ const out: EdgePlugs[] = [];
19
+ for (const e of graph.edges) {
20
+ const s = byId.get(e.source.node);
21
+ const t = byId.get(e.target.node);
22
+ if (!s || !t) continue;
23
+ out.push({ edge: e, ps: placePlug(s, e.source, center(t.box)), pt: placePlug(t, e.target, center(s.box)) });
24
+ }
25
+ return out;
26
+ }
27
+
28
+ /** The way of one edge through the corner graph, with a key. Only the ways with one key share a highway. */
29
+ export interface KeyedWay {
30
+ readonly edge: string;
31
+ readonly key: string;
32
+ /** The points of the way, without the plugs. Two ways share a link when the link has the same two points. */
33
+ readonly points: readonly Point[];
34
+ }
35
+
36
+ /** The options of `sharedStretches`. */
37
+ export interface SharedStretchOptions {
38
+ /** A highway has this number of edges or more. The value is 2 or more. */
39
+ readonly minSize: number;
40
+ /** This function tells if a link can be part of a highway. Without it, each link can. */
41
+ readonly link?: (a: Point, b: Point) => boolean;
42
+ /** This function removes the needless corners of a spine. Without it, the spine keeps each point of the stretch. */
43
+ readonly pull?: (points: readonly Point[]) => Point[];
44
+ }
45
+
46
+ const pointKey = (p: Point): string => `${p.x},${p.y}`;
47
+ const linkKey = (a: string, b: string): string => (a < b ? `${a}|${b}` : `${b}|${a}`);
48
+
49
+ /** This function removes each point of a polyline that is equal to the point before it. */
50
+ const dedupe = (p: readonly Point[]): Point[] => p.filter((q, i) => i === 0 || dist(q, p[i - 1]!) > 1e-9);
51
+
52
+ /**
53
+ * This function gives a highway for each stretch that two ways or more of one key share. A way is a polyline through the corner graph.
54
+ * Each way breaks into stretches where the set of the ways on its links stays the same. The router `shared` makes its runs in the same way.
55
+ * A stretch with fewer than `minSize` ways is not a highway. Each way joins the spine at one end of the stretch and leaves it at the other end.
56
+ * The `seq` of each member follows the order of the stretches along the way.
57
+ */
58
+ export function sharedStretches(ways: readonly KeyedWay[], o: Readonly<SharedStretchOptions>): HighwayPlan {
59
+ const min = Math.max(2, o.minSize);
60
+ const clean = ways.map((w) => ({ ...w, points: dedupe(w.points) }));
61
+ const users = new Map<string, string[]>();
62
+ const usable = (a: Point, b: Point): boolean => o.link?.(a, b) ?? true;
63
+ for (const w of clean) {
64
+ const own = new Set<string>();
65
+ for (let i = 1; i < w.points.length; i++) {
66
+ const a = w.points[i - 1]!;
67
+ const b = w.points[i]!;
68
+ if (usable(a, b)) own.add(`${w.key}\u0000${linkKey(pointKey(a), pointKey(b))}`);
69
+ }
70
+ for (const k of own) {
71
+ const list = users.get(k);
72
+ if (list) list.push(w.edge);
73
+ else users.set(k, [w.edge]);
74
+ }
75
+ }
76
+ interface Stretch {
77
+ readonly key: string;
78
+ readonly points: readonly Point[];
79
+ readonly members: { edge: string; forward: boolean; at: number }[];
80
+ }
81
+ const stretches = new Map<string, Stretch>();
82
+ const wayOf = new Map<string, readonly Point[]>();
83
+ for (const w of clean) {
84
+ wayOf.set(w.edge, w.points);
85
+ const sig = (i: number): string => {
86
+ const a = w.points[i]!;
87
+ const b = w.points[i + 1]!;
88
+ if (!usable(a, b)) return "";
89
+ const list = users.get(`${w.key}\u0000${linkKey(pointKey(a), pointKey(b))}`) ?? [];
90
+ return list.length < min ? "" : [...list].sort().join("\u0001");
91
+ };
92
+ let start = 0;
93
+ for (let i = 0; i < w.points.length - 1; i++) {
94
+ const s = sig(i);
95
+ if (i + 1 < w.points.length - 1 && sig(i + 1) === s) continue;
96
+ const from = start;
97
+ start = i + 1;
98
+ if (s === "") continue;
99
+ const a = pointKey(w.points[from]!);
100
+ const b = pointKey(w.points[i + 1]!);
101
+ if (a === b) continue;
102
+ const id = `${w.key}\u0000${s}\u0000${linkKey(a, b)}`;
103
+ const piece = w.points.slice(from, i + 2);
104
+ let found = stretches.get(id);
105
+ if (!found) stretches.set(id, (found = { key: w.key, points: a < b ? piece : [...piece].reverse(), members: [] }));
106
+ if (!found.members.some((m) => m.edge === w.edge)) found.members.push({ edge: w.edge, forward: a < b, at: from });
107
+ }
108
+ }
109
+ // The order of the stretches of each way gives the `seq` of its members.
110
+ const kept = [...stretches.values()].filter((s) => s.members.length >= min);
111
+ const order = new Map<string, { s: number; at: number }[]>();
112
+ kept.forEach((s, si) => {
113
+ for (const m of s.members) {
114
+ const list = order.get(m.edge);
115
+ if (list) list.push({ s: si, at: m.at });
116
+ else order.set(m.edge, [{ s: si, at: m.at }]);
117
+ }
118
+ });
119
+ const seq = new Map<string, number>();
120
+ for (const [edge, list] of order) {
121
+ list.sort((p, q) => p.at - q.at || p.s - q.s);
122
+ list.forEach((x, k) => seq.set(`${x.s}\u0000${edge}`, k));
123
+ }
124
+ const highways: Highway[] = kept.map((s, si) => {
125
+ const spine = o.pull ? o.pull(s.points) : [...s.points];
126
+ const last = spine.length - 1;
127
+ const lanes = orderLanes(
128
+ s.points,
129
+ s.members.map((m) => ({ edge: m.edge, points: wayOf.get(m.edge)! })),
130
+ "bundle",
131
+ );
132
+ const byEdge = new Map(s.members.map((m) => [m.edge, m]));
133
+ const members: HighwayMember[] = lanes.map((edge) => {
134
+ const m = byEdge.get(edge)!;
135
+ const k = seq.get(`${si}\u0000${edge}`) ?? 0;
136
+ return m.forward ? memberAt(spine, edge, 0, last, k) : memberAt(spine, edge, last, 0, k);
137
+ });
138
+ return { id: `h${si}`, key: s.key, spine, lanes, members };
139
+ });
140
+ return tidyPlan({ highways });
141
+ }
@@ -0,0 +1,117 @@
1
+ import { center, dist, pointBoxDistance, type Point } from "../../geom.ts";
2
+ import { nodeIndex, type Node } from "../../model.ts";
3
+ import { pointOnSide, SIDE_NORMAL, sideToward } from "../../plugs.ts";
4
+ import { WayFinder } from "../../ways.ts";
5
+ import { basePlan, tidyPlan, tripsOf, type HighwayBase } from "../base.ts";
6
+ import { memberAt, type Highway, type HighwayInput, type HighwayPlan } from "../model.ts";
7
+
8
+ /** The ends of the edges that get stubs: the source ends, the target ends, or both. */
9
+ export type StubEnds = "source" | "target" | "both";
10
+
11
+ /** The options of the highway strategy `stubs` (HWY-015 with WILD-005 of the notes). */
12
+ export interface StubOptions {
13
+ /** The plan that the stubs go into. The default is `shared-ways`. */
14
+ readonly from: HighwayBase;
15
+ /** The ends of one side of a box whose directions are in a window of this angle share a stub, in degrees. The default is 30. */
16
+ readonly omega: number;
17
+ /** The length of a stub is this share of the distance to the nearest other end. The default is 0.25. */
18
+ readonly sigma: number;
19
+ /** The ends that get stubs. The default is `both`. */
20
+ readonly ends: StubEnds;
21
+ /** A stub has this number of edges or more. The default is 2. */
22
+ readonly minSize: number;
23
+ }
24
+
25
+ /** The default options of the strategy `stubs`. */
26
+ export const STUB_DEFAULTS: Readonly<StubOptions> = Object.freeze({ from: "shared-ways", omega: 30, sigma: 0.25, ends: "both", minSize: 2 });
27
+
28
+ /** One end of an edge that can get a stub. */
29
+ interface StubEnd {
30
+ readonly edge: string;
31
+ readonly node: Node;
32
+ readonly other: Node;
33
+ /** The angle from the normal of the side to the direction of the other node, in radians. A negative angle turns to the left. */
34
+ readonly turn: number;
35
+ /** The first merge point of the edge in the plan, from this end, or `undefined`. */
36
+ readonly merge: Point | undefined;
37
+ }
38
+
39
+ /**
40
+ * This function gives the highways of the plan `from`, with a stub at the ends of the edges (HWY-015, WILD-005).
41
+ * The ends on one side of one box are sorted by the direction of their other node. A run of ends in a window of `omega` makes a group.
42
+ * Each group of `minSize` ends or more shares one plug and one stub along the normal of the side. Then the edges fan out.
43
+ *
44
+ * The length of the stub is `sigma` times the distance to the nearest other node, and half that distance at most.
45
+ * It is two times the margin of the way finder at least, and the stub stays out of the other boxes. Else the group gets no stub.
46
+ * An end with a fixed plug or a side of its own gets no stub. An end whose first highway starts on its box, or near it, keeps the plan.
47
+ */
48
+ export function stubPlan(input: HighwayInput, o: Readonly<StubOptions>): HighwayPlan {
49
+ const graph = input.graph;
50
+ const plan = basePlan(input, o.from);
51
+ const trips = tripsOf(plan);
52
+ const byId = nodeIndex(graph);
53
+ const ways = new WayFinder(graph.nodes);
54
+ const margin = ways.margin;
55
+ const omega = (Math.max(0, o.omega) * Math.PI) / 180;
56
+ const groups = new Map<string, StubEnd[]>();
57
+ const add = (kind: "source" | "target", node: Node, other: Node, edge: string, merge: Point | undefined): void => {
58
+ const side = sideToward(node.box, center(other.box));
59
+ const n = SIDE_NORMAL[side];
60
+ const c = center(node.box);
61
+ const d = center(other.box);
62
+ const dx = d.x - c.x;
63
+ const dy = d.y - c.y;
64
+ const turn = Math.atan2(n.x * dy - n.y * dx, n.x * dx + n.y * dy);
65
+ const key = `${node.id}\u0000${kind}\u0000${side}`;
66
+ const list = groups.get(key);
67
+ const end: StubEnd = { edge, node, other, turn, merge };
68
+ if (list) list.push(end);
69
+ else groups.set(key, [end]);
70
+ };
71
+ for (const e of graph.edges) {
72
+ const s = byId.get(e.source.node);
73
+ const t = byId.get(e.target.node);
74
+ if (!s || !t || s === t) continue;
75
+ const trip = trips.get(e.id);
76
+ if (o.ends !== "target" && e.source.port === undefined) add("source", s, t, e.id, trip?.[0]?.m.merge);
77
+ if (o.ends !== "source" && e.target.port === undefined) add("target", t, s, e.id, trip?.[trip.length - 1]?.m.split);
78
+ }
79
+
80
+ const stubs: Highway[] = [];
81
+ for (const [key, list] of [...groups].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) {
82
+ const [, kind, side] = key.split("\u0000") as [string, "source" | "target", keyof typeof SIDE_NORMAL];
83
+ list.sort((a, b) => a.turn - b.turn || (a.edge < b.edge ? -1 : 1));
84
+ // The runs of ends in a window of omega.
85
+ const runs: StubEnd[][] = [];
86
+ for (const end of list) {
87
+ const run = runs[runs.length - 1];
88
+ if (run && end.turn - run[0]!.turn <= omega) run.push(end);
89
+ else runs.push([end]);
90
+ }
91
+ for (const run of runs) {
92
+ if (run.length < Math.max(2, o.minSize)) continue;
93
+ const node = run[0]!.node;
94
+ const n = SIDE_NORMAL[side];
95
+ const toward = { x: run.reduce((s, r) => s + center(r.other.box).x, 0) / run.length, y: run.reduce((s, r) => s + center(r.other.box).y, 0) / run.length };
96
+ const plug = pointOnSide(node.box, side, toward);
97
+ const span = Math.min(...run.map((r) => dist(plug, center(r.other.box))));
98
+ const gap = Math.min(...run.map((r) => pointBoxDistance(plug, r.other.box)));
99
+ let len = Math.min(Math.max(0, o.sigma) * span, gap / 2);
100
+ const skip = new Set([node]);
101
+ const tip = (l: number): Point => ({ x: plug.x + n.x * l, y: plug.y + n.y * l });
102
+ while (len >= 2 * margin && !ways.obstacles.clear(tip(margin / 2), tip(len), margin / 2, skip)) len /= 2;
103
+ if (len < 2 * margin) continue;
104
+ // An end whose highway starts on the box, or near the tip, keeps the plan.
105
+ const kept = run.filter((r) => r.merge === undefined || pointBoxDistance(r.merge, node.box) > 2 * len);
106
+ if (kept.length < Math.max(2, o.minSize)) continue;
107
+ const end = tip(len);
108
+ const spine = kind === "source" ? [plug, end] : [end, plug];
109
+ // Lane 0 is on the left of the spine. At a source, the left lane turns most to the left. At a target, the order turns over.
110
+ const lanes = kept.map((r) => r.edge);
111
+ if (kind === "target") lanes.reverse();
112
+ const seq = kind === "source" ? -1 : Number.MAX_SAFE_INTEGER;
113
+ stubs.push({ id: "", key: `stub:${node.id}:${kind}:${side}`, spine, lanes, members: lanes.map((edge) => memberAt(spine, edge, 0, 1, seq)) });
114
+ }
115
+ }
116
+ return tidyPlan({ highways: [...plan.highways, ...stubs] });
117
+ }