@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.
@@ -0,0 +1,170 @@
1
+ import { ambiguity, bundleStretch, inkSaved, mergeAngle } from "../../bench/measures/bundles.ts";
2
+ import type { Point } from "../../geom.ts";
3
+ import type { Graph, Route } from "../../model.ts";
4
+ import { WayFinder } from "../../ways.ts";
5
+ import { nearestPlace, orderAlongRoutes, tidyPlan, firstRoutes } from "../base.ts";
6
+ import { follow } from "../follow.ts";
7
+ import { memberAt, type Highway, type HighwayInput, type HighwayPlan } from "../model.ts";
8
+ import { candidatePlan, type CandidateSource } from "../select/index.ts";
9
+ import { edgePlugs } from "../stretches.ts";
10
+
11
+ /** The options of the highway strategy `search` (HWY-059 of the note on highways). */
12
+ export interface SearchOptions {
13
+ /** The plan where the search starts. The default is `corridors`. */
14
+ readonly from: CandidateSource;
15
+ /** The generators of the highways that the move `add` takes. The default is `quickbundles` and `road-classes`. */
16
+ readonly pool: readonly CandidateSource[];
17
+ /** The number of plans that the search measures, from 0 to 400. The value 0 gives the start plan. The default is 16. */
18
+ readonly evaluations: number;
19
+ /** The first temperature of the annealing, on the scale of the score. Each sweep of 4 moves multiplies it by 0.95. The default is 0.02. */
20
+ readonly temperature: number;
21
+ /** The weight of the ink that the routes save (B1). The default is 1. */
22
+ readonly inkWeight: number;
23
+ /** The weight of the 0.95 quantile of the stretch against the first routes (B3). The default is 0.5. */
24
+ readonly stretchWeight: number;
25
+ /** The weight of the ambiguity (B4). The default is 0.5. */
26
+ readonly ambiguityWeight: number;
27
+ /** The weight of the largest merge angle (B6), divided by 180 degrees. The default is 0.25. */
28
+ readonly angleWeight: number;
29
+ /** The seed of the moves. The default is 1. */
30
+ readonly seed: number;
31
+ }
32
+
33
+ /** The default options of the strategy `search`. */
34
+ export const SEARCH_DEFAULTS: Readonly<SearchOptions> = Object.freeze({
35
+ from: "corridors",
36
+ pool: Object.freeze(["quickbundles", "road-classes"] as CandidateSource[]),
37
+ evaluations: 16,
38
+ temperature: 0.02,
39
+ inkWeight: 1,
40
+ stretchWeight: 0.5,
41
+ ambiguityWeight: 0.5,
42
+ angleWeight: 0.25,
43
+ seed: 1,
44
+ });
45
+
46
+ /** The moves of the search. */
47
+ export type SearchMove = "add" | "drop" | "edge" | "point";
48
+
49
+ /** The values of `SearchMove`, in the order of the draw. */
50
+ export const SEARCH_MOVES: readonly SearchMove[] = Object.freeze(["add", "drop", "edge", "point"]);
51
+
52
+ /** The number of moves of one sweep of the annealing. */
53
+ const SWEEP = 4;
54
+
55
+ /** This function gives a seeded random source with values from 0 to 1 (mulberry32). */
56
+ function random(seed: number): () => number {
57
+ let a = Math.floor(seed) >>> 0;
58
+ return () => {
59
+ a = (a + 0x6d2b79f5) >>> 0;
60
+ let t = a;
61
+ t = Math.imul(t ^ (t >>> 15), t | 1);
62
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
63
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
64
+ };
65
+ }
66
+
67
+ /**
68
+ * This function gives the score of the routes of a plan: `w1 B1 - w3 B3 - w4 B4 - w6 B6 / 180`. Higher is better.
69
+ * B3 is the 0.95 quantile of the stretch against the first routes. The score reads only the graph and the routes of the plan.
70
+ */
71
+ export function searchScore(graph: Graph, routes: readonly Route[], first: readonly Route[], o: Readonly<SearchOptions>): number {
72
+ const ink = inkSaved(routes);
73
+ const stretch = bundleStretch(routes, first).q95;
74
+ const amb = o.ambiguityWeight > 0 ? ambiguity(graph, routes).value : 0;
75
+ const angle = o.angleWeight > 0 ? mergeAngle(routes) / 180 : 0;
76
+ return o.inkWeight * ink - o.stretchWeight * stretch - o.ambiguityWeight * amb - o.angleWeight * angle;
77
+ }
78
+
79
+ /**
80
+ * The function `acceptance` gives the chance to keep a worse plan, for a loss `y` of 0 or more on the scale of the temperature.
81
+ * It is `1 / (1 + y + y² / 2)`, near `exp(-y)` for a small loss.
82
+ * It uses only exact arithmetic, so each platform keeps the same plans.
83
+ */
84
+ function acceptance(y: number): number {
85
+ return 1 / (1 + y + (y * y) / 2);
86
+ }
87
+
88
+ /**
89
+ * This function gives the highways of a local search over the set of highways (HWY-059). The search starts at the plan of `from`.
90
+ * Each move adds a highway of the pool, drops a highway, moves one edge to another highway, or moves one point of a spine.
91
+ * The annealing keeps a worse plan with the chance `acceptance(-delta / T)`. The best plan that the search measures is the result.
92
+ *
93
+ * The score reads only this graph and its routes. Thus the search does not fit to the inputs of a screen.
94
+ */
95
+ export function searchPlan(input: HighwayInput, o: Readonly<SearchOptions>): HighwayPlan {
96
+ const graph = input.graph;
97
+ const start = tidyPlan(candidatePlan(input, o.from));
98
+ const budget = Math.max(0, Math.floor(o.evaluations));
99
+ if (budget === 0 || graph.edges.length === 0) return start;
100
+ const pool: Highway[] = [];
101
+ for (const source of o.pool) if (source !== o.from) pool.push(...candidatePlan(input, source).highways);
102
+ const finder = new WayFinder(graph.nodes);
103
+ const pad = finder.margin / 2;
104
+ const plugs = new Map(edgePlugs(graph).map((p) => [p.edge.id, p] as const));
105
+ const routes = firstRoutes(input);
106
+ const first = follow(graph, { highways: [] }).routes;
107
+ const scoreOf = (plan: HighwayPlan): number => searchScore(graph, follow(graph, plan).routes, first, o);
108
+ const next = random(o.seed);
109
+ const pick = <T>(list: readonly T[]): T => list[Math.floor(next() * list.length) % list.length]!;
110
+ const tidy = (highways: Highway[]): HighwayPlan => tidyPlan(orderAlongRoutes({ highways }, routes));
111
+
112
+ /** This function gives the plan after one move, or `undefined` when the move does not apply. */
113
+ const moved = (plan: HighwayPlan, move: SearchMove): HighwayPlan | undefined => {
114
+ const list = [...plan.highways];
115
+ if (move === "add") {
116
+ const known = new Set(list.map((h) => h.spine.map((p) => `${p.x},${p.y}`).join(";")));
117
+ const fresh = pool.filter((h) => !known.has(h.spine.map((p) => `${p.x},${p.y}`).join(";")));
118
+ if (fresh.length === 0) return undefined;
119
+ return tidy([...list, pick(fresh)]);
120
+ }
121
+ if (list.length === 0) return undefined;
122
+ const i = Math.floor(next() * list.length) % list.length;
123
+ const h = list[i]!;
124
+ if (move === "drop") return tidy(list.filter((_, j) => j !== i));
125
+ if (move === "edge") {
126
+ // One member leaves its highway and rides the nearest other highway that does not carry it.
127
+ if (list.length < 2 || h.members.length < 3) return undefined;
128
+ const m = pick(h.members);
129
+ const plug = plugs.get(m.edge);
130
+ if (!plug) return undefined;
131
+ const others = list.map((g, j) => ({ g, j })).filter((x) => x.j !== i && !x.g.members.some((y) => y.edge === m.edge));
132
+ if (others.length === 0) return undefined;
133
+ const s = plug.ps.point;
134
+ const t = plug.pt.point;
135
+ const to = pick(others);
136
+ const on = nearestPlace(to.g.spine, s);
137
+ const off = nearestPlace(to.g.spine, t);
138
+ if (Math.abs(on - off) < 1e-6) return undefined;
139
+ list[i] = { ...h, members: h.members.filter((y) => y.edge !== m.edge), lanes: h.lanes.filter((e) => e !== m.edge) };
140
+ list[to.j] = { ...to.g, members: [...to.g.members, memberAt(to.g.spine, m.edge, on, off, m.seq)], lanes: [...to.g.lanes, m.edge] };
141
+ return tidy(list);
142
+ }
143
+ // The move `point`: one point of the spine moves by two margins on one axis, when the spine stays in clear view.
144
+ const k = Math.floor(next() * h.spine.length) % h.spine.length;
145
+ const step = 2 * finder.margin * (next() < 0.5 ? -1 : 1);
146
+ const p = h.spine[k]!;
147
+ const q: Point = next() < 0.5 ? { x: p.x + step, y: p.y } : { x: p.x, y: p.y + step };
148
+ const spine = h.spine.map((x, j) => (j === k ? q : x));
149
+ if (!finder.obstacles.clearPath(spine, pad)) return undefined;
150
+ list[i] = { ...h, spine, members: h.members.map((m) => memberAt(spine, m.edge, m.on, m.off, m.seq)) };
151
+ return tidy(list);
152
+ };
153
+
154
+ let current = start;
155
+ let currentScore = scoreOf(start);
156
+ let best = start;
157
+ let bestScore = currentScore;
158
+ let temperature = Math.max(0, o.temperature);
159
+ for (let n = 1, tries = 0; n < budget && tries < 4 * budget; tries++) {
160
+ const plan = moved(current, pick(SEARCH_MOVES));
161
+ if (!plan) continue;
162
+ n++;
163
+ const score = scoreOf(plan);
164
+ const delta = score - currentScore;
165
+ if (delta >= 0 || (temperature > 0 && next() < acceptance(-delta / temperature))) [current, currentScore] = [plan, score];
166
+ if (score > bestScore + 1e-12) [best, bestScore] = [plan, score];
167
+ if (n % SWEEP === 0) temperature *= 0.95;
168
+ }
169
+ return best;
170
+ }
@@ -28,6 +28,8 @@ export interface SnapLine {
28
28
  export interface SnapOptions extends Pick<WayOptions, "margin" | "k"> {
29
29
  /** A highway has this number of edges or more. The value is 2 or more. */
30
30
  readonly minSize: number;
31
+ /** With `true`, a line in clear view is its own spine, with no path through the corner graph. The default is `false`. */
32
+ readonly direct?: boolean;
31
33
  }
32
34
 
33
35
  /** This function gives the line `u d + rho n` of the direction angle `theta`, with `d = (cos, sin)` and the normal `n = (-sin, cos)`. */
@@ -61,9 +63,9 @@ export function snapPlan(input: HighwayInput, lines: readonly SnapLine[], o: Rea
61
63
  const a = free(line.a);
62
64
  const b = free(line.b);
63
65
  if (dist(a, b) <= finder.margin) continue;
64
- const raw = corners.path(a, b);
66
+ const raw = o.direct === true && finder.obstacles.clear(a, b, pad) ? [a, b] : corners.path(a, b);
65
67
  if (!raw) continue;
66
- const spine = corners.pull(raw);
68
+ const spine = o.direct === true && raw.length === 2 ? raw : corners.pull(raw);
67
69
  if (spine.length < 2 || polylineLength(spine) <= 1e-6) continue;
68
70
  const riders: { edge: string; on: number; off: number; points: Point[] }[] = [];
69
71
  for (const m of line.members) {
@@ -0,0 +1,215 @@
1
+ import { polylineLength, type Point } from "../../geom.ts";
2
+ import { byCollection } from "../../routers/trunks.ts";
3
+ import { WayFinder } from "../../ways.ts";
4
+ import { firstRoutes } from "../base.ts";
5
+ import type { HighwayInput, HighwayPlan } from "../model.ts";
6
+ import { edgePlugs, sharedStretches, type KeyedWay } from "../stretches.ts";
7
+
8
+ /** The groups of the trees: the edges from one source node, or the edges of one collection. */
9
+ export type SteinerGroup = "source" | "key";
10
+
11
+ /** The values of `SteinerGroup`. */
12
+ export const STEINER_GROUPS: readonly SteinerGroup[] = Object.freeze(["source", "key"]);
13
+
14
+ /** The method of the tree: the iterated 1-Steiner method on the Hanan grid, or the spanning tree of the terminals alone. */
15
+ export type SteinerMethod = "one-steiner" | "spanning";
16
+
17
+ /** The values of `SteinerMethod`. */
18
+ export const STEINER_METHODS: readonly SteinerMethod[] = Object.freeze(["one-steiner", "spanning"]);
19
+
20
+ /** The metric of the tree: rectilinear (two directions) or octilinear (four directions). */
21
+ export type SteinerMetric = "rectilinear" | "octilinear";
22
+
23
+ /** The values of `SteinerMetric`. */
24
+ export const STEINER_METRICS: readonly SteinerMetric[] = Object.freeze(["rectilinear", "octilinear"]);
25
+
26
+ /** The options of the highway strategy `steiner` (HWY-036 of the note on highways, with EDA-015). */
27
+ export interface SteinerOptions {
28
+ /** The groups of the trees. The default is `source`. */
29
+ readonly group: SteinerGroup;
30
+ /** The method of the tree. The default is `one-steiner`. */
31
+ readonly method: SteinerMethod;
32
+ /** A group with more terminals than this number gets the spanning tree alone. The default is 12. */
33
+ readonly maxGroup: number;
34
+ /** The metric of the tree. The default is `rectilinear`. */
35
+ readonly metric: SteinerMetric;
36
+ /** An edge rides the tree only when its path in the tree is this many times its first route or less. The default is 1.5. */
37
+ readonly detour: number;
38
+ /** A stretch of a tree with fewer edges than this number is not a highway. The default is 2. */
39
+ readonly minSize: number;
40
+ }
41
+
42
+ /** The default options of the strategy `steiner`. */
43
+ export const STEINER_DEFAULTS: Readonly<SteinerOptions> = Object.freeze({ group: "source", method: "one-steiner", maxGroup: 12, metric: "rectilinear", detour: 1.5, minSize: 2 });
44
+
45
+ /** This function gives the distance of two points in the metric. */
46
+ function metricDistance(a: Point, b: Point, metric: SteinerMetric): number {
47
+ const dx = Math.abs(a.x - b.x);
48
+ const dy = Math.abs(a.y - b.y);
49
+ return metric === "rectilinear" ? dx + dy : Math.max(dx, dy) + (Math.SQRT2 - 1) * Math.min(dx, dy);
50
+ }
51
+
52
+ /** This function gives the links of the spanning tree of the points (Prim), as pairs of indices, and the sum of their lengths. */
53
+ export function spanningTree(points: readonly Point[], metric: SteinerMetric): { links: [number, number][]; cost: number } {
54
+ const n = points.length;
55
+ if (n < 2) return { links: [], cost: 0 };
56
+ const inTree = new Uint8Array(n);
57
+ const best = new Float64Array(n).fill(Infinity);
58
+ const from = new Int32Array(n).fill(-1);
59
+ best[0] = 0;
60
+ const links: [number, number][] = [];
61
+ let cost = 0;
62
+ for (let k = 0; k < n; k++) {
63
+ let u = -1;
64
+ for (let i = 0; i < n; i++) if (!inTree[i] && (u < 0 || best[i]! < best[u]!)) u = i;
65
+ inTree[u] = 1;
66
+ if (from[u]! >= 0) {
67
+ links.push([from[u]!, u]);
68
+ cost += best[u]!;
69
+ }
70
+ for (let i = 0; i < n; i++) {
71
+ if (inTree[i]) continue;
72
+ const d = metricDistance(points[u]!, points[i]!, metric);
73
+ if (d < best[i]!) {
74
+ best[i] = d;
75
+ from[i] = u;
76
+ }
77
+ }
78
+ }
79
+ return { links, cost };
80
+ }
81
+
82
+ /**
83
+ * This function gives a Steiner tree of the terminals by the iterated 1-Steiner method (Kahng and Robins). The candidates are the points of the Hanan grid.
84
+ * Each round adds the candidate that makes the spanning tree shortest, and drops each added point with two links or fewer.
85
+ * The test `free` removes the candidates in the boxes. The result has the terminals first, then the added points.
86
+ */
87
+ export function oneSteiner(terminals: readonly Point[], metric: SteinerMetric, free: (p: Point) => boolean): { points: Point[]; links: [number, number][] } {
88
+ const points = [...terminals];
89
+ const xs = [...new Set(terminals.map((p) => p.x))].sort((a, b) => a - b);
90
+ const ys = [...new Set(terminals.map((p) => p.y))].sort((a, b) => a - b);
91
+ const taken = new Set(terminals.map((p) => `${p.x},${p.y}`));
92
+ const candidates: Point[] = [];
93
+ for (const x of xs) for (const y of ys) if (!taken.has(`${x},${y}`) && free({ x, y })) candidates.push({ x, y });
94
+ let tree = spanningTree(points, metric);
95
+ for (let round = 0; round < terminals.length; round++) {
96
+ let best = -1;
97
+ let bestCost = tree.cost - 1e-9 * Math.max(1, tree.cost);
98
+ candidates.forEach((c, i) => {
99
+ const cost = spanningTree([...points, c], metric).cost;
100
+ if (cost < bestCost) [best, bestCost] = [i, cost];
101
+ });
102
+ if (best < 0) break;
103
+ points.push(candidates[best]!);
104
+ candidates.splice(best, 1);
105
+ tree = spanningTree(points, metric);
106
+ // An added point with two links or fewer saves nothing, so it leaves the tree.
107
+ for (;;) {
108
+ const degree = new Array<number>(points.length).fill(0);
109
+ for (const [a, b] of tree.links) {
110
+ degree[a] = degree[a]! + 1;
111
+ degree[b] = degree[b]! + 1;
112
+ }
113
+ let drop = -1;
114
+ for (let i = terminals.length; i < points.length; i++) if (degree[i]! <= 2) drop = i;
115
+ if (drop < 0) break;
116
+ points.splice(drop, 1);
117
+ tree = spanningTree(points, metric);
118
+ }
119
+ }
120
+ return { points, links: tree.links };
121
+ }
122
+
123
+ /**
124
+ * This function gives the polyline of one link of the tree. A rectilinear link is an L in clear view, else a path through the corner graph.
125
+ * An octilinear link is a straight piece and a diagonal piece. The same link gives the same points each time.
126
+ */
127
+ function linkPath(a: Point, b: Point, metric: SteinerMetric, finder: WayFinder): Point[] {
128
+ const pad = finder.margin / 2;
129
+ const dx = b.x - a.x;
130
+ const dy = b.y - a.y;
131
+ const tries: Point[][] = [];
132
+ if (Math.abs(dx) < 1e-9 || Math.abs(dy) < 1e-9) tries.push([a, b]);
133
+ else if (metric === "rectilinear") tries.push([a, { x: b.x, y: a.y }, b], [a, { x: a.x, y: b.y }, b]);
134
+ else {
135
+ const d = Math.min(Math.abs(dx), Math.abs(dy));
136
+ const diag = { x: a.x + Math.sign(dx) * d, y: a.y + Math.sign(dy) * d };
137
+ const late = { x: b.x - Math.sign(dx) * d, y: b.y - Math.sign(dy) * d };
138
+ tries.push([a, diag, b], [a, late, b]);
139
+ }
140
+ for (const t of tries) if (finder.obstacles.clearPath(t, pad)) return t.filter((p, i) => i === 0 || p.x !== t[i - 1]!.x || p.y !== t[i - 1]!.y);
141
+ const way = finder.corners.path(a, b);
142
+ return way ? finder.corners.pull(way) : [a, b];
143
+ }
144
+
145
+ /**
146
+ * This function gives the highways of obstacle-avoiding Steiner trees (HWY-036 with EDA-015). Each group of edges gets one tree over the points out of its plugs.
147
+ * The links of the tree are rectilinear or octilinear, and they go around the boxes. Each edge takes its path in the tree, when that path is short enough.
148
+ * The stretches of the tree that two edges or more share become highways.
149
+ */
150
+ export function steinerPlan(input: HighwayInput, o: Readonly<SteinerOptions>): HighwayPlan {
151
+ const finder = new WayFinder(input.graph.nodes);
152
+ const pad = finder.margin / 2;
153
+ const first = firstRoutes(input);
154
+ const groups = new Map<string, { edge: string; s: Point; t: Point; base: number }[]>();
155
+ for (const p of edgePlugs(input.graph)) {
156
+ const key = o.group === "key" ? (byCollection(p.edge) ?? `\u0000${p.edge.source.node}`) : p.edge.source.node;
157
+ const s = finder.out(p.ps);
158
+ const t = finder.out(p.pt);
159
+ const route = first.get(p.edge.id);
160
+ const list = groups.get(key) ?? [];
161
+ list.push({ edge: p.edge.id, s, t, base: route ? polylineLength(route) : metricDistance(s, t, "rectilinear") });
162
+ groups.set(key, list);
163
+ }
164
+ const ways: KeyedWay[] = [];
165
+ for (const key of [...groups.keys()].sort()) {
166
+ const list = groups.get(key)!;
167
+ if (list.length < 2) continue;
168
+ const index = new Map<string, number>();
169
+ const terminals: Point[] = [];
170
+ const at = (p: Point): number => {
171
+ const k = `${p.x},${p.y}`;
172
+ let i = index.get(k);
173
+ if (i === undefined) {
174
+ index.set(k, (i = terminals.length));
175
+ terminals.push(p);
176
+ }
177
+ return i;
178
+ };
179
+ const ends = list.map((e) => [at(e.s), at(e.t)] as const);
180
+ const tree = o.method === "one-steiner" && terminals.length <= o.maxGroup ? oneSteiner(terminals, o.metric, (p) => !finder.obstacles.inside(p, pad)) : { points: terminals, ...spanningTree(terminals, o.metric) };
181
+ const n = tree.points.length;
182
+ const next: { to: number; path: Point[] }[][] = Array.from({ length: n }, () => []);
183
+ for (const [a, b] of tree.links) {
184
+ const path = linkPath(tree.points[a]!, tree.points[b]!, o.metric, finder);
185
+ next[a]!.push({ to: b, path });
186
+ next[b]!.push({ to: a, path: [...path].reverse() });
187
+ }
188
+ list.forEach((e, k) => {
189
+ const [s, t] = ends[k]!;
190
+ // The path in the tree from `s` to `t`, by a search from `s`.
191
+ const back = new Int32Array(n).fill(-2);
192
+ const via: (Point[] | undefined)[] = new Array(n);
193
+ back[s] = -1;
194
+ const queue = [s];
195
+ while (queue.length > 0) {
196
+ const u = queue.shift()!;
197
+ if (u === t) break;
198
+ for (const x of next[u]!) {
199
+ if (back[x.to] !== -2) continue;
200
+ back[x.to] = u;
201
+ via[x.to] = x.path;
202
+ queue.push(x.to);
203
+ }
204
+ }
205
+ if (back[t] === -2 || s === t) return;
206
+ const pieces: Point[][] = [];
207
+ for (let v = t; v !== s; v = back[v]!) pieces.push(via[v]!);
208
+ const points: Point[] = [];
209
+ for (const piece of pieces.reverse()) for (const p of piece) if (points.length === 0 || p.x !== points[points.length - 1]!.x || p.y !== points[points.length - 1]!.y) points.push(p);
210
+ if (polylineLength(points) > o.detour * e.base) return;
211
+ ways.push({ edge: e.edge, key, points });
212
+ });
213
+ }
214
+ return sharedStretches(ways, { minSize: o.minSize });
215
+ }
@@ -0,0 +1,78 @@
1
+ import { dist } from "../../geom.ts";
2
+ import { WayFinder } from "../../ways.ts";
3
+ import { freeLines, streetEdges, streetRide, type StreetRide, type StreetSource } from "../free-lines.ts";
4
+ import type { HighwayInput, HighwayPlan } from "../model.ts";
5
+ import { snapPlan, type SnapLine } from "../snap.ts";
6
+
7
+ /** The options of the highway strategy `streets` (HWY-058 of the note on highways). */
8
+ export interface StreetOptions {
9
+ /** A street covers an edge when the way of the edge along the street is this many times its first route or less (the limit `s`). The default is 1.2. */
10
+ readonly detour: number;
11
+ /** The greedy cover stops when the best street gives less than this gain: the length that its edges ride over the length of the street. The default is 2. */
12
+ readonly minGain: number;
13
+ /** The largest number of streets. The default is 32. */
14
+ readonly maxStreets: number;
15
+ /** A street with fewer new edges than this number is not a highway. The default is 2. */
16
+ readonly minSize: number;
17
+ /** The source of the streets: the gaps of the room step, or the lines through the corners. The default is `corners`. */
18
+ readonly source: StreetSource;
19
+ }
20
+
21
+ /** The default options of the strategy `streets`. */
22
+ export const STREET_DEFAULTS: Readonly<StreetOptions> = Object.freeze({ detour: 1.2, minGain: 2, maxStreets: 32, minSize: 2, source: "corners" });
23
+
24
+ /**
25
+ * This function gives orthogonal streets by a greedy set cover (HWY-058). A street covers each edge whose way along it is at most `detour` times its first route.
26
+ * Each step takes the street with the largest gain: the length that its new edges ride, over the length of the street that they use.
27
+ * The cover stops at `maxStreets` streets, or when the best gain is below `minGain`. Each edge rides one street at most.
28
+ */
29
+ export function streetPlan(input: HighwayInput, o: Readonly<StreetOptions>): HighwayPlan {
30
+ const finder = new WayFinder(input.graph.nodes);
31
+ const lines = freeLines(input.graph, finder, o.source);
32
+ const edges = streetEdges(input, finder);
33
+ const min = 2 * finder.margin;
34
+ const size = Math.max(2, Math.round(o.minSize));
35
+ // The cover of each street: each edge that it covers, with its ride.
36
+ const covers: { edge: string; ride: StreetRide }[][] = lines.map((line) => {
37
+ const out: { edge: string; ride: StreetRide }[] = [];
38
+ for (const e of edges) {
39
+ const r = streetRide(line, e);
40
+ if (r.ride >= min && r.ramps + r.ride <= o.detour * e.base) out.push({ edge: e.edge, ride: r });
41
+ }
42
+ return out;
43
+ });
44
+ const done = new Set<string>();
45
+ const chosen: SnapLine[] = [];
46
+ const used = new Set<number>();
47
+ while (chosen.length < Math.max(0, Math.floor(o.maxStreets))) {
48
+ let best = -1;
49
+ let bestGain = -Infinity;
50
+ let bestPart: { edge: string; ride: StreetRide }[] = [];
51
+ covers.forEach((cover, i) => {
52
+ if (used.has(i)) return;
53
+ const part = cover.filter((c) => !done.has(c.edge));
54
+ if (part.length < size) return;
55
+ const vertical = lines[i]!.a.x === lines[i]!.b.x;
56
+ const us = part.flatMap((c) => (vertical ? [c.ride.on.y, c.ride.off.y] : [c.ride.on.x, c.ride.off.x]));
57
+ const span = Math.max(...us) - Math.min(...us);
58
+ if (span <= 0) return;
59
+ let ride = 0;
60
+ for (const c of part) ride += c.ride.ride;
61
+ const gain = ride / span;
62
+ if (gain > bestGain) [best, bestGain, bestPart] = [i, gain, part];
63
+ });
64
+ if (best < 0 || bestGain < o.minGain) break;
65
+ used.add(best);
66
+ for (const c of bestPart) done.add(c.edge);
67
+ const line = lines[best]!;
68
+ const vertical = line.a.x === line.b.x;
69
+ const us = bestPart.flatMap((c) => (vertical ? [c.ride.on.y, c.ride.off.y] : [c.ride.on.x, c.ride.off.x]));
70
+ const lo = Math.min(...us);
71
+ const hi = Math.max(...us);
72
+ const a = vertical ? { x: line.a.x, y: lo } : { x: lo, y: line.a.y };
73
+ const b = vertical ? { x: line.a.x, y: hi } : { x: hi, y: line.a.y };
74
+ if (dist(a, b) <= 0) continue;
75
+ chosen.push({ key: `street${chosen.length}`, a, b, members: bestPart.map((c) => ({ edge: c.edge, from: c.ride.on, to: c.ride.off, at: 0 })) });
76
+ }
77
+ return snapPlan(input, chosen, { minSize: size, direct: true });
78
+ }
package/src/model.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { portPoint } from "@mark1russell7/layout-contract";
1
2
  import type { Box, Point } from "./geom.ts";
2
3
 
3
4
  /** A side of a node box. */
@@ -5,7 +6,8 @@ export type Side = "north" | "east" | "south" | "west";
5
6
 
6
7
  /**
7
8
  * A plug of a node: a place where edges go into the node.
8
- * A plug with `at` is fixed. The router does not move it. A plug without `at` is free: the router places it.
9
+ * A plug with `at` is fixed. The router does not move it. A plug with `side` and `along` is fixed too.
10
+ * Another plug is free: the router places it.
9
11
  */
10
12
  export interface Port {
11
13
  readonly id: string;
@@ -13,6 +15,12 @@ export interface Port {
13
15
  readonly at?: Point;
14
16
  /** This side is where a free plug goes. Without it, the router picks the side. */
15
17
  readonly side?: Side;
18
+ /**
19
+ * This fraction from 0 to 1 fixes the plug on its `side`. The plug is at the side start plus `along` times the side length.
20
+ * The start of `north` and `south` is the left end. The start of `east` and `west` is the top end.
21
+ * A plug with `along` needs a `side` and has no `at`. `resolvePorts` throws a `RangeError` for a bad value.
22
+ */
23
+ readonly along?: number;
16
24
  }
17
25
 
18
26
  /** A node: a box that routes must not go through. */
@@ -131,10 +139,58 @@ export function nodeIndex(g: Graph): Map<string, Node> {
131
139
  return m;
132
140
  }
133
141
 
134
- /** This function gives the absolute place of a fixed plug, or `undefined` for a free plug or no plug. */
142
+ /** The names of the sides in `@mark1russell7/layout-contract`. */
143
+ const CONTRACT_SIDE = { north: "top", east: "right", south: "bottom", west: "left" } as const;
144
+
145
+ /**
146
+ * This function gives the place of a fixed plug, relative to the top-left corner of the node box, or `undefined` for a free plug.
147
+ * A plug with `at` gives `at`. A plug with `side` and `along` gives the point of `portPoint` of `@mark1russell7/layout-contract`.
148
+ */
149
+ export function portOffset(box: Box, port: Port): Point | undefined {
150
+ if (port.at !== undefined) return port.at;
151
+ if (port.side === undefined || port.along === undefined) return undefined;
152
+ return portPoint(box, { id: port.id, side: CONTRACT_SIDE[port.side], along: port.along });
153
+ }
154
+
155
+ /** This function throws a `RangeError` when the `along` of a plug is bad: not finite, outside 0 to 1, without `side` or with `at`. */
156
+ export function checkPort(node: Node, port: Port): void {
157
+ if (port.along === undefined) return;
158
+ const at = `The plug "${port.id}" of the node "${node.id}"`;
159
+ if (typeof port.along !== "number" || !Number.isFinite(port.along) || port.along < 0 || port.along > 1)
160
+ throw new RangeError(`${at} has the along value ${String(port.along)}. It must be a finite number from 0 to 1.`);
161
+ if (port.side === undefined) throw new RangeError(`${at} has an along value and no side. A plug with along needs a side.`);
162
+ if (port.at !== undefined) throw new RangeError(`${at} has an along value and an at point. Give one of the two.`);
163
+ }
164
+
165
+ /**
166
+ * This function checks the plugs of a graph, and gives each plug with `side` and `along` a fixed `at`. The plug keeps its `side`.
167
+ * Each router calls it at the start of `route`, so all routers agree. A graph without `along` comes back as the same object.
168
+ * It throws a `RangeError` for a bad `along` (`checkPort`).
169
+ */
170
+ export function resolvePorts(graph: Graph): Graph {
171
+ let found = false;
172
+ for (const n of graph.nodes)
173
+ for (const p of n.ports ?? []) {
174
+ checkPort(n, p);
175
+ if (p.along !== undefined) found = true;
176
+ }
177
+ if (!found) return graph;
178
+ const nodes = graph.nodes.map((n): Node => {
179
+ if (!n.ports?.some((p) => p.along !== undefined)) return n;
180
+ const ports = n.ports.map((p): Port => {
181
+ if (p.along === undefined) return p;
182
+ return { id: p.id, side: p.side!, at: portOffset(n.box, p)! };
183
+ });
184
+ return { ...n, ports };
185
+ });
186
+ return { ...graph, nodes };
187
+ }
188
+
189
+ /** This function gives the absolute place of a fixed plug, or `undefined` for a free plug or no plug. A plug with `side` and `along` is fixed. */
135
190
  export function fixedPortPoint(node: Node, end: End): Point | undefined {
136
191
  if (end.port === undefined) return undefined;
137
192
  const p = node.ports?.find((q) => q.id === end.port);
138
- if (p?.at === undefined) return undefined;
139
- return { x: node.box.x + p.at.x, y: node.box.y + p.at.y };
193
+ const at = p === undefined ? undefined : portOffset(node.box, p);
194
+ if (at === undefined) return undefined;
195
+ return { x: node.box.x + at.x, y: node.box.y + at.y };
140
196
  }
@@ -0,0 +1,61 @@
1
+ import type { Graph, GroupRule, Result, Router } from "../../model.ts";
2
+ import { defineStrategy, type Strategy } from "../../strategies/model.ts";
3
+ import type { OptionSchema } from "../../strategies/options.ts";
4
+ import { sharedRouter } from "../shared.ts";
5
+ import { byCollection } from "../trunks.ts";
6
+
7
+ /** The options of the router `attraction` (HWY-044 of the note on highways: negotiated attraction, PathFinder turned around). */
8
+ export interface AttractionOptions {
9
+ /** The number of passes, from 1 to 10. Each pass after the first routes each edge again from no ways. The default is 3. */
10
+ readonly passes: number;
11
+ /** The pull `a` of the history of a link, from 0 to 1. A link then costs `1 - a h` times more. The default is 0.5. */
12
+ readonly attraction: number;
13
+ /** The cost weight `b` of each edge over the capacity of a link, from 0 to 10. The default is 1. */
14
+ readonly capacityWeight: number;
15
+ /** The width of one lane. The capacity of a link is its free width divided by this width. The default is 4. */
16
+ readonly laneWidth: number;
17
+ }
18
+
19
+ /** The default options of the router `attraction`. */
20
+ export const ATTRACTION_DEFAULTS: Readonly<AttractionOptions> = Object.freeze({ passes: 3, attraction: 0.5, capacityWeight: 1, laneWidth: 4 });
21
+
22
+ /** The declarations of the options of the router `attraction` (HWY-044). */
23
+ export const ATTRACTION_SCHEMA: OptionSchema<AttractionOptions> = {
24
+ passes: { type: "integer", min: 1, max: 10, step: 1, marks: [2, 3, 5], default: 3, describe: "The number of passes. Each later pass routes each edge again from no ways." },
25
+ attraction: { type: "number", min: 0, max: 1, step: 0.05, marks: [0.25, 0.5, 0.75], default: 0.5, describe: "The pull of the history of a link. A busy link of the passes before costs less." },
26
+ capacityWeight: { type: "number", min: 0, max: 10, step: 0.5, marks: [0, 1, 4], default: 1, describe: "The cost weight of each edge over the capacity of a link." },
27
+ laneWidth: { type: "number", min: 1, max: 12, step: 1, marks: [4, 6], default: 4, describe: "The width of one lane. The capacity of a link is its free width divided by this width." },
28
+ };
29
+
30
+ /**
31
+ * This function makes the router `attraction` (HWY-044). It is the router `shared` with a negotiation over the passes.
32
+ * After each pass, the history `h` of each link grows by `use / m`, with `m` the edges of the key. The history stops at `0.9 / a`.
33
+ * In each pass, a link costs `len (1 - a h) (1 + b max(0, use - cap))` times the cost in `shared`.
34
+ * The busy links of the passes before thus pull the edges, and a link over its capacity pushes them away.
35
+ */
36
+ export function attractionRouter(options: Partial<AttractionOptions> & { readonly name: string; readonly rule?: GroupRule; readonly maxNodes?: number }): Router {
37
+ const o = { ...ATTRACTION_DEFAULTS, ...options };
38
+ return sharedRouter(
39
+ { name: o.name, ...(o.rule === undefined ? {} : { rule: o.rule }), ...(o.maxNodes === undefined ? {} : { maxNodes: o.maxNodes }) },
40
+ {
41
+ passes: Math.max(1, Math.floor(o.passes)),
42
+ attraction: Math.min(1, Math.max(0, o.attraction)),
43
+ capacityWeight: Math.max(0, o.capacityWeight),
44
+ laneWidth: Math.max(1e-6, o.laneWidth),
45
+ },
46
+ );
47
+ }
48
+
49
+ /**
50
+ * The router strategy `attraction` (HWY-044), with the rule `byCollection` of `shared`. It is not a built-in router, so the benchmark and the hashes of `ROUTERS` do not change.
51
+ * A screen module or a consumer adds it to a registry.
52
+ */
53
+ export const attractionStrategy: Strategy<"router", AttractionOptions> = defineStrategy({
54
+ step: "router",
55
+ name: "attraction",
56
+ describe: "Negotiated attraction (HWY-044). The router `shared` routes each edge again in each pass, and the busy links of the passes before pull the edges.",
57
+ defaults: { ...ATTRACTION_DEFAULTS },
58
+ schema: ATTRACTION_SCHEMA,
59
+ maxNodes: 10_000,
60
+ run: (graph: Graph, o: Readonly<AttractionOptions>): Result => attractionRouter({ ...o, name: "attraction", rule: byCollection }).route(graph),
61
+ });
@@ -1,5 +1,5 @@
1
1
  import { center, type Point } from "../geom.ts";
2
- import { nodeIndex, type Edge, type Graph, type Node, type Result, type Route, type Router } from "../model.ts";
2
+ import { nodeIndex, fixedPortPoint, resolvePorts, type Edge, type Graph, type Node, type Result, type Route, type Router } from "../model.ts";
3
3
  import { placePlug, type Plug } from "../plugs.ts";
4
4
  import { justifyPlugs } from "../plugs/justify.ts";
5
5
  import { sidePlugs } from "../plugs/side.ts";
@@ -42,7 +42,8 @@ export interface CornersOptions extends WayOptions {
42
42
  export function cornersRouter(options: CornersOptions): Router {
43
43
  const router: Router = {
44
44
  name: options.name,
45
- route(graph: Graph): Result {
45
+ route(input: Graph): Result {
46
+ const graph = resolvePorts(input);
46
47
  const byId = nodeIndex(graph);
47
48
  const ways = new WayFinder(graph.nodes, options);
48
49
  const routes: Route[] = [];
@@ -104,8 +105,7 @@ function spacePlugs(ends: [Edge, Node, Node, Plug, Plug][], gap: number): void {
104
105
  [e.source, s, ps, pt, 3],
105
106
  [e.target, t, pt, ps, 4],
106
107
  ] as const) {
107
- const port = end.port === undefined ? undefined : node.ports?.find((p) => p.id === end.port);
108
- if (port?.at !== undefined) continue;
108
+ if (fixedPortPoint(node, end) !== undefined) continue;
109
109
  const key = JSON.stringify([node.id, plug.normal.x, plug.normal.y]);
110
110
  let side = sides.get(key);
111
111
  if (!side) sides.set(key, (side = { node, normal: plug.normal, items: [] }));