@mark1russell7/highways 0.1.2 → 0.1.3

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.1.2",
12
+ "version": "0.1.3",
13
13
  "type": "module",
14
14
  "main": "./build/index.js",
15
15
  "types": "./build/index.d.ts",
@@ -39,6 +39,7 @@
39
39
  "test": "vitest run",
40
40
  "bench": "node scripts/bench.ts",
41
41
  "profile": "node scripts/profile.ts",
42
+ "levels": "node scripts/levels.ts",
42
43
  "test:watch": "vitest",
43
44
  "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json"
44
45
  }
@@ -0,0 +1,191 @@
1
+ import { dist, inflate, pointSegmentDistance, polylineLength, segmentHitsBox, type Box, type Point } from "../geom.ts";
2
+ import { Grid } from "../grid.ts";
3
+ import type { Graph, Level, Result } from "../model.ts";
4
+ import { TOLERANCE } from "./properties.ts";
5
+
6
+ /**
7
+ * The faults of a set of links: the routes of the edges, or the links of one zoom level.
8
+ * Each value counts links, except `worstDetour`. Lower is better for each value.
9
+ */
10
+ export interface LinkFaults {
11
+ /** The number of links that the measure examined. */
12
+ readonly links: number;
13
+ /**
14
+ * The number of links that go through the inside of a box that is not one of their two ends.
15
+ * A segment goes through a box when a part of it longer than `TOLERANCE` is inside the box made smaller by `TOLERANCE`.
16
+ */
17
+ readonly through: number;
18
+ /**
19
+ * The number of links that go out and back. The distance from a point of the link to its last point grows after the nearest approach.
20
+ * A link goes out and back when this growth is more than `BACKTRACK_SHARE` of the distance between its ends, and more than `BACKTRACK_MIN` units.
21
+ */
22
+ readonly backtrack: number;
23
+ /** The largest ratio of the length of a link to the distance between its ends. Links with ends nearer than 1 unit do not count. */
24
+ readonly worstDetour: number;
25
+ /**
26
+ * The number of links with a point outside the bounding box of all the boxes, made larger by `slack`.
27
+ * Both ends of a link are always in that bounding box, so such a link goes around the outer edge of the board.
28
+ */
29
+ readonly outside: number;
30
+ }
31
+
32
+ /** The faults of the links of one zoom level. */
33
+ export interface LevelFaults extends LinkFaults {
34
+ readonly depth: number;
35
+ /** The number of items of the level. */
36
+ readonly items: number;
37
+ /** The number of items that hold more than one node. */
38
+ readonly groups: number;
39
+ /** The number of pairs of items whose boxes overlap by more than `TOLERANCE` on each axis. */
40
+ readonly overlaps: number;
41
+ }
42
+
43
+ /** The options of the link measures. */
44
+ export interface LinkFaultOptions {
45
+ /** A link that goes farther than this distance outside the bounding box of all the boxes counts in `outside`. The default is 24. */
46
+ readonly slack?: number;
47
+ }
48
+
49
+ /** A link goes out and back when its distance to its target grows by more than this share of the distance between its ends. */
50
+ export const BACKTRACK_SHARE = 0.25;
51
+ /** A link goes out and back only when its distance to its target grows by more than this number of units. */
52
+ export const BACKTRACK_MIN = 40;
53
+ /** The default `slack` of `LinkFaultOptions`. */
54
+ export const OUTSIDE_SLACK = 24;
55
+
56
+ /** A link from one box to another box, as `measureLinks` sees it. */
57
+ export interface MeasuredLink {
58
+ readonly source: string;
59
+ readonly target: string;
60
+ readonly points: readonly Point[];
61
+ }
62
+
63
+ /**
64
+ * This function gives the largest growth of the distance to the last point of a polyline.
65
+ * The growth at a vertex is its distance to the last point, less the nearest approach of the polyline before that vertex.
66
+ */
67
+ export function backtrackOf(p: readonly Point[]): number {
68
+ if (p.length < 3) return 0;
69
+ const end = p[p.length - 1]!;
70
+ let near = dist(p[0]!, end);
71
+ let worst = 0;
72
+ for (let i = 1; i < p.length; i++) {
73
+ worst = Math.max(worst, dist(p[i]!, end) - near);
74
+ near = Math.min(near, pointSegmentDistance(end, p[i - 1]!, p[i]!));
75
+ }
76
+ return worst;
77
+ }
78
+
79
+ /** This function tells if the polyline `p` goes out and back: `backtrackOf(p)` is more than `BACKTRACK_MIN` and more than `BACKTRACK_SHARE` of the span. */
80
+ export function goesOutAndBack(p: readonly Point[]): boolean {
81
+ if (p.length < 3) return false;
82
+ const span = dist(p[0]!, p[p.length - 1]!);
83
+ return backtrackOf(p) > Math.max(BACKTRACK_MIN, BACKTRACK_SHARE * span);
84
+ }
85
+
86
+ /**
87
+ * This function measures the faults of links between boxes. The map `boxes` gives the box of each end.
88
+ * The bounding box of `outside` is the bounding box of all the boxes of the map.
89
+ */
90
+ export function measureLinks(boxes: ReadonlyMap<string, Box>, links: readonly MeasuredLink[], options: LinkFaultOptions = {}): LinkFaults {
91
+ const slack = options.slack ?? OUTSIDE_SLACK;
92
+ let size = 0;
93
+ let x0 = Infinity;
94
+ let y0 = Infinity;
95
+ let x1 = -Infinity;
96
+ let y1 = -Infinity;
97
+ for (const b of boxes.values()) {
98
+ size += Math.max(b.w, b.h);
99
+ x0 = Math.min(x0, b.x);
100
+ y0 = Math.min(y0, b.y);
101
+ x1 = Math.max(x1, b.x + b.w);
102
+ y1 = Math.max(y1, b.y + b.h);
103
+ }
104
+ // Large items cover many buckets of a small grid, so the buckets grow with the items.
105
+ const grid = new Grid<string>(Math.max(180, (2 * size) / Math.max(1, boxes.size)));
106
+ for (const [id, b] of boxes) grid.add(id, b);
107
+ let through = 0;
108
+ let backtrack = 0;
109
+ let worstDetour = 0;
110
+ let outside = 0;
111
+ for (const l of links) {
112
+ const p = l.points;
113
+ if (p.length === 0) continue;
114
+ let hit = false;
115
+ for (let i = 1; i < p.length && !hit; i++) {
116
+ const a = p[i - 1]!;
117
+ const b = p[i]!;
118
+ grid.querySegment(a, b, 0, (id) => {
119
+ if (!hit && id !== l.source && id !== l.target && segmentHitsBox(a, b, inflate(boxes.get(id)!, -TOLERANCE), TOLERANCE)) hit = true;
120
+ });
121
+ }
122
+ if (hit) through++;
123
+ if (goesOutAndBack(p)) backtrack++;
124
+ const span = dist(p[0]!, p[p.length - 1]!);
125
+ if (span > 1) worstDetour = Math.max(worstDetour, polylineLength(p) / span);
126
+ if (p.some((q) => q.x < x0 - slack || q.x > x1 + slack || q.y < y0 - slack || q.y > y1 + slack)) outside++;
127
+ }
128
+ return { links: links.length, through, backtrack, worstDetour, outside };
129
+ }
130
+
131
+ /** This function measures the faults of the routes of a result. The ends of a route are the nodes of its edge. */
132
+ export function measureRouteFaults(graph: Graph, result: Result, options: LinkFaultOptions = {}): LinkFaults {
133
+ const boxes = new Map(graph.nodes.map((n) => [n.id, n.box]));
134
+ const edges = new Map(graph.edges.map((e) => [e.id, e]));
135
+ const links: MeasuredLink[] = [];
136
+ for (const r of result.routes) {
137
+ const e = edges.get(r.edge);
138
+ if (e) links.push({ source: e.source.node, target: e.target.node, points: r.points });
139
+ }
140
+ return measureLinks(boxes, links, options);
141
+ }
142
+
143
+ /** This function counts the pairs of boxes that overlap by more than `TOLERANCE` on each axis. */
144
+ export function countOverlaps(boxes: readonly Box[]): number {
145
+ let size = 0;
146
+ for (const b of boxes) size += Math.max(b.w, b.h);
147
+ const grid = new Grid<number>(Math.max(180, (2 * size) / Math.max(1, boxes.length)));
148
+ boxes.forEach((b, i) => grid.add(i, b));
149
+ let n = 0;
150
+ boxes.forEach((a, i) => {
151
+ grid.query(a, (j) => {
152
+ const b = boxes[j]!;
153
+ if (j > i && Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) > TOLERANCE && Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) > TOLERANCE) n++;
154
+ });
155
+ });
156
+ return n;
157
+ }
158
+
159
+ /** This function measures the faults of the links of one zoom level. The items of the level are the boxes. */
160
+ export function measureLevel(level: Level, options: LinkFaultOptions = {}): LevelFaults {
161
+ const boxes = new Map(level.items.map((i) => [i.id, i.box]));
162
+ return {
163
+ depth: level.depth,
164
+ items: level.items.length,
165
+ groups: level.items.filter((i) => i.nodes.length > 1).length,
166
+ overlaps: countOverlaps(level.items.map((i) => i.box)),
167
+ ...measureLinks(boxes, level.links, options),
168
+ };
169
+ }
170
+
171
+ /**
172
+ * This function measures the faults of each zoom level of a result that has groups: the levels of the far view.
173
+ * A level of single nodes only is the near view. Its links are routes, and `measureRouteFaults` measures them.
174
+ */
175
+ export function measureLevelFaults(result: Result, options: LinkFaultOptions = {}): LevelFaults[] {
176
+ return (result.levels ?? []).filter((l) => l.items.some((i) => i.nodes.length > 1)).map((l) => measureLevel(l, options));
177
+ }
178
+
179
+ /** This function adds the faults of several levels: it sums the counts and keeps the largest `worstDetour`. */
180
+ export function sumFaults(list: readonly LinkFaults[]): LinkFaults {
181
+ return list.reduce<LinkFaults>(
182
+ (s, f) => ({
183
+ links: s.links + f.links,
184
+ through: s.through + f.through,
185
+ backtrack: s.backtrack + f.backtrack,
186
+ worstDetour: Math.max(s.worstDetour, f.worstDetour),
187
+ outside: s.outside + f.outside,
188
+ }),
189
+ { links: 0, through: 0, backtrack: 0, worstDetour: 0, outside: 0 },
190
+ );
191
+ }
package/src/bench/run.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { makeFixture, type FixtureSpec } from "../fixtures.ts";
2
2
  import type { Graph, Result, Router } from "../model.ts";
3
+ import { measureLevelFaults, measureRouteFaults, sumFaults, type LevelFaults, type LinkFaults } from "./links.ts";
3
4
  import { checkProperties, type Violation } from "./properties.ts";
4
5
  import { measureQuality, type Quality } from "./quality.ts";
5
6
 
@@ -20,6 +21,10 @@ export interface BenchRow {
20
21
  /** The first violations, for the report. */
21
22
  readonly sample: readonly Violation[];
22
23
  readonly quality: Quality;
24
+ /** The faults of the routes of the edges (`measureRouteFaults`). */
25
+ readonly faults: LinkFaults;
26
+ /** The faults of each zoom level with groups (`measureLevelFaults`). The list is empty for a router without levels. */
27
+ readonly levelFaults: readonly LevelFaults[];
23
28
  }
24
29
 
25
30
  /** The options of a benchmark run. */
@@ -87,6 +92,8 @@ export function benchOne(router: Router, fixture: string, graph: Graph, repeat =
87
92
  violations: all.length,
88
93
  sample: all.slice(0, 5),
89
94
  quality: measureQuality(graph, result),
95
+ faults: measureRouteFaults(graph, result),
96
+ levelFaults: measureLevelFaults(result),
90
97
  };
91
98
  }
92
99
 
@@ -101,3 +108,24 @@ export function formatTable(rows: readonly BenchRow[]): string {
101
108
  });
102
109
  return [head, line, ...body].join("\n");
103
110
  }
111
+
112
+ /**
113
+ * This function writes the faults of the rows as a Markdown table: the routes first, then the sum of the levels with groups.
114
+ * A router without such levels gets a dash in the columns of the levels.
115
+ */
116
+ export function formatFaults(rows: readonly BenchRow[]): string {
117
+ const head =
118
+ "| router | fixture | through | out and back | worst detour | outside | level links | level overlaps | level through | level out and back | level worst detour | level outside |";
119
+ const line = "|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|";
120
+ const body = rows.map((r) => {
121
+ const f = r.faults;
122
+ const cells = [r.router, r.fixture, f.through, f.backtrack, f.worstDetour.toFixed(2), f.outside];
123
+ if (r.levelFaults.length === 0) cells.push("-", "-", "-", "-", "-", "-");
124
+ else {
125
+ const l = sumFaults(r.levelFaults);
126
+ cells.push(l.links, r.levelFaults.reduce((s, x) => s + x.overlaps, 0), l.through, l.backtrack, l.worstDetour.toFixed(2), l.outside);
127
+ }
128
+ return `| ${cells.join(" | ")} |`;
129
+ });
130
+ return [head, line, ...body].join("\n");
131
+ }
package/src/cases.ts ADDED
@@ -0,0 +1,111 @@
1
+ import { makeFixture, rng } from "./fixtures.ts";
2
+ import type { Edge, Graph, Node } from "./model.ts";
3
+
4
+ /**
5
+ * A board of made-up data that shows one known defect of the routes or of the zoom levels.
6
+ * The benchmark races the routers on each case, and the measures of `measureLinks` give a number for the defect.
7
+ */
8
+ export interface CaseBoard {
9
+ readonly name: string;
10
+ /** The defect that the board shows, in a few words. */
11
+ readonly defect: string;
12
+ /** This function makes the graph. The same function always gives the same graph. */
13
+ readonly make: () => Graph;
14
+ }
15
+
16
+ /** A board while it grows: the functions add nodes and edges in order. */
17
+ class Board {
18
+ readonly nodes: Node[] = [];
19
+ readonly edges: Edge[] = [];
20
+ readonly #pairs = new Set<string>();
21
+
22
+ node(id: string, x: number, y: number, w: number, h: number): string {
23
+ this.nodes.push({ id, box: { x, y, w, h } });
24
+ return id;
25
+ }
26
+
27
+ /** This method adds an edge. It adds nothing for a loop or for a second edge between the same two nodes. */
28
+ link(source: string, target: string): void {
29
+ const key = source < target ? `${source}|${target}` : `${target}|${source}`;
30
+ if (source === target || this.#pairs.has(key)) return;
31
+ this.#pairs.add(key);
32
+ this.edges.push({ id: `e${this.edges.length}`, source: { node: source }, target: { node: target } });
33
+ }
34
+
35
+ graph(): Graph {
36
+ return { nodes: this.nodes, edges: this.edges };
37
+ }
38
+ }
39
+
40
+ /**
41
+ * This function makes a dense board: 150 nodes in tight clusters. At the far view, the boxes of the groups overlap.
42
+ * Then a level link starts or ends in a group that another group covers.
43
+ */
44
+ export function denseGroupsBoard(): Graph {
45
+ return makeFixture({ name: "dense-groups", kind: "clustered", nodes: 150, edgesPerNode: 2, seed: 3 });
46
+ }
47
+
48
+ /**
49
+ * This function makes a board of three rows of 32 cards. Each card links to the next card of its row.
50
+ * Forty long links join cards far apart. The hub of a section is near the middle of its cards.
51
+ * So a long link climbs to hubs behind its source or past its target, and comes back.
52
+ */
53
+ export function hubChainBoard(): Graph {
54
+ const R = rng(3);
55
+ const b = new Board();
56
+ const rows = 3;
57
+ const cols = 32;
58
+ const gap = 48;
59
+ const id = (r: number, c: number): string => `c${r}-${c}`;
60
+ for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) b.node(id(r, c), c * (80 + gap), r * (36 + gap), 80, 36);
61
+ for (let r = 0; r < rows; r++) for (let c = 0; c + 1 < cols; c++) b.link(id(r, c), id(r, c + 1));
62
+ for (let n = 0; n < 40; n++) {
63
+ const r = Math.floor(R() * rows);
64
+ const c = Math.floor(R() * cols);
65
+ const span = Math.floor(cols / 8 + (R() * cols) / 2);
66
+ if (c + span < cols) b.link(id(r, c), id((r + 1) % rows, c + span));
67
+ }
68
+ return b.graph();
69
+ }
70
+
71
+ /**
72
+ * This function makes a board of 32 groups in 8 columns and 4 rows. Each group has 4 cards in a square.
73
+ * The cards of a group link in a chain, and 96 random links join cards of two groups.
74
+ * On a level with many groups, some level links go around the outer edge of the board.
75
+ */
76
+ export function manyGroupsBoard(): Graph {
77
+ const R = rng(7);
78
+ const b = new Board();
79
+ const cols = 8;
80
+ const rows = 4;
81
+ const per = 4;
82
+ const w = 60;
83
+ const h = 30;
84
+ const cardGap = 12;
85
+ const groupGap = 60;
86
+ const side = Math.ceil(Math.sqrt(per));
87
+ const gw = side * w + (side - 1) * cardGap;
88
+ const gh = Math.ceil(per / side) * h + (Math.ceil(per / side) - 1) * cardGap;
89
+ const groups: string[][] = [];
90
+ for (let r = 0; r < rows; r++)
91
+ for (let c = 0; c < cols; c++) {
92
+ const list: string[] = [];
93
+ for (let i = 0; i < per; i++) {
94
+ const x = c * (gw + groupGap) + (i % side) * (w + cardGap);
95
+ const y = r * (gh + groupGap) + Math.floor(i / side) * (h + cardGap);
96
+ list.push(b.node(`g${r}-${c}-${i}`, x, y, w, h));
97
+ }
98
+ groups.push(list);
99
+ }
100
+ for (const list of groups) for (let i = 1; i < list.length; i++) b.link(list[i - 1]!, list[i]!);
101
+ const pick = (list: readonly string[]): string => list[Math.floor(R() * list.length)]!;
102
+ for (let n = 0; n < groups.length * 3; n++) b.link(pick(groups[Math.floor(R() * groups.length)]!), pick(groups[Math.floor(R() * groups.length)]!));
103
+ return b.graph();
104
+ }
105
+
106
+ /** The case boards of the benchmark. Each board has a few hundred nodes at most, so the benchmark races them on each run. */
107
+ export const CASES: readonly CaseBoard[] = [
108
+ { name: "case-dense-groups", defect: "Groups overlap at the far view, and level links go through other groups.", make: denseGroupsBoard },
109
+ { name: "case-hub-chain", defect: "A route climbs a chain of hubs away from its target and comes back.", make: hubChainBoard },
110
+ { name: "case-many-groups", defect: "On a level with many groups, links go around the outer edge of the board.", make: manyGroupsBoard },
111
+ ];
package/src/grid.ts CHANGED
@@ -71,6 +71,56 @@ export class Grid<T> {
71
71
  }
72
72
  }
73
73
 
74
+ /**
75
+ * This method gives each item in the buckets along the segment `ab`, widened by `margin`, to `visit`, one time each.
76
+ * The buckets are those of `#segment`, in the same order, so the items come in the same order.
77
+ * A bucket in the range of the step before has no new item, so the walk skips it.
78
+ * The walk stops when `visit` gives true, and then the method gives true.
79
+ */
80
+ #walk(a: Point, b: Point, margin: number, visit: (item: T) => boolean | void): boolean {
81
+ const stamp = ++this.#stamp;
82
+ const c = this.#cell;
83
+ const items = this.#items;
84
+ const n = Math.max(1, Math.ceil(Math.hypot(b.x - a.x, b.y - a.y) / (c / 2)));
85
+ let px = a.x;
86
+ let py = a.y;
87
+ // The bucket range of the step before. The first step has none.
88
+ let pi0 = 1;
89
+ let pi1 = 0;
90
+ let pj0 = 1;
91
+ let pj1 = 0;
92
+ for (let k = 1; k <= n; k++) {
93
+ const qx = k === n ? b.x : a.x + ((b.x - a.x) * k) / n;
94
+ const qy = k === n ? b.y : a.y + ((b.y - a.y) * k) / n;
95
+ const i0 = Math.floor((Math.min(px, qx) - margin) / c);
96
+ const j0 = Math.floor((Math.min(py, qy) - margin) / c);
97
+ const i1 = Math.floor((Math.max(px, qx) + margin) / c);
98
+ const j1 = Math.floor((Math.max(py, qy) + margin) / c);
99
+ // A visit can add an item and so replace the array of stamps. Each step reads it again, as `#take` does.
100
+ const seen = this.#seen;
101
+ for (let i = i0; i <= i1; i++) {
102
+ const before = i >= pi0 && i <= pi1;
103
+ for (let j = j0; j <= j1; j++) {
104
+ if (before && j >= pj0 && j <= pj1) continue;
105
+ const list = this.#buckets.get(this.#key(i, j));
106
+ if (list === undefined) continue;
107
+ for (const id of list) {
108
+ if (seen[id] === stamp) continue;
109
+ seen[id] = stamp;
110
+ if (visit(items[id]!) === true) return true;
111
+ }
112
+ }
113
+ }
114
+ pi0 = i0;
115
+ pi1 = i1;
116
+ pj0 = j0;
117
+ pj1 = j1;
118
+ px = qx;
119
+ py = qy;
120
+ }
121
+ return false;
122
+ }
123
+
74
124
  // The buckets along a segment, widened by `margin`: the boxes of steps of at most half a bucket.
75
125
  #segment(a: Point, b: Point, margin: number, each: (x0: number, y0: number, x1: number, y1: number) => void): void {
76
126
  const n = Math.max(1, Math.ceil(Math.hypot(b.x - a.x, b.y - a.y) / (this.#cell / 2)));
@@ -103,8 +153,17 @@ export class Grid<T> {
103
153
 
104
154
  /** This method gives each item whose buckets come within `margin` of the segment `ab` to `visit`, one time each. */
105
155
  querySegment(a: Point, b: Point, margin: number, visit: (item: T) => void): void {
106
- const stamp = ++this.#stamp;
107
- this.#segment(a, b, margin, (x0, y0, x1, y1) => this.#take(stamp, x0, y0, x1, y1, visit));
156
+ this.#walk(a, b, margin, (item) => {
157
+ visit(item);
158
+ });
159
+ }
160
+
161
+ /**
162
+ * This method tells if `test` gives true for an item whose buckets come within `margin` of the segment `ab`.
163
+ * It gives the items in the order of `querySegment`, and it stops at the first item that passes the test.
164
+ */
165
+ someSegment(a: Point, b: Point, margin: number, test: (item: T) => boolean): boolean {
166
+ return this.#walk(a, b, margin, test);
108
167
  }
109
168
  }
110
169
 
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ export * from "./geom.ts";
2
2
  export { Grid, segmentBounds } from "./grid.ts";
3
3
  export * from "./model.ts";
4
4
  export * from "./fixtures.ts";
5
+ export * from "./cases.ts";
5
6
  export * from "./plugs.ts";
6
7
  export * from "./curves.ts";
7
8
  export * from "./visibility.ts";
@@ -11,4 +12,5 @@ export * from "./sections.ts";
11
12
  export * from "./routers/index.ts";
12
13
  export * from "./bench/properties.ts";
13
14
  export * from "./bench/quality.ts";
15
+ export * from "./bench/links.ts";
14
16
  export * from "./bench/run.ts";
package/src/levels.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { center, dist, type Box, type Point } from "./geom.ts";
1
+ import { center, dist, polylineLength, type Box, type Point } from "./geom.ts";
2
2
  import type { LevelItem, LevelLink, Node, Side } from "./model.ts";
3
3
  import { pointOnSide, SIDE_NORMAL, sideToward, type Plug } from "./plugs.ts";
4
4
  import { CornerGraph, Obstacles } from "./visibility.ts";
@@ -14,6 +14,23 @@ export interface ItemPair {
14
14
  /** The parts of a way finder that a link uses. */
15
15
  type Ways = Pick<WayFinder, "obstacles" | "corners" | "margin" | "out">;
16
16
 
17
+ /** The free plugs of a node, and the reach of each plug. */
18
+ interface FreePlugs {
19
+ readonly plugs: readonly Plug[];
20
+ readonly reach: readonly number[];
21
+ }
22
+
23
+ /** The ways of a level, with a second corner graph and the free plugs of the nodes. */
24
+ interface LevelWays extends Ways {
25
+ /** This function gives a second corner graph with a small margin. The first link that needs it makes it. */
26
+ readonly tight: () => CornerGraph;
27
+ /** The free plugs of each node that a link needed. */
28
+ readonly free: Map<Node, FreePlugs>;
29
+ }
30
+
31
+ /** The margin of the second corner graph. A gap between two boxes wider than approximately 1.5 times this margin lets a link through. */
32
+ const TIGHT_MARGIN = 1;
33
+
17
34
  const EPS = 1e-6;
18
35
 
19
36
  /**
@@ -58,23 +75,42 @@ export function levelLinks(items: readonly LevelItem[], pairs: readonly ItemPair
58
75
  const [fs, ft] = facingPlugs(s.box, t.box, inset);
59
76
  let points = isFree(ways, s, fs) && isFree(ways, t, ft) ? quickWay(ways, ends(ways, s, fs, t, ft)) : undefined;
60
77
  // Else the link goes from the middle of a side. Each item has at most four such plugs, so the corner graph finds their ends one time.
61
- points ??= squareWay(ways, ends(ways, s, sidePlug(ways, s, center(t.box)), t, sidePlug(ways, t, center(s.box))));
78
+ points ??= sideWay(ways, s, t, inset);
62
79
  out.push({ source: p.source, target: p.target, count: p.count, points });
63
80
  }
64
81
  return out;
65
82
  }
66
83
 
84
+ /**
85
+ * This function gives a way from plug to plug between the plugs at the middle of a side of the two nodes.
86
+ * Without a way in the corner graph, the link goes straight between the ends, with legs parallel to the axes where they are clear.
87
+ * When that line goes through another box, the link goes between the free plugs of the two nodes (`freeWay`) instead.
88
+ */
89
+ function sideWay(ways: LevelWays, s: Node, t: Node, inset: number): Point[] {
90
+ const e = ends(ways, s, sidePlug(ways, s, center(t.box)), t, sidePlug(ways, t, center(s.box)));
91
+ const quick = quickWay(ways, e);
92
+ if (quick) return quick;
93
+ const path = ways.corners.path(e.a, e.b);
94
+ const way = pulled(ways, e.ps, path ?? [e.a, e.b], e.pt);
95
+ if (path || ways.obstacles.clearPath(way, 0, new Set([s, t]))) return way;
96
+ return freeWay(ways, s, t, inset) ?? way;
97
+ }
98
+
67
99
  /**
68
100
  * This function makes the obstacles and the corner graph of the items of a level.
69
101
  * The buckets of the obstacles grow with the items, so a long link visits few buckets.
70
102
  */
71
- function levelWays(nodes: readonly Node[], options: WayOptions): Ways {
103
+ function levelWays(nodes: readonly Node[], options: WayOptions): LevelWays {
72
104
  const margin = options.margin ?? 5;
105
+ const k = options.k ?? 8;
73
106
  let size = 0;
74
107
  for (const n of nodes) size += Math.max(n.box.w, n.box.h);
75
108
  const obstacles = new Obstacles(nodes, Math.max(180, (2 * size) / Math.max(1, nodes.length)));
76
- const corners = new CornerGraph(nodes, obstacles, { margin, k: options.k ?? 8 });
77
- return { obstacles, corners, margin, out: (p) => ({ x: p.point.x + p.normal.x * margin, y: p.point.y + p.normal.y * margin }) };
109
+ const corners = new CornerGraph(nodes, obstacles, { margin, k });
110
+ const out = (p: Plug): Point => ({ x: p.point.x + p.normal.x * margin, y: p.point.y + p.normal.y * margin });
111
+ let tight: CornerGraph | undefined;
112
+ const tightGraph = (): CornerGraph => (tight ??= new CornerGraph(nodes, obstacles, { margin: Math.min(margin, TIGHT_MARGIN), k }));
113
+ return { obstacles, corners, margin, out, tight: tightGraph, free: new Map() };
78
114
  }
79
115
 
80
116
  /**
@@ -129,6 +165,105 @@ function sidePlug(ways: Ways, node: Node, toward: Point): Plug {
129
165
  return plug(order[0]!);
130
166
  }
131
167
 
168
+ /**
169
+ * This function gives the free plugs of a node: on each side, the free point nearest to the middle of the side.
170
+ * A point is free when it and the point at `margin` from it are in no other box. The function keeps the plugs of each node.
171
+ */
172
+ function freePlugs(ways: LevelWays, node: Node, inset: number): FreePlugs {
173
+ const known = ways.free.get(node);
174
+ if (known) return known;
175
+ const b = node.box;
176
+ const out: Plug[] = [];
177
+ for (const side of SIDES) {
178
+ const n = SIDE_NORMAL[side];
179
+ const across = n.x === 0;
180
+ // The side goes from `lo` to `hi` along its axis, at the coordinate `at` of the other axis.
181
+ const lo = across ? b.x : b.y;
182
+ const hi = across ? b.x + b.w : b.y + b.h;
183
+ const at = across ? (n.y > 0 ? b.y + b.h : b.y) : n.x > 0 ? b.x + b.w : b.x;
184
+ const far = at + (across ? n.y : n.x) * ways.margin;
185
+ // A box covers a point of the side when the point or its way out is inside the box, as in `isFree`.
186
+ const covered: [number, number][] = [];
187
+ const strip = across
188
+ ? { x: lo, y: Math.min(at, far), w: hi - lo, h: Math.abs(far - at) }
189
+ : { x: Math.min(at, far), y: lo, w: Math.abs(far - at), h: hi - lo };
190
+ ways.obstacles.query(strip, (m) => {
191
+ if (m === node) return;
192
+ const o = m.box;
193
+ const [o0, o1, p0, p1] = across ? [o.x, o.x + o.w, o.y, o.y + o.h] : [o.y, o.y + o.h, o.x, o.x + o.w];
194
+ if ((at > p0 && at < p1) || (far > p0 && far < p1)) covered.push([o0, o1]);
195
+ });
196
+ const mid = (lo + hi) / 2;
197
+ let best: number | undefined;
198
+ for (const [f0, f1] of freeSpans(lo, hi, covered)) {
199
+ const room = Math.min(inset, (f1 - f0) / 2);
200
+ const u = Math.max(f0 + room, Math.min(f1 - room, mid));
201
+ if (best === undefined || Math.abs(u - mid) < Math.abs(best - mid)) best = u;
202
+ }
203
+ if (best !== undefined) out.push({ point: across ? { x: best, y: at } : { x: at, y: best }, normal: n });
204
+ }
205
+ const free = { plugs: out, reach: out.map((p) => reach(ways, node, p)) };
206
+ ways.free.set(node, free);
207
+ return free;
208
+ }
209
+
210
+ /** This function gives the indices of the plugs, and the plugs that face `toward` most come first. */
211
+ function facingOrder(plugs: readonly Plug[], from: Point, toward: Point): number[] {
212
+ const facing = (p: Plug): number => p.normal.x * (toward.x - from.x) + p.normal.y * (toward.y - from.y);
213
+ return plugs.map((_, i) => i).sort((u, v) => facing(plugs[v]!) - facing(plugs[u]!) || u - v);
214
+ }
215
+
216
+ /** This function gives the parts of the span from `lo` to `hi` outside each open interval of `covered`. A part of zero length does not count. */
217
+ function freeSpans(lo: number, hi: number, covered: [number, number][]): [number, number][] {
218
+ covered.sort((u, v) => u[0] - v[0]);
219
+ const out: [number, number][] = [];
220
+ let x = lo;
221
+ for (const [c0, c1] of covered) {
222
+ if (c0 > x) out.push([x, Math.min(c0, hi)]);
223
+ x = Math.max(x, c1);
224
+ if (x >= hi) break;
225
+ }
226
+ if (x < hi) out.push([x, hi]);
227
+ return out.filter(([a, b]) => b - a > EPS);
228
+ }
229
+
230
+ /**
231
+ * This function gives the shortest way between the free plugs of the two nodes, or `undefined` when the second corner graph has none.
232
+ * A link uses it when its plugs at the middle of a side find no way. Other boxes can cover such a plug, or close the space in front of it.
233
+ */
234
+ function freeWay(ways: LevelWays, s: Node, t: Node, inset: number): Point[] | undefined {
235
+ const fs = freePlugs(ways, s, inset);
236
+ const ft = freePlugs(ways, t, inset);
237
+ const cs = center(s.box);
238
+ const ct = center(t.box);
239
+ const is = facingOrder(fs.plugs, cs, ct);
240
+ const it = facingOrder(ft.plugs, ct, cs);
241
+ const from = is.map((i) => fs.plugs[i]!);
242
+ const to = it.map((k) => ft.plugs[k]!);
243
+ const ra = is.map((i) => fs.reach[i]!);
244
+ const rb = it.map((k) => ft.reach[k]!);
245
+ // A pair of plugs with one or two clear legs between them gives the short way.
246
+ let best: Point[] | undefined;
247
+ let bestLength = Infinity;
248
+ from.forEach((ps, i) =>
249
+ to.forEach((pt, k) => {
250
+ const way = quickWay(ways, endsAt(ps, ra[i]!, pt, rb[k]!));
251
+ const length = way ? polylineLength(way) : Infinity;
252
+ if (length < bestLength) {
253
+ best = way;
254
+ bestLength = length;
255
+ }
256
+ }),
257
+ );
258
+ if (best) return best;
259
+ // Else one search of the second corner graph finds the shortest way from a plug of `s` to a plug of `t`.
260
+ // The corners of the second graph are near the boxes, so the way can go through a gap that is narrower than the margin.
261
+ const a = from.map((ps, i) => ({ point: along(ps, ra[i]!), cost: ra[i]! }));
262
+ const b = to.map((pt, k) => ({ point: along(pt, rb[k]!), cost: rb[k]! }));
263
+ const found = ways.tight().pathAmong(a, b);
264
+ return found && pulled(ways, from[found.from]!, found.points, to[found.to]!);
265
+ }
266
+
132
267
  /** The two ends of a link: each plug, and the point where the link turns away from the normal of the plug. */
133
268
  interface Ends {
134
269
  readonly ps: Plug;
@@ -143,8 +278,11 @@ interface Ends {
143
278
  * Two plugs that face each other across a narrow space make the link turn in the middle of that space.
144
279
  */
145
280
  function ends(ways: Ways, s: Node, ps: Plug, t: Node, pt: Plug): Ends {
146
- let ra = reach(ways, s, ps);
147
- let rb = reach(ways, t, pt);
281
+ return endsAt(ps, reach(ways, s, ps), pt, reach(ways, t, pt));
282
+ }
283
+
284
+ /** This function gives the ends of a link from the plug `ps` to the plug `pt`, with the reach of each plug. */
285
+ function endsAt(ps: Plug, ra: number, pt: Plug, rb: number): Ends {
148
286
  // Two plugs that face each other turn at the same distance, so the link does not go past itself.
149
287
  if (ps.normal.x === -pt.normal.x && ps.normal.y === -pt.normal.y) {
150
288
  const d = (pt.point.x - ps.point.x) * ps.normal.x + (pt.point.y - ps.point.y) * ps.normal.y;
@@ -182,14 +320,12 @@ function quickWay(ways: Ways, e: Ends): Point[] | undefined {
182
320
  }
183
321
 
184
322
  /**
185
- * This function gives a way from plug to plug through the corner graph. The way leaves and enters each plug along its normal.
186
- * Then it pulls the corners tight with legs that are parallel to the axes, where the obstacles let it.
323
+ * This function gives a way from the plug `ps` through the points of `path` to the plug `pt`.
324
+ * The way leaves and enters each plug along its normal. Then it pulls the corners tight with legs that are parallel to the axes, where the obstacles let it.
187
325
  */
188
- function squareWay(ways: Ways, e: Ends): Point[] {
189
- const quick = quickWay(ways, e);
190
- if (quick) return quick;
191
- const legs = squarePull(ways.corners.path(e.a, e.b) ?? [e.a, e.b], ways.obstacles, ways.margin / 2, axisOf(e.ps.normal), axisOf(e.pt.normal));
192
- return tidy([e.ps.point, ...legs, e.pt.point]);
326
+ function pulled(ways: Ways, ps: Plug, path: readonly Point[], pt: Plug): Point[] {
327
+ const legs = squarePull(path, ways.obstacles, ways.margin / 2, axisOf(ps.normal), axisOf(pt.normal));
328
+ return tidy([ps.point, ...legs, pt.point]);
193
329
  }
194
330
 
195
331
  type Axis = "x" | "y";