@mark1russell7/highways 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "directory": "packages/core"
10
10
  },
11
11
  "homepage": "https://mark1russell7.github.io/highways/",
12
- "version": "0.9.0",
12
+ "version": "0.9.1",
13
13
  "type": "module",
14
14
  "main": "./build/index.js",
15
15
  "types": "./build/index.d.ts",
@@ -29,7 +29,7 @@
29
29
  "access": "public"
30
30
  },
31
31
  "dependencies": {
32
- "@mark1russell7/layout-contract": "^0.2.14"
32
+ "@mark1russell7/layout-contract": "^0.2.15"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@mark1russell7/cue": "github:mark1russell7/cue",
@@ -77,6 +77,11 @@ export const MOVE_NAMES: Readonly<Record<string, string>> = Object.freeze({
77
77
  consensus: "cluster",
78
78
  "p-hub": "hubLink",
79
79
  "scale-space": "cluster",
80
+ steiner: "tree",
81
+ gaps: "gap",
82
+ streets: "street",
83
+ search: "searched",
84
+ interchanges: "networkRoad",
80
85
  });
81
86
 
82
87
  /**
@@ -12,6 +12,8 @@ export interface LayoutPort {
12
12
  /** The fixed place of the port, from the top-left corner of the box. The router does not move a port with `x` and `y`. */
13
13
  readonly x?: number;
14
14
  readonly y?: number;
15
+ /** The fixed place of the port on its `side`: a fraction from 0 to 1. The start is the top end or the left end of the side. */
16
+ readonly along?: number;
15
17
  }
16
18
 
17
19
  /** A point of a region: `[x, y]`. The y axis points down. */
@@ -238,18 +240,20 @@ function sideAt(p: { x: number; y: number }, w: number, h: number): LayoutSide {
238
240
  const num = round3;
239
241
 
240
242
  function portToFile(p: Port, n: Node): LayoutPort {
243
+ if (p.along !== undefined) return { id: p.id, ...(p.side ? { side: TO_FILE[p.side] } : {}), along: p.along };
241
244
  const side = p.side ? TO_FILE[p.side] : p.at ? sideAt(p.at, n.box.w, n.box.h) : undefined;
242
245
  return { id: p.id, ...(side ? { side } : {}), ...(p.at ? { x: num(p.at.x), y: num(p.at.y) } : {}) };
243
246
  }
244
247
 
245
248
  function portToModel(p: LayoutPort): Port {
249
+ if (p.along !== undefined) return { id: p.id, ...(p.side ? { side: TO_MODEL[p.side] } : {}), along: p.along };
246
250
  if (p.x !== undefined && p.y !== undefined) return { id: p.id, at: { x: p.x, y: p.y } };
247
251
  return { id: p.id, ...(p.side ? { side: TO_MODEL[p.side] } : {}) };
248
252
  }
249
253
 
250
254
  /**
251
255
  * This function writes a graph as an input file of the form `layout-input/1`, with its hash.
252
- * A fixed plug gets `x`, `y` and the side that holds it. `inputGraph` reads the file back.
256
+ * A fixed plug gets `x`, `y` and the side that holds it. A plug with `along` keeps its `side` and `along`. `inputGraph` reads the file back.
253
257
  */
254
258
  export function graphInput(graph: Graph, head: LayoutInputHead): LayoutInput {
255
259
  const boxes = graph.nodes.map(
@@ -49,6 +49,19 @@ export const parallel: Graph = {
49
49
  edges: [0, 1, 2].map((i) => ({ id: `e${i}`, source: { node: `l${i}` }, target: { node: `r${i}` } })),
50
50
  };
51
51
 
52
+ /** Three rows of nodes on the left and on the right, with three edges between the rows. The gaps between the rows are 40 wide. */
53
+ export const rows: Graph = {
54
+ nodes: [0, 1, 2].flatMap((i) => [
55
+ { id: `l${i}`, box: box(0, 60 * i) },
56
+ { id: `r${i}`, box: box(600, 60 * i) },
57
+ ]),
58
+ edges: [
59
+ { id: "a", source: { node: "l0" }, target: { node: "r2" } },
60
+ { id: "b", source: { node: "l1" }, target: { node: "r1" } },
61
+ { id: "c", source: { node: "l2" }, target: { node: "r0" } },
62
+ ],
63
+ };
64
+
52
65
  /**
53
66
  * This function adds the common tests of a highway strategy. The strategy is in the registry, and it gives a correct plan the same each time.
54
67
  * Its routes break no hard rule after `follow`.
@@ -1,5 +1,5 @@
1
1
  import { center, dist, pointBoxDistance, type Point } from "../geom.ts";
2
- import { fixedPortPoint, nodeIndex, type End, type Graph, type Node, type Result, type Route, type SharedRun } from "../model.ts";
2
+ import { fixedPortPoint, nodeIndex, resolvePorts, type End, type Graph, type Node, type Result, type Route, type SharedRun } from "../model.ts";
3
3
  import { placePlug } from "../plugs.ts";
4
4
  import { orderLanes, type RunOrder } from "../segments.ts";
5
5
  import { WayFinder, type WayOptions } from "../ways.ts";
@@ -68,7 +68,8 @@ function slice(stops: Stops, on: number, off: number): Point[] {
68
68
  * The result has a shared run for each stretch of a spine where the same two edges or more go. Each run holds the points of its routes in a row.
69
69
  * The same graph, plan and options give the same result.
70
70
  */
71
- export function follow(graph: Graph, plan: HighwayPlan, options: FollowOptions = {}): Result {
71
+ export function follow(input: Graph, plan: HighwayPlan, options: FollowOptions = {}): Result {
72
+ const graph = resolvePorts(input);
72
73
  const byId = nodeIndex(graph);
73
74
  const ways = new WayFinder(graph.nodes, options);
74
75
  const base = new Map((options.routes?.routes ?? []).map((r) => [r.edge, r.points]));
@@ -0,0 +1,141 @@
1
+ import { dist, polylineLength, type Point } from "../geom.ts";
2
+ import type { Graph } from "../model.ts";
3
+ import { boardGaps } from "../room/index.ts";
4
+ import type { WayFinder } from "../ways.ts";
5
+ import { firstRoutes } from "./base.ts";
6
+ import type { HighwayInput } from "./model.ts";
7
+ import { edgePlugs } from "./stretches.ts";
8
+
9
+ /** The source of the candidate streets: the middle lines of the gaps of the room step, or the lines through the corners. */
10
+ export type StreetSource = "room" | "corners";
11
+
12
+ /** The values of `StreetSource`. */
13
+ export const STREET_SOURCES: readonly StreetSource[] = Object.freeze(["room", "corners"]);
14
+
15
+ /** One axis-parallel street in the free space, from `a` to `b`. */
16
+ export interface FreeLine {
17
+ readonly a: Point;
18
+ readonly b: Point;
19
+ }
20
+
21
+ /** One edge for the streets: its two points out of its plugs, and the length of its first route. */
22
+ export interface StreetEdge {
23
+ readonly edge: string;
24
+ readonly s: Point;
25
+ readonly t: Point;
26
+ readonly base: number;
27
+ }
28
+
29
+ /** The ride of one edge on one street. It has the points where the edge joins and leaves the street, and two lengths. */
30
+ export interface StreetRide {
31
+ readonly on: Point;
32
+ readonly off: Point;
33
+ /** The length on the street. */
34
+ readonly ride: number;
35
+ /** The length of the two ramps, to the street and from it. */
36
+ readonly ramps: number;
37
+ }
38
+
39
+ const round = (v: number): number => Math.round(v * 2) / 2;
40
+
41
+ /**
42
+ * This function gives the free pieces of the line `x = c` (axis `x`) or `y = c` (axis `y`). A box closer than `pad` to the line blocks it.
43
+ * A piece shorter than `min` is not in the list. The pieces run over the board and `2 pad` past it.
44
+ */
45
+ function freePieces(finder: WayFinder, axis: "x" | "y", c: number, pad: number, min: number, lo: number, hi: number): FreeLine[] {
46
+ const blocks: [number, number][] = [];
47
+ const strip = axis === "x" ? { x: c - pad, y: lo, w: 2 * pad, h: hi - lo } : { x: lo, y: c - pad, w: hi - lo, h: 2 * pad };
48
+ finder.obstacles.query(strip, (n) => {
49
+ const a = axis === "x" ? n.box.x : n.box.y;
50
+ const w = axis === "x" ? n.box.w : n.box.h;
51
+ if (c <= a - pad || c >= a + w + pad) return;
52
+ const b = axis === "x" ? n.box.y : n.box.x;
53
+ const h = axis === "x" ? n.box.h : n.box.w;
54
+ blocks.push([b - pad, b + h + pad]);
55
+ });
56
+ blocks.sort((p, q) => p[0] - q[0] || p[1] - q[1]);
57
+ const out: FreeLine[] = [];
58
+ const at = (u: number): Point => (axis === "x" ? { x: c, y: u } : { x: u, y: c });
59
+ let from = lo;
60
+ for (const [s, e] of blocks) {
61
+ if (s - from >= min) out.push({ a: at(from), b: at(s) });
62
+ from = Math.max(from, e);
63
+ }
64
+ if (hi - from >= min) out.push({ a: at(from), b: at(hi) });
65
+ return out;
66
+ }
67
+
68
+ /**
69
+ * This function gives the candidate streets of a board. Each street is an axis-parallel piece of free space.
70
+ * The source `room` takes the middle line of each gap of `boardGaps` that is two margins wide or more.
71
+ * The source `corners` takes the lines one margin out from each side of each box.
72
+ * A street is two margins long or more. The list has no copies, and its order is the same each time.
73
+ */
74
+ export function freeLines(graph: Graph, finder: WayFinder, source: StreetSource): FreeLine[] {
75
+ const nodes = graph.nodes;
76
+ if (nodes.length === 0) return [];
77
+ const m = finder.margin;
78
+ const pad = m / 2;
79
+ let x0 = Infinity;
80
+ let y0 = Infinity;
81
+ let x1 = -Infinity;
82
+ let y1 = -Infinity;
83
+ for (const n of nodes) {
84
+ x0 = Math.min(x0, n.box.x);
85
+ y0 = Math.min(y0, n.box.y);
86
+ x1 = Math.max(x1, n.box.x + n.box.w);
87
+ y1 = Math.max(y1, n.box.y + n.box.h);
88
+ }
89
+ const lines: { axis: "x" | "y"; c: number }[] = [];
90
+ if (source === "room") {
91
+ for (const g of boardGaps(nodes)) if (g.width >= 2 * m) lines.push({ axis: g.axis, c: round(g.at - g.width / 2) });
92
+ } else {
93
+ for (const n of nodes) {
94
+ lines.push({ axis: "x", c: round(n.box.x - m) }, { axis: "x", c: round(n.box.x + n.box.w + m) });
95
+ lines.push({ axis: "y", c: round(n.box.y - m) }, { axis: "y", c: round(n.box.y + n.box.h + m) });
96
+ }
97
+ }
98
+ const seen = new Set<string>();
99
+ const out: FreeLine[] = [];
100
+ const sorted = [...lines].sort((p, q) => (p.axis === q.axis ? p.c - q.c : p.axis < q.axis ? -1 : 1));
101
+ for (const l of sorted) {
102
+ const k = `${l.axis}${l.c}`;
103
+ if (seen.has(k)) continue;
104
+ seen.add(k);
105
+ const [lo, hi] = l.axis === "x" ? [y0 - 2 * m, y1 + 2 * m] : [x0 - 2 * m, x1 + 2 * m];
106
+ out.push(...freePieces(finder, l.axis, l.c, pad, 2 * m, lo, hi));
107
+ }
108
+ return out;
109
+ }
110
+
111
+ /** This function gives the edges of the input for the streets, with the points out of their plugs and the lengths of their first routes. */
112
+ export function streetEdges(input: HighwayInput, finder: WayFinder): StreetEdge[] {
113
+ const first = firstRoutes(input);
114
+ const out: StreetEdge[] = [];
115
+ for (const p of edgePlugs(input.graph)) {
116
+ const route = first.get(p.edge.id);
117
+ const s = finder.out(p.ps);
118
+ const t = finder.out(p.pt);
119
+ out.push({ edge: p.edge.id, s, t, base: route ? polylineLength(route) : dist(s, t) });
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /** This function gives the point of the street nearest to `p`. */
125
+ export function streetPoint(line: FreeLine, p: Point): Point {
126
+ if (line.a.x === line.b.x) {
127
+ const lo = Math.min(line.a.y, line.b.y);
128
+ const hi = Math.max(line.a.y, line.b.y);
129
+ return { x: line.a.x, y: Math.min(hi, Math.max(lo, p.y)) };
130
+ }
131
+ const lo = Math.min(line.a.x, line.b.x);
132
+ const hi = Math.max(line.a.x, line.b.x);
133
+ return { x: Math.min(hi, Math.max(lo, p.x)), y: line.a.y };
134
+ }
135
+
136
+ /** This function gives the ride of an edge on a street. The edge joins the street at the point nearest to `s`, and leaves it at the point nearest to `t`. */
137
+ export function streetRide(line: FreeLine, e: StreetEdge): StreetRide {
138
+ const on = streetPoint(line, e.s);
139
+ const off = streetPoint(line, e.t);
140
+ return { on, off, ride: dist(on, off), ramps: dist(e.s, on) + dist(off, e.t) };
141
+ }
@@ -0,0 +1,85 @@
1
+ import { WayFinder } from "../../ways.ts";
2
+ import { freeLines, streetEdges, streetRide, type FreeLine, type StreetEdge, type StreetSource } from "../free-lines.ts";
3
+ import type { HighwayInput, HighwayPlan } from "../model.ts";
4
+ import { snapPlan, type SnapLine } from "../snap.ts";
5
+
6
+ /** The options of the highway strategy `gaps` (HWY-057 of the note on highways). */
7
+ export interface GapOptions {
8
+ /** A street with fewer edges than this number (the demand `k`) is not a highway. The default is 3. */
9
+ readonly minSize: number;
10
+ /** The cost of a length on a street against a length off the streets, from 0.1 to 0.8. The default is 0.5. */
11
+ readonly discount: number;
12
+ /** An edge rides a street only when its way along the street is this many times its first route or less. The default is 1.3. */
13
+ readonly detour: number;
14
+ /** The source of the streets: the gaps of the room step, or the lines through the corners. The default is `room`. */
15
+ readonly source: StreetSource;
16
+ }
17
+
18
+ /** The default options of the strategy `gaps`. */
19
+ export const GAP_DEFAULTS: Readonly<GapOptions> = Object.freeze({ minSize: 3, discount: 0.5, detour: 1.3, source: "room" });
20
+
21
+ /** The best street of an edge, from the streets in `open`. The ride costs `discount` times its length. The way is at most `detour` times the first route. */
22
+ function bestStreet(lines: readonly FreeLine[], open: ReadonlySet<number>, e: StreetEdge, o: Readonly<GapOptions>, min: number): number {
23
+ let best = -1;
24
+ let bestSaving = 1e-9;
25
+ for (const i of open) {
26
+ const r = streetRide(lines[i]!, e);
27
+ if (r.ride < min) continue;
28
+ if (r.ramps + r.ride > o.detour * e.base) continue;
29
+ const saving = e.base - (r.ramps + o.discount * r.ride);
30
+ if (saving > bestSaving || (saving === bestSaving && i < best)) [best, bestSaving] = [i, saving];
31
+ }
32
+ return best;
33
+ }
34
+
35
+ /**
36
+ * This function gives the highways on the gaps of the board (HWY-057). The streets are the free middle lines of the gaps.
37
+ * Each edge takes the street that saves it the most cost, when a length on a street costs `discount` times its length.
38
+ * A street with `minSize` edges or more becomes a highway. The edges of the other streets then choose again among the highways, two times.
39
+ */
40
+ export function gapPlan(input: HighwayInput, o: Readonly<GapOptions>): HighwayPlan {
41
+ const finder = new WayFinder(input.graph.nodes);
42
+ const lines = freeLines(input.graph, finder, o.source);
43
+ const edges = streetEdges(input, finder);
44
+ const min = 2 * finder.margin;
45
+ const size = Math.max(2, Math.round(o.minSize));
46
+ let open = new Set(lines.map((_, i) => i));
47
+ let choice = new Map<string, number>();
48
+ for (let round = 0; round < 3; round++) {
49
+ choice = new Map();
50
+ const count = new Map<number, number>();
51
+ for (const e of edges) {
52
+ const i = bestStreet(lines, open, e, o, min);
53
+ if (i < 0) continue;
54
+ choice.set(e.edge, i);
55
+ count.set(i, (count.get(i) ?? 0) + 1);
56
+ }
57
+ const kept = new Set([...open].filter((i) => (count.get(i) ?? 0) >= size));
58
+ if (kept.size === open.size) break;
59
+ open = kept;
60
+ }
61
+ const byLine = new Map<number, SnapLine["members"][number][]>();
62
+ for (const e of edges) {
63
+ const i = choice.get(e.edge);
64
+ if (i === undefined || !open.has(i)) continue;
65
+ const r = streetRide(lines[i]!, e);
66
+ const list = byLine.get(i) ?? [];
67
+ list.push({ edge: e.edge, from: r.on, to: r.off, at: 0 });
68
+ byLine.set(i, list);
69
+ }
70
+ const snaps: SnapLine[] = [...byLine.keys()]
71
+ .sort((p, q) => p - q)
72
+ .map((i) => {
73
+ const members = byLine.get(i)!;
74
+ // The spine runs only over the part of the street that its members ride.
75
+ const line = lines[i]!;
76
+ const vertical = line.a.x === line.b.x;
77
+ const us = members.flatMap((m) => (vertical ? [m.from.y, m.to.y] : [m.from.x, m.to.x]));
78
+ const lo = Math.min(...us);
79
+ const hi = Math.max(...us);
80
+ const a = vertical ? { x: line.a.x, y: lo } : { x: lo, y: line.a.y };
81
+ const b = vertical ? { x: line.a.x, y: hi } : { x: hi, y: line.a.y };
82
+ return { key: `gap${i}`, a, b, members };
83
+ });
84
+ return snapPlan(input, snaps, { minSize: size, direct: true });
85
+ }
@@ -97,5 +97,11 @@ export { LINK_COMMUNITY_DEFAULTS, linkCommunities, linkCommunityGroups, linkComm
97
97
  export { cellSteps, nearestCentre, pointGroups, TERRITORY_DEFAULTS, TERRITORY_POINTS, territoryPlan, type CellStep, type TerritoryOptions, type TerritoryPoints } from "./territories/index.ts";
98
98
  export { coAssociation, CONSENSUS_DEFAULTS, CONSENSUS_LINKAGES, consensusGroups, consensusLabels, consensusPlan, type ConsensusLinkage, type ConsensusOptions } from "./consensus/index.ts";
99
99
  export { HUB_ALLOCATIONS, HUB_CANDIDATES, hubCandidates, P_HUB_DEFAULTS, pHubPlan, pMedian, type HubAllocation, type HubCandidates, type PHubOptions } from "./p-hub/index.ts";
100
+ export { freeLines, STREET_SOURCES, streetEdges, streetPoint, streetRide, type FreeLine, type StreetEdge, type StreetRide, type StreetSource } from "./free-lines.ts";
101
+ export { oneSteiner, spanningTree, STEINER_DEFAULTS, STEINER_GROUPS, STEINER_METHODS, STEINER_METRICS, steinerPlan, type SteinerGroup, type SteinerMethod, type SteinerMetric, type SteinerOptions } from "./steiner/index.ts";
102
+ export { GAP_DEFAULTS, gapPlan, type GapOptions } from "./gaps/index.ts";
103
+ export { STREET_DEFAULTS, streetPlan, type StreetOptions } from "./streets/index.ts";
104
+ export { SEARCH_DEFAULTS, SEARCH_MOVES, searchPlan, searchScore, type SearchMove, type SearchOptions } from "./search/index.ts";
105
+ export { INTERCHANGE_BASES, INTERCHANGE_DEFAULTS, interchangeCandidates, interchangePlan, type Interchange, type InterchangeBase, type InterchangeOptions } from "./interchanges/index.ts";
100
106
  export { SCALE_SPACE_DEFAULTS, scaleClusters, scaleSpaceGroups, scaleSpacePlan, type ScaleCluster, type ScaleSpaceOptions } from "./scale-space/index.ts";
101
107
  export * from "./gates/index.ts";
@@ -0,0 +1,231 @@
1
+ import { boardUnit } from "../../bench/measures/routes.ts";
2
+ import { dist, polylineLength, type Point } from "../../geom.ts";
3
+ import { orderLanes } from "../../segments.ts";
4
+ import { WayFinder } from "../../ways.ts";
5
+ import { basePlan, firstRoutes, nearestPlace, tidyPlan } from "../base.ts";
6
+ import { lengthBetween, memberAt, pointAt, type Highway, type HighwayInput, type HighwayMember, type HighwayPlan } from "../model.ts";
7
+ import { spinePart } from "../snap.ts";
8
+ import { edgePlugs } from "../stretches.ts";
9
+
10
+ /** The plan that gives the highways of the network: the plan of `corridors` or of `shared-ways`, with their defaults. */
11
+ export type InterchangeBase = "corridors" | "shared-ways";
12
+
13
+ /** The values of `InterchangeBase`. */
14
+ export const INTERCHANGE_BASES: readonly InterchangeBase[] = Object.freeze(["corridors", "shared-ways"]);
15
+
16
+ /** The options of the highway strategy `interchanges` (HWY-060 of the note on highways). */
17
+ export interface InterchangeOptions {
18
+ /** The plan that gives the highways. The default is `corridors`. */
19
+ readonly from: InterchangeBase;
20
+ /** The cost of a change between two highways, in units of the board, from 0 to 8. The default is 2. */
21
+ readonly changeCost: number;
22
+ /** The largest number of interchanges, from 1 to 32. The default is 16. */
23
+ readonly interchanges: number;
24
+ /** The largest number of highways on the way of one edge, from 1 to 3. The default is 2. */
25
+ readonly perEdge: number;
26
+ /** Two highways get an interchange where they come this near, in units of the board. The default is 2. */
27
+ readonly radius: number;
28
+ /** The cost of a length on a highway against a length off the highways, from 0.1 to 1. The default is 0.5. */
29
+ readonly discount: number;
30
+ /** An edge rides the network only when its way is this many times its first route or less. The default is 1.5. */
31
+ readonly detour: number;
32
+ }
33
+
34
+ /** The default options of the strategy `interchanges`. */
35
+ export const INTERCHANGE_DEFAULTS: Readonly<InterchangeOptions> = Object.freeze({ from: "corridors", changeCost: 2, interchanges: 16, perEdge: 2, radius: 2, discount: 0.5, detour: 1.5 });
36
+
37
+ /** The largest number of candidate interchanges of the first pass. */
38
+ const MAX_CANDIDATES = 64;
39
+
40
+ /** One interchange: a place on the highway `h`, a place on the highway `g`, and the length of the link between the two places. */
41
+ export interface Interchange {
42
+ readonly h: number;
43
+ readonly hAt: number;
44
+ readonly g: number;
45
+ readonly gAt: number;
46
+ readonly gap: number;
47
+ }
48
+
49
+ /** The nearest points of the segments `a b` and `c d`, as the parameters `s` and `t` from 0 to 1, with their distance. */
50
+ function nearestOfSegments(a: Point, b: Point, c: Point, d: Point): { s: number; t: number; d: number } {
51
+ const ux = b.x - a.x;
52
+ const uy = b.y - a.y;
53
+ const vx = d.x - c.x;
54
+ const vy = d.y - c.y;
55
+ const at = (s: number, t: number): number => {
56
+ const dx = a.x + s * ux - (c.x + t * vx);
57
+ const dy = a.y + s * uy - (c.y + t * vy);
58
+ // `Math.sqrt` is exact on each platform. `Math.hypot` is not.
59
+ return Math.sqrt(dx * dx + dy * dy);
60
+ };
61
+ const clamp = (v: number): number => Math.min(1, Math.max(0, v));
62
+ const uu = ux * ux + uy * uy;
63
+ const vv = vx * vx + vy * vy;
64
+ const project = (p: Point, q: Point, x: number, y: number, len: number): number => (len > 0 ? clamp(((p.x - q.x) * x + (p.y - q.y) * y) / len) : 0);
65
+ // The four ends against the other segment, and the crossing point, give the nearest pair.
66
+ const tries: [number, number][] = [
67
+ [0, project(a, c, vx, vy, vv)],
68
+ [1, project(b, c, vx, vy, vv)],
69
+ [project(c, a, ux, uy, uu), 0],
70
+ [project(d, a, ux, uy, uu), 1],
71
+ ];
72
+ const den = ux * vy - uy * vx;
73
+ if (Math.abs(den) > 1e-12) {
74
+ const s = ((c.x - a.x) * vy - (c.y - a.y) * vx) / den;
75
+ const t = ((c.x - a.x) * uy - (c.y - a.y) * ux) / den;
76
+ if (s >= 0 && s <= 1 && t >= 0 && t <= 1) tries.push([s, t]);
77
+ }
78
+ let best = { s: 0, t: 0, d: Infinity };
79
+ for (const [s, t] of tries) {
80
+ const v = at(s, t);
81
+ if (v < best.d - 1e-12) best = { s, t, d: v };
82
+ }
83
+ return best;
84
+ }
85
+
86
+ /** This function gives the candidate interchanges: the nearest places of each pair of spines that come within `radius`, with a link in clear view. */
87
+ export function interchangeCandidates(spines: readonly (readonly Point[])[], radius: number, clear: (a: Point, b: Point) => boolean): Interchange[] {
88
+ const out: Interchange[] = [];
89
+ for (let h = 0; h < spines.length; h++) {
90
+ for (let g = h + 1; g < spines.length; g++) {
91
+ const p = spines[h]!;
92
+ const q = spines[g]!;
93
+ let best: Interchange | undefined;
94
+ for (let i = 0; i + 1 < p.length; i++) {
95
+ for (let j = 0; j + 1 < q.length; j++) {
96
+ const n = nearestOfSegments(p[i]!, p[i + 1]!, q[j]!, q[j + 1]!);
97
+ if (n.d <= radius && (!best || n.d < best.gap - 1e-12)) best = { h, hAt: i + n.s, g, gAt: j + n.t, gap: n.d };
98
+ }
99
+ }
100
+ if (best && (best.gap <= 1e-9 || clear(pointAt(p, best.hAt), pointAt(q, best.gAt)))) out.push(best);
101
+ }
102
+ }
103
+ return out.sort((x, y) => x.gap - y.gap || x.h - y.h || x.g - y.g).slice(0, MAX_CANDIDATES);
104
+ }
105
+
106
+ /** One leg of the way of an edge: the highway and its two places. */
107
+ interface Leg {
108
+ readonly h: number;
109
+ readonly on: number;
110
+ readonly off: number;
111
+ }
112
+
113
+ /** The best way of one edge over the network, with the interchanges that it uses. */
114
+ interface Trip {
115
+ readonly legs: readonly Leg[];
116
+ readonly used: readonly number[];
117
+ }
118
+
119
+ /**
120
+ * This function gives the highways of a network with interchanges (HWY-060). The highways come from a plan. Two highways that come near get an interchange.
121
+ * Each edge takes its cheapest way: a ride on one highway, or on up to `perEdge` highways with a change at an interchange.
122
+ * A change costs `changeCost` units. The interchanges that the most edges use stay, up to `interchanges`.
123
+ */
124
+ export function interchangePlan(input: HighwayInput, o: Readonly<InterchangeOptions>): HighwayPlan {
125
+ const graph = input.graph;
126
+ const base = basePlan(input, o.from);
127
+ const spines = base.highways.map((h) => h.spine);
128
+ if (spines.length === 0) return { highways: [] };
129
+ const finder = new WayFinder(graph.nodes);
130
+ const pad = finder.margin / 2;
131
+ const unit = boardUnit(graph);
132
+ const first = firstRoutes(input);
133
+ const change = Math.max(0, o.changeCost) * unit;
134
+ const perEdge = Math.min(3, Math.max(1, Math.round(o.perEdge)));
135
+ const disc = Math.min(1, Math.max(0.05, o.discount));
136
+ const ride = (h: number, a: number, b: number): number => lengthBetween(spines[h]!, Math.min(a, b), Math.max(a, b));
137
+ const edges = edgePlugs(graph).map((p) => {
138
+ const s = finder.out(p.ps);
139
+ const t = finder.out(p.pt);
140
+ const route = first.get(p.edge.id);
141
+ const ends = spines.map((spine) => {
142
+ const on = nearestPlace(spine, s);
143
+ const off = nearestPlace(spine, t);
144
+ return { on, off, rampIn: dist(s, pointAt(spine, on)), rampOut: dist(pointAt(spine, off), t) };
145
+ });
146
+ return { edge: p.edge.id, s, t, base: route ? polylineLength(route) : dist(s, t), ends };
147
+ });
148
+
149
+ // The cheapest way of one edge over the interchanges `list`. The cost counts a ride at `disc` times its length. Each leg is one margin long or more.
150
+ const tripOf = (e: (typeof edges)[number], list: readonly Interchange[]): Trip | undefined => {
151
+ const found: { cost: number; trip: Trip | undefined } = { cost: Infinity, trip: undefined };
152
+ const offer = (cost: number, length: number, trip: Trip): void => {
153
+ if (trip.legs.some((l) => ride(l.h, l.on, l.off) < finder.margin)) return;
154
+ if (cost >= e.base || length > o.detour * e.base) return;
155
+ if (cost < found.cost - 1e-9) [found.cost, found.trip] = [cost, trip];
156
+ };
157
+ spines.forEach((_, h) => {
158
+ const x = e.ends[h]!;
159
+ const r = ride(h, x.on, x.off);
160
+ offer(x.rampIn + disc * r + x.rampOut, x.rampIn + r + x.rampOut, { legs: [{ h, on: x.on, off: x.off }], used: [] });
161
+ });
162
+ if (perEdge < 2) return found.trip;
163
+ // Each interchange, in both directions: on `p` at its place `pAt`, then off at the place `qAt` of `q`.
164
+ const sides = list.flatMap((c, i) => [
165
+ { i, p: c.h, pAt: c.hAt, q: c.g, qAt: c.gAt, gap: c.gap },
166
+ { i, p: c.g, pAt: c.gAt, q: c.h, qAt: c.hAt, gap: c.gap },
167
+ ]);
168
+ for (const a of sides) {
169
+ const x = e.ends[a.p]!;
170
+ const y = e.ends[a.q]!;
171
+ const r1 = ride(a.p, x.on, a.pAt);
172
+ const r2 = ride(a.q, a.qAt, y.off);
173
+ offer(x.rampIn + disc * (r1 + r2) + a.gap + change + y.rampOut, x.rampIn + r1 + r2 + a.gap + y.rampOut, {
174
+ legs: [
175
+ { h: a.p, on: x.on, off: a.pAt },
176
+ { h: a.q, on: a.qAt, off: y.off },
177
+ ],
178
+ used: [a.i],
179
+ });
180
+ if (perEdge < 3) continue;
181
+ for (const b of sides) {
182
+ if (b.p !== a.q || b.q === a.p || b.i === a.i) continue;
183
+ const z = e.ends[b.q]!;
184
+ const r3 = ride(a.q, a.qAt, b.pAt);
185
+ const r4 = ride(b.q, b.qAt, z.off);
186
+ offer(x.rampIn + disc * (r1 + r3 + r4) + a.gap + b.gap + 2 * change + z.rampOut, x.rampIn + r1 + r3 + r4 + a.gap + b.gap + z.rampOut, {
187
+ legs: [
188
+ { h: a.p, on: x.on, off: a.pAt },
189
+ { h: a.q, on: a.qAt, off: b.pAt },
190
+ { h: b.q, on: b.qAt, off: z.off },
191
+ ],
192
+ used: [a.i, b.i],
193
+ });
194
+ }
195
+ }
196
+ return found.trip;
197
+ };
198
+
199
+ const clear = (a: Point, b: Point): boolean => finder.obstacles.clear(a, b, pad);
200
+ const candidates = interchangeCandidates(spines, Math.max(0, o.radius) * unit, clear);
201
+ // The first pass counts the edges at each candidate. The busiest candidates stay for the second pass.
202
+ const use = new Array<number>(candidates.length).fill(0);
203
+ for (const e of edges) for (const i of tripOf(e, candidates)?.used ?? []) use[i] = use[i]! + 1;
204
+ const kept = candidates
205
+ .map((c, i) => ({ c, i, n: use[i]! }))
206
+ .filter((x) => x.n > 0)
207
+ .sort((x, y) => y.n - x.n || x.i - y.i)
208
+ .slice(0, Math.max(1, Math.round(o.interchanges)))
209
+ .sort((x, y) => x.i - y.i)
210
+ .map((x) => x.c);
211
+
212
+ const riders = spines.map(() => [] as { edge: string; on: number; off: number; seq: number; points: Point[] }[]);
213
+ for (const e of edges) {
214
+ const trip = tripOf(e, kept);
215
+ if (!trip) continue;
216
+ trip.legs.forEach((l, seq) => riders[l.h]!.push({ edge: e.edge, on: l.on, off: l.off, seq, points: [e.s, ...spinePart(spines[l.h]!, l.on, l.off), e.t] }));
217
+ }
218
+ const highways: Highway[] = [];
219
+ spines.forEach((spine, h) => {
220
+ const list = riders[h]!;
221
+ if (list.length < 2) return;
222
+ const lanes = orderLanes(spine, list.map((r) => ({ edge: r.edge, points: r.points })), "bundle");
223
+ const byEdge = new Map(list.map((r) => [r.edge, r]));
224
+ const members: HighwayMember[] = lanes.map((edge) => {
225
+ const r = byEdge.get(edge)!;
226
+ return memberAt(spine, edge, r.on, r.off, r.seq);
227
+ });
228
+ highways.push({ id: `h${h}`, key: base.highways[h]!.key, spine, lanes, members });
229
+ });
230
+ return tidyPlan({ highways });
231
+ }