@mark1russell7/highways 0.9.4 → 0.9.5

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/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "directory": "packages/core"
10
10
  },
11
11
  "homepage": "https://mark1russell7.github.io/highways/",
12
- "version": "0.9.4",
12
+ "version": "0.9.5",
13
13
  "type": "module",
14
14
  "main": "./build/index.js",
15
15
  "types": "./build/index.d.ts",
@@ -84,13 +84,14 @@ export const MOVE_NAMES: Readonly<Record<string, string>> = Object.freeze({
84
84
  interchanges: "networkRoad",
85
85
  knee: "tree",
86
86
  bronchial: "tree",
87
+ manifolds: "manifold",
87
88
  });
88
89
 
89
90
  /**
90
91
  * The highway strategies that change the plan of another strategy (`from`). Their own move is a change of that plan, so they count no move.
91
92
  * The output against the plan `from` tells if they ran.
92
93
  */
93
- export const PLAN_CHANGERS: readonly string[] = Object.freeze(["crossbars", "divided", "lanes", "level-links", "meet", "stubs", "weiszfeld", "zipper"]);
94
+ export const PLAN_CHANGERS: readonly string[] = Object.freeze(["balance", "crossbars", "divided", "lanes", "level-links", "meet", "slots", "stubs", "weiszfeld", "zipper"]);
94
95
 
95
96
  const FINDERS: readonly string[] = [...new Set([...Object.keys(highwayStrategies).filter((n) => !n.includes("+")), "shared-ways-all"])].sort((a, b) => b.length - a.length);
96
97
 
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The highway strategy `balance` (EDA-017): the stretch of each edge of a bundle stays near a target.
3
+ * A Lagrange price for each edge grows while its stretch is above the target, and the price moves the exit of the edge forward.
4
+ */
5
+ import { polylineLength } from "../../geom.ts";
6
+ import { outerGraph, type Graph } from "../../model.ts";
7
+ import { basePlan, tidyPlan, type HighwayBase } from "../base.ts";
8
+ import { follow } from "../follow.ts";
9
+ import { lengthBetween, memberAt, type HighwayInput, type HighwayMember, type HighwayPlan } from "../model.ts";
10
+
11
+ /** The end of a ride that the price moves: the exit, the entry, or both by half. */
12
+ export type BalanceEnd = "exit" | "entry" | "both";
13
+
14
+ /** The values of `BalanceEnd`. */
15
+ export const BALANCE_ENDS: readonly BalanceEnd[] = Object.freeze(["exit", "entry", "both"]);
16
+
17
+ /** The options of the highway strategy `balance` (EDA-017). */
18
+ export interface BalanceOptions {
19
+ /** The target of the stretch of an edge: its route on the plan over its route alone, from 1.1 to 2. The default is 1.2. */
20
+ readonly tau: number;
21
+ /** The number of updates of the prices, from 5 to 30. The default is 5. */
22
+ readonly iterations: number;
23
+ /** The end of a ride that the price moves. The default is `exit`. */
24
+ readonly end: BalanceEnd;
25
+ /** The plan that the strategy balances. The default is `shared-ways`. */
26
+ readonly from: HighwayBase;
27
+ }
28
+
29
+ /** The default options of the strategy `balance`. */
30
+ export const BALANCE_DEFAULTS: Readonly<BalanceOptions> = Object.freeze({ tau: 1.2, iterations: 5, end: "exit", from: "shared-ways" });
31
+
32
+ /** The margin of the way finder of `follow`. A ride shorter than this length leaves the plan. */
33
+ const MARGIN = 5;
34
+
35
+ /** This function gives the length of the route of each edge of a plan after `follow`. */
36
+ function routeLengths(graph: Graph, plan: HighwayPlan): Map<string, number> {
37
+ return new Map(follow(graph, plan).routes.map((r) => [r.edge, polylineLength(r.points)]));
38
+ }
39
+
40
+ /**
41
+ * This function gives the plan of the highway strategy `balance` (EDA-017, after the Lagrange relaxation of the stretch).
42
+ * The stretch of an edge is its route on the plan over its route alone, as the gate `stretch` measures it.
43
+ * At each update, an edge with a stretch above `tau` adds `stretch - tau` to its price. Each of its rides keeps the share `1 - price` of its first length.
44
+ * A ride shorter than the margin leaves the plan. The updates stop when no edge is above `tau`.
45
+ */
46
+ export function balancePlan(input: HighwayInput, options: Partial<BalanceOptions> = {}): HighwayPlan {
47
+ const o = { ...BALANCE_DEFAULTS, ...options };
48
+ const graph = outerGraph(input.graph);
49
+ return balanceOf(graph, basePlan({ ...input, graph }, o.from), o);
50
+ }
51
+
52
+ /** This function balances the plan `first` of the graph, as `balancePlan` does. It reads all the options except `from`. */
53
+ export function balanceOf(graph: Graph, first: HighwayPlan, options: Partial<BalanceOptions> = {}): HighwayPlan {
54
+ const o = { ...BALANCE_DEFAULTS, ...options };
55
+ const riders = new Set(first.highways.flatMap((h) => h.members.map((m) => m.edge)));
56
+ if (riders.size === 0) return first;
57
+ const sub: Graph = { nodes: graph.nodes, edges: graph.edges.filter((e) => riders.has(e.id)) };
58
+ const alone = routeLengths(sub, { highways: [] });
59
+ const price = new Map<string, number>();
60
+ let plan = first;
61
+ for (let k = 0; k < Math.max(1, Math.floor(o.iterations)); k++) {
62
+ const along = routeLengths(sub, plan);
63
+ let over = false;
64
+ for (const [edge, length] of along) {
65
+ const base = alone.get(edge) ?? 0;
66
+ if (base <= 1e-9) continue;
67
+ const stretch = length / base;
68
+ if (stretch <= o.tau + 1e-9) continue;
69
+ price.set(edge, (price.get(edge) ?? 0) + stretch - o.tau);
70
+ over = true;
71
+ }
72
+ if (!over) break;
73
+ // Each update cuts the rides of the first plan by the prices as they are now.
74
+ plan = tidyPlan({
75
+ highways: first.highways.map((h) => ({
76
+ ...h,
77
+ members: h.members.flatMap((m): HighwayMember[] => {
78
+ const keep = 1 - Math.min(1, price.get(m.edge) ?? 0);
79
+ if (keep >= 1) return [m];
80
+ const span = m.off - m.on;
81
+ // The exit moves back toward the entry, the entry moves forward toward the exit, or each moves by half the cut.
82
+ const cut = (1 - keep) * span;
83
+ const on = o.end === "entry" ? m.on + cut : o.end === "both" ? m.on + cut / 2 : m.on;
84
+ const off = o.end === "exit" ? m.off - cut : o.end === "both" ? m.off - cut / 2 : m.off;
85
+ if (lengthBetween(h.spine, Math.min(on, off), Math.max(on, off)) < MARGIN) return [];
86
+ return [memberAt(h.spine, m.edge, on, off, m.seq)];
87
+ }),
88
+ })),
89
+ });
90
+ if (plan.highways.length === 0) break;
91
+ }
92
+ return plan;
93
+ }
@@ -103,6 +103,9 @@ export { GAP_DEFAULTS, gapPlan, type GapOptions } from "./gaps/index.ts";
103
103
  export { STREET_DEFAULTS, streetPlan, type StreetOptions } from "./streets/index.ts";
104
104
  export { SEARCH_DEFAULTS, SEARCH_MOVES, searchPlan, searchScore, type SearchMove, type SearchOptions } from "./search/index.ts";
105
105
  export { KNEE_DEFAULTS, KNEE_RULES, kneeOf, kneePlan, type KneeOptions, type KneeRule, type KneeTree } from "./knee/index.ts";
106
+ export { BALANCE_DEFAULTS, BALANCE_ENDS, balanceOf, balancePlan, type BalanceEnd, type BalanceOptions } from "./balance/index.ts";
107
+ export { SLOT_DEFAULTS, SLOT_KEYS, slotJoin, slotPlan, slotsOf, type SlotJoin, type SlotKeys, type SlotOptions } from "./slots/index.ts";
108
+ export { MANIFOLD_DEFAULTS, MANIFOLD_SIDES, manifoldPlan, type ManifoldOptions, type ManifoldSide } from "./manifolds/index.ts";
106
109
  export { BRONCHIAL_DEFAULTS, BRONCHIAL_SPLITS, bronchialPlan, diamondAngle, splitTargets, type BronchialOptions, type BronchialSplit } from "./bronchial/index.ts";
107
110
  export { INTERCHANGE_BASES, INTERCHANGE_DEFAULTS, interchangeCandidates, interchangePlan, type Interchange, type InterchangeBase, type InterchangeOptions } from "./interchanges/index.ts";
108
111
  export { SCALE_SPACE_DEFAULTS, scaleClusters, scaleSpaceGroups, scaleSpacePlan, type ScaleCluster, type ScaleSpaceOptions } from "./scale-space/index.ts";
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The highway strategy `manifolds` (WILD-036): a header for a fan from one node to a row of nodes.
3
+ * The header lies in the gap before the row, the taps leave it at right angles, and the feed joins it from the node.
4
+ */
5
+ import { center, dist, type Point } from "../../geom.ts";
6
+ import { nodeIndex, outerGraph, type Node } from "../../model.ts";
7
+ import { basePlan, tidyPlan, type HighwayBase } from "../base.ts";
8
+ import { keepMembers } from "../gates/model.ts";
9
+ import type { HighwayInput, HighwayPlan } from "../model.ts";
10
+ import { snapPlan, type SnapLine } from "../snap.ts";
11
+
12
+ /** The place of the header in the gap before the row: near the row, or near the feed node. */
13
+ export type ManifoldSide = "near" | "far";
14
+
15
+ /** The values of `ManifoldSide`. */
16
+ export const MANIFOLD_SIDES: readonly ManifoldSide[] = Object.freeze(["near", "far"]);
17
+
18
+ /** The options of the highway strategy `manifolds` (WILD-036). */
19
+ export interface ManifoldOptions {
20
+ /** A manifold has this number of taps or more, from 3 to 8. The default is 3. */
21
+ readonly kMin: number;
22
+ /** The nodes of one row have centers in a band of this many median box heights, from 0.5 to 2. The default is 1. */
23
+ readonly band: number;
24
+ /** The place of the header in the gap before the row. The default is `near`. */
25
+ readonly side: ManifoldSide;
26
+ /** A tap stays on the manifold only when its way is this many times the direct line or less, from 1 to 3. The default is 1.5. */
27
+ readonly detour: number;
28
+ /** The plan of the edges that are not on a manifold. The default is `shared-ways`. */
29
+ readonly from: HighwayBase;
30
+ }
31
+
32
+ /** The default options of the strategy `manifolds`. */
33
+ export const MANIFOLD_DEFAULTS: Readonly<ManifoldOptions> = Object.freeze({ kMin: 3, band: 1, side: "near", detour: 1.5, from: "shared-ways" });
34
+
35
+ /** One end of a fan: an edge from the feed node to a far node. */
36
+ interface Tap {
37
+ readonly edge: string;
38
+ readonly far: Node;
39
+ /** With `true`, the feed node is the source of the edge. */
40
+ readonly out: boolean;
41
+ }
42
+
43
+ /** A manifold that the strategy can make: the feed node, the axis of the header, its place across the axis and its taps. */
44
+ interface Candidate {
45
+ readonly feed: Node;
46
+ /** With `x`, the header is horizontal. With `y`, the header is vertical. */
47
+ readonly axis: "x" | "y";
48
+ readonly at: number;
49
+ readonly taps: readonly Tap[];
50
+ }
51
+
52
+ /** This function gives the median of the values, or 0 when there is none. */
53
+ function median(values: readonly number[]): number {
54
+ if (values.length === 0) return 0;
55
+ const s = [...values].sort((a, b) => a - b);
56
+ return s[Math.floor(s.length / 2)]!;
57
+ }
58
+
59
+ /** This function compares two texts in the order of their code units. */
60
+ function byId(a: string, b: string): number {
61
+ return a < b ? -1 : a > b ? 1 : 0;
62
+ }
63
+
64
+ /**
65
+ * This function gives the manifolds that the strategy can make from one feed node, for one axis of the header.
66
+ * The far centers go in bands across the axis. A band with `kMin` taps or more, wholly on one side of the feed node, is a candidate.
67
+ */
68
+ function candidatesOf(feed: Node, taps: readonly Tap[], axis: "x" | "y", band: number, o: Readonly<ManifoldOptions>, margin: number): Candidate[] {
69
+ const across = axis === "x" ? "y" : "x";
70
+ const size = axis === "x" ? "h" : "w";
71
+ const sorted = [...taps].sort((a, b) => center(a.far.box)[across] - center(b.far.box)[across] || byId(a.edge, b.edge));
72
+ const out: Candidate[] = [];
73
+ const lo = feed.box[across];
74
+ const hi = lo + feed.box[size];
75
+ // The share of the gap from the row to its header: a third with `near`, two thirds with `far`.
76
+ const share = o.side === "near" ? 1 / 3 : 2 / 3;
77
+ for (let i = 0; i < sorted.length; ) {
78
+ let j = i;
79
+ const first = center(sorted[i]!.far.box)[across];
80
+ while (j + 1 < sorted.length && center(sorted[j + 1]!.far.box)[across] - first <= band) j++;
81
+ const row = sorted.slice(i, j + 1);
82
+ i = j + 1;
83
+ if (row.length < o.kMin) continue;
84
+ const top = Math.min(...row.map((t) => t.far.box[across]));
85
+ const bottom = Math.max(...row.map((t) => t.far.box[across] + t.far.box[size]));
86
+ if (hi + 2 * margin <= top) out.push({ feed, axis, at: top - share * (top - hi), taps: row });
87
+ else if (bottom + 2 * margin <= lo) out.push({ feed, axis, at: bottom + share * (lo - bottom), taps: row });
88
+ }
89
+ return out;
90
+ }
91
+
92
+ /**
93
+ * This function gives the plan of the highway strategy `manifolds` (WILD-036, after the headers of pipes and of bus bars).
94
+ * A fan of `kMin` edges or more from one node to a row of nodes gets a straight header in the gap before the row.
95
+ * Each edge of the fan joins the header at the feed point, in line with the center of the node. It leaves the header at its tap, in line with the center of its far node.
96
+ * A tap whose way is more than `detour` times the direct line leaves the manifold. Each edge is on one manifold at most. The other edges keep the plan `from`.
97
+ */
98
+ export function manifoldPlan(input: HighwayInput, options: Partial<ManifoldOptions> = {}): HighwayPlan {
99
+ const o = { ...MANIFOLD_DEFAULTS, ...options };
100
+ const graph = outerGraph(input.graph);
101
+ const nodes = nodeIndex(graph);
102
+ const margin = 5;
103
+ const bandOf = { x: o.band * median(graph.nodes.map((n) => n.box.h)), y: o.band * median(graph.nodes.map((n) => n.box.w)) };
104
+ const fans = new Map<string, Tap[]>();
105
+ const add = (id: string, tap: Tap): void => {
106
+ const list = fans.get(id);
107
+ if (list) list.push(tap);
108
+ else fans.set(id, [tap]);
109
+ };
110
+ for (const e of graph.edges) {
111
+ const s = nodes.get(e.source.node);
112
+ const t = nodes.get(e.target.node);
113
+ if (!s || !t || s === t) continue;
114
+ add(s.id, { edge: e.id, far: t, out: true });
115
+ add(t.id, { edge: e.id, far: s, out: false });
116
+ }
117
+ const all: Candidate[] = [];
118
+ for (const [id, taps] of fans) {
119
+ if (taps.length < o.kMin) continue;
120
+ const feed = nodes.get(id)!;
121
+ for (const axis of ["x", "y"] as const) all.push(...candidatesOf(feed, taps, axis, bandOf[axis], o, margin));
122
+ }
123
+ // The largest manifolds go first. A tie goes to the node id, then to the axis, so the plan does not depend on the order of the map.
124
+ all.sort((a, b) => b.taps.length - a.taps.length || byId(a.feed.id, b.feed.id) || byId(a.axis, b.axis) || a.at - b.at);
125
+ const used = new Set<string>();
126
+ const lines: SnapLine[] = [];
127
+ for (const c of all) {
128
+ const fc = center(c.feed.box);
129
+ const feedPoint: Point = c.axis === "x" ? { x: fc.x, y: c.at } : { x: c.at, y: fc.y };
130
+ const tapOf = (t: Tap): Point => {
131
+ const tc = center(t.far.box);
132
+ return c.axis === "x" ? { x: tc.x, y: c.at } : { x: c.at, y: tc.y };
133
+ };
134
+ const taps = c.taps.filter((t) => {
135
+ if (used.has(t.edge)) return false;
136
+ const tc = center(t.far.box);
137
+ return dist(fc, feedPoint) + dist(feedPoint, tapOf(t)) + dist(tapOf(t), tc) <= o.detour * dist(fc, tc);
138
+ });
139
+ if (taps.length < o.kMin) continue;
140
+ const along = [feedPoint, ...taps.map(tapOf)].map((p) => p[c.axis]);
141
+ const lo = Math.min(...along);
142
+ const hi = Math.max(...along);
143
+ const a: Point = c.axis === "x" ? { x: lo, y: c.at } : { x: c.at, y: lo };
144
+ const b: Point = c.axis === "x" ? { x: hi, y: c.at } : { x: c.at, y: hi };
145
+ for (const t of taps) used.add(t.edge);
146
+ lines.push({
147
+ key: `manifold:${c.feed.id}:${c.axis}`,
148
+ a,
149
+ b,
150
+ members: taps.map((t) => (t.out ? { edge: t.edge, from: feedPoint, to: tapOf(t), at: 0.5 } : { edge: t.edge, from: tapOf(t), to: feedPoint, at: 0.5 })),
151
+ });
152
+ }
153
+ const manifolds = snapPlan({ graph }, lines, { minSize: Math.max(2, o.kMin), direct: true });
154
+ const riding = new Set(manifolds.highways.flatMap((h) => h.members.map((m) => m.edge)));
155
+ const base = keepMembers(basePlan({ ...input, graph }, o.from), (_, m) => !riding.has(m.edge));
156
+ return tidyPlan({ highways: [...manifolds.highways, ...base.highways] });
157
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * The highway strategy `slots` (RULE-047): a pull of each route toward the lane slots of the highway skeleton.
3
+ * An edge whose first route runs near a trunk of the plan joins that trunk when the pull is larger than its detour.
4
+ */
5
+ import { checkProperties } from "../../bench/properties.ts";
6
+ import { dist, polylineLength, type Point } from "../../geom.ts";
7
+ import { outerGraph, type Graph } from "../../model.ts";
8
+ import { byCollection } from "../../routers/trunks.ts";
9
+ import { basePlan, firstRoutes, nearestPlace, tidyPlan, type HighwayBase } from "../base.ts";
10
+ import { follow } from "../follow.ts";
11
+ import { lengthBetween, memberAt, pointAt, type Highway, type HighwayInput, type HighwayPlan } from "../model.ts";
12
+
13
+ /** The edges that can join a trunk: only the edges with the key of the trunk, or each edge. */
14
+ export type SlotKeys = "same" | "any";
15
+
16
+ /** The values of `SlotKeys`. */
17
+ export const SLOT_KEYS: readonly SlotKeys[] = Object.freeze(["same", "any"]);
18
+
19
+ /** The options of the highway strategy `slots` (RULE-047). */
20
+ export interface SlotOptions {
21
+ /** The weight `k` of the pull, from 0.25 to 8. The default is 2. The name is not `k`, because `follow` reads `k` as an option of its ways. */
22
+ readonly pull: number;
23
+ /** The reach `r` of the pull, in units, from 8 to 64. A route point farther than this distance from a trunk gets no pull. The default is 24. */
24
+ readonly reach: number;
25
+ /** The distance between two lanes of a trunk, in units, from 2 to 8. The default is 4. */
26
+ readonly laneSpacing: number;
27
+ /** The largest stretch of a route after it joins a trunk: its new length over its first length, from 1.05 to 2. The default is 1.25. */
28
+ readonly maxStretch: number;
29
+ /** The edges that can join a trunk. The default is `same`. */
30
+ readonly keys: SlotKeys;
31
+ /** The plan that gives the trunks. The default is `shared-ways`. */
32
+ readonly from: HighwayBase;
33
+ }
34
+
35
+ /** The default options of the strategy `slots`. */
36
+ export const SLOT_DEFAULTS: Readonly<SlotOptions> = Object.freeze({ pull: 2, reach: 24, laneSpacing: 4, maxStretch: 1.25, keys: "same", from: "shared-ways" });
37
+
38
+ /** One sample of a route: a point and its length from the first point of the route. */
39
+ interface Sample {
40
+ readonly p: Point;
41
+ readonly arc: number;
42
+ }
43
+
44
+ /** One possible join of an edge to a trunk: the trunk, the places on its spine, the pull and the detour. */
45
+ export interface SlotJoin {
46
+ readonly highway: number;
47
+ readonly on: number;
48
+ readonly off: number;
49
+ /** The pull energy that the join removes: `k × Σ w(d) × (d - slot)² × step / r²`, in units of length. */
50
+ readonly pull: number;
51
+ /** The length that the join adds to the route, as an estimate. */
52
+ readonly detour: number;
53
+ }
54
+
55
+ /** This function gives points along a polyline, one at each `step` of length, and the last point. */
56
+ function samplesOf(points: readonly Point[], step: number): Sample[] {
57
+ const out: Sample[] = [];
58
+ if (points.length === 0) return out;
59
+ out.push({ p: points[0]!, arc: 0 });
60
+ let walked = 0;
61
+ let next = step;
62
+ for (let i = 1; i < points.length; i++) {
63
+ const a = points[i - 1]!;
64
+ const b = points[i]!;
65
+ const len = dist(a, b);
66
+ while (len > 0 && next <= walked + len) {
67
+ const u = (next - walked) / len;
68
+ out.push({ p: { x: a.x + (b.x - a.x) * u, y: a.y + (b.y - a.y) * u }, arc: next });
69
+ next += step;
70
+ }
71
+ walked += len;
72
+ }
73
+ if (walked > out[out.length - 1]!.arc + 1e-9) out.push({ p: points[points.length - 1]!, arc: walked });
74
+ return out;
75
+ }
76
+
77
+ /**
78
+ * This function gives the best join of one route to the trunk `h`, or `undefined`.
79
+ * The longest row of samples nearer than `reach` to the spine is the part of the route that the trunk pulls.
80
+ * The slot of the new lane is at `laneSpacing × (lanes + 1) / 2` from the spine. The weight is `w(d) = max(0, 1 - d / r)`.
81
+ */
82
+ export function slotJoin(route: readonly Point[], h: Highway, index: number, o: Readonly<SlotOptions>): SlotJoin | undefined {
83
+ const step = Math.max(2, o.reach / 4);
84
+ const samples = samplesOf(route, step);
85
+ const offset = (o.laneSpacing * (h.lanes.length + 1)) / 2;
86
+ let best: { from: number; to: number } | undefined;
87
+ let start = -1;
88
+ const near = samples.map((s) => {
89
+ const at = nearestPlace(h.spine, s.p);
90
+ return { at, d: dist(s.p, pointAt(h.spine, at)) };
91
+ });
92
+ for (let i = 0; i <= samples.length; i++) {
93
+ const inside = i < samples.length && near[i]!.d < o.reach;
94
+ if (inside && start < 0) start = i;
95
+ if (!inside && start >= 0) {
96
+ if (!best || samples[i - 1]!.arc - samples[start]!.arc > samples[best.to]!.arc - samples[best.from]!.arc) best = { from: start, to: i - 1 };
97
+ start = -1;
98
+ }
99
+ }
100
+ if (!best || best.to <= best.from) return undefined;
101
+ const on = near[best.from]!.at;
102
+ const off = near[best.to]!.at;
103
+ const ride = lengthBetween(h.spine, Math.min(on, off), Math.max(on, off));
104
+ if (ride < 2 * o.reach || Math.abs(on - off) < 1e-9) return undefined;
105
+ let pull = 0;
106
+ for (let i = best.from; i <= best.to; i++) {
107
+ const d = near[i]!.d;
108
+ const w = Math.max(0, 1 - d / o.reach);
109
+ const gap = d - offset;
110
+ pull += w * gap * gap;
111
+ }
112
+ pull = (o.pull * pull * step) / (o.reach * o.reach);
113
+ const arc = samples[best.to]!.arc - samples[best.from]!.arc;
114
+ const detour = near[best.from]!.d + near[best.to]!.d + ride - arc;
115
+ return { highway: index, on, off, pull, detour };
116
+ }
117
+
118
+ /**
119
+ * This function gives the plan of the highway strategy `slots` (RULE-047, after the pull of force-directed edge bundling, Holten and van Wijk 2009).
120
+ * The trunks are the highways of the plan `from`. Each edge outside the plan gets a pull toward the slot of a new lane of a trunk near its first route.
121
+ * The energy of the pull is `E = k Σ w(d_i) ‖x_i - slot_i‖²` over the samples of the route, with `w(d) = max(0, 1 - d / r)`.
122
+ * The edge joins the trunk when the pull is larger than the length that the join adds, and the stretch stays under `maxStretch`.
123
+ * The edge takes the trunk with the largest gain, on the outer lane. An edge that does not fit stays off the trunks.
124
+ */
125
+ export function slotPlan(input: HighwayInput, options: Partial<SlotOptions> = {}): HighwayPlan {
126
+ const o = { ...SLOT_DEFAULTS, ...options };
127
+ const graph = outerGraph(input.graph);
128
+ const first = basePlan({ ...input, graph }, o.from);
129
+ return slotsOf(graph, first, firstRoutes({ ...input, graph }), o);
130
+ }
131
+
132
+ /** This function adds the joins of `slotPlan` to the plan `first` of the graph. It reads all the options except `from`. A join that breaks a hard rule after `follow` leaves the plan. */
133
+ export function slotsOf(graph: Graph, first: HighwayPlan, routes: ReadonlyMap<string, readonly Point[]>, options: Partial<SlotOptions> = {}): HighwayPlan {
134
+ const o = { ...SLOT_DEFAULTS, ...options };
135
+ if (first.highways.length === 0) return first;
136
+ const riders = new Set(first.highways.flatMap((h) => h.members.map((m) => m.edge)));
137
+ const added = first.highways.map(() => [] as { edge: string; on: number; off: number }[]);
138
+ const edges = [...graph.edges].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
139
+ for (const e of edges) {
140
+ if (riders.has(e.id)) continue;
141
+ const route = routes.get(e.id);
142
+ if (!route || route.length < 2) continue;
143
+ const length = polylineLength(route);
144
+ if (length <= 1e-9) continue;
145
+ const key = byCollection(e);
146
+ let best: SlotJoin | undefined;
147
+ first.highways.forEach((h, i) => {
148
+ if (o.keys === "same" && (key === undefined || h.key !== key)) return;
149
+ const j = slotJoin(route, h, i, o);
150
+ if (!j || j.pull <= j.detour || (length + Math.max(0, j.detour)) / length > o.maxStretch + 1e-9) return;
151
+ if (!best || j.pull - j.detour > best.pull - best.detour + 1e-9) best = j;
152
+ });
153
+ if (best) added[best.highway]!.push({ edge: e.id, on: best.on, off: best.off });
154
+ }
155
+ const build = (): HighwayPlan =>
156
+ tidyPlan({
157
+ highways: first.highways.map((h, i) => {
158
+ const more = added[i]!;
159
+ if (more.length === 0) return h;
160
+ return { ...h, lanes: [...h.lanes, ...more.map((m) => m.edge)], members: [...h.members, ...more.map((m) => memberAt(h.spine, m.edge, m.on, m.off, 0))] };
161
+ }),
162
+ });
163
+ // A join whose route after `follow` breaks a hard rule leaves the plan. A new break of a rider takes each join of its trunks off.
164
+ const before = new Set(checkProperties(graph, follow(graph, first), 1_000_000).flatMap((v) => (v.edge === undefined ? [] : [v.edge])));
165
+ for (let round = 0; round < 4; round++) {
166
+ if (added.every((list) => list.length === 0)) return first;
167
+ const plan = build();
168
+ const faulty = new Set(checkProperties(graph, follow(graph, plan), 1_000_000).flatMap((v) => (v.edge === undefined ? [] : [v.edge])));
169
+ const joined = new Set(added.flatMap((list) => list.map((m) => m.edge)));
170
+ const others = [...faulty].filter((e) => !joined.has(e) && !before.has(e));
171
+ if (![...faulty].some((e) => joined.has(e)) && others.length === 0) return plan;
172
+ first.highways.forEach((h, i) => {
173
+ const hit = others.some((e) => h.members.some((m) => m.edge === e));
174
+ added[i] = hit ? [] : added[i]!.filter((m) => !faulty.has(m.edge));
175
+ });
176
+ }
177
+ return first;
178
+ }
package/src/index.ts CHANGED
@@ -19,11 +19,17 @@ export * from "./room/lanes.ts";
19
19
  export * from "./room/levels/index.ts";
20
20
  export * from "./plugs/facing/index.ts";
21
21
  export * from "./plugs/spread/index.ts";
22
+ export * from "./plugs/leaders/index.ts";
22
23
  export * from "./plugs/fix.ts";
23
24
  export * from "./levels/depths/index.ts";
24
25
  export * from "./whole/pipeline/index.ts";
25
26
  export * from "./levels/venation/index.ts";
27
+ export * from "./levels/detail/index.ts";
26
28
  export * from "./whole/portfolio/index.ts";
29
+ export * from "./whole/curriculum/index.ts";
30
+ export * from "./whole/preferences/index.ts";
31
+ export * from "./whole/fireflies/index.ts";
32
+ export * from "./whole/tracing/index.ts";
27
33
  export * from "./bench/properties.ts";
28
34
  export * from "./bench/quality.ts";
29
35
  export * from "./bench/links.ts";
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The level strategy `detail` (WILD-016 with EDA-038): the level of detail of the bundles of each zoom level.
3
+ * Two legs of level links that are closer on the screen than `pxMin` pixels become one stroke, with a width that grows with the count.
4
+ */
5
+ import { segmentHitsBox, type Point } from "../../geom.ts";
6
+ import type { Level, LevelItem, LevelLink } from "../../model.ts";
7
+ import { itemSizes, PICK } from "../../pick.ts";
8
+ import type { LevelInput } from "../../strategies/model.ts";
9
+ import { depthLevels, type DepthLevelOptions } from "../depths/index.ts";
10
+
11
+ /** The growth of the width of a stroke with the number of its edges. */
12
+ export type WidthScale = "linear" | "sqrt" | "log";
13
+
14
+ /** The values of `WidthScale`. */
15
+ export const WIDTH_SCALES: readonly WidthScale[] = Object.freeze(["linear", "sqrt", "log"]);
16
+
17
+ /** The options of the level strategy `detail`: the options of `depths`, and the limits of the collapse. */
18
+ export interface DetailOptions extends DepthLevelOptions {
19
+ /** Two parallel legs closer than this number of pixels on the screen become one stroke, from 0.5 to 4. The default is 3. */
20
+ readonly pxMin: number;
21
+ /** A stroke holds this number of edges or more, from 2 to 5. The default is 2. */
22
+ readonly minCount: number;
23
+ /** The width of a stroke of one edge, in pixels, from 1 to 4. The default is 1.5. A renderer reads it with `detailWidth`. */
24
+ readonly w0: number;
25
+ /** The growth of the width of a stroke with its count. The default is `log`. */
26
+ readonly widthScale: WidthScale;
27
+ }
28
+
29
+ /** The default options of the level strategy `detail`. */
30
+ export const DETAIL_DEFAULTS: Readonly<DetailOptions> = Object.freeze({ pxMin: 3, minCount: 2, w0: 1.5, widthScale: "log" });
31
+
32
+ /**
33
+ * This function gives the width of a stroke of `count` edges, in pixels (WILD-016). With `log`, it is `w0 × (1 + log2(count))`.
34
+ * With `sqrt`, it is `w0 × sqrt(count)`. With `linear`, it is `w0 × count`.
35
+ */
36
+ export function detailWidth(count: number, o: Pick<DetailOptions, "w0" | "widthScale"> = DETAIL_DEFAULTS): number {
37
+ const c = Math.max(1, count);
38
+ if (o.widthScale === "linear") return o.w0 * c;
39
+ if (o.widthScale === "sqrt") return o.w0 * Math.sqrt(c);
40
+ return o.w0 * (1 + Math.log2(c));
41
+ }
42
+
43
+ /** One inner leg of a level link, parallel to an axis. It has the index of the link and of its first point, its axis and its place across the axis. */
44
+ interface Leg {
45
+ readonly link: number;
46
+ readonly at: number;
47
+ /** With `x`, the leg is horizontal. */
48
+ readonly axis: "x" | "y";
49
+ readonly across: number;
50
+ readonly lo: number;
51
+ readonly hi: number;
52
+ readonly count: number;
53
+ }
54
+
55
+ /** This function gives the inner legs of the links that are parallel to an axis. The first and the last leg of a link touch its items, so they stay. */
56
+ function legsOf(links: readonly LevelLink[]): Leg[] {
57
+ const out: Leg[] = [];
58
+ links.forEach((l, link) => {
59
+ const p = l.points;
60
+ for (let at = 1; at + 2 < p.length; at++) {
61
+ const a = p[at]!;
62
+ const b = p[at + 1]!;
63
+ if (Math.abs(a.y - b.y) <= 1e-9 && Math.abs(a.x - b.x) > 1e-9) out.push({ link, at, axis: "x", across: a.y, lo: Math.min(a.x, b.x), hi: Math.max(a.x, b.x), count: l.count });
64
+ else if (Math.abs(a.x - b.x) <= 1e-9 && Math.abs(a.y - b.y) > 1e-9) out.push({ link, at, axis: "y", across: a.x, lo: Math.min(a.y, b.y), hi: Math.max(a.y, b.y), count: l.count });
65
+ }
66
+ });
67
+ return out;
68
+ }
69
+
70
+ /** This function takes out the repeated points and the points in the middle of a straight run. The first and the last point stay. */
71
+ function tidy(p: readonly Point[]): Point[] {
72
+ const out: Point[] = [];
73
+ for (const q of p) {
74
+ const last = out[out.length - 1];
75
+ if (last && Math.abs(last.x - q.x) <= 1e-9 && Math.abs(last.y - q.y) <= 1e-9) continue;
76
+ if (out.length >= 2) {
77
+ const a = out[out.length - 2]!;
78
+ const b = out[out.length - 1]!;
79
+ const cross = (b.x - a.x) * (q.y - b.y) - (b.y - a.y) * (q.x - b.x);
80
+ const dot = (b.x - a.x) * (q.x - b.x) + (b.y - a.y) * (q.y - b.y);
81
+ if (Math.abs(cross) <= 1e-9 && dot > 0) out.pop();
82
+ }
83
+ out.push(q);
84
+ }
85
+ if (out.length < 2 && p.length >= 2) return [p[0]!, p[p.length - 1]!];
86
+ return out;
87
+ }
88
+
89
+ /**
90
+ * This function collapses the legs of the links of one level that are closer than `tol` (WILD-016, EDA-038).
91
+ * The legs of each axis go in clusters along the axis across it. The leg with the largest count of a cluster is its stroke, and each other leg moves onto it.
92
+ * A cluster needs two legs whose projections overlap, and a count of `minCount` or more. A move that puts a leg through the box of an item does not happen.
93
+ */
94
+ export function collapseLinks(items: readonly LevelItem[], links: readonly LevelLink[], tol: number, minCount: number): LevelLink[] {
95
+ if (!(tol > 0)) return [...links];
96
+ const points = links.map((l) => [...l.points]);
97
+ const boxes = items.map((i) => i.box);
98
+ const clear = (a: Point, b: Point): boolean => boxes.every((box) => !segmentHitsBox(a, b, box, 1e-3));
99
+ for (const axis of ["x", "y"] as const) {
100
+ const legs = legsOf(links.map((l, i) => ({ ...l, points: points[i]! })))
101
+ .filter((g) => g.axis === axis)
102
+ .sort((a, b) => a.across - b.across || a.lo - b.lo || a.link - b.link || a.at - b.at);
103
+ const done = new Set<Leg>();
104
+ for (const anchor of [...legs].sort((a, b) => b.count - a.count || b.hi - b.lo - (a.hi - a.lo) || a.link - b.link || a.at - b.at)) {
105
+ if (done.has(anchor)) continue;
106
+ const near = legs.filter((g) => !done.has(g) && g !== anchor && g.link !== anchor.link && Math.abs(g.across - anchor.across) < tol && Math.min(g.hi, anchor.hi) - Math.max(g.lo, anchor.lo) > 1e-9);
107
+ if (near.length === 0 || near.reduce((s, g) => s + g.count, anchor.count) < minCount) continue;
108
+ done.add(anchor);
109
+ for (const g of near) {
110
+ done.add(g);
111
+ const p = points[g.link]!;
112
+ const a = p[g.at]!;
113
+ const b = p[g.at + 1]!;
114
+ // The leg moves only when its two points are still where the scan found them: an earlier move of the same link can change them.
115
+ if (axis === "x" ? a.y !== g.across || b.y !== g.across : a.x !== g.across || b.x !== g.across) continue;
116
+ const a2 = axis === "x" ? { x: a.x, y: anchor.across } : { x: anchor.across, y: a.y };
117
+ const b2 = axis === "x" ? { x: b.x, y: anchor.across } : { x: anchor.across, y: b.y };
118
+ if (!clear(p[g.at - 1]!, a2) || !clear(a2, b2) || !clear(b2, p[g.at + 2]!)) continue;
119
+ p[g.at] = a2;
120
+ p[g.at + 1] = b2;
121
+ }
122
+ }
123
+ }
124
+ return links.map((l, i) => (points[i]!.every((q, k) => q === l.points[k]) ? l : { ...l, points: tidy(points[i]!) }));
125
+ }
126
+
127
+ /**
128
+ * This function gives the zoom levels of the level strategy `detail` (WILD-016 with EDA-038). The levels and their items are the levels of `depths`.
129
+ * A level with groups first shows at the scale `itemAt / size`, with `size` its median item (`pickLevel`). At that scale, `pxMin` pixels are `pxMin × size / itemAt` units.
130
+ * The links of the level collapse their legs that are closer than that length (`collapseLinks`). The last level stays as in `depths`.
131
+ */
132
+ export function detailLevels(input: LevelInput, options: Readonly<DetailOptions>): Level[] {
133
+ const levels = depthLevels(input, options);
134
+ const sizes = itemSizes(levels);
135
+ return levels.map((level, i) => {
136
+ if (i === levels.length - 1) return level;
137
+ const tol = (options.pxMin * sizes[i]!) / PICK.itemAt;
138
+ return { ...level, links: collapseLinks(level.items, level.links, tol, options.minCount) };
139
+ });
140
+ }