@mark1russell7/highways 0.1.5 → 0.3.0

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.
Files changed (56) hide show
  1. package/README.md +293 -8
  2. package/build/index.d.ts +1774 -236
  3. package/build/index.d.ts.map +1 -1
  4. package/build/index.js +9307 -3090
  5. package/build/index.js.map +1 -1
  6. package/package.json +2 -1
  7. package/src/bench/compare.ts +145 -0
  8. package/src/bench/curves.ts +514 -2
  9. package/src/bench/links.ts +28 -6
  10. package/src/bench/measures/bundles.ts +119 -0
  11. package/src/bench/measures/curves.ts +211 -0
  12. package/src/bench/measures/groups.ts +122 -0
  13. package/src/bench/measures/index.ts +9 -0
  14. package/src/bench/measures/levels.ts +103 -0
  15. package/src/bench/measures/pareto.ts +37 -0
  16. package/src/bench/measures/room.ts +43 -0
  17. package/src/bench/measures/routes.ts +226 -0
  18. package/src/bench/measures/stability.ts +104 -0
  19. package/src/bench/measures/stats.ts +114 -0
  20. package/src/bench/properties.ts +18 -2
  21. package/src/bench/room.ts +209 -2
  22. package/src/bench/run.ts +86 -11
  23. package/src/bench/sections.ts +121 -0
  24. package/src/bench/suite.ts +781 -0
  25. package/src/cases.ts +41 -5
  26. package/src/curves/clothoid/index.ts +150 -0
  27. package/src/curves/corner.ts +116 -0
  28. package/src/curves/fit.ts +79 -0
  29. package/src/curves/highway/index.ts +636 -0
  30. package/src/curves/index.ts +16 -1
  31. package/src/curves/model.ts +4 -2
  32. package/src/curves/smooth/index.ts +50 -0
  33. package/src/curves/space.ts +76 -0
  34. package/src/groups/cut/index.ts +292 -0
  35. package/src/groups/index.ts +4 -0
  36. package/src/groups/kd/index.ts +207 -0
  37. package/src/groups/keys/index.ts +81 -0
  38. package/src/groups/quadtree/index.ts +10 -0
  39. package/src/index.ts +7 -0
  40. package/src/levels.ts +178 -9
  41. package/src/model.ts +2 -0
  42. package/src/pick.ts +36 -0
  43. package/src/room/bends.ts +203 -0
  44. package/src/room/index.ts +28 -0
  45. package/src/room/levels/index.ts +155 -0
  46. package/src/routers/sections.ts +78 -55
  47. package/src/routers/shared.ts +7 -2
  48. package/src/routers/trunks.ts +131 -6
  49. package/src/sections.ts +93 -4
  50. package/src/segments.ts +81 -31
  51. package/src/strategies/builtins.ts +153 -7
  52. package/src/strategies/index.ts +1 -0
  53. package/src/strategies/model.ts +29 -7
  54. package/src/strategies/options.ts +296 -0
  55. package/src/strategies/schemas.ts +221 -0
  56. package/src/visibility.ts +190 -1
@@ -7,11 +7,13 @@ import type { Point } from "../geom.ts";
7
7
  * - `rounded`: a quadratic Bezier curve at each corner, as `roundCorners` draws it.
8
8
  * - `spline`: a centripetal Catmull-Rom spline through the points. It does not overshoot or make loops.
9
9
  * - `arc`: a circular arc at each corner, tangent to the two legs.
10
+ * - `smooth`: a cubic spiral, an arc and a second cubic spiral at each corner, as the corner smoothing of Figma.
11
+ * - `clothoid`: a clothoid, an arc and a second clothoid at each corner, as a transition curve of a road.
10
12
  */
11
- export type CurveType = "straight" | "rounded" | "spline" | "arc";
13
+ export type CurveType = "straight" | "rounded" | "spline" | "arc" | "smooth" | "clothoid";
12
14
 
13
15
  /** The curve types, in the order of the benchmark. */
14
- export const CURVE_TYPES: readonly CurveType[] = ["straight", "rounded", "spline", "arc"];
16
+ export const CURVE_TYPES: readonly CurveType[] = ["straight", "rounded", "spline", "arc", "smooth", "clothoid"];
15
17
 
16
18
  /** The start of a curve: the pen goes to `to` and draws nothing. */
17
19
  export interface MoveSegment {
@@ -0,0 +1,50 @@
1
+ import type { Point } from "../../geom.ts";
2
+ import { cornerTangent, spiralCorners, type SpiralShape } from "../corner.ts";
3
+ import type { CurveSegment } from "../model.ts";
4
+ import type { CurveSpace } from "../space.ts";
5
+
6
+ /** The options of `smoothCurve`. */
7
+ export interface SmoothOptions {
8
+ /** The radius of the arc in the middle of each corner: the smallest radius of the corner. The default is 12. */
9
+ readonly radius?: number;
10
+ /** The share of the turn of a corner in its two spirals, from 0 (a circular arc) to 1 (no arc). The default is 0.6. */
11
+ readonly smoothing?: number;
12
+ /** The boxes that the curve must not go through. Without it, each corner gets the largest radius that fits on its legs. */
13
+ readonly space?: CurveSpace;
14
+ }
15
+
16
+ /** The largest turn of one spiral, in radians. A cubic spiral with a larger turn has a curvature that is not monotone. */
17
+ export const MAX_SPIRAL_TURN = 1;
18
+
19
+ /**
20
+ * This function gives the cubic Bezier spiral at the radius 1 with the turn `theta` (F7 of the note CURVES).
21
+ * The first three control points are on the x axis, so the curvature is 0 at the start. The end has the curvature 1 and the tangent angle `theta`.
22
+ * The handles are `a = (2/3) θ (1 - θ²/2)` and `b = (2/3) θ (1 + θ²/5)`, and the middle leg is `g = 1.5 b² / sin θ`.
23
+ * With these handles, the curvature rises monotonically from 0 to 1 for each turn up to `MAX_SPIRAL_TURN`.
24
+ */
25
+ export function bezierSpiral(theta: number): { readonly c1: Point; readonly c2: Point; readonly to: Point } {
26
+ const a = (2 / 3) * theta * (1 - (theta * theta) / 2);
27
+ const b = (2 / 3) * theta * (1 + (theta * theta) / 5);
28
+ const g = (1.5 * b * b) / Math.sin(theta);
29
+ return { c1: { x: a, y: 0 }, c2: { x: a + g, y: 0 }, to: { x: a + g + b * Math.cos(theta), y: b * Math.sin(theta) } };
30
+ }
31
+
32
+ /** This function gives the shape of a corner of `smoothCurve` with the turn `turn` at the radius 1. */
33
+ export function smoothShape(turn: number, smoothing: number): SpiralShape {
34
+ const theta = Math.min((Math.max(0, Math.min(1, smoothing)) * turn) / 2, MAX_SPIRAL_TURN);
35
+ if (theta < 1e-9) return { tangent: Math.tan(turn / 2), theta: 0, pieces: () => [], end: { x: 0, y: 0 } };
36
+ const spiral = bezierSpiral(theta);
37
+ return { tangent: cornerTangent(turn, theta, spiral.to), theta, pieces: () => [spiral], end: spiral.to };
38
+ }
39
+
40
+ /**
41
+ * This function rounds each inner vertex of a polyline with a cubic spiral, a circular arc and a second cubic spiral.
42
+ * It is the shape of the corner smoothing of Figma (F7 of the note CURVES). The curvature rises from 0 on the leg to `1 / radius` on the arc,
43
+ * so the curve is G2: it has no jump of the curvature. Each spiral turns by `smoothing` times half of the turn, up to `MAX_SPIRAL_TURN`.
44
+ * The ends of each corner stay on the first half of each leg, so the ends of the polyline stay.
45
+ * With `space`, the radius of a corner shrinks until its corner triangle overlaps no box that the polyline avoids.
46
+ */
47
+ export function smoothCurve(points: readonly Point[], options: SmoothOptions = {}): CurveSegment[] {
48
+ const smoothing = options.smoothing ?? 0.6;
49
+ return spiralCorners(points, { radius: options.radius ?? 12, ...(options.space ? { space: options.space } : {}) }, (turn) => smoothShape(turn, smoothing));
50
+ }
@@ -18,6 +18,12 @@ export interface CurveSpace {
18
18
  * A polyline goes through a box when a part of it longer than `TOLERANCE` is inside the box made smaller by `TOLERANCE`.
19
19
  */
20
20
  addsHit(q: readonly Point[], p: readonly Point[]): boolean;
21
+ /**
22
+ * This method tells if the triangle `(a, v, b)` overlaps a box that the polyline `(a, v, b)` does not go through.
23
+ * A convex corner from `a` to `b` stays in this triangle, so a clear triangle proves a clear corner with no samples.
24
+ * The method is optional. Without it, a corner samples its curve.
25
+ */
26
+ addsHitTriangle?(a: Point, v: Point, b: Point): boolean;
21
27
  }
22
28
 
23
29
  /**
@@ -59,9 +65,79 @@ export function curveSpace(boxes: readonly Box[], tolerance = 0.25): CurveSpace
59
65
  }
60
66
  return false;
61
67
  },
68
+ addsHitTriangle(a: Point, v: Point, b: Point): boolean {
69
+ const x0 = Math.min(a.x, v.x, b.x);
70
+ const y0 = Math.min(a.y, v.y, b.y);
71
+ let before: Set<number> | undefined;
72
+ let hit = false;
73
+ grid.query({ x: x0, y: y0, w: Math.max(a.x, v.x, b.x) - x0, h: Math.max(a.y, v.y, b.y) - y0 }, (id) => {
74
+ if (hit || !triangleOverlapsBox(a, v, b, inner[id]!)) return;
75
+ before ??= hits([a, v, b]);
76
+ if (!before.has(id)) hit = true;
77
+ });
78
+ return hit;
79
+ },
62
80
  };
63
81
  }
64
82
 
83
+ /**
84
+ * This function tells if the triangle `(a, b, c)` and the box overlap by more than `eps`. It is the test of the separating axes:
85
+ * the two axes of the box and the normal of each side of the triangle.
86
+ */
87
+ export function triangleOverlapsBox(a: Point, b: Point, c: Point, box: Box, eps = 1e-9): boolean {
88
+ if (Math.max(a.x, b.x, c.x) <= box.x + eps || Math.min(a.x, b.x, c.x) >= box.x + box.w - eps) return false;
89
+ if (Math.max(a.y, b.y, c.y) <= box.y + eps || Math.min(a.y, b.y, c.y) >= box.y + box.h - eps) return false;
90
+ const corners: readonly Point[] = [
91
+ { x: box.x, y: box.y },
92
+ { x: box.x + box.w, y: box.y },
93
+ { x: box.x, y: box.y + box.h },
94
+ { x: box.x + box.w, y: box.y + box.h },
95
+ ];
96
+ const tri = [a, b, c];
97
+ for (let i = 0; i < 3; i++) {
98
+ const p = tri[i]!;
99
+ const q = tri[(i + 1) % 3]!;
100
+ const n = { x: q.y - p.y, y: p.x - q.x };
101
+ const len = Math.hypot(n.x, n.y);
102
+ if (len < 1e-12) continue;
103
+ const proj = (r: Point): number => ((r.x - p.x) * n.x + (r.y - p.y) * n.y) / len;
104
+ // The triangle is on one side of its side pq: the side of the third vertex.
105
+ const side = proj(tri[(i + 2) % 3]!);
106
+ if (Math.abs(side) < 1e-12) continue;
107
+ const toward = corners.map(proj).map((x) => (side > 0 ? x : -x));
108
+ if (Math.max(...toward) <= eps) return false;
109
+ }
110
+ return true;
111
+ }
112
+
113
+ /**
114
+ * This function finds the largest size from `max` down to 0 that keeps a convex corner clear, with the test of its triangle.
115
+ * The function `triangle` gives the start of the corner, the vertex and the end of the corner at a size.
116
+ * Without `addsHitTriangle` in the space, the function samples the segments of `piece`, as `fitSize` does.
117
+ * The function tries `max` first, then it halves the interval `steps` times.
118
+ */
119
+ export function fitCorner(
120
+ space: CurveSpace | undefined,
121
+ max: number,
122
+ triangle: (size: number) => readonly [Point, Point, Point],
123
+ piece: (size: number) => readonly CurveSegment[],
124
+ steps = 8,
125
+ ): number {
126
+ if (space === undefined || max <= 0) return Math.max(0, max);
127
+ const test = space.addsHitTriangle?.bind(space);
128
+ if (!test) return fitSize(space, max, triangle, piece, steps);
129
+ const clear = (size: number): boolean => !test(...triangle(size));
130
+ if (clear(max)) return max;
131
+ let lo = 0;
132
+ let hi = max;
133
+ for (let i = 0; i < steps; i++) {
134
+ const mid = (lo + hi) / 2;
135
+ if (clear(mid)) lo = mid;
136
+ else hi = mid;
137
+ }
138
+ return lo;
139
+ }
140
+
65
141
  /**
66
142
  * This function finds the largest size from `max` down to 0 that keeps a piece of a curve clear.
67
143
  * A clear piece goes through no box that its polyline avoids.
@@ -0,0 +1,292 @@
1
+ import { center, type Box, type Point } from "../../geom.ts";
2
+ import type { Graph } from "../../model.ts";
3
+ import type { Section } from "../../sections.ts";
4
+ import { coord, growSections, halves, sides, sortOn, type SplitPart } from "../kd/index.ts";
5
+
6
+ /** The cost of a line of the rule `cut`, from the cut `c` and the parts `A` and `B` of the split. */
7
+ export type CutObjective = "ratio" | "normalized" | "sparsest";
8
+
9
+ /** The options of the grouping rule `cut` (M1 of GRP-R, the guillotine cut). */
10
+ export interface CutOptions {
11
+ /** A section with more nodes splits. The default is 8, as for the quadtree. */
12
+ readonly cap?: number;
13
+ /** The tree stops at this depth. The default is 16. */
14
+ readonly maxDepth?: number;
15
+ /** The number of children of a section: 2 or 4. With 4, a section splits two times on one level. The default is 4. */
16
+ readonly fanout?: number;
17
+ /** The smallest share of the nodes in each part of a split (`β`), from 0.05 to 0.5. The default is 0.25. */
18
+ readonly balance?: number;
19
+ /**
20
+ * The cost of a line. The value `ratio` is `c / min(|A|, |B|)`. The value `normalized` is `c / vol(A) + c / vol(B)`, with `vol` the sum of the degrees
21
+ * inside the section. The value `sparsest` is `c / (|A| · |B|)`. The default is `ratio`.
22
+ */
23
+ readonly objective?: CutObjective;
24
+ /**
25
+ * The cost of a line grows by the factor `1 + pull · d`. The value `d` is the distance of the line from the middle of the cell, as a share of the side.
26
+ * A large value prefers the middle line, as the quadtree does. The default is 0.5.
27
+ */
28
+ readonly pull?: number;
29
+ /**
30
+ * With `true`, each section also tests the split of the quadtree, and keeps the split that cuts fewer edges.
31
+ * Thus no section cuts more edges than the quadtree split of its cell. With the fan-out 2, the test uses each midline of the cell. The default is `true`.
32
+ */
33
+ readonly guard?: boolean;
34
+ }
35
+
36
+ /** The default values of `CutOptions`. */
37
+ export const CUT_DEFAULTS: Readonly<Required<CutOptions>> = Object.freeze({ cap: 8, maxDepth: 16, fanout: 4, balance: 0.25, objective: "ratio", pull: 0.5, guard: true });
38
+
39
+ /** A split with the number of edges between its parts. */
40
+ interface CutSplit {
41
+ readonly parts: readonly SplitPart[];
42
+ readonly cut: number;
43
+ }
44
+
45
+ /** The most times that the guard shrinks a cell to one quarter without a split, as `buildSections` does. */
46
+ const SHRINK = 32;
47
+
48
+ /**
49
+ * This function gives the tree of sections of the grouping rule `cut`: a guillotine cut that reads the edges (M1 of GRP-R).
50
+ * A section with more than `cap` nodes splits in two along the line with the lowest cost, on either axis. A line goes between two centers.
51
+ * Each part of a split holds at least the share `balance` of the nodes. With the fan-out 4, each half splits again on the same level.
52
+ *
53
+ * The cells nest and do not overlap. The option `guard` keeps the split of the quadtree when that split cuts fewer edges.
54
+ */
55
+ export function cutGroups(graph: Graph, options: CutOptions = {}): Section {
56
+ const o: Required<CutOptions> = { ...CUT_DEFAULTS, ...options };
57
+ const fanout = o.fanout >= 4 ? 4 : 2;
58
+ const balance = Math.min(0.5, Math.max(0, o.balance));
59
+ const pull = Math.max(0, o.pull);
60
+ const nodes = graph.nodes;
61
+ const n = nodes.length;
62
+ const centers: Point[] = nodes.map((v) => center(v.box));
63
+ const cx = Float64Array.from(centers, (c) => c.x);
64
+ const cy = Float64Array.from(centers, (c) => c.y);
65
+ const index = new Map(nodes.map((v, i) => [v.id, i]));
66
+ const adj: number[][] = nodes.map(() => []);
67
+ for (const e of graph.edges) {
68
+ const a = index.get(e.source.node);
69
+ const b = index.get(e.target.node);
70
+ if (a === undefined || b === undefined || a === b) continue;
71
+ adj[a]!.push(b);
72
+ adj[b]!.push(a);
73
+ }
74
+ const mark = new Int32Array(n);
75
+ const rank = new Int32Array(n);
76
+ const degree = new Float64Array(n);
77
+ const owner = new Int32Array(n);
78
+ let token = 0;
79
+ // The orders of the nodes of a part on each axis, by the list of the part. A split hands the orders of a section down to its parts, so only the root sorts.
80
+ const orders = new WeakMap<readonly number[], readonly [number[], number[]]>();
81
+ const ordersOf = (list: readonly number[]): readonly [number[], number[]] => {
82
+ let both = orders.get(list);
83
+ if (!both) {
84
+ both = [sortOn(list, centers, 0), sortOn(list, centers, 1)];
85
+ orders.set(list, both);
86
+ }
87
+ return both;
88
+ };
89
+ const handDown = (list: readonly number[], parts: readonly SplitPart[]): void => {
90
+ parts.forEach((p, k) => {
91
+ for (const i of p.list) owner[i] = k;
92
+ });
93
+ const [ox, oy] = ordersOf(list);
94
+ const xs: number[][] = parts.map(() => []);
95
+ const ys: number[][] = parts.map(() => []);
96
+ for (const i of ox) xs[owner[i]!]!.push(i);
97
+ for (const i of oy) ys[owner[i]!]!.push(i);
98
+ parts.forEach((p, k) => orders.set(p.list, [xs[k]!, ys[k]!]));
99
+ };
100
+
101
+ /** This function gives the edges with both ends in the list, as a flat list of index pairs. It keeps the pairs of the last list for the guard. */
102
+ let lastList: readonly number[] | undefined;
103
+ let lastPairs: number[] = [];
104
+ const insideEdges = (list: readonly number[]): number[] => {
105
+ if (list === lastList) return lastPairs;
106
+ token++;
107
+ for (const i of list) mark[i] = token;
108
+ const out: number[] = [];
109
+ for (const i of list) for (const j of adj[i]!) if (j > i && mark[j] === token) out.push(i, j);
110
+ lastList = list;
111
+ lastPairs = out;
112
+ return out;
113
+ };
114
+
115
+ /** This function gives the number of edges of the list between two parts: `part` gives the part of a node. */
116
+ const cutOf = (list: readonly number[], part: (i: number) => number): number => {
117
+ const pairs = insideEdges(list);
118
+ let cut = 0;
119
+ for (let p = 0; p < pairs.length; p += 2) if (part(pairs[p]!) !== part(pairs[p + 1]!)) cut++;
120
+ return cut;
121
+ };
122
+
123
+ /** This function gives the cost of a line. Its inputs are the cut `c`, and the sizes `a`, `b` and the volumes `va`, `vb` of the parts. */
124
+ const objective = (c: number, a: number, b: number, va: number, vb: number): number => {
125
+ if (o.objective === "sparsest") return c / (a * b);
126
+ if (o.objective === "normalized") return (va > 0 ? c / va : 0) + (vb > 0 ? c / vb : 0);
127
+ return c / Math.min(a, b);
128
+ };
129
+
130
+ /**
131
+ * This function splits the list in two along the line with the lowest cost. It tests each line between two centers on each axis.
132
+ * When no line keeps the share `balance` in each part, it takes the line with the largest smaller part.
133
+ */
134
+ const guillotine = (list: readonly number[], cell: Box): CutSplit | undefined => {
135
+ const len = list.length;
136
+ if (len < 2) return undefined;
137
+ const pairs = insideEdges(list);
138
+ for (const i of list) degree[i] = 0;
139
+ for (const i of pairs) degree[i]!++;
140
+ let total = 0;
141
+ for (const i of list) total += degree[i]!;
142
+ const minPart = Math.max(1, Math.ceil(balance * len - 1e-9));
143
+ // The best line, and the most even line for a list where no line keeps the balance. An axis of -1 is no line.
144
+ let bestAxis = -1;
145
+ let bestT = 0;
146
+ let bestAt = 0;
147
+ let bestCut = 0;
148
+ let bestCost = Infinity;
149
+ let bestShare = Infinity;
150
+ let fbAxis = -1;
151
+ let fbT = 0;
152
+ let fbAt = 0;
153
+ let fbCut = 0;
154
+ let fbSmall = 0;
155
+ let fbShare = Infinity;
156
+ const both = ordersOf(list);
157
+ for (const axis of [0, 1] as const) {
158
+ const order = both[axis];
159
+ const at0 = axis === 0 ? cx : cy;
160
+ for (let r = 0; r < len; r++) rank[order[r]!] = r;
161
+ const diff = new Int32Array(len + 1);
162
+ for (let p = 0; p < pairs.length; p += 2) {
163
+ const ra = rank[pairs[p]!]!;
164
+ const rb = rank[pairs[p + 1]!]!;
165
+ if (ra < rb) {
166
+ diff[ra + 1]!++;
167
+ diff[rb + 1]!--;
168
+ } else {
169
+ diff[rb + 1]!++;
170
+ diff[ra + 1]!--;
171
+ }
172
+ }
173
+ const side = axis === 0 ? cell.w : cell.h;
174
+ const mid = (axis === 0 ? cell.x : cell.y) + side / 2;
175
+ let cut = 0;
176
+ let va = 0;
177
+ let before = at0[order[0]!]!;
178
+ for (let t = 1; t < len; t++) {
179
+ cut += diff[t]!;
180
+ va += degree[order[t - 1]!]!;
181
+ const after = at0[order[t]!]!;
182
+ const prev = before;
183
+ before = after;
184
+ // A line goes between two centers, so two nodes at one coordinate stay in one part.
185
+ if (!(after > prev)) continue;
186
+ const at = (prev + after) / 2;
187
+ const share = side > 0 ? Math.abs(at - mid) / side : 0;
188
+ const small = t < len - t ? t : len - t;
189
+ if (small < minPart) {
190
+ if (small > fbSmall || (small === fbSmall && share < fbShare)) {
191
+ fbAxis = axis;
192
+ fbT = t;
193
+ fbAt = at;
194
+ fbCut = cut;
195
+ fbSmall = small;
196
+ fbShare = share;
197
+ }
198
+ continue;
199
+ }
200
+ const cost = objective(cut, t, len - t, va, total - va) * (1 + pull * share);
201
+ if (cost < bestCost - 1e-12 || (Math.abs(cost - bestCost) <= 1e-12 && share < bestShare)) {
202
+ bestAxis = axis;
203
+ bestT = t;
204
+ bestAt = at;
205
+ bestCut = cut;
206
+ bestCost = cost;
207
+ bestShare = share;
208
+ }
209
+ }
210
+ }
211
+ const found = bestAxis >= 0;
212
+ const axis = (found ? bestAxis : fbAxis) as 0 | 1 | -1;
213
+ if (axis < 0) return undefined;
214
+ const t = found ? bestT : fbT;
215
+ const order = both[axis as 0 | 1];
216
+ token++;
217
+ for (let r = 0; r < t; r++) mark[order[r]!] = token;
218
+ const [a, b] = sides(list, (i) => mark[i] === token);
219
+ const cells = halves(cell, axis as 0 | 1, found ? bestAt : fbAt);
220
+ const parts: SplitPart[] = [
221
+ { digit: 0, cell: cells[0], list: a },
222
+ { digit: 1, cell: cells[1], list: b },
223
+ ];
224
+ handDown(list, parts);
225
+ return { parts, cut: found ? bestCut : fbCut };
226
+ };
227
+
228
+ /** This function gives the guillotine split of a section. With the fan-out 2, it is one cut. With the fan-out 4, it also cuts each half. */
229
+ const split = (list: readonly number[], cell: Box): CutSplit | undefined => {
230
+ const first = guillotine(list, cell);
231
+ if (!first || fanout < 4) return first;
232
+ let cut = first.cut;
233
+ const parts = first.parts.flatMap((half) => {
234
+ const second = guillotine(half.list, half.cell);
235
+ if (!second) return [{ ...half, digit: 2 * half.digit }];
236
+ cut += second.cut;
237
+ return second.parts.map((q) => ({ ...q, digit: 2 * half.digit + q.digit }));
238
+ });
239
+ return { parts, cut };
240
+ };
241
+
242
+ /**
243
+ * This function gives the split of the quadtree at the cell, as `buildSections` makes it: four quarters at the middle of the cell.
244
+ * When the nodes are all in one quarter, the cell shrinks to that quarter. With the fan-out 2, it gives the midline of the cell with the lower cut.
245
+ */
246
+ const quadSplit = (list: readonly number[], cell: Box): CutSplit | undefined => {
247
+ if (fanout < 4) {
248
+ let out: CutSplit | undefined;
249
+ for (const axis of [0, 1] as const) {
250
+ const lo = axis === 0 ? cell.x : cell.y;
251
+ const mid = lo + (axis === 0 ? cell.w : cell.h) / 2;
252
+ const [a, b] = sides(list, (i) => coord(centers[i]!, axis) < mid);
253
+ if (a.length === 0 || b.length === 0) continue;
254
+ const cut = cutOf(list, (i) => (coord(centers[i]!, axis) < mid ? 0 : 1));
255
+ const cells = halves(cell, axis, mid);
256
+ if (!out || cut < out.cut)
257
+ out = {
258
+ parts: [
259
+ { digit: 0, cell: cells[0], list: a },
260
+ { digit: 1, cell: cells[1], list: b },
261
+ ],
262
+ cut,
263
+ };
264
+ }
265
+ if (out) handDown(list, out.parts);
266
+ return out;
267
+ }
268
+ let box = cell;
269
+ for (let s = 0; s < SHRINK; s++) {
270
+ const mx = box.x + box.w / 2;
271
+ const my = box.y + box.h / 2;
272
+ const quarterOf = (i: number): number => (centers[i]!.x < mx ? 0 : 1) + (centers[i]!.y < my ? 0 : 2);
273
+ const lists: number[][] = [[], [], [], []];
274
+ for (const i of list) lists[quarterOf(i)]!.push(i);
275
+ const quarter = (q: number): Box => ({ x: q % 2 === 0 ? box.x : mx, y: q < 2 ? box.y : my, w: box.w / 2, h: box.h / 2 });
276
+ const full = lists.map((l, q) => ({ digit: q, cell: quarter(q), list: l })).filter((p) => p.list.length > 0);
277
+ if (full.length > 1) {
278
+ handDown(list, full);
279
+ return { parts: full, cut: cutOf(list, quarterOf) };
280
+ }
281
+ box = full[0]?.cell ?? box;
282
+ }
283
+ return undefined;
284
+ };
285
+
286
+ return growSections(nodes, o.cap, o.maxDepth, (list, cell) => {
287
+ const cut = split(list, cell);
288
+ if (!o.guard) return cut?.parts;
289
+ const quad = quadSplit(list, cell);
290
+ return quad && (!cut || quad.cut < cut.cut) ? quad.parts : cut?.parts;
291
+ });
292
+ }
@@ -0,0 +1,4 @@
1
+ export { quadtreeGroups } from "./quadtree/index.ts";
2
+ export { byDataGroup, keyedSections, keyGroups, type KeyOptions, type NodeKey } from "./keys/index.ts";
3
+ export { CUT_DEFAULTS, cutGroups, type CutObjective, type CutOptions } from "./cut/index.ts";
4
+ export { KD_DEFAULTS, kdGroups, type KdAxis, type KdLine, type KdOptions } from "./kd/index.ts";
@@ -0,0 +1,207 @@
1
+ import { center, type Box, type Point } from "../../geom.ts";
2
+ import type { Graph, Node } from "../../model.ts";
3
+ import type { Section } from "../../sections.ts";
4
+
5
+ /** The place of the line of a split of the rule `kd`. */
6
+ export type KdLine = "median" | "middle" | "slide";
7
+
8
+ /** The axis of each split of the rule `kd`. */
9
+ export type KdAxis = "longer" | "alternate";
10
+
11
+ /** The options of the grouping rule `kd` (S3 of GRP-R). */
12
+ export interface KdOptions {
13
+ /** A section with more nodes splits. The default is 8, as for the quadtree. */
14
+ readonly cap?: number;
15
+ /** The tree stops at this depth. The default is 16. */
16
+ readonly maxDepth?: number;
17
+ /**
18
+ * This value sets the place of the line in the cell. The value `median` puts half of the nodes on each side. The value `middle` cuts the cell in half,
19
+ * and an empty half makes the cell shrink to the other half. The value `slide` cuts the cell in half, and a line with an empty side slides to the nearest node.
20
+ * The default is `slide`.
21
+ */
22
+ readonly line?: KdLine;
23
+ /** The number of children of a section: 2 or 4. With 4, a section splits two times on one level. The default is 4. */
24
+ readonly fanout?: number;
25
+ /** The value `longer` cuts across the longer side of the cell. The value `alternate` cuts across x, then y, then x again. The default is `longer`. */
26
+ readonly axis?: KdAxis;
27
+ }
28
+
29
+ /** The default values of `KdOptions`. */
30
+ export const KD_DEFAULTS: Readonly<Required<KdOptions>> = Object.freeze({ cap: 8, maxDepth: 16, line: "slide", fanout: 4, axis: "longer" });
31
+
32
+ /** One child of a split: the digit of its id, its cell and the indices of its nodes, in the order of the graph. */
33
+ export interface SplitPart {
34
+ readonly digit: number;
35
+ readonly cell: Box;
36
+ readonly list: readonly number[];
37
+ }
38
+
39
+ /** A split rule gives the children of a section, or `undefined` when the section cannot split. */
40
+ export type SplitRule = (list: readonly number[], cell: Box, depth: number) => readonly SplitPart[] | undefined;
41
+
42
+ /** This function gives the bounding box of the boxes of the nodes. */
43
+ export function nodeBounds(nodes: readonly Node[]): Box {
44
+ let x0 = Infinity;
45
+ let y0 = Infinity;
46
+ let x1 = -Infinity;
47
+ let y1 = -Infinity;
48
+ for (const n of nodes) {
49
+ x0 = Math.min(x0, n.box.x);
50
+ y0 = Math.min(y0, n.box.y);
51
+ x1 = Math.max(x1, n.box.x + n.box.w);
52
+ y1 = Math.max(y1, n.box.y + n.box.h);
53
+ }
54
+ return nodes.length ? { x: x0, y: y0, w: x1 - x0, h: y1 - y0 } : { x: 0, y: 0, w: 0, h: 0 };
55
+ }
56
+
57
+ /**
58
+ * This function makes a tree of sections with a split rule. The root cell is the bounding box of the nodes.
59
+ * A section with more than `cap` nodes, above `maxDepth`, gets the children of the rule. A child without nodes does not become a section.
60
+ * The id of a child is the id of its parent and the digit of the child.
61
+ */
62
+ export function growSections(nodes: readonly Node[], cap: number, maxDepth: number, rule: SplitRule): Section {
63
+ const limit = Math.max(1, cap);
64
+ const make = (id: string, depth: number, cell: Box, list: readonly number[]): Section => {
65
+ if (list.length > limit && depth < maxDepth) {
66
+ const parts = (rule(list, cell, depth) ?? []).filter((p) => p.list.length > 0);
67
+ if (parts.length > 1) return { id, depth, cell, nodes: list, children: parts.map((p) => make(`${id}${p.digit}`, depth + 1, p.cell, p.list)) };
68
+ }
69
+ return { id, depth, cell, nodes: list, children: [] };
70
+ };
71
+ return make(
72
+ "s",
73
+ 0,
74
+ nodeBounds(nodes),
75
+ nodes.map((_, i) => i),
76
+ );
77
+ }
78
+
79
+ /** This function gives the coordinate of a point on an axis: 0 is x, and 1 is y. */
80
+ export const coord = (p: Point, axis: 0 | 1): number => (axis === 0 ? p.x : p.y);
81
+
82
+ /** This function gives the two halves of a cell, cut across the axis at the coordinate `at`. */
83
+ export function halves(cell: Box, axis: 0 | 1, at: number): [Box, Box] {
84
+ if (axis === 0) {
85
+ const w = Math.min(cell.w, Math.max(0, at - cell.x));
86
+ return [
87
+ { x: cell.x, y: cell.y, w, h: cell.h },
88
+ { x: cell.x + w, y: cell.y, w: cell.w - w, h: cell.h },
89
+ ];
90
+ }
91
+ const h = Math.min(cell.h, Math.max(0, at - cell.y));
92
+ return [
93
+ { x: cell.x, y: cell.y, w: cell.w, h },
94
+ { x: cell.x, y: cell.y + h, w: cell.w, h: cell.h - h },
95
+ ];
96
+ }
97
+
98
+ /** This function puts each node of the list on its side of a split: `low` tells if a node goes to the first part. Each part keeps the order of the graph. */
99
+ export function sides(list: readonly number[], low: (i: number) => boolean): [number[], number[]] {
100
+ const a: number[] = [];
101
+ const b: number[] = [];
102
+ for (const i of list) (low(i) ? a : b).push(i);
103
+ return [a, b];
104
+ }
105
+
106
+ /** This function gives the nodes of the list in the order of their centers on the axis. Two nodes at one coordinate keep the order of the graph. */
107
+ export function sortOn(list: readonly number[], centers: readonly Point[], axis: 0 | 1): number[] {
108
+ return [...list].sort((i, j) => coord(centers[i]!, axis) - coord(centers[j]!, axis) || i - j);
109
+ }
110
+
111
+ /** The most times that the rule `middle` shrinks a cell to one half without a split. The quadtree has the same limit (`collapse`). */
112
+ const SHRINK = 32;
113
+
114
+ /** This function splits the list in two on one axis with the line rule. It gives `undefined` when no line of the rule has a node on each side. */
115
+ function lineSplit(list: readonly number[], cell: Box, centers: readonly Point[], axis: 0 | 1, line: KdLine): [SplitPart, SplitPart] | undefined {
116
+ const c = (i: number): number => coord(centers[i]!, axis);
117
+ const part = (cells: [Box, Box], [a, b]: [number[], number[]]): [SplitPart, SplitPart] | undefined =>
118
+ a.length > 0 && b.length > 0
119
+ ? [
120
+ { digit: 0, cell: cells[0], list: a },
121
+ { digit: 1, cell: cells[1], list: b },
122
+ ]
123
+ : undefined;
124
+ const lo = axis === 0 ? cell.x : cell.y;
125
+ const size = axis === 0 ? cell.w : cell.h;
126
+ if (line === "median") {
127
+ const order = sortOn(list, centers, axis);
128
+ const k = Math.floor(order.length / 2);
129
+ const rank = new Map(order.map((i, r) => [i, r]));
130
+ const at = (c(order[k - 1]!) + c(order[k]!)) / 2;
131
+ return part(
132
+ halves(cell, axis, at),
133
+ sides(list, (i) => rank.get(i)! < k),
134
+ );
135
+ }
136
+ if (line === "slide") {
137
+ const mid = lo + size / 2;
138
+ let [a, b] = sides(list, (i) => c(i) < mid);
139
+ let at = mid;
140
+ if (a.length === 0) {
141
+ // Each center is at the middle or past it. The line slides to the nearest center, and the nodes at that coordinate go to the first part.
142
+ at = list.reduce((m, i) => Math.min(m, c(i)), Infinity);
143
+ [a, b] = sides(list, (i) => c(i) <= at);
144
+ } else if (b.length === 0) {
145
+ at = list.reduce((m, i) => Math.max(m, c(i)), -Infinity);
146
+ [a, b] = sides(list, (i) => c(i) < at);
147
+ }
148
+ return part(halves(cell, axis, at), [a, b]);
149
+ }
150
+ // The rule `middle`: an empty half makes the cell shrink to the other half, up to `SHRINK` times.
151
+ let box = cell;
152
+ for (let n = 0; n < SHRINK; n++) {
153
+ const blo = axis === 0 ? box.x : box.y;
154
+ const bsize = axis === 0 ? box.w : box.h;
155
+ if (!(bsize > 0)) return undefined;
156
+ const mid = blo + bsize / 2;
157
+ const split = sides(list, (i) => c(i) < mid);
158
+ const cells = halves(box, axis, mid);
159
+ if (split[0].length > 0 && split[1].length > 0) return part(cells, split);
160
+ box = split[0].length > 0 ? cells[0] : cells[1];
161
+ }
162
+ return undefined;
163
+ }
164
+
165
+ /** This function gives the axes of one binary split, in the order to try: the axis of the rule first, then the other axis. */
166
+ function axesOf(cell: Box, axis: KdAxis, step: number): [0 | 1, 0 | 1] {
167
+ const first: 0 | 1 = axis === "longer" ? (cell.w >= cell.h ? 0 : 1) : step % 2 === 0 ? 0 : 1;
168
+ return first === 0 ? [0, 1] : [1, 0];
169
+ }
170
+
171
+ /** This function splits the list in two with the line rule. When no line on the first axis has a node on each side, it tries the other axis. */
172
+ function binarySplit(list: readonly number[], cell: Box, centers: readonly Point[], o: Readonly<Required<KdOptions>>, step: number): [SplitPart, SplitPart] | undefined {
173
+ for (const axis of axesOf(cell, o.axis, step)) {
174
+ const split = lineSplit(list, cell, centers, axis, o.line);
175
+ if (split) return split;
176
+ }
177
+ return undefined;
178
+ }
179
+
180
+ /**
181
+ * This function makes a split rule of fan-out 4 from a binary split. The binary split cuts the section in two. Then it cuts each half with 2 nodes or more.
182
+ * The digit of a quarter is 2 times the digit of its half plus its own digit. A half that does not split keeps the digit of its first quarter.
183
+ */
184
+ export function twoSteps(binary: (list: readonly number[], cell: Box, step: number) => readonly SplitPart[] | undefined, fanout: number): SplitRule {
185
+ return (list, cell, depth) => {
186
+ if (fanout < 4) return binary(list, cell, depth);
187
+ const first = binary(list, cell, 2 * depth);
188
+ if (!first) return undefined;
189
+ return first.flatMap((half) => {
190
+ const second = half.list.length >= 2 ? binary(half.list, half.cell, 2 * depth + 1) : undefined;
191
+ return second ? second.map((q) => ({ ...q, digit: 2 * half.digit + q.digit })) : [{ ...half, digit: 2 * half.digit }];
192
+ });
193
+ };
194
+ }
195
+
196
+ /**
197
+ * This function gives the tree of sections of the grouping rule `kd`: a k-d tree of the plane (S3 of GRP-R).
198
+ * A section with more than `cap` nodes splits in two with a line across one axis. With the fan-out 4, each half splits again on the same level.
199
+ * The cells nest and do not overlap. The line `median` gives halves of equal size.
200
+ */
201
+ export function kdGroups(graph: Graph, options: KdOptions = {}): Section {
202
+ const o: Required<KdOptions> = { ...KD_DEFAULTS, ...options };
203
+ const centers = graph.nodes.map((n) => center(n.box));
204
+ const fanout = o.fanout >= 4 ? 4 : 2;
205
+ const binary = (list: readonly number[], cell: Box, step: number): readonly SplitPart[] | undefined => binarySplit(list, cell, centers, o, step);
206
+ return growSections(graph.nodes, o.cap, o.maxDepth, twoSteps(binary, fanout));
207
+ }