@mark1russell7/highways 0.9.3 → 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.
Files changed (39) hide show
  1. package/README.md +85 -5
  2. package/build/index.d.ts +954 -5
  3. package/build/index.d.ts.map +1 -1
  4. package/build/index.js +10689 -5294
  5. package/build/index.js.map +1 -1
  6. package/package.json +1 -1
  7. package/src/bench/screen/exercise.ts +4 -1
  8. package/src/bench/screen/set.ts +58 -1
  9. package/src/families/cases.ts +17 -9
  10. package/src/families/coverage.ts +502 -0
  11. package/src/families/index.ts +4 -1
  12. package/src/highways/balance/index.ts +93 -0
  13. package/src/highways/bronchial/index.ts +144 -0
  14. package/src/highways/index.ts +5 -0
  15. package/src/highways/knee/index.ts +154 -0
  16. package/src/highways/knee/root.ts +19 -0
  17. package/src/highways/manifolds/index.ts +157 -0
  18. package/src/highways/slots/index.ts +178 -0
  19. package/src/index.ts +8 -0
  20. package/src/levels/detail/index.ts +140 -0
  21. package/src/levels/venation/index.ts +191 -0
  22. package/src/plugs/leaders/index.ts +42 -0
  23. package/src/plugs/spread/index.ts +39 -5
  24. package/src/routers/adam/index.ts +294 -0
  25. package/src/routers/annealing/index.ts +260 -0
  26. package/src/routers/canals/index.ts +191 -0
  27. package/src/routers/frames.ts +80 -16
  28. package/src/routers/gutter.ts +545 -0
  29. package/src/routers/index.ts +8 -2
  30. package/src/routers/reroute/index.ts +164 -0
  31. package/src/routers/shared.ts +43 -2
  32. package/src/routers/tight/index.ts +122 -0
  33. package/src/strategies/builtins.ts +158 -2
  34. package/src/strategies/schemas.ts +143 -0
  35. package/src/whole/curriculum/index.ts +243 -0
  36. package/src/whole/fireflies/index.ts +177 -0
  37. package/src/whole/portfolio/index.ts +108 -0
  38. package/src/whole/preferences/index.ts +247 -0
  39. package/src/whole/tracing/index.ts +144 -0
@@ -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,9 +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";
26
+ export * from "./levels/venation/index.ts";
27
+ export * from "./levels/detail/index.ts";
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";
25
33
  export * from "./bench/properties.ts";
26
34
  export * from "./bench/quality.ts";
27
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
+ }
@@ -0,0 +1,191 @@
1
+ import { center, dist, polylineLength, type Box, type Point } from "../../geom.ts";
2
+ import { levelLinks, type ItemPair } from "../../levels.ts";
3
+ import type { Level, LevelItem, LevelLink } from "../../model.ts";
4
+ import type { LevelInput } from "../../strategies/model.ts";
5
+ import { depthLevels, type DepthLevelOptions } from "../depths/index.ts";
6
+
7
+ /** The graph of the backbone of a level: the relative neighbourhood graph, or the Gabriel graph of the centers of the items. */
8
+ export type VenationBackbone = "rng" | "gabriel";
9
+
10
+ /** The values of `VenationBackbone`. */
11
+ export const VENATION_BACKBONES: readonly VenationBackbone[] = Object.freeze(["rng", "gabriel"]);
12
+
13
+ /** The options of the level strategy `venation` (NAT-016 of the nature notes): the options of `depths`, and the options of the backbone. */
14
+ export interface VenationOptions extends DepthLevelOptions {
15
+ /** The graph of the backbone. The relative neighbourhood graph has fewer links than the Gabriel graph. The default is `rng`. */
16
+ readonly backbone: VenationBackbone;
17
+ /** A later level link saves this share of the cost of a backbone link that an earlier level link uses, from 0 to 0.8. The default is 0.4. */
18
+ readonly discount: number;
19
+ /** A level link takes its way on the backbone only when that way is this many times its direct link or less. The default is 1.5. */
20
+ readonly detour: number;
21
+ /** A level with more items than this number keeps the links of `depths`. The default is 400. */
22
+ readonly maxItems: number;
23
+ }
24
+
25
+ /** The default options of the level strategy `venation`. */
26
+ export const VENATION_DEFAULTS: Readonly<VenationOptions> = Object.freeze({ backbone: "rng", discount: 0.4, detour: 1.5, maxItems: 400 });
27
+
28
+ /**
29
+ * This function gives the links of the backbone of the points, as pairs of indices `i < j` (NAT-016).
30
+ * With `rng`, the link `p q` exists when no third point `r` has `max(d(p, r), d(q, r)) < d(p, q)`.
31
+ * With `gabriel`, the link exists when no third point is inside the circle with the diameter `p q`.
32
+ */
33
+ export function backboneLinks(points: readonly Point[], backbone: VenationBackbone): [number, number][] {
34
+ const out: [number, number][] = [];
35
+ const n = points.length;
36
+ const d2 = (a: Point, b: Point): number => (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y);
37
+ for (let i = 0; i < n; i++) {
38
+ for (let j = i + 1; j < n; j++) {
39
+ const pq = d2(points[i]!, points[j]!);
40
+ let keep = true;
41
+ for (let r = 0; r < n && keep; r++) {
42
+ if (r === i || r === j) continue;
43
+ const pr = d2(points[i]!, points[r]!);
44
+ const qr = d2(points[j]!, points[r]!);
45
+ if (backbone === "rng" ? Math.max(pr, qr) < pq - 1e-9 : pr + qr < pq - 1e-9) keep = false;
46
+ }
47
+ if (keep) out.push([i, j]);
48
+ }
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * This function gives a way around the box `b`, inflated by `m`, from the point `a` to the point `c` of its boundary.
55
+ * The way goes along the shorter side of the inflated box. It holds the two points at `m` out from `a` and `c`, and the corners between them.
56
+ */
57
+ export function aroundBox(b: Box, m: number, a: Point, c: Point): Point[] {
58
+ const x0 = b.x - m;
59
+ const y0 = b.y - m;
60
+ const x1 = b.x + b.w + m;
61
+ const y1 = b.y + b.h + m;
62
+ const w = x1 - x0;
63
+ const h = y1 - y0;
64
+ const per = 2 * (w + h);
65
+ // The point on the boundary of the inflated box nearest to `p`, and its place along the boundary, clockwise from the top left corner.
66
+ const onBoundary = (p: Point): { p: Point; t: number } => {
67
+ const cx = Math.min(x1, Math.max(x0, p.x));
68
+ const cy = Math.min(y1, Math.max(y0, p.y));
69
+ const dl = cx - x0;
70
+ const dr = x1 - cx;
71
+ const dt = cy - y0;
72
+ const db = y1 - cy;
73
+ const least = Math.min(dl, dr, dt, db);
74
+ if (least === dt) return { p: { x: cx, y: y0 }, t: cx - x0 };
75
+ if (least === dr) return { p: { x: x1, y: cy }, t: w + (cy - y0) };
76
+ if (least === db) return { p: { x: cx, y: y1 }, t: w + h + (x1 - cx) };
77
+ return { p: { x: x0, y: cy }, t: 2 * w + h + (y1 - cy) };
78
+ };
79
+ const corners: { p: Point; t: number }[] = [
80
+ { p: { x: x0, y: y0 }, t: 0 },
81
+ { p: { x: x1, y: y0 }, t: w },
82
+ { p: { x: x1, y: y1 }, t: w + h },
83
+ { p: { x: x0, y: y1 }, t: 2 * w + h },
84
+ ];
85
+ const A = onBoundary(a);
86
+ const C = onBoundary(c);
87
+ const ahead = (C.t - A.t + per) % per;
88
+ const out: Point[] = [A.p];
89
+ if (ahead <= per / 2) {
90
+ const list = corners.map((k) => ({ ...k, s: (k.t - A.t + per) % per })).filter((k) => k.s > 1e-9 && k.s < ahead - 1e-9);
91
+ for (const k of list.sort((p, q) => p.s - q.s)) out.push(k.p);
92
+ } else {
93
+ const back = per - ahead;
94
+ const list = corners.map((k) => ({ ...k, s: (A.t - k.t + per) % per })).filter((k) => k.s > 1e-9 && k.s < back - 1e-9);
95
+ for (const k of list.sort((p, q) => p.s - q.s)) out.push(k.p);
96
+ }
97
+ out.push(C.p);
98
+ return out.filter((p, i) => i === 0 || dist(p, out[i - 1]!) > 1e-9);
99
+ }
100
+
101
+ /**
102
+ * This function gives the links of one level along a closed venation backbone (NAT-016, after Runions and others, 2005).
103
+ * The backbone links the items by their relative neighbourhood graph or their Gabriel graph. Each backbone link goes around the other items.
104
+ * The level links go in the order of their counts, the largest first. Each one takes the cheapest way on the backbone, with the cost `L (1 - discount)` for a used backbone link.
105
+ * The way goes around each item between its two ends. A way longer than `detour` times the direct link, or a direct backbone link, keeps the direct link.
106
+ */
107
+ export function venationLinks(items: readonly LevelItem[], direct: readonly LevelLink[], o: Readonly<VenationOptions>): LevelLink[] {
108
+ if (direct.length < 2 || items.length < 3 || items.length > o.maxItems) return [...direct];
109
+ const index = new Map(items.map((it, i) => [it.id, i]));
110
+ const links = backboneLinks(items.map((it) => center(it.box)), o.backbone);
111
+ const pairs: ItemPair[] = links.map(([i, j]) => ({ source: items[i]!.id, target: items[j]!.id, count: 1 }));
112
+ const routed = levelLinks(items, pairs, o);
113
+ const n = items.length;
114
+ const next: { to: number; link: number }[][] = Array.from({ length: n }, () => []);
115
+ const cost = routed.map((l) => polylineLength(l.points));
116
+ routed.forEach((l, k) => {
117
+ const i = index.get(l.source)!;
118
+ const j = index.get(l.target)!;
119
+ next[i]!.push({ to: j, link: k });
120
+ next[j]!.push({ to: i, link: k });
121
+ });
122
+ const direct2 = new Set(links.map(([i, j]) => `${i},${j}`));
123
+ const used = new Uint8Array(routed.length);
124
+ const share = 1 - Math.min(0.8, Math.max(0, o.discount));
125
+ const order = direct.map((_, k) => k).sort((p, q) => direct[q]!.count - direct[p]!.count || direct[p]!.source.localeCompare(direct[q]!.source) || direct[p]!.target.localeCompare(direct[q]!.target));
126
+ const margin = o.margin ?? 5;
127
+ const out = [...direct];
128
+ for (const k of order) {
129
+ const l = direct[k]!;
130
+ const s = index.get(l.source);
131
+ const t = index.get(l.target);
132
+ if (s === undefined || t === undefined) continue;
133
+ if (direct2.has(`${Math.min(s, t)},${Math.max(s, t)}`)) {
134
+ const at = next[s]!.find((x) => x.to === t);
135
+ if (at) used[at.link] = 1;
136
+ continue;
137
+ }
138
+ // Dijkstra on the backbone. The graph is small, so a scan for the least open item is enough.
139
+ const best = new Float64Array(n).fill(Infinity);
140
+ const from = new Int32Array(n).fill(-1);
141
+ const via = new Int32Array(n).fill(-1);
142
+ const done = new Uint8Array(n);
143
+ best[s] = 0;
144
+ for (;;) {
145
+ let u = -1;
146
+ for (let i = 0; i < n; i++) if (!done[i] && best[i]! < Infinity && (u < 0 || best[i]! < best[u]!)) u = i;
147
+ if (u < 0 || u === t) break;
148
+ done[u] = 1;
149
+ for (const x of next[u]!) {
150
+ const c = best[u]! + cost[x.link]! * (used[x.link] ? share : 1);
151
+ if (c < best[x.to]! - 1e-9) {
152
+ best[x.to] = c;
153
+ from[x.to] = u;
154
+ via[x.to] = x.link;
155
+ }
156
+ }
157
+ }
158
+ if (from[t]! < 0) continue;
159
+ const hops: { link: number; forward: boolean }[] = [];
160
+ for (let v = t; v !== s; v = from[v]!) hops.push({ link: via[v]!, forward: routed[via[v]!]!.target === items[v]!.id });
161
+ hops.reverse();
162
+ const points: Point[] = [];
163
+ hops.forEach((h, i) => {
164
+ const p = routed[h.link]!.points;
165
+ const seq = h.forward ? [...p] : [...p].reverse();
166
+ if (i > 0) {
167
+ // The way goes around the item between the end of the hop before and the start of this hop.
168
+ const item = items[index.get(h.forward ? routed[h.link]!.source : routed[h.link]!.target)!]!;
169
+ const around = aroundBox(item.box, margin, points[points.length - 1]!, seq[0]!);
170
+ // The first point stays: it is on the box of the source. A link between two items that touch has no length, so its two points are one point.
171
+ if (points.length > 1) points.pop();
172
+ points.push(...around);
173
+ seq.shift();
174
+ }
175
+ for (const q of seq) if (points.length === 0 || dist(q, points[points.length - 1]!) > 1e-9) points.push(q);
176
+ });
177
+ if (points.length < 2 || polylineLength(points) > o.detour * polylineLength(l.points)) continue;
178
+ for (const h of hops) used[h.link] = 1;
179
+ out[k] = { ...l, points };
180
+ }
181
+ return out;
182
+ }
183
+
184
+ /**
185
+ * This function gives the zoom levels of the level strategy `venation` (NAT-016). The levels and their items are the levels of `depths`.
186
+ * In each level with groups, the links take their ways along a closed venation backbone (`venationLinks`). The last level stays as in `depths`.
187
+ */
188
+ export function venationLevels(input: LevelInput, options: Readonly<VenationOptions>): Level[] {
189
+ const levels = depthLevels(input, options);
190
+ return levels.map((level, i) => (i === levels.length - 1 ? level : { ...level, links: venationLinks(level.items, level.links, options) }));
191
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The plug strategy `leaders` (EDA-041): boundary leaders of the labeling of maps, used as the stubs of the plugs.
3
+ * Each wall places its plugs in an order that gives leaders with no crossing, and the least length of the leaders.
4
+ */
5
+ import type { PlugInput, PlugPair } from "../../strategies/model.ts";
6
+ import { spreadWith, SPREAD_DEFAULTS } from "../spread/index.ts";
7
+
8
+ /**
9
+ * The type of leader of a plug. With `s`, the order of a wall is the order of the angles of the far ends, as in `spread`.
10
+ * With `po`, the stub leaves along the normal and turns once, so the order is the order of the far ends along the wall.
11
+ * With `opo`, the plugs of a wall go at even places, in the order of the angles.
12
+ */
13
+ export type LeaderType = "s" | "po" | "opo";
14
+
15
+ /** The values of `LeaderType`. */
16
+ export const LEADER_TYPES: readonly LeaderType[] = Object.freeze(["s", "po", "opo"]);
17
+
18
+ /** The options of the plug strategy `leaders`. */
19
+ export interface LeaderOptions {
20
+ /** The type of leader. The default is `po`. */
21
+ readonly leader: LeaderType;
22
+ /** The least gap between two plugs of one wall, from 2 to 8. The default is 8. */
23
+ readonly slotSpacing: number;
24
+ /** The distance from a plug to a corner of its box, or a quarter of the wall when the wall is short. The default is 6. */
25
+ readonly inset: number;
26
+ /** The number of passes that sort each wall again by the new places of the far ends, from 0 to 4. The default is 2. */
27
+ readonly rounds: number;
28
+ }
29
+
30
+ /** The defaults of the plug strategy `leaders`. */
31
+ export const LEADER_DEFAULTS: Readonly<LeaderOptions> = Object.freeze({ leader: "po", slotSpacing: 8, inset: SPREAD_DEFAULTS.inset, rounds: SPREAD_DEFAULTS.rounds });
32
+
33
+ /**
34
+ * This function gives the plugs of the plug strategy `leaders` (EDA-041, after Bekos 2007). A fixed plug stays at its point, and a plug with a side stays on that side.
35
+ * Each free plug goes on the side that faces the other end. The plugs of each wall keep at least `slotSpacing` between two of them.
36
+ * The type of leader sets their order and their places. The output has one entry for each edge with two known nodes, in the order of the edges.
37
+ */
38
+ export function leaderEnds(input: PlugInput, options: Partial<LeaderOptions> = {}): PlugPair[] {
39
+ const o = { ...LEADER_DEFAULTS, ...options };
40
+ const spread = { plugGap: o.slotSpacing, inset: o.inset, rounds: o.rounds };
41
+ return spreadWith(input, spread, o.leader === "s" ? undefined : o.leader);
42
+ }
@@ -84,6 +84,20 @@ function keyOf(box: Box, side: Side, far: Point): number {
84
84
  return along >= 0 ? 2 - p : -2 - p;
85
85
  }
86
86
 
87
+ /**
88
+ * This function gives the sort key of a far point for a po-leader (EDA-041): its place on the axis along the wall.
89
+ * A far point behind the wall gets the key of `keyOf` with a large shift, so it goes to an end of the wall.
90
+ */
91
+ function poKeyOf(box: Box, side: Side, far: Point): number {
92
+ const c = center(box);
93
+ const n = SIDE_NORMAL[side];
94
+ const out = (far.x - c.x) * n.x + (far.y - c.y) * n.y;
95
+ const along = alongOf(side) === "y" ? far.y : far.x;
96
+ if (out > 0) return along;
97
+ const k = keyOf(box, side, far);
98
+ return k >= 0 ? 1e12 + k : -1e12 + k;
99
+ }
100
+
87
101
  /**
88
102
  * This function places `ideal.length` plugs in their given order on the segment from `lo` to `hi`. Two neighbours are at least `gap` apart.
89
103
  * The gap shrinks evenly when the segment is too short. The sum of the squares of the moves from the ideal places is the least.
@@ -102,7 +116,7 @@ function placeInOrder(ideal: readonly number[], lo: number, hi: number, gap: num
102
116
  * Each slot goes into the part of its ideal place, and the parts keep the order of the slots. In each part, two plugs and a plug and a fixed plug are at least `gap` apart.
103
117
  * The gap of a part shrinks evenly when the part is too short.
104
118
  */
105
- function placeWall(slots: readonly Slot[], fixed: readonly number[], lo: number, hi: number, gap: number): void {
119
+ function placeWall(slots: readonly Slot[], fixed: readonly number[], lo: number, hi: number, gap: number, idealOf: (s: Slot) => number = (s) => s.ideal): void {
106
120
  const cuts = [...new Set(fixed.filter((f) => f > lo - 1e-9 && f < hi + 1e-9))].sort((a, b) => a - b);
107
121
  // The part i is from cuts[i - 1] to cuts[i]. The first part starts at lo, and the last part ends at hi.
108
122
  const ends = [lo, ...cuts, hi];
@@ -110,7 +124,7 @@ function placeWall(slots: readonly Slot[], fixed: readonly number[], lo: number,
110
124
  let part = 0;
111
125
  for (const s of slots) {
112
126
  let p = 0;
113
- while (p < cuts.length && s.ideal > cuts[p]!) p++;
127
+ while (p < cuts.length && idealOf(s) > cuts[p]!) p++;
114
128
  part = Math.max(part, p);
115
129
  parts[part]!.push(s);
116
130
  }
@@ -125,7 +139,7 @@ function placeWall(slots: readonly Slot[], fixed: readonly number[], lo: number,
125
139
  const lower = a + fa * s;
126
140
  const upper = Math.max(lower, b - fb * s);
127
141
  const at = placeInOrder(
128
- list.map((x) => x.ideal),
142
+ list.map((x) => idealOf(x)),
129
143
  lower,
130
144
  upper,
131
145
  s,
@@ -144,6 +158,21 @@ function placeWall(slots: readonly Slot[], fixed: readonly number[], lo: number,
144
158
  * The output has one entry for each edge with two known nodes, in the order of the edges.
145
159
  */
146
160
  export function spreadEnds(input: PlugInput, options: SpreadOptions = {}): PlugPair[] {
161
+ return spreadWith(input, options);
162
+ }
163
+
164
+ /**
165
+ * The order and the places of the plugs of one wall in the strategy `leaders` (EDA-041).
166
+ * With `po`, the sort key of a far end in front of the wall is its place on the axis along the wall.
167
+ * With `opo`, the plugs of a wall go at even places in the order of the keys of `spread`.
168
+ */
169
+ export type WallLeader = "po" | "opo";
170
+
171
+ /**
172
+ * This function does the work of `spreadEnds`, and of the strategy `leaders` with the argument `leader`.
173
+ * Without `leader`, it gives the plugs of `spread`.
174
+ */
175
+ export function spreadWith(input: PlugInput, options: SpreadOptions = {}, leader?: WallLeader): PlugPair[] {
147
176
  const { graph } = input;
148
177
  const gap = options.plugGap ?? SPREAD_DEFAULTS.plugGap;
149
178
  const inset = options.inset ?? SPREAD_DEFAULTS.inset;
@@ -206,11 +235,16 @@ export function spreadEnds(input: PlugInput, options: SpreadOptions = {}): PlugP
206
235
  for (let round = 0; round <= rounds; round++) {
207
236
  for (const wall of walls.values()) {
208
237
  const [lo, hi] = roomOf(wall.node.box, wall.side, inset);
209
- const keys = new Map<Slot, number>(wall.slots.map((s) => [s, keyOf(wall.node.box, wall.side, resolve(s.far!).point)]));
238
+ const key = leader === "po" ? poKeyOf : keyOf;
239
+ const keys = new Map<Slot, number>(wall.slots.map((s) => [s, key(wall.node.box, wall.side, resolve(s.far!).point)]));
210
240
  const tie = (a: Slot, b: Slot): number => (round === 0 ? a.rank[0] - b.rank[0] || a.rank[1] - b.rank[1] : order.get(a)! - order.get(b)!);
211
241
  const sorted = [...wall.slots].sort((a, b) => keys.get(a)! - keys.get(b)! || tie(a, b));
212
242
  sorted.forEach((s, i) => order.set(s, i));
213
- placeWall(sorted, wall.fixed, lo, hi, gap);
243
+ if (leader === "opo") {
244
+ // The even places: the plugs split the room into equal parts, so the plug spacing of the wall is the largest that it can be.
245
+ const even = new Map<Slot, number>(sorted.map((s, i) => [s, lo + ((i + 0.5) * (hi - lo)) / sorted.length]));
246
+ placeWall(sorted, wall.fixed, lo, hi, gap, (s) => even.get(s)!);
247
+ } else placeWall(sorted, wall.fixed, lo, hi, gap);
214
248
  }
215
249
  }
216
250
  return ends.map((x) => ({ edge: x.edge, source: resolve(x.source), target: resolve(x.target) }));