@mark1russell7/highways 0.4.2 → 0.5.1
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 +59 -2
- package/build/index.d.ts +2333 -193
- package/build/index.d.ts.map +1 -1
- package/build/index.js +13042 -2334
- package/build/index.js.map +1 -1
- package/package.json +12 -1
- package/src/bench/exact/index.ts +258 -0
- package/src/bench/exact/optima-exact-v1-bounds.json +14409 -0
- package/src/bench/exact/optima-exact-v1.json +4809 -0
- package/src/bench/exact/solve.ts +129 -0
- package/src/bench/measures/index.ts +1 -0
- package/src/bench/measures/lanes.ts +304 -0
- package/src/bench/measures/levels.ts +86 -2
- package/src/bench/screen/activation.ts +3 -0
- package/src/bench/screen/bounds.ts +140 -0
- package/src/bench/screen/compare.ts +26 -0
- package/src/bench/screen/exact-set.ts +100 -0
- package/src/bench/screen/exercise.ts +290 -0
- package/src/bench/screen/forms.ts +25 -5
- package/src/bench/screen/gap.ts +316 -0
- package/src/bench/screen/hash.ts +7 -76
- package/src/bench/screen/index.ts +6 -0
- package/src/bench/screen/measures.ts +89 -1
- package/src/bench/screen/portfolio-pick.ts +20 -0
- package/src/bench/screen/screen.ts +78 -2
- package/src/bench/screen/set.ts +3 -2
- package/src/channels.ts +56 -0
- package/src/highways/areas/index.ts +160 -0
- package/src/highways/confluent/index.ts +154 -0
- package/src/highways/confluent/k23.test-util.ts +8 -0
- package/src/highways/consensus/index.ts +129 -0
- package/src/highways/crossbars/index.ts +184 -0
- package/src/highways/cubu/index.ts +26 -0
- package/src/highways/diffusion/index.ts +103 -0
- package/src/highways/divided/index.ts +111 -0
- package/src/highways/edge-paths/index.ts +155 -0
- package/src/highways/fdeb/index.ts +193 -0
- package/src/highways/fixtures.test-util.ts +71 -0
- package/src/highways/flow-clusters/index.ts +143 -0
- package/src/highways/frechet/index.ts +232 -0
- package/src/highways/growth/index.ts +148 -0
- package/src/highways/hdbscan/index.ts +250 -0
- package/src/highways/hierarchy/index.ts +140 -0
- package/src/highways/hough/index.ts +182 -0
- package/src/highways/hub-arcs/index.ts +181 -0
- package/src/highways/index.ts +79 -0
- package/src/highways/kdeeb/index.ts +355 -0
- package/src/highways/lanes/index.ts +189 -0
- package/src/highways/link-communities/index.ts +189 -0
- package/src/highways/mesh/index.ts +209 -0
- package/src/highways/mingle/index.ts +171 -0
- package/src/highways/mlseb/index.ts +204 -0
- package/src/highways/nested/index.ts +143 -0
- package/src/highways/p-hub/index.ts +248 -0
- package/src/highways/power/index.ts +117 -0
- package/src/highways/resistance/index.ts +159 -0
- package/src/highways/ridges/index.ts +328 -0
- package/src/highways/sbeb/index.ts +303 -0
- package/src/highways/scale-space/index.ts +135 -0
- package/src/highways/snap.ts +110 -0
- package/src/highways/soft-keys/index.ts +114 -0
- package/src/highways/spectral/index.ts +391 -0
- package/src/highways/spiral/index.ts +205 -0
- package/src/highways/territories/index.ts +150 -0
- package/src/highways/trace.ts +153 -0
- package/src/highways/traclus/index.ts +296 -0
- package/src/index.ts +2 -0
- package/src/portfolio/bands.ts +33 -0
- package/src/portfolio/fit.ts +29 -0
- package/src/portfolio/index.ts +6 -0
- package/src/portfolio/key.ts +98 -0
- package/src/portfolio/members.ts +171 -0
- package/src/portfolio/router.ts +133 -0
- package/src/portfolio/sets.ts +28 -0
- package/src/routers/shared.ts +56 -3
- package/src/strategies/builtins.ts +376 -1
- package/src/strategies/schemas.ts +460 -1
- package/src/visibility.ts +5 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { boardUnit } from "../../bench/measures/routes.ts";
|
|
2
|
+
import { dist, polylineLength, type Point } from "../../geom.ts";
|
|
3
|
+
import { firstRoutes } from "../base.ts";
|
|
4
|
+
import type { HighwayInput, HighwayPlan } from "../model.ts";
|
|
5
|
+
import { snapPlan, type SnapLine, type SnapMember } from "../snap.ts";
|
|
6
|
+
import { edgePlugs } from "../stretches.ts";
|
|
7
|
+
|
|
8
|
+
/** The characteristic points of the territories: the plugs, the bends of the first routes, or both. */
|
|
9
|
+
export type TerritoryPoints = "plugs" | "bends" | "both";
|
|
10
|
+
|
|
11
|
+
/** The values of `TerritoryPoints`. */
|
|
12
|
+
export const TERRITORY_POINTS: readonly TerritoryPoints[] = Object.freeze(["plugs", "bends", "both"]);
|
|
13
|
+
|
|
14
|
+
/** The options of the highway strategy `territories` (HWY-033 of the note on highways). */
|
|
15
|
+
export interface TerritoryOptions {
|
|
16
|
+
/** The radius of a group of points, in units of the board. The default is 4. */
|
|
17
|
+
readonly radius: number;
|
|
18
|
+
/** A flow between two cells becomes a highway when this number of edges or more cross it. The default is 2. */
|
|
19
|
+
readonly minSize: number;
|
|
20
|
+
/** The characteristic points. The default is `both`. */
|
|
21
|
+
readonly points: TerritoryPoints;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The default options of the strategy `territories`. */
|
|
25
|
+
export const TERRITORY_DEFAULTS: Readonly<TerritoryOptions> = Object.freeze({ radius: 4, minSize: 2, points: "both" });
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* This function puts points into groups of the radius `r` (Andrienko and Andrienko 2011). Each point joins the group with the nearest centre within `r`.
|
|
29
|
+
* Else it starts a new group. The centre of a group is the mean of its points. A second pass puts each point into the group of its nearest centre.
|
|
30
|
+
* The result is the centres of the groups that keep points.
|
|
31
|
+
*/
|
|
32
|
+
export function pointGroups(points: readonly Point[], r: number): Point[] {
|
|
33
|
+
const sums: { x: number; y: number; n: number }[] = [];
|
|
34
|
+
const centre = (g: { x: number; y: number; n: number }): Point => ({ x: g.x / g.n, y: g.y / g.n });
|
|
35
|
+
for (const p of points) {
|
|
36
|
+
let best = -1;
|
|
37
|
+
let bestD = Infinity;
|
|
38
|
+
sums.forEach((g, i) => {
|
|
39
|
+
const d = dist(p, centre(g));
|
|
40
|
+
if (d < bestD) [best, bestD] = [i, d];
|
|
41
|
+
});
|
|
42
|
+
if (best >= 0 && bestD <= r) {
|
|
43
|
+
const g = sums[best]!;
|
|
44
|
+
g.x += p.x;
|
|
45
|
+
g.y += p.y;
|
|
46
|
+
g.n++;
|
|
47
|
+
} else sums.push({ x: p.x, y: p.y, n: 1 });
|
|
48
|
+
}
|
|
49
|
+
const first = sums.map(centre);
|
|
50
|
+
const again = first.map(() => ({ x: 0, y: 0, n: 0 }));
|
|
51
|
+
for (const p of points) {
|
|
52
|
+
const i = nearestCentre(first, p);
|
|
53
|
+
again[i]!.x += p.x;
|
|
54
|
+
again[i]!.y += p.y;
|
|
55
|
+
again[i]!.n++;
|
|
56
|
+
}
|
|
57
|
+
return again.filter((g) => g.n > 0).map(centre);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** This function gives the index of the centre nearest to `p`. The first of two equal centres wins. */
|
|
61
|
+
export function nearestCentre(centres: readonly Point[], p: Point): number {
|
|
62
|
+
let best = -1;
|
|
63
|
+
let bestD = Infinity;
|
|
64
|
+
centres.forEach((c, i) => {
|
|
65
|
+
const d = dist(p, c);
|
|
66
|
+
if (d < bestD) [best, bestD] = [i, d];
|
|
67
|
+
});
|
|
68
|
+
return best;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One crossing of a route from the cell `from` into the cell `to`, at the share `at` of the length of the route. */
|
|
72
|
+
export interface CellStep {
|
|
73
|
+
readonly from: number;
|
|
74
|
+
readonly to: number;
|
|
75
|
+
readonly at: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* This function gives the crossings of the cells of a polyline. The cells are the Voronoi cells of the centres.
|
|
80
|
+
* It samples the polyline at the spacing `step`, and gives each change of the nearest centre.
|
|
81
|
+
*/
|
|
82
|
+
export function cellSteps(line: readonly Point[], centres: readonly Point[], step: number): CellStep[] {
|
|
83
|
+
const total = polylineLength(line);
|
|
84
|
+
if (line.length < 2 || centres.length === 0 || total <= 0) return [];
|
|
85
|
+
const out: CellStep[] = [];
|
|
86
|
+
let cell = nearestCentre(centres, line[0]!);
|
|
87
|
+
let walked = 0;
|
|
88
|
+
for (let i = 1; i < line.length; i++) {
|
|
89
|
+
const a = line[i - 1]!;
|
|
90
|
+
const b = line[i]!;
|
|
91
|
+
const len = dist(a, b);
|
|
92
|
+
const n = Math.max(1, Math.ceil(len / Math.max(1e-6, step)));
|
|
93
|
+
for (let k = 1; k <= n; k++) {
|
|
94
|
+
const t = k / n;
|
|
95
|
+
const c = nearestCentre(centres, { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
|
|
96
|
+
if (c !== cell) {
|
|
97
|
+
out.push({ from: cell, to: c, at: (walked + len * t) / total });
|
|
98
|
+
cell = c;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
walked += len;
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* This function gives the highways of the territories of the flows (HWY-033, Andrienko and Andrienko 2011).
|
|
108
|
+
* The characteristic points form groups of the radius `radius`. The centres of the groups make Voronoi cells.
|
|
109
|
+
* Each first route becomes a list of crossings between neighbour cells.
|
|
110
|
+
*
|
|
111
|
+
* A pair of cells with `minSize` crossings or more is a line from centre to centre. The edges that cross it snap to it. The centres of the cells are the interchanges.
|
|
112
|
+
*/
|
|
113
|
+
export function territoryPlan(input: HighwayInput, o: Readonly<TerritoryOptions>): HighwayPlan {
|
|
114
|
+
const graph = input.graph;
|
|
115
|
+
const unit = boardUnit(graph);
|
|
116
|
+
const r = Math.max(0.5, o.radius) * unit;
|
|
117
|
+
const routes = firstRoutes(input);
|
|
118
|
+
const plugs = edgePlugs(graph);
|
|
119
|
+
const points: Point[] = [];
|
|
120
|
+
for (const { edge, ps, pt } of plugs) {
|
|
121
|
+
if (o.points !== "bends") points.push(ps.point, pt.point);
|
|
122
|
+
if (o.points !== "plugs") {
|
|
123
|
+
const route = routes.get(edge.id) ?? [];
|
|
124
|
+
for (let i = 1; i < route.length - 1; i++) points.push(route[i]!);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (points.length === 0) return { highways: [] };
|
|
128
|
+
const centres = pointGroups(points, r);
|
|
129
|
+
// The crossings of each pair of cells, in the order of the edges.
|
|
130
|
+
const flows = new Map<string, { a: number; b: number; members: SnapMember[] }>();
|
|
131
|
+
for (const { edge, ps, pt } of plugs) {
|
|
132
|
+
const route = routes.get(edge.id) ?? [ps.point, pt.point];
|
|
133
|
+
const seen = new Set<string>();
|
|
134
|
+
for (const s of cellSteps(route, centres, r / 4)) {
|
|
135
|
+
const [a, b] = s.from < s.to ? [s.from, s.to] : [s.to, s.from];
|
|
136
|
+
const key = `${a}:${b}`;
|
|
137
|
+
if (seen.has(key)) continue;
|
|
138
|
+
seen.add(key);
|
|
139
|
+
const flow = flows.get(key) ?? { a, b, members: [] };
|
|
140
|
+
flow.members.push({ edge: edge.id, from: centres[s.from]!, to: centres[s.to]!, at: s.at });
|
|
141
|
+
flows.set(key, flow);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const min = Math.max(2, Math.round(o.minSize));
|
|
145
|
+
const lines: SnapLine[] = [...flows.values()]
|
|
146
|
+
.filter((f) => f.members.length >= min)
|
|
147
|
+
.sort((p, q) => p.a - q.a || p.b - q.b)
|
|
148
|
+
.map((f) => ({ key: `t${f.a}-${f.b}`, a: centres[f.a]!, b: centres[f.b]!, members: f.members }));
|
|
149
|
+
return snapPlan(input, lines, { minSize: min });
|
|
150
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { dist, type Point } from "../geom.ts";
|
|
2
|
+
import type { Obstacles } from "../visibility.ts";
|
|
3
|
+
import type { WayFinder } from "../ways.ts";
|
|
4
|
+
import type { HighwayInput, HighwayPlan } from "./model.ts";
|
|
5
|
+
import { edgePlugs, sharedStretches, type KeyedWay } from "./stretches.ts";
|
|
6
|
+
|
|
7
|
+
/** The options of `tracePlan`. */
|
|
8
|
+
export interface TraceOptions {
|
|
9
|
+
/** The side of the grid that the waypoints snap to, in units of the board. The value 0 keeps each waypoint where it is. */
|
|
10
|
+
readonly snap: number;
|
|
11
|
+
/** A highway has this number of edges or more. The value is 2 or more. */
|
|
12
|
+
readonly minSize: number;
|
|
13
|
+
/** The key of the ways. Only the ways with one key share a highway. */
|
|
14
|
+
readonly key: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* This function moves a point out of each box, made larger by `pad`. A point in a box moves to the nearest side of that box.
|
|
19
|
+
* The function tries four times, so a point between two near boxes can stay in a box.
|
|
20
|
+
*/
|
|
21
|
+
export function outOfBoxes(obstacles: Obstacles, p: Point, pad: number): Point {
|
|
22
|
+
let q = p;
|
|
23
|
+
for (let k = 0; k < 4; k++) {
|
|
24
|
+
let hit: { x: number; y: number; w: number; h: number } | undefined;
|
|
25
|
+
obstacles.query({ x: q.x - pad, y: q.y - pad, w: 2 * pad, h: 2 * pad }, (n) => {
|
|
26
|
+
const b = n.box;
|
|
27
|
+
if (!hit && q.x > b.x - pad && q.x < b.x + b.w + pad && q.y > b.y - pad && q.y < b.y + b.h + pad) hit = b;
|
|
28
|
+
});
|
|
29
|
+
if (!hit) return q;
|
|
30
|
+
const b = hit;
|
|
31
|
+
const eps = 1e-3;
|
|
32
|
+
const moves = [
|
|
33
|
+
{ d: q.x - (b.x - pad), p: { x: b.x - pad - eps, y: q.y } },
|
|
34
|
+
{ d: b.x + b.w + pad - q.x, p: { x: b.x + b.w + pad + eps, y: q.y } },
|
|
35
|
+
{ d: q.y - (b.y - pad), p: { x: q.x, y: b.y - pad - eps } },
|
|
36
|
+
{ d: b.y + b.h + pad - q.y, p: { x: q.x, y: b.y + b.h + pad + eps } },
|
|
37
|
+
];
|
|
38
|
+
moves.sort((u, v) => u.d - v.d);
|
|
39
|
+
q = moves[0]!.p;
|
|
40
|
+
}
|
|
41
|
+
return q;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* This function gives the push of the boxes on a point. Each box nearer than `reach` adds a unit vector away from it, times `(reach - d) / reach`.
|
|
46
|
+
* The value `d` is the distance from the point to the box. It is the gradient of a field that is 0 at the distance `reach` from each box.
|
|
47
|
+
*/
|
|
48
|
+
export function boxPush(obstacles: Obstacles, p: Point, reach: number): Point {
|
|
49
|
+
let x = 0;
|
|
50
|
+
let y = 0;
|
|
51
|
+
if (reach <= 0) return { x, y };
|
|
52
|
+
obstacles.query({ x: p.x - reach, y: p.y - reach, w: 2 * reach, h: 2 * reach }, (n) => {
|
|
53
|
+
const b = n.box;
|
|
54
|
+
const qx = Math.max(b.x, Math.min(b.x + b.w, p.x));
|
|
55
|
+
const qy = Math.max(b.y, Math.min(b.y + b.h, p.y));
|
|
56
|
+
let dx = p.x - qx;
|
|
57
|
+
let dy = p.y - qy;
|
|
58
|
+
let d = Math.hypot(dx, dy);
|
|
59
|
+
if (d >= reach) return;
|
|
60
|
+
if (d < 1e-9) {
|
|
61
|
+
// The point is in the box: it goes out through the nearest side.
|
|
62
|
+
const sides = [
|
|
63
|
+
{ d: p.x - b.x, v: [-1, 0] },
|
|
64
|
+
{ d: b.x + b.w - p.x, v: [1, 0] },
|
|
65
|
+
{ d: p.y - b.y, v: [0, -1] },
|
|
66
|
+
{ d: b.y + b.h - p.y, v: [0, 1] },
|
|
67
|
+
].sort((u, v) => u.d - v.d);
|
|
68
|
+
[dx, dy] = sides[0]!.v as [number, number];
|
|
69
|
+
d = 0;
|
|
70
|
+
} else {
|
|
71
|
+
dx /= d;
|
|
72
|
+
dy /= d;
|
|
73
|
+
}
|
|
74
|
+
const f = (reach - d) / reach;
|
|
75
|
+
x += dx * f;
|
|
76
|
+
y += dy * f;
|
|
77
|
+
});
|
|
78
|
+
return { x, y };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** This function gives points at equal steps of `step` or a little less along a polyline, without its two ends. A polyline without length gives no point. */
|
|
82
|
+
export function innerPoints(line: readonly Point[], step: number): Point[] {
|
|
83
|
+
const at: number[] = [0];
|
|
84
|
+
for (let i = 1; i < line.length; i++) at.push(at[i - 1]! + dist(line[i - 1]!, line[i]!));
|
|
85
|
+
const total = at[at.length - 1] ?? 0;
|
|
86
|
+
if (total <= 1e-9 || step <= 0) return [];
|
|
87
|
+
const count = Math.floor(total / step);
|
|
88
|
+
const out: Point[] = [];
|
|
89
|
+
let j = 1;
|
|
90
|
+
for (let k = 1; k <= count; k++) {
|
|
91
|
+
const s = (total * k) / (count + 1);
|
|
92
|
+
while (j < line.length - 1 && at[j]! < s) j++;
|
|
93
|
+
const a = line[j - 1]!;
|
|
94
|
+
const b = line[j]!;
|
|
95
|
+
const len = at[j]! - at[j - 1]!;
|
|
96
|
+
const u = len > 0 ? Math.max(0, Math.min(1, (s - at[j - 1]!) / len)) : 0;
|
|
97
|
+
out.push({ x: a.x + (b.x - a.x) * u, y: a.y + (b.y - a.y) * u });
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* This function gives the way of one edge through its waypoints. The way starts at `a` and ends at `b`.
|
|
104
|
+
* Each waypoint snaps to the grid of side `snap`. A waypoint in a box moves to the nearest corner of the corner graph.
|
|
105
|
+
* From each waypoint to the next one, the way is a straight line when that line is clear. Otherwise it goes through the corner graph.
|
|
106
|
+
* A waypoint that the graph does not reach is not on the way. When the way cannot reach `b`, the function gives the way of the corner graph from `a` to `b`.
|
|
107
|
+
*/
|
|
108
|
+
export function waypointWay(finder: WayFinder, a: Point, b: Point, waypoints: readonly Point[], snap: number): Point[] | undefined {
|
|
109
|
+
const corners = finder.corners;
|
|
110
|
+
const pad = finder.margin / 2;
|
|
111
|
+
const fixed: Point[] = [];
|
|
112
|
+
for (const w of waypoints) {
|
|
113
|
+
let q = snap > 0 ? { x: Math.round(w.x / snap) * snap, y: Math.round(w.y / snap) * snap } : w;
|
|
114
|
+
if (finder.obstacles.inside(q, pad)) {
|
|
115
|
+
const i = corners.nearest(q);
|
|
116
|
+
if (i < 0) continue;
|
|
117
|
+
q = corners.points[i]!;
|
|
118
|
+
}
|
|
119
|
+
const last = fixed[fixed.length - 1];
|
|
120
|
+
if (last && dist(last, q) <= 1e-9) continue;
|
|
121
|
+
fixed.push(q);
|
|
122
|
+
}
|
|
123
|
+
const out: Point[] = [a];
|
|
124
|
+
let at = a;
|
|
125
|
+
for (const q of fixed) {
|
|
126
|
+
if (dist(at, q) <= 1e-9) continue;
|
|
127
|
+
const piece = corners.path(at, q);
|
|
128
|
+
if (!piece) continue;
|
|
129
|
+
out.push(...piece.slice(1));
|
|
130
|
+
at = q;
|
|
131
|
+
}
|
|
132
|
+
const tail = dist(at, b) <= 1e-9 ? [b] : corners.path(at, b);
|
|
133
|
+
if (!tail) return corners.path(a, b);
|
|
134
|
+
out.push(...tail.slice(1));
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* This function gives the highways of the waypoints of each edge. The ways go through the corner graph, so they stay out of the boxes.
|
|
140
|
+
* Each edge goes from the point at `margin` from its source plug, through its waypoints, to the point at `margin` from its target plug.
|
|
141
|
+
* Each stretch that `minSize` ways or more share is one highway, with the needless corners removed.
|
|
142
|
+
* An edge without waypoints takes its way of the corner graph.
|
|
143
|
+
*/
|
|
144
|
+
export function tracePlan(input: HighwayInput, finder: WayFinder, waypoints: ReadonlyMap<string, readonly Point[]>, o: Readonly<TraceOptions>): HighwayPlan {
|
|
145
|
+
const ways: KeyedWay[] = [];
|
|
146
|
+
for (const { edge, ps, pt } of edgePlugs(input.graph)) {
|
|
147
|
+
const a = finder.out(ps);
|
|
148
|
+
const b = finder.out(pt);
|
|
149
|
+
const way = waypointWay(finder, a, b, waypoints.get(edge.id) ?? [], o.snap);
|
|
150
|
+
if (way) ways.push({ edge: edge.id, key: o.key, points: way });
|
|
151
|
+
}
|
|
152
|
+
return sharedStretches(ways, { minSize: o.minSize, pull: (p) => finder.corners.pull(p) });
|
|
153
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { boardUnit } from "../../bench/measures/routes.ts";
|
|
2
|
+
import { dist, pointSegmentDistance, polylineLength, type Point } from "../../geom.ts";
|
|
3
|
+
import { orderLanes } from "../../segments.ts";
|
|
4
|
+
import { WayFinder, type WayOptions } from "../../ways.ts";
|
|
5
|
+
import { firstRoutes, nearestPlace, orderAlongRoutes, tidyPlan } from "../base.ts";
|
|
6
|
+
import { lengthBetween, memberAt, type Highway, type HighwayInput, type HighwayMember, type HighwayPlan } from "../model.ts";
|
|
7
|
+
|
|
8
|
+
/** The options of the highway strategy `traclus` (HWY-025 of the note on highways). */
|
|
9
|
+
export interface TraclusOptions extends Pick<WayOptions, "margin" | "k"> {
|
|
10
|
+
/** Two segments are neighbours when their distance is this value or less, in units of the board. The default is 2. */
|
|
11
|
+
readonly eps: number;
|
|
12
|
+
/** A cluster has segments of this number of edges or more, and a core segment has this number of neighbours or more. The default is 3. */
|
|
13
|
+
readonly minLns: number;
|
|
14
|
+
/** The weight of the perpendicular distance. The default is 1. */
|
|
15
|
+
readonly wPerp: number;
|
|
16
|
+
/** The weight of the parallel distance. The default is 1. */
|
|
17
|
+
readonly wPar: number;
|
|
18
|
+
/** The weight of the angle distance. The default is 1. */
|
|
19
|
+
readonly wTheta: number;
|
|
20
|
+
/** A route keeps a corner when the cost of the line without it is more than this value times the cost with it. The default is 1. */
|
|
21
|
+
readonly mdl: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The default options of the strategy `traclus`. */
|
|
25
|
+
export const TRACLUS_DEFAULTS: Readonly<Required<Omit<TraclusOptions, "margin" | "k">>> = Object.freeze({ eps: 2, minLns: 3, wPerp: 1, wPar: 1, wTheta: 1, mdl: 1 });
|
|
26
|
+
|
|
27
|
+
/** One segment of a partitioned route: the edge, the index of the segment in the route, and its two ends. */
|
|
28
|
+
export interface RouteSegment {
|
|
29
|
+
readonly edge: string;
|
|
30
|
+
readonly index: number;
|
|
31
|
+
readonly a: Point;
|
|
32
|
+
readonly b: Point;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** This function gives the distance of the point `p` to the line through `a` and `b`. It also gives the place of its projection as a share of `ab`. */
|
|
36
|
+
function project(p: Point, a: Point, b: Point): { perp: number; u: number } {
|
|
37
|
+
const dx = b.x - a.x;
|
|
38
|
+
const dy = b.y - a.y;
|
|
39
|
+
const len2 = dx * dx + dy * dy;
|
|
40
|
+
if (len2 < 1e-12) return { perp: dist(p, a), u: 0 };
|
|
41
|
+
const u = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2;
|
|
42
|
+
return { perp: Math.abs((p.x - a.x) * dy - (p.y - a.y) * dx) / Math.sqrt(len2), u };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* This function gives the TRACLUS distance of two segments (Lee, Han and Whang 2007): `w_perp d_perp + w_par d_par + w_theta d_theta`.
|
|
47
|
+
* `d_perp` is `(l1² + l2²) / (l1 + l2)` with the distances of the ends of the shorter segment to the line of the longer segment.
|
|
48
|
+
* `d_par` is the gap along the longer segment between the two segments, 0 when they overlap. `d_theta` is `|shorter| sin(theta)`.
|
|
49
|
+
* The segments have no direction, so the angle is 90 degrees or less.
|
|
50
|
+
*/
|
|
51
|
+
export function segmentDistance(p: { a: Point; b: Point }, q: { a: Point; b: Point }, w: { perp: number; par: number; theta: number }): number {
|
|
52
|
+
const [long, short] = dist(p.a, p.b) >= dist(q.a, q.b) ? [p, q] : [q, p];
|
|
53
|
+
const len = dist(long.a, long.b);
|
|
54
|
+
const s = project(short.a, long.a, long.b);
|
|
55
|
+
const e = project(short.b, long.a, long.b);
|
|
56
|
+
const perp = s.perp + e.perp > 1e-12 ? (s.perp * s.perp + e.perp * e.perp) / (s.perp + e.perp) : 0;
|
|
57
|
+
const lo = Math.min(s.u, e.u);
|
|
58
|
+
const hi = Math.max(s.u, e.u);
|
|
59
|
+
const par = (hi < 0 ? -hi : lo > 1 ? lo - 1 : 0) * len;
|
|
60
|
+
const shortLen = dist(short.a, short.b);
|
|
61
|
+
let theta = 0;
|
|
62
|
+
if (len > 1e-12 && shortLen > 1e-12) {
|
|
63
|
+
const cos = Math.abs(((long.b.x - long.a.x) * (short.b.x - short.a.x) + (long.b.y - long.a.y) * (short.b.y - short.a.y)) / (len * shortLen));
|
|
64
|
+
theta = shortLen * Math.sqrt(Math.max(0, 1 - cos * cos));
|
|
65
|
+
}
|
|
66
|
+
return w.perp * perp + w.par * par + w.theta * theta;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* This function gives the characteristic points of a route by the rule of the minimum description length (MDL) of TRACLUS.
|
|
71
|
+
* A partition from point `i` to point `j` costs `L(H) + L(D|H)`. `L(H)` is the log of its length, and `L(D|H)` the logs of the perpendicular and angle distances of the route parts.
|
|
72
|
+
* The route keeps the point before `j` when that cost is more than `ratio` times the cost of the route parts. The lengths are in units of the board.
|
|
73
|
+
*/
|
|
74
|
+
export function characteristicPoints(route: readonly Point[], unit: number, ratio: number): Point[] {
|
|
75
|
+
const n = route.length;
|
|
76
|
+
if (n <= 2) return [...route];
|
|
77
|
+
const lg = (x: number): number => Math.log2(1 + x / unit);
|
|
78
|
+
const costs = (i: number, j: number): { par: number; nopar: number } => {
|
|
79
|
+
const a = route[i]!;
|
|
80
|
+
const b = route[j]!;
|
|
81
|
+
let perp = 0;
|
|
82
|
+
let theta = 0;
|
|
83
|
+
let nopar = 0;
|
|
84
|
+
for (let k = i; k < j; k++) {
|
|
85
|
+
const part = { a: route[k]!, b: route[k + 1]! };
|
|
86
|
+
perp += segmentDistance({ a, b }, part, { perp: 1, par: 0, theta: 0 });
|
|
87
|
+
theta += segmentDistance({ a, b }, part, { perp: 0, par: 0, theta: 1 });
|
|
88
|
+
nopar += lg(dist(part.a, part.b));
|
|
89
|
+
}
|
|
90
|
+
return { par: lg(dist(a, b)) + lg(perp) + lg(theta), nopar };
|
|
91
|
+
};
|
|
92
|
+
const out: Point[] = [route[0]!];
|
|
93
|
+
let start = 0;
|
|
94
|
+
let length = 1;
|
|
95
|
+
while (start + length < n) {
|
|
96
|
+
const curr = start + length;
|
|
97
|
+
const c = costs(start, curr);
|
|
98
|
+
if (c.par > ratio * c.nopar && curr - 1 > start) {
|
|
99
|
+
out.push(route[curr - 1]!);
|
|
100
|
+
start = curr - 1;
|
|
101
|
+
length = 1;
|
|
102
|
+
} else length++;
|
|
103
|
+
}
|
|
104
|
+
out.push(route[n - 1]!);
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* This function gives the clusters of segments of DBSCAN. A segment with `minLns` neighbours or more within `eps` (itself included) is a core segment.
|
|
110
|
+
* A cluster grows from each core segment through its neighbours. A cluster with segments of fewer than `minLns` edges is dropped.
|
|
111
|
+
*/
|
|
112
|
+
export function segmentClusters(segments: readonly RouteSegment[], eps: number, minLns: number, w: { perp: number; par: number; theta: number }): RouteSegment[][] {
|
|
113
|
+
const n = segments.length;
|
|
114
|
+
// A grid of cells of side `eps` prunes the pairs. Two segments within `eps` have boxes within `eps` of each other in each axis.
|
|
115
|
+
const near: number[][] = segments.map(() => []);
|
|
116
|
+
const box = segments.map((s) => ({ x0: Math.min(s.a.x, s.b.x), y0: Math.min(s.a.y, s.b.y), x1: Math.max(s.a.x, s.b.x), y1: Math.max(s.a.y, s.b.y) }));
|
|
117
|
+
for (let i = 0; i < n; i++) {
|
|
118
|
+
near[i]!.push(i);
|
|
119
|
+
for (let j = i + 1; j < n; j++) {
|
|
120
|
+
const a = box[i]!;
|
|
121
|
+
const b = box[j]!;
|
|
122
|
+
if (a.x0 > b.x1 + eps || b.x0 > a.x1 + eps || a.y0 > b.y1 + eps || b.y0 > a.y1 + eps) continue;
|
|
123
|
+
if (segmentDistance(segments[i]!, segments[j]!, w) <= eps) {
|
|
124
|
+
near[i]!.push(j);
|
|
125
|
+
near[j]!.push(i);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const label = new Int32Array(n).fill(-1);
|
|
130
|
+
const out: RouteSegment[][] = [];
|
|
131
|
+
for (let i = 0; i < n; i++) {
|
|
132
|
+
if (label[i] !== -1 || near[i]!.length < minLns) continue;
|
|
133
|
+
const id = out.length;
|
|
134
|
+
const members: number[] = [];
|
|
135
|
+
const queue = [i];
|
|
136
|
+
label[i] = id;
|
|
137
|
+
while (queue.length > 0) {
|
|
138
|
+
const k = queue.shift()!;
|
|
139
|
+
members.push(k);
|
|
140
|
+
if (near[k]!.length < minLns) continue;
|
|
141
|
+
for (const j of near[k]!) {
|
|
142
|
+
if (label[j] !== -1) continue;
|
|
143
|
+
label[j] = id;
|
|
144
|
+
queue.push(j);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
out.push(members.sort((a, b) => a - b).map((k) => segments[k]!));
|
|
148
|
+
}
|
|
149
|
+
return out.filter((c) => new Set(c.map((s) => s.edge)).size >= minLns);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* This function gives the representative line of a cluster of segments. The line goes along the mean direction of the segments.
|
|
154
|
+
* A sweep along that direction stops at each end of a segment. Where `minLns` segments or more cover the stop, the point is the mean of their points there.
|
|
155
|
+
* Two points of the line are at least `gap` apart.
|
|
156
|
+
*/
|
|
157
|
+
export function representative(cluster: readonly RouteSegment[], minLns: number, gap: number): Point[] {
|
|
158
|
+
const first = cluster[0]!;
|
|
159
|
+
const fx = first.b.x - first.a.x;
|
|
160
|
+
const fy = first.b.y - first.a.y;
|
|
161
|
+
let vx = 0;
|
|
162
|
+
let vy = 0;
|
|
163
|
+
for (const s of cluster) {
|
|
164
|
+
let dx = s.b.x - s.a.x;
|
|
165
|
+
let dy = s.b.y - s.a.y;
|
|
166
|
+
const len = Math.hypot(dx, dy);
|
|
167
|
+
if (len < 1e-9) continue;
|
|
168
|
+
if (dx * fx + dy * fy < 0) [dx, dy] = [-dx, -dy];
|
|
169
|
+
vx += dx / len;
|
|
170
|
+
vy += dy / len;
|
|
171
|
+
}
|
|
172
|
+
const vl = Math.hypot(vx, vy);
|
|
173
|
+
if (vl < 1e-9) return [];
|
|
174
|
+
const ux = vx / vl;
|
|
175
|
+
const uy = vy / vl;
|
|
176
|
+
const along = (p: Point): number => p.x * ux + p.y * uy;
|
|
177
|
+
const across = (p: Point): number => -p.x * uy + p.y * ux;
|
|
178
|
+
const spans = cluster.map((s) => {
|
|
179
|
+
const [p, q] = along(s.a) <= along(s.b) ? [s.a, s.b] : [s.b, s.a];
|
|
180
|
+
return { x0: along(p), y0: across(p), x1: along(q), y1: across(q) };
|
|
181
|
+
});
|
|
182
|
+
const stops = [...new Set(spans.flatMap((s) => [s.x0, s.x1]))].sort((a, b) => a - b);
|
|
183
|
+
const out: Point[] = [];
|
|
184
|
+
let lastX = -Infinity;
|
|
185
|
+
for (const x of stops) {
|
|
186
|
+
const cover = spans.filter((s) => s.x0 <= x + 1e-9 && s.x1 >= x - 1e-9);
|
|
187
|
+
if (cover.length < minLns) continue;
|
|
188
|
+
if (x - lastX < gap) continue;
|
|
189
|
+
let y = 0;
|
|
190
|
+
for (const s of cover) y += s.x1 - s.x0 > 1e-9 ? s.y0 + ((s.y1 - s.y0) * (x - s.x0)) / (s.x1 - s.x0) : (s.y0 + s.y1) / 2;
|
|
191
|
+
y /= cover.length;
|
|
192
|
+
out.push({ x: x * ux - y * uy, y: x * uy + y * ux });
|
|
193
|
+
lastX = x;
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** This function gives the distance of the point `p` to the polyline `line`. */
|
|
199
|
+
function lineDistance(line: readonly Point[], p: Point): number {
|
|
200
|
+
let best = Infinity;
|
|
201
|
+
for (let i = 1; i < line.length; i++) best = Math.min(best, pointSegmentDistance(p, line[i - 1]!, line[i]!));
|
|
202
|
+
return best;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The largest ratio of the length along the spine of a member to the length of its own route between the same ends. */
|
|
206
|
+
const DETOUR = 1.25;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* This function gives the highways of TRACLUS (HWY-025). Each first route breaks into a few long segments by the rule of MDL.
|
|
210
|
+
* DBSCAN puts the segments that are near in distance and angle into clusters. Each cluster with segments of `minLns` edges or more is one highway.
|
|
211
|
+
* The spine is the representative line of the cluster, or a way through the corner graph between its ends when the line hits a box.
|
|
212
|
+
* Each edge of the cluster joins the spine nearest to the first end of its segments. It leaves the spine nearest to the last end.
|
|
213
|
+
* An edge rides only when both ends of its part are within `eps` of the spine.
|
|
214
|
+
* Its length on the spine is also at most 1.25 times the length of its own route there.
|
|
215
|
+
* Each edge rides one highway at most: the highway where it rides the longest length.
|
|
216
|
+
*/
|
|
217
|
+
export function traclusPlan(input: HighwayInput, o: Readonly<TraclusOptions>): HighwayPlan {
|
|
218
|
+
const graph = input.graph;
|
|
219
|
+
const unit = boardUnit(graph);
|
|
220
|
+
const finder = new WayFinder(graph.nodes, { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) });
|
|
221
|
+
const pad = finder.margin / 2;
|
|
222
|
+
const routes = firstRoutes(input);
|
|
223
|
+
const segments: RouteSegment[] = [];
|
|
224
|
+
const corners = new Map<string, Point[]>();
|
|
225
|
+
for (const e of graph.edges) {
|
|
226
|
+
const route = routes.get(e.id);
|
|
227
|
+
if (!route || route.length < 2) continue;
|
|
228
|
+
const cps = characteristicPoints(route, unit, o.mdl);
|
|
229
|
+
corners.set(e.id, cps);
|
|
230
|
+
for (let i = 1; i < cps.length; i++) if (dist(cps[i - 1]!, cps[i]!) > 1e-9) segments.push({ edge: e.id, index: i - 1, a: cps[i - 1]!, b: cps[i]! });
|
|
231
|
+
}
|
|
232
|
+
const minLns = Math.max(2, Math.round(o.minLns));
|
|
233
|
+
const w = { perp: o.wPerp, par: o.wPar, theta: o.wTheta };
|
|
234
|
+
const eps = o.eps * unit;
|
|
235
|
+
const clusters = segmentClusters(segments, eps, minLns, w);
|
|
236
|
+
type Placed = { edge: string; on: number; off: number; ride: number };
|
|
237
|
+
const found: { spine: Point[]; placed: Placed[] }[] = [];
|
|
238
|
+
for (const cluster of clusters) {
|
|
239
|
+
const line = representative(cluster, minLns, finder.margin);
|
|
240
|
+
if (line.length < 2 || polylineLength(line) <= finder.margin) continue;
|
|
241
|
+
let spine: Point[] | undefined = line;
|
|
242
|
+
if (!finder.obstacles.clearPath(line, pad) || line.some((p) => finder.obstacles.inside(p, pad))) {
|
|
243
|
+
const a = line[0]!;
|
|
244
|
+
const b = line[line.length - 1]!;
|
|
245
|
+
const way = finder.obstacles.inside(a, pad) || finder.obstacles.inside(b, pad) ? undefined : finder.corners.path(a, b);
|
|
246
|
+
spine = way ? finder.corners.pull(way) : undefined;
|
|
247
|
+
}
|
|
248
|
+
if (!spine || spine.length < 2 || polylineLength(spine) <= finder.margin) continue;
|
|
249
|
+
// The part of each edge in the cluster: its first and its last segment in the order of its route.
|
|
250
|
+
const parts = new Map<string, { first: RouteSegment; last: RouteSegment }>();
|
|
251
|
+
for (const s of cluster) {
|
|
252
|
+
const p = parts.get(s.edge);
|
|
253
|
+
if (!p) parts.set(s.edge, { first: s, last: s });
|
|
254
|
+
else {
|
|
255
|
+
if (s.index < p.first.index) p.first = s;
|
|
256
|
+
if (s.index > p.last.index) p.last = s;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const placed: Placed[] = [];
|
|
260
|
+
for (const [edge, p] of parts) {
|
|
261
|
+
if (lineDistance(spine, p.first.a) > eps || lineDistance(spine, p.last.b) > eps) continue;
|
|
262
|
+
const on = nearestPlace(spine, p.first.a);
|
|
263
|
+
const off = nearestPlace(spine, p.last.b);
|
|
264
|
+
const ride = lengthBetween(spine, Math.min(on, off), Math.max(on, off));
|
|
265
|
+
const own = polylineLength(corners.get(edge)!.slice(p.first.index, p.last.index + 2));
|
|
266
|
+
if (ride > finder.margin && ride <= DETOUR * own + finder.margin) placed.push({ edge, on, off, ride });
|
|
267
|
+
}
|
|
268
|
+
if (placed.length >= 2) found.push({ spine, placed });
|
|
269
|
+
}
|
|
270
|
+
// Each edge keeps the highway where it rides the longest length. The first highway wins a tie.
|
|
271
|
+
const best = new Map<string, { at: number; ride: number }>();
|
|
272
|
+
found.forEach((f, at) => {
|
|
273
|
+
for (const x of f.placed) if ((best.get(x.edge)?.ride ?? -1) < x.ride) best.set(x.edge, { at, ride: x.ride });
|
|
274
|
+
});
|
|
275
|
+
const highways: Highway[] = [];
|
|
276
|
+
found.forEach((f, at) => {
|
|
277
|
+
const placed = f.placed.filter((x) => best.get(x.edge)!.at === at);
|
|
278
|
+
if (placed.length < 2) return;
|
|
279
|
+
const route = f.spine;
|
|
280
|
+
// The lane order reads the plugs of each edge on each side of the spine.
|
|
281
|
+
const trip = (x: Placed): Point[] => {
|
|
282
|
+
const own = routes.get(x.edge)!;
|
|
283
|
+
const [from, to] = [own[0]!, own[own.length - 1]!];
|
|
284
|
+
return x.on <= x.off ? [from, ...route, to] : [to, ...route, from];
|
|
285
|
+
};
|
|
286
|
+
const lanes = orderLanes(
|
|
287
|
+
route,
|
|
288
|
+
placed.map((x) => ({ edge: x.edge, points: trip(x) })),
|
|
289
|
+
"bundle",
|
|
290
|
+
);
|
|
291
|
+
const byEdge = new Map(placed.map((x) => [x.edge, x]));
|
|
292
|
+
const members: HighwayMember[] = lanes.map((edge) => memberAt(route, edge, byEdge.get(edge)!.on, byEdge.get(edge)!.off));
|
|
293
|
+
highways.push({ id: `h${highways.length}`, key: `t${highways.length}`, spine: route, lanes, members });
|
|
294
|
+
});
|
|
295
|
+
return tidyPlan(orderAlongRoutes({ highways }, routes));
|
|
296
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export * from "./plugs.ts";
|
|
|
7
7
|
export * from "./curves.ts";
|
|
8
8
|
export * from "./curves/index.ts";
|
|
9
9
|
export * from "./visibility.ts";
|
|
10
|
+
export * from "./channels.ts";
|
|
10
11
|
export * from "./ways.ts";
|
|
11
12
|
export * from "./segments.ts";
|
|
12
13
|
export * from "./sections.ts";
|
|
@@ -31,3 +32,4 @@ export * from "./bench/screen/index.ts";
|
|
|
31
32
|
export * from "./highways/index.ts";
|
|
32
33
|
export * from "./order/index.ts";
|
|
33
34
|
export * from "./repair/index.ts";
|
|
35
|
+
export * from "./portfolio/index.ts";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** The rule of the caller. Under `collection`, a shared segment holds edges of one collection only. Under `all`, collections can mix. */
|
|
2
|
+
export type PortfolioRule = "collection" | "all";
|
|
3
|
+
|
|
4
|
+
/** The stretch caps of the portfolio router: the caps of the gap closed (B10). */
|
|
5
|
+
export type PortfolioCap = 1.1 | 1.25 | 1.5;
|
|
6
|
+
|
|
7
|
+
/** The rules of the portfolio router. */
|
|
8
|
+
export const PORTFOLIO_RULES: readonly PortfolioRule[] = ["collection", "all"];
|
|
9
|
+
|
|
10
|
+
/** The caps of the portfolio router. */
|
|
11
|
+
export const PORTFOLIO_CAPS: readonly PortfolioCap[] = [1.1, 1.25, 1.5];
|
|
12
|
+
|
|
13
|
+
/** The sizes of the sets of the portfolio router, without the base. */
|
|
14
|
+
export const PORTFOLIO_KS_API: readonly (3 | 5 | 8)[] = [3, 5, 8];
|
|
15
|
+
|
|
16
|
+
/** One size band of the inputs. Each band has its own greedy sets. */
|
|
17
|
+
export interface PortfolioBand {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
/** The largest node count of the band. An input with more nodes than the last band uses the last band. */
|
|
20
|
+
readonly maxNodes: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The size bands: up to 12 nodes, 13 to 24 nodes, and 25 to 40 nodes. An input above 40 nodes uses the band `n25-40`. */
|
|
24
|
+
export const PORTFOLIO_BANDS: readonly PortfolioBand[] = [
|
|
25
|
+
{ id: "n1-12", maxNodes: 12 },
|
|
26
|
+
{ id: "n13-24", maxNodes: 24 },
|
|
27
|
+
{ id: "n25-40", maxNodes: 40 },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** This function gives the band of a node count: the first band whose `maxNodes` is the count or more, else the last band. */
|
|
31
|
+
export function portfolioBand(nodes: number, bands: readonly PortfolioBand[] = PORTFOLIO_BANDS): PortfolioBand {
|
|
32
|
+
return bands.find((b) => nodes <= b.maxNodes) ?? bands[bands.length - 1]!;
|
|
33
|
+
}
|