@mark1russell7/highways 0.1.3 → 0.1.5

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,73 @@
1
+ import type { Point } from "../geom.ts";
2
+ import type { CurveSegment } from "./model.ts";
3
+
4
+ /** The largest number of points that `sampleCurve` gives for one segment. */
5
+ const MAX_STEPS = 1024;
6
+
7
+ /**
8
+ * This function gives a polyline that follows the curve. The distance from each part of the curve to the polyline is `tolerance` or less.
9
+ * The polyline starts at the point of the `move` and goes through the end of each segment.
10
+ * Hit tests, measures and renderers that draw only lines use this polyline.
11
+ */
12
+ export function sampleCurve(segments: readonly CurveSegment[], tolerance = 0.5): Point[] {
13
+ const tol = Math.max(1e-6, tolerance);
14
+ const out: Point[] = [];
15
+ let at: Point | undefined;
16
+ for (const s of segments) {
17
+ if (s.kind === "move" || at === undefined) {
18
+ out.push(s.to);
19
+ at = s.to;
20
+ continue;
21
+ }
22
+ if (s.kind === "cubic") {
23
+ // The chord of a step of 1/n of the parameter is within 3·D/(4·n²) of the curve. D is the largest second difference of the control points.
24
+ const d = Math.max(
25
+ Math.hypot(at.x - 2 * s.c1.x + s.c2.x, at.y - 2 * s.c1.y + s.c2.y),
26
+ Math.hypot(s.c1.x - 2 * s.c2.x + s.to.x, s.c1.y - 2 * s.c2.y + s.to.y),
27
+ );
28
+ const n = Math.min(MAX_STEPS, Math.max(1, Math.ceil(Math.sqrt((3 * d) / (4 * tol)))));
29
+ for (let k = 1; k < n; k++) out.push(cubicAt(at, s.c1, s.c2, s.to, k / n));
30
+ } else if (s.kind === "arc") {
31
+ // The chord of an angle φ is within r·(1 - cos(φ/2)) of the arc.
32
+ const step = s.radius <= tol ? Math.PI : 2 * Math.acos(1 - tol / s.radius);
33
+ const n = Math.min(MAX_STEPS, Math.max(1, Math.ceil(Math.abs(s.sweep) / step)));
34
+ for (let k = 1; k < n; k++) {
35
+ const a = s.start + (s.sweep * k) / n;
36
+ out.push({ x: s.center.x + s.radius * Math.cos(a), y: s.center.y + s.radius * Math.sin(a) });
37
+ }
38
+ }
39
+ out.push(s.to);
40
+ at = s.to;
41
+ }
42
+ return out;
43
+ }
44
+
45
+ /** This function gives the point at the parameter `t` of the cubic Bezier curve with the control points `p0`, `p1`, `p2` and `p3`. */
46
+ export function cubicAt(p0: Point, p1: Point, p2: Point, p3: Point, t: number): Point {
47
+ const u = 1 - t;
48
+ const a = u * u * u;
49
+ const b = 3 * u * u * t;
50
+ const c = 3 * u * t * t;
51
+ const d = t * t * t;
52
+ return { x: a * p0.x + b * p1.x + c * p2.x + d * p3.x, y: a * p0.y + b * p1.y + c * p2.y + d * p3.y };
53
+ }
54
+
55
+ /**
56
+ * This function writes the segments as the data of an SVG path: the commands `M`, `L`, `C` and `A`.
57
+ * Each number has at most `digits` digits after the point.
58
+ */
59
+ export function toSvgPath(segments: readonly CurveSegment[], digits = 2): string {
60
+ const n = (v: number): string => {
61
+ const s = String(Number(v.toFixed(digits)));
62
+ return s === "-0" ? "0" : s;
63
+ };
64
+ const p = (q: Point): string => `${n(q.x)} ${n(q.y)}`;
65
+ const parts: string[] = [];
66
+ for (const s of segments) {
67
+ if (s.kind === "move") parts.push(`M ${p(s.to)}`);
68
+ else if (s.kind === "line") parts.push(`L ${p(s.to)}`);
69
+ else if (s.kind === "cubic") parts.push(`C ${p(s.c1)} ${p(s.c2)} ${p(s.to)}`);
70
+ else parts.push(`A ${n(s.radius)} ${n(s.radius)} 0 ${Math.abs(s.sweep) > Math.PI ? 1 : 0} ${s.sweep > 0 ? 1 : 0} ${p(s.to)}`);
71
+ }
72
+ return parts.join(" ");
73
+ }
@@ -0,0 +1,91 @@
1
+ import { TOLERANCE } from "../bench/properties.ts";
2
+ import { inflate, segmentHitsBox, type Box, type Point } from "../geom.ts";
3
+ import { Grid } from "../grid.ts";
4
+ import type { CurveSegment } from "./model.ts";
5
+ import { sampleCurve } from "./sample.ts";
6
+
7
+ /**
8
+ * The free space of a board: the boxes that a curve must not go through.
9
+ * Make it one time for a board with `curveSpace`, and give it to each call of `curveOf`.
10
+ */
11
+ export interface CurveSpace {
12
+ /** The boxes of the board. */
13
+ readonly boxes: readonly Box[];
14
+ /** The tolerance of the samples of a curve, in units of the plane. */
15
+ readonly tolerance: number;
16
+ /**
17
+ * This method tells if the polyline `q` goes through a box that the polyline `p` does not go through.
18
+ * A polyline goes through a box when a part of it longer than `TOLERANCE` is inside the box made smaller by `TOLERANCE`.
19
+ */
20
+ addsHit(q: readonly Point[], p: readonly Point[]): boolean;
21
+ }
22
+
23
+ /**
24
+ * This function makes the free space of a board from its boxes. The boxes are the nodes, or the items of a zoom level.
25
+ * The curves sample each piece within `tolerance` of the curve for the test.
26
+ */
27
+ export function curveSpace(boxes: readonly Box[], tolerance = 0.25): CurveSpace {
28
+ const inner = boxes.map((b) => inflate(b, -TOLERANCE));
29
+ let size = 0;
30
+ for (const b of boxes) size += Math.max(b.w, b.h);
31
+ // The buckets grow with the boxes, as the buckets of `measureLinks` do.
32
+ const grid = new Grid<number>(Math.max(180, (2 * size) / Math.max(1, boxes.length)));
33
+ inner.forEach((b, i) => grid.add(i, b));
34
+ const hits = (p: readonly Point[]): Set<number> => {
35
+ const out = new Set<number>();
36
+ for (let i = 1; i < p.length; i++) {
37
+ const a = p[i - 1]!;
38
+ const b = p[i]!;
39
+ grid.querySegment(a, b, 0, (id) => {
40
+ if (!out.has(id) && segmentHitsBox(a, b, inner[id]!, TOLERANCE)) out.add(id);
41
+ });
42
+ }
43
+ return out;
44
+ };
45
+ return {
46
+ boxes,
47
+ tolerance,
48
+ addsHit(q: readonly Point[], p: readonly Point[]): boolean {
49
+ let before: Set<number> | undefined;
50
+ for (let i = 1; i < q.length; i++) {
51
+ const a = q[i - 1]!;
52
+ const b = q[i]!;
53
+ const hit = grid.someSegment(a, b, 0, (id) => {
54
+ if (!segmentHitsBox(a, b, inner[id]!, TOLERANCE)) return false;
55
+ before ??= hits(p);
56
+ return !before.has(id);
57
+ });
58
+ if (hit) return true;
59
+ }
60
+ return false;
61
+ },
62
+ };
63
+ }
64
+
65
+ /**
66
+ * This function finds the largest size from `max` down to 0 that keeps a piece of a curve clear.
67
+ * A clear piece goes through no box that its polyline avoids.
68
+ *
69
+ * The function `piece` gives the segments of the piece at a size. They start with a `move`.
70
+ * The polyline `chord` is the part of the original polyline that the piece replaces.
71
+ * The function tries `max` first, then it halves the interval `steps` times. The result is 0 when no size of the search is clear.
72
+ */
73
+ export function fitSize(
74
+ space: CurveSpace | undefined,
75
+ max: number,
76
+ chord: (size: number) => readonly Point[],
77
+ piece: (size: number) => readonly CurveSegment[],
78
+ steps = 8,
79
+ ): number {
80
+ if (space === undefined || max <= 0) return Math.max(0, max);
81
+ const clear = (size: number): boolean => !space.addsHit(sampleCurve(piece(size), space.tolerance), chord(size));
82
+ if (clear(max)) return max;
83
+ let lo = 0;
84
+ let hi = max;
85
+ for (let i = 0; i < steps; i++) {
86
+ const mid = (lo + hi) / 2;
87
+ if (clear(mid)) lo = mid;
88
+ else hi = mid;
89
+ }
90
+ return lo;
91
+ }
@@ -0,0 +1,77 @@
1
+ import { dist, type Point } from "../../geom.ts";
2
+ import { distinctPoints, type CurveSegment } from "../model.ts";
3
+ import { fitSize, type CurveSpace } from "../space.ts";
4
+
5
+ /** The options of `splineCurve`. */
6
+ export interface SplineOptions {
7
+ /**
8
+ * The tension of the spline, from 0 to 1. The value 0 gives the centripetal Catmull-Rom spline, and 1 gives the polyline.
9
+ * The default is 0.
10
+ */
11
+ readonly tension?: number;
12
+ /** The outward normal of the plug at the first point. With it, the curve leaves the first point along this normal. */
13
+ readonly startNormal?: Point;
14
+ /** The outward normal of the plug at the last point. With it, the curve comes into the last point against this normal. */
15
+ readonly endNormal?: Point;
16
+ /** The boxes that the curve must not go through. Without it, each piece gets the full tangents. */
17
+ readonly space?: CurveSpace;
18
+ }
19
+
20
+ /**
21
+ * This function draws a centripetal Catmull-Rom spline through the points of a polyline (Yuksel, Schaefer and Keyser, 2011).
22
+ * Each piece between two points is one cubic Bezier curve. The centripetal knots keep the curve free of cusps and loops in a piece.
23
+ * The curve goes through each point. Its tangent at a point keeps one direction on the two sides of the point.
24
+ *
25
+ * With `space`, the function makes the tangents of a piece shorter until the piece goes through no box that its leg avoids.
26
+ * A piece with no tangent is the straight leg.
27
+ */
28
+ export function splineCurve(points: readonly Point[], options: SplineOptions = {}): CurveSegment[] {
29
+ const p = distinctPoints(points);
30
+ if (p.length === 0) return [];
31
+ const out: CurveSegment[] = [{ kind: "move", to: p[0]! }];
32
+ const n = p.length;
33
+ if (n === 1) return out;
34
+ const scale = 1 - Math.max(0, Math.min(1, options.tension ?? 0));
35
+ // The knot interval of each leg is the square root of its length.
36
+ const dt = Array.from({ length: n - 1 }, (_, i) => Math.sqrt(dist(p[i]!, p[i + 1]!)));
37
+ const unit = (q: Point): Point => {
38
+ const l = Math.hypot(q.x, q.y);
39
+ return l === 0 ? { x: 0, y: 0 } : { x: q.x / l, y: q.y / l };
40
+ };
41
+ // The tangent at each point, as a speed in the parameter of the knots.
42
+ const m: Point[] = p.map((v, i) => {
43
+ if (i === 0) {
44
+ const leg = { x: p[1]!.x - v.x, y: p[1]!.y - v.y };
45
+ if (!options.startNormal) return { x: leg.x / dt[0]!, y: leg.y / dt[0]! };
46
+ const u = unit(options.startNormal);
47
+ return { x: u.x * dt[0]!, y: u.y * dt[0]! };
48
+ }
49
+ if (i === n - 1) {
50
+ const a = p[i - 1]!;
51
+ const leg = { x: v.x - a.x, y: v.y - a.y };
52
+ if (!options.endNormal) return { x: leg.x / dt[i - 1]!, y: leg.y / dt[i - 1]! };
53
+ const u = unit(options.endNormal);
54
+ return { x: -u.x * dt[i - 1]!, y: -u.y * dt[i - 1]! };
55
+ }
56
+ const a = p[i - 1]!;
57
+ const b = p[i + 1]!;
58
+ const t0 = dt[i - 1]!;
59
+ const t1 = dt[i]!;
60
+ return {
61
+ x: (v.x - a.x) / t0 - (b.x - a.x) / (t0 + t1) + (b.x - v.x) / t1,
62
+ y: (v.y - a.y) / t0 - (b.y - a.y) / (t0 + t1) + (b.y - v.y) / t1,
63
+ };
64
+ });
65
+ for (let i = 0; i + 1 < n; i++) {
66
+ const a = p[i]!;
67
+ const b = p[i + 1]!;
68
+ const h = dt[i]! / 3;
69
+ const piece = (s: number): CurveSegment[] => [
70
+ { kind: "move", to: a },
71
+ { kind: "cubic", c1: { x: a.x + m[i]!.x * h * s, y: a.y + m[i]!.y * h * s }, c2: { x: b.x - m[i + 1]!.x * h * s, y: b.y - m[i + 1]!.y * h * s }, to: b },
72
+ ];
73
+ const s = fitSize(options.space, scale, () => [a, b], piece);
74
+ out.push(s > 0 ? piece(s)[1]! : { kind: "line", to: b });
75
+ }
76
+ return out;
77
+ }
@@ -0,0 +1,7 @@
1
+ import type { Point } from "../../geom.ts";
2
+ import type { CurveSegment } from "../model.ts";
3
+
4
+ /** This function gives the polyline as it is: one `move` to the first point and one `line` to each other point. */
5
+ export function straightCurve(points: readonly Point[]): CurveSegment[] {
6
+ return points.map((to, i): CurveSegment => (i === 0 ? { kind: "move", to } : { kind: "line", to }));
7
+ }
package/src/index.ts CHANGED
@@ -5,12 +5,18 @@ export * from "./fixtures.ts";
5
5
  export * from "./cases.ts";
6
6
  export * from "./plugs.ts";
7
7
  export * from "./curves.ts";
8
+ export * from "./curves/index.ts";
8
9
  export * from "./visibility.ts";
9
10
  export * from "./ways.ts";
10
11
  export * from "./segments.ts";
11
12
  export * from "./sections.ts";
12
13
  export * from "./routers/index.ts";
14
+ export * from "./room/index.ts";
15
+ export * from "./room/lanes.ts";
13
16
  export * from "./bench/properties.ts";
14
17
  export * from "./bench/quality.ts";
15
18
  export * from "./bench/links.ts";
16
19
  export * from "./bench/run.ts";
20
+ export * from "./bench/curves.ts";
21
+ export * from "./bench/room.ts";
22
+ export * from "./strategies/index.ts";
package/src/levels.ts CHANGED
@@ -99,6 +99,7 @@ function sideWay(ways: LevelWays, s: Node, t: Node, inset: number): Point[] {
99
99
  /**
100
100
  * This function makes the obstacles and the corner graph of the items of a level.
101
101
  * The buckets of the obstacles grow with the items, so a long link visits few buckets.
102
+ * The corner graphs use `cones`, so a link goes along the corridors between the groups, not around the outer edge of the board.
102
103
  */
103
104
  function levelWays(nodes: readonly Node[], options: WayOptions): LevelWays {
104
105
  const margin = options.margin ?? 5;
@@ -106,10 +107,10 @@ function levelWays(nodes: readonly Node[], options: WayOptions): LevelWays {
106
107
  let size = 0;
107
108
  for (const n of nodes) size += Math.max(n.box.w, n.box.h);
108
109
  const obstacles = new Obstacles(nodes, Math.max(180, (2 * size) / Math.max(1, nodes.length)));
109
- const corners = new CornerGraph(nodes, obstacles, { margin, k });
110
+ const corners = new CornerGraph(nodes, obstacles, { margin, k, cones: true });
110
111
  const out = (p: Plug): Point => ({ x: p.point.x + p.normal.x * margin, y: p.point.y + p.normal.y * margin });
111
112
  let tight: CornerGraph | undefined;
112
- const tightGraph = (): CornerGraph => (tight ??= new CornerGraph(nodes, obstacles, { margin: Math.min(margin, TIGHT_MARGIN), k }));
113
+ const tightGraph = (): CornerGraph => (tight ??= new CornerGraph(nodes, obstacles, { margin: Math.min(margin, TIGHT_MARGIN), k, cones: true }));
113
114
  return { obstacles, corners, margin, out, tight: tightGraph, free: new Map() };
114
115
  }
115
116
 
@@ -0,0 +1,354 @@
1
+ import { center, type Box, type Point } from "../geom.ts";
2
+ import { levelLinks } from "../levels.ts";
3
+ import type { Edge, Graph, Router } from "../model.ts";
4
+
5
+ /** An item with a box: a node of a graph, an item of a level or a box of a host layout. */
6
+ export interface Boxed {
7
+ readonly id: string;
8
+ readonly box: Box;
9
+ }
10
+
11
+ /** A link between two boxes. For a bundled link, `count` is the number of its edges. The default count is 1. */
12
+ export interface RoomLink {
13
+ readonly source: string;
14
+ readonly target: string;
15
+ readonly count?: number;
16
+ }
17
+
18
+ /**
19
+ * A gap of a board: a boundary of the sweep over the left or top edges of the boxes.
20
+ * On the axis `x`, the gap is a vertical channel between two columns. On the axis `y`, it is a horizontal channel between two rows.
21
+ */
22
+ export interface Gap {
23
+ readonly axis: "x" | "y";
24
+ /** The place of the gap in the order of the gaps on its axis, from 0. */
25
+ readonly index: number;
26
+ /** The coordinate where the boxes after the gap start, on the axis of the gap. */
27
+ readonly at: number;
28
+ /** The ids of the boxes that end at the near side of the gap. */
29
+ readonly before: readonly string[];
30
+ /** The ids of the boxes that start at the far side of the gap, at `at`. */
31
+ readonly after: readonly string[];
32
+ /** The current width of the gap: the distance from the nearest box edge before the gap to `at`. */
33
+ readonly width: number;
34
+ /** The number of lanes that the links need in the gap. */
35
+ readonly lanes: number;
36
+ /** The width that the gap needs: at least the current width, and at least `gutter + lanes × spacing`. */
37
+ readonly need: number;
38
+ }
39
+
40
+ /** The ways that `roomDemand` finds the lanes of each gap. */
41
+ export type RoomMethod = "routes" | "lines";
42
+
43
+ /** The options of `roomDemand` and `makeRoom`. */
44
+ export interface RoomOptions {
45
+ /** The smallest width of each gap. The default is 8. */
46
+ readonly gutter?: number;
47
+ /** The width of one lane: the distance between two lines that run side by side. The default is 4. */
48
+ readonly spacing?: number;
49
+ /**
50
+ * The largest growth of the board on each axis, as a share of its size. The default is `Infinity`.
51
+ * When the lanes need more, each gap gets its gutter, and the lanes share the rest in proportion.
52
+ */
53
+ readonly maxGrowth?: number;
54
+ /**
55
+ * The way to find the lanes. The default is `routes`.
56
+ * With `routes`, the function routes the links one time on the board with each gap open to the gutter.
57
+ * The lanes of a gap are the most routes that run along the gap side by side.
58
+ * With `lines`, the lanes of a gap are the straight lines between the centers of the boxes that cross the gap.
59
+ */
60
+ readonly method?: RoomMethod;
61
+ /** The router of the method `routes`. Without it, the links go as the links of a zoom level (`levelLinks`), with the margin `gutter / 2`. */
62
+ readonly router?: Router;
63
+ /** With `count`, a link takes one lane for each of its edges. With `link`, the default, a link takes one lane. */
64
+ readonly lanes?: "link" | "count";
65
+ /** Two box edges nearer than this distance make one boundary of the sweep. The default is 0.5. */
66
+ readonly snap?: number;
67
+ /**
68
+ * The number of passes of the demand. The default is 1.
69
+ * Each pass after the first finds the lanes again on the board that the passes before it opened, and adds the growth that it needs.
70
+ * The limit `maxGrowth` applies to the sum of the passes.
71
+ */
72
+ readonly passes?: number;
73
+ }
74
+
75
+ /** The demand of a board and the board with its gaps open. */
76
+ export interface Room<T extends Boxed> {
77
+ readonly gaps: readonly Gap[];
78
+ readonly items: T[];
79
+ }
80
+
81
+ const DEFAULT_GUTTER = 8;
82
+ const DEFAULT_SPACING = 4;
83
+ const DEFAULT_SNAP = 0.5;
84
+
85
+ type Axis = "x" | "y";
86
+
87
+ const lo = (b: Box, a: Axis): number => (a === "x" ? b.x : b.y);
88
+ const len = (b: Box, a: Axis): number => (a === "x" ? b.w : b.h);
89
+ const other = (a: Axis): Axis => (a === "x" ? "y" : "x");
90
+
91
+ /**
92
+ * This function gives the gaps of a board. A sweep over each axis sorts the left (or top) edges of the boxes.
93
+ * Each distinct edge after the first is a gap. The `lanes` of each gap are 0, and its `need` is its current width.
94
+ */
95
+ export function boardGaps(items: readonly Boxed[], snap: number = DEFAULT_SNAP): Gap[] {
96
+ return [...axisGaps(items, "x", snap), ...axisGaps(items, "y", snap)];
97
+ }
98
+
99
+ function axisGaps(items: readonly Boxed[], axis: Axis, snap: number): Gap[] {
100
+ const starts = [...new Set(items.map((i) => lo(i.box, axis)))].sort((u, v) => u - v);
101
+ const bounds: number[] = [];
102
+ for (const s of starts) if (bounds.length === 0 || s - bounds[bounds.length - 1]! > snap) bounds.push(s);
103
+ const after: string[][] = bounds.map(() => []);
104
+ for (const i of items) after[lastAtOrBefore(bounds, lo(i.box, axis) + snap)]!.push(i.id);
105
+ const ends = items.map((i) => ({ id: i.id, end: lo(i.box, axis) + len(i.box, axis) })).sort((u, v) => u.end - v.end);
106
+ const gaps: Gap[] = [];
107
+ let k = 0;
108
+ for (let n = 1; n < bounds.length; n++) {
109
+ const at = bounds[n]!;
110
+ // The nearest box edge before the gap is the largest end at or before the boundary.
111
+ while (k < ends.length && ends[k]!.end <= at + snap) k++;
112
+ const near = k > 0 ? ends[k - 1]!.end : at;
113
+ const before: string[] = [];
114
+ for (let j = k - 1; j >= 0 && ends[j]!.end >= near - snap; j--) before.push(ends[j]!.id);
115
+ gaps.push({ axis, index: n - 1, at, before: before.reverse(), after: after[n]!, width: Math.max(0, at - near), lanes: 0, need: Math.max(0, at - near) });
116
+ }
117
+ return gaps;
118
+ }
119
+
120
+ /** This function gives the index of the last value of the sorted list `v` that is at or before `x`, or 0. */
121
+ function lastAtOrBefore(v: readonly number[], x: number): number {
122
+ let a = 0;
123
+ let b = v.length - 1;
124
+ while (a < b) {
125
+ const m = (a + b + 1) >> 1;
126
+ if (v[m]! <= x) a = m;
127
+ else b = m - 1;
128
+ }
129
+ return a;
130
+ }
131
+
132
+ /**
133
+ * This function moves the boxes apart, so that each gap gets the width `need`. The boxes keep their size, their order and their alignment.
134
+ * A box moves by the sum of the growth of each gap at or before its left (or top) edge. Thus a grid stays a grid.
135
+ * The function moves no box nearer to another box, so it makes no overlap.
136
+ */
137
+ export function openGaps<T extends Boxed>(items: readonly T[], gaps: readonly Gap[], snap: number = DEFAULT_SNAP): T[] {
138
+ const shift = (axis: Axis): ((v: number) => number) => {
139
+ const list = gaps.filter((g) => g.axis === axis).sort((u, v) => u.at - v.at);
140
+ const at = list.map((g) => g.at);
141
+ const sum: number[] = [];
142
+ let s = 0;
143
+ for (const g of list) sum.push((s += Math.max(0, g.need - g.width)));
144
+ return (v) => (list.length === 0 || at[0]! > v + snap ? 0 : sum[lastAtOrBefore(at, v + snap)]!);
145
+ };
146
+ const sx = shift("x");
147
+ const sy = shift("y");
148
+ return items.map((i) => ({ ...i, box: { ...i.box, x: i.box.x + sx(i.box.x), y: i.box.y + sy(i.box.y) } }));
149
+ }
150
+
151
+ /**
152
+ * This function gives the room that the links need in each gap of the board.
153
+ * The lanes of a gap come from the `method` of the options. The need of a gap is `gutter + lanes × spacing`, and not less than its current width.
154
+ */
155
+ export function roomDemand(items: readonly Boxed[], links: readonly RoomLink[], options: RoomOptions = {}): Gap[] {
156
+ const gutter = options.gutter ?? DEFAULT_GUTTER;
157
+ const snap = options.snap ?? DEFAULT_SNAP;
158
+ const passes = Math.max(1, Math.floor(options.passes ?? 1));
159
+ let total = demandOnce(items, links, options);
160
+ for (let p = 1; p < passes; p++) {
161
+ // The next pass measures the board that the passes before it opened. Its gaps are the same gaps, in the same order.
162
+ const next = demandOnce(openGaps(items, total, snap), links, options);
163
+ if (next.length !== total.length || next.some((g, i) => g.axis !== total[i]!.axis)) break;
164
+ total = total.map((g, i) => ({ ...g, lanes: Math.max(g.lanes, next[i]!.lanes), need: g.need + Math.max(0, next[i]!.need - next[i]!.width) }));
165
+ }
166
+ return capGrowth(items, total, gutter, options.maxGrowth ?? Infinity);
167
+ }
168
+
169
+ /** This function gives the demand of one pass, without the limit `maxGrowth`. */
170
+ function demandOnce(items: readonly Boxed[], links: readonly RoomLink[], options: RoomOptions): Gap[] {
171
+ const gutter = options.gutter ?? DEFAULT_GUTTER;
172
+ const spacing = options.spacing ?? DEFAULT_SPACING;
173
+ const snap = options.snap ?? DEFAULT_SNAP;
174
+ const weight = (l: RoomLink): number => (options.lanes === "count" ? Math.max(1, l.count ?? 1) : 1);
175
+ const gaps = boardGaps(items, snap);
176
+ const lanes = (options.method ?? "routes") === "lines" ? lineLanes(items, links, gaps, weight) : routeLanes(items, links, gaps, gutter, snap, weight, options.router);
177
+ return gaps.map((g, i) => ({ ...g, lanes: lanes[i]!, need: Math.max(g.width, gutter + lanes[i]! * spacing) }));
178
+ }
179
+
180
+ /** This function gives the demand of the board and the board with each gap open to its need. */
181
+ export function makeRoom<T extends Boxed>(items: readonly T[], links: readonly RoomLink[], options: RoomOptions = {}): Room<T> {
182
+ const gaps = roomDemand(items, links, options);
183
+ return { gaps, items: openGaps(items, gaps, options.snap ?? DEFAULT_SNAP) };
184
+ }
185
+
186
+ /** This function gives the links between the nodes of a graph, with the count of the edges of each pair. A loop does not count. */
187
+ export function roomLinks(graph: Graph): RoomLink[] {
188
+ const byPair = new Map<string, { source: string; target: string; count: number }>();
189
+ for (const e of graph.edges) {
190
+ const [s, t] = [e.source.node, e.target.node];
191
+ if (s === t) continue;
192
+ const key = s < t ? `${s}\u0000${t}` : `${t}\u0000${s}`;
193
+ const known = byPair.get(key);
194
+ if (known) known.count++;
195
+ else byPair.set(key, { source: s, target: t, count: 1 });
196
+ }
197
+ return [...byPair.values()];
198
+ }
199
+
200
+ /** This function counts, for each gap, the straight lines between the centers of the boxes of a link that cross the gap. */
201
+ function lineLanes(items: readonly Boxed[], links: readonly RoomLink[], gaps: readonly Gap[], weight: (l: RoomLink) => number): number[] {
202
+ const byId = new Map(items.map((i) => [i.id, center(i.box)]));
203
+ return gaps.map((g) => {
204
+ let n = 0;
205
+ for (const l of links) {
206
+ const a = byId.get(l.source);
207
+ const b = byId.get(l.target);
208
+ if (a && b && Math.min(a[g.axis], b[g.axis]) < g.at && Math.max(a[g.axis], b[g.axis]) > g.at) n += weight(l);
209
+ }
210
+ return n;
211
+ });
212
+ }
213
+
214
+ /**
215
+ * This function routes the links one time on the board with each gap open to the gutter.
216
+ * The lanes of a gap are the largest number of routes that run along the gap at one place.
217
+ * A piece of a route goes along a gap when it is in the channel of the gap.
218
+ * It must also go farther along the gap than across it.
219
+ */
220
+ function routeLanes(
221
+ items: readonly Boxed[],
222
+ links: readonly RoomLink[],
223
+ gaps: readonly Gap[],
224
+ gutter: number,
225
+ snap: number,
226
+ weight: (l: RoomLink) => number,
227
+ router: Router | undefined,
228
+ ): number[] {
229
+ const probeGaps = gaps.map((g) => ({ ...g, need: Math.max(g.width, gutter) }));
230
+ const probe = openGaps(items, probeGaps, snap);
231
+ const ways = probeWays(probe, links, gutter, router);
232
+ return probeGaps.map((g) => {
233
+ // The channel of the gap on the probe board: its far side moves with the growth of each gap up to this one.
234
+ let grow = 0;
235
+ for (const h of probeGaps) if (h.axis === g.axis && h.at <= g.at + snap) grow += h.need - h.width;
236
+ const hi = g.at + grow;
237
+ const lo = hi - g.need;
238
+ const spans: { from: number; to: number; w: number }[] = [];
239
+ ways.forEach((points, i) => {
240
+ const along = alongSpans(points, g.axis, lo, hi);
241
+ for (const [from, to] of merge(along)) spans.push({ from, to, w: weight(links[i]!) });
242
+ });
243
+ return density(spans);
244
+ });
245
+ }
246
+
247
+ /** This function gives the points of the way of each link on the probe board, in the order of the links. */
248
+ function probeWays(items: readonly Boxed[], links: readonly RoomLink[], gutter: number, router: Router | undefined): (readonly Point[])[] {
249
+ if (router) {
250
+ const edges: Edge[] = links.map((l, i) => ({ id: `l${i}`, source: { node: l.source }, target: { node: l.target } }));
251
+ const routes = new Map(router.route({ nodes: items, edges }).routes.map((r) => [r.edge, r.points]));
252
+ return edges.map((e) => routes.get(e.id) ?? []);
253
+ }
254
+ const level = items.map((i) => ({ id: i.id, box: i.box, nodes: [i.id] }));
255
+ const pairs = links.map((l) => ({ source: l.source, target: l.target, count: l.count ?? 1 }));
256
+ // The margin puts the corners of the two sides of a gap on its middle line.
257
+ const out = levelLinks(level, pairs, { margin: Math.max(1, gutter / 2) });
258
+ const byPair = new Map(out.map((l) => [`${l.source}\u0000${l.target}`, l.points]));
259
+ return links.map((l) => byPair.get(`${l.source}\u0000${l.target}`) ?? []);
260
+ }
261
+
262
+ /**
263
+ * This function gives the spans along the axis of a gap where the polyline goes along the channel from `lo` to `hi`.
264
+ * The axis `x` gap is a vertical channel, so its spans are on the y axis.
265
+ */
266
+ function alongSpans(p: readonly Point[], axis: Axis, lo: number, hi: number): [number, number][] {
267
+ const across = axis;
268
+ const along = other(axis);
269
+ const out: [number, number][] = [];
270
+ for (let i = 1; i < p.length; i++) {
271
+ const a = p[i - 1]!;
272
+ const b = p[i]!;
273
+ const da = b[across] - a[across];
274
+ // The part of the segment inside the channel, as the parameters t0 and t1 of the segment.
275
+ let t0 = 0;
276
+ let t1 = 1;
277
+ if (Math.abs(da) < 1e-9) {
278
+ if (a[across] < lo - 1e-6 || a[across] > hi + 1e-6) continue;
279
+ } else {
280
+ const u = (lo - a[across]) / da;
281
+ const v = (hi - a[across]) / da;
282
+ t0 = Math.max(0, Math.min(u, v));
283
+ t1 = Math.min(1, Math.max(u, v));
284
+ if (t1 <= t0) continue;
285
+ }
286
+ const s = a[along] + (b[along] - a[along]) * t0;
287
+ const e = a[along] + (b[along] - a[along]) * t1;
288
+ const run = Math.abs(e - s);
289
+ const cross = Math.abs(da) * (t1 - t0);
290
+ if (run > cross && run > 1e-6) out.push([Math.min(s, e), Math.max(s, e)]);
291
+ }
292
+ return out;
293
+ }
294
+
295
+ /** This function joins the spans that overlap, so that one link counts one time at each place. */
296
+ function merge(spans: [number, number][]): [number, number][] {
297
+ spans.sort((u, v) => u[0] - v[0]);
298
+ const out: [number, number][] = [];
299
+ for (const s of spans) {
300
+ const last = out[out.length - 1];
301
+ if (last && s[0] <= last[1]) last[1] = Math.max(last[1], s[1]);
302
+ else out.push([s[0], s[1]]);
303
+ }
304
+ return out;
305
+ }
306
+
307
+ /** This function gives the largest sum of the weights of the spans that overlap at one place. Spans that only touch do not overlap. */
308
+ function density(spans: readonly { from: number; to: number; w: number }[]): number {
309
+ const events: [number, number][] = [];
310
+ for (const s of spans) events.push([s.from, s.w], [s.to, -s.w]);
311
+ // At one coordinate, the ends come before the starts.
312
+ events.sort((u, v) => u[0] - v[0] || u[1] - v[1]);
313
+ let n = 0;
314
+ let best = 0;
315
+ for (const [, w] of events) {
316
+ n += w;
317
+ best = Math.max(best, n);
318
+ }
319
+ return best;
320
+ }
321
+
322
+ /**
323
+ * This function keeps the growth of the board on each axis at most `maxGrowth` times its size.
324
+ * Each gap keeps its gutter. The lanes of all gaps of the axis share the rest of the growth in proportion.
325
+ */
326
+ function capGrowth(items: readonly Boxed[], gaps: Gap[], gutter: number, maxGrowth: number): Gap[] {
327
+ if (!Number.isFinite(maxGrowth)) return gaps;
328
+ const out = [...gaps];
329
+ for (const axis of ["x", "y"] as const) {
330
+ let x0 = Infinity;
331
+ let x1 = -Infinity;
332
+ for (const i of items) {
333
+ x0 = Math.min(x0, lo(i.box, axis));
334
+ x1 = Math.max(x1, lo(i.box, axis) + len(i.box, axis));
335
+ }
336
+ const budget = Math.max(0, maxGrowth) * Math.max(0, x1 - x0);
337
+ let base = 0;
338
+ let spare = 0;
339
+ for (const g of gaps) {
340
+ if (g.axis !== axis) continue;
341
+ const b = Math.max(0, gutter - g.width);
342
+ base += b;
343
+ spare += Math.max(0, g.need - g.width - b);
344
+ }
345
+ if (base + spare <= budget || spare === 0) continue;
346
+ const f = Math.max(0, (budget - base) / spare);
347
+ gaps.forEach((g, i) => {
348
+ if (g.axis !== axis) return;
349
+ const b = Math.max(0, gutter - g.width);
350
+ out[i] = { ...g, need: g.width + b + f * Math.max(0, g.need - g.width - b) };
351
+ });
352
+ }
353
+ return out;
354
+ }