@mark1russell7/highways 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +47 -2
  2. package/build/index.d.ts +1489 -63
  3. package/build/index.d.ts.map +1 -1
  4. package/build/index.js +10139 -3356
  5. package/build/index.js.map +1 -1
  6. package/package.json +11 -2
  7. package/src/bench/exact/index.ts +218 -11
  8. package/src/bench/exact/optima-exact-v1-bounds.json +14409 -0
  9. package/src/bench/exact/optima-exact-v1.json +4809 -0
  10. package/src/bench/exact/solve.ts +129 -0
  11. package/src/bench/screen/activation.ts +3 -0
  12. package/src/bench/screen/bounds.ts +140 -0
  13. package/src/bench/screen/compare.ts +26 -0
  14. package/src/bench/screen/exact-set.ts +100 -0
  15. package/src/bench/screen/exercise.ts +290 -0
  16. package/src/bench/screen/forms.ts +14 -0
  17. package/src/bench/screen/gap.ts +316 -0
  18. package/src/bench/screen/hash.ts +7 -76
  19. package/src/bench/screen/index.ts +6 -0
  20. package/src/bench/screen/measures.ts +71 -1
  21. package/src/bench/screen/portfolio-pick.ts +20 -0
  22. package/src/bench/screen/screen.ts +50 -2
  23. package/src/bench/screen/set.ts +3 -2
  24. package/src/highways/areas/index.ts +160 -0
  25. package/src/highways/confluent/index.ts +154 -0
  26. package/src/highways/confluent/k23.test-util.ts +8 -0
  27. package/src/highways/consensus/index.ts +129 -0
  28. package/src/highways/crossbars/index.ts +184 -0
  29. package/src/highways/cubu/index.ts +26 -0
  30. package/src/highways/divided/index.ts +111 -0
  31. package/src/highways/fdeb/index.ts +193 -0
  32. package/src/highways/frechet/index.ts +232 -0
  33. package/src/highways/hierarchy/index.ts +140 -0
  34. package/src/highways/index.ts +64 -0
  35. package/src/highways/kdeeb/index.ts +355 -0
  36. package/src/highways/link-communities/index.ts +189 -0
  37. package/src/highways/mesh/index.ts +209 -0
  38. package/src/highways/mlseb/index.ts +204 -0
  39. package/src/highways/p-hub/index.ts +248 -0
  40. package/src/highways/power/index.ts +117 -0
  41. package/src/highways/resistance/index.ts +159 -0
  42. package/src/highways/sbeb/index.ts +303 -0
  43. package/src/highways/scale-space/index.ts +135 -0
  44. package/src/highways/spectral/index.ts +391 -0
  45. package/src/highways/territories/index.ts +150 -0
  46. package/src/highways/trace.ts +153 -0
  47. package/src/index.ts +3 -0
  48. package/src/portfolio/bands.ts +38 -0
  49. package/src/portfolio/fit.ts +41 -0
  50. package/src/portfolio/index.ts +6 -0
  51. package/src/portfolio/key.ts +98 -0
  52. package/src/portfolio/members.ts +171 -0
  53. package/src/portfolio/router.ts +133 -0
  54. package/src/portfolio/sets.ts +40 -0
  55. package/src/strategies/builtins.ts +222 -1
  56. package/src/strategies/schemas.ts +280 -1
@@ -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
+ };
@@ -0,0 +1,129 @@
1
+ import { groupedPlan, type EdgeGroup, type GroupSpine } from "../grouped.ts";
2
+ import type { HighwayInput, HighwayPlan } from "../model.ts";
3
+ import { candidatePlan, type CandidateSource } from "../select/index.ts";
4
+ import { edgeEnds, labelGroups } from "../spectral/index.ts";
5
+
6
+ /** The linkage of the clusters of the vote matrix: `average` (the mean distance of the pairs) or `single` (the nearest pair). */
7
+ export type ConsensusLinkage = "average" | "single";
8
+
9
+ /** The values of `ConsensusLinkage`. */
10
+ export const CONSENSUS_LINKAGES: readonly ConsensusLinkage[] = Object.freeze(["average", "single"]);
11
+
12
+ /** The options of the highway strategy `consensus` (HWY-035 of the note on highways). */
13
+ export interface ConsensusOptions {
14
+ /** The finders that vote. The default is `corridors`, `quickbundles`, `wspd` and `road-classes`. */
15
+ readonly members: readonly CandidateSource[];
16
+ /** Two clusters join while their distance `1 - A` is below this value. The default is 0.5: more than half of the votes. */
17
+ readonly cut: number;
18
+ /** The linkage of the clusters. The default is `average`. */
19
+ readonly linkage: ConsensusLinkage;
20
+ /** A cluster with this number of edges or more gets highways. The default is 2. */
21
+ readonly minSize: number;
22
+ /** The spine of each cluster. The default is `shared`. */
23
+ readonly spine: GroupSpine;
24
+ }
25
+
26
+ /** The default options of the strategy `consensus`. */
27
+ export const CONSENSUS_DEFAULTS: Readonly<ConsensusOptions> = Object.freeze({
28
+ members: Object.freeze(["corridors", "quickbundles", "wspd", "road-classes"] as CandidateSource[]),
29
+ cut: 0.5,
30
+ linkage: "average",
31
+ minSize: 2,
32
+ spine: "shared",
33
+ });
34
+
35
+ /**
36
+ * This function gives the co-association matrix of the plans (Fred and Jain 2005). `A[e][f]` is the share of the plans with a highway that holds both `e` and `f`.
37
+ * The edges are in the order of `edges`. An edge of no plan has no pair.
38
+ */
39
+ export function coAssociation(edges: readonly string[], plans: readonly HighwayPlan[]): Float64Array {
40
+ const n = edges.length;
41
+ const index = new Map(edges.map((e, i) => [e, i] as const));
42
+ const a = new Float64Array(n * n);
43
+ if (plans.length === 0) return a;
44
+ const share = 1 / plans.length;
45
+ for (const plan of plans) {
46
+ const pairs = new Set<number>();
47
+ for (const h of plan.highways) {
48
+ const list = [...new Set(h.members.map((m) => index.get(m.edge)).filter((i): i is number => i !== undefined))].sort((x, y) => x - y);
49
+ for (let x = 0; x < list.length; x++) for (let y = x + 1; y < list.length; y++) pairs.add(list[x]! * n + list[y]!);
50
+ }
51
+ for (const p of pairs) {
52
+ const i = Math.floor(p / n);
53
+ const j = p % n;
54
+ a[i * n + j] = a[i * n + j]! + share;
55
+ a[j * n + i] = a[j * n + i]! + share;
56
+ }
57
+ }
58
+ return a;
59
+ }
60
+
61
+ /**
62
+ * This function gives the clusters of the agglomerative linkage on the distance `1 - A`. Two clusters join while their distance is below `cut`.
63
+ * The linkage `average` uses the mean of the distances of the pairs, and `single` the smallest distance.
64
+ * The result is the cluster of each edge, and -1 for an edge that stays alone.
65
+ */
66
+ export function consensusLabels(n: number, a: Float64Array, cut: number, linkage: ConsensusLinkage): Int32Array {
67
+ const d = new Float64Array(n * n);
68
+ for (let i = 0; i < n * n; i++) d[i] = 1 - a[i]!;
69
+ const size = new Int32Array(n).fill(1);
70
+ const alive = new Uint8Array(n).fill(1);
71
+ const owner = new Int32Array(n);
72
+ for (let i = 0; i < n; i++) owner[i] = i;
73
+ for (;;) {
74
+ let bi = -1;
75
+ let bj = -1;
76
+ let best = cut;
77
+ for (let i = 0; i < n; i++) {
78
+ if (!alive[i]) continue;
79
+ for (let j = i + 1; j < n; j++) {
80
+ if (!alive[j]) continue;
81
+ const v = d[i * n + j]!;
82
+ if (v < best - 1e-12) [best, bi, bj] = [v, i, j];
83
+ }
84
+ }
85
+ if (bi < 0) break;
86
+ // The cluster `bj` joins the cluster `bi` (the formula of Lance and Williams).
87
+ for (let k = 0; k < n; k++) {
88
+ if (!alive[k] || k === bi || k === bj) continue;
89
+ const v =
90
+ linkage === "single"
91
+ ? Math.min(d[bi * n + k]!, d[bj * n + k]!)
92
+ : (size[bi]! * d[bi * n + k]! + size[bj]! * d[bj * n + k]!) / (size[bi]! + size[bj]!);
93
+ d[bi * n + k] = v;
94
+ d[k * n + bi] = v;
95
+ }
96
+ size[bi] = size[bi]! + size[bj]!;
97
+ alive[bj] = 0;
98
+ for (let k = 0; k < n; k++) if (owner[k] === bj) owner[k] = bi;
99
+ }
100
+ const labels = new Int32Array(n).fill(-1);
101
+ const count = new Int32Array(n);
102
+ for (let k = 0; k < n; k++) count[owner[k]!]!++;
103
+ for (let k = 0; k < n; k++) if (count[owner[k]!]! > 1) labels[k] = owner[k]!;
104
+ return labels;
105
+ }
106
+
107
+ /** This function gives the edge groups of the consensus of the plans of the finders `members` (HWY-035). */
108
+ export function consensusGroups(input: HighwayInput, o: Readonly<ConsensusOptions>): Map<string, EdgeGroup> {
109
+ const plans = [...new Set(o.members)].map((m) => candidatePlan(input, m));
110
+ // Only the edges on a highway of a plan get votes.
111
+ const voted = new Set(plans.flatMap((p) => p.highways.flatMap((h) => h.members.map((m) => m.edge))));
112
+ const ends = edgeEnds(input).filter((e) => voted.has(e.edge));
113
+ const a = coAssociation(
114
+ ends.map((e) => e.edge),
115
+ plans,
116
+ );
117
+ const labels = consensusLabels(ends.length, a, Math.max(0, Math.min(1, o.cut)), o.linkage);
118
+ const groups = new Map<string, EdgeGroup>();
119
+ labelGroups(ends, labels, "c", groups);
120
+ return groups;
121
+ }
122
+
123
+ /**
124
+ * This function gives the highways of the consensus of clusterings (HWY-035). Each finder of `members` votes for the pairs of edges that share one of its highways.
125
+ * The clusters of the vote matrix with `minSize` edges or more get highways. A small edit changes few votes, so the clusters are stable.
126
+ */
127
+ export function consensusPlan(input: HighwayInput, o: Readonly<ConsensusOptions>): HighwayPlan {
128
+ return groupedPlan(input, consensusGroups(input, o), { spine: o.spine, minSize: Math.max(2, Math.round(o.minSize)) });
129
+ }
@@ -0,0 +1,184 @@
1
+ import { boardUnit } from "../../bench/measures/routes.ts";
2
+ import { dist, type Point } from "../../geom.ts";
3
+ import type { WayOptions } from "../../ways.ts";
4
+ import { arcPlace, basePlan, planRoutes, routeIndex, tidyPlan, type HighwayBase } from "../base.ts";
5
+ import type { HighwayInput, HighwayPlan } from "../model.ts";
6
+ import { nearestOnLine } from "../sbeb/index.ts";
7
+ import { snapPlan, type SnapLine } from "../snap.ts";
8
+
9
+ /** The options of the highway strategy `crossbars` (HWY-022 of the notes). */
10
+ export interface CrossbarOptions extends Pick<WayOptions, "margin" | "k"> {
11
+ /** The plan that gets the bundled crossings. Only the edges outside its highways can be on a crossbar. The default is `shared-ways`. */
12
+ readonly from: HighwayBase;
13
+ /** Two segments are parallel when the angle between them is this value or less, in degrees. The default is 15. */
14
+ readonly tolerance: number;
15
+ /** Each side of a crossbar has this number of edges or more. The default is 2. */
16
+ readonly minBar: number;
17
+ /** The crossings of one crossbar are this distance or nearer to its first crossing, as a multiple of `boardUnit`. The default is 3. */
18
+ readonly reach: number;
19
+ }
20
+
21
+ /** The default options of the strategy `crossbars`. */
22
+ export const CROSSBAR_DEFAULTS: Readonly<Required<Omit<CrossbarOptions, "margin" | "k">>> = Object.freeze({
23
+ from: "shared-ways",
24
+ tolerance: 15,
25
+ minBar: 2,
26
+ reach: 3,
27
+ });
28
+
29
+ /** One crossing of two routes: the edges, the point and the unit directions of the two segments there. */
30
+ export interface RouteCrossing {
31
+ readonly e: string;
32
+ readonly f: string;
33
+ readonly p: Point;
34
+ readonly de: Point;
35
+ readonly df: Point;
36
+ }
37
+
38
+ /** This function gives the unit direction from `a` to `b`. */
39
+ const unit = (a: Point, b: Point): Point => {
40
+ const l = dist(a, b);
41
+ return l > 1e-12 ? { x: (b.x - a.x) / l, y: (b.y - a.y) / l } : { x: 1, y: 0 };
42
+ };
43
+
44
+ /** This function gives the point where the segments `a b` and `c d` cross inside both, or `undefined`. Segments that only touch at an end do not cross. */
45
+ function crossPoint(a: Point, b: Point, c: Point, d: Point): Point | undefined {
46
+ const rx = b.x - a.x;
47
+ const ry = b.y - a.y;
48
+ const sx = d.x - c.x;
49
+ const sy = d.y - c.y;
50
+ const den = rx * sy - ry * sx;
51
+ if (Math.abs(den) < 1e-12) return undefined;
52
+ const t = ((c.x - a.x) * sy - (c.y - a.y) * sx) / den;
53
+ const u = ((c.x - a.x) * ry - (c.y - a.y) * rx) / den;
54
+ const eps = 1e-9;
55
+ return t > eps && t < 1 - eps && u > eps && u < 1 - eps ? { x: a.x + t * rx, y: a.y + t * ry } : undefined;
56
+ }
57
+
58
+ /** This function gives each crossing of two routes of the list, in the order of the list. */
59
+ export function routeCrossings(routes: readonly { readonly edge: string; readonly points: readonly Point[] }[]): RouteCrossing[] {
60
+ const segs = routes.flatMap((r) =>
61
+ r.points.slice(1).map((b, i) => {
62
+ const a = r.points[i]!;
63
+ return { edge: r.edge, a, b, x0: Math.min(a.x, b.x), x1: Math.max(a.x, b.x), y0: Math.min(a.y, b.y), y1: Math.max(a.y, b.y) };
64
+ }),
65
+ );
66
+ const out: RouteCrossing[] = [];
67
+ for (let i = 0; i < segs.length; i++) {
68
+ const s = segs[i]!;
69
+ for (let j = i + 1; j < segs.length; j++) {
70
+ const t = segs[j]!;
71
+ if (t.edge === s.edge || t.x0 > s.x1 || t.x1 < s.x0 || t.y0 > s.y1 || t.y1 < s.y0) continue;
72
+ const p = crossPoint(s.a, s.b, t.a, t.b);
73
+ if (p) out.push({ e: s.edge, f: t.edge, p, de: unit(s.a, s.b), df: unit(t.a, t.b) });
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /** This function tells if two unit directions are parallel within the angle `tol` in radians, in either sense. */
80
+ const parallel = (u: Point, v: Point, tol: number): boolean => Math.abs(u.x * v.x + u.y * v.y) >= Math.cos(tol);
81
+
82
+ /** A crossbar: two sets of edges, with a crossing of each edge of `p` with each edge of `q` near one place. */
83
+ export interface Crossbar {
84
+ readonly p: readonly string[];
85
+ readonly q: readonly string[];
86
+ /** The mean point of the crossings of the crossbar. */
87
+ readonly at: Point;
88
+ /** The mean direction of the side `p`, and of the side `q`. */
89
+ readonly dp: Point;
90
+ readonly dq: Point;
91
+ }
92
+
93
+ /**
94
+ * This function gives the crossbars of a greedy search (HWY-022, after Fink, Hershberger, Suri and Verbeek 2016).
95
+ * The search starts at each crossing in order. It grows the two sides while each new edge crosses each edge of the other side near the start, at the same angles.
96
+ * A crossbar with `minBar` edges or more on each side is kept. Each edge is on one crossbar at most.
97
+ */
98
+ export function greedyCrossbars(crossings: readonly RouteCrossing[], reach: number, tolDeg: number, minBar: number): Crossbar[] {
99
+ const tol = (Math.max(0, tolDeg) * Math.PI) / 180;
100
+ const used = new Set<string>();
101
+ const out: Crossbar[] = [];
102
+ for (const x of crossings) {
103
+ if (used.has(x.e) || used.has(x.f)) continue;
104
+ // The crossings near the start whose two segments are parallel to the two sides, as pairs (an edge of P, an edge of Q).
105
+ const near = new Map<string, RouteCrossing & { pe: string; qe: string }>();
106
+ for (const y of crossings) {
107
+ if (dist(y.p, x.p) > reach || used.has(y.e) || used.has(y.f)) continue;
108
+ if (parallel(y.de, x.de, tol) && parallel(y.df, x.df, tol)) near.set(`${y.e}\u0000${y.f}`, { ...y, pe: y.e, qe: y.f });
109
+ else if (parallel(y.df, x.de, tol) && parallel(y.de, x.df, tol)) near.set(`${y.f}\u0000${y.e}`, { ...y, pe: y.f, qe: y.e, de: y.df, df: y.de });
110
+ }
111
+ const crosses = (pe: string, qe: string): boolean => near.has(`${pe}\u0000${qe}`);
112
+ const P = [x.e];
113
+ const Q = [x.f];
114
+ const candP = [...new Set([...near.values()].map((c) => c.pe))].sort();
115
+ const candQ = [...new Set([...near.values()].map((c) => c.qe))].sort();
116
+ for (let grown = true; grown; ) {
117
+ grown = false;
118
+ for (const pe of candP)
119
+ if (!P.includes(pe) && !Q.includes(pe) && Q.every((qe) => crosses(pe, qe))) {
120
+ P.push(pe);
121
+ grown = true;
122
+ }
123
+ for (const qe of candQ)
124
+ if (!Q.includes(qe) && !P.includes(qe) && P.every((pe) => crosses(pe, qe))) {
125
+ Q.push(qe);
126
+ grown = true;
127
+ }
128
+ }
129
+ if (P.length < Math.max(1, minBar) || Q.length < Math.max(1, minBar)) continue;
130
+ const parts = P.flatMap((pe) => Q.map((qe) => near.get(`${pe}\u0000${qe}`)!));
131
+ const at = { x: parts.reduce((s, c) => s + c.p.x, 0) / parts.length, y: parts.reduce((s, c) => s + c.p.y, 0) / parts.length };
132
+ // The mean directions, each turned to the sense of the first crossing.
133
+ const mean = (pick: (c: RouteCrossing) => Point, ref: Point): Point => {
134
+ let sx = 0;
135
+ let sy = 0;
136
+ for (const c of parts) {
137
+ const d = pick(c);
138
+ const s = d.x * ref.x + d.y * ref.y >= 0 ? 1 : -1;
139
+ sx += s * d.x;
140
+ sy += s * d.y;
141
+ }
142
+ return unit({ x: 0, y: 0 }, { x: sx, y: sy });
143
+ };
144
+ out.push({ p: P, q: Q, at, dp: mean((c) => c.de, x.de), dq: mean((c) => c.df, x.df) });
145
+ for (const e of [...P, ...Q]) used.add(e);
146
+ }
147
+ return out;
148
+ }
149
+
150
+ /**
151
+ * This function gives the highways of the bundled crossings (HWY-022). The edges outside the highways of the plan `from` keep their routes after `follow`.
152
+ * Each crossbar of those routes gives two highways, one for each side. Each spine is a line through the mean crossing, along the mean direction of its side.
153
+ * It is `2 reach` long, and it goes through the corner graph. So each side crosses the other side at one place, as a bundle.
154
+ * An edge joins the spine near the point of its route nearest to one end of the line. It leaves the spine near the other end.
155
+ */
156
+ export function crossbarPlan(input: HighwayInput, o: Readonly<CrossbarOptions>): HighwayPlan {
157
+ const way: WayOptions = { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) };
158
+ const base = basePlan(input, o.from);
159
+ const inPlan = new Set(base.highways.flatMap((h) => h.members.map((m) => m.edge)));
160
+ const routes = routeIndex(planRoutes(input.graph, base).routes);
161
+ const free = input.graph.edges.filter((e) => !inPlan.has(e.id) && routes.has(e.id)).map((e) => ({ edge: e.id, points: routes.get(e.id)!.slice(1, -1) }));
162
+ const reach = Math.max(1e-3, o.reach * boardUnit(input.graph));
163
+ const bars = greedyCrossbars(routeCrossings(free), reach, o.tolerance, o.minBar);
164
+ const lines: SnapLine[] = [];
165
+ bars.forEach((bar, i) => {
166
+ for (const [side, d] of [
167
+ [bar.p, bar.dp],
168
+ [bar.q, bar.dq],
169
+ ] as const) {
170
+ const a = { x: bar.at.x - reach * d.x, y: bar.at.y - reach * d.y };
171
+ const b = { x: bar.at.x + reach * d.x, y: bar.at.y + reach * d.y };
172
+ const members = side.map((edge) => {
173
+ const r = routes.get(edge)!;
174
+ const from = nearestOnLine(r, a);
175
+ const to = nearestOnLine(r, b);
176
+ const total = arcPlace(r, r[r.length - 1]!);
177
+ return { edge, from, to, at: total > 0 ? arcPlace(r, from) / total : 0 };
178
+ });
179
+ lines.push({ key: `x${i}`, a, b, members });
180
+ }
181
+ });
182
+ const bars2 = snapPlan(input, lines, { ...way, minSize: 2 });
183
+ return tidyPlan({ highways: [...base.highways, ...bars2.highways] });
184
+ }
@@ -0,0 +1,26 @@
1
+ import type { HighwayInput, HighwayPlan } from "../model.ts";
2
+ import { densityPlan, KDEEB_DEFAULTS, type DensityKernel, type KdeebOptions } from "../kdeeb/index.ts";
3
+
4
+ /** The options of the highway strategy `cubu` (HWY-007 of the notes). */
5
+ export interface CubuOptions extends KdeebOptions {
6
+ /** The way to compute the density: `separable` (CUBu) or `fft` (FFTEB). The default is `separable`. */
7
+ readonly kernel: Exclude<DensityKernel, "direct">;
8
+ /** The spacing of the sample points after each step, in units of the board. The value 0 keeps the points. The default is 8. */
9
+ readonly resample: number;
10
+ }
11
+
12
+ /** The default options of the strategy `cubu`. */
13
+ export const CUBU_DEFAULTS: Readonly<Required<Omit<CubuOptions, "margin" | "k">>> = Object.freeze({
14
+ ...KDEEB_DEFAULTS,
15
+ kernel: "separable",
16
+ resample: 8,
17
+ });
18
+
19
+ /**
20
+ * This function gives the highways of the fast density bundling (HWY-007: CUBu of van der Zwan, Codreanu and Telea 2016, and FFTEB of Lhuillier, Hurter and Telea 2017).
21
+ * The steps are those of `kdeeb`, with the mask of the boxes. The density comes from the counts of the cells, blurred by a separable kernel or by a Fourier transform.
22
+ * The sample points of each edge are spaced again after each step.
23
+ */
24
+ export function cubuPlan(input: HighwayInput, o: Readonly<CubuOptions>): HighwayPlan {
25
+ return densityPlan(input, o, { kernel: o.kernel, resample: o.resample }, "cubu");
26
+ }
@@ -0,0 +1,111 @@
1
+ import type { Point } from "../../geom.ts";
2
+ import { WayFinder, type WayOptions } from "../../ways.ts";
3
+ import { basePlan, tidyPlan, type HighwayBase } from "../base.ts";
4
+ import { pointAt, type Highway, type HighwayInput, type HighwayMember, type HighwayPlan } from "../model.ts";
5
+
6
+ /** The side of the road that the traffic keeps to. */
7
+ export type TrafficRule = "right" | "left";
8
+
9
+ /** The values of `TrafficRule`. */
10
+ export const TRAFFIC_RULES: readonly TrafficRule[] = Object.freeze(["right", "left"]);
11
+
12
+ /** The options of the highway strategy `divided` (HWY-011 of the notes). */
13
+ export interface DividedOptions extends Pick<WayOptions, "margin" | "k"> {
14
+ /** The plan that the strategy divides. The default is `shared-ways`. */
15
+ readonly from: HighwayBase;
16
+ /** The side of the road of the traffic. With `right`, the edges of each direction keep to the right of the spine. The default is `right`. */
17
+ readonly rule: TrafficRule;
18
+ /** The gap between the two carriageways, in lanes. The default is 1. */
19
+ readonly gap: number;
20
+ /** The width of one lane, in units of the board. The default is 4. */
21
+ readonly laneWidth: number;
22
+ /** A highway divides only when each direction has this number of edges or more. The default is 2. */
23
+ readonly minSize: number;
24
+ }
25
+
26
+ /** The default options of the strategy `divided`. */
27
+ export const DIVIDED_DEFAULTS: Readonly<Required<Omit<DividedOptions, "margin" | "k">>> = Object.freeze({
28
+ from: "shared-ways",
29
+ rule: "right",
30
+ gap: 1,
31
+ laneWidth: 4,
32
+ minSize: 2,
33
+ });
34
+
35
+ /** The largest length of a miter, as a multiple of the offset. A sharper corner gets a shorter miter. */
36
+ const MITER = 2;
37
+
38
+ /**
39
+ * This function gives the polyline at the distance `offset` from `line`, with the same number of points. A positive offset is on the right of the line.
40
+ * The y axis points down, so the right of the direction `(dx, dy)` is `(-dy, dx)`. Each inner point moves along the bisector of its two legs (a miter join).
41
+ */
42
+ export function offsetLine(line: readonly Point[], offset: number): Point[] {
43
+ const n = line.length;
44
+ const normals: Point[] = [];
45
+ for (let i = 1; i < n; i++) {
46
+ const a = line[i - 1]!;
47
+ const b = line[i]!;
48
+ const l = Math.hypot(b.x - a.x, b.y - a.y);
49
+ normals.push(l > 1e-12 ? { x: -(b.y - a.y) / l, y: (b.x - a.x) / l } : { x: 0, y: 0 });
50
+ }
51
+ return line.map((p, i) => {
52
+ const before = normals[i - 1] ?? normals[i]!;
53
+ const after = normals[i] ?? normals[i - 1]!;
54
+ let mx = before.x + after.x;
55
+ let my = before.y + after.y;
56
+ const ml = Math.hypot(mx, my);
57
+ if (ml < 1e-12) {
58
+ mx = after.x;
59
+ my = after.y;
60
+ } else {
61
+ mx /= ml;
62
+ my /= ml;
63
+ }
64
+ const cos = mx * after.x + my * after.y;
65
+ const scale = Math.min(MITER, 1 / Math.max(1e-6, cos));
66
+ return { x: p.x + mx * offset * scale, y: p.y + my * offset * scale };
67
+ });
68
+ }
69
+
70
+ /** This function gives a highway with a new spine and the members in `members`. The merge points and split points move to the new spine. */
71
+ function carriageway(h: Highway, spine: readonly Point[], members: readonly HighwayMember[], suffix: string): Highway {
72
+ const moved = members.map((m) => ({ ...m, merge: pointAt(spine, m.on), split: pointAt(spine, m.off) }));
73
+ return { id: `${h.id}${suffix}`, key: h.key, spine, lanes: moved.map((m) => m.edge), members: moved };
74
+ }
75
+
76
+ /**
77
+ * This function divides one highway into two carriageways, one for each direction (HWY-011, divided edge bundling of Selassie, Heller and Heer 2011).
78
+ * Each carriageway is a copy of the spine at an offset to the side of its traffic. The offset is half the lanes of that side plus half the gap.
79
+ * When an offset spine is not clear of the boxes, the function tries one half and one quarter of the offset. It gives `undefined` when none fits.
80
+ */
81
+ export function divideHighway(h: Highway, finder: WayFinder, o: Readonly<Omit<DividedOptions, "from" | "margin" | "k">>): Highway[] | undefined {
82
+ const forward = h.members.filter((m) => m.on < m.off);
83
+ const backward = h.members.filter((m) => m.on > m.off);
84
+ const size = Math.max(1, o.minSize);
85
+ if (forward.length < size || backward.length < size) return undefined;
86
+ const sign = o.rule === "left" ? -1 : 1;
87
+ const lane = Math.max(0, o.laneWidth);
88
+ const gap = Math.max(0, o.gap);
89
+ const pad = finder.margin / 4;
90
+ const wanted = (count: number): number => lane * (count / 2 + gap / 2);
91
+ for (const share of [1, 0.5, 0.25]) {
92
+ const right = offsetLine(h.spine, sign * share * wanted(forward.length));
93
+ const left = offsetLine(h.spine, -sign * share * wanted(backward.length));
94
+ if (!finder.obstacles.clearPath(right, pad) || !finder.obstacles.clearPath(left, pad)) continue;
95
+ return [carriageway(h, right, forward, "a"), carriageway(h, left, backward, "b")];
96
+ }
97
+ return undefined;
98
+ }
99
+
100
+ /**
101
+ * This function gives the highways of the plan `from`, with each highway of two-way traffic divided into two carriageways (HWY-011).
102
+ * The edges of each direction get their own spine on the side of the rule `rule`. Thus the two directions do not cross in the lanes.
103
+ * A highway with fewer than `minSize` edges in one direction, or without the room for the offsets, stays as it is.
104
+ */
105
+ export function dividedPlan(input: HighwayInput, o: Readonly<DividedOptions>): HighwayPlan {
106
+ const finder = new WayFinder(input.graph.nodes, { ...(o.margin === undefined ? {} : { margin: o.margin }), ...(o.k === undefined ? {} : { k: o.k }) });
107
+ const base = basePlan(input, o.from);
108
+ const highways: Highway[] = [];
109
+ for (const h of base.highways) highways.push(...(divideHighway(h, finder, o) ?? [h]));
110
+ return tidyPlan({ highways });
111
+ }