@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
|
@@ -7,8 +7,11 @@ import { asRouter, createRegistry, getStrategy, listStrategies, register, STEPS,
|
|
|
7
7
|
import { compareStep, type CompareRow, type CompareStep, type StepComparison } from "../compare.ts";
|
|
8
8
|
import { bundledShare, bundleStretch, mergeAngle } from "../measures/bundles.ts";
|
|
9
9
|
import { highwayEdits } from "../measures/highways.ts";
|
|
10
|
+
import { laneArea, laneCrossings, laneFidelity, lanesThrough, occupiedArea, openLanes, orthogonality, routeSeparation, runProfile } from "../measures/lanes.ts";
|
|
11
|
+
import { linkAgreement } from "../measures/levels.ts";
|
|
10
12
|
import { orderFragility } from "../measures/order.ts";
|
|
11
13
|
import { paretoFront, type Objective } from "../measures/pareto.ts";
|
|
14
|
+
import { boardUnit } from "../measures/routes.ts";
|
|
12
15
|
import { routeStability } from "../measures/stability.ts";
|
|
13
16
|
import { quantile } from "../measures/stats.ts";
|
|
14
17
|
import { checkLevels, checkProperties, TOLERANCE } from "../properties.ts";
|
|
@@ -16,6 +19,10 @@ import { measureQuality } from "../quality.ts";
|
|
|
16
19
|
import { sameData } from "../run.ts";
|
|
17
20
|
import { FRONT_OBJECTIVES } from "../suite.ts";
|
|
18
21
|
import { inputGraph, jsonSafe, type LayoutInput, type LayoutPrediction, type LayoutRecord, type PredictionOp } from "./forms.ts";
|
|
22
|
+
import { curvesHash, resultHash, type MoveCounter } from "./exercise.ts";
|
|
23
|
+
import { outputHash } from "./activation.ts";
|
|
24
|
+
import { RECORD_EPS } from "./hash.ts";
|
|
25
|
+
import { formatGap, gapBaselines, gapValues, optimumKey, summarizeGap, type ExactOptimum, type GapRule, type GapSummary } from "./gap.ts";
|
|
19
26
|
import { measureByName, measureName, namedObjectives } from "./measures.ts";
|
|
20
27
|
import { SCREEN_V1, screenInputs, type ScreenSet } from "./set.ts";
|
|
21
28
|
|
|
@@ -95,6 +102,16 @@ export interface ScreenOptions {
|
|
|
95
102
|
readonly commit?: string;
|
|
96
103
|
/** The predictions of the run. Each record names the first prediction with its strategy, its set and its input. */
|
|
97
104
|
readonly predictions?: readonly LayoutPrediction[];
|
|
105
|
+
/**
|
|
106
|
+
* The exact optima of the inputs, from the file of the optima of a set (MEAS2). The steps `router` and `highway` read them.
|
|
107
|
+
* Each run on an input with optima gets the largest B3 and the gap closed of each rule (B10). The summary then gets the gap table.
|
|
108
|
+
*/
|
|
109
|
+
readonly optima?: readonly ExactOptimum[];
|
|
110
|
+
/**
|
|
111
|
+
* This function gives the own move counts of a strategy on an input (ACTIVATION), for example `{ "move.powerEdge": 3 }`.
|
|
112
|
+
* Each record of a strategy with counts gets them as the trace measures of its step. The default is no counts.
|
|
113
|
+
*/
|
|
114
|
+
readonly moves?: MoveCounter;
|
|
98
115
|
/** This function gets one line of progress for each input. */
|
|
99
116
|
readonly log?: (line: string) => void;
|
|
100
117
|
}
|
|
@@ -151,6 +168,8 @@ export interface ScreenSummary {
|
|
|
151
168
|
/** The strategies with a break over the floor, without a run or with two different outputs of one input. They do not enter the front. */
|
|
152
169
|
readonly excluded: readonly string[];
|
|
153
170
|
readonly predictions: readonly PredictionCheck[];
|
|
171
|
+
/** The gap closed of each strategy and rule, when the screen has optima. */
|
|
172
|
+
readonly gap?: readonly GapSummary[];
|
|
154
173
|
}
|
|
155
174
|
|
|
156
175
|
/** The result of `screen`: one record for each strategy and input, and the summary. */
|
|
@@ -208,6 +227,8 @@ const givesRoutes = (step: ScreenStep): step is "router" | "highway" => step ===
|
|
|
208
227
|
/**
|
|
209
228
|
* This function gives the values of a row of `compareStep`, with the measures of `measureQuality` for a router.
|
|
210
229
|
* A router also gets the bundled share (B2), the merge angle (B6) and the bundle stretch (B3) against the routes `base` of `corners`.
|
|
230
|
+
* It also gets the measures of MEAS3A: R12, R14, B5, B7, B8, B9, M1 and M4. The lane measures open each shared segment at `MEASURE_LANE_WIDTH`.
|
|
231
|
+
* A result with zoom levels also gets the link agreement (Z9), for a router and for a grouping rule.
|
|
211
232
|
*/
|
|
212
233
|
function rowValues(step: ScreenStep, graph: Graph, row: CompareRow, base: () => readonly Route[]): Record<string, number> {
|
|
213
234
|
const values: Record<string, number> = { ...row.values };
|
|
@@ -224,6 +245,27 @@ function rowValues(step: ScreenStep, graph: Graph, row: CompareRow, base: () =>
|
|
|
224
245
|
values["B3.median"] = st.median;
|
|
225
246
|
values["B3.q95"] = st.q95;
|
|
226
247
|
values["B6.max"] = mergeAngle(row.result.routes);
|
|
248
|
+
const routes = row.result.routes;
|
|
249
|
+
const open = openLanes(row.result);
|
|
250
|
+
values["R12.share"] = routeSeparation(routes);
|
|
251
|
+
values["R14.share"] = orthogonality(routes);
|
|
252
|
+
values["B5.crossings"] = laneCrossings(open.graph);
|
|
253
|
+
values["B7.ratio"] = occupiedArea(routes, boardUnit(graph));
|
|
254
|
+
const runs = runProfile(open.graph);
|
|
255
|
+
values["B8.runs"] = runs.runs;
|
|
256
|
+
values["B8.median"] = runs.median;
|
|
257
|
+
values["B8.max"] = runs.max;
|
|
258
|
+
const lf = laneFidelity(routes, open);
|
|
259
|
+
if (lf) {
|
|
260
|
+
values["B9.median"] = lf.median;
|
|
261
|
+
values["B9.max"] = lf.max;
|
|
262
|
+
}
|
|
263
|
+
values["M1.area"] = laneArea(graph, open);
|
|
264
|
+
values["M4.laneThrough"] = lanesThrough(graph, open);
|
|
265
|
+
}
|
|
266
|
+
if ((givesRoutes(step) || step === "group") && row.result?.levels) {
|
|
267
|
+
const la = linkAgreement(graph, row.result.levels, row.result.routes, boardUnit(graph));
|
|
268
|
+
if (la !== undefined) values["Z9.mean"] = la;
|
|
227
269
|
}
|
|
228
270
|
return values;
|
|
229
271
|
}
|
|
@@ -246,6 +288,27 @@ function rowRules(step: ScreenStep, graph: Graph, row: CompareRow, again: Compar
|
|
|
246
288
|
return { hard: { ...countRules(rules, []), "same-twice": deterministic ? 0 : 1 }, deterministic };
|
|
247
289
|
}
|
|
248
290
|
|
|
291
|
+
/**
|
|
292
|
+
* This function gives the output hash of a row of `compareStep` (ACTIVATION), or `undefined` for a row without output.
|
|
293
|
+
* A router hashes its result with `resultHash`. The step `group` hashes its tree and its result, and the step `curve` hashes its curves.
|
|
294
|
+
*/
|
|
295
|
+
function rowHash(row: CompareRow): string | undefined {
|
|
296
|
+
try {
|
|
297
|
+
return rowHashOf(row);
|
|
298
|
+
} catch (e) {
|
|
299
|
+
// An output with NaN or an infinite number has no hash. The record then has no `outputHash`, and the script `screen` names it.
|
|
300
|
+
if (!(e instanceof RangeError)) throw e;
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** This function gives the output hash of a row. It throws a `RangeError` for an output with a number that is not finite. */
|
|
306
|
+
function rowHashOf(row: CompareRow): string | undefined {
|
|
307
|
+
if (row.step === "curve") return row.curves && row.edges ? curvesHash(row.edges, row.curves) : undefined;
|
|
308
|
+
if (row.step === "group") return row.result || row.tree ? outputHash({ tree: row.tree, result: row.result }, { grid: RECORD_EPS }) : undefined;
|
|
309
|
+
return row.result ? resultHash(row.result) : undefined;
|
|
310
|
+
}
|
|
311
|
+
|
|
249
312
|
/** This function adds the router `<name>+<w>` for each router of the registry and each wrapper `w`. It gives the new registry and the new names. */
|
|
250
313
|
function wrapRouters(registry: Registry, wraps: readonly ScreenWrap[]): { registry: Registry; added: string[] } {
|
|
251
314
|
const routers = listStrategies(registry, "router");
|
|
@@ -313,6 +376,8 @@ export function screen(step: ScreenStep, strategies: readonly (Strategy<ScreenSt
|
|
|
313
376
|
const comparisons: StepComparison[] = [];
|
|
314
377
|
const kept = new Map<string, { graph: Graph; results: Map<string, Result> }>();
|
|
315
378
|
const variants: { record: (typeof records)[number]; base: string; result: Result }[] = [];
|
|
379
|
+
const optima = givesRoutes(step) && options.optima ? new Map(options.optima.map((o) => [optimumKey(o.input, o.rule, o.cap), o])) : undefined;
|
|
380
|
+
const withOptima = new Set(options.optima?.map((o) => o.input));
|
|
316
381
|
|
|
317
382
|
for (const input of inputs) {
|
|
318
383
|
const graph = inputGraph(input);
|
|
@@ -326,6 +391,9 @@ export function screen(step: ScreenStep, strategies: readonly (Strategy<ScreenSt
|
|
|
326
391
|
let cornersRoutes: readonly Route[] | undefined;
|
|
327
392
|
const cornersBase = (): readonly Route[] =>
|
|
328
393
|
(cornersRoutes ??= (cmp.rows.find((r) => r.strategy === "corners" && r.result)?.result ?? asRouter(getStrategy(STRATEGIES, "router", "corners")).route(graph)).routes);
|
|
394
|
+
// The B1 of the baseline of each rule of the gap closed, for an input with optima.
|
|
395
|
+
let baselines: Record<GapRule, number> | undefined;
|
|
396
|
+
const gapBase = (): Record<GapRule, number> => (baselines ??= gapBaselines(graph));
|
|
329
397
|
cmp.rows.forEach((row, i) => {
|
|
330
398
|
const strategy = byName.get(row.strategy)!;
|
|
331
399
|
const head = {
|
|
@@ -350,6 +418,9 @@ export function screen(step: ScreenStep, strategies: readonly (Strategy<ScreenSt
|
|
|
350
418
|
}
|
|
351
419
|
const { hard, deterministic } = rowRules(step, graph, row, again?.rows[i]);
|
|
352
420
|
const values = rowValues(step, graph, row, cornersBase);
|
|
421
|
+
// Z2 is the count of the hard rule `link-ends`, as a measure. The steps `router`, `highway` and `group` check the rule.
|
|
422
|
+
if (hard["link-ends"] !== undefined) values["Z2.linkEnds"] = hard["link-ends"];
|
|
423
|
+
if (optima && withOptima.has(input.id) && row.result) Object.assign(values, gapValues(input, graph, row.result, cornersBase(), gapBase(), optima));
|
|
353
424
|
if (shuffles > 0 && row.result) {
|
|
354
425
|
const router = asRouter(strategy as Strategy<"router">, own[row.strategy]);
|
|
355
426
|
const f = orderFragility(graph, (g) => router.route(g), { orders: shuffles, seed, base: row.result });
|
|
@@ -360,7 +431,10 @@ export function screen(step: ScreenStep, strategies: readonly (Strategy<ScreenSt
|
|
|
360
431
|
const z = highwayEdits(graph, plan, own[row.strategy], edits, seed);
|
|
361
432
|
Object.assign(values, { "Z10.edits.mean": z.mean, "Z10.edits.moved": z.moved, "Z10.churn": z.churn, "Z10.ids": z.ids });
|
|
362
433
|
}
|
|
363
|
-
const
|
|
434
|
+
const output = rowHash(row);
|
|
435
|
+
const counts = row.result ? options.moves?.(row.strategy, graph) : undefined;
|
|
436
|
+
const facts = { ...(output === undefined ? {} : { outputHash: output }), ...(counts === undefined ? {} : { trace: [{ id: head.step, measures: { ...counts } }] }) };
|
|
437
|
+
const record = { ...head, measures: named(values), hard, tolerance: { through: TOLERANCE }, deterministic, ms: row.ms, ...tail, ...facts };
|
|
364
438
|
records.push(record);
|
|
365
439
|
if (givesRoutes(step) && input.base !== undefined && row.result) variants.push({ record, base: input.base, result: row.result });
|
|
366
440
|
});
|
|
@@ -375,7 +449,8 @@ export function screen(step: ScreenStep, strategies: readonly (Strategy<ScreenSt
|
|
|
375
449
|
Object.assign(v.record.measures, named({ "Z10.base.mean": s.mean, "Z10.base.max": s.max, "Z10.base.moved": s.moved }));
|
|
376
450
|
}
|
|
377
451
|
|
|
378
|
-
|
|
452
|
+
const summary = summarizeScreen(records, comparisons, { set: set.id, step, candidates, ...(options.predictions ? { predictions: options.predictions } : {}) });
|
|
453
|
+
return { records, summary: optima ? { ...summary, gap: summarizeGap(records) } : summary };
|
|
379
454
|
}
|
|
380
455
|
|
|
381
456
|
/** This function gives the summary of the records of a screen. */
|
|
@@ -464,5 +539,6 @@ export function formatScreen(summary: ScreenSummary): string {
|
|
|
464
539
|
for (const p of summary.predictions)
|
|
465
540
|
lines.push(`| ${p.id} | ${p.strategy} | ${p.input ?? "median"} | ${p.measure} | ${p.op} ${fmt(p.expected)} | ${fmt(p.measured)} | ${p.met === undefined ? "-" : p.met ? "yes" : "no"} | ${p.reason} |`);
|
|
466
541
|
}
|
|
542
|
+
if (summary.gap) lines.push(formatGap(summary.gap).trimEnd());
|
|
467
543
|
return lines.join("\n") + "\n";
|
|
468
544
|
}
|
package/src/bench/screen/set.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { hubChainBoard, manyGroupsBoard, packedGridBoard } from "../../cases.ts"
|
|
|
2
2
|
import { FIXTURES, makeFixture, type FixtureSpec } from "../../fixtures.ts";
|
|
3
3
|
import type { Graph } from "../../model.ts";
|
|
4
4
|
import { familySeed } from "../suite.ts";
|
|
5
|
+
import { EXACT_V1, EXACT_V1_BOUNDS } from "./exact-set.ts";
|
|
5
6
|
import { graphInput, type LayoutCell, type LayoutGenerator, type LayoutInput } from "./forms.ts";
|
|
6
7
|
|
|
7
8
|
/** A family of boards of a screen set. The member `k` of the family has the seed `k`. */
|
|
@@ -85,8 +86,8 @@ export const SCREEN_V1: ScreenSet = {
|
|
|
85
86
|
],
|
|
86
87
|
};
|
|
87
88
|
|
|
88
|
-
/** The screen sets by id. */
|
|
89
|
-
export const SCREEN_SETS: Readonly<Record<string, ScreenSet>> = { [SCREEN_V1.id]: SCREEN_V1 };
|
|
89
|
+
/** The screen sets by id: `screen-v1`, the small boards with a known optimum `exact-v1` (MEAS2), and the larger boards with bounds `exact-v1-bounds` (EXACT-BOUNDS). */
|
|
90
|
+
export const SCREEN_SETS: Readonly<Record<string, ScreenSet>> = { [SCREEN_V1.id]: SCREEN_V1, [EXACT_V1.id]: EXACT_V1, [EXACT_V1_BOUNDS.id]: EXACT_V1_BOUNDS };
|
|
90
91
|
|
|
91
92
|
/** This function gives the id of the member `seed` of a family of a set: `<set>/<family>/s<seed>`. */
|
|
92
93
|
export function inputId(set: string, family: string, seed: number): string {
|
package/src/channels.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { dist, type Box, type Point } from "./geom.ts";
|
|
2
|
+
import type { Obstacles } from "./visibility.ts";
|
|
3
|
+
|
|
4
|
+
/** The largest distance that `channelWidth` looks on each side of a link. A wider channel counts as this width on that side. */
|
|
5
|
+
export const CHANNEL_REACH = 64;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* This function gives the distance from `p` along the unit direction `d` to the box, or `Infinity` when the ray does not meet the box.
|
|
9
|
+
* A point inside the box gives 0. The function uses the slab test.
|
|
10
|
+
*/
|
|
11
|
+
export function rayToBox(p: Point, d: Point, box: Box): number {
|
|
12
|
+
let lo = 0;
|
|
13
|
+
let hi = Infinity;
|
|
14
|
+
for (const [o, v, min, max] of [
|
|
15
|
+
[p.x, d.x, box.x, box.x + box.w],
|
|
16
|
+
[p.y, d.y, box.y, box.y + box.h],
|
|
17
|
+
] as const) {
|
|
18
|
+
if (Math.abs(v) < 1e-12) {
|
|
19
|
+
if (o < min || o > max) return Infinity;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const t1 = (min - o) / v;
|
|
23
|
+
const t2 = (max - o) / v;
|
|
24
|
+
lo = Math.max(lo, Math.min(t1, t2));
|
|
25
|
+
hi = Math.min(hi, Math.max(t1, t2));
|
|
26
|
+
if (lo > hi) return Infinity;
|
|
27
|
+
}
|
|
28
|
+
return lo;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** This function gives the free distance from `p` along the unit direction `d` to the nearest box, up to `reach`. */
|
|
32
|
+
function freeRay(obstacles: Obstacles, p: Point, d: Point, reach: number): number {
|
|
33
|
+
const q = { x: p.x + d.x * reach, y: p.y + d.y * reach };
|
|
34
|
+
let best = reach;
|
|
35
|
+
obstacles.query({ x: Math.min(p.x, q.x), y: Math.min(p.y, q.y), w: Math.abs(q.x - p.x), h: Math.abs(q.y - p.y) }, (n) => {
|
|
36
|
+
best = Math.min(best, rayToBox(p, d, n.box));
|
|
37
|
+
});
|
|
38
|
+
return best;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* This function gives the width of the channel of the link from `a` to `b`. The width is the free space between the nearest boxes on the two sides.
|
|
43
|
+
* It measures the width at five points of the link, and gives the smallest. Each side counts up to `reach`.
|
|
44
|
+
* A link of length 0 gives the free space across the x axis at its point.
|
|
45
|
+
*/
|
|
46
|
+
export function channelWidth(obstacles: Obstacles, a: Point, b: Point, reach: number = CHANNEL_REACH): number {
|
|
47
|
+
const len = dist(a, b);
|
|
48
|
+
const n = len > 1e-9 ? { x: -(b.y - a.y) / len, y: (b.x - a.x) / len } : { x: 0, y: 1 };
|
|
49
|
+
const back = { x: -n.x, y: -n.y };
|
|
50
|
+
let width = Infinity;
|
|
51
|
+
for (const u of [0.1, 0.3, 0.5, 0.7, 0.9]) {
|
|
52
|
+
const p = { x: a.x + (b.x - a.x) * u, y: a.y + (b.y - a.y) * u };
|
|
53
|
+
width = Math.min(width, freeRay(obstacles, p, n, reach) + freeRay(obstacles, p, back, reach));
|
|
54
|
+
}
|
|
55
|
+
return width;
|
|
56
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { boardUnit } from "../../bench/measures/routes.ts";
|
|
2
|
+
import { dist, type Point } from "../../geom.ts";
|
|
3
|
+
import { WayFinder, type WayOptions } from "../../ways.ts";
|
|
4
|
+
import type { DensitySource } from "../kdeeb/index.ts";
|
|
5
|
+
import type { HighwayInput, HighwayPlan } from "../model.ts";
|
|
6
|
+
import { bigGroups, firstLines, nearestOnLine, shapeSkeleton, skeletonGroups, type ShapeSkeleton, type SkeletonGroups } from "../sbeb/index.ts";
|
|
7
|
+
import { snapPlan, type SnapLine, type SnapMember } from "../snap.ts";
|
|
8
|
+
|
|
9
|
+
/** The rule of the half width of an area: the spread of the edges of the group, or the number of its lanes. */
|
|
10
|
+
export type AreaWidth = "spread" | "count";
|
|
11
|
+
|
|
12
|
+
/** The values of `AreaWidth`. */
|
|
13
|
+
export const AREA_WIDTHS: readonly AreaWidth[] = Object.freeze(["spread", "count"]);
|
|
14
|
+
|
|
15
|
+
/** The options of the highway strategy `areas` (HWY-010 of the notes). */
|
|
16
|
+
export interface AreaOptions extends Pick<WayOptions, "margin" | "k"> {
|
|
17
|
+
/** The clusterer of the groups. The default is `quickbundles`. */
|
|
18
|
+
readonly group: SkeletonGroups;
|
|
19
|
+
/** The lines of the edges that make the shape: their straight lines, or their first routes. The default is `lines`. */
|
|
20
|
+
readonly source: DensitySource;
|
|
21
|
+
/** The radius of the shape of a group, as a multiple of `boardUnit`. The default is 1.5. */
|
|
22
|
+
readonly radius: number;
|
|
23
|
+
/** The rule of the half width of the area. The default is `spread`. */
|
|
24
|
+
readonly width: AreaWidth;
|
|
25
|
+
/** The width of one lane for the rule `count`, in units of the board. The default is 4. */
|
|
26
|
+
readonly laneWidth: number;
|
|
27
|
+
/** The side of a cell of the image of the shape, in units of the board. The default is 4. */
|
|
28
|
+
readonly cell: number;
|
|
29
|
+
/** A highway has this number of edges or more. The default is 2. */
|
|
30
|
+
readonly minSize: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The default options of the strategy `areas`. */
|
|
34
|
+
export const AREA_DEFAULTS: Readonly<Required<Omit<AreaOptions, "margin" | "k">>> = Object.freeze({
|
|
35
|
+
group: "quickbundles",
|
|
36
|
+
source: "lines",
|
|
37
|
+
radius: 1.5,
|
|
38
|
+
width: "spread",
|
|
39
|
+
laneWidth: 4,
|
|
40
|
+
cell: 4,
|
|
41
|
+
minSize: 2,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* This function gives the longest path of the largest part of a skeleton, as the middles of its cells. The neighbours of a cell are its 8 neighbours.
|
|
46
|
+
* The function finds the cell farthest from a first cell, then the cell farthest from that cell. A skeleton without cells gives an empty list.
|
|
47
|
+
*/
|
|
48
|
+
export function skeletonPath(s: ShapeSkeleton): Point[] {
|
|
49
|
+
const { grid: g, skeleton } = s;
|
|
50
|
+
const n = g.w * g.h;
|
|
51
|
+
const part = new Int32Array(n).fill(-1);
|
|
52
|
+
const sizes: number[] = [];
|
|
53
|
+
const neighbours = (i: number): number[] => {
|
|
54
|
+
const x = i % g.w;
|
|
55
|
+
const y = (i - x) / g.w;
|
|
56
|
+
const out: number[] = [];
|
|
57
|
+
for (let dy = -1; dy <= 1; dy++)
|
|
58
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
59
|
+
if (dx === 0 && dy === 0) continue;
|
|
60
|
+
const u = x + dx;
|
|
61
|
+
const v = y + dy;
|
|
62
|
+
if (u >= 0 && v >= 0 && u < g.w && v < g.h && skeleton[v * g.w + u]) out.push(v * g.w + u);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
};
|
|
66
|
+
for (let i = 0; i < n; i++) {
|
|
67
|
+
if (!skeleton[i] || part[i]! >= 0) continue;
|
|
68
|
+
const id = sizes.length;
|
|
69
|
+
let size = 0;
|
|
70
|
+
const stack = [i];
|
|
71
|
+
part[i] = id;
|
|
72
|
+
while (stack.length > 0) {
|
|
73
|
+
const c = stack.pop()!;
|
|
74
|
+
size++;
|
|
75
|
+
for (const j of neighbours(c))
|
|
76
|
+
if (part[j]! < 0) {
|
|
77
|
+
part[j] = id;
|
|
78
|
+
stack.push(j);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
sizes.push(size);
|
|
82
|
+
}
|
|
83
|
+
if (sizes.length === 0) return [];
|
|
84
|
+
const big = sizes.indexOf(Math.max(...sizes));
|
|
85
|
+
const bfs = (from: number): { far: number; prev: Int32Array } => {
|
|
86
|
+
const prev = new Int32Array(n).fill(-2);
|
|
87
|
+
prev[from] = -1;
|
|
88
|
+
const queue = [from];
|
|
89
|
+
let far = from;
|
|
90
|
+
for (let q = 0; q < queue.length; q++) {
|
|
91
|
+
const c = queue[q]!;
|
|
92
|
+
far = c;
|
|
93
|
+
for (const j of neighbours(c))
|
|
94
|
+
if (prev[j] === -2) {
|
|
95
|
+
prev[j] = c;
|
|
96
|
+
queue.push(j);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { far, prev };
|
|
100
|
+
};
|
|
101
|
+
let first = -1;
|
|
102
|
+
for (let i = 0; i < n && first < 0; i++) if (part[i] === big) first = i;
|
|
103
|
+
const a = bfs(first).far;
|
|
104
|
+
const { far: b, prev } = bfs(a);
|
|
105
|
+
const path: Point[] = [];
|
|
106
|
+
for (let c = b; c >= 0; c = prev[c]!) {
|
|
107
|
+
const x = c % g.w;
|
|
108
|
+
const y = (c - x) / g.w;
|
|
109
|
+
path.push({ x: g.x0 + (x + 0.5) * g.cell, y: g.y0 + (y + 0.5) * g.cell });
|
|
110
|
+
}
|
|
111
|
+
return path;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** This function gives the value at the share `q` of the sorted list, with a linear mix between two values. An empty list gives 0. */
|
|
115
|
+
function quantileOf(values: readonly number[], q: number): number {
|
|
116
|
+
if (values.length === 0) return 0;
|
|
117
|
+
const s = [...values].sort((a, b) => a - b);
|
|
118
|
+
const at = Math.max(0, Math.min(s.length - 1, q * (s.length - 1)));
|
|
119
|
+
const i = Math.floor(at);
|
|
120
|
+
const u = at - i;
|
|
121
|
+
return i + 1 < s.length ? s[i]! * (1 - u) + s[i + 1]! * u : s[i]!;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* This function gives the highways of the image-based edge bundles (HWY-010, Telea and Ersoy 2010). Each group of edges is one area with one spine.
|
|
126
|
+
* The spine goes through the corner graph between the two ends of the longest path of the skeleton of the shape of the group.
|
|
127
|
+
* The area is the skeleton path with a half width. With `spread`, the half width is the 90th percentile of the distances of the points to the path.
|
|
128
|
+
* With `count`, it is the number of lanes times half of `laneWidth`. The half width is at least the margin.
|
|
129
|
+
* An edge joins the spine where its line first goes into the area. It leaves the spine where its line last comes out of the area.
|
|
130
|
+
*/
|
|
131
|
+
export function areaPlan(input: HighwayInput, o: Readonly<AreaOptions>): HighwayPlan {
|
|
132
|
+
const way: WayOptions = { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) };
|
|
133
|
+
const finder = new WayFinder(input.graph.nodes, way);
|
|
134
|
+
const radius = Math.max(1e-3, o.radius * boardUnit(input.graph));
|
|
135
|
+
const lines = firstLines(input, finder, o.source, Math.max(4, 2 * o.cell));
|
|
136
|
+
const snapLines: SnapLine[] = [];
|
|
137
|
+
for (const g of bigGroups(skeletonGroups(input, o.group), o.minSize)) {
|
|
138
|
+
const own = g.edges.filter((m) => lines.has(m.edge));
|
|
139
|
+
if (own.length < Math.max(2, o.minSize)) continue;
|
|
140
|
+
const path = skeletonPath(shapeSkeleton(own.map((m) => lines.get(m.edge)!), finder, radius, o.cell));
|
|
141
|
+
if (path.length < 2 || dist(path[0]!, path[path.length - 1]!) <= finder.margin) continue;
|
|
142
|
+
const spread = own.flatMap((m) => lines.get(m.edge)!.map((p) => dist(p, nearestOnLine(path, p))));
|
|
143
|
+
const half = Math.max(finder.margin, o.width === "count" ? (own.length * o.laneWidth) / 2 : quantileOf(spread, 0.9));
|
|
144
|
+
const members: SnapMember[] = [];
|
|
145
|
+
for (const m of own) {
|
|
146
|
+
const l = lines.get(m.edge)!;
|
|
147
|
+
let first = -1;
|
|
148
|
+
let last = -1;
|
|
149
|
+
l.forEach((p, i) => {
|
|
150
|
+
if (dist(p, nearestOnLine(path, p)) > half) return;
|
|
151
|
+
if (first < 0) first = i;
|
|
152
|
+
last = i;
|
|
153
|
+
});
|
|
154
|
+
if (first < 0 || last <= first) continue;
|
|
155
|
+
members.push({ edge: m.edge, from: l[first]!, to: l[last]!, at: first / Math.max(1, l.length - 1) });
|
|
156
|
+
}
|
|
157
|
+
snapLines.push({ key: g.key, a: path[0]!, b: path[path.length - 1]!, members });
|
|
158
|
+
}
|
|
159
|
+
return snapPlan(input, snapLines, { ...way, minSize: o.minSize });
|
|
160
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { Graph } from "../../model.ts";
|
|
2
|
+
import { byCollection } from "../../routers/trunks.ts";
|
|
3
|
+
import type { WayOptions } from "../../ways.ts";
|
|
4
|
+
import { groupedPlan, type EdgeGroup, type GroupSpine } from "../grouped.ts";
|
|
5
|
+
import type { HighwayInput, HighwayPlan } from "../model.ts";
|
|
6
|
+
|
|
7
|
+
/** The edges that one group can hold: the edges of one collection, or each edge. */
|
|
8
|
+
export type GroupScope = "collection" | "all";
|
|
9
|
+
|
|
10
|
+
/** The values of `GroupScope`. */
|
|
11
|
+
export const GROUP_SCOPES: readonly GroupScope[] = Object.freeze(["collection", "all"]);
|
|
12
|
+
|
|
13
|
+
/** The options of the highway strategy `confluent` (HWY-013 of the notes). */
|
|
14
|
+
export interface ConfluentOptions extends Pick<WayOptions, "margin" | "k"> {
|
|
15
|
+
/** A biclique `K(A, B)` becomes a track when `|A| |B|` is this value or more. The default is 4. */
|
|
16
|
+
readonly minBiclique: number;
|
|
17
|
+
/** The edges of one track. With `collection`, a track holds the edges of one collection, and an edge without a collection is on no track. The default is `all`. */
|
|
18
|
+
readonly scope: GroupScope;
|
|
19
|
+
/** The spine of each track. The default is `trunk`: one trunk from the merge junction to the split junction. */
|
|
20
|
+
readonly spine: GroupSpine;
|
|
21
|
+
/** An edge rides the trunk only when each of its legs is this share of the trunk or shorter. `Infinity` sets no limit. The default is 1. */
|
|
22
|
+
readonly legMax: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The default options of the strategy `confluent`. */
|
|
26
|
+
export const CONFLUENT_DEFAULTS: Readonly<Required<Omit<ConfluentOptions, "margin" | "k">>> = Object.freeze({
|
|
27
|
+
minBiclique: 4,
|
|
28
|
+
scope: "all",
|
|
29
|
+
spine: "trunk",
|
|
30
|
+
legMax: 1,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** The edges between pairs of nodes of one scope: for each node, its neighbours and the edges to each of them. */
|
|
34
|
+
export interface NodeLinks {
|
|
35
|
+
/** The key of the scope. */
|
|
36
|
+
readonly key: string;
|
|
37
|
+
/** For each node, the edges to each neighbour, by the id of the neighbour. */
|
|
38
|
+
readonly links: Map<string, Map<string, string[]>>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** This function gives the links of each scope. A loop (an edge from a node to itself) is not a link. The order of the maps follows the order of the edges. */
|
|
42
|
+
export function nodeLinks(graph: Graph, scope: GroupScope): NodeLinks[] {
|
|
43
|
+
const byKey = new Map<string, Map<string, Map<string, string[]>>>();
|
|
44
|
+
const nodes = new Set(graph.nodes.map((n) => n.id));
|
|
45
|
+
for (const e of graph.edges) {
|
|
46
|
+
const s = e.source.node;
|
|
47
|
+
const t = e.target.node;
|
|
48
|
+
if (s === t || !nodes.has(s) || !nodes.has(t)) continue;
|
|
49
|
+
const key = scope === "all" ? "" : byCollection(e);
|
|
50
|
+
if (key === undefined) continue;
|
|
51
|
+
let links = byKey.get(key);
|
|
52
|
+
if (!links) byKey.set(key, (links = new Map()));
|
|
53
|
+
for (const [u, v] of [
|
|
54
|
+
[s, t],
|
|
55
|
+
[t, s],
|
|
56
|
+
] as const) {
|
|
57
|
+
let m = links.get(u);
|
|
58
|
+
if (!m) links.set(u, (m = new Map()));
|
|
59
|
+
const list = m.get(v);
|
|
60
|
+
if (list) list.push(e.id);
|
|
61
|
+
else m.set(v, [e.id]);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return [...byKey].map(([key, links]) => ({ key, links }));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A biclique: the two sets of nodes, with a link from each node of `a` to each node of `b`. */
|
|
68
|
+
export interface Biclique {
|
|
69
|
+
readonly a: readonly string[];
|
|
70
|
+
readonly b: readonly string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* This function gives the bicliques of a greedy search (HWY-013). Each search starts at one node `x` with `A = {x}` and `B` its neighbours.
|
|
75
|
+
* It then adds to `A` the node that gives the largest `|A| |B|`, with `B` cut to the common neighbours, while the product grows.
|
|
76
|
+
* The best biclique of all starts with `|A| |B| >= min` becomes a track, its links go away, and the search repeats.
|
|
77
|
+
* The nodes of `A` and `B` are apart. The largest set is `b`.
|
|
78
|
+
*/
|
|
79
|
+
export function greedyBicliques(links: Map<string, Map<string, string[]>>, min: number): Biclique[] {
|
|
80
|
+
const live = new Map<string, Set<string>>();
|
|
81
|
+
for (const [u, m] of links) live.set(u, new Set(m.keys()));
|
|
82
|
+
const out: Biclique[] = [];
|
|
83
|
+
const order = [...live.keys()].sort();
|
|
84
|
+
for (;;) {
|
|
85
|
+
let best: { a: string[]; b: Set<string>; size: number } | undefined;
|
|
86
|
+
for (const x of order) {
|
|
87
|
+
const nx = live.get(x)!;
|
|
88
|
+
if (nx.size === 0) continue;
|
|
89
|
+
const a = [x];
|
|
90
|
+
let b = new Set(nx);
|
|
91
|
+
for (;;) {
|
|
92
|
+
// The candidates: the other neighbours of the nodes of B, outside A and B.
|
|
93
|
+
let pick: { y: string; b: Set<string> } | undefined;
|
|
94
|
+
let pickSize = a.length * b.size;
|
|
95
|
+
const seen = new Set<string>();
|
|
96
|
+
for (const v of [...b].sort())
|
|
97
|
+
for (const y of [...live.get(v)!].sort()) {
|
|
98
|
+
if (seen.has(y) || a.includes(y) || b.has(y)) continue;
|
|
99
|
+
seen.add(y);
|
|
100
|
+
const ny = live.get(y)!;
|
|
101
|
+
const common = new Set([...b].filter((z) => ny.has(z)));
|
|
102
|
+
const size = (a.length + 1) * common.size;
|
|
103
|
+
if (size > pickSize) [pick, pickSize] = [{ y, b: common }, size];
|
|
104
|
+
}
|
|
105
|
+
if (!pick) break;
|
|
106
|
+
a.push(pick.y);
|
|
107
|
+
b = pick.b;
|
|
108
|
+
}
|
|
109
|
+
const size = a.length * b.size;
|
|
110
|
+
if (size >= min && (!best || size > best.size)) best = { a: [...a], b, size };
|
|
111
|
+
}
|
|
112
|
+
if (!best) break;
|
|
113
|
+
const a = [...best.a].sort();
|
|
114
|
+
const b = [...best.b].sort();
|
|
115
|
+
out.push(a.length > b.length ? { a: b, b: a } : { a, b });
|
|
116
|
+
for (const u of a)
|
|
117
|
+
for (const v of b) {
|
|
118
|
+
live.get(u)!.delete(v);
|
|
119
|
+
live.get(v)!.delete(u);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* This function gives the group of each edge of a set of bicliques. The edges of one biclique are one group, with the key `<prefix><scope>/<index>`.
|
|
127
|
+
* An edge from a node of `b` to a node of `a` has `flip`. Thus each trunk goes from the side `a` to the side `b`.
|
|
128
|
+
*/
|
|
129
|
+
export function bicliqueGroups(graph: Graph, scope: NodeLinks, bicliques: readonly Biclique[], prefix: string, into: Map<string, EdgeGroup>): void {
|
|
130
|
+
const sourceOf = new Map(graph.edges.map((e) => [e.id, e.source.node] as const));
|
|
131
|
+
bicliques.forEach((bc, i) => {
|
|
132
|
+
const key = `${prefix}${scope.key}/${i}`;
|
|
133
|
+
const inA = new Set(bc.a);
|
|
134
|
+
for (const u of bc.a)
|
|
135
|
+
for (const v of bc.b) for (const edge of scope.links.get(u)?.get(v) ?? []) if (!into.has(edge)) into.set(edge, { key, flip: !inA.has(sourceOf.get(edge)!) });
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* This function gives the highways of the confluent drawing (HWY-013, Dickerson, Eppstein, Goodrich and Meng 2005).
|
|
141
|
+
* Each biclique `K(A, B)` of the greedy search is one track. The track merges the edges near the side `A` and splits them near the side `B`.
|
|
142
|
+
* The junctions are the Weber points of the ends, as in the spine `trunk` of `groupedPlan`.
|
|
143
|
+
*/
|
|
144
|
+
export function confluentPlan(input: HighwayInput, o: Readonly<ConfluentOptions>): HighwayPlan {
|
|
145
|
+
const groups = new Map<string, EdgeGroup>();
|
|
146
|
+
for (const scope of nodeLinks(input.graph, o.scope)) bicliqueGroups(input.graph, scope, greedyBicliques(scope.links, Math.max(2, o.minBiclique)), "c", groups);
|
|
147
|
+
return groupedPlan(input, groups, {
|
|
148
|
+
...(o.margin === undefined ? {} : { margin: o.margin }),
|
|
149
|
+
...(o.k === undefined ? {} : { k: o.k }),
|
|
150
|
+
spine: o.spine,
|
|
151
|
+
minSize: 2,
|
|
152
|
+
legMax: o.legMax,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Graph } from "../../model.ts";
|
|
2
|
+
import { box } from "../fixtures.test-util.ts";
|
|
3
|
+
|
|
4
|
+
/** A complete biclique K(2, 3): two nodes on the left, three on the right, and an edge from each left node to each right node. */
|
|
5
|
+
export const k23: Graph = {
|
|
6
|
+
nodes: [...[0, 1].map((i) => ({ id: `a${i}`, box: box(0, 200 * i) })), ...[0, 1, 2].map((i) => ({ id: `b${i}`, box: box(700, 150 * i) }))],
|
|
7
|
+
edges: [0, 1].flatMap((i) => [0, 1, 2].map((j) => ({ id: `e${i}${j}`, source: { node: `a${i}` }, target: { node: `b${j}` } }))),
|
|
8
|
+
};
|