@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
@@ -1,12 +1,14 @@
1
1
  // The corpus families of highways on the shared registry of `@mark1russell7/layout-contract` (CORPUS-GEN, L45).
2
2
  import { createFamilyRegistry, type FamilyRegistry } from "@mark1russell7/layout-contract";
3
3
  import { HUB_CHAIN, MANY_GROUPS, PACKED_GRID } from "./cases.ts";
4
+ import { COVERAGE_FAMILIES } from "./coverage.ts";
4
5
  import { DENSE_GROUPS, SMALL_CLUSTERED, TINY_UNIFORM } from "./fixtures.ts";
5
6
  import type { HighwaysBoard, HighwaysFamily } from "./model.ts";
6
7
 
7
8
  export * from "./model.ts";
8
9
  export { DENSE_GROUPS, FIXTURE_LAYOUTS, FIXTURE_LOCALITY, fixtureParams, fixtureSpecOf, SMALL_CLUSTERED, TINY_UNIFORM } from "./fixtures.ts";
9
10
  export { HUB_CHAIN, MANY_GROUPS, PACKED_GRID } from "./cases.ts";
11
+ export { BIPARTITE, BIPARTITE_LAYOUTS, COVERAGE_FAMILIES, FAN_OUT, FRAME_ROWS, LOCAL_MESH, PIPELINE, SPARSE_LONG, THIN_BARS, TWO_HUBS, UNEVEN_SIZES } from "./coverage.ts";
10
12
  // The parts of the shared registry that a host of the families uses. The site imports only `@mark1russell7/highways`.
11
13
  export {
12
14
  createFamilyRegistry,
@@ -26,9 +28,10 @@ export {
26
28
 
27
29
  /**
28
30
  * The corpus families of highways, in the order of the screen sets. Each family at its defaults makes the board of its screen family.
31
+ * The first 6 families are the families of `screen-v1` and `screen-v2`. The 9 families of `COVERAGE_FAMILIES` follow them (CORPUS-FAMILIES).
29
32
  * An import registers nothing. A host makes a registry with `createFamilyRegistry(HIGHWAYS_FAMILIES)`, or with the lists of other engines too.
30
33
  */
31
- export const HIGHWAYS_FAMILIES: readonly HighwaysFamily[] = Object.freeze([TINY_UNIFORM, SMALL_CLUSTERED, DENSE_GROUPS, HUB_CHAIN, MANY_GROUPS, PACKED_GRID]);
34
+ export const HIGHWAYS_FAMILIES: readonly HighwaysFamily[] = Object.freeze([TINY_UNIFORM, SMALL_CLUSTERED, DENSE_GROUPS, HUB_CHAIN, MANY_GROUPS, PACKED_GRID, ...COVERAGE_FAMILIES]);
32
35
 
33
36
  /** This function gives a new registry of `HIGHWAYS_FAMILIES`. */
34
37
  export function highwaysRegistry(): FamilyRegistry<HighwaysBoard> {
@@ -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
+ }
@@ -0,0 +1,144 @@
1
+ import { dist, polylineLength, type Point } from "../../geom.ts";
2
+ import { WayFinder } from "../../ways.ts";
3
+ import { firstRoutes } from "../base.ts";
4
+ import type { HighwayInput, HighwayPlan } from "../model.ts";
5
+ import { edgePlugs, sharedStretches, type KeyedWay } from "../stretches.ts";
6
+ import { rootOf } from "../knee/root.ts";
7
+
8
+ /** The rule that splits the targets of a tip in two: by count, by weight, or at the widest gap of the angles. */
9
+ export type BronchialSplit = "count" | "weight" | "gap";
10
+
11
+ /** The values of `BronchialSplit`. */
12
+ export const BRONCHIAL_SPLITS: readonly BronchialSplit[] = Object.freeze(["count", "weight", "gap"]);
13
+
14
+ /** The options of the highway strategy `bronchial` (NAT-019 of the nature notes). */
15
+ export interface BronchialOptions {
16
+ /**
17
+ * The rule of the split. With `count`, each half gets half of the targets. With `weight`, the two halves get near-equal sums of the distances from the tip.
18
+ * With `gap`, the cut goes at the widest gap between the angles of the targets. The default is `count`.
19
+ */
20
+ readonly split: BronchialSplit;
21
+ /** The share of the distance to the center of the targets that a branch grows, from 0.3 to 0.7. The default is 0.5. */
22
+ readonly share: number;
23
+ /** A set with this number of targets or fewer stops the splits, from 1 to 3. Each of its targets gets its own way from the tip. The default is 1. */
24
+ readonly smallest: number;
25
+ /** An edge rides the tree only when its path in the tree is this many times its first route or less. The default is 1.5. */
26
+ readonly detour: number;
27
+ /** A stretch of a tree with fewer edges than this number is not a highway. The default is 2. */
28
+ readonly minSize: number;
29
+ }
30
+
31
+ /** The default options of the strategy `bronchial`. */
32
+ export const BRONCHIAL_DEFAULTS: Readonly<BronchialOptions> = Object.freeze({ split: "count", share: 0.5, smallest: 1, detour: 1.5, minSize: 2 });
33
+
34
+ /**
35
+ * This function gives the diamond angle of the direction `(dx, dy)`: a value from 0 to 4 that grows with the angle, with no trigonometry.
36
+ * The value 0 is the direction of the positive x axis.
37
+ */
38
+ export function diamondAngle(dx: number, dy: number): number {
39
+ const s = Math.abs(dx) + Math.abs(dy);
40
+ if (s <= 1e-12) return 0;
41
+ if (dy >= 0) return dx >= 0 ? dy / s : 1 - dx / s;
42
+ return dx < 0 ? 2 - dy / s : 3 + dx / s;
43
+ }
44
+
45
+ /**
46
+ * This function splits the targets of a tip in two halves (NAT-019). The targets go in the order of their angles around the tip.
47
+ * The order starts after the widest gap of the angles, so each half is a sector. The rule of `split` then cuts the order in two.
48
+ */
49
+ export function splitTargets(tip: Point, targets: readonly Point[], ids: readonly number[], split: BronchialSplit): [number[], number[]] {
50
+ const sorted = [...ids].sort((a, b) => diamondAngle(targets[a]!.x - tip.x, targets[a]!.y - tip.y) - diamondAngle(targets[b]!.x - tip.x, targets[b]!.y - tip.y) || a - b);
51
+ const angle = (i: number): number => diamondAngle(targets[sorted[i]!]!.x - tip.x, targets[sorted[i]!]!.y - tip.y);
52
+ const n = sorted.length;
53
+ // The gap after each target, to the next target around the circle.
54
+ const gaps = sorted.map((_, i) => (i === n - 1 ? angle(0) + 4 - angle(i) : angle(i + 1) - angle(i)));
55
+ let widest = 0;
56
+ for (let i = 1; i < n; i++) if (gaps[i]! > gaps[widest]! + 1e-12) widest = i;
57
+ const order = [...sorted.slice(widest + 1), ...sorted.slice(0, widest + 1)];
58
+ let cut = Math.floor(n / 2);
59
+ if (split === "weight") {
60
+ const w = order.map((i) => dist(tip, targets[i]!));
61
+ const total = w.reduce((s, x) => s + x, 0);
62
+ let run = 0;
63
+ let best = Infinity;
64
+ for (let c = 1; c < n; c++) {
65
+ run += w[c - 1]!;
66
+ const off = Math.abs(2 * run - total);
67
+ if (off < best - 1e-9) [best, cut] = [off, c];
68
+ }
69
+ } else if (split === "gap") {
70
+ let best = -Infinity;
71
+ const at = (k: number): number => diamondAngle(targets[order[k]!]!.x - tip.x, targets[order[k]!]!.y - tip.y);
72
+ for (let c = 1; c < n; c++) {
73
+ const g = (at(c) - at(c - 1) + 4) % 4;
74
+ if (g > best + 1e-12) [best, cut] = [g, c];
75
+ }
76
+ }
77
+ return [order.slice(0, cut), order.slice(cut)];
78
+ }
79
+
80
+ /**
81
+ * This function gives the highways of the bronchial trees (NAT-019, after Kitaoka and others, 1999). The edges from one source node get one tree.
82
+ * The root of a tree is the out point of the plug nearest the center of the targets.
83
+ * From the tip, the tree grows a share of the way to the center of its targets. Then it splits the targets in two and grows a branch for each half.
84
+ * A branch that hits a box grows half as far, or not at all. A small set of targets gets its own ways from the tip, through the corner graph.
85
+ * Each edge takes its path in the tree when that path is short enough, and the stretches that two edges or more share become highways.
86
+ */
87
+ export function bronchialPlan(input: HighwayInput, o: Readonly<BronchialOptions>): HighwayPlan {
88
+ const finder = new WayFinder(input.graph.nodes);
89
+ const pad = finder.margin / 2;
90
+ const first = firstRoutes(input);
91
+ const groups = new Map<string, { edge: string; key: string; s: Point; t: Point; base: number }[]>();
92
+ for (const p of edgePlugs(input.graph)) {
93
+ const s = finder.out(p.ps);
94
+ const t = finder.out(p.pt);
95
+ const route = first.get(p.edge.id);
96
+ const group = p.edge.source.node;
97
+ const list = groups.get(group) ?? [];
98
+ list.push({ edge: p.edge.id, key: p.edge.source.node, s, t, base: route ? polylineLength(route) : dist(s, t) });
99
+ groups.set(group, list);
100
+ }
101
+ const share = Math.min(0.7, Math.max(0.3, o.share));
102
+ const smallest = Math.min(3, Math.max(1, Math.floor(o.smallest)));
103
+ const ways: KeyedWay[] = [];
104
+ for (const group of [...groups.keys()].sort()) {
105
+ const list = groups.get(group)!;
106
+ if (list.length < 2) continue;
107
+ const goals = list.map((e) => e.t);
108
+ const { root, leads } = rootOf(finder, list.map((e) => e.s), goals);
109
+ const paths: (Point[] | undefined)[] = list.map(() => undefined);
110
+ const grow = (trunk: readonly Point[], ids: readonly number[]): void => {
111
+ const tip = trunk[trunk.length - 1]!;
112
+ if (ids.length <= smallest) {
113
+ for (const i of ids) {
114
+ const way = finder.corners.path(tip, goals[i]!);
115
+ if (way) paths[i] = [...trunk, ...finder.corners.pull(way).slice(1)];
116
+ }
117
+ return;
118
+ }
119
+ const c = { x: ids.reduce((s, i) => s + goals[i]!.x, 0) / ids.length, y: ids.reduce((s, i) => s + goals[i]!.y, 0) / ids.length };
120
+ let next = trunk;
121
+ for (let f = share; f > share / 8; f /= 2) {
122
+ const p = { x: tip.x + f * (c.x - tip.x), y: tip.y + f * (c.y - tip.y) };
123
+ if (dist(p, tip) <= 1e-6) break;
124
+ if (!finder.obstacles.inside(p, pad) && finder.obstacles.clearPath([tip, p], pad)) {
125
+ next = [...trunk, p];
126
+ break;
127
+ }
128
+ }
129
+ const [a, b] = splitTargets(next[next.length - 1]!, goals, ids, o.split);
130
+ grow(next, a);
131
+ grow(next, b);
132
+ };
133
+ grow([root], list.map((_, i) => i));
134
+ list.forEach((e, i) => {
135
+ const tail = paths[i];
136
+ const lead = leads[i];
137
+ if (!tail || !lead) return;
138
+ const points = [...lead, ...tail.slice(1)];
139
+ if (points.length < 2 || polylineLength(points) > o.detour * e.base) return;
140
+ ways.push({ edge: e.edge, key: e.key, points });
141
+ });
142
+ }
143
+ return sharedStretches(ways, { minSize: Math.max(2, Math.floor(o.minSize)) });
144
+ }
@@ -102,6 +102,11 @@ export { oneSteiner, spanningTree, STEINER_DEFAULTS, STEINER_GROUPS, STEINER_MET
102
102
  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
+ 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";
109
+ export { BRONCHIAL_DEFAULTS, BRONCHIAL_SPLITS, bronchialPlan, diamondAngle, splitTargets, type BronchialOptions, type BronchialSplit } from "./bronchial/index.ts";
105
110
  export { INTERCHANGE_BASES, INTERCHANGE_DEFAULTS, interchangeCandidates, interchangePlan, type Interchange, type InterchangeBase, type InterchangeOptions } from "./interchanges/index.ts";
106
111
  export { SCALE_SPACE_DEFAULTS, scaleClusters, scaleSpaceGroups, scaleSpacePlan, type ScaleCluster, type ScaleSpaceOptions } from "./scale-space/index.ts";
107
112
  export * from "./gates/index.ts";
@@ -0,0 +1,154 @@
1
+ import { dist, polylineLength, type Point } from "../../geom.ts";
2
+ import { balancedFan, fanPath, type Fan } from "../../routers/fan.ts";
3
+ import { WayFinder } from "../../ways.ts";
4
+ import { firstRoutes } from "../base.ts";
5
+ import type { HighwayInput, HighwayPlan } from "../model.ts";
6
+ import { edgePlugs, sharedStretches, type KeyedWay } from "../stretches.ts";
7
+ import { rootOf } from "./root.ts";
8
+
9
+ /** The rule that picks one tree on the front: the knee of the chord, or the least weighted sum. */
10
+ export type KneeRule = "chord" | "weights";
11
+
12
+ /** The values of `KneeRule`. */
13
+ export const KNEE_RULES: readonly KneeRule[] = Object.freeze(["chord", "weights"]);
14
+
15
+ /** The options of the highway strategy `knee` (NAT-018 of the nature notes, on the trees of NAT-017). */
16
+ export interface KneeOptions {
17
+ /** The number of points of the sweep of the balancing factor, from 5 to 21. The default is 11. */
18
+ readonly sweep: number;
19
+ /** The rule that picks one tree on the front. The default is `chord`. */
20
+ readonly rule: KneeRule;
21
+ /** The weight of the ink in the rule `weights`, from 0 to 1. The rest of the weight goes to the path length. The default is 0.5. */
22
+ readonly ink: number;
23
+ /** An edge rides the tree only when its path in the tree is this many times its first route or less. The default is 1.5. */
24
+ readonly detour: number;
25
+ /** A stretch of a tree with fewer edges than this number is not a highway. The default is 2. */
26
+ readonly minSize: number;
27
+ }
28
+
29
+ /** The default options of the strategy `knee`. */
30
+ export const KNEE_DEFAULTS: Readonly<KneeOptions> = Object.freeze({ sweep: 11, rule: "chord", ink: 0.5, detour: 1.5, minSize: 2 });
31
+
32
+ /** One tree of the sweep: its balancing factor, its ink `L`, the sum of its path lengths `S`, and the tree. */
33
+ export interface KneeTree {
34
+ readonly bf: number;
35
+ readonly ink: number;
36
+ readonly paths: number;
37
+ readonly fan: Fan;
38
+ }
39
+
40
+ /** This function gives the ink of a fan: the sum of the lengths of its links. */
41
+ function fanInk(fan: Fan): number {
42
+ let sum = 0;
43
+ for (const p of fan.points) if (p.parent >= 0) sum += dist(p.point, fan.points[p.parent]!.point);
44
+ return sum;
45
+ }
46
+
47
+ /**
48
+ * This function adds a stub point at `stub` from the end of the last link of a way. The tree of NAT-017 joins a new branch only at an open point or on a link to one.
49
+ * The last point of a way is not open, so without the stub no branch joins the last link.
50
+ */
51
+ function withStub(way: Point[], stub: number): Point[] {
52
+ if (way.length < 2) return way;
53
+ const a = way[way.length - 2]!;
54
+ const b = way[way.length - 1]!;
55
+ const d = dist(a, b);
56
+ if (d <= 2 * stub) return way;
57
+ return [...way.slice(0, -1), { x: b.x + ((a.x - b.x) * stub) / d, y: b.y + ((a.y - b.y) * stub) / d }, b];
58
+ }
59
+
60
+ /**
61
+ * This function gives the index of the tree that the rule picks on the front of the trees (NAT-018).
62
+ * The front holds the trees that no other tree beats on the ink and on the path lengths. The two values go to the range from 0 to 1.
63
+ * With `chord`, the pick is the tree of the front farthest from the line between the two ends of the front.
64
+ * With `weights`, the pick is the tree with the least `ink × L + (1 - ink) × S`. A tie goes to the earlier tree.
65
+ */
66
+ export function kneeOf(trees: readonly Pick<KneeTree, "ink" | "paths">[], rule: KneeRule, ink: number): number {
67
+ if (trees.length === 0) return -1;
68
+ const front = trees.map((_, i) => i).filter((i) => !trees.some((q, j) => j !== i && q.ink <= trees[i]!.ink && q.paths <= trees[i]!.paths && (q.ink < trees[i]!.ink || q.paths < trees[i]!.paths)));
69
+ const lo = { x: Math.min(...front.map((i) => trees[i]!.ink)), y: Math.min(...front.map((i) => trees[i]!.paths)) };
70
+ const hi = { x: Math.max(...front.map((i) => trees[i]!.ink)), y: Math.max(...front.map((i) => trees[i]!.paths)) };
71
+ const norm = (i: number): Point => ({
72
+ x: hi.x - lo.x > 1e-9 ? (trees[i]!.ink - lo.x) / (hi.x - lo.x) : 0,
73
+ y: hi.y - lo.y > 1e-9 ? (trees[i]!.paths - lo.y) / (hi.y - lo.y) : 0,
74
+ });
75
+ let best = front[0]!;
76
+ let score = rule === "chord" ? -Infinity : Infinity;
77
+ if (rule === "weights") {
78
+ const w = Math.min(1, Math.max(0, ink));
79
+ for (const i of front) {
80
+ const p = norm(i);
81
+ const s = w * p.x + (1 - w) * p.y;
82
+ if (s < score - 1e-12) [best, score] = [i, s];
83
+ }
84
+ return best;
85
+ }
86
+ // The ends of the front: the tree with the least ink, and the tree with the least path lengths.
87
+ const a = front.reduce((u, i) => (trees[i]!.ink < trees[u]!.ink || (trees[i]!.ink === trees[u]!.ink && trees[i]!.paths < trees[u]!.paths) ? i : u));
88
+ const b = front.reduce((u, i) => (trees[i]!.paths < trees[u]!.paths || (trees[i]!.paths === trees[u]!.paths && trees[i]!.ink < trees[u]!.ink) ? i : u));
89
+ const A = norm(a);
90
+ const B = norm(b);
91
+ const len = dist(A, B);
92
+ if (len <= 1e-12) return a;
93
+ for (const i of front) {
94
+ const p = norm(i);
95
+ const s = Math.abs((B.x - A.x) * (A.y - p.y) - (A.x - p.x) * (B.y - A.y)) / len;
96
+ if (s > score + 1e-12) [best, score] = [i, s];
97
+ }
98
+ return best;
99
+ }
100
+
101
+ /**
102
+ * This function gives the highways of the knee trees (NAT-018). The edges from one source node get one tree. Its root is the out point of the plug nearest the center of the targets.
103
+ * A sweep of the balancing factor of NAT-017 gives trees from the least ink to the most direct paths.
104
+ * The tree at the knee of the front of ink and path lengths is the pick. Each edge takes its path in the tree, when that path is short enough.
105
+ * The stretches of the tree that two edges or more share become highways.
106
+ */
107
+ export function kneePlan(input: HighwayInput, o: Readonly<KneeOptions>): HighwayPlan {
108
+ const finder = new WayFinder(input.graph.nodes);
109
+ const first = firstRoutes(input);
110
+ const groups = new Map<string, { edge: string; key: string; s: Point; t: Point; base: number }[]>();
111
+ for (const p of edgePlugs(input.graph)) {
112
+ const s = finder.out(p.ps);
113
+ const t = finder.out(p.pt);
114
+ const route = first.get(p.edge.id);
115
+ const group = p.edge.source.node;
116
+ const list = groups.get(group) ?? [];
117
+ list.push({ edge: p.edge.id, key: p.edge.source.node, s, t, base: route ? polylineLength(route) : dist(s, t) });
118
+ groups.set(group, list);
119
+ }
120
+ const sweep = Math.min(21, Math.max(2, Math.floor(o.sweep)));
121
+ const ways: KeyedWay[] = [];
122
+ for (const group of [...groups.keys()].sort()) {
123
+ const list = groups.get(group)!;
124
+ if (list.length < 2) continue;
125
+ const goals = list.map((e) => e.t);
126
+ const { root, leads } = rootOf(finder, list.map((e) => e.s), goals);
127
+ const lost = new Set<number>();
128
+ const branch = (from: Point, t: number): Point[] => {
129
+ const way = finder.corners.path(from, goals[t]!);
130
+ if (way) return withStub(finder.corners.pull(way), finder.margin);
131
+ lost.add(t);
132
+ return [from, goals[t]!];
133
+ };
134
+ // The sweep: the cost `a L + (1 - a) S` for `a` from `1 / sweep` to 1, as the balancing factor `bf = (1 - a) / a`.
135
+ const trees: KneeTree[] = [];
136
+ for (let k = 0; k < sweep; k++) {
137
+ const a = (k + 1) / sweep;
138
+ const bf = (1 - a) / a;
139
+ const fan = balancedFan(root, goals, bf, branch);
140
+ let paths = 0;
141
+ for (const leaf of fan.leaf) paths += fan.points[leaf]!.pl;
142
+ trees.push({ bf, ink: fanInk(fan), paths, fan });
143
+ }
144
+ const pick = trees[kneeOf(trees, o.rule, o.ink)]!;
145
+ list.forEach((e, t) => {
146
+ const lead = leads[t];
147
+ if (lost.has(t) || !lead) return;
148
+ const points = [...lead, ...fanPath(pick.fan, pick.fan.leaf[t]!).slice(1)];
149
+ if (points.length < 2 || polylineLength(points) > o.detour * e.base) return;
150
+ ways.push({ edge: e.edge, key: e.key, points });
151
+ });
152
+ }
153
+ return sharedStretches(ways, { minSize: Math.max(2, Math.floor(o.minSize)) });
154
+ }
@@ -0,0 +1,19 @@
1
+ import { dist, type Point } from "../../geom.ts";
2
+ import type { WayFinder } from "../../ways.ts";
3
+
4
+ /**
5
+ * This function gives the root of the tree of one source node (NAT-018, NAT-019): the start point nearest the center of the targets.
6
+ * It also gives the lead of each edge, the way from its own start point to the root. A lead is `undefined` when the corner graph has no way.
7
+ */
8
+ export function rootOf(finder: WayFinder, starts: readonly Point[], goals: readonly Point[]): { root: Point; leads: (Point[] | undefined)[] } {
9
+ const c = { x: goals.reduce((s, p) => s + p.x, 0) / goals.length, y: goals.reduce((s, p) => s + p.y, 0) / goals.length };
10
+ let root = starts[0]!;
11
+ for (const s of starts) if (dist(s, c) < dist(root, c) - 1e-9) root = s;
12
+ const leads = starts.map((s) => {
13
+ if (dist(s, root) <= 1e-9) return [root];
14
+ if (finder.obstacles.clearPath([s, root], finder.margin / 2)) return [s, root];
15
+ const way = finder.corners.path(s, root);
16
+ return way ? finder.corners.pull(way) : undefined;
17
+ });
18
+ return { root, leads };
19
+ }
@@ -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
+ }