@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,303 @@
|
|
|
1
|
+
import { boardUnit } from "../../bench/measures/routes.ts";
|
|
2
|
+
import { dist, type Point } from "../../geom.ts";
|
|
3
|
+
import { byCollection } from "../../routers/trunks.ts";
|
|
4
|
+
import { WayFinder, type WayOptions } from "../../ways.ts";
|
|
5
|
+
import { firstRoutes } from "../base.ts";
|
|
6
|
+
import type { EdgeGroup } from "../grouped.ts";
|
|
7
|
+
import type { DensityGrid, DensitySource } from "../kdeeb/index.ts";
|
|
8
|
+
import type { HighwayInput, HighwayPlan } from "../model.ts";
|
|
9
|
+
import { QUICKBUNDLES_DEFAULTS, quickBundles, resample } from "../quickbundles/index.ts";
|
|
10
|
+
import { edgePlugs } from "../stretches.ts";
|
|
11
|
+
import { outOfBoxes, tracePlan } from "../trace.ts";
|
|
12
|
+
|
|
13
|
+
/** The clusterer that gives the groups of edges: QuickBundles (HWY-026) with its defaults, or the collection of each edge. */
|
|
14
|
+
export type SkeletonGroups = "quickbundles" | "keys";
|
|
15
|
+
|
|
16
|
+
/** The values of `SkeletonGroups`. */
|
|
17
|
+
export const SKELETON_GROUPS: readonly SkeletonGroups[] = Object.freeze(["quickbundles", "keys"]);
|
|
18
|
+
|
|
19
|
+
/** The point that each sample point moves toward: the nearest point of the skeleton of its group, or of the mean polyline of its group. */
|
|
20
|
+
export type SkeletonTarget = "skeleton" | "mean";
|
|
21
|
+
|
|
22
|
+
/** The values of `SkeletonTarget`. */
|
|
23
|
+
export const SKELETON_TARGETS: readonly SkeletonTarget[] = Object.freeze(["skeleton", "mean"]);
|
|
24
|
+
|
|
25
|
+
/** The options of the highway strategy `sbeb` (HWY-009 of the notes). */
|
|
26
|
+
export interface SbebOptions extends Pick<WayOptions, "margin" | "k"> {
|
|
27
|
+
/** The clusterer of the groups. The default is `quickbundles`. */
|
|
28
|
+
readonly group: SkeletonGroups;
|
|
29
|
+
/** The first lines of the edges: their straight lines, or their first routes. The default is `lines`. */
|
|
30
|
+
readonly source: DensitySource;
|
|
31
|
+
/** The radius of the shape of a group, as a multiple of `boardUnit`. The default is 1.5. */
|
|
32
|
+
readonly radius: number;
|
|
33
|
+
/** The share of the distance to the target that a point moves in one step, from 0 to 1. The default is 0.5. */
|
|
34
|
+
readonly attraction: number;
|
|
35
|
+
/** The number of steps. The default is 4. */
|
|
36
|
+
readonly steps: number;
|
|
37
|
+
/** The share of the Laplacian smoothing of each polyline after each step, from 0 to 1. The default is 0.5. */
|
|
38
|
+
readonly smoothing: number;
|
|
39
|
+
/** The target of the points. The default is `skeleton`. */
|
|
40
|
+
readonly target: SkeletonTarget;
|
|
41
|
+
/** The side of a cell of the image of the shape, in units of the board. A large shape gets larger cells, up to 256 cells on a side. The default is 4. */
|
|
42
|
+
readonly cell: number;
|
|
43
|
+
/** The side of the grid that the points of the edges snap to after the steps, in units of the board. The default is 8. */
|
|
44
|
+
readonly snap: number;
|
|
45
|
+
/** A group with this number of edges or more moves, and a highway has this number of edges or more. The default is 2. */
|
|
46
|
+
readonly minSize: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The default options of the strategy `sbeb`. */
|
|
50
|
+
export const SBEB_DEFAULTS: Readonly<Required<Omit<SbebOptions, "margin" | "k">>> = Object.freeze({
|
|
51
|
+
group: "quickbundles",
|
|
52
|
+
source: "lines",
|
|
53
|
+
radius: 1.5,
|
|
54
|
+
attraction: 0.5,
|
|
55
|
+
steps: 4,
|
|
56
|
+
smoothing: 0.5,
|
|
57
|
+
target: "skeleton",
|
|
58
|
+
cell: 4,
|
|
59
|
+
snap: 8,
|
|
60
|
+
minSize: 2,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** This function gives the group of each edge: the clusters of QuickBundles, or the collection of the edge. An edge without a collection has no group with `keys`. */
|
|
64
|
+
export function skeletonGroups(input: HighwayInput, group: SkeletonGroups): Map<string, EdgeGroup> {
|
|
65
|
+
if (group === "quickbundles") return quickBundles(input, QUICKBUNDLES_DEFAULTS);
|
|
66
|
+
const out = new Map<string, EdgeGroup>();
|
|
67
|
+
for (const e of input.graph.edges) {
|
|
68
|
+
const key = byCollection(e);
|
|
69
|
+
if (key !== undefined) out.set(e.id, { key, flip: false });
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** This function gives the first line of each edge. The line goes from a point at `margin` out of the source plug to a point at `margin` out of the target plug. */
|
|
75
|
+
export function firstLines(input: HighwayInput, finder: WayFinder, source: DensitySource, spacing: number): Map<string, Point[]> {
|
|
76
|
+
const routes = source === "routes" ? firstRoutes(input) : undefined;
|
|
77
|
+
const out = new Map<string, Point[]>();
|
|
78
|
+
for (const { edge, ps, pt } of edgePlugs(input.graph)) {
|
|
79
|
+
const a = finder.out(ps);
|
|
80
|
+
const b = finder.out(pt);
|
|
81
|
+
const route = routes?.get(edge.id);
|
|
82
|
+
const base = route && route.length > 2 ? [a, ...route.slice(1, -1), b] : [a, b];
|
|
83
|
+
let len = 0;
|
|
84
|
+
for (let i = 1; i < base.length; i++) len += dist(base[i - 1]!, base[i]!);
|
|
85
|
+
out.set(edge.id, resample(base, Math.max(2, Math.min(129, Math.ceil(len / Math.max(1e-3, spacing)) + 1))));
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The image of the shape of a group and its skeleton. */
|
|
91
|
+
export interface ShapeSkeleton {
|
|
92
|
+
readonly grid: DensityGrid;
|
|
93
|
+
/** The distance of each cell to the nearest cell outside the shape, in cells. A cell outside the shape has 0. */
|
|
94
|
+
readonly depth: Float64Array;
|
|
95
|
+
/** The value is 1 for each cell of the skeleton. */
|
|
96
|
+
readonly skeleton: Uint8Array;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** This function gives the distance from the point `p` to the segment from `a` to `b`. */
|
|
100
|
+
function segDist(p: Point, a: Point, b: Point): number {
|
|
101
|
+
const dx = b.x - a.x;
|
|
102
|
+
const dy = b.y - a.y;
|
|
103
|
+
const l2 = dx * dx + dy * dy;
|
|
104
|
+
const u = l2 > 0 ? Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / l2)) : 0;
|
|
105
|
+
return Math.hypot(p.x - a.x - u * dx, p.y - a.y - u * dy);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The steps to the 8 neighbours of a cell, as pairs of opposite steps: along x, along y and along the two diagonals. */
|
|
109
|
+
const PAIRS: readonly (readonly [number, number])[] = [
|
|
110
|
+
[1, 0],
|
|
111
|
+
[0, 1],
|
|
112
|
+
[1, 1],
|
|
113
|
+
[1, -1],
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* This function gives the shape of a group and its skeleton (its medial axis). The shape is the union of the disks of radius `radius` along the lines, minus the boxes.
|
|
118
|
+
* The depth is a chamfer distance transform with the steps 1 and √2. A cell is on the skeleton when two conditions are true.
|
|
119
|
+
* Its depth is half of `radius` or more. Its depth is a ridge along one of the four pairs of opposite neighbours.
|
|
120
|
+
* A ridge is not lower than the two neighbours, and it is higher than one of them.
|
|
121
|
+
*/
|
|
122
|
+
export function shapeSkeleton(lines: readonly (readonly Point[])[], finder: WayFinder, radius: number, cell: number): ShapeSkeleton {
|
|
123
|
+
let x0 = Infinity;
|
|
124
|
+
let y0 = Infinity;
|
|
125
|
+
let x1 = -Infinity;
|
|
126
|
+
let y1 = -Infinity;
|
|
127
|
+
for (const l of lines)
|
|
128
|
+
for (const p of l) {
|
|
129
|
+
x0 = Math.min(x0, p.x);
|
|
130
|
+
y0 = Math.min(y0, p.y);
|
|
131
|
+
x1 = Math.max(x1, p.x);
|
|
132
|
+
y1 = Math.max(y1, p.y);
|
|
133
|
+
}
|
|
134
|
+
const pad = radius + 2 * Math.max(1e-3, cell);
|
|
135
|
+
const c = Math.max(1e-3, cell, (x1 - x0 + 2 * pad) / 256, (y1 - y0 + 2 * pad) / 256);
|
|
136
|
+
const grid: DensityGrid = { cell: c, x0: x0 - pad, y0: y0 - pad, w: Math.max(1, Math.ceil((x1 - x0 + 2 * pad) / c)), h: Math.max(1, Math.ceil((y1 - y0 + 2 * pad) / c)) };
|
|
137
|
+
const { w, h } = grid;
|
|
138
|
+
const inside = new Uint8Array(w * h);
|
|
139
|
+
const mid = (x: number, y: number): Point => ({ x: grid.x0 + (x + 0.5) * c, y: grid.y0 + (y + 0.5) * c });
|
|
140
|
+
for (const l of lines)
|
|
141
|
+
for (let i = 1; i < l.length; i++) {
|
|
142
|
+
const a = l[i - 1]!;
|
|
143
|
+
const b = l[i]!;
|
|
144
|
+
const gx0 = Math.max(0, Math.floor((Math.min(a.x, b.x) - radius - grid.x0) / c));
|
|
145
|
+
const gx1 = Math.min(w - 1, Math.floor((Math.max(a.x, b.x) + radius - grid.x0) / c));
|
|
146
|
+
const gy0 = Math.max(0, Math.floor((Math.min(a.y, b.y) - radius - grid.y0) / c));
|
|
147
|
+
const gy1 = Math.min(h - 1, Math.floor((Math.max(a.y, b.y) + radius - grid.y0) / c));
|
|
148
|
+
for (let y = gy0; y <= gy1; y++) for (let x = gx0; x <= gx1; x++) if (!inside[y * w + x] && segDist(mid(x, y), a, b) <= radius) inside[y * w + x] = 1;
|
|
149
|
+
}
|
|
150
|
+
const boxPad = finder.margin / 2;
|
|
151
|
+
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (inside[y * w + x] && finder.obstacles.inside(mid(x, y), boxPad)) inside[y * w + x] = 0;
|
|
152
|
+
// The chamfer distance transform: one pass forward and one pass backward.
|
|
153
|
+
const depth = new Float64Array(w * h);
|
|
154
|
+
for (let i = 0; i < w * h; i++) depth[i] = inside[i] ? Infinity : 0;
|
|
155
|
+
const at = (x: number, y: number): number => (x < 0 || y < 0 || x >= w || y >= h ? 0 : depth[y * w + x]!);
|
|
156
|
+
const D = Math.SQRT2;
|
|
157
|
+
for (let y = 0; y < h; y++)
|
|
158
|
+
for (let x = 0; x < w; x++) {
|
|
159
|
+
const i = y * w + x;
|
|
160
|
+
if (!inside[i]) continue;
|
|
161
|
+
depth[i] = Math.min(depth[i]!, at(x - 1, y) + 1, at(x, y - 1) + 1, at(x - 1, y - 1) + D, at(x + 1, y - 1) + D);
|
|
162
|
+
}
|
|
163
|
+
for (let y = h - 1; y >= 0; y--)
|
|
164
|
+
for (let x = w - 1; x >= 0; x--) {
|
|
165
|
+
const i = y * w + x;
|
|
166
|
+
if (!inside[i]) continue;
|
|
167
|
+
depth[i] = Math.min(depth[i]!, at(x + 1, y) + 1, at(x, y + 1) + 1, at(x + 1, y + 1) + D, at(x - 1, y + 1) + D);
|
|
168
|
+
}
|
|
169
|
+
const skeleton = new Uint8Array(w * h);
|
|
170
|
+
const least = Math.max(1, (0.5 * radius) / c);
|
|
171
|
+
for (let y = 0; y < h; y++)
|
|
172
|
+
for (let x = 0; x < w; x++) {
|
|
173
|
+
const v = depth[y * w + x]!;
|
|
174
|
+
if (v < least) continue;
|
|
175
|
+
for (const [dx, dy] of PAIRS) {
|
|
176
|
+
const p = at(x + dx, y + dy);
|
|
177
|
+
const q = at(x - dx, y - dy);
|
|
178
|
+
if (v >= p && v >= q && (v > p || v > q)) {
|
|
179
|
+
skeleton[y * w + x] = 1;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { grid, depth, skeleton };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** This function gives the middle of the skeleton cell nearest to `p`, within `reach` in units of the board, or `undefined`. */
|
|
188
|
+
export function nearestSkeleton(s: ShapeSkeleton, p: Point, reach: number): Point | undefined {
|
|
189
|
+
const { grid: g, skeleton } = s;
|
|
190
|
+
const cx = Math.floor((p.x - g.x0) / g.cell);
|
|
191
|
+
const cy = Math.floor((p.y - g.y0) / g.cell);
|
|
192
|
+
const r = Math.ceil(reach / g.cell);
|
|
193
|
+
let best: Point | undefined;
|
|
194
|
+
let bestD = Infinity;
|
|
195
|
+
for (let y = Math.max(0, cy - r); y <= Math.min(g.h - 1, cy + r); y++)
|
|
196
|
+
for (let x = Math.max(0, cx - r); x <= Math.min(g.w - 1, cx + r); x++) {
|
|
197
|
+
if (!skeleton[y * g.w + x]) continue;
|
|
198
|
+
const q = { x: g.x0 + (x + 0.5) * g.cell, y: g.y0 + (y + 0.5) * g.cell };
|
|
199
|
+
const d = dist(p, q);
|
|
200
|
+
if (d < bestD && d <= reach) [best, bestD] = [q, d];
|
|
201
|
+
}
|
|
202
|
+
return best;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** This function gives the mean polyline of lines in one direction, with `k` points. */
|
|
206
|
+
export function meanLine(lines: readonly (readonly Point[])[], k: number): Point[] {
|
|
207
|
+
const n = Math.max(2, Math.round(k));
|
|
208
|
+
const sum = Array.from({ length: n }, () => ({ x: 0, y: 0 }));
|
|
209
|
+
for (const l of lines)
|
|
210
|
+
resample(l, n).forEach((p, i) => {
|
|
211
|
+
sum[i]!.x += p.x;
|
|
212
|
+
sum[i]!.y += p.y;
|
|
213
|
+
});
|
|
214
|
+
return sum.map((p) => ({ x: p.x / Math.max(1, lines.length), y: p.y / Math.max(1, lines.length) }));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** This function gives the point of a polyline nearest to `p`. */
|
|
218
|
+
export function nearestOnLine(line: readonly Point[], p: Point): Point {
|
|
219
|
+
let best = line[0]!;
|
|
220
|
+
let bestD = dist(p, best);
|
|
221
|
+
for (let i = 1; i < line.length; i++) {
|
|
222
|
+
const a = line[i - 1]!;
|
|
223
|
+
const b = line[i]!;
|
|
224
|
+
const dx = b.x - a.x;
|
|
225
|
+
const dy = b.y - a.y;
|
|
226
|
+
const l2 = dx * dx + dy * dy;
|
|
227
|
+
const u = l2 > 0 ? Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / l2)) : 0;
|
|
228
|
+
const q = { x: a.x + u * dx, y: a.y + u * dy };
|
|
229
|
+
const d = dist(p, q);
|
|
230
|
+
if (d < bestD) [best, bestD] = [q, d];
|
|
231
|
+
}
|
|
232
|
+
return best;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** This function gives the groups with `min` edges or more, as lists of edges. The groups are in the order of their first edges. */
|
|
236
|
+
export function bigGroups(groups: ReadonlyMap<string, EdgeGroup>, min: number): { key: string; edges: { edge: string; flip: boolean }[] }[] {
|
|
237
|
+
const byKey = new Map<string, { edge: string; flip: boolean }[]>();
|
|
238
|
+
for (const [edge, g] of groups) {
|
|
239
|
+
const list = byKey.get(g.key);
|
|
240
|
+
if (list) list.push({ edge, flip: g.flip });
|
|
241
|
+
else byKey.set(g.key, [{ edge, flip: g.flip }]);
|
|
242
|
+
}
|
|
243
|
+
return [...byKey]
|
|
244
|
+
.filter(([, list]) => list.length >= Math.max(2, min))
|
|
245
|
+
.map(([key, edges]) => ({ key, edges }));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* This function gives the polylines of the skeleton-based edge bundling (SBEB, Ersoy, Hurter, Paulovich, Cantareiro and Telea 2011). The ends of each polyline stay.
|
|
250
|
+
* In each step, each group of `minSize` edges or more draws its shape, and each inner point moves the share `attraction` toward its target.
|
|
251
|
+
* The share falls to 0 at the distance `2 radius`. A Laplacian smoothing keeps each polyline smooth, and a point in a box moves out of it.
|
|
252
|
+
* The edges outside such a group do not move.
|
|
253
|
+
*/
|
|
254
|
+
export function sbebLines(input: HighwayInput, finder: WayFinder, o: Readonly<SbebOptions>): Map<string, Point[]> {
|
|
255
|
+
const radius = Math.max(1e-3, o.radius * boardUnit(input.graph));
|
|
256
|
+
const lines = firstLines(input, finder, o.source, Math.max(4, 2 * o.cell));
|
|
257
|
+
const groups = bigGroups(skeletonGroups(input, o.group), o.minSize).map((g) => ({ ...g, edges: g.edges.filter((m) => lines.has(m.edge)) }));
|
|
258
|
+
const pad = finder.margin / 2;
|
|
259
|
+
const a = Math.max(0, Math.min(1, o.attraction));
|
|
260
|
+
const smooth = Math.max(0, Math.min(1, o.smoothing));
|
|
261
|
+
for (let step = 0; step < Math.max(0, Math.round(o.steps)); step++) {
|
|
262
|
+
for (const g of groups) {
|
|
263
|
+
if (g.edges.length < 2) continue;
|
|
264
|
+
const own = g.edges.map((m) => lines.get(m.edge)!);
|
|
265
|
+
const target: (p: Point) => Point | undefined =
|
|
266
|
+
o.target === "mean"
|
|
267
|
+
? ((mean) => (p: Point) => nearestOnLine(mean, p))(meanLine(g.edges.map((m, i) => (m.flip ? [...own[i]!].reverse() : own[i]!)), 33))
|
|
268
|
+
: ((s) => (p: Point) => nearestSkeleton(s, p, 2 * radius))(shapeSkeleton(own, finder, radius, o.cell));
|
|
269
|
+
g.edges.forEach((m, i) => {
|
|
270
|
+
const l = own[i]!;
|
|
271
|
+
const moved = l.map((p, j) => {
|
|
272
|
+
if (j === 0 || j === l.length - 1) return p;
|
|
273
|
+
const s = target(p);
|
|
274
|
+
if (!s) return p;
|
|
275
|
+
const k = a * Math.max(0, 1 - dist(p, s) / (2 * radius));
|
|
276
|
+
return { x: p.x + k * (s.x - p.x), y: p.y + k * (s.y - p.y) };
|
|
277
|
+
});
|
|
278
|
+
const smoothed = moved.map((p, j) => {
|
|
279
|
+
if (j === 0 || j === moved.length - 1 || smooth === 0) return p;
|
|
280
|
+
const u = moved[j - 1]!;
|
|
281
|
+
const v = moved[j + 1]!;
|
|
282
|
+
return { x: (1 - smooth) * p.x + (smooth * (u.x + v.x)) / 2, y: (1 - smooth) * p.y + (smooth * (u.y + v.y)) / 2 };
|
|
283
|
+
});
|
|
284
|
+
lines.set(
|
|
285
|
+
m.edge,
|
|
286
|
+
smoothed.map((p, j) => (j === 0 || j === smoothed.length - 1 ? p : outOfBoxes(finder.obstacles, p, pad))),
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return lines;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* This function gives the highways of the skeleton-based edge bundling (HWY-009). The points of `sbebLines` snap to a grid of side `snap`.
|
|
296
|
+
* The ways go through the corner graph, and each stretch that `minSize` ways share is a highway.
|
|
297
|
+
*/
|
|
298
|
+
export function sbebPlan(input: HighwayInput, o: Readonly<SbebOptions>): HighwayPlan {
|
|
299
|
+
const finder = new WayFinder(input.graph.nodes, { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) });
|
|
300
|
+
const lines = sbebLines(input, finder, o);
|
|
301
|
+
const waypoints = new Map([...lines].map(([edge, l]) => [edge, l.slice(1, -1)] as const));
|
|
302
|
+
return tracePlan(input, finder, waypoints, { snap: o.snap, minSize: o.minSize, key: "sbeb" });
|
|
303
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { boardUnit } from "../../bench/measures/routes.ts";
|
|
2
|
+
import type { Point } from "../../geom.ts";
|
|
3
|
+
import { firstRoutes } from "../base.ts";
|
|
4
|
+
import { groupedPlan, type EdgeGroup, type GroupSpine } from "../grouped.ts";
|
|
5
|
+
import type { HighwayInput, HighwayPlan } from "../model.ts";
|
|
6
|
+
import { mdf, resample, type BundleLine } from "../quickbundles/index.ts";
|
|
7
|
+
import { edgePlugs } from "../stretches.ts";
|
|
8
|
+
|
|
9
|
+
/** The options of the highway strategy `scale-space` (HWY-053 of the note on highways). */
|
|
10
|
+
export interface ScaleSpaceOptions {
|
|
11
|
+
/** The first scale, in units of the board. The default is 1.5. */
|
|
12
|
+
readonly theta: number;
|
|
13
|
+
/** The scale of each next scale is this value times the scale before it. The default is the root of 2. */
|
|
14
|
+
readonly ratio: number;
|
|
15
|
+
/** The number of scales. The default is 8. */
|
|
16
|
+
readonly scales: number;
|
|
17
|
+
/** The scale of the plan, from 0 (the first scale). With the defaults, the scale 2 is 3 units, as in the level 0 of `nested`. The default is 2. */
|
|
18
|
+
readonly level: number;
|
|
19
|
+
/** A cluster gets highways only when its persistence `ln(σ_death / σ_birth)` is this value or more. The default is 0.6: two steps of the root of 2. */
|
|
20
|
+
readonly p0: number;
|
|
21
|
+
/** The number of points of each line after the resampling. The default is 12. */
|
|
22
|
+
readonly points: number;
|
|
23
|
+
/** The line of each edge. The default is `routes`. */
|
|
24
|
+
readonly line: BundleLine;
|
|
25
|
+
/** A cluster with this number of edges or more gets highways. The default is 2. */
|
|
26
|
+
readonly minSize: number;
|
|
27
|
+
/** The spine of each cluster. The default is `shared`. */
|
|
28
|
+
readonly spine: GroupSpine;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The default options of the strategy `scale-space`. */
|
|
32
|
+
export const SCALE_SPACE_DEFAULTS: Readonly<ScaleSpaceOptions> = Object.freeze({
|
|
33
|
+
theta: 1.5,
|
|
34
|
+
ratio: Math.SQRT2,
|
|
35
|
+
scales: 8,
|
|
36
|
+
level: 2,
|
|
37
|
+
p0: 0.6,
|
|
38
|
+
points: 12,
|
|
39
|
+
line: "routes",
|
|
40
|
+
minSize: 2,
|
|
41
|
+
spine: "shared",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** One cluster of one scale: its edges (as indices), and the scales of its birth and its death. */
|
|
45
|
+
export interface ScaleCluster {
|
|
46
|
+
readonly members: readonly number[];
|
|
47
|
+
/** The first scale of the cluster. A cluster of the first scale has the birth 0. */
|
|
48
|
+
readonly birth: number;
|
|
49
|
+
/** The first scale where the cluster joins a larger one. A cluster that lives past the last scale has the death `scales`. */
|
|
50
|
+
readonly death: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* This function gives the clusters of the single linkage of `n` items at each scale. The distance `d(i, j)` of each pair is in `pairs`.
|
|
55
|
+
* At the scale `s`, two items with a distance of `limits[s]` or less are in one cluster. Thus each cluster of a scale is in one cluster of each larger scale.
|
|
56
|
+
* The result has the clusters of each scale with 2 items or more, with their birth and their death.
|
|
57
|
+
*/
|
|
58
|
+
export function scaleClusters(n: number, pairs: readonly { i: number; j: number; d: number }[], limits: readonly number[]): ScaleCluster[][] {
|
|
59
|
+
const parent = new Int32Array(n);
|
|
60
|
+
for (let i = 0; i < n; i++) parent[i] = i;
|
|
61
|
+
const find = (i: number): number => {
|
|
62
|
+
while (parent[i] !== i) i = parent[i] = parent[parent[i]!]!;
|
|
63
|
+
return i;
|
|
64
|
+
};
|
|
65
|
+
const sorted = [...pairs].sort((a, b) => a.d - b.d || a.i - b.i || a.j - b.j);
|
|
66
|
+
// The member sets of each scale, as sorted keys.
|
|
67
|
+
const levels: number[][][] = [];
|
|
68
|
+
let next = 0;
|
|
69
|
+
for (const limit of limits) {
|
|
70
|
+
for (; next < sorted.length && sorted[next]!.d <= limit; next++) {
|
|
71
|
+
const a = find(sorted[next]!.i);
|
|
72
|
+
const b = find(sorted[next]!.j);
|
|
73
|
+
if (a !== b) parent[Math.max(a, b)] = Math.min(a, b);
|
|
74
|
+
}
|
|
75
|
+
const sets = new Map<number, number[]>();
|
|
76
|
+
for (let i = 0; i < n; i++) {
|
|
77
|
+
const r = find(i);
|
|
78
|
+
const list = sets.get(r);
|
|
79
|
+
if (list) list.push(i);
|
|
80
|
+
else sets.set(r, [i]);
|
|
81
|
+
}
|
|
82
|
+
levels.push([...sets.values()].filter((s) => s.length >= 2));
|
|
83
|
+
}
|
|
84
|
+
const keyOf = (s: readonly number[]): string => s.join(",");
|
|
85
|
+
const keys = levels.map((sets) => new Set(sets.map(keyOf)));
|
|
86
|
+
return levels.map((sets, s) =>
|
|
87
|
+
sets.map((members) => {
|
|
88
|
+
const key = keyOf(members);
|
|
89
|
+
let birth = s;
|
|
90
|
+
while (birth > 0 && keys[birth - 1]!.has(key)) birth--;
|
|
91
|
+
let death = s + 1;
|
|
92
|
+
while (death < levels.length && keys[death]!.has(key)) death++;
|
|
93
|
+
return { members, birth, death };
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* This function gives the groups of the edges at the scale `level` that persist (HWY-053). The scale `s` is `theta × ratio^s` units of the board.
|
|
100
|
+
* A cluster of the level gets a group when its persistence `(death - birth) × ln(ratio)` is `p0` or more. Each edge of a group gets `flip` against the first edge of the group.
|
|
101
|
+
*/
|
|
102
|
+
export function scaleSpaceGroups(input: HighwayInput, o: Readonly<ScaleSpaceOptions>): Map<string, EdgeGroup> {
|
|
103
|
+
const graph = input.graph;
|
|
104
|
+
const unit = boardUnit(graph);
|
|
105
|
+
const scales = Math.max(1, Math.round(o.scales));
|
|
106
|
+
const ratio = Math.max(1.01, o.ratio);
|
|
107
|
+
const limits = Array.from({ length: scales }, (_, s) => o.theta * ratio ** s * unit);
|
|
108
|
+
const k = Math.max(2, Math.round(o.points));
|
|
109
|
+
const routes = o.line === "routes" ? firstRoutes(input) : undefined;
|
|
110
|
+
const lines: { edge: string; line: Point[] }[] = edgePlugs(graph).map(({ edge, ps, pt }) => ({ edge: edge.id, line: resample(routes?.get(edge.id) ?? [ps.point, pt.point], k) }));
|
|
111
|
+
const top = limits[limits.length - 1]!;
|
|
112
|
+
const pairs: { i: number; j: number; d: number }[] = [];
|
|
113
|
+
for (let i = 0; i < lines.length; i++)
|
|
114
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
115
|
+
const d = mdf(lines[i]!.line, lines[j]!.line).d;
|
|
116
|
+
if (d <= top) pairs.push({ i, j, d });
|
|
117
|
+
}
|
|
118
|
+
const level = Math.max(0, Math.min(scales - 1, Math.round(o.level)));
|
|
119
|
+
const clusters = scaleClusters(lines.length, pairs, limits)[level]!;
|
|
120
|
+
const groups = new Map<string, EdgeGroup>();
|
|
121
|
+
clusters.forEach((c, ci) => {
|
|
122
|
+
if ((c.death - c.birth) * Math.log(ratio) < o.p0 - 1e-9) return;
|
|
123
|
+
const lead = lines[c.members[0]!]!.line;
|
|
124
|
+
for (const i of c.members) groups.set(lines[i]!.edge, { key: `s${level}.${ci}`, flip: mdf(lines[i]!.line, lead).flip });
|
|
125
|
+
});
|
|
126
|
+
return groups;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* This function gives the highways of one scale of the scale space with persistence (HWY-053). Only the clusters that live over `p0` of the log scale get highways.
|
|
131
|
+
* A short-lived cluster is noise, so a zoom level shows only the stable structure.
|
|
132
|
+
*/
|
|
133
|
+
export function scaleSpacePlan(input: HighwayInput, o: Readonly<ScaleSpaceOptions>): HighwayPlan {
|
|
134
|
+
return groupedPlan(input, scaleSpaceGroups(input, o), { spine: o.spine, minSize: Math.max(2, Math.round(o.minSize)) });
|
|
135
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { dist, polylineLength, type Point } from "../geom.ts";
|
|
2
|
+
import { orderLanes } from "../segments.ts";
|
|
3
|
+
import { WayFinder, type WayOptions } from "../ways.ts";
|
|
4
|
+
import { nearestPlace, tidyPlan } from "./base.ts";
|
|
5
|
+
import { lengthBetween, memberAt, pointAt, type Highway, type HighwayInput, type HighwayMember, type HighwayPlan } from "./model.ts";
|
|
6
|
+
import { edgePlugs } from "./stretches.ts";
|
|
7
|
+
|
|
8
|
+
/** One edge that snaps to a line: the points where it joins and leaves the line, and its place along its own way. */
|
|
9
|
+
export interface SnapMember {
|
|
10
|
+
readonly edge: string;
|
|
11
|
+
/** The point on the line where the edge joins it. */
|
|
12
|
+
readonly from: Point;
|
|
13
|
+
/** The point on the line where the edge leaves it. */
|
|
14
|
+
readonly to: Point;
|
|
15
|
+
/** The place of the join along the edge, from 0 at the source to 1 at the target. It orders the highways of one edge. */
|
|
16
|
+
readonly at: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A straight line that a finder gives as the place of a highway, with the edges that snap to it. */
|
|
20
|
+
export interface SnapLine {
|
|
21
|
+
readonly key: string;
|
|
22
|
+
readonly a: Point;
|
|
23
|
+
readonly b: Point;
|
|
24
|
+
readonly members: readonly SnapMember[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The options of `snapPlan`. */
|
|
28
|
+
export interface SnapOptions extends Pick<WayOptions, "margin" | "k"> {
|
|
29
|
+
/** A highway has this number of edges or more. The value is 2 or more. */
|
|
30
|
+
readonly minSize: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** This function gives the line `u d + rho n` of the direction angle `theta`, with `d = (cos, sin)` and the normal `n = (-sin, cos)`. */
|
|
34
|
+
export function linePoint(theta: number, rho: number, u: number): Point {
|
|
35
|
+
const c = Math.cos(theta);
|
|
36
|
+
const s = Math.sin(theta);
|
|
37
|
+
return { x: u * c - rho * s, y: u * s + rho * c };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* This function gives the highways of straight lines in the free space. Each line becomes a spine through the corner graph between its two ends.
|
|
42
|
+
* An end inside a box moves to the nearest corner of the graph.
|
|
43
|
+
* Each member joins the spine at the place nearest to its point `from`, and leaves it at the place nearest to its point `to`.
|
|
44
|
+
* A member whose part of the spine is shorter than the margin does not ride. A spine with fewer than `minSize` members is not a highway.
|
|
45
|
+
* An edge on several highways takes them in the order of `at`.
|
|
46
|
+
*/
|
|
47
|
+
export function snapPlan(input: HighwayInput, lines: readonly SnapLine[], o: Readonly<SnapOptions>): HighwayPlan {
|
|
48
|
+
const min = Math.max(2, o.minSize);
|
|
49
|
+
const finder = new WayFinder(input.graph.nodes, { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) });
|
|
50
|
+
const corners = finder.corners;
|
|
51
|
+
const plugs = new Map(edgePlugs(input.graph).map((p) => [p.edge.id, p] as const));
|
|
52
|
+
const pad = finder.margin / 2;
|
|
53
|
+
const free = (p: Point): Point => {
|
|
54
|
+
if (!finder.obstacles.inside(p, pad)) return p;
|
|
55
|
+
const i = corners.nearest(p);
|
|
56
|
+
return i >= 0 ? corners.points[i]! : p;
|
|
57
|
+
};
|
|
58
|
+
const highways: Highway[] = [];
|
|
59
|
+
const at = new Map<string, number>();
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
const a = free(line.a);
|
|
62
|
+
const b = free(line.b);
|
|
63
|
+
if (dist(a, b) <= finder.margin) continue;
|
|
64
|
+
const raw = corners.path(a, b);
|
|
65
|
+
if (!raw) continue;
|
|
66
|
+
const spine = corners.pull(raw);
|
|
67
|
+
if (spine.length < 2 || polylineLength(spine) <= 1e-6) continue;
|
|
68
|
+
const riders: { edge: string; on: number; off: number; points: Point[] }[] = [];
|
|
69
|
+
for (const m of line.members) {
|
|
70
|
+
const plug = plugs.get(m.edge);
|
|
71
|
+
if (!plug) continue;
|
|
72
|
+
const on = nearestPlace(spine, m.from);
|
|
73
|
+
const off = nearestPlace(spine, m.to);
|
|
74
|
+
if (lengthBetween(spine, Math.min(on, off), Math.max(on, off)) < finder.margin) continue;
|
|
75
|
+
riders.push({ edge: m.edge, on, off, points: [plug.ps.point, ...spinePart(spine, on, off), plug.pt.point] });
|
|
76
|
+
}
|
|
77
|
+
if (riders.length < min) continue;
|
|
78
|
+
const hi = highways.length;
|
|
79
|
+
for (const m of line.members) at.set(`${hi}\u0000${m.edge}`, m.at);
|
|
80
|
+
const lanes = orderLanes(spine, riders.map((r) => ({ edge: r.edge, points: r.points })), "bundle");
|
|
81
|
+
const byEdge = new Map(riders.map((r) => [r.edge, r]));
|
|
82
|
+
const members: HighwayMember[] = lanes.map((edge) => memberAt(spine, edge, byEdge.get(edge)!.on, byEdge.get(edge)!.off));
|
|
83
|
+
highways.push({ id: `h${hi}`, key: line.key, spine, lanes, members });
|
|
84
|
+
}
|
|
85
|
+
// The `seq` of each member follows `at` along its edge.
|
|
86
|
+
const trips = new Map<string, { h: number; at: number }[]>();
|
|
87
|
+
highways.forEach((h, hi) => {
|
|
88
|
+
for (const m of h.members) {
|
|
89
|
+
const list = trips.get(m.edge) ?? [];
|
|
90
|
+
list.push({ h: hi, at: at.get(`${hi}\u0000${m.edge}`) ?? 0 });
|
|
91
|
+
trips.set(m.edge, list);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
const seq = new Map<string, number>();
|
|
95
|
+
for (const [edge, list] of trips) {
|
|
96
|
+
list.sort((p, q) => p.at - q.at || p.h - q.h);
|
|
97
|
+
list.forEach((x, k) => seq.set(`${x.h}\u0000${edge}`, k));
|
|
98
|
+
}
|
|
99
|
+
return tidyPlan({ highways: highways.map((h, hi) => ({ ...h, members: h.members.map((m) => ({ ...m, seq: seq.get(`${hi}\u0000${m.edge}`) ?? 0 })) })) });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** This function gives the points of a spine from the place `on` to the place `off`, backward when `on` is larger. */
|
|
103
|
+
export function spinePart(spine: readonly Point[], on: number, off: number): Point[] {
|
|
104
|
+
const lo = Math.min(on, off);
|
|
105
|
+
const hi = Math.max(on, off);
|
|
106
|
+
const out = [pointAt(spine, lo)];
|
|
107
|
+
for (let i = Math.floor(lo) + 1; i < hi; i++) out.push(spine[i]!);
|
|
108
|
+
out.push(pointAt(spine, hi));
|
|
109
|
+
return on <= off ? out : out.reverse();
|
|
110
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { GroupRule } from "../../model.ts";
|
|
2
|
+
import { byCollection } from "../../routers/trunks.ts";
|
|
3
|
+
import { chainClusters, flowDistance, type Linkage } from "../flow-clusters/index.ts";
|
|
4
|
+
import { groupedPlan, type EdgeGroup, type GroupSpine } from "../grouped.ts";
|
|
5
|
+
import type { HighwayInput, HighwayPlan } from "../model.ts";
|
|
6
|
+
import { edgePlugs } from "../stretches.ts";
|
|
7
|
+
|
|
8
|
+
/** The options of the highway strategy `soft-keys` (HWY-019 of the note on highways). */
|
|
9
|
+
export interface SoftKeyOptions {
|
|
10
|
+
/** The rule that gives the key of each edge. The default is `byCollection`: the string `data.collection`. */
|
|
11
|
+
readonly rule: GroupRule;
|
|
12
|
+
/** The distance that two different keys add. `Infinity` keeps the edges of two keys apart, as the hard keys do. The default is 0.25. */
|
|
13
|
+
readonly lambda: number;
|
|
14
|
+
/** Two clusters join while their distance is this value or less. The default is 0.5. */
|
|
15
|
+
readonly tau: number;
|
|
16
|
+
/** The names of number fields of `data` that add a distance. The default is no field. */
|
|
17
|
+
readonly attributes: readonly string[];
|
|
18
|
+
/** The weight of each field of `attributes`. The distance of a field is its difference over its range. The default is 1. */
|
|
19
|
+
readonly weight: number;
|
|
20
|
+
/** The linkage of the clustering. The default is `average`. */
|
|
21
|
+
readonly linkage: Linkage;
|
|
22
|
+
/** A cluster with this number of edges or more gets highways. The default is 2. */
|
|
23
|
+
readonly minSize: number;
|
|
24
|
+
/** The spine of each cluster. The default is `shared`: the router `shared` with the cluster as the key. */
|
|
25
|
+
readonly spine: GroupSpine;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The default options of the strategy `soft-keys`. */
|
|
29
|
+
export const SOFT_KEY_DEFAULTS: Readonly<SoftKeyOptions> = Object.freeze({
|
|
30
|
+
rule: byCollection,
|
|
31
|
+
lambda: 0.25,
|
|
32
|
+
tau: 0.5,
|
|
33
|
+
attributes: Object.freeze([]) as readonly string[],
|
|
34
|
+
weight: 1,
|
|
35
|
+
linkage: "average",
|
|
36
|
+
minSize: 2,
|
|
37
|
+
spine: "shared",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** The largest number of edges in one distance matrix. A larger graph splits into blocks of edges in the order of their sources. */
|
|
41
|
+
const BLOCK = 2500;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* This function gives the soft-key distance of two edges (Peysakhovich, Hurter and Telea 2015): `d = d_geom + lambda [key(e) != key(f)] + sum_a w |a(e) - a(f)| / range(a)`.
|
|
45
|
+
* `d_geom` is the flow distance of HWY-023. The key term is 0 when an edge has no key.
|
|
46
|
+
*/
|
|
47
|
+
export function softDistance(geom: number, keyA: string | undefined, keyB: string | undefined, lambda: number, attributes: number): number {
|
|
48
|
+
const key = keyA !== undefined && keyB !== undefined && keyA !== keyB ? lambda : 0;
|
|
49
|
+
return geom + key + attributes;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* This function gives the soft-key clusters of the edges (HWY-019). It clusters the edges with `softDistance`, with the cut at `tau`.
|
|
54
|
+
* Each edge of a cluster gets the key of the cluster. It gets `flip` when it goes opposite to the first edge of the cluster.
|
|
55
|
+
*/
|
|
56
|
+
export function softKeyClusters(input: HighwayInput, o: Readonly<SoftKeyOptions>): Map<string, EdgeGroup> {
|
|
57
|
+
const ends = edgePlugs(input.graph).map(({ edge, ps, pt }) => ({
|
|
58
|
+
edge: edge.id,
|
|
59
|
+
s: ps.point,
|
|
60
|
+
t: pt.point,
|
|
61
|
+
key: o.rule(edge),
|
|
62
|
+
values: o.attributes.map((a) => {
|
|
63
|
+
const v = edge.data?.[a];
|
|
64
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
65
|
+
}),
|
|
66
|
+
}));
|
|
67
|
+
// The range of each field, so that each field adds a distance from 0 to `weight`.
|
|
68
|
+
const ranges = o.attributes.map((_, ai) => {
|
|
69
|
+
const values = ends.map((e) => e.values[ai]).filter((v): v is number => v !== undefined);
|
|
70
|
+
return values.length > 1 ? Math.max(...values) - Math.min(...values) : 0;
|
|
71
|
+
});
|
|
72
|
+
const extra = (a: (typeof ends)[number], b: (typeof ends)[number]): number => {
|
|
73
|
+
let sum = 0;
|
|
74
|
+
ranges.forEach((range, ai) => {
|
|
75
|
+
const va = a.values[ai];
|
|
76
|
+
const vb = b.values[ai];
|
|
77
|
+
if (range > 0 && va !== undefined && vb !== undefined) sum += (o.weight * Math.abs(va - vb)) / range;
|
|
78
|
+
});
|
|
79
|
+
return sum;
|
|
80
|
+
};
|
|
81
|
+
const groups = new Map<string, EdgeGroup>();
|
|
82
|
+
const order = ends.length > BLOCK ? [...ends].sort((a, b) => a.s.x - b.s.x || a.s.y - b.s.y || (a.edge < b.edge ? -1 : 1)) : ends;
|
|
83
|
+
for (let from = 0; from < order.length; from += BLOCK) {
|
|
84
|
+
const block = order.slice(from, from + BLOCK);
|
|
85
|
+
const n = block.length;
|
|
86
|
+
const d = new Float64Array(n * n);
|
|
87
|
+
for (let i = 0; i < n; i++)
|
|
88
|
+
for (let j = i + 1; j < n; j++) {
|
|
89
|
+
const a = block[i]!;
|
|
90
|
+
const b = block[j]!;
|
|
91
|
+
const v = softDistance(flowDistance(a.s, a.t, b.s, b.t, false).d, a.key, b.key, o.lambda, extra(a, b));
|
|
92
|
+
d[i * n + j] = v;
|
|
93
|
+
d[j * n + i] = v;
|
|
94
|
+
}
|
|
95
|
+
const cluster = chainClusters(n, d, o.tau, o.linkage);
|
|
96
|
+
const first = new Map<number, number>();
|
|
97
|
+
block.forEach((e, i) => {
|
|
98
|
+
const c = cluster[i]!;
|
|
99
|
+
if (!first.has(c)) first.set(c, i);
|
|
100
|
+
const lead = block[first.get(c)!]!;
|
|
101
|
+
const flip = i === first.get(c) ? false : flowDistance(lead.s, lead.t, e.s, e.t, false).flip;
|
|
102
|
+
groups.set(e.edge, { key: `k${from + first.get(c)!}`, flip });
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return groups;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* This function gives the highways of the soft keys (HWY-019). The key of an edge is one more term of the distance, not a hard split.
|
|
110
|
+
* Edges of two keys still share a highway when they are very near, and edges of one key do not bundle when they are far.
|
|
111
|
+
*/
|
|
112
|
+
export function softKeyPlan(input: HighwayInput, o: Readonly<SoftKeyOptions>): HighwayPlan {
|
|
113
|
+
return groupedPlan(input, softKeyClusters(input, o), { spine: o.spine, minSize: o.minSize });
|
|
114
|
+
}
|