@mark1russell7/highways 0.1.3 → 0.1.4
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/README.md +109 -0
- package/build/index.d.ts +321 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1506 -580
- package/build/index.js.map +1 -1
- package/package.json +1 -1
- package/src/bench/curves.ts +98 -0
- package/src/bench/room.ts +122 -0
- package/src/cases.ts +28 -0
- package/src/curves/arc/index.ts +71 -0
- package/src/curves/index.ts +48 -0
- package/src/curves/model.ts +64 -0
- package/src/curves/rounded/index.ts +60 -0
- package/src/curves/sample.ts +73 -0
- package/src/curves/space.ts +91 -0
- package/src/curves/spline/index.ts +77 -0
- package/src/curves/straight/index.ts +7 -0
- package/src/index.ts +4 -0
- package/src/levels.ts +3 -2
- package/src/room/index.ts +334 -0
- package/src/visibility.ts +28 -3
|
@@ -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,16 @@ 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";
|
|
13
15
|
export * from "./bench/properties.ts";
|
|
14
16
|
export * from "./bench/quality.ts";
|
|
15
17
|
export * from "./bench/links.ts";
|
|
16
18
|
export * from "./bench/run.ts";
|
|
19
|
+
export * from "./bench/curves.ts";
|
|
20
|
+
export * from "./bench/room.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,334 @@
|
|
|
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
|
+
|
|
69
|
+
/** The demand of a board and the board with its gaps open. */
|
|
70
|
+
export interface Room<T extends Boxed> {
|
|
71
|
+
readonly gaps: readonly Gap[];
|
|
72
|
+
readonly items: T[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const DEFAULT_GUTTER = 8;
|
|
76
|
+
const DEFAULT_SPACING = 4;
|
|
77
|
+
const DEFAULT_SNAP = 0.5;
|
|
78
|
+
|
|
79
|
+
type Axis = "x" | "y";
|
|
80
|
+
|
|
81
|
+
const lo = (b: Box, a: Axis): number => (a === "x" ? b.x : b.y);
|
|
82
|
+
const len = (b: Box, a: Axis): number => (a === "x" ? b.w : b.h);
|
|
83
|
+
const other = (a: Axis): Axis => (a === "x" ? "y" : "x");
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* This function gives the gaps of a board. A sweep over each axis sorts the left (or top) edges of the boxes.
|
|
87
|
+
* Each distinct edge after the first is a gap. The `lanes` of each gap are 0, and its `need` is its current width.
|
|
88
|
+
*/
|
|
89
|
+
export function boardGaps(items: readonly Boxed[], snap: number = DEFAULT_SNAP): Gap[] {
|
|
90
|
+
return [...axisGaps(items, "x", snap), ...axisGaps(items, "y", snap)];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function axisGaps(items: readonly Boxed[], axis: Axis, snap: number): Gap[] {
|
|
94
|
+
const starts = [...new Set(items.map((i) => lo(i.box, axis)))].sort((u, v) => u - v);
|
|
95
|
+
const bounds: number[] = [];
|
|
96
|
+
for (const s of starts) if (bounds.length === 0 || s - bounds[bounds.length - 1]! > snap) bounds.push(s);
|
|
97
|
+
const after: string[][] = bounds.map(() => []);
|
|
98
|
+
for (const i of items) after[lastAtOrBefore(bounds, lo(i.box, axis) + snap)]!.push(i.id);
|
|
99
|
+
const ends = items.map((i) => ({ id: i.id, end: lo(i.box, axis) + len(i.box, axis) })).sort((u, v) => u.end - v.end);
|
|
100
|
+
const gaps: Gap[] = [];
|
|
101
|
+
let k = 0;
|
|
102
|
+
for (let n = 1; n < bounds.length; n++) {
|
|
103
|
+
const at = bounds[n]!;
|
|
104
|
+
// The nearest box edge before the gap is the largest end at or before the boundary.
|
|
105
|
+
while (k < ends.length && ends[k]!.end <= at + snap) k++;
|
|
106
|
+
const near = k > 0 ? ends[k - 1]!.end : at;
|
|
107
|
+
const before: string[] = [];
|
|
108
|
+
for (let j = k - 1; j >= 0 && ends[j]!.end >= near - snap; j--) before.push(ends[j]!.id);
|
|
109
|
+
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) });
|
|
110
|
+
}
|
|
111
|
+
return gaps;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** This function gives the index of the last value of the sorted list `v` that is at or before `x`, or 0. */
|
|
115
|
+
function lastAtOrBefore(v: readonly number[], x: number): number {
|
|
116
|
+
let a = 0;
|
|
117
|
+
let b = v.length - 1;
|
|
118
|
+
while (a < b) {
|
|
119
|
+
const m = (a + b + 1) >> 1;
|
|
120
|
+
if (v[m]! <= x) a = m;
|
|
121
|
+
else b = m - 1;
|
|
122
|
+
}
|
|
123
|
+
return a;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* This function moves the boxes apart, so that each gap gets the width `need`. The boxes keep their size, their order and their alignment.
|
|
128
|
+
* 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.
|
|
129
|
+
* The function moves no box nearer to another box, so it makes no overlap.
|
|
130
|
+
*/
|
|
131
|
+
export function openGaps<T extends Boxed>(items: readonly T[], gaps: readonly Gap[], snap: number = DEFAULT_SNAP): T[] {
|
|
132
|
+
const shift = (axis: Axis): ((v: number) => number) => {
|
|
133
|
+
const list = gaps.filter((g) => g.axis === axis).sort((u, v) => u.at - v.at);
|
|
134
|
+
const at = list.map((g) => g.at);
|
|
135
|
+
const sum: number[] = [];
|
|
136
|
+
let s = 0;
|
|
137
|
+
for (const g of list) sum.push((s += Math.max(0, g.need - g.width)));
|
|
138
|
+
return (v) => (list.length === 0 || at[0]! > v + snap ? 0 : sum[lastAtOrBefore(at, v + snap)]!);
|
|
139
|
+
};
|
|
140
|
+
const sx = shift("x");
|
|
141
|
+
const sy = shift("y");
|
|
142
|
+
return items.map((i) => ({ ...i, box: { ...i.box, x: i.box.x + sx(i.box.x), y: i.box.y + sy(i.box.y) } }));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* This function gives the room that the links need in each gap of the board.
|
|
147
|
+
* 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.
|
|
148
|
+
*/
|
|
149
|
+
export function roomDemand(items: readonly Boxed[], links: readonly RoomLink[], options: RoomOptions = {}): Gap[] {
|
|
150
|
+
const gutter = options.gutter ?? DEFAULT_GUTTER;
|
|
151
|
+
const spacing = options.spacing ?? DEFAULT_SPACING;
|
|
152
|
+
const snap = options.snap ?? DEFAULT_SNAP;
|
|
153
|
+
const weight = (l: RoomLink): number => (options.lanes === "count" ? Math.max(1, l.count ?? 1) : 1);
|
|
154
|
+
const gaps = boardGaps(items, snap);
|
|
155
|
+
const lanes = (options.method ?? "routes") === "lines" ? lineLanes(items, links, gaps, weight) : routeLanes(items, links, gaps, gutter, snap, weight, options.router);
|
|
156
|
+
const raw = gaps.map((g, i) => ({ ...g, lanes: lanes[i]!, need: Math.max(g.width, gutter + lanes[i]! * spacing) }));
|
|
157
|
+
return capGrowth(items, raw, gutter, options.maxGrowth ?? Infinity);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** This function gives the demand of the board and the board with each gap open to its need. */
|
|
161
|
+
export function makeRoom<T extends Boxed>(items: readonly T[], links: readonly RoomLink[], options: RoomOptions = {}): Room<T> {
|
|
162
|
+
const gaps = roomDemand(items, links, options);
|
|
163
|
+
return { gaps, items: openGaps(items, gaps, options.snap ?? DEFAULT_SNAP) };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 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. */
|
|
167
|
+
export function roomLinks(graph: Graph): RoomLink[] {
|
|
168
|
+
const byPair = new Map<string, { source: string; target: string; count: number }>();
|
|
169
|
+
for (const e of graph.edges) {
|
|
170
|
+
const [s, t] = [e.source.node, e.target.node];
|
|
171
|
+
if (s === t) continue;
|
|
172
|
+
const key = s < t ? `${s}\u0000${t}` : `${t}\u0000${s}`;
|
|
173
|
+
const known = byPair.get(key);
|
|
174
|
+
if (known) known.count++;
|
|
175
|
+
else byPair.set(key, { source: s, target: t, count: 1 });
|
|
176
|
+
}
|
|
177
|
+
return [...byPair.values()];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** This function counts, for each gap, the straight lines between the centers of the boxes of a link that cross the gap. */
|
|
181
|
+
function lineLanes(items: readonly Boxed[], links: readonly RoomLink[], gaps: readonly Gap[], weight: (l: RoomLink) => number): number[] {
|
|
182
|
+
const byId = new Map(items.map((i) => [i.id, center(i.box)]));
|
|
183
|
+
return gaps.map((g) => {
|
|
184
|
+
let n = 0;
|
|
185
|
+
for (const l of links) {
|
|
186
|
+
const a = byId.get(l.source);
|
|
187
|
+
const b = byId.get(l.target);
|
|
188
|
+
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);
|
|
189
|
+
}
|
|
190
|
+
return n;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* This function routes the links one time on the board with each gap open to the gutter.
|
|
196
|
+
* The lanes of a gap are the largest number of routes that run along the gap at one place.
|
|
197
|
+
* A piece of a route goes along a gap when it is in the channel of the gap.
|
|
198
|
+
* It must also go farther along the gap than across it.
|
|
199
|
+
*/
|
|
200
|
+
function routeLanes(
|
|
201
|
+
items: readonly Boxed[],
|
|
202
|
+
links: readonly RoomLink[],
|
|
203
|
+
gaps: readonly Gap[],
|
|
204
|
+
gutter: number,
|
|
205
|
+
snap: number,
|
|
206
|
+
weight: (l: RoomLink) => number,
|
|
207
|
+
router: Router | undefined,
|
|
208
|
+
): number[] {
|
|
209
|
+
const probeGaps = gaps.map((g) => ({ ...g, need: Math.max(g.width, gutter) }));
|
|
210
|
+
const probe = openGaps(items, probeGaps, snap);
|
|
211
|
+
const ways = probeWays(probe, links, gutter, router);
|
|
212
|
+
return probeGaps.map((g) => {
|
|
213
|
+
// The channel of the gap on the probe board: its far side moves with the growth of each gap up to this one.
|
|
214
|
+
let grow = 0;
|
|
215
|
+
for (const h of probeGaps) if (h.axis === g.axis && h.at <= g.at + snap) grow += h.need - h.width;
|
|
216
|
+
const hi = g.at + grow;
|
|
217
|
+
const lo = hi - g.need;
|
|
218
|
+
const spans: { from: number; to: number; w: number }[] = [];
|
|
219
|
+
ways.forEach((points, i) => {
|
|
220
|
+
const along = alongSpans(points, g.axis, lo, hi);
|
|
221
|
+
for (const [from, to] of merge(along)) spans.push({ from, to, w: weight(links[i]!) });
|
|
222
|
+
});
|
|
223
|
+
return density(spans);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** This function gives the points of the way of each link on the probe board, in the order of the links. */
|
|
228
|
+
function probeWays(items: readonly Boxed[], links: readonly RoomLink[], gutter: number, router: Router | undefined): (readonly Point[])[] {
|
|
229
|
+
if (router) {
|
|
230
|
+
const edges: Edge[] = links.map((l, i) => ({ id: `l${i}`, source: { node: l.source }, target: { node: l.target } }));
|
|
231
|
+
const routes = new Map(router.route({ nodes: items, edges }).routes.map((r) => [r.edge, r.points]));
|
|
232
|
+
return edges.map((e) => routes.get(e.id) ?? []);
|
|
233
|
+
}
|
|
234
|
+
const level = items.map((i) => ({ id: i.id, box: i.box, nodes: [i.id] }));
|
|
235
|
+
const pairs = links.map((l) => ({ source: l.source, target: l.target, count: l.count ?? 1 }));
|
|
236
|
+
// The margin puts the corners of the two sides of a gap on its middle line.
|
|
237
|
+
const out = levelLinks(level, pairs, { margin: Math.max(1, gutter / 2) });
|
|
238
|
+
const byPair = new Map(out.map((l) => [`${l.source}\u0000${l.target}`, l.points]));
|
|
239
|
+
return links.map((l) => byPair.get(`${l.source}\u0000${l.target}`) ?? []);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* This function gives the spans along the axis of a gap where the polyline goes along the channel from `lo` to `hi`.
|
|
244
|
+
* The axis `x` gap is a vertical channel, so its spans are on the y axis.
|
|
245
|
+
*/
|
|
246
|
+
function alongSpans(p: readonly Point[], axis: Axis, lo: number, hi: number): [number, number][] {
|
|
247
|
+
const across = axis;
|
|
248
|
+
const along = other(axis);
|
|
249
|
+
const out: [number, number][] = [];
|
|
250
|
+
for (let i = 1; i < p.length; i++) {
|
|
251
|
+
const a = p[i - 1]!;
|
|
252
|
+
const b = p[i]!;
|
|
253
|
+
const da = b[across] - a[across];
|
|
254
|
+
// The part of the segment inside the channel, as the parameters t0 and t1 of the segment.
|
|
255
|
+
let t0 = 0;
|
|
256
|
+
let t1 = 1;
|
|
257
|
+
if (Math.abs(da) < 1e-9) {
|
|
258
|
+
if (a[across] < lo - 1e-6 || a[across] > hi + 1e-6) continue;
|
|
259
|
+
} else {
|
|
260
|
+
const u = (lo - a[across]) / da;
|
|
261
|
+
const v = (hi - a[across]) / da;
|
|
262
|
+
t0 = Math.max(0, Math.min(u, v));
|
|
263
|
+
t1 = Math.min(1, Math.max(u, v));
|
|
264
|
+
if (t1 <= t0) continue;
|
|
265
|
+
}
|
|
266
|
+
const s = a[along] + (b[along] - a[along]) * t0;
|
|
267
|
+
const e = a[along] + (b[along] - a[along]) * t1;
|
|
268
|
+
const run = Math.abs(e - s);
|
|
269
|
+
const cross = Math.abs(da) * (t1 - t0);
|
|
270
|
+
if (run > cross && run > 1e-6) out.push([Math.min(s, e), Math.max(s, e)]);
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** This function joins the spans that overlap, so that one link counts one time at each place. */
|
|
276
|
+
function merge(spans: [number, number][]): [number, number][] {
|
|
277
|
+
spans.sort((u, v) => u[0] - v[0]);
|
|
278
|
+
const out: [number, number][] = [];
|
|
279
|
+
for (const s of spans) {
|
|
280
|
+
const last = out[out.length - 1];
|
|
281
|
+
if (last && s[0] <= last[1]) last[1] = Math.max(last[1], s[1]);
|
|
282
|
+
else out.push([s[0], s[1]]);
|
|
283
|
+
}
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** This function gives the largest sum of the weights of the spans that overlap at one place. Spans that only touch do not overlap. */
|
|
288
|
+
function density(spans: readonly { from: number; to: number; w: number }[]): number {
|
|
289
|
+
const events: [number, number][] = [];
|
|
290
|
+
for (const s of spans) events.push([s.from, s.w], [s.to, -s.w]);
|
|
291
|
+
// At one coordinate, the ends come before the starts.
|
|
292
|
+
events.sort((u, v) => u[0] - v[0] || u[1] - v[1]);
|
|
293
|
+
let n = 0;
|
|
294
|
+
let best = 0;
|
|
295
|
+
for (const [, w] of events) {
|
|
296
|
+
n += w;
|
|
297
|
+
best = Math.max(best, n);
|
|
298
|
+
}
|
|
299
|
+
return best;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* This function keeps the growth of the board on each axis at most `maxGrowth` times its size.
|
|
304
|
+
* Each gap keeps its gutter. The lanes of all gaps of the axis share the rest of the growth in proportion.
|
|
305
|
+
*/
|
|
306
|
+
function capGrowth(items: readonly Boxed[], gaps: Gap[], gutter: number, maxGrowth: number): Gap[] {
|
|
307
|
+
if (!Number.isFinite(maxGrowth)) return gaps;
|
|
308
|
+
const out = [...gaps];
|
|
309
|
+
for (const axis of ["x", "y"] as const) {
|
|
310
|
+
let x0 = Infinity;
|
|
311
|
+
let x1 = -Infinity;
|
|
312
|
+
for (const i of items) {
|
|
313
|
+
x0 = Math.min(x0, lo(i.box, axis));
|
|
314
|
+
x1 = Math.max(x1, lo(i.box, axis) + len(i.box, axis));
|
|
315
|
+
}
|
|
316
|
+
const budget = Math.max(0, maxGrowth) * Math.max(0, x1 - x0);
|
|
317
|
+
let base = 0;
|
|
318
|
+
let spare = 0;
|
|
319
|
+
for (const g of gaps) {
|
|
320
|
+
if (g.axis !== axis) continue;
|
|
321
|
+
const b = Math.max(0, gutter - g.width);
|
|
322
|
+
base += b;
|
|
323
|
+
spare += Math.max(0, g.need - g.width - b);
|
|
324
|
+
}
|
|
325
|
+
if (base + spare <= budget || spare === 0) continue;
|
|
326
|
+
const f = Math.max(0, (budget - base) / spare);
|
|
327
|
+
gaps.forEach((g, i) => {
|
|
328
|
+
if (g.axis !== axis) return;
|
|
329
|
+
const b = Math.max(0, gutter - g.width);
|
|
330
|
+
out[i] = { ...g, need: g.width + b + f * Math.max(0, g.need - g.width - b) };
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
}
|
package/src/visibility.ts
CHANGED
|
@@ -86,6 +86,11 @@ export interface CornerGraphOptions {
|
|
|
86
86
|
* The lattice links groups of nodes across empty space. Without it, the nearest corners of a group are all in that group.
|
|
87
87
|
*/
|
|
88
88
|
readonly lattice?: number;
|
|
89
|
+
/**
|
|
90
|
+
* With `cones`, each corner also links to the nearest corner that it can see in each of four cones around the axes.
|
|
91
|
+
* Then a path can go along a long corridor between rows or columns of boxes. The default is false.
|
|
92
|
+
*/
|
|
93
|
+
readonly cones?: boolean;
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
/**
|
|
@@ -164,7 +169,8 @@ export class CornerGraph {
|
|
|
164
169
|
}
|
|
165
170
|
const links: Set<number>[] = this.points.map(() => new Set<number>());
|
|
166
171
|
for (let i = 0; i < this.points.length; i++) {
|
|
167
|
-
|
|
172
|
+
// The k nearest corners of a corner in a long corridor are often all on one side. The cones add a link along the corridor.
|
|
173
|
+
for (const j of this.#nearestVisible(this.points[i]!, this.#k, i, this.#pad, options.cones ?? false)) {
|
|
168
174
|
links[i]!.add(j);
|
|
169
175
|
links[j]!.add(i);
|
|
170
176
|
}
|
|
@@ -221,13 +227,17 @@ export class CornerGraph {
|
|
|
221
227
|
* within `r` that an earlier ring did not test, nearest first, and equal distances by index.
|
|
222
228
|
* The ring of `r` takes the corners of the buckets that the square of side `2r` around `p` touches.
|
|
223
229
|
* A corner of these buckets beyond `r` waits for the next ring. So the method visits each bucket one time.
|
|
230
|
+
* With `cones`, the method also gives the nearest visible corner of each of four cones around the axes that the `k` nearest corners leave empty.
|
|
224
231
|
*/
|
|
225
|
-
#nearestVisible(p: Point, k: number, self: number, pad: number): number[] {
|
|
232
|
+
#nearestVisible(p: Point, k: number, self: number, pad: number, cones = false): number[] {
|
|
226
233
|
const out: number[] = [];
|
|
234
|
+
// The bits of the full cones: 1 is +x, 2 is +y, 4 is -x and 8 is -y.
|
|
235
|
+
let full = 0;
|
|
227
236
|
if (this.#dist.length < this.points.length) this.#dist = new Float64Array(this.points.length);
|
|
228
237
|
const dd = this.#dist;
|
|
229
238
|
const points = this.points;
|
|
230
239
|
const cell = this.#cell;
|
|
240
|
+
const reach = CONE_CELLS * cell;
|
|
231
241
|
const blockers: Node[] = [];
|
|
232
242
|
const now = this.#near;
|
|
233
243
|
// The corners that the rings found beyond their radius. The next ring takes them.
|
|
@@ -238,6 +248,8 @@ export class CornerGraph {
|
|
|
238
248
|
let pj0 = 1;
|
|
239
249
|
let pj1 = 0;
|
|
240
250
|
for (let r = cell; r <= cell * 8; r *= 2) {
|
|
251
|
+
// The ring of `r` holds the corners beyond `r / 2`. With the k nearest corners, the cones take no corner beyond `reach`.
|
|
252
|
+
if (out.length >= k && r / 2 >= reach) return out;
|
|
241
253
|
// These bounds are the bounds of a query of the box at (p.x - r, p.y - r) with the side 2r.
|
|
242
254
|
const x0 = p.x - r;
|
|
243
255
|
const y0 = p.y - r;
|
|
@@ -273,6 +285,12 @@ export class CornerGraph {
|
|
|
273
285
|
// A node that stopped an earlier segment often stops this segment too, so the test takes these nodes first.
|
|
274
286
|
// The order of the nodes does not change the result.
|
|
275
287
|
const q = points[j]!;
|
|
288
|
+
const cone = cones ? coneOf(q.x - p.x, q.y - p.y) : 0;
|
|
289
|
+
if (out.length >= k) {
|
|
290
|
+
// After the k nearest corners, only a corner of an empty cone counts, up to `CONE_CELLS` cells from `p`.
|
|
291
|
+
if (dd[j]! > reach) return out;
|
|
292
|
+
if ((full & cone) !== 0) continue;
|
|
293
|
+
}
|
|
276
294
|
let blocked = false;
|
|
277
295
|
for (let b = 0; b < blockers.length && !blocked; b++) blocked = hits(p, q, blockers[b]!, pad);
|
|
278
296
|
if (blocked) continue;
|
|
@@ -280,7 +298,8 @@ export class CornerGraph {
|
|
|
280
298
|
if (hit !== undefined) blockers.push(hit);
|
|
281
299
|
else {
|
|
282
300
|
out.push(j);
|
|
283
|
-
|
|
301
|
+
full |= cone;
|
|
302
|
+
if (out.length >= k && (!cones || full === 15)) return out;
|
|
284
303
|
}
|
|
285
304
|
}
|
|
286
305
|
wait = later;
|
|
@@ -474,6 +493,12 @@ export class CornerGraph {
|
|
|
474
493
|
}
|
|
475
494
|
}
|
|
476
495
|
|
|
496
|
+
/** A link of a cone is at most this number of cells long. A longer search costs time on a dense level and finds few new links. */
|
|
497
|
+
const CONE_CELLS = 3;
|
|
498
|
+
|
|
499
|
+
/** This function gives the bit of the cone of the offset (`dx`, `dy`): 1 is +x, 2 is +y, 4 is -x and 8 is -y. A diagonal offset is in a cone of the x axis. */
|
|
500
|
+
const coneOf = (dx: number, dy: number): number => (Math.abs(dx) >= Math.abs(dy) ? (dx > 0 ? 1 : 4) : dy > 0 ? 2 : 8);
|
|
501
|
+
|
|
477
502
|
/** This function packs the two indices of a bucket into one number. Each index stays within ±2^20. */
|
|
478
503
|
const bucketKey = (i: number, j: number): number => (i + 1_048_576) * 2_097_152 + (j + 1_048_576);
|
|
479
504
|
|