@mark1russell7/highways 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +240 -8
  2. package/build/index.d.ts +1459 -78
  3. package/build/index.d.ts.map +1 -1
  4. package/build/index.js +9315 -3411
  5. package/build/index.js.map +1 -1
  6. package/package.json +2 -1
  7. package/src/bench/compare.ts +145 -0
  8. package/src/bench/curves.ts +514 -2
  9. package/src/bench/links.ts +28 -6
  10. package/src/bench/measures/bundles.ts +119 -0
  11. package/src/bench/measures/curves.ts +211 -0
  12. package/src/bench/measures/groups.ts +122 -0
  13. package/src/bench/measures/index.ts +9 -0
  14. package/src/bench/measures/levels.ts +103 -0
  15. package/src/bench/measures/pareto.ts +37 -0
  16. package/src/bench/measures/room.ts +43 -0
  17. package/src/bench/measures/routes.ts +226 -0
  18. package/src/bench/measures/stability.ts +104 -0
  19. package/src/bench/measures/stats.ts +114 -0
  20. package/src/bench/properties.ts +18 -2
  21. package/src/bench/room.ts +209 -2
  22. package/src/bench/run.ts +86 -11
  23. package/src/bench/suite.ts +781 -0
  24. package/src/cases.ts +41 -5
  25. package/src/curves/clothoid/index.ts +150 -0
  26. package/src/curves/corner.ts +116 -0
  27. package/src/curves/fit.ts +79 -0
  28. package/src/curves/highway/index.ts +636 -0
  29. package/src/curves/index.ts +16 -1
  30. package/src/curves/model.ts +4 -2
  31. package/src/curves/smooth/index.ts +50 -0
  32. package/src/curves/space.ts +76 -0
  33. package/src/groups/cut/index.ts +292 -0
  34. package/src/groups/index.ts +2 -0
  35. package/src/groups/kd/index.ts +207 -0
  36. package/src/index.ts +4 -0
  37. package/src/levels.ts +163 -5
  38. package/src/room/bends.ts +203 -0
  39. package/src/room/index.ts +28 -0
  40. package/src/room/levels/index.ts +155 -0
  41. package/src/routers/sections.ts +15 -40
  42. package/src/routers/shared.ts +7 -2
  43. package/src/routers/trunks.ts +131 -6
  44. package/src/sections.ts +47 -1
  45. package/src/segments.ts +81 -31
  46. package/src/strategies/builtins.ts +129 -8
  47. package/src/strategies/index.ts +1 -0
  48. package/src/strategies/model.ts +19 -1
  49. package/src/strategies/options.ts +296 -0
  50. package/src/strategies/schemas.ts +221 -0
  51. package/src/visibility.ts +190 -1
@@ -1,19 +1,27 @@
1
1
  import { arcCurve, type ArcOptions } from "../curves/arc/index.ts";
2
+ import { clothoidCurve, type ClothoidOptions } from "../curves/clothoid/index.ts";
3
+ import { highwayCurve, type JogStyle } from "../curves/highway/index.ts";
2
4
  import { roundedCurve, type RoundedOptions } from "../curves/rounded/index.ts";
5
+ import { smoothCurve, type SmoothOptions } from "../curves/smooth/index.ts";
3
6
  import { splineCurve, type SplineOptions } from "../curves/spline/index.ts";
4
7
  import { straightCurve } from "../curves/straight/index.ts";
8
+ import { CUT_DEFAULTS, cutGroups, type CutOptions } from "../groups/cut/index.ts";
9
+ import { KD_DEFAULTS, kdGroups, type KdOptions } from "../groups/kd/index.ts";
5
10
  import { byDataGroup, keyGroups, type KeyOptions } from "../groups/keys/index.ts";
6
11
  import { quadtreeGroups } from "../groups/quadtree/index.ts";
7
12
  import type { Graph, Result, Router } from "../model.ts";
8
13
  import { roomDemand, type RoomMethod, type RoomOptions } from "../room/index.ts";
14
+ import { levelRoom, type LevelRoomOptions } from "../room/levels/index.ts";
9
15
  import { cornersRouter, type CornersOptions } from "../routers/corners.ts";
10
16
  import { plugsRouter } from "../routers/plugs.ts";
11
17
  import { sectionsRouter, type SectionsOptions } from "../routers/sections.ts";
12
18
  import { sharedRouter, type SharedOptions } from "../routers/shared.ts";
13
19
  import { straightRouter } from "../routers/straight.ts";
14
20
  import { byCollection, trunksRouter, type TrunksOptions } from "../routers/trunks.ts";
15
- import { SECTION_DEFAULTS, type SectionOptions } from "../sections.ts";
21
+ import { buildSections, SECTION_DEFAULTS, type SectionOptions } from "../sections.ts";
16
22
  import { createRegistry, defineStrategy, type AnyStrategy, type CurveInput, type Registry, type Strategy } from "./model.ts";
23
+ import type { OptionSchema } from "./options.ts";
24
+ import { CLOTHOID_SCHEMA, CORNERS_SCHEMA, CUT_SCHEMA, HIGHWAY_SCHEMA, KD_SCHEMA, KEY_SCHEMA, LEVEL_ROOM_SCHEMA, RADIUS_SCHEMA, ROOM_SCHEMA, SECTION_SCHEMA, SECTIONS_SCHEMA, SHARED_SCHEMA, SMOOTH_SCHEMA, TENSION_SCHEMA, TRUNKS_SCHEMA } from "./schemas.ts";
17
25
 
18
26
  /** The options of a router strategy: the options of its router, without the name and the limit of the benchmark. */
19
27
  export type RouterPreset<O> = Omit<O, "name" | "maxNodes">;
@@ -25,13 +33,14 @@ function routerStrategy<O extends object>(
25
33
  maxNodes: number,
26
34
  defaults: O,
27
35
  make: (options: O & { readonly name: string }) => Router,
36
+ schema: OptionSchema<O>,
28
37
  ): Strategy<"router", O> {
29
- return defineStrategy({ step: "router", name, describe, defaults, maxNodes, run: (graph: Graph, options: Readonly<O>): Result => make({ ...options, name }).route(graph) });
38
+ return defineStrategy({ step: "router", name, describe, defaults, schema, maxNodes, run: (graph: Graph, options: Readonly<O>): Result => make({ ...options, name }).route(graph) });
30
39
  }
31
40
 
32
41
  /** This function makes a router strategy for a router without options. */
33
42
  function fixedRouter(router: Router, describe: string): Strategy<"router", object> {
34
- return defineStrategy({ step: "router", name: router.name, describe, defaults: {}, run: (graph: Graph): Result => router.route(graph) });
43
+ return defineStrategy({ step: "router", name: router.name, describe, defaults: {}, schema: {}, run: (graph: Graph): Result => router.route(graph) });
35
44
  }
36
45
 
37
46
  /** The built-in router strategies, in the order of the benchmark. They route as the routers of `ROUTERS`. */
@@ -57,6 +66,7 @@ export const routerStrategies: {
57
66
  10_000,
58
67
  {},
59
68
  cornersRouter,
69
+ CORNERS_SCHEMA,
60
70
  ),
61
71
  "corners-smooth": routerStrategy<RouterPreset<CornersOptions>>(
62
72
  "corners-smooth",
@@ -64,6 +74,7 @@ export const routerStrategies: {
64
74
  10_000,
65
75
  { smooth: true },
66
76
  cornersRouter,
77
+ CORNERS_SCHEMA,
67
78
  ),
68
79
  trunks: routerStrategy<RouterPreset<TrunksOptions>>(
69
80
  "trunks",
@@ -71,6 +82,7 @@ export const routerStrategies: {
71
82
  10_000,
72
83
  { rule: byCollection },
73
84
  trunksRouter,
85
+ TRUNKS_SCHEMA,
74
86
  ),
75
87
  shared: routerStrategy<RouterPreset<SharedOptions>>(
76
88
  "shared",
@@ -78,14 +90,16 @@ export const routerStrategies: {
78
90
  10_000,
79
91
  { rule: byCollection },
80
92
  sharedRouter,
93
+ SHARED_SCHEMA,
81
94
  ),
82
- "shared-all": routerStrategy<RouterPreset<SharedOptions>>("shared-all", "The same as `shared`, but all edges share their ways.", 10_000, {}, sharedRouter),
95
+ "shared-all": routerStrategy<RouterPreset<SharedOptions>>("shared-all", "The same as `shared`, but all edges share their ways.", 10_000, {}, sharedRouter, SHARED_SCHEMA),
83
96
  sections: routerStrategy<RouterPreset<SectionsOptions>>(
84
97
  "sections",
85
98
  "A tree of sections, with a hub in each section. A long edge goes through the hubs, and a short edge goes directly.",
86
99
  100_000,
87
100
  {},
88
101
  sectionsRouter,
102
+ SECTIONS_SCHEMA,
89
103
  ),
90
104
  "sections-tree": routerStrategy<RouterPreset<SectionsOptions>>(
91
105
  "sections-tree",
@@ -93,6 +107,7 @@ export const routerStrategies: {
93
107
  100_000,
94
108
  { stretch: Infinity },
95
109
  sectionsRouter,
110
+ SECTIONS_SCHEMA,
96
111
  ),
97
112
  });
98
113
 
@@ -115,19 +130,56 @@ export interface TensionOptions {
115
130
  readonly tension: number;
116
131
  }
117
132
 
118
- /** The built-in curve strategies, in the order of `CURVE_TYPES`. Each one gives the same segments as `curveOf` with its type. */
133
+ /** The options of the curve strategy `highway`. */
134
+ export interface HighwayStrategyOptions {
135
+ /** The radius of the center line of a bundle at a bend, and of each lone corner. The default is 12. */
136
+ readonly radius: number;
137
+ /** The smallest radius of a lane at a bend of a bundle. The default is 4. */
138
+ readonly minRadius: number;
139
+ /** The shape of the S curve where a lane changes its offset. The default is `biarc`. */
140
+ readonly jog: JogStyle;
141
+ /** The number of cubic pieces of a `quintic` S curve. The default is 4. */
142
+ readonly jogPieces: number;
143
+ }
144
+
145
+ /** The options of the curve strategy `smooth`. */
146
+ export interface SmoothStrategyOptions {
147
+ /** The radius of the arc of each corner. The default is 12. */
148
+ readonly radius: number;
149
+ /** The share of the turn of a corner in its two spirals, from 0 to 1. The default is 0.6. */
150
+ readonly smoothing: number;
151
+ }
152
+
153
+ /** The options of the curve strategy `clothoid`. */
154
+ export interface ClothoidStrategyOptions {
155
+ /** The radius of the arc of each corner. The default is 12. */
156
+ readonly radius: number;
157
+ /** The share of the turn of a corner in its two clothoids, from 0 to 1. The default is 0.5. */
158
+ readonly spiral: number;
159
+ /** The largest distance from the cubic pieces to the true clothoid. The default is 0.25. */
160
+ readonly tolerance: number;
161
+ }
162
+
163
+ /**
164
+ * The built-in curve strategies. The first six are in the order of `CURVE_TYPES`, and each one gives the same segments as `curveOf` with its type.
165
+ * The strategy `highway` gives the curve of `arc` for one polyline. `highwayCurves` bends the lanes of a bundle as one.
166
+ */
119
167
  export const curveStrategies: {
120
168
  readonly straight: Strategy<"curve", object>;
121
169
  readonly rounded: Strategy<"curve", RadiusOptions>;
122
170
  readonly spline: Strategy<"curve", TensionOptions>;
123
171
  readonly arc: Strategy<"curve", RadiusOptions>;
172
+ readonly smooth: Strategy<"curve", SmoothStrategyOptions>;
173
+ readonly clothoid: Strategy<"curve", ClothoidStrategyOptions>;
174
+ readonly highway: Strategy<"curve", HighwayStrategyOptions>;
124
175
  } = Object.freeze({
125
- straight: defineStrategy({ step: "curve", name: "straight", describe: "The polyline as it is.", defaults: {}, run: (input: CurveInput) => straightCurve(input.points) }),
176
+ straight: defineStrategy({ step: "curve", name: "straight", describe: "The polyline as it is.", defaults: {}, schema: {}, run: (input: CurveInput) => straightCurve(input.points) }),
126
177
  rounded: defineStrategy({
127
178
  step: "curve",
128
179
  name: "rounded",
129
180
  describe: "A quadratic Bezier curve at each corner, as `roundCorners` draws it. The first and the last legs stay.",
130
181
  defaults: { radius: 12 },
182
+ schema: RADIUS_SCHEMA,
131
183
  run: (input: CurveInput, o: Readonly<RadiusOptions>) => roundedCurve(input.points, { ...contextOf(input), radius: o.radius } satisfies RoundedOptions),
132
184
  }),
133
185
  spline: defineStrategy({
@@ -135,6 +187,7 @@ export const curveStrategies: {
135
187
  name: "spline",
136
188
  describe: "A centripetal Catmull-Rom spline through each point. It does not overshoot or make loops in a piece.",
137
189
  defaults: { tension: 0 },
190
+ schema: TENSION_SCHEMA,
138
191
  run: (input: CurveInput, o: Readonly<TensionOptions>) => splineCurve(input.points, { ...contextOf(input), tension: o.tension } satisfies SplineOptions),
139
192
  }),
140
193
  arc: defineStrategy({
@@ -142,35 +195,86 @@ export const curveStrategies: {
142
195
  name: "arc",
143
196
  describe: "A circular arc at each corner, tangent to the two legs. The first and the last legs stay.",
144
197
  defaults: { radius: 12 },
198
+ schema: RADIUS_SCHEMA,
145
199
  run: (input: CurveInput, o: Readonly<RadiusOptions>) => arcCurve(input.points, { ...contextOf(input), radius: o.radius } satisfies ArcOptions),
146
200
  }),
201
+ smooth: defineStrategy({
202
+ step: "curve",
203
+ name: "smooth",
204
+ describe: "A cubic spiral, an arc and a second cubic spiral at each corner, as the corner smoothing of Figma. The curvature has no jump.",
205
+ defaults: { radius: 12, smoothing: 0.6 },
206
+ schema: SMOOTH_SCHEMA,
207
+ run: (input: CurveInput, o: Readonly<SmoothStrategyOptions>) =>
208
+ smoothCurve(input.points, { ...(input.space ? { space: input.space } : {}), radius: o.radius, smoothing: o.smoothing } satisfies SmoothOptions),
209
+ }),
210
+ clothoid: defineStrategy({
211
+ step: "curve",
212
+ name: "clothoid",
213
+ describe: "A clothoid, an arc and a second clothoid at each corner, as a transition curve of a road. Cubic pieces draw each clothoid.",
214
+ defaults: { radius: 12, spiral: 0.5, tolerance: 0.25 },
215
+ schema: CLOTHOID_SCHEMA,
216
+ run: (input: CurveInput, o: Readonly<ClothoidStrategyOptions>) =>
217
+ clothoidCurve(input.points, {
218
+ ...(input.space ? { space: input.space } : {}),
219
+ radius: o.radius,
220
+ spiral: o.spiral,
221
+ tolerance: o.tolerance,
222
+ } satisfies ClothoidOptions),
223
+ }),
224
+ highway: defineStrategy({
225
+ step: "curve",
226
+ name: "highway",
227
+ describe: "The lanes of a bundle bend as one, with concentric arcs at the lane spacing. One lane alone gets the curve of `arc`.",
228
+ defaults: { radius: 12, minRadius: 4, jog: "biarc" as JogStyle, jogPieces: 4 },
229
+ schema: HIGHWAY_SCHEMA,
230
+ run: (input: CurveInput, o: Readonly<HighwayStrategyOptions>) => highwayCurve(input.points, { radius: o.radius, ...(input.space ? { space: input.space } : {}) }),
231
+ }),
147
232
  });
148
233
 
149
234
  /** The options of a room strategy: the options of `roomDemand`, without the method. */
150
235
  export type RoomPreset = Omit<RoomOptions, "method">;
151
236
 
152
237
  const roomStrategy = (method: RoomMethod, describe: string): Strategy<"room", RoomPreset> =>
153
- defineStrategy<"room", RoomPreset>({ step: "room", name: method, describe, defaults: {}, run: (input, o) => roomDemand(input.items, input.links, { ...o, method }) });
238
+ defineStrategy<"room", RoomPreset>({ step: "room", name: method, describe, defaults: {}, schema: ROOM_SCHEMA, run: (input, o) => roomDemand(input.items, input.links, { ...o, method }) });
239
+
240
+ /** The options of the room strategy `levels`: the options of `levelRoom` without the method, and the limits of the quadtree. */
241
+ export type LevelRoomPreset = Omit<LevelRoomOptions, "method"> & SectionOptions;
154
242
 
155
- /** The built-in room strategies: the methods of `roomDemand`. */
243
+ /** The built-in room strategies: the methods of `roomDemand`, and the room of each zoom level (`levelRoom`). */
156
244
  export const roomStrategies: {
157
245
  readonly routes: Strategy<"room", RoomPreset>;
158
246
  readonly lines: Strategy<"room", RoomPreset>;
247
+ readonly levels: Strategy<"room", LevelRoomPreset>;
159
248
  } = Object.freeze({
160
249
  routes: roomStrategy("routes", "The lanes of a gap are the most routes that run along it side by side, with each gap open to the gutter."),
161
250
  lines: roomStrategy("lines", "The lanes of a gap are the straight lines between the centers of the boxes that cross the gap."),
251
+ levels: defineStrategy<"room", LevelRoomPreset>({
252
+ step: "room",
253
+ name: "levels",
254
+ describe: "Each zoom level gets the gaps that its links need. The far levels open first, and a box moves with its parent.",
255
+ defaults: {},
256
+ schema: LEVEL_ROOM_SCHEMA,
257
+ run: (input, o) => {
258
+ const { cap, maxDepth, collapse, ...room } = o;
259
+ const root = input.sections ?? buildSections(input.items, { cap: cap ?? SECTION_DEFAULTS.cap, maxDepth: maxDepth ?? SECTION_DEFAULTS.maxDepth, collapse: collapse ?? SECTION_DEFAULTS.collapse });
260
+ return levelRoom(input.items, input.links, root, room).gaps;
261
+ },
262
+ }),
162
263
  });
163
264
 
164
265
  /** 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
266
  export const groupStrategies: {
166
267
  readonly quadtree: Strategy<"group", Required<SectionOptions>>;
167
268
  readonly keys: Strategy<"group", KeyOptions>;
269
+ readonly kd: Strategy<"group", Required<KdOptions>>;
270
+ readonly cut: Strategy<"group", Required<CutOptions>>;
168
271
  } = Object.freeze({
169
272
  quadtree: defineStrategy({
170
273
  step: "group",
171
274
  name: "quadtree",
172
275
  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
276
  defaults: { ...SECTION_DEFAULTS },
277
+ schema: SECTION_SCHEMA,
174
278
  run: (graph: Graph, o: Readonly<Required<SectionOptions>>) => quadtreeGroups(graph, o),
175
279
  }),
176
280
  keys: defineStrategy({
@@ -178,8 +282,25 @@ export const groupStrategies: {
178
282
  name: "keys",
179
283
  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
284
  defaults: { key: byDataGroup } as KeyOptions,
285
+ schema: KEY_SCHEMA,
181
286
  run: (graph: Graph, o: Readonly<KeyOptions>) => keyGroups(graph, o),
182
287
  }),
288
+ kd: defineStrategy({
289
+ step: "group",
290
+ name: "kd",
291
+ describe: "The cells of a k-d tree of the plane. A cell with more than `cap` nodes splits in two with a line across one axis, and each half splits again.",
292
+ defaults: { ...KD_DEFAULTS },
293
+ schema: KD_SCHEMA,
294
+ run: (graph: Graph, o: Readonly<Required<KdOptions>>) => kdGroups(graph, o),
295
+ }),
296
+ cut: defineStrategy({
297
+ step: "group",
298
+ name: "cut",
299
+ describe: "A guillotine cut that reads the edges. Each line goes where it cuts the fewest edges for its balance, so the far view has fewer links.",
300
+ defaults: { ...CUT_DEFAULTS },
301
+ schema: CUT_SCHEMA,
302
+ run: (graph: Graph, o: Readonly<Required<CutOptions>>) => cutGroups(graph, o),
303
+ }),
183
304
  });
184
305
 
185
306
  /** The built-in strategies of each step, in order. A consumer adds its own: `createRegistry([...BUILT_IN_STRATEGIES, mine])`. */
@@ -1,2 +1,3 @@
1
1
  export * from "./model.ts";
2
2
  export * from "./builtins.ts";
3
+ export * from "./options.ts";
@@ -4,6 +4,7 @@ import type { Point } from "../geom.ts";
4
4
  import type { Graph, Result, Router } from "../model.ts";
5
5
  import type { Boxed, Gap, RoomLink } from "../room/index.ts";
6
6
  import type { Section } from "../sections.ts";
7
+ import type { OptionSchema } from "./options.ts";
7
8
 
8
9
  /** The input of a curve strategy: one polyline and the context of its route. */
9
10
  export interface CurveInput {
@@ -21,6 +22,11 @@ export interface CurveInput {
21
22
  export interface RoomInput {
22
23
  readonly items: readonly Boxed[];
23
24
  readonly links: readonly RoomLink[];
25
+ /**
26
+ * The tree of sections of the items, for a strategy that gives room to each zoom level. The indices of its nodes are the indices of `items`.
27
+ * Without it, such a strategy makes the quadtree of `buildSections`. The other strategies do not read it.
28
+ */
29
+ readonly sections?: Section;
24
30
  }
25
31
 
26
32
  /**
@@ -63,6 +69,8 @@ export interface Strategy<S extends Step = Step, O extends object = object> {
63
69
  readonly describe: string;
64
70
  /** The values of the options when the caller gives no value. */
65
71
  readonly defaults: Readonly<O>;
72
+ /** The declarations of the options: the type, the range, the step and the meaning of each one. A strategy without it declares nothing. */
73
+ readonly schema?: OptionSchema<O>;
66
74
  /** The benchmark skips an input with more nodes than this. Without it, the strategy takes each input. */
67
75
  readonly maxNodes?: number;
68
76
  /** This method does the step. Each option that `options` does not give gets its value from `defaults`. */
@@ -79,23 +87,33 @@ export interface StrategyDefinition<S extends Step, O extends object> {
79
87
  readonly describe: string;
80
88
  /** These values are the options when the caller gives no value. Give `{}` for a strategy without options. */
81
89
  readonly defaults: O;
90
+ /** These declarations tell the type, the range, the step and the meaning of each option. `checkSchema` examines them. */
91
+ readonly schema?: NoInfer<OptionSchema<O>>;
82
92
  readonly maxNodes?: number;
83
93
  /** This function does the step. It gets each option, from the caller or from `defaults`. */
84
94
  run(input: StepInput<S>, options: Readonly<O>): StepOutput<S>;
85
95
  }
86
96
 
97
+ /** This function gives a frozen copy of a schema, with each declaration frozen. */
98
+ function freezeSchema<O>(schema: OptionSchema<O>): OptionSchema<O> {
99
+ const out: Record<string, unknown> = {};
100
+ for (const [key, d] of Object.entries(schema)) out[key] = Object.freeze({ ...(d as object) });
101
+ return Object.freeze(out) as OptionSchema<O>;
102
+ }
103
+
87
104
  /**
88
105
  * This function makes a strategy from its definition. The strategy fills the missing options from `defaults`.
89
106
  * When the caller gives no option, the definition gets the `defaults` object itself.
90
107
  */
91
108
  export function defineStrategy<S extends Step, O extends object>(definition: StrategyDefinition<S, O>): Strategy<S, O> {
92
- const { step, name, describe, defaults, maxNodes } = definition;
109
+ const { step, name, describe, defaults, schema, maxNodes } = definition;
93
110
  if (name.length === 0) throw new Error(`A strategy of the step "${step}" has no name.`);
94
111
  const strategy: Strategy<S, O> = {
95
112
  step,
96
113
  name,
97
114
  describe,
98
115
  defaults: Object.freeze({ ...defaults }),
116
+ ...(schema === undefined ? {} : { schema: freezeSchema(schema) }),
99
117
  ...(maxNodes === undefined ? {} : { maxNodes }),
100
118
  run(input: StepInput<S>, options?: Partial<O>): StepOutput<S> {
101
119
  const all = options === undefined || Object.keys(options).length === 0 ? strategy.defaults : { ...strategy.defaults, ...options };
@@ -0,0 +1,296 @@
1
+ import type { Step, Strategy } from "./model.ts";
2
+
3
+ /** The scale of a number option. A sweep on the scale `log` gives values with equal ratios, not with equal differences. */
4
+ export type OptionScale = "linear" | "log";
5
+
6
+ /** The fields of each option declaration. */
7
+ interface OptionBase {
8
+ /** One short sentence that tells what the option sets. The site and the documents show it. */
9
+ readonly describe: string;
10
+ }
11
+
12
+ /**
13
+ * The declaration of a number option. The range from `min` to `max` is the range of a control and of a sweep.
14
+ * The strategy can take a value outside the range. `checkOptions` reports such a value, and `clampOptions` moves it into the range.
15
+ */
16
+ export interface NumberOption extends OptionBase {
17
+ /** The type `integer` takes only whole numbers. */
18
+ readonly type: "number" | "integer";
19
+ readonly min: number;
20
+ readonly max: number;
21
+ /** The grain of a control and of a sweep. A value off the step is valid. Without it, the grain is free (or 1 for an integer). */
22
+ readonly step?: number;
23
+ /** The scale of a control and of a sweep. The default is `linear`. The scale `log` needs a `min` above 0. */
24
+ readonly scale?: OptionScale;
25
+ /** These values are valid outside the range. For example, `Infinity` sets no limit. */
26
+ readonly special?: readonly number[];
27
+ /** These values are the notable values of the option: the default and the values of the sweeps of the benchmark. */
28
+ readonly marks?: readonly number[];
29
+ /** The strategy uses this value when the caller and `defaults` give no value. */
30
+ readonly default?: number;
31
+ }
32
+
33
+ /** The declaration of a boolean option. */
34
+ export interface BooleanOption extends OptionBase {
35
+ readonly type: "boolean";
36
+ /** The strategy uses this value when the caller and `defaults` give no value. */
37
+ readonly default?: boolean;
38
+ }
39
+
40
+ /** The declaration of an option with a fixed list of string values. */
41
+ export interface EnumOption<V extends string = string> extends OptionBase {
42
+ readonly type: "enum";
43
+ /** The values of the option, in the order of a control. */
44
+ readonly values: readonly V[];
45
+ /** The strategy uses this value when the caller and `defaults` give no value. */
46
+ readonly default?: V;
47
+ }
48
+
49
+ /**
50
+ * The declaration of an option that a control or a sweep cannot set from a range.
51
+ * The type `function` is a rule, `strategy` is a strategy of the step `step`, `list` is an array and `object` is any other object.
52
+ */
53
+ export interface ValueOption<V = unknown> extends OptionBase {
54
+ readonly type: "function" | "strategy" | "list" | "object";
55
+ /** The step of the strategy, for the type `strategy`. A control lists the strategies of this step of a registry. */
56
+ readonly step?: Step;
57
+ /** The strategy uses this value when the caller and `defaults` give no value. */
58
+ readonly default?: V;
59
+ }
60
+
61
+ /** The declaration of one option of any type. */
62
+ export type AnyOption = NumberOption | BooleanOption | EnumOption | ValueOption;
63
+
64
+ /** The declaration that fits the type of an option value: numbers, booleans, string unions or other values. */
65
+ export type OptionDeclaration<V> = [V] extends [number] ? NumberOption : [V] extends [boolean] ? BooleanOption : [V] extends [string] ? EnumOption<V> : ValueOption<V>;
66
+
67
+ /** The declarations of the options of a strategy. The schema has one declaration for each key of the options type, and no other key. */
68
+ export type OptionSchema<O> = { readonly [K in keyof O]-?: OptionDeclaration<NonNullable<O[K]>> };
69
+
70
+ /** One problem of an option value against its declaration. */
71
+ export interface OptionIssue {
72
+ /** The key of the option. */
73
+ readonly key: string;
74
+ readonly value: unknown;
75
+ /** The kind of the problem: no declaration, the wrong type, out of the range, not a whole number or not one of the values. */
76
+ readonly problem: "unknown" | "type" | "range" | "integer" | "value";
77
+ /** One sentence that tells the problem. */
78
+ readonly message: string;
79
+ }
80
+
81
+ /** One setting of a sweep: a name for the rows of a table, and the options of the strategy. */
82
+ export interface OptionSetting<O extends object> {
83
+ /** The key and the value of each option of the setting, for example `stretch 1.5`. */
84
+ readonly name: string;
85
+ readonly options: Partial<O>;
86
+ }
87
+
88
+ /** The options of `sweepValues`, `sweepOf` and `sweepGrid`. */
89
+ export interface SweepOptions {
90
+ /**
91
+ * The number of values on the range of a number option. Without it, the sweep gives the `marks` of the declaration.
92
+ * A declaration without `marks` gets 5 values.
93
+ */
94
+ readonly count?: number;
95
+ /** With `true`, a sweep of a number option on the range also gives the `special` values. The default is `false`. */
96
+ readonly special?: boolean;
97
+ }
98
+
99
+ /** The number of values of a sweep on a range when the call and the declaration give no number. */
100
+ const SWEEP_COUNT = 5;
101
+
102
+ /** This function gives the declarations of a strategy as a record. A strategy without a schema gives an empty record. */
103
+ function schemaOf(strategy: Strategy<Step, object>): Readonly<Record<string, AnyOption>> {
104
+ return (strategy.schema ?? {}) as Readonly<Record<string, AnyOption>>;
105
+ }
106
+
107
+ /** This function gives the declared options of a strategy, in the order of its schema, with the effective default of each one. */
108
+ export function listOptions<S extends Step, O extends object>(
109
+ strategy: Strategy<S, O>,
110
+ ): { readonly key: keyof O & string; readonly declaration: AnyOption; readonly value: unknown }[] {
111
+ const defaults = optionDefaults(strategy) as Record<string, unknown>;
112
+ return Object.entries(schemaOf(strategy)).map(([key, declaration]) => ({ key: key as keyof O & string, declaration, value: defaults[key] }));
113
+ }
114
+
115
+ /**
116
+ * This function gives the effective default of each declared option: the value of `defaults`, else the `default` of the declaration.
117
+ * An option without either value stays out. The strategy gives the same output with this object as with no options.
118
+ */
119
+ export function optionDefaults<S extends Step, O extends object>(strategy: Strategy<S, O>): Partial<O> {
120
+ const out: Record<string, unknown> = {};
121
+ const defaults = strategy.defaults as Readonly<Record<string, unknown>>;
122
+ for (const [key, declaration] of Object.entries(schemaOf(strategy))) {
123
+ if (key in defaults) out[key] = defaults[key];
124
+ else if (declaration.default !== undefined) out[key] = declaration.default;
125
+ }
126
+ for (const key of Object.keys(defaults)) if (!(key in out)) out[key] = defaults[key];
127
+ return out as Partial<O>;
128
+ }
129
+
130
+ /** This function gives `true` if the number is one of the special values of the declaration. */
131
+ const isSpecial = (d: NumberOption, v: number): boolean => (d.special ?? []).some((s) => Object.is(s, v) || s === v);
132
+
133
+ /** This function gives the problem of one value against its declaration, or `undefined` when the value fits. */
134
+ function issueOf(key: string, value: unknown, d: AnyOption): OptionIssue | undefined {
135
+ const issue = (problem: OptionIssue["problem"], message: string): OptionIssue => ({ key, value, problem, message });
136
+ if (value === undefined) return undefined;
137
+ switch (d.type) {
138
+ case "number":
139
+ case "integer": {
140
+ if (typeof value !== "number" || Number.isNaN(value)) return issue("type", `The option "${key}" takes a number.`);
141
+ if (isSpecial(d, value)) return undefined;
142
+ if (value < d.min || value > d.max) return issue("range", `The option "${key}" is ${value}, out of the range ${d.min} to ${d.max}.`);
143
+ if (d.type === "integer" && !Number.isInteger(value)) return issue("integer", `The option "${key}" takes a whole number, not ${value}.`);
144
+ return undefined;
145
+ }
146
+ case "boolean":
147
+ return typeof value === "boolean" ? undefined : issue("type", `The option "${key}" takes true or false.`);
148
+ case "enum":
149
+ return d.values.includes(value as string) ? undefined : issue("value", `The option "${key}" takes ${d.values.join(", ")}, not ${String(value)}.`);
150
+ case "function":
151
+ return typeof value === "function" ? undefined : issue("type", `The option "${key}" takes a function.`);
152
+ case "list":
153
+ return Array.isArray(value) ? undefined : issue("type", `The option "${key}" takes a list.`);
154
+ default:
155
+ return typeof value === "object" && value !== null ? undefined : issue("type", `The option "${key}" takes an object.`);
156
+ }
157
+ }
158
+
159
+ /**
160
+ * This function examines option values against the schema of a strategy. It gives one issue for each key without a declaration.
161
+ * It also gives one issue for each value of the wrong type, out of the range, not whole or not one of the values.
162
+ * It changes no value.
163
+ * A strategy without a schema gives no issue.
164
+ */
165
+ export function checkOptions<S extends Step, O extends object>(strategy: Strategy<S, O>, options: Partial<O>): OptionIssue[] {
166
+ if (strategy.schema === undefined) return [];
167
+ const schema = schemaOf(strategy);
168
+ const out: OptionIssue[] = [];
169
+ for (const [key, value] of Object.entries(options)) {
170
+ const d = schema[key];
171
+ const issue = d ? issueOf(key, value, d) : { key, value, problem: "unknown" as const, message: `The strategy "${strategy.name}" has no option "${key}".` };
172
+ if (issue) out.push(issue);
173
+ }
174
+ return out;
175
+ }
176
+
177
+ /**
178
+ * This function moves each number option into the range of its declaration, for a control. An integer option gets the nearest whole number.
179
+ * A special value stays. The function keeps each other value as it is. `checkOptions` reports the values that this function cannot correct.
180
+ */
181
+ export function clampOptions<S extends Step, O extends object>(strategy: Strategy<S, O>, options: Partial<O>): Partial<O> {
182
+ const schema = schemaOf(strategy);
183
+ const out: Record<string, unknown> = { ...options };
184
+ for (const [key, value] of Object.entries(options)) {
185
+ const d = schema[key];
186
+ if (!d || (d.type !== "number" && d.type !== "integer") || typeof value !== "number" || Number.isNaN(value) || isSpecial(d, value)) continue;
187
+ const v = Math.min(d.max, Math.max(d.min, value));
188
+ out[key] = d.type === "integer" ? Math.min(d.max, Math.max(d.min, Math.round(v))) : v;
189
+ }
190
+ return out as Partial<O>;
191
+ }
192
+
193
+ /** This function gives `count` values from `min` to `max` on the scale, on the grain of the step, without repeats. */
194
+ function rangeValues(d: NumberOption, count: number): number[] {
195
+ const n = Math.max(1, Math.floor(count));
196
+ const log = d.scale === "log" && d.min > 0;
197
+ const at = (t: number): number => (log ? d.min * (d.max / d.min) ** t : d.min + (d.max - d.min) * t);
198
+ const step = d.step ?? (d.type === "integer" ? 1 : undefined);
199
+ const snap = (v: number): number => {
200
+ const s = step === undefined ? v : d.min + Math.round((v - d.min) / step) * step;
201
+ const r = Number(s.toPrecision(12));
202
+ return Math.min(d.max, Math.max(d.min, d.type === "integer" ? Math.round(r) : r));
203
+ };
204
+ const out: number[] = [];
205
+ for (let i = 0; i < n; i++) {
206
+ const v = snap(at(n === 1 ? 0 : i / (n - 1)));
207
+ if (!out.includes(v)) out.push(v);
208
+ }
209
+ return out;
210
+ }
211
+
212
+ /**
213
+ * This function gives the values of a sweep of one option declaration.
214
+ * A number option gives its `marks`, or `count` values on its range and its scale. A boolean gives `false` and `true`. An enum gives its values.
215
+ * An option of another type has no sweep, so the function throws an error.
216
+ */
217
+ export function sweepValues(declaration: AnyOption, options: SweepOptions = {}): unknown[] {
218
+ switch (declaration.type) {
219
+ case "number":
220
+ case "integer": {
221
+ if (options.count === undefined && declaration.marks !== undefined) return [...declaration.marks];
222
+ const values = rangeValues(declaration, options.count ?? SWEEP_COUNT);
223
+ return options.special ? [...values, ...(declaration.special ?? []).filter((s) => !values.includes(s))] : values;
224
+ }
225
+ case "boolean":
226
+ return [false, true];
227
+ case "enum":
228
+ return [...declaration.values];
229
+ default:
230
+ throw new Error(`An option of the type "${declaration.type}" has no sweep. A sweep takes a number, an integer, a boolean or an enum.`);
231
+ }
232
+ }
233
+
234
+ /** This function gives the declaration of one option of a strategy. When the strategy has no such declaration, the function throws an error. */
235
+ function declarationOf(strategy: Strategy<Step, object>, key: string): AnyOption {
236
+ const d = schemaOf(strategy)[key];
237
+ if (!d) throw new Error(`The strategy "${strategy.name}" has no declaration of the option "${key}".`);
238
+ return d;
239
+ }
240
+
241
+ /**
242
+ * This function gives the settings of a sweep of one option of a strategy, from its declaration only.
243
+ * The name of each setting is the key and the value, for example `stretch 1.5`.
244
+ */
245
+ export function sweepOf<S extends Step, O extends object>(strategy: Strategy<S, O>, key: keyof O & string, options: SweepOptions = {}): OptionSetting<O>[] {
246
+ return sweepValues(declarationOf(strategy, key), options).map((value) => ({ name: `${key} ${String(value)}`, options: { [key]: value } as Partial<O> }));
247
+ }
248
+
249
+ /**
250
+ * This function gives the settings of a grid of options: each combination of the values of the sweeps of the keys.
251
+ * The name of a setting joins the names of its values with commas, in the order of the keys.
252
+ */
253
+ export function sweepGrid<S extends Step, O extends object>(strategy: Strategy<S, O>, keys: readonly (keyof O & string)[], options: SweepOptions = {}): OptionSetting<O>[] {
254
+ let out: OptionSetting<O>[] = [{ name: "", options: {} }];
255
+ for (const key of keys) {
256
+ const sweep = sweepOf(strategy, key, options);
257
+ out = out.flatMap((s) => sweep.map((t) => ({ name: s.name === "" ? t.name : `${s.name}, ${t.name}`, options: { ...s.options, ...t.options } })));
258
+ }
259
+ return keys.length === 0 ? [] : out;
260
+ }
261
+
262
+ /** This function gives the problems of one declaration: a bad range, a bad step, or marks and a default that do not fit. */
263
+ function declarationIssues(key: string, d: AnyOption): string[] {
264
+ const out: string[] = [];
265
+ if (d.describe.trim().length === 0) out.push(`The option "${key}" has no description.`);
266
+ if (d.type === "number" || d.type === "integer") {
267
+ if (!Number.isFinite(d.min) || !Number.isFinite(d.max) || d.min > d.max) out.push(`The option "${key}" has the bad range ${d.min} to ${d.max}.`);
268
+ if (d.type === "integer" && (!Number.isInteger(d.min) || !Number.isInteger(d.max))) out.push(`The integer option "${key}" has a range that is not whole.`);
269
+ if (d.step !== undefined && !(d.step > 0)) out.push(`The option "${key}" has the step ${d.step}. A step is more than 0.`);
270
+ if (d.scale === "log" && !(d.min > 0)) out.push(`The option "${key}" has the scale log and the min ${d.min}. The scale log needs a min above 0.`);
271
+ for (const v of [...(d.marks ?? []), ...(d.default === undefined ? [] : [d.default])]) {
272
+ const issue = issueOf(key, v, d);
273
+ if (issue) out.push(issue.message);
274
+ }
275
+ } else if (d.type === "enum") {
276
+ if (d.values.length === 0) out.push(`The enum option "${key}" has no values.`);
277
+ if (d.default !== undefined && !d.values.includes(d.default)) out.push(`The default of the option "${key}" is not one of its values.`);
278
+ } else if (d.type === "boolean") {
279
+ if (d.default !== undefined && typeof d.default !== "boolean") out.push(`The default of the option "${key}" is not true or false.`);
280
+ }
281
+ return out;
282
+ }
283
+
284
+ /**
285
+ * This function examines the schema of a strategy. It gives one message for each option of `defaults` without a declaration.
286
+ * It also gives one message for each bad declaration, and for each value of `defaults` that does not fit its declaration.
287
+ * A good schema gives an empty list.
288
+ */
289
+ export function checkSchema(strategy: Strategy<Step, object>): string[] {
290
+ const schema = schemaOf(strategy);
291
+ const out: string[] = [];
292
+ for (const key of Object.keys(strategy.defaults)) if (!(key in schema)) out.push(`The strategy "${strategy.name}" has the option "${key}" in its defaults, without a declaration.`);
293
+ for (const [key, d] of Object.entries(schema)) out.push(...declarationIssues(key, d).map((m) => `The strategy "${strategy.name}": ${m}`));
294
+ out.push(...checkOptions(strategy, strategy.defaults).filter((i) => i.problem !== "unknown").map((i) => `The strategy "${strategy.name}": ${i.message}`));
295
+ return out;
296
+ }