@mark1russell7/highways 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.0",
12
+ "version": "0.1.2",
13
13
  "type": "module",
14
14
  "main": "./build/index.js",
15
15
  "types": "./build/index.d.ts",
@@ -1,10 +1,18 @@
1
- import { dist, inflate, pointBoxDistance, segmentHitsBox, type Point } from "../geom.ts";
1
+ import { dist, inflate, pointBoxDistance, segmentHitsBox, type Box, type Point } from "../geom.ts";
2
2
  import { Grid } from "../grid.ts";
3
3
  import { fixedPortPoint, nodeIndex, type Graph, type Node, type Result } from "../model.ts";
4
4
 
5
5
  /** A broken hard rule. A correct router has none. */
6
6
  export interface Violation {
7
- readonly rule: "complete" | "ends-at-plugs" | "fixed-plugs" | "no-pass-through" | "counts-sum" | "bundle-trunk" | "run-shared";
7
+ readonly rule:
8
+ | "complete"
9
+ | "ends-at-plugs"
10
+ | "fixed-plugs"
11
+ | "no-pass-through"
12
+ | "counts-sum"
13
+ | "link-ends"
14
+ | "bundle-trunk"
15
+ | "run-shared";
8
16
  readonly edge?: string;
9
17
  readonly detail: string;
10
18
  }
@@ -15,6 +23,7 @@ export const TOLERANCE = 0.5;
15
23
  /**
16
24
  * This function checks the hard rules of a result. Each edge has one route, and each route starts and ends at its nodes.
17
25
  * Fixed plugs stay in place. No route goes through a node that is not one of its ends. The counts of each level add up.
26
+ * Each link of a level starts on the box of its source and ends on the box of its target.
18
27
  *
19
28
  * Each route of a bundle starts with its trunk, and an edge is in one bundle at most.
20
29
  * Each run has two edges or more, and the route of each edge holds the points of the run in a row.
@@ -91,10 +100,26 @@ export function checkProperties(graph: Graph, result: Result, limit = 100): Viol
91
100
  const sum = level.links.reduce((s, l) => s + l.count, 0);
92
101
  if (sum !== between)
93
102
  if (add({ rule: "counts-sum", detail: `level ${level.depth}: the links count ${sum} edges, not ${between}` })) return out;
103
+ const boxOf = new Map(level.items.map((it) => [it.id, it.box]));
104
+ for (const l of level.links) {
105
+ const s = boxOf.get(l.source);
106
+ const t = boxOf.get(l.target);
107
+ const p = l.points;
108
+ if (!s || !t || p.length < 2 || !onBoundary(p[0]!, s) || !onBoundary(p[p.length - 1]!, t))
109
+ if (add({ rule: "link-ends", detail: `level ${level.depth}: the link ${l.source} to ${l.target} does not start and end on the boundaries of its items` }))
110
+ return out;
111
+ }
94
112
  }
95
113
  return out;
96
114
  }
97
115
 
116
+ /** This function tells if the point is on the boundary of the box, within `TOLERANCE`. */
117
+ function onBoundary(p: Point, b: Box): boolean {
118
+ const inner = inflate(b, -TOLERANCE);
119
+ const deep = p.x > inner.x && p.x < inner.x + inner.w && p.y > inner.y && p.y < inner.y + inner.h;
120
+ return !deep && pointBoxDistance(p, b) <= TOLERANCE;
121
+ }
122
+
98
123
  /** This function tells if the polyline `p` holds the points of `part` in a row. */
99
124
  function holds(p: readonly Point[], part: readonly Point[]): boolean {
100
125
  if (part.length === 0) return true;
package/src/levels.ts ADDED
@@ -0,0 +1,243 @@
1
+ import { center, dist, type Box, type Point } from "./geom.ts";
2
+ import type { LevelItem, LevelLink, Node, Side } from "./model.ts";
3
+ import { pointOnSide, SIDE_NORMAL, sideToward, type Plug } from "./plugs.ts";
4
+ import { CornerGraph, Obstacles } from "./visibility.ts";
5
+ import type { WayFinder, WayOptions } from "./ways.ts";
6
+
7
+ /** Two items of a level that a link joins, and the count of the edges between them. */
8
+ export interface ItemPair {
9
+ readonly source: string;
10
+ readonly target: string;
11
+ readonly count: number;
12
+ }
13
+
14
+ /** The parts of a way finder that a link uses. */
15
+ type Ways = Pick<WayFinder, "obstacles" | "corners" | "margin" | "out">;
16
+
17
+ const EPS = 1e-6;
18
+
19
+ /**
20
+ * This function removes the repeated points, the points in the middle of a straight run,
21
+ * and the points where the way turns back on itself.
22
+ */
23
+ function tidy(p: readonly Point[]): Point[] {
24
+ const out: Point[] = [];
25
+ for (const q of p) {
26
+ if (out.length > 0 && dist(q, out[out.length - 1]!) <= EPS) continue;
27
+ while (out.length >= 2) {
28
+ const a = out[out.length - 2]!;
29
+ const b = out[out.length - 1]!;
30
+ const cross = (b.x - a.x) * (q.y - b.y) - (b.y - a.y) * (q.x - b.x);
31
+ if (Math.abs(cross) > EPS * (1 + dist(a, b) * dist(b, q))) break;
32
+ // The point b is on the line through a and q: in the middle of a straight run, or at the tip of a turn back.
33
+ out.pop();
34
+ }
35
+ if (out.length > 0 && dist(q, out[out.length - 1]!) <= EPS) continue;
36
+ out.push(q);
37
+ }
38
+ return out;
39
+ }
40
+
41
+ /**
42
+ * This function gives the links of one level. The items of the level are the obstacles.
43
+ * A link starts on the boundary of the box of its source and ends on the boundary of the box of its target.
44
+ * It goes around the boxes of the other items. Where there is space, its legs are parallel to the axes.
45
+ */
46
+ export function levelLinks(items: readonly LevelItem[], pairs: readonly ItemPair[], options: WayOptions = {}): LevelLink[] {
47
+ if (pairs.length === 0) return [];
48
+ const nodes: Node[] = items.map((i) => ({ id: i.id, box: i.box }));
49
+ const byId = new Map(nodes.map((n) => [n.id, n]));
50
+ const ways = levelWays(nodes, options);
51
+ const inset = Math.min(6, ways.margin);
52
+ const out: LevelLink[] = [];
53
+ for (const p of pairs) {
54
+ const s = byId.get(p.source);
55
+ const t = byId.get(p.target);
56
+ if (!s || !t) continue;
57
+ // Two items with a clear way between their facing sides get the short way.
58
+ const [fs, ft] = facingPlugs(s.box, t.box, inset);
59
+ let points = isFree(ways, s, fs) && isFree(ways, t, ft) ? quickWay(ways, ends(ways, s, fs, t, ft)) : undefined;
60
+ // 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))));
62
+ out.push({ source: p.source, target: p.target, count: p.count, points });
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * This function makes the obstacles and the corner graph of the items of a level.
69
+ * The buckets of the obstacles grow with the items, so a long link visits few buckets.
70
+ */
71
+ function levelWays(nodes: readonly Node[], options: WayOptions): Ways {
72
+ const margin = options.margin ?? 5;
73
+ let size = 0;
74
+ for (const n of nodes) size += Math.max(n.box.w, n.box.h);
75
+ 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 }) };
78
+ }
79
+
80
+ /**
81
+ * This function gives the plugs of a link between two boxes. Each plug is on the side that faces the other box.
82
+ * When the two sides face each other and their spans overlap, both plugs are at the middle of the overlap, so the link is straight.
83
+ */
84
+ export function facingPlugs(a: Box, b: Box, inset: number): [Plug, Plug] {
85
+ const sa = sideToward(a, center(b));
86
+ const sb = sideToward(b, center(a));
87
+ const plug = (box: Box, side: Side, toward: Point): Plug => ({ point: pointOnSide(box, side, toward, inset), normal: SIDE_NORMAL[side] });
88
+ const across = (sa === "east" && sb === "west") || (sa === "west" && sb === "east");
89
+ const down = (sa === "south" && sb === "north") || (sa === "north" && sb === "south");
90
+ if (across || down) {
91
+ const lo = across ? Math.max(a.y, b.y) + inset : Math.max(a.x, b.x) + inset;
92
+ const hi = across ? Math.min(a.y + a.h, b.y + b.h) - inset : Math.min(a.x + a.w, b.x + b.w) - inset;
93
+ if (lo <= hi) {
94
+ const ca = center(a);
95
+ const cb = center(b);
96
+ const m = Math.max(lo, Math.min(hi, across ? (ca.y + cb.y) / 2 : (ca.x + cb.x) / 2));
97
+ const at = (box: Box, side: Side): Point =>
98
+ across
99
+ ? { x: side === "east" ? box.x + box.w : box.x, y: m }
100
+ : { x: m, y: side === "south" ? box.y + box.h : box.y };
101
+ return [
102
+ { point: at(a, sa), normal: SIDE_NORMAL[sa] },
103
+ { point: at(b, sb), normal: SIDE_NORMAL[sb] },
104
+ ];
105
+ }
106
+ }
107
+ return [plug(a, sa, center(b)), plug(b, sb, center(a))];
108
+ }
109
+
110
+ const SIDES: readonly Side[] = ["north", "east", "south", "west"];
111
+
112
+ /** This function tells if the way out of a plug is free. The plug and the point at `margin` from it are in no other box. */
113
+ function isFree(ways: Ways, node: Node, plug: Plug): boolean {
114
+ return !ways.obstacles.inside(ways.out(plug), 0, node) && !ways.obstacles.inside(plug.point, 0, node);
115
+ }
116
+
117
+ /** This function gives the plug at the middle of a side of the node: the side with a free way out that faces `toward` most. */
118
+ function sidePlug(ways: Ways, node: Node, toward: Point): Plug {
119
+ const b = node.box;
120
+ const c = center(b);
121
+ const d = { x: toward.x - c.x, y: toward.y - c.y };
122
+ const facing = (s: Side): number => SIDE_NORMAL[s].x * d.x + SIDE_NORMAL[s].y * d.y;
123
+ const order = [...SIDES].sort((u, v) => facing(v) - facing(u));
124
+ const plug = (s: Side): Plug => {
125
+ const n = SIDE_NORMAL[s];
126
+ return { point: { x: c.x + (n.x * b.w) / 2, y: c.y + (n.y * b.h) / 2 }, normal: n };
127
+ };
128
+ for (const s of order) if (isFree(ways, node, plug(s))) return plug(s);
129
+ return plug(order[0]!);
130
+ }
131
+
132
+ /** The two ends of a link: each plug, and the point where the link turns away from the normal of the plug. */
133
+ interface Ends {
134
+ readonly ps: Plug;
135
+ readonly pt: Plug;
136
+ readonly a: Point;
137
+ readonly b: Point;
138
+ }
139
+
140
+ /**
141
+ * This function gives the ends of a link. The link leaves each plug along its normal for the margin.
142
+ * A box can be nearer than twice the margin in front of a plug. Then the link turns in the middle of the space before that box.
143
+ * Two plugs that face each other across a narrow space make the link turn in the middle of that space.
144
+ */
145
+ 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);
148
+ // Two plugs that face each other turn at the same distance, so the link does not go past itself.
149
+ if (ps.normal.x === -pt.normal.x && ps.normal.y === -pt.normal.y) {
150
+ const d = (pt.point.x - ps.point.x) * ps.normal.x + (pt.point.y - ps.point.y) * ps.normal.y;
151
+ if (d > 0 && ra + rb > d) ra = rb = d / 2;
152
+ }
153
+ return { ps, pt, a: along(ps, ra), b: along(pt, rb) };
154
+ }
155
+
156
+ const along = (p: Plug, d: number): Point => ({ x: p.point.x + p.normal.x * d, y: p.point.y + p.normal.y * d });
157
+
158
+ /** This function gives the distance that a link goes along the normal of a plug before it turns. */
159
+ function reach(ways: Ways, node: Node, plug: Plug): number {
160
+ const skip = new Set([node]);
161
+ const far = 2 * ways.margin;
162
+ if (ways.obstacles.clear(plug.point, along(plug, far), 0, skip)) return ways.margin;
163
+ let lo = 0;
164
+ let hi = far;
165
+ for (let i = 0; i < 10; i++) {
166
+ const m = (lo + hi) / 2;
167
+ if (ways.obstacles.clear(plug.point, along(plug, m), 0, skip)) lo = m;
168
+ else hi = m;
169
+ }
170
+ return lo / 2;
171
+ }
172
+
173
+ /**
174
+ * This function gives the way between the ends when one or two legs parallel to the axes are clear, or `undefined`.
175
+ * The legs keep half the margin from the boxes, or less when an end turns nearer to a box.
176
+ */
177
+ function quickWay(ways: Ways, e: Ends): Point[] | undefined {
178
+ const near = Math.min(dist(e.ps.point, e.a), dist(e.pt.point, e.b));
179
+ const pad = Math.max(0, Math.min(ways.margin / 2, near - 1));
180
+ const legs = square(e.a, e.b, ways.obstacles, pad, axisOf(e.ps.normal), axisOf(e.pt.normal));
181
+ return legs && tidy([e.ps.point, e.a, ...legs, e.pt.point]);
182
+ }
183
+
184
+ /**
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.
187
+ */
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]);
193
+ }
194
+
195
+ type Axis = "x" | "y";
196
+
197
+ const axisOf = (n: Point): Axis => (Math.abs(n.x) > Math.abs(n.y) ? "x" : "y");
198
+
199
+ /**
200
+ * This function pulls a path tight. From each vertex, it goes to the farthest vertex that one or two legs can reach.
201
+ * These legs are parallel to the axes and clear of the obstacles by `pad`. It keeps a diagonal leg only where no such legs are clear.
202
+ * `first` is the axis of the way into the first vertex, and `last` is the axis of the way out of the last vertex.
203
+ */
204
+ function squarePull(p: readonly Point[], obstacles: Obstacles, pad: number, first: Axis, last: Axis, lookahead = 16): Point[] {
205
+ if (p.length === 0) return [];
206
+ const out: Point[] = [p[0]!];
207
+ let axis: Axis | undefined = first;
208
+ let i = 0;
209
+ while (i < p.length - 1) {
210
+ let step: { j: number; legs: Point[] } | undefined;
211
+ for (let j = Math.min(p.length - 1, i + lookahead); j > i && !step; j--) {
212
+ const legs = square(p[i]!, p[j]!, obstacles, pad, axis, j === p.length - 1 ? last : undefined);
213
+ if (legs) step = { j, legs };
214
+ }
215
+ // With no clear legs to the next vertex, the legs may touch a box, but they do not go through it.
216
+ step ??= {
217
+ j: i + 1,
218
+ legs: square(p[i]!, p[i + 1]!, obstacles, 0, axis, i + 1 === p.length - 1 ? last : undefined) ?? [p[i + 1]!],
219
+ };
220
+ out.push(...step.legs);
221
+ const end = out[out.length - 1]!;
222
+ const before = out[out.length - 2]!;
223
+ axis = Math.abs(end.y - before.y) < EPS ? "x" : Math.abs(end.x - before.x) < EPS ? "y" : undefined;
224
+ i = step.j;
225
+ }
226
+ return out;
227
+ }
228
+
229
+ /**
230
+ * This function gives one or two legs parallel to the axes from `a` to `b`, clear of the obstacles by `pad`, or `undefined`.
231
+ * Of two clear corners, it picks the corner that continues the axis `from` and arrives along the axis `to`.
232
+ */
233
+ function square(a: Point, b: Point, obstacles: Obstacles, pad: number, from: Axis | undefined, to: Axis | undefined): Point[] | undefined {
234
+ if (Math.abs(a.x - b.x) < EPS || Math.abs(a.y - b.y) < EPS) return obstacles.clear(a, b, pad) ? [b] : undefined;
235
+ // The corner of `x` goes along the x axis first, and the corner of `y` along the y axis first.
236
+ const options: { c: Point; bends: number }[] = [
237
+ { c: { x: b.x, y: a.y }, bends: (from === "y" ? 1 : 0) + (to === "x" ? 1 : 0) },
238
+ { c: { x: a.x, y: b.y }, bends: (from === "x" ? 1 : 0) + (to === "y" ? 1 : 0) },
239
+ ];
240
+ options.sort((u, v) => u.bends - v.bends);
241
+ for (const { c } of options) if (obstacles.clear(a, c, pad) && obstacles.clear(c, b, pad)) return [c, b];
242
+ return undefined;
243
+ }
@@ -1,16 +1,6 @@
1
1
  import { center, dist, polylineLength, type Box, type Point } from "../geom.ts";
2
- import {
3
- nodeIndex,
4
- type Graph,
5
- type Level,
6
- type LevelItem,
7
- type LevelLink,
8
- type Node,
9
- type Result,
10
- type Route,
11
- type Router,
12
- type SharedRun,
13
- } from "../model.ts";
2
+ import { levelLinks, type ItemPair } from "../levels.ts";
3
+ import { nodeIndex, type Graph, type Level, type LevelItem, type Node, type Result, type Route, type Router, type SharedRun } from "../model.ts";
14
4
  import { placePlug } from "../plugs.ts";
15
5
  import { allSections, buildSections, treeDepth, type Section, type SectionOptions } from "../sections.ts";
16
6
  import { orderLanes } from "../segments.ts";
@@ -37,7 +27,10 @@ const dedupe = (p: readonly Point[]): Point[] => p.filter((q, i) => i === 0 || d
37
27
  * This function makes a router with a tree of sections. Each section has a hub: the free corner nearest to the center of its nodes.
38
28
  * An edge goes from its node up the hubs of its sections to the lowest common section, across, and down to the other node.
39
29
  * Each way from hub to hub is found one time, so all the edges on it share its points.
40
- * The edges in one section of the lowest level go straight around the nodes. The result gives one zoom level for each depth of the tree.
30
+ * The edges in one section of the lowest level go straight around the nodes.
31
+ *
32
+ * The result gives one zoom level for each depth of the tree.
33
+ * A link of a level goes from the boundary of one item to the boundary of the other, around the other items.
41
34
  */
42
35
  export function sectionsRouter(options: SectionsOptions): Router {
43
36
  const router: Router = {
@@ -98,8 +91,6 @@ export function sectionsRouter(options: SectionsOptions): Router {
98
91
  const at = (p: Point): string => `${p.x},${p.y}`;
99
92
 
100
93
  const routes: Route[] = [];
101
- // The index in the route of each hub that the route passes, by section.
102
- const marks = new Map<string, Map<Section, number>>();
103
94
  for (const e of graph.edges) {
104
95
  const s = byId.get(e.source.node);
105
96
  const t = byId.get(e.target.node);
@@ -122,18 +113,13 @@ export function sectionsRouter(options: SectionsOptions): Router {
122
113
  const side = (list: Section[]): Section[] => list.slice(Math.max(0, list.indexOf(lca) - keep), list.indexOf(lca));
123
114
  const chain = [...side(a), ...side(b).reverse()];
124
115
  const out: Point[] = [ps.point];
125
- const mark = new Map<Section, number>();
126
116
  const append = (p: readonly Point[]): void => {
127
117
  for (const q of p) if (dist(q, out[out.length - 1]!) > 1e-9) out.push(q);
128
118
  };
129
119
  const used: string[] = [];
130
120
  const start = ways.out(ps);
131
121
  append(piece(at(start), start, chain[0]!.id, hub(chain[0]!), used));
132
- mark.set(chain[0]!, out.length - 1);
133
- for (let i = 1; i < chain.length; i++) {
134
- append(piece(chain[i - 1]!.id, hub(chain[i - 1]!), chain[i]!.id, hub(chain[i]!), used));
135
- mark.set(chain[i]!, out.length - 1);
136
- }
122
+ for (let i = 1; i < chain.length; i++) append(piece(chain[i - 1]!.id, hub(chain[i - 1]!), chain[i]!.id, hub(chain[i]!), used));
137
123
  const end = ways.out(pt);
138
124
  append(piece(chain[chain.length - 1]!.id, hub(chain[chain.length - 1]!), at(end), end, used));
139
125
  append([pt.point]);
@@ -148,7 +134,6 @@ export function sectionsRouter(options: SectionsOptions): Router {
148
134
  else users.set(key, [e.id]);
149
135
  }
150
136
  routes.push({ edge: e.id, points: out });
151
- marks.set(e.id, mark);
152
137
  }
153
138
 
154
139
  const routeOf = new Map(routes.map((r) => [r.edge, r]));
@@ -158,7 +143,7 @@ export function sectionsRouter(options: SectionsOptions): Router {
158
143
  if (list.length < 2 || p.length < 2) continue;
159
144
  runs.push({ id: `r${runs.length}`, key, points: p, edges: orderLanes(p, list.map((id) => routeOf.get(id)!)) });
160
145
  }
161
- return { routes, runs, levels: makeLevels(graph, root, routeOf, marks) };
146
+ return { routes, runs, levels: makeLevels(graph, root, routeOf, options) };
162
147
  },
163
148
  };
164
149
  return options.maxNodes === undefined ? router : { ...router, maxNodes: options.maxNodes };
@@ -167,14 +152,10 @@ export function sectionsRouter(options: SectionsOptions): Router {
167
152
  /**
168
153
  * This function gives one zoom level for each depth of the tree, and a last level with each node alone.
169
154
  * At depth d, a section of depth d is one item. A link joins two items and counts the edges between them.
170
- * Its points are the part of the route of its first edge between the two items.
155
+ * In a level with groups, a link goes around the boxes of the other items (`levelLinks`).
156
+ * In the last level, the points of a link are the route of its first edge.
171
157
  */
172
- function makeLevels(
173
- graph: Graph,
174
- root: Section,
175
- routeOf: ReadonlyMap<string, Route>,
176
- marks: ReadonlyMap<string, ReadonlyMap<Section, number>>,
177
- ): Level[] {
158
+ function makeLevels(graph: Graph, root: Section, routeOf: ReadonlyMap<string, Route>, options: WayOptions): Level[] {
178
159
  const indexOfNode = new Map(graph.nodes.map((n, i) => [n.id, i]));
179
160
  const levels: Level[] = [];
180
161
  const deepest = treeDepth(root);
@@ -208,7 +189,7 @@ function makeLevels(
208
189
  }
209
190
  };
210
191
  if (graph.nodes.length > 0) visit(root);
211
- const links = new Map<string, { source: string; target: string; count: number; points: readonly Point[] }>();
192
+ const links = new Map<string, { source: string; target: string; count: number; edge: string }>();
212
193
  for (const e of graph.edges) {
213
194
  const iu = indexOfNode.get(e.source.node);
214
195
  const iv = indexOfNode.get(e.target.node);
@@ -224,13 +205,12 @@ function makeLevels(
224
205
  link.count++;
225
206
  continue;
226
207
  }
227
- const route = routeOf.get(e.id)?.points ?? [];
228
- const mark = marks.get(e.id);
229
- const from = typeof a === "number" ? 0 : (mark?.get(a) ?? 0);
230
- const to = typeof b === "number" ? route.length - 1 : (mark?.get(b) ?? route.length - 1);
231
- links.set(key, { source: ida, target: idb, count: 1, points: route.slice(from, to + 1) });
208
+ links.set(key, { source: ida, target: idb, count: 1, edge: e.id });
232
209
  }
233
- levels.push({ depth: d, items, links: [...links.values()] as LevelLink[] });
210
+ const pairs: ItemPair[] = [...links.values()].map(({ source, target, count }) => ({ source, target, count }));
211
+ if (d > deepest)
212
+ levels.push({ depth: d, items, links: [...links.values()].map(({ edge, ...l }) => ({ ...l, points: routeOf.get(edge)?.points ?? [] })) });
213
+ else levels.push({ depth: d, items, links: levelLinks(items, pairs, options) });
234
214
  }
235
215
  return levels;
236
216
  }
package/src/segments.ts CHANGED
@@ -148,6 +148,30 @@ export interface PullApartOptions {
148
148
  * The lanes keep the lane order, so they do not cross in a segment. The two ends of each route stay at their plugs.
149
149
  */
150
150
  export function pullApart(graph: SegmentGraph, options: PullApartOptions): Route[] {
151
+ return lanePaths(graph, (s) => (s.count > 1 ? Math.min(options.spacing, options.maxWidth / (s.count - 1)) : 0));
152
+ }
153
+
154
+ /** This function gives the key of a shared run: the ids of its edges, sorted and joined with commas. */
155
+ export function runKey(edges: readonly string[]): string {
156
+ return [...edges].sort().join(",");
157
+ }
158
+
159
+ /**
160
+ * This function opens the shared segments whose key is in `open`: each edge of an open segment gets its own lane.
161
+ * The lanes are `laneWidth` apart, and an open segment is at most `maxWidth` wide. The other segments stay as one way.
162
+ *
163
+ * Lane 0 is on the left of the segment, from its first point to its last, with the y axis down.
164
+ * The function takes a result or its segments. It gives a route for each edge, with the ends at their plugs.
165
+ * The key of a segment is `runKey` of its edges.
166
+ */
167
+ export function laneRoutes(source: Result | SegmentGraph, open: ReadonlySet<string>, laneWidth: number, maxWidth: number = Infinity): Route[] {
168
+ const graph = "paths" in source ? source : segmentsOf(source);
169
+ if (open.size === 0) return graph.paths.map((path) => ({ edge: path.edge, points: pathPoints(graph, path) }));
170
+ return lanePaths(graph, (s) => (s.count > 1 && open.has(runKey(s.edges)) ? Math.min(laneWidth, maxWidth / (s.count - 1)) : 0));
171
+ }
172
+
173
+ /** This function gives the route of each path, with the lanes of each segment at the spacing that `spacingOf` gives. */
174
+ function lanePaths(graph: SegmentGraph, spacingOf: (s: Segment) => number): Route[] {
151
175
  const byId = new Map(graph.segments.map((s) => [s.id, s]));
152
176
  const routes: Route[] = [];
153
177
  for (const path of graph.paths) {
@@ -156,9 +180,8 @@ export function pullApart(graph: SegmentGraph, options: PullApartOptions): Route
156
180
  for (const ref of path.segments) {
157
181
  const s = byId.get(ref.segment);
158
182
  if (!s) continue;
159
- const n = s.count;
160
- const spacing = n > 1 ? Math.min(options.spacing, options.maxWidth / (n - 1)) : 0;
161
- const moved = offsetPolyline(s.points, ((n - 1) / 2 - ref.lane) * spacing);
183
+ const spacing = spacingOf(s);
184
+ const moved = offsetPolyline(s.points, ((s.count - 1) / 2 - ref.lane) * spacing);
162
185
  for (const q of ref.reverse ? moved.reverse() : moved) if (out.length === 0 || dist(q, out[out.length - 1]!) > SAME) out.push(q);
163
186
  }
164
187
  if (out.length >= 2 && ends.length >= 2) {
package/src/visibility.ts CHANGED
@@ -81,6 +81,8 @@ export class CornerGraph {
81
81
  // The stamp of the last call of nearestVisible that tested each corner.
82
82
  #tested = new Int32Array(0);
83
83
  #testStamp = 0;
84
+ // The corners that each end of a path sees, by the coordinates of the end.
85
+ readonly #endCache = new Map<string, number[]>();
84
86
 
85
87
  /** This constructor makes the graph of the nodes. */
86
88
  constructor(nodes: readonly Node[], obstacles: Obstacles, options: CornerGraphOptions) {
@@ -167,6 +169,10 @@ export class CornerGraph {
167
169
 
168
170
  /** This method gives at most `k` corners that `p` can see, nearest first. The corner `self` does not count. */
169
171
  nearestVisible(p: Point, k: number, self = -1): number[] {
172
+ return this.#nearestVisible(p, k, self, this.#pad);
173
+ }
174
+
175
+ #nearestVisible(p: Point, k: number, self: number, pad: number): number[] {
170
176
  const out: number[] = [];
171
177
  if (this.#tested.length < this.points.length) this.#tested = new Int32Array(this.points.length);
172
178
  const tested = this.#tested;
@@ -181,7 +187,7 @@ export class CornerGraph {
181
187
  // A corner beyond r can be nearer than a corner of the next ring, so it waits for that ring.
182
188
  if (d > r) break;
183
189
  tested[j] = stamp;
184
- if (this.#obstacles.clear(p, this.points[j]!, this.#pad)) out.push(j);
190
+ if (this.#obstacles.clear(p, this.points[j]!, pad)) out.push(j);
185
191
  if (out.length >= k) return out;
186
192
  }
187
193
  }
@@ -205,7 +211,7 @@ export class CornerGraph {
205
211
  }
206
212
  const stamp = ++this.#stamp;
207
213
  const goal = new Map<number, number>();
208
- for (const j of this.nearestVisible(to, this.#k * 2)) goal.set(j, dist(this.points[j]!, to));
214
+ for (const j of this.#ends(to)) goal.set(j, dist(this.points[j]!, to));
209
215
  if (goal.size === 0) return undefined;
210
216
  const heap = new MinHeap();
211
217
  const h = (i: number): number => floor * dist(this.points[i]!, to);
@@ -217,7 +223,7 @@ export class CornerGraph {
217
223
  this.#parent[j] = parent;
218
224
  heap.push(g + h(j), j);
219
225
  };
220
- for (const j of this.nearestVisible(from, this.#k * 2)) relax(j, dist(from, this.points[j]!), -1);
226
+ for (const j of this.#ends(from)) relax(j, dist(from, this.points[j]!), -1);
221
227
  let best = Infinity;
222
228
  let bestEnd = -1;
223
229
  while (heap.size > 0) {
@@ -246,6 +252,22 @@ export class CornerGraph {
246
252
  return out.reverse();
247
253
  }
248
254
 
255
+ /**
256
+ * This method gives the corners that an end of a path can see. An end near a box sees no corner with the clearance.
257
+ * Then the end takes the corners that it sees without the clearance, so the path does not cut through the boxes.
258
+ * The graph does not change, so the method keeps the corners of each end.
259
+ */
260
+ #ends(p: Point): number[] {
261
+ const key = `${p.x},${p.y}`;
262
+ let out = this.#endCache.get(key);
263
+ if (!out) {
264
+ out = this.nearestVisible(p, this.#k * 2);
265
+ if (out.length === 0) out = this.#nearestVisible(p, this.#k * 2, -1, 0);
266
+ this.#endCache.set(key, out);
267
+ }
268
+ return out;
269
+ }
270
+
249
271
  /** This method removes the vertices that a later vertex makes needless: from each vertex, it goes to the farthest vertex in view. */
250
272
  pull(p: readonly Point[], lookahead = 16): Point[] {
251
273
  if (p.length < 3) return [...p];