@mark1russell7/highways 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.1.5",
12
+ "version": "0.2.0",
13
13
  "type": "module",
14
14
  "main": "./build/index.js",
15
15
  "types": "./build/index.d.ts",
@@ -0,0 +1,121 @@
1
+ import type { Graph, Result } from "../model.ts";
2
+ import { sectionsRouter, type SectionsOptions } from "../routers/sections.ts";
3
+ import { groupStrategies } from "../strategies/builtins.ts";
4
+ import type { Strategy } from "../strategies/model.ts";
5
+ import { sameData } from "./run.ts";
6
+ import { measureLevelFaults, measureRouteFaults, sumFaults, type LinkFaults } from "./links.ts";
7
+
8
+ // The core runs in any host, so it reads the clock without the types of Node or the browser.
9
+ const now = (): number => (globalThis as { performance?: { now(): number } }).performance?.now() ?? Date.now();
10
+
11
+ /** The options of one setting of the router `sections`, without the name. */
12
+ export type SectionsSettingOptions = Omit<SectionsOptions, "name" | "maxNodes">;
13
+
14
+ /** One setting of a sweep of the router `sections`. */
15
+ export interface SectionsSetting {
16
+ /** The name of the setting in the column `setting`. */
17
+ readonly name: string;
18
+ readonly options: SectionsSettingOptions;
19
+ }
20
+
21
+ /** One row of a sweep of the router `sections`: one setting on one board, and the measures of its routes and of its zoom levels. */
22
+ export interface SectionsBenchRow {
23
+ readonly fixture: string;
24
+ readonly setting: string;
25
+ /** The faults of the routes of the edges (`measureRouteFaults`). */
26
+ readonly faults: LinkFaults;
27
+ /** The sum of the faults of the levels with groups (`measureLevelFaults` and `sumFaults`). */
28
+ readonly levelFaults: LinkFaults;
29
+ /** The sum of the overlaps of the levels with groups. */
30
+ readonly overlaps: number;
31
+ /** The number of routes that go through hubs: the routes that are not the same as the direct route of their edge. */
32
+ readonly hubRoutes: number;
33
+ /** The number of shared stretches of way of the result (`runs`). */
34
+ readonly runs: number;
35
+ /** The number of zoom levels, with the last level of single nodes. */
36
+ readonly levels: number;
37
+ /** The number of items of the far view: the first level with more than one item. */
38
+ readonly farItems: number;
39
+ /** The time of one route, in milliseconds. */
40
+ readonly ms: number;
41
+ }
42
+
43
+ /** This function gives the number of items of the first level with more than one item, or 0 for a result without levels. */
44
+ function farItems(result: Result): number {
45
+ return result.levels?.find((l) => l.items.length > 1)?.items.length ?? 0;
46
+ }
47
+
48
+ /**
49
+ * This function measures the router `sections` on one board with each setting. The first row is the fair baseline: the defaults, with no option.
50
+ * The direct routes come from the hub limit 0, so `hubRoutes` counts the routes that use the hubs.
51
+ */
52
+ export function sweepSections(fixture: string, graph: Graph, settings: readonly SectionsSetting[]): SectionsBenchRow[] {
53
+ const all: SectionsSetting[] = [{ name: "defaults", options: {} }, ...settings];
54
+ return all.map((s) => {
55
+ const direct = sectionsRouter({ ...s.options, name: "direct", stretch: 0, stretchPad: 0 }).route(graph);
56
+ const t0 = now();
57
+ const result = sectionsRouter({ ...s.options, name: s.name }).route(graph);
58
+ const ms = now() - t0;
59
+ const levelFaults = measureLevelFaults(result);
60
+ return {
61
+ fixture,
62
+ setting: s.name,
63
+ faults: measureRouteFaults(graph, result),
64
+ levelFaults: sumFaults(levelFaults),
65
+ overlaps: levelFaults.reduce((n, f) => n + f.overlaps, 0),
66
+ hubRoutes: result.routes.filter((r, i) => !sameData(r.points, direct.routes[i]?.points)).length,
67
+ runs: result.runs?.length ?? 0,
68
+ levels: result.levels?.length ?? 0,
69
+ farItems: farItems(result),
70
+ ms,
71
+ };
72
+ });
73
+ }
74
+
75
+ /** The values of the hub limit (`stretch`) of the sweep of CUSTOM. The default is 2. */
76
+ export const STRETCH_SWEEP: readonly number[] = [1.25, 1.5, 2, 3, Infinity];
77
+
78
+ /** This function gives the settings of the sweep of the hub limit: one setting for each value of `stretch`. */
79
+ export function stretchSettings(values: readonly number[] = STRETCH_SWEEP): SectionsSetting[] {
80
+ return values.map((stretch) => ({ name: `stretch ${stretch}`, options: { stretch } }));
81
+ }
82
+
83
+ /** The values of `hubSlack` of the sweep of CUSTOM. The default is 80. */
84
+ export const SLACK_SWEEP: readonly number[] = [0, 40, 80, 160, Infinity];
85
+
86
+ /** This function gives the settings of the sweep of the slack of the hubs: one setting for each value of `hubSlack`. */
87
+ export function slackSettings(values: readonly number[] = SLACK_SWEEP): SectionsSetting[] {
88
+ return values.map((hubSlack) => ({ name: `hubSlack ${hubSlack}`, options: { hubSlack } }));
89
+ }
90
+
91
+ /** The values of the section limit (`cap`) of the sweep of CUSTOM. The default is 8. */
92
+ export const CAP_SWEEP: readonly number[] = [4, 6, 8, 12, 16];
93
+
94
+ /** This function gives the settings of the sweep of the section limit: one setting for each value of `cap`. */
95
+ export function capSettings(values: readonly number[] = CAP_SWEEP): SectionsSetting[] {
96
+ return values.map((cap) => ({ name: `cap ${cap}`, options: { cap } }));
97
+ }
98
+
99
+ /**
100
+ * This function gives one setting for each grouping rule: the router `sections` with the strategy as its option `group`.
101
+ * The map `options` gives the options of a strategy by its name.
102
+ */
103
+ export function groupSettings(
104
+ strategies: readonly Strategy<"group">[] = Object.values(groupStrategies),
105
+ options: Readonly<Record<string, object>> = {},
106
+ ): SectionsSetting[] {
107
+ return strategies.map((group) => ({ name: `group ${group.name}`, options: { group, ...(options[group.name] ? { groupOptions: options[group.name] } : {}) } }));
108
+ }
109
+
110
+ /** This function writes the rows of `sweepSections` as a Markdown table. */
111
+ export function formatSections(rows: readonly SectionsBenchRow[]): string {
112
+ const head =
113
+ "| fixture | setting | through | out and back | worst detour | outside | hub routes | runs | levels | far items | level through | level out and back | level outside | level overlaps | ms |";
114
+ const line = "|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|";
115
+ const body = rows.map((r) => {
116
+ const f = r.faults;
117
+ const l = r.levelFaults;
118
+ return `| ${r.fixture} | ${r.setting} | ${f.through} | ${f.backtrack} | ${f.worstDetour.toFixed(2)} | ${f.outside} | ${r.hubRoutes} | ${r.runs} | ${r.levels} | ${r.farItems} | ${l.through} | ${l.backtrack} | ${l.outside} | ${r.overlaps} | ${r.ms.toFixed(1)} |`;
119
+ });
120
+ return [head, line, ...body].join("\n");
121
+ }
@@ -0,0 +1,2 @@
1
+ export { quadtreeGroups } from "./quadtree/index.ts";
2
+ export { byDataGroup, keyedSections, keyGroups, type KeyOptions, type NodeKey } from "./keys/index.ts";
@@ -0,0 +1,81 @@
1
+ import type { Box } from "../../geom.ts";
2
+ import type { Graph, Node } from "../../model.ts";
3
+ import { buildSections, type Section, type SectionOptions } from "../../sections.ts";
4
+
5
+ /** A key rule gives the key of the group of a node. A node without a key is a group alone. */
6
+ export type NodeKey = (node: Node, index: number) => string | undefined;
7
+
8
+ /** The options of the grouping rule `keys`. */
9
+ export interface KeyOptions {
10
+ /** This function gives the key of the group of each node. The default is `byDataGroup`. */
11
+ readonly key?: NodeKey;
12
+ /**
13
+ * With this value, a group with more nodes than its `cap` splits into the cells of a quadtree, as `buildSections` does.
14
+ * Without it, each group is one leaf. The default is no split.
15
+ */
16
+ readonly split?: SectionOptions;
17
+ }
18
+
19
+ /** This key rule gives the string `data.group` of the node, or `undefined` when the node has no such string. */
20
+ export const byDataGroup: NodeKey = (node) => {
21
+ const group = node.data?.["group"];
22
+ return typeof group === "string" ? group : undefined;
23
+ };
24
+
25
+ /** This function gives the bounding box of the boxes of the nodes of the list. */
26
+ function boundsOf(nodes: readonly Node[], list: readonly number[]): Box {
27
+ let x0 = Infinity;
28
+ let y0 = Infinity;
29
+ let x1 = -Infinity;
30
+ let y1 = -Infinity;
31
+ for (const i of list) {
32
+ const b = nodes[i]!.box;
33
+ x0 = Math.min(x0, b.x);
34
+ y0 = Math.min(y0, b.y);
35
+ x1 = Math.max(x1, b.x + b.w);
36
+ y1 = Math.max(y1, b.y + b.h);
37
+ }
38
+ return list.length ? { x: x0, y: y0, w: x1 - x0, h: y1 - y0 } : { x: 0, y: 0, w: 0, h: 0 };
39
+ }
40
+
41
+ /**
42
+ * This function gives the tree of sections of the grouping rule `keys`. The root holds each node. Each child of the root holds the nodes of one key,
43
+ * in the order of their first node. The id of a group is `s:` and its key. A node without a key is a group alone, with the id `s#` and its index.
44
+ * Thus the far view of the router `sections` shows one item for each group.
45
+ */
46
+ export function keyedSections(nodes: readonly Node[], options: KeyOptions = {}): Section {
47
+ const key = options.key ?? byDataGroup;
48
+ const groups = new Map<string, number[]>();
49
+ nodes.forEach((n, i) => {
50
+ const k = key(n, i);
51
+ const id = k === undefined ? `s#${i}` : `s:${k}`;
52
+ const list = groups.get(id);
53
+ if (list) list.push(i);
54
+ else groups.set(id, [i]);
55
+ });
56
+ const children = [...groups].map(([id, list]) => group(nodes, id, list, options.split));
57
+ return { id: "s", depth: 0, cell: boundsOf(nodes, nodes.map((_, i) => i)), nodes: nodes.map((_, i) => i), children };
58
+ }
59
+
60
+ /** This function gives the section of one group at depth 1. With `split`, the section has the quadtree of its nodes below it. */
61
+ function group(nodes: readonly Node[], id: string, list: readonly number[], split: SectionOptions | undefined): Section {
62
+ if (!split) return { id, depth: 1, cell: boundsOf(nodes, list), nodes: list, children: [] };
63
+ const tree = buildSections(
64
+ list.map((i) => nodes[i]!),
65
+ split,
66
+ );
67
+ // The quadtree numbers the nodes of the group from 0. Each section gets the indices of the graph, one more level and the id of the group before its path.
68
+ const rebase = (s: Section): Section => ({
69
+ id: s.id === "s" ? id : `${id}/${s.id.slice(1)}`,
70
+ depth: s.depth + 1,
71
+ cell: s.cell,
72
+ nodes: s.nodes.map((j) => list[j]!),
73
+ children: s.children.map(rebase),
74
+ });
75
+ return rebase(tree);
76
+ }
77
+
78
+ /** This function gives the tree of sections of the grouping rule `keys` for a graph. */
79
+ export function keyGroups(graph: Graph, options: KeyOptions = {}): Section {
80
+ return keyedSections(graph.nodes, options);
81
+ }
@@ -0,0 +1,10 @@
1
+ import type { Graph } from "../../model.ts";
2
+ import { buildSections, type Section, type SectionOptions } from "../../sections.ts";
3
+
4
+ /**
5
+ * This function gives the tree of sections of the grouping rule `quadtree`: the cells of a quadtree of the plane (`buildSections`).
6
+ * It is the default grouping rule of the router `sections`.
7
+ */
8
+ export function quadtreeGroups(graph: Graph, options: SectionOptions = {}): Section {
9
+ return buildSections(graph.nodes, options);
10
+ }
package/src/index.ts CHANGED
@@ -10,6 +10,8 @@ export * from "./visibility.ts";
10
10
  export * from "./ways.ts";
11
11
  export * from "./segments.ts";
12
12
  export * from "./sections.ts";
13
+ export * from "./groups/index.ts";
14
+ export * from "./pick.ts";
13
15
  export * from "./routers/index.ts";
14
16
  export * from "./room/index.ts";
15
17
  export * from "./room/lanes.ts";
@@ -19,4 +21,5 @@ export * from "./bench/links.ts";
19
21
  export * from "./bench/run.ts";
20
22
  export * from "./bench/curves.ts";
21
23
  export * from "./bench/room.ts";
24
+ export * from "./bench/sections.ts";
22
25
  export * from "./strategies/index.ts";
package/src/levels.ts CHANGED
@@ -28,8 +28,19 @@ interface LevelWays extends Ways {
28
28
  readonly free: Map<Node, FreePlugs>;
29
29
  }
30
30
 
31
- /** The margin of the second corner graph. A gap between two boxes wider than approximately 1.5 times this margin lets a link through. */
32
- const TIGHT_MARGIN = 1;
31
+ /** The options of the links of a level: the options of the ways, and two limits of the level links. */
32
+ export interface LevelLinkOptions extends WayOptions {
33
+ /**
34
+ * The margin of the second corner graph. A gap between two boxes wider than approximately 1.5 times this margin lets a link through.
35
+ * The second graph uses the smaller of this value and `margin`. The default is 1.
36
+ */
37
+ readonly tightMargin?: number;
38
+ /** A plug stays at least this far from the corners of its side. The level links use the smaller of this value and `margin`. The default is 6. */
39
+ readonly plugInset?: number;
40
+ }
41
+
42
+ /** The default values of the two limits of `LevelLinkOptions`. */
43
+ export const LEVEL_LINK_DEFAULTS: Readonly<Required<Pick<LevelLinkOptions, "tightMargin" | "plugInset">>> = Object.freeze({ tightMargin: 1, plugInset: 6 });
33
44
 
34
45
  const EPS = 1e-6;
35
46
 
@@ -60,12 +71,12 @@ function tidy(p: readonly Point[]): Point[] {
60
71
  * A link starts on the boundary of the box of its source and ends on the boundary of the box of its target.
61
72
  * It goes around the boxes of the other items. Where there is space, its legs are parallel to the axes.
62
73
  */
63
- export function levelLinks(items: readonly LevelItem[], pairs: readonly ItemPair[], options: WayOptions = {}): LevelLink[] {
74
+ export function levelLinks(items: readonly LevelItem[], pairs: readonly ItemPair[], options: LevelLinkOptions = {}): LevelLink[] {
64
75
  if (pairs.length === 0) return [];
65
76
  const nodes: Node[] = items.map((i) => ({ id: i.id, box: i.box }));
66
77
  const byId = new Map(nodes.map((n) => [n.id, n]));
67
78
  const ways = levelWays(nodes, options);
68
- const inset = Math.min(6, ways.margin);
79
+ const inset = Math.min(options.plugInset ?? LEVEL_LINK_DEFAULTS.plugInset, ways.margin);
69
80
  const out: LevelLink[] = [];
70
81
  for (const p of pairs) {
71
82
  const s = byId.get(p.source);
@@ -101,7 +112,7 @@ function sideWay(ways: LevelWays, s: Node, t: Node, inset: number): Point[] {
101
112
  * The buckets of the obstacles grow with the items, so a long link visits few buckets.
102
113
  * The corner graphs use `cones`, so a link goes along the corridors between the groups, not around the outer edge of the board.
103
114
  */
104
- function levelWays(nodes: readonly Node[], options: WayOptions): LevelWays {
115
+ function levelWays(nodes: readonly Node[], options: LevelLinkOptions): LevelWays {
105
116
  const margin = options.margin ?? 5;
106
117
  const k = options.k ?? 8;
107
118
  let size = 0;
@@ -110,7 +121,7 @@ function levelWays(nodes: readonly Node[], options: WayOptions): LevelWays {
110
121
  const corners = new CornerGraph(nodes, obstacles, { margin, k, cones: true });
111
122
  const out = (p: Plug): Point => ({ x: p.point.x + p.normal.x * margin, y: p.point.y + p.normal.y * margin });
112
123
  let tight: CornerGraph | undefined;
113
- const tightGraph = (): CornerGraph => (tight ??= new CornerGraph(nodes, obstacles, { margin: Math.min(margin, TIGHT_MARGIN), k, cones: true }));
124
+ const tightGraph = (): CornerGraph => (tight ??= new CornerGraph(nodes, obstacles, { margin: Math.min(margin, options.tightMargin ?? LEVEL_LINK_DEFAULTS.tightMargin), k, cones: true }));
114
125
  return { obstacles, corners, margin, out, tight: tightGraph, free: new Map() };
115
126
  }
116
127
 
package/src/model.ts CHANGED
@@ -20,6 +20,8 @@ export interface Node {
20
20
  readonly id: string;
21
21
  readonly box: Box;
22
22
  readonly ports?: readonly Port[];
23
+ /** This data is free for the grouping rules of the nodes. The rule `keys` reads the string `data.group` by default. */
24
+ readonly data?: Readonly<Record<string, unknown>>;
23
25
  }
24
26
 
25
27
  /** One end of an edge: a node, and optionally one plug of that node. */
package/src/pick.ts ADDED
@@ -0,0 +1,36 @@
1
+ import type { Level } from "./model.ts";
2
+
3
+ /** This function gives the median width of the shown items of each level, in units of the plane. */
4
+ export function itemSizes(levels: readonly Level[]): number[] {
5
+ return levels.map((level) => {
6
+ const sizes = level.items.map((i) => Math.max(i.box.w, i.box.h)).sort((a, b) => a - b);
7
+ return sizes.length ? sizes[Math.floor(sizes.length / 2)]! : 0;
8
+ });
9
+ }
10
+
11
+ /** The far-view thresholds of `pickLevel`, in pixels on the screen. */
12
+ export interface PickOptions {
13
+ /** When a node is at least this wide on the screen, the view shows each route. */
14
+ readonly routesAt: number;
15
+ /** A level is fine enough when its median item is at least this wide on the screen. */
16
+ readonly itemAt: number;
17
+ }
18
+
19
+ /** The default thresholds: a node of 22 pixels shows its routes, and a group of 120 pixels shows its parts. */
20
+ export const PICK: PickOptions = Object.freeze({ routesAt: 22, itemAt: 120 });
21
+
22
+ /**
23
+ * This function picks the level that a camera shows at a scale, in pixels for each unit of the plane. The sizes come from `itemSizes`.
24
+ * The value `sizes.length` stands for the routes of each edge. The last level holds the nodes alone.
25
+ * Each threshold that `options` does not give gets its value from `PICK`.
26
+ */
27
+ export function pickLevel(sizes: readonly number[], scale: number, options: Partial<PickOptions> = PICK): number {
28
+ const routesAt = options.routesAt ?? PICK.routesAt;
29
+ const itemAt = options.itemAt ?? PICK.itemAt;
30
+ const n = sizes.length;
31
+ if (n === 0) return 0;
32
+ if (sizes[n - 1]! * scale >= routesAt) return n;
33
+ let pick = 0;
34
+ for (let d = 0; d < n - 1; d++) if (sizes[d]! * scale >= itemAt) pick = d;
35
+ return pick;
36
+ }
@@ -1,14 +1,19 @@
1
1
  import { BACKTRACK_MIN, goesOutAndBack } from "../bench/links.ts";
2
2
  import { center, dist, pointSegmentDistance, polylineLength, type Box, type Point } from "../geom.ts";
3
- import { levelLinks, type ItemPair } from "../levels.ts";
3
+ import { LEVEL_LINK_DEFAULTS, levelLinks, type ItemPair, type LevelLinkOptions } from "../levels.ts";
4
4
  import { nodeIndex, type Graph, type Level, type LevelItem, type Node, type Result, type Route, type Router, type SharedRun } from "../model.ts";
5
5
  import { placePlug } from "../plugs.ts";
6
- import { allSections, buildSections, treeDepth, type Section, type SectionOptions } from "../sections.ts";
6
+ import { allSections, buildSections, checkSections, SECTION_DEFAULTS, treeDepth, type Section, type SectionOptions } from "../sections.ts";
7
7
  import { orderLanes } from "../segments.ts";
8
- import { WayFinder, type WayOptions } from "../ways.ts";
8
+ import type { Strategy } from "../strategies/model.ts";
9
+ import { WayFinder } from "../ways.ts";
9
10
 
10
- /** The options of the router with sections. */
11
- export interface SectionsOptions extends WayOptions, SectionOptions {
11
+ /**
12
+ * The options of the router with sections. Each option has a default, and the defaults give the output of version 0.1.5.
13
+ * The options `cap`, `maxDepth` and `collapse` are the limits of the default grouping rule, the quadtree of `buildSections`.
14
+ * The options `tightMargin` and `plugInset` are the limits of the level links (`levelLinks`).
15
+ */
16
+ export interface SectionsOptions extends LevelLinkOptions, SectionOptions {
12
17
  /** The name in the benchmark. */
13
18
  readonly name: string;
14
19
  /**
@@ -16,19 +21,49 @@ export interface SectionsOptions extends WayOptions, SectionOptions {
16
21
  * and fewer shared ways. Without it, a way passes each hub from its node up.
17
22
  */
18
23
  readonly hubs?: number;
19
- /** A way through the hubs can be at most this many times as long as the line between its plugs. Else the edge goes directly. The default is 2. */
24
+ /**
25
+ * The hub limit. A way through the hubs can be at most this many times as long as the line between its plugs, plus `stretchPad` margins.
26
+ * Else the edge goes directly. The default is 2. The value `Infinity` keeps each way through the hubs.
27
+ */
20
28
  readonly stretch?: number;
29
+ /** A way through the hubs can be this many margins longer than `stretch` times the line between its plugs. The default is 4. */
30
+ readonly stretchPad?: number;
31
+ /**
32
+ * A way skips a hub that is more than this many units farther from the target than the nearest approach of the way before it.
33
+ * The default is 80, two times `BACKTRACK_MIN`. The value `Infinity` keeps each hub.
34
+ */
35
+ readonly hubSlack?: number;
36
+ /**
37
+ * The grouping rule: a strategy of the step `group` that makes the tree of sections. The hubs and the zoom levels come from this tree.
38
+ * Without it, the router uses the quadtree of `buildSections` with the options `cap`, `maxDepth` and `collapse`.
39
+ */
40
+ readonly group?: Strategy<"group">;
41
+ /** The options of the strategy `group`. The strategy gets its `defaults` for each option that this object does not give. */
42
+ readonly groupOptions?: object;
43
+ /**
44
+ * The depths of the tree that get a zoom level with groups. The last level, with each node alone, always comes after them.
45
+ * The default is each depth, from 0 to the depth of the deepest section.
46
+ */
47
+ readonly levels?: readonly number[];
21
48
  /** The benchmark skips a fixture with more nodes. */
22
49
  readonly maxNodes?: number;
23
50
  }
24
51
 
25
- const dedupe = (p: readonly Point[]): Point[] => p.filter((q, i) => i === 0 || dist(q, p[i - 1]!) > 1e-9);
52
+ /** The default values of the numbers of `SectionsOptions`: the limits of the hubs, of the tree and of the level links. */
53
+ export const SECTIONS_DEFAULTS: Readonly<Required<Pick<SectionsOptions, "cap" | "maxDepth" | "collapse" | "tightMargin" | "plugInset" | "hubs" | "stretch" | "stretchPad" | "hubSlack">>> =
54
+ Object.freeze({
55
+ cap: SECTION_DEFAULTS.cap,
56
+ maxDepth: SECTION_DEFAULTS.maxDepth,
57
+ collapse: SECTION_DEFAULTS.collapse,
58
+ tightMargin: LEVEL_LINK_DEFAULTS.tightMargin,
59
+ plugInset: LEVEL_LINK_DEFAULTS.plugInset,
60
+ hubs: Infinity,
61
+ stretch: 2,
62
+ stretchPad: 4,
63
+ hubSlack: 2 * BACKTRACK_MIN,
64
+ });
26
65
 
27
- /**
28
- * A hub can be this many units farther from the target than the nearest approach of the way before it.
29
- * A hub farther away goes out and back, so the way skips it.
30
- */
31
- const HUB_SLACK = 2 * BACKTRACK_MIN;
66
+ const dedupe = (p: readonly Point[]): Point[] => p.filter((q, i) => i === 0 || dist(q, p[i - 1]!) > 1e-9);
32
67
 
33
68
  /**
34
69
  * This function makes a router with a tree of sections. Each section has a hub: the free corner nearest to the center of its nodes.
@@ -45,12 +80,14 @@ export function sectionsRouter(options: SectionsOptions): Router {
45
80
  const router: Router = {
46
81
  name: options.name,
47
82
  route(graph: Graph): Result {
48
- const stretch = options.stretch ?? 2;
83
+ const stretch = options.stretch ?? SECTIONS_DEFAULTS.stretch;
84
+ const stretchPad = options.stretchPad ?? SECTIONS_DEFAULTS.stretchPad;
85
+ const hubSlack = options.hubSlack ?? SECTIONS_DEFAULTS.hubSlack;
49
86
  const byId = nodeIndex(graph);
50
87
  const indexOfNode = new Map(graph.nodes.map((n, i) => [n.id, i]));
51
88
  const ways = new WayFinder(graph.nodes, options);
52
89
  const corners = ways.corners;
53
- const root = buildSections(graph.nodes, options);
90
+ const root = groupNodes(graph, options);
54
91
  const parent = new Map<Section, Section>();
55
92
  const leafOf: Section[] = [];
56
93
  for (const s of allSections(root)) {
@@ -82,7 +119,7 @@ export function sectionsRouter(options: SectionsOptions): Router {
82
119
  let last = from;
83
120
  for (const c of list) {
84
121
  const h = hub(c);
85
- if (dist(h, to) > near + HUB_SLACK) continue;
122
+ if (dist(h, to) > near + hubSlack) continue;
86
123
  near = Math.min(near, pointSegmentDistance(to, last, h));
87
124
  last = h;
88
125
  kept.push(c);
@@ -132,7 +169,7 @@ export function sectionsRouter(options: SectionsOptions): Router {
132
169
  const b = up(lv);
133
170
  const common = new Set(b);
134
171
  const lca = a.find((x) => common.has(x))!;
135
- const keep = options.hubs ?? Infinity;
172
+ const keep = options.hubs ?? SECTIONS_DEFAULTS.hubs;
136
173
  const side = (list: Section[]): Section[] => list.slice(Math.max(0, list.indexOf(lca) - keep), list.indexOf(lca));
137
174
  const end = ways.out(pt);
138
175
  const start = ways.out(ps);
@@ -151,7 +188,7 @@ export function sectionsRouter(options: SectionsOptions): Router {
151
188
  append(piece(chain[chain.length - 1]!.id, hub(chain[chain.length - 1]!), at(end), end, used));
152
189
  append([pt.point]);
153
190
  // A way through the hubs that is too long, or that goes out and back, gives way to the direct way.
154
- if (polylineLength(out) > stretch * dist(ps.point, pt.point) + 4 * ways.margin || goesOutAndBack(out)) {
191
+ if (polylineLength(out) > stretch * dist(ps.point, pt.point) + stretchPad * ways.margin || goesOutAndBack(out)) {
155
192
  routes.push({ edge: e.id, points: ways.between(ps, pt, new Set<Node>([s, t])) });
156
193
  continue;
157
194
  }
@@ -176,17 +213,28 @@ export function sectionsRouter(options: SectionsOptions): Router {
176
213
  return options.maxNodes === undefined ? router : { ...router, maxNodes: options.maxNodes };
177
214
  }
178
215
 
216
+ /** This function gives the tree of sections of the grouping rule. It examines the tree of a strategy (`checkSections`). */
217
+ function groupNodes(graph: Graph, options: SectionsOptions): Section {
218
+ if (!options.group) return buildSections(graph.nodes, options);
219
+ const root = options.group.run(graph, options.groupOptions);
220
+ checkSections(root, graph.nodes.length);
221
+ return root;
222
+ }
223
+
179
224
  /**
180
225
  * This function gives one zoom level for each depth of the tree, and a last level with each node alone.
226
+ * With the option `levels`, only those depths get a level with groups.
181
227
  * At depth d, a section of depth d is one item. A link joins two items and counts the edges between them.
182
228
  * In a level with groups, a link goes around the boxes of the other items (`levelLinks`).
183
229
  * In the last level, the points of a link are the route of its first edge.
184
230
  */
185
- function makeLevels(graph: Graph, root: Section, routeOf: ReadonlyMap<string, Route>, options: WayOptions): Level[] {
231
+ function makeLevels(graph: Graph, root: Section, routeOf: ReadonlyMap<string, Route>, options: SectionsOptions): Level[] {
186
232
  const indexOfNode = new Map(graph.nodes.map((n, i) => [n.id, i]));
187
233
  const levels: Level[] = [];
188
234
  const deepest = treeDepth(root);
235
+ const shown = options.levels && new Set(options.levels);
189
236
  for (let d = 0; d <= deepest + 1; d++) {
237
+ if (shown && d <= deepest && !shown.has(d)) continue;
190
238
  const items: LevelItem[] = [];
191
239
  const itemOf: (Section | number)[] = [];
192
240
  const boxOf = (list: readonly number[]): Box => {
package/src/sections.ts CHANGED
@@ -18,15 +18,21 @@ export interface SectionOptions {
18
18
  readonly cap?: number;
19
19
  /** The tree stops at this depth. The default is 16. */
20
20
  readonly maxDepth?: number;
21
+ /** A cell shrinks to one quarter at most this many times without a split. Then it stops. The default is 32. */
22
+ readonly collapse?: number;
21
23
  }
22
24
 
25
+ /** The default values of `SectionOptions`. */
26
+ export const SECTION_DEFAULTS: Readonly<Required<SectionOptions>> = Object.freeze({ cap: 8, maxDepth: 16, collapse: 32 });
27
+
23
28
  /**
24
29
  * This function makes the tree of sections of the nodes. A cell with more than `cap` nodes splits in four.
25
30
  * When the nodes of a cell are all in one quarter, the cell becomes that quarter and does not make a level.
26
31
  */
27
32
  export function buildSections(nodes: readonly Node[], options: SectionOptions = {}): Section {
28
- const cap = Math.max(1, options.cap ?? 8);
29
- const maxDepth = options.maxDepth ?? 16;
33
+ const cap = Math.max(1, options.cap ?? SECTION_DEFAULTS.cap);
34
+ const maxDepth = options.maxDepth ?? SECTION_DEFAULTS.maxDepth;
35
+ const collapse = options.collapse ?? SECTION_DEFAULTS.collapse;
30
36
  const centers = nodes.map((n) => center(n.box));
31
37
  let x0 = Infinity;
32
38
  let y0 = Infinity;
@@ -41,7 +47,7 @@ export function buildSections(nodes: readonly Node[], options: SectionOptions =
41
47
  const root: Box = nodes.length ? { x: x0, y: y0, w: x1 - x0, h: y1 - y0 } : { x: 0, y: 0, w: 0, h: 0 };
42
48
  const make = (id: string, depth: number, cell: Box, list: readonly number[]): Section => {
43
49
  let box = cell;
44
- for (let guard = 0; list.length > cap && depth < maxDepth && guard < 32; guard++) {
50
+ for (let guard = 0; list.length > cap && depth < maxDepth && guard < collapse; guard++) {
45
51
  const mx = box.x + box.w / 2;
46
52
  const my = box.y + box.h / 2;
47
53
  const parts: number[][] = [[], [], [], []];
@@ -80,3 +86,40 @@ export function allSections(root: Section): Section[] {
80
86
  export function treeDepth(root: Section): number {
81
87
  return root.children.reduce((d, c) => Math.max(d, treeDepth(c)), root.depth);
82
88
  }
89
+
90
+ /**
91
+ * This function examines a tree of sections from a grouping rule. It throws an error when the tree is not valid for `count` nodes.
92
+ * In a valid tree, each node is in exactly one leaf. The children of a section split its nodes, and a child is one level deeper than its parent.
93
+ */
94
+ export function checkSections(root: Section, count: number): void {
95
+ const seen = new Array<number>(count).fill(0);
96
+ const ids = new Set<string>();
97
+ const fail = (s: Section, what: string): never => {
98
+ throw new Error(`The tree of sections is not valid at the section "${s.id}": ${what}.`);
99
+ };
100
+ if (root.depth !== 0) fail(root, "the root has a depth that is not 0");
101
+ if (root.nodes.length !== count) fail(root, `the root has ${root.nodes.length} nodes, and the graph has ${count}`);
102
+ const walk = (s: Section): void => {
103
+ if (ids.has(s.id)) fail(s, "two sections have this id");
104
+ ids.add(s.id);
105
+ if (s.children.length === 0) {
106
+ for (const i of s.nodes) {
107
+ if (!Number.isInteger(i) || i < 0 || i >= count) fail(s, `the node index ${i} is not in the graph`);
108
+ seen[i]!++;
109
+ }
110
+ return;
111
+ }
112
+ let total = 0;
113
+ const own = new Set(s.nodes);
114
+ for (const c of s.children) {
115
+ if (c.depth !== s.depth + 1) fail(c, "a child is not one level deeper than its parent");
116
+ for (const i of c.nodes) if (!own.has(i)) fail(c, `the node index ${i} is not in the parent`);
117
+ total += c.nodes.length;
118
+ walk(c);
119
+ }
120
+ if (total !== s.nodes.length) fail(s, "the children do not split the nodes of the section");
121
+ };
122
+ walk(root);
123
+ const lost = seen.findIndex((n) => n !== 1);
124
+ if (lost >= 0) fail(root, `the node index ${lost} is in ${seen[lost]} leaves, not in one`);
125
+ }
@@ -2,6 +2,8 @@ import { arcCurve, type ArcOptions } from "../curves/arc/index.ts";
2
2
  import { roundedCurve, type RoundedOptions } from "../curves/rounded/index.ts";
3
3
  import { splineCurve, type SplineOptions } from "../curves/spline/index.ts";
4
4
  import { straightCurve } from "../curves/straight/index.ts";
5
+ import { byDataGroup, keyGroups, type KeyOptions } from "../groups/keys/index.ts";
6
+ import { quadtreeGroups } from "../groups/quadtree/index.ts";
5
7
  import type { Graph, Result, Router } from "../model.ts";
6
8
  import { roomDemand, type RoomMethod, type RoomOptions } from "../room/index.ts";
7
9
  import { cornersRouter, type CornersOptions } from "../routers/corners.ts";
@@ -10,6 +12,7 @@ import { sectionsRouter, type SectionsOptions } from "../routers/sections.ts";
10
12
  import { sharedRouter, type SharedOptions } from "../routers/shared.ts";
11
13
  import { straightRouter } from "../routers/straight.ts";
12
14
  import { byCollection, trunksRouter, type TrunksOptions } from "../routers/trunks.ts";
15
+ import { SECTION_DEFAULTS, type SectionOptions } from "../sections.ts";
13
16
  import { createRegistry, defineStrategy, type AnyStrategy, type CurveInput, type Registry, type Strategy } from "./model.ts";
14
17
 
15
18
  /** The options of a router strategy: the options of its router, without the name and the limit of the benchmark. */
@@ -158,8 +161,30 @@ export const roomStrategies: {
158
161
  lines: roomStrategy("lines", "The lanes of a gap are the straight lines between the centers of the boxes that cross the gap."),
159
162
  });
160
163
 
164
+ /** The built-in grouping rules: the strategies of the step `group`. The router `sections` makes its hubs and its zoom levels from their tree. */
165
+ export const groupStrategies: {
166
+ readonly quadtree: Strategy<"group", Required<SectionOptions>>;
167
+ readonly keys: Strategy<"group", KeyOptions>;
168
+ } = Object.freeze({
169
+ quadtree: defineStrategy({
170
+ step: "group",
171
+ name: "quadtree",
172
+ describe: "The cells of a quadtree of the plane. A cell with more than `cap` nodes splits in four. It is the default rule of the router `sections`.",
173
+ defaults: { ...SECTION_DEFAULTS },
174
+ run: (graph: Graph, o: Readonly<Required<SectionOptions>>) => quadtreeGroups(graph, o),
175
+ }),
176
+ keys: defineStrategy({
177
+ step: "group",
178
+ name: "keys",
179
+ describe: "One group for each key of the nodes. The default key is the string `data.group` of the node. A node without a key is a group alone.",
180
+ defaults: { key: byDataGroup } as KeyOptions,
181
+ run: (graph: Graph, o: Readonly<KeyOptions>) => keyGroups(graph, o),
182
+ }),
183
+ });
184
+
161
185
  /** The built-in strategies of each step, in order. A consumer adds its own: `createRegistry([...BUILT_IN_STRATEGIES, mine])`. */
162
186
  export const BUILT_IN_STRATEGIES: readonly AnyStrategy[] = Object.freeze([
187
+ ...Object.values(groupStrategies),
163
188
  ...Object.values(routerStrategies),
164
189
  ...Object.values(curveStrategies),
165
190
  ...Object.values(roomStrategies),