@mark1russell7/site-pages 0.2.0 → 0.2.1

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.
@@ -0,0 +1,1342 @@
1
+ import { h } from "../chrome.js";
2
+ import { heldOutError, parseResponseRecord, quantile, sliceResponse, surfaceResponse } from "@mark1russell7/rigor-statistics";
3
+ // src/explorer/model.ts
4
+ /**
5
+ * The pure core of the response explorer: the state, the rules of the controls and the data of a view. This module
6
+ * reads a record of the format `response-records/1` only with `@mark1russell7/rigor-statistics`. It has no DOM and no
7
+ * clock of its own, so a test gives it each input.
8
+ *
9
+ * The rules of the controls follow the rules of a conditional dimension. The functions `axisChoices`, `facetChoices`
10
+ * and `holdControls` offer only combinations that `sliceResponse` and `surfaceResponse` accept. The function `settle`
11
+ * gives the nearest good state for any wanted state.
12
+ */
13
+ /** The largest number of values on a continuous axis. It is the default of `ResponseViewOptions.steps`. */
14
+ const MAX_STEPS = 33;
15
+ /** The smallest number of values on a continuous axis. A smaller grid shows too little of the response. */
16
+ const MIN_STEPS = 5;
17
+ /**
18
+ * The largest number of point reads of one view. The field `nearest` of each place reads each point, so the cost of a
19
+ * view is its number of places times the number of points. This number is a first bound, not a measured time.
20
+ * `SURFACE_BUDGET_MS` corrects the grid when a surface takes too long on the device.
21
+ */
22
+ const READ_BUDGET = 4e5;
23
+ /** The time of one surface in milliseconds. A longer surface gives a smaller grid for the next surface. */
24
+ const SURFACE_BUDGET_MS = 50;
25
+ /** The largest number of panels of one view. */
26
+ const MAX_PANELS = 8;
27
+ /** The distance from a measurement, from 0 to 1, at which a band has its weakest color. */
28
+ const FADE_END = .5;
29
+ /**
30
+ * This function reads a record from JSON text or from parsed data. It does not throw: bad data gives faults with
31
+ * their paths.
32
+ */
33
+ function openRecord(value) {
34
+ if (typeof value !== "string") return parseResponseRecord(value);
35
+ try {
36
+ return parseResponseRecord(JSON.parse(value));
37
+ } catch (error) {
38
+ return {
39
+ ok: false,
40
+ faults: [{
41
+ path: "",
42
+ message: `this text is not JSON: ${String(error)}.`
43
+ }]
44
+ };
45
+ }
46
+ }
47
+ const numberText = new Intl.NumberFormat("en-US", { maximumSignificantDigits: 3 });
48
+ /** This function gives the text of a value: a number with three significant digits, or the text of a level. */
49
+ function formatValue(value) {
50
+ if (value === null) return "none";
51
+ return typeof value === "number" ? numberText.format(value) : value;
52
+ }
53
+ /** This function gives the dimension with an id that the state or the record names. */
54
+ function dimensionOf(record, id) {
55
+ const dimension = record.dimensions.find((d) => d.id === id);
56
+ if (dimension === void 0) throw new RangeError(`The record has no dimension "${id}".`);
57
+ return dimension;
58
+ }
59
+ /** This function gives the measured values of a dimension, with no `null`. */
60
+ function measuredValues(record, id) {
61
+ return record.points.flatMap((p) => {
62
+ const value = p.values[id];
63
+ return value === null || value === void 0 ? [] : [value];
64
+ });
65
+ }
66
+ const stopCache = /* @__PURE__ */ new WeakMap();
67
+ /** This function gives the distinct measured values of a continuous dimension, in order. */
68
+ function distinctNumbers(record, id) {
69
+ const numbers = measuredValues(record, id).filter((v) => typeof v === "number");
70
+ return [...new Set(numbers)].sort((a, b) => a - b);
71
+ }
72
+ /**
73
+ * This function gives the values that a visitor can hold: each level, or the measured values of a continuous dimension.
74
+ * With more than `MAX_STOPS` measured values, it gives that number of quantiles of them.
75
+ */
76
+ function stopsOf(record, dimension) {
77
+ if (dimension.kind !== "continuous") return dimension.levels;
78
+ let stops = stopCache.get(record);
79
+ if (stops === void 0) stopCache.set(record, stops = /* @__PURE__ */ new Map());
80
+ const known = stops.get(dimension.id);
81
+ if (known !== void 0) return known;
82
+ const distinct = distinctNumbers(record, dimension.id);
83
+ const thin = () => [...new Set(Array.from({ length: 33 }, (_, i) => quantile(distinct, i / 32, "nearest-rank")))];
84
+ const found = distinct.length === 0 ? [dimension.range[0]] : distinct.length > 33 ? thin() : distinct;
85
+ stops.set(dimension.id, found);
86
+ return found;
87
+ }
88
+ /** This function gives the place of a number on a continuous dimension, from 0 to 1. */
89
+ function shareOf(dimension, value) {
90
+ const scale = (v) => dimension.log === true ? Math.log10(v) : v;
91
+ const [low, high] = dimension.range;
92
+ return (scale(value) - scale(low)) / (scale(high) - scale(low));
93
+ }
94
+ /**
95
+ * This function gives the option `tolerance` of a view. A dimension with all its measured values as stops needs none.
96
+ * A thinned dimension needs half of its largest gap, so each measured point matches a stop.
97
+ */
98
+ function toleranceOf(record) {
99
+ let tolerance = 0;
100
+ for (const dimension of record.dimensions) {
101
+ if (dimension.kind !== "continuous") continue;
102
+ const stops = stopsOf(record, dimension);
103
+ if (stops.length === distinctNumbers(record, dimension.id).length) continue;
104
+ for (let i = 1; i < stops.length; i++) {
105
+ const gap = shareOf(dimension, stops[i]) - shareOf(dimension, stops[i - 1]);
106
+ tolerance = Math.max(tolerance, gap / 2);
107
+ }
108
+ }
109
+ return tolerance;
110
+ }
111
+ /** This function gives the usual value of a dimension: the measured median, or the most common level in `among`. */
112
+ function usualValue(record, dimension, among) {
113
+ const values = measuredValues(record, dimension.id);
114
+ if (dimension.kind === "continuous") {
115
+ const numbers = values.filter((v) => typeof v === "number");
116
+ return numbers.length === 0 ? dimension.range[0] : quantile(numbers, .5, "nearest-rank");
117
+ }
118
+ const levels = among ?? dimension.levels;
119
+ const count = (level) => values.filter((v) => v === level).length;
120
+ return levels.reduce((best, level) => count(level) > count(best) ? level : best, levels[0]);
121
+ }
122
+ /** This function gives the stop of a dimension that is nearest to a value, or `undefined` for a value of no use. */
123
+ function nearestStop(record, dimension, value) {
124
+ const stops = stopsOf(record, dimension);
125
+ if (value === void 0) return void 0;
126
+ if (dimension.kind !== "continuous") return stops.includes(value) ? value : void 0;
127
+ if (typeof value !== "number") return void 0;
128
+ const far = (stop) => Math.abs(shareOf(dimension, stop) - shareOf(dimension, value));
129
+ return stops.reduce((best, stop) => far(stop) < far(best) ? stop : best);
130
+ }
131
+ /** This function gives the levels that the parent of each varied conditional dimension needs. */
132
+ function neededLevels(record, varied) {
133
+ const needed = /* @__PURE__ */ new Map();
134
+ for (const id of varied) {
135
+ const when = dimensionOf(record, id).when;
136
+ if (when === void 0) continue;
137
+ const earlier = needed.get(when.dimension);
138
+ needed.set(when.dimension, earlier === void 0 ? when.levels : earlier.filter((level) => when.levels.includes(level)));
139
+ }
140
+ return needed;
141
+ }
142
+ /** This function gives the condition of a dimension as a phrase of a sentence. */
143
+ function whenText(record, dimension) {
144
+ const when = dimension.when;
145
+ if (when === void 0) return "";
146
+ return `only when ${dimensionOf(record, when.dimension).label} is ${when.levels.map(formatValue).join(" or ")}`;
147
+ }
148
+ const variedOf = (state) => state.y === null ? [state.x] : [state.x, state.y];
149
+ /** This function gives the values of a dimension in panels, inside the needs of the varied dimensions. */
150
+ function panelLevels(record, state, id) {
151
+ const stops = stopsOf(record, dimensionOf(record, id));
152
+ const needed = neededLevels(record, variedOf(state)).get(id);
153
+ return needed === void 0 ? stops : stops.filter((value) => needed.includes(value));
154
+ }
155
+ /** This function gives the reason that a dimension cannot be in panels. A good dimension gives an empty string. */
156
+ function facetBlock(record, state, dimension) {
157
+ if (variedOf(state).includes(dimension.id)) return "It is on an axis";
158
+ const when = dimension.when;
159
+ if (when !== void 0 && !variedOf(state).includes(when.dimension) && !when.levels.includes(state.hold[when.dimension])) return `It applies ${whenText(record, dimension)}`;
160
+ const count = panelLevels(record, state, dimension.id).length;
161
+ if (count > 8) return `It has ${count} values, and a view has 8 panels or fewer`;
162
+ if (count < 2) return "It has one value here";
163
+ return "";
164
+ }
165
+ /** This function gives the reason that a dimension cannot go on an axis. A good dimension gives an empty string. */
166
+ function axisBlock(record, state, slot, dimension) {
167
+ const otherId = slot === "x" ? state.y : state.x;
168
+ if (dimension.id === otherId) return "It is on the other axis";
169
+ const other = otherId === null ? void 0 : dimensionOf(record, otherId);
170
+ if (other?.when?.dimension === dimension.id) return `${other.label} needs it held`;
171
+ const when = dimension.when;
172
+ if (when === void 0) return "";
173
+ const parent = dimensionOf(record, when.dimension);
174
+ if (parent.id === otherId) return `It needs ${parent.label} held`;
175
+ const shared = other?.when?.dimension === parent.id ? when.levels.filter((level) => other.when?.levels.includes(level) === true) : when.levels;
176
+ return (parent.id === state.facet ? stopsOf(record, parent) : [state.hold[parent.id]]).some((level) => shared.includes(level)) ? "" : `It applies ${whenText(record, dimension)}`;
177
+ }
178
+ /** This function gives the first dimension with no condition, and a continuous dimension before another kind. */
179
+ function firstAxis(record) {
180
+ const plain = record.dimensions.filter((d) => d.when === void 0);
181
+ return (plain.find((d) => d.kind === "continuous") ?? plain[0] ?? record.dimensions[0]).id;
182
+ }
183
+ /**
184
+ * This function gives the nearest good state for a wanted state. Each unknown or missing field takes its usual value.
185
+ * A parent moves to a level that a varied conditional dimension needs. A combination that a view refuses loses its
186
+ * second axis or its panels.
187
+ */
188
+ function settle(record, wanted = {}) {
189
+ const known = (id) => id !== null && id !== void 0 && record.dimensions.some((d) => d.id === id);
190
+ const measure = record.measures.some((m) => m.id === wanted.measure) ? wanted.measure : record.measures[0]?.id ?? "";
191
+ const x = known(wanted.x) ? wanted.x : firstAxis(record);
192
+ const parentOf = (id) => dimensionOf(record, id).when?.dimension;
193
+ let y = known(wanted.y) && wanted.y !== x ? wanted.y : null;
194
+ if (y !== null && (parentOf(y) === x || parentOf(x) === y || [...neededLevels(record, [x, y]).values()].some((levels) => levels.length === 0))) y = null;
195
+ const hold = {};
196
+ for (const dimension of record.dimensions) hold[dimension.id] = nearestStop(record, dimension, wanted.hold?.[dimension.id]) ?? usualValue(record, dimension);
197
+ const facetId = known(wanted.facet) ? wanted.facet : null;
198
+ const facet = facetId !== null && facetBlock(record, {
199
+ x,
200
+ y,
201
+ hold
202
+ }, dimensionOf(record, facetId)) === "" ? facetId : null;
203
+ for (const [parent, levels] of neededLevels(record, variedOf({
204
+ x,
205
+ y
206
+ }))) {
207
+ if (parent === facet || levels.includes(hold[parent])) continue;
208
+ hold[parent] = usualValue(record, dimensionOf(record, parent), levels);
209
+ }
210
+ return {
211
+ measure,
212
+ x,
213
+ y,
214
+ facet: facet !== null && facetBlock(record, {
215
+ x,
216
+ y,
217
+ hold
218
+ }, dimensionOf(record, facet)) === "" ? facet : null,
219
+ hold
220
+ };
221
+ }
222
+ /** This function gives each dimension as a choice for an axis. A disabled choice tells its reason. */
223
+ function axisChoices(record, state, slot) {
224
+ return record.dimensions.map((d) => {
225
+ const reason = axisBlock(record, state, slot, d);
226
+ return {
227
+ id: d.id,
228
+ label: d.label,
229
+ disabled: reason !== "",
230
+ reason
231
+ };
232
+ });
233
+ }
234
+ /** This function gives each dimension as a choice for panels. A disabled choice tells its reason. */
235
+ function facetChoices(record, state) {
236
+ return record.dimensions.map((d) => {
237
+ const reason = facetBlock(record, state, d);
238
+ return {
239
+ id: d.id,
240
+ label: d.label,
241
+ disabled: reason !== "",
242
+ reason
243
+ };
244
+ });
245
+ }
246
+ /** This function gives the control of each held dimension. A dimension on an axis or in panels has no control. */
247
+ function holdControls(record, state) {
248
+ const needed = neededLevels(record, variedOf(state));
249
+ return record.dimensions.filter((d) => !variedOf(state).includes(d.id) && d.id !== state.facet).map((d) => {
250
+ const parent = d.when?.dimension;
251
+ const applies = parent === void 0 || parent === state.facet || variedOf(state).includes(parent) || d.when?.levels.includes(state.hold[parent]) === true;
252
+ const levels = needed.get(d.id);
253
+ const options = stopsOf(record, d).map((value) => {
254
+ const locked = levels !== void 0 && !levels.includes(value);
255
+ const reason = !applies ? `It applies ${whenText(record, d)}` : locked ? "A varied dimension has no value at this level" : "";
256
+ return {
257
+ value,
258
+ text: formatValue(value),
259
+ disabled: reason !== "",
260
+ reason
261
+ };
262
+ });
263
+ const note = d.when === void 0 ? "" : `Applies ${whenText(record, d)}`;
264
+ return {
265
+ id: d.id,
266
+ label: d.label,
267
+ kind: d.kind === "continuous" ? "stops" : "levels",
268
+ applies,
269
+ note,
270
+ options,
271
+ value: state.hold[d.id]
272
+ };
273
+ });
274
+ }
275
+ /** This function gives the state with a new measure. */
276
+ function setMeasure(record, state, measure) {
277
+ return settle(record, {
278
+ ...state,
279
+ measure
280
+ });
281
+ }
282
+ /** This function gives the state with a new axis. It does not take a disabled choice. The value `null` removes the y axis. */
283
+ function setAxis(record, state, slot, id) {
284
+ if (id === null) return slot === "y" ? settle(record, {
285
+ ...state,
286
+ y: null
287
+ }) : state;
288
+ if (axisBlock(record, state, slot, dimensionOf(record, id)) !== "") return state;
289
+ return settle(record, {
290
+ ...state,
291
+ [slot]: id,
292
+ facet: state.facet === id ? null : state.facet
293
+ });
294
+ }
295
+ /** This function gives the state with new panels. It does not take a disabled choice. The value `null` gives one panel. */
296
+ function setFacet(record, state, id) {
297
+ if (id !== null && facetBlock(record, state, dimensionOf(record, id)) !== "") return state;
298
+ return settle(record, {
299
+ ...state,
300
+ facet: id
301
+ });
302
+ }
303
+ /** This function gives the state with a new held value. It does not take a disabled value. */
304
+ function setHold(record, state, id, value) {
305
+ const option = holdControls(record, state).find((c) => c.id === id)?.options.find((o) => o.value === value);
306
+ if (option === void 0 || option.disabled) return state;
307
+ return settle(record, {
308
+ ...state,
309
+ hold: {
310
+ ...state.hold,
311
+ [id]: value
312
+ }
313
+ });
314
+ }
315
+ /**
316
+ * This function gives the number of steps of a view, from the number of points, before the call. The field `nearest`
317
+ * reads each point at each place, so the number of places comes from `READ_BUDGET`.
318
+ *
319
+ * @param points - The number of points of the record.
320
+ * @param panels - The number of panels of the view.
321
+ * @param continuous - The number of continuous axes of the view.
322
+ * @param levels - The number of levels of a level axis of a surface, or 1.
323
+ * @param cap - The largest result.
324
+ */
325
+ function planSteps(points, panels, continuous, levels, cap = 33) {
326
+ const places = READ_BUDGET / (Math.max(1, points) * Math.max(1, panels));
327
+ const room = continuous === 2 ? Math.sqrt(places) : places / Math.max(1, levels);
328
+ return Math.max(5, Math.min(cap, 33, Math.floor(room)));
329
+ }
330
+ /** This function gives the cap of the next view: a smaller grid when this surface took more than the budget. */
331
+ function nextStepsCap(steps, elapsedMs, budgetMs) {
332
+ if (elapsedMs <= budgetMs) return Number.POSITIVE_INFINITY;
333
+ return Math.max(5, Math.min(steps - 1, Math.floor(steps * Math.sqrt(budgetMs / elapsedMs))));
334
+ }
335
+ /** This function gives the axis of a dimension. */
336
+ function axisOf(dimension) {
337
+ const { id, label, unit } = dimension;
338
+ const name = unit === "" ? label : `${label} (${unit})`;
339
+ if (dimension.kind === "continuous") return {
340
+ id,
341
+ label: name,
342
+ kind: "number",
343
+ log: dimension.log === true,
344
+ range: dimension.range,
345
+ levels: []
346
+ };
347
+ return {
348
+ id,
349
+ label: name,
350
+ kind: "levels",
351
+ log: false,
352
+ range: [0, 1],
353
+ levels: dimension.levels
354
+ };
355
+ }
356
+ /** This function gives the place of a value on an axis, from 0 to 1. A value outside a number axis goes past the ends. */
357
+ function placeOn(axis, value) {
358
+ if (axis.kind === "levels") return (axis.levels.indexOf(value) + .5) / axis.levels.length;
359
+ const scale = (v) => axis.log ? Math.log10(v) : v;
360
+ const [low, high] = axis.range;
361
+ return (scale(value) - scale(low)) / (scale(high) - scale(low));
362
+ }
363
+ /** This function gives true for a point that matches the held values, with the rules of `sliceResponse`. */
364
+ function pointMatches(record, point, measure, varied, hold, tolerance) {
365
+ if (typeof point.measures[measure] !== "number") return false;
366
+ return record.dimensions.every((d) => {
367
+ const value = point.values[d.id] ?? null;
368
+ if (varied.includes(d.id)) return value !== null;
369
+ const parent = d.when === void 0 ? null : point.values[d.when.dimension] ?? null;
370
+ const wanted = d.when === void 0 || parent !== null && d.when.levels.includes(parent) ? hold[d.id] : null;
371
+ if (typeof value !== "number" || typeof wanted !== "number" || d.kind !== "continuous") return value === wanted;
372
+ return Math.abs(shareOf(d, value) - shareOf(d, wanted)) <= tolerance;
373
+ });
374
+ }
375
+ /** This function gives the error of a measure in plain words. */
376
+ function errorSentence(measure, model, error) {
377
+ const unit = measure.unit === "" ? "" : ` ${measure.unit}`;
378
+ const amount = (value) => model.scale === "log" ? `a factor of ${formatValue(10 ** value)}` : `${formatValue(value)}${unit}`;
379
+ const seen = `On ${error.n} points that each fit did not see, over ${error.folds} folds, the model misses by ${amount(error.mae)} on average.`;
380
+ const base = `A plain mean misses by ${amount(error.baselineMae)}.`;
381
+ if (error.skill === null) return `${seen} ${base}`;
382
+ return error.skill > 0 ? `${seen} ${base} The model removes ${Math.round(error.skill * 100)}% of that miss.` : `${seen} ${base} The model is no better than the mean.`;
383
+ }
384
+ /** This function gives the name of a panel. */
385
+ const panelTitle = (facet, level) => facet === void 0 || level === null ? "" : `${facet.label}: ${formatValue(level)}`;
386
+ /** This function gives the panels of a measure with no model: the measured points that match the held values. */
387
+ function pointPanels(record, state, levels, facet, tolerance) {
388
+ const varied = variedOf(state);
389
+ return levels.map((level) => {
390
+ const hold = level === null || facet === void 0 ? state.hold : {
391
+ ...state.hold,
392
+ [facet.id]: level
393
+ };
394
+ const points = record.points.flatMap((p) => {
395
+ if (!pointMatches(record, p, state.measure, varied, hold, tolerance)) return [];
396
+ return [{
397
+ id: p.id,
398
+ at: varied.map((id) => p.values[id]),
399
+ value: p.measures[state.measure]
400
+ }];
401
+ });
402
+ return {
403
+ kind: "points",
404
+ title: panelTitle(facet, level),
405
+ level,
406
+ points
407
+ };
408
+ });
409
+ }
410
+ /** This function gives the held values of a request: each dimension that does not vary. */
411
+ function requestHold(state, facet, level) {
412
+ const varied = variedOf(state);
413
+ const hold = Object.fromEntries(Object.entries(state.hold).filter(([id]) => !varied.includes(id)));
414
+ return facet === void 0 || level === null ? hold : {
415
+ ...hold,
416
+ [facet.id]: level
417
+ };
418
+ }
419
+ /** This function gives the text of the size of a grid. */
420
+ function gridSentence(x, y, cells, elapsedMs, budgetMs) {
421
+ if (cells) return `The grid has ${x.length} by ${y.length} measured levels.`;
422
+ const size = `The grid has ${x.length} by ${y.length} places.`;
423
+ return elapsedMs > budgetMs ? `${size} This surface took ${Math.round(elapsedMs)} ms, so the next grid is smaller.` : size;
424
+ }
425
+ /** This function gives the steps of a view from its axes and the plan of `planSteps`. */
426
+ function stepsOfView(record, state, panels, cap) {
427
+ const axes = variedOf(state).map((id) => dimensionOf(record, id));
428
+ const fixed = axes.filter((d) => d.kind !== "continuous");
429
+ const levels = fixed.reduce((count, d) => count * d.levels.length, 1);
430
+ return planSteps(record.points.length, panels, axes.length - fixed.length, levels, cap);
431
+ }
432
+ /**
433
+ * This function gives the data of a view. It does not throw. A request that the record refuses gives the text in
434
+ * `problem`, and no panels. A measure with no model gives only the measured points, and a table model gives cells.
435
+ */
436
+ function viewOf(record, state, options = {}) {
437
+ const { budgetMs = 50, stepsCap = 33, now = () => 0 } = options;
438
+ const measure = record.measures.find((m) => m.id === state.measure) ?? record.measures[0];
439
+ const model = record.models.find((m) => m.measure === measure.id);
440
+ const x = axisOf(dimensionOf(record, state.x));
441
+ const y = state.y === null ? null : axisOf(dimensionOf(record, state.y));
442
+ const facet = state.facet === null ? void 0 : dimensionOf(record, state.facet);
443
+ const levels = facet === void 0 ? [null] : panelLevels(record, state, facet.id);
444
+ const steps = stepsOfView(record, state, levels.length, stepsCap);
445
+ const tolerance = toleranceOf(record);
446
+ const base = {
447
+ measure,
448
+ x,
449
+ y,
450
+ log: model?.scale === "log",
451
+ steps,
452
+ elapsedMs: 0,
453
+ nextCap: stepsCap,
454
+ gridText: "",
455
+ problem: ""
456
+ };
457
+ if (model === void 0) {
458
+ const modelText = "This measure has no valid model in the record, so the view shows only the measured points.";
459
+ return {
460
+ ...base,
461
+ panels: pointPanels(record, state, levels, facet, tolerance),
462
+ error: null,
463
+ errorText: "",
464
+ modelText
465
+ };
466
+ }
467
+ const modelText = model.kind === "table" ? "The model is the mean of each measured cell, so the view shows cells and no smooth surface." : "The model is a smooth fit over all the dimensions.";
468
+ try {
469
+ const start = now();
470
+ const panels = levels.map((level) => {
471
+ const hold = requestHold(state, facet, level);
472
+ const title = panelTitle(facet, level);
473
+ if (state.y === null) return {
474
+ kind: "curve",
475
+ title,
476
+ level,
477
+ slice: sliceResponse(record, measure.id, {
478
+ vary: state.x,
479
+ hold,
480
+ steps,
481
+ tolerance
482
+ })
483
+ };
484
+ const surface = surfaceResponse(record, measure.id, {
485
+ vary: [state.x, state.y],
486
+ hold,
487
+ steps,
488
+ tolerance
489
+ });
490
+ return {
491
+ kind: "surface",
492
+ title,
493
+ level,
494
+ cells: model.kind === "table" || x.kind === "levels" && y?.kind === "levels",
495
+ surface
496
+ };
497
+ });
498
+ const elapsedMs = Math.max(0, now() - start);
499
+ const first = panels[0];
500
+ const surface = first?.kind === "surface" ? first : void 0;
501
+ const late = surface !== void 0 && !surface.cells ? nextStepsCap(steps, elapsedMs, budgetMs) : Number.POSITIVE_INFINITY;
502
+ const gridText = surface === void 0 ? "" : gridSentence(surface.surface.x, surface.surface.y, surface.cells, elapsedMs, budgetMs);
503
+ const error = heldOutError(record, measure.id);
504
+ return {
505
+ ...base,
506
+ panels,
507
+ error,
508
+ errorText: errorSentence(measure, model, error),
509
+ modelText,
510
+ gridText,
511
+ elapsedMs,
512
+ nextCap: Math.min(stepsCap, late)
513
+ };
514
+ } catch (error) {
515
+ return {
516
+ ...base,
517
+ panels: [],
518
+ error: null,
519
+ errorText: "",
520
+ modelText,
521
+ problem: error instanceof Error ? error.message : String(error)
522
+ };
523
+ }
524
+ }
525
+ /** This function gives the strength of the band at a distance from a measurement: 1 at a measurement, 0.15 far away. */
526
+ function fadeOf(nearest) {
527
+ return 1 - .85 * Math.min(1, nearest / FADE_END);
528
+ }
529
+ /** This function gives the share of the neutral veil of a surface cell at a distance from a measurement. */
530
+ function veilOf(nearest) {
531
+ return .8 * Math.min(1, nearest / FADE_END);
532
+ }
533
+ /** This function gives a round step for a range and a tick count: 1, 2 or 5 times a power of 10. */
534
+ function roundStep(span, count) {
535
+ const raw = span / Math.max(1, count);
536
+ const power = 10 ** Math.floor(Math.log10(raw));
537
+ const share = raw / power;
538
+ return (share <= 1 ? 1 : share <= 2 ? 2 : share <= 5 ? 5 : 10) * power;
539
+ }
540
+ /** This function gives round numbers in a range, for approximately `count` ticks. */
541
+ function roundNumbers(low, high, count) {
542
+ if (!(high > low)) return [low];
543
+ const step = roundStep(high - low, count);
544
+ const out = [];
545
+ for (let i = Math.ceil(low / step - 1e-9); i * step <= high + step * 1e-9; i++) out.push(Number((i * step).toPrecision(12)));
546
+ return out;
547
+ }
548
+ /** This function gives the values 1, 2 and 5 times each power of 10 in a range, for approximately `count` ticks. */
549
+ function logNumbers(low, high, count) {
550
+ const out = [];
551
+ for (let power = Math.floor(Math.log10(low)); power <= Math.ceil(Math.log10(high)); power++) for (const factor of [
552
+ 1,
553
+ 2,
554
+ 5
555
+ ]) {
556
+ const value = Number((factor * 10 ** power).toPrecision(12));
557
+ if (value >= low * (1 - 1e-9) && value <= high * (1 + 1e-9)) out.push(value);
558
+ }
559
+ const powers = out.filter((value) => Number.isInteger(Math.log10(value)));
560
+ return out.length > count && powers.length >= 2 ? powers : out;
561
+ }
562
+ /** This function gives the ticks of a number range. A log range with too few round values gives linear round values. */
563
+ function numberTicks(low, high, log, count) {
564
+ const scale = (v) => log ? Math.log10(v) : v;
565
+ const logged = log ? logNumbers(low, high, count) : [];
566
+ return (logged.length >= 3 ? logged : roundNumbers(low, high, count)).map((value) => ({
567
+ place: high > low ? (scale(value) - scale(low)) / (scale(high) - scale(low)) : .5,
568
+ text: formatValue(value)
569
+ }));
570
+ }
571
+ /** This function gives the ticks of an axis. */
572
+ function axisTicks(axis, count) {
573
+ if (axis.kind === "number") return numberTicks(axis.range[0], axis.range[1], axis.log, count);
574
+ return axis.levels.map((level) => ({
575
+ place: placeOn(axis, level),
576
+ text: formatValue(level)
577
+ }));
578
+ }
579
+ /** This function gives the values of one panel that the value axis must show. */
580
+ function panelValues(panel, varied) {
581
+ if (panel.kind === "points") return varied === 1 ? panel.points.map((p) => p.value) : [];
582
+ if (panel.kind === "curve") return [...panel.slice.curve.flatMap((c) => [c.low, c.high]), ...panel.slice.points.map((p) => p.value)];
583
+ return [];
584
+ }
585
+ /**
586
+ * This function gives the two ends of the value axis of a curve view. All the panels of a view share them, so two
587
+ * panels compare by eye. A range of one value grows, so the axis keeps a length.
588
+ */
589
+ function valueRange(view) {
590
+ const values = view.panels.flatMap((panel) => panelValues(panel, view.y === null ? 1 : 2)).filter((v) => Number.isFinite(v) && (!view.log || v > 0));
591
+ if (values.length === 0) return view.log ? [1, 10] : [0, 1];
592
+ const low = Math.min(...values);
593
+ const high = Math.max(...values);
594
+ if (view.log) {
595
+ const margin = high > low ? (high / low) ** .05 : 2;
596
+ return [low / margin, high * margin];
597
+ }
598
+ const margin = high > low ? (high - low) * .05 : Math.max(Math.abs(low) * .1, 1);
599
+ return [low - margin, high + margin];
600
+ }
601
+ /** This function gives each fitted value of a surface view, with the values of a view of plain points. */
602
+ function heatValues(view) {
603
+ return view.panels.flatMap((panel) => {
604
+ if (panel.kind === "surface") return panel.surface.cells.flatMap((row) => row.map((cell) => cell.value));
605
+ return panel.kind === "points" ? panel.points.map((p) => p.value) : [];
606
+ });
607
+ }
608
+ /** This function gives the two ends of the color scale of a surface view. All the panels share them. */
609
+ function heatRange(view) {
610
+ const values = heatValues(view).filter((v) => Number.isFinite(v) && (!view.log || v > 0));
611
+ if (values.length === 0) return [0, 1];
612
+ return [Math.min(...values), Math.max(...values)];
613
+ }
614
+ /** This function gives the step of the color scale of a value, from 0 to `HEAT_STEPS - 1`. */
615
+ function heatStep(value, range, log) {
616
+ const [low, high] = range;
617
+ if (!(high > low)) return Math.floor(9 / 2);
618
+ const share = log ? (Math.log10(value) - Math.log10(low)) / (Math.log10(high) - Math.log10(low)) : (value - low) / (high - low);
619
+ return Math.max(0, Math.min(8, Math.floor(share * 9)));
620
+ }
621
+ /** This function gives the edges of the cells around places on an axis, from 0 to 1. There is one more edge than places. */
622
+ function cellEdges(places) {
623
+ return [
624
+ 0,
625
+ ...places.slice(1).map((place, i) => (place + places[i]) / 2),
626
+ 1
627
+ ];
628
+ }
629
+ /** This function gives the path of a line through pixels. */
630
+ function linePath(pixels) {
631
+ return pixels.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x.toFixed(1)} ${y.toFixed(1)}`).join("");
632
+ }
633
+ /** This function gives the closed path of a band between its upper and its lower pixels. */
634
+ function bandPath(upper, lower) {
635
+ return `${linePath([...upper, ...[...lower].reverse()])}Z`;
636
+ }
637
+ /** This function gives the distance from a measurement in plain words. */
638
+ function nearText(nearest) {
639
+ return nearest === 0 ? "A point was measured here." : `The nearest measured point is ${Math.round(nearest * 100)}% of an axis away.`;
640
+ }
641
+ /** This function gives the text of one place of a band. */
642
+ function bandText(band) {
643
+ return `fitted ${formatValue(band.value)}, band ${formatValue(band.low)} to ${formatValue(band.high)}. ${nearText(band.nearest)}`;
644
+ }
645
+ /** This function gives the text of one place of a curve. */
646
+ function curveText(axis, place) {
647
+ return `${axis.label} ${formatValue(place.at)}: ${bandText(place)}`;
648
+ }
649
+ /** This function gives the text of one place of a surface. */
650
+ function cellText(x, y, atX, atY, band) {
651
+ return `${x.label} ${formatValue(atX)}, ${y.label} ${formatValue(atY)}: ${bandText(band)}`;
652
+ }
653
+ /** This function gives the numbers of a panel: each place of a curve, or the measured points of a surface. */
654
+ function numberTable(view, panel) {
655
+ const axes = view.y === null ? [view.x.label] : [view.x.label, view.y.label];
656
+ if (panel.kind === "curve") {
657
+ const rows = panel.slice.curve.map((c) => [
658
+ formatValue(c.at),
659
+ formatValue(c.value),
660
+ formatValue(c.low),
661
+ formatValue(c.high),
662
+ `${Math.round(c.nearest * 100)}%`
663
+ ]);
664
+ return {
665
+ columns: [
666
+ view.x.label,
667
+ "Fitted",
668
+ "Low",
669
+ "High",
670
+ "Distance"
671
+ ],
672
+ rows
673
+ };
674
+ }
675
+ const points = panel.kind === "points" ? panel.points : panel.surface.points;
676
+ return {
677
+ columns: [...axes, "Measured"],
678
+ rows: points.map((p) => [...p.at.map(formatValue), formatValue(p.value)])
679
+ };
680
+ }
681
+ // src/explorer/view.ts
682
+ /**
683
+ * The DOM view of the response explorer. This module draws SVG with no framework. The core in `model.ts` gives each
684
+ * rule and each number, and this module connects the core to the page.
685
+ */
686
+ const SVG = "http://www.w3.org/2000/svg";
687
+ /** The most faults that the view lists. */
688
+ const MAX_FAULTS = 20;
689
+ /** The width of a chart with no layout, for example in a test. */
690
+ const FALLBACK_WIDTH = 640;
691
+ const MARGIN = {
692
+ top: 10,
693
+ right: 14,
694
+ bottom: 42,
695
+ left: 50
696
+ };
697
+ /** The height of a line of text of an axis, in pixels. */
698
+ const CHAR_WIDTH = 6.4;
699
+ let mounts = 0;
700
+ /** This function makes an SVG element with attributes and children. */
701
+ function s(tag, attrs, ...children) {
702
+ const el = document.createElementNS(SVG, tag);
703
+ for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, String(value));
704
+ el.append(...children);
705
+ return el;
706
+ }
707
+ /** This function gives the plot of a chart. Long level names need room for their turned text. */
708
+ function plotOf(width, height, view) {
709
+ const turned = turnedLevels(view.x, width - MARGIN.left - MARGIN.right);
710
+ const longest = Math.max(0, ...view.x.levels.map((level) => formatValue(level).length));
711
+ const bottom = height - MARGIN.bottom - (turned ? Math.min(60, longest * CHAR_WIDTH * .7) : 0);
712
+ return {
713
+ width,
714
+ height,
715
+ left: view.y?.kind === "levels" ? Math.min(110, Math.max(MARGIN.left, 12 + Math.max(...view.y.levels.map((level) => formatValue(level).length)) * CHAR_WIDTH)) : MARGIN.left,
716
+ top: MARGIN.top,
717
+ right: width - MARGIN.right,
718
+ bottom
719
+ };
720
+ }
721
+ /** This function gives true when the levels of an axis do not fit side by side. */
722
+ function turnedLevels(axis, room) {
723
+ if (axis.kind !== "levels") return false;
724
+ return (Math.max(0, ...axis.levels.map((level) => formatValue(level).length)) + 1) * CHAR_WIDTH > room / Math.max(1, axis.levels.length);
725
+ }
726
+ /** This function gives the ticks of an axis that fit in a length. */
727
+ function fittedTicks(axis, length) {
728
+ return axisTicks(axis, Math.max(2, Math.floor(length / 70)));
729
+ }
730
+ /** This function draws the horizontal axis. */
731
+ function xAxis(plot, axis) {
732
+ const length = plot.right - plot.left;
733
+ const turned = turnedLevels(axis, length);
734
+ const ticks = fittedTicks(axis, length).map((tick) => {
735
+ const x = plot.left + tick.place * length;
736
+ const label = turned ? s("text", {
737
+ class: "rx-tick",
738
+ x,
739
+ y: plot.bottom + 14,
740
+ "text-anchor": "end",
741
+ transform: `rotate(-35 ${x} ${plot.bottom + 14})`
742
+ }, tick.text) : s("text", {
743
+ class: "rx-tick",
744
+ x,
745
+ y: plot.bottom + 15,
746
+ "text-anchor": x + tick.text.length * CHAR_WIDTH / 2 > plot.width ? "end" : "middle"
747
+ }, tick.text);
748
+ return s("g", {}, s("line", {
749
+ class: "rx-axis",
750
+ x1: x,
751
+ x2: x,
752
+ y1: plot.bottom,
753
+ y2: plot.bottom + 4
754
+ }), label);
755
+ });
756
+ const name = s("text", {
757
+ class: "rx-axis-name",
758
+ x: (plot.left + plot.right) / 2,
759
+ y: plot.height - 6,
760
+ "text-anchor": "middle"
761
+ }, axis.log ? `${axis.label}, log scale` : axis.label);
762
+ return s("g", {}, s("line", {
763
+ class: "rx-axis",
764
+ x1: plot.left,
765
+ x2: plot.right,
766
+ y1: plot.bottom,
767
+ y2: plot.bottom
768
+ }), ...ticks, name);
769
+ }
770
+ /** This function draws the value axis with its grid lines. */
771
+ function valueAxis(plot, ticks) {
772
+ const length = plot.bottom - plot.top;
773
+ return s("g", {}, ...ticks.map((tick) => {
774
+ const y = plot.bottom - tick.place * length;
775
+ return s("g", {}, s("line", {
776
+ class: "rx-grid",
777
+ x1: plot.left,
778
+ x2: plot.right,
779
+ y1: y,
780
+ y2: y
781
+ }), s("text", {
782
+ class: "rx-tick",
783
+ x: plot.left - 6,
784
+ y: y + 4,
785
+ "text-anchor": "end"
786
+ }, tick.text));
787
+ }));
788
+ }
789
+ /** This function draws the vertical axis of a surface. */
790
+ function surfaceAxis(plot, axis) {
791
+ const length = plot.bottom - plot.top;
792
+ return s("g", {}, ...axisTicks(axis, Math.max(2, Math.floor(length / 45))).map((tick) => {
793
+ const y = plot.bottom - tick.place * length;
794
+ return s("text", {
795
+ class: "rx-tick",
796
+ x: plot.left - 6,
797
+ y: y + 4,
798
+ "text-anchor": "end"
799
+ }, tick.text);
800
+ }));
801
+ }
802
+ /** This function gives the place of a value on the value axis, from 0 to 1. */
803
+ function valuePlace(scale, value) {
804
+ const [low, high] = scale.range;
805
+ if (scale.log) return (Math.log10(Math.max(value, low)) - Math.log10(low)) / (Math.log10(high) - Math.log10(low));
806
+ return (value - low) / (high - low);
807
+ }
808
+ /** This function gives a small steady offset for a dot from its id, so the dots of one level do not all overlap. */
809
+ function jitter(id, room) {
810
+ let hash = 7;
811
+ for (const char of id) hash = (hash * 31 + (char.codePointAt(0) ?? 0)) % 1009;
812
+ return (hash / 1008 - .5) * room;
813
+ }
814
+ /** This function connects the pointer and the arrow keys of a chart to its cursor. */
815
+ function connect(svg, cursor, tell) {
816
+ let column = -1;
817
+ let row = 0;
818
+ const move = (nextColumn, nextRow) => {
819
+ column = Math.max(0, Math.min(cursor.columns - 1, nextColumn));
820
+ row = Math.max(0, Math.min(cursor.rows - 1, nextRow));
821
+ tell(cursor.show(column, row));
822
+ };
823
+ svg.addEventListener("pointermove", (event) => {
824
+ const box = svg.getBoundingClientRect();
825
+ const [c, r] = cursor.at(event.clientX - box.left, event.clientY - box.top);
826
+ move(c, r);
827
+ });
828
+ svg.addEventListener("keydown", (event) => {
829
+ const step = {
830
+ ArrowLeft: [-1, 0],
831
+ ArrowRight: [1, 0],
832
+ ArrowUp: [0, -1],
833
+ ArrowDown: [0, 1]
834
+ }[event.key];
835
+ if (step === void 0) return;
836
+ event.preventDefault();
837
+ move(column < 0 ? 0 : column + step[0], row + step[1]);
838
+ });
839
+ }
840
+ /** This function makes the frame of a chart with its text for a screen reader. */
841
+ function chartFrame(plot, label) {
842
+ return s("svg", {
843
+ class: "rx-svg",
844
+ width: plot.width,
845
+ height: plot.height,
846
+ viewBox: `0 0 ${plot.width} ${plot.height}`,
847
+ role: "img",
848
+ "aria-label": label,
849
+ tabindex: 0
850
+ });
851
+ }
852
+ /** This function draws a band with one strength for each place along the axis. */
853
+ function fadedBand(plot, panel, scale, axis, id) {
854
+ const length = plot.right - plot.left;
855
+ const height = plot.bottom - plot.top;
856
+ const curve = panel.slice.curve;
857
+ const px = (at) => plot.left + placeOn(axis, at) * length;
858
+ const py = (value) => plot.bottom - valuePlace(scale, value) * height;
859
+ const stops = curve.map((place) => s("stop", {
860
+ class: "rx-band-stop",
861
+ offset: placeOn(axis, place.at),
862
+ "stop-opacity": fadeOf(place.nearest).toFixed(3)
863
+ }));
864
+ const gradient = s("linearGradient", {
865
+ id,
866
+ gradientUnits: "userSpaceOnUse",
867
+ x1: plot.left,
868
+ x2: plot.right,
869
+ y1: 0,
870
+ y2: 0
871
+ }, ...stops);
872
+ const upper = curve.map((place) => [px(place.at), py(place.high)]);
873
+ const lower = curve.map((place) => [px(place.at), py(place.low)]);
874
+ return [s("defs", {}, gradient), s("path", {
875
+ class: "rx-band",
876
+ d: bandPath(upper, lower),
877
+ fill: `url(#${id})`
878
+ })];
879
+ }
880
+ /** This function draws a band and a mark at each level of a level axis. */
881
+ function levelMarks(plot, panel, scale, axis) {
882
+ const length = plot.right - plot.left;
883
+ const height = plot.bottom - plot.top;
884
+ const room = Math.min(44, length / Math.max(1, axis.levels.length) * .5);
885
+ return panel.slice.curve.flatMap((place) => {
886
+ const x = plot.left + placeOn(axis, place.at) * length;
887
+ const top = plot.bottom - valuePlace(scale, place.high) * height;
888
+ const bottom = plot.bottom - valuePlace(scale, place.low) * height;
889
+ const y = plot.bottom - valuePlace(scale, place.value) * height;
890
+ return [s("rect", {
891
+ class: "rx-whisker",
892
+ x: x - room / 2,
893
+ y: top,
894
+ width: room,
895
+ height: Math.max(1, bottom - top),
896
+ rx: 4,
897
+ "fill-opacity": (.3 * fadeOf(place.nearest)).toFixed(3)
898
+ }), s("line", {
899
+ class: "rx-line",
900
+ x1: x - room / 2,
901
+ x2: x + room / 2,
902
+ y1: y,
903
+ y2: y
904
+ })];
905
+ });
906
+ }
907
+ /** This function draws a chart with a fitted curve, its band and the measured points. */
908
+ function curveChart(view, panel, width, id, tell) {
909
+ const plot = plotOf(width, 270, view);
910
+ const scale = {
911
+ range: valueRange(view),
912
+ log: view.log
913
+ };
914
+ const length = plot.right - plot.left;
915
+ const height = plot.bottom - plot.top;
916
+ const curve = panel.slice.curve;
917
+ const px = (at) => plot.left + placeOn(view.x, at) * length;
918
+ const py = (value) => plot.bottom - valuePlace(scale, value) * height;
919
+ const smooth = view.x.kind === "number";
920
+ const marks = smooth ? [...fadedBand(plot, panel, scale, view.x, id), s("path", {
921
+ class: "rx-line",
922
+ d: linePath(curve.map((place) => [px(place.at), py(place.value)]))
923
+ })] : levelMarks(plot, panel, scale, view.x);
924
+ const room = smooth ? 0 : Math.min(30, length / Math.max(1, view.x.levels.length) * .35);
925
+ const dots = panel.slice.points.map((point) => s("circle", {
926
+ class: "rx-dot",
927
+ cx: px(point.at) + jitter(point.id, room),
928
+ cy: py(point.value),
929
+ r: 3.5
930
+ }));
931
+ const ticks = numberTicks(scale.range[0], scale.range[1], scale.log, Math.max(2, Math.floor(height / 50)));
932
+ const rule = s("line", {
933
+ class: "rx-cursor",
934
+ x1: 0,
935
+ x2: 0,
936
+ y1: plot.top,
937
+ y2: plot.bottom,
938
+ visibility: "hidden"
939
+ });
940
+ const ring = s("circle", {
941
+ class: "rx-ring",
942
+ cx: 0,
943
+ cy: 0,
944
+ r: 5,
945
+ visibility: "hidden"
946
+ });
947
+ const svg = chartFrame(plot, `${panel.title} ${view.measure.label} over ${view.x.label}.`.trim());
948
+ svg.append(valueAxis(plot, ticks), ...marks, ...dots, xAxis(plot, view.x), rule, ring);
949
+ connect(svg, {
950
+ columns: curve.length,
951
+ rows: 1,
952
+ at: (x) => {
953
+ const distance = (place) => Math.abs(px(place.at) - x);
954
+ const nearest = curve.reduce((best, place) => distance(place) < distance(best) ? place : best);
955
+ return [curve.indexOf(nearest), 0];
956
+ },
957
+ show: (column) => {
958
+ const place = curve[column];
959
+ for (const [name, value] of [["x1", px(place.at)], ["x2", px(place.at)]]) rule.setAttribute(name, String(value));
960
+ ring.setAttribute("cx", String(px(place.at)));
961
+ ring.setAttribute("cy", String(py(place.value)));
962
+ rule.setAttribute("visibility", "visible");
963
+ ring.setAttribute("visibility", "visible");
964
+ return curveText(view.x, place);
965
+ }
966
+ }, tell);
967
+ return svg;
968
+ }
969
+ /** This function draws a chart of a surface, or the separate cells of a table. */
970
+ function surfaceChart(view, panel, width, tell) {
971
+ const axisY = view.y;
972
+ const plot = plotOf(width, Math.min(380, Math.max(280, width * .72)), view);
973
+ const { x, y, cells } = panel.surface;
974
+ const length = plot.right - plot.left;
975
+ const height = plot.bottom - plot.top;
976
+ const xEdges = cellEdges(x.map((value) => placeOn(view.x, value)));
977
+ const yEdges = cellEdges(y.map((value) => placeOn(axisY, value)));
978
+ const range = heatRange(view);
979
+ const gap = panel.cells ? 2 : 0;
980
+ const cellMarks = cells.flatMap((row, j) => row.flatMap((band, i) => {
981
+ const left = plot.left + xEdges[i] * length;
982
+ const right = plot.left + xEdges[i + 1] * length;
983
+ const lower = plot.bottom - yEdges[j] * height;
984
+ const upper = plot.bottom - yEdges[j + 1] * height;
985
+ const box = {
986
+ x: left + gap / 2,
987
+ y: upper + gap / 2,
988
+ width: Math.max(.5, right - left - gap + (gap === 0 ? .6 : 0)),
989
+ height: Math.max(.5, lower - upper - gap + (gap === 0 ? .6 : 0))
990
+ };
991
+ const step = heatStep(band.value, range, view.log);
992
+ const parts = [s("rect", {
993
+ ...box,
994
+ class: `rx-h${step}`
995
+ })];
996
+ const veil = veilOf(band.nearest);
997
+ if (veil > .02) parts.push(s("rect", {
998
+ ...box,
999
+ class: "rx-veil",
1000
+ "fill-opacity": veil.toFixed(3)
1001
+ }));
1002
+ if (panel.cells && box.width > 34 && box.height > 18) parts.push(s("text", {
1003
+ class: `rx-cell-text ${step > 9 / 2 ? "rx-on-dark" : "rx-on-light"}`,
1004
+ x: box.x + box.width / 2,
1005
+ y: box.y + box.height / 2 + 4,
1006
+ "text-anchor": "middle"
1007
+ }, formatValue(band.value)));
1008
+ return parts;
1009
+ }));
1010
+ const dots = panel.surface.points.map((point) => s("circle", {
1011
+ class: "rx-dot rx-dot-heat",
1012
+ cx: plot.left + placeOn(view.x, point.at[0]) * length + jitter(point.id, 6),
1013
+ cy: plot.bottom - placeOn(axisY, point.at[1]) * height + jitter(`${point.id}y`, 6),
1014
+ r: 2.5
1015
+ }));
1016
+ const outline = s("rect", {
1017
+ class: "rx-cell-cursor",
1018
+ x: 0,
1019
+ y: 0,
1020
+ width: 0,
1021
+ height: 0,
1022
+ visibility: "hidden"
1023
+ });
1024
+ const svg = chartFrame(plot, `${panel.title} ${view.measure.label} over ${view.x.label} and ${axisY.label}.`.trim());
1025
+ svg.append(...cellMarks, ...dots, surfaceAxis(plot, axisY), xAxis(plot, view.x), outline);
1026
+ const find = (edges, share) => {
1027
+ const index = edges.findIndex((edge) => edge > share);
1028
+ return Math.max(0, (index < 0 ? edges.length : index) - 1);
1029
+ };
1030
+ connect(svg, {
1031
+ columns: x.length,
1032
+ rows: y.length,
1033
+ at: (px, py) => [find(xEdges, (px - plot.left) / length), y.length - 1 - find(yEdges, (plot.bottom - py) / height)],
1034
+ show: (column, row) => {
1035
+ const j = y.length - 1 - row;
1036
+ outline.setAttribute("x", String(plot.left + xEdges[column] * length));
1037
+ outline.setAttribute("width", String((xEdges[column + 1] - xEdges[column]) * length));
1038
+ outline.setAttribute("y", String(plot.bottom - yEdges[j + 1] * height));
1039
+ outline.setAttribute("height", String((yEdges[j + 1] - yEdges[j]) * height));
1040
+ outline.setAttribute("visibility", "visible");
1041
+ return cellText(view.x, axisY, x[column], y[j], cells[j]?.[column]);
1042
+ }
1043
+ }, tell);
1044
+ return svg;
1045
+ }
1046
+ /** This function draws the measured points of a measure with no model. */
1047
+ function pointsChart(view, panel, width) {
1048
+ const plot = plotOf(width, 270, view);
1049
+ const length = plot.right - plot.left;
1050
+ const height = plot.bottom - plot.top;
1051
+ const svg = chartFrame(plot, `${panel.title} ${view.measure.label}: ${panel.points.length} measured points.`.trim());
1052
+ if (view.y === null) {
1053
+ const scale = {
1054
+ range: valueRange(view),
1055
+ log: view.log
1056
+ };
1057
+ const room = view.x.kind === "levels" ? Math.min(30, length / Math.max(1, view.x.levels.length) * .35) : 0;
1058
+ const dots = panel.points.map((point) => s("circle", {
1059
+ class: "rx-dot",
1060
+ cx: plot.left + placeOn(view.x, point.at[0]) * length + jitter(point.id, room),
1061
+ cy: plot.bottom - valuePlace(scale, point.value) * height,
1062
+ r: 3.5
1063
+ }));
1064
+ const ticks = numberTicks(scale.range[0], scale.range[1], scale.log, Math.max(2, Math.floor(height / 50)));
1065
+ svg.append(valueAxis(plot, ticks), ...dots, xAxis(plot, view.x));
1066
+ return svg;
1067
+ }
1068
+ const axisY = view.y;
1069
+ const range = heatRange(view);
1070
+ const dots = panel.points.map((point) => s("circle", {
1071
+ class: `rx-h${heatStep(point.value, range, view.log)} rx-ring-dot`,
1072
+ cx: plot.left + placeOn(view.x, point.at[0]) * length + jitter(point.id, 8),
1073
+ cy: plot.bottom - placeOn(axisY, point.at[1]) * height + jitter(`${point.id}y`, 8),
1074
+ r: 5
1075
+ }));
1076
+ svg.append(...dots, surfaceAxis(plot, axisY), xAxis(plot, view.x));
1077
+ return svg;
1078
+ }
1079
+ /** This function gives the number of measured points of a panel. */
1080
+ function pointCount(panel) {
1081
+ if (panel.kind === "curve") return panel.slice.points.length;
1082
+ return panel.kind === "surface" ? panel.surface.points.length : panel.points.length;
1083
+ }
1084
+ /** This function gives the sentence about the measured points of a panel. */
1085
+ function pointsText(panel) {
1086
+ const count = pointCount(panel);
1087
+ if (count === 0) return "No measured point has these held values.";
1088
+ return count === 1 ? "1 measured point has these held values." : `${count} measured points have these held values.`;
1089
+ }
1090
+ /** This function draws one panel. */
1091
+ function panelChart(view, panel, width, id, tell) {
1092
+ if (panel.kind === "curve") return curveChart(view, panel, width, id, tell);
1093
+ return panel.kind === "surface" ? surfaceChart(view, panel, width, tell) : pointsChart(view, panel, width);
1094
+ }
1095
+ /** This function makes the legend of a view. */
1096
+ function legendOf(view) {
1097
+ const item = (mark, text) => h("span", { class: "rx-key" }, mark, text);
1098
+ const mark = (name) => h("i", {
1099
+ class: `rx-mark ${name}`,
1100
+ "aria-hidden": "true"
1101
+ });
1102
+ const parts = [];
1103
+ const panel = view.panels[0];
1104
+ const surface = view.y !== null && panel?.kind !== "curve";
1105
+ if (panel?.kind === "curve") parts.push(item(mark("rx-mark-line"), "fitted value"), item(mark("rx-mark-band"), "band: one held-out error each side. It fades away from the data."));
1106
+ if (surface) {
1107
+ const [low, high] = heatRange(view);
1108
+ const ramp = h("i", {
1109
+ class: "rx-ramp",
1110
+ "aria-hidden": "true"
1111
+ }, ...Array.from({ length: 9 }, (_, i) => h("b", { class: `rx-s${i}` })));
1112
+ parts.push(h("span", { class: "rx-key" }, formatValue(low), ramp, formatValue(high)));
1113
+ if (panel?.kind === "surface") parts.push(item(mark("rx-mark-veil"), "gray veil: far from the data"));
1114
+ }
1115
+ if (panel !== void 0) parts.push(item(mark("rx-mark-dot"), "measured point"));
1116
+ return h("p", { class: "rx-legend" }, ...parts);
1117
+ }
1118
+ /** This function makes a table of numbers. */
1119
+ function tableOf(view, panel) {
1120
+ const { columns, rows } = numberTable(view, panel);
1121
+ const head = h("tr", {}, ...columns.map((column) => h("th", { scope: "col" }, column)));
1122
+ const body = rows.map((row) => h("tr", {}, ...row.map((cell) => h("td", {}, cell))));
1123
+ return h("table", { class: "rx-table" }, ...panel.title === "" ? [] : [h("caption", {}, panel.title)], h("thead", {}, head), h("tbody", {}, ...body));
1124
+ }
1125
+ /** This function makes a select of dimensions. The first option is the value `none`, if the control has one. */
1126
+ function dimensionSelect(label, key, choices, value, none, change) {
1127
+ const options = choices.map((choice) => {
1128
+ const text = choice.disabled ? `${choice.label}: ${choice.reason}` : choice.label;
1129
+ return h("option", {
1130
+ value: choice.id,
1131
+ disabled: choice.disabled,
1132
+ selected: choice.id === value
1133
+ }, text);
1134
+ });
1135
+ const select = h("select", { "data-key": key }, ...none === null ? [] : [h("option", {
1136
+ value: "",
1137
+ selected: value === null
1138
+ }, none)], ...options);
1139
+ select.addEventListener("change", () => change(select.value === "" ? null : select.value));
1140
+ return h("label", { class: "rx-field" }, h("span", {}, label), select);
1141
+ }
1142
+ /** This function makes a group of level buttons. They are radio inputs, so the keyboard works with no script. */
1143
+ function levelGroup(label, key, name, options, note) {
1144
+ const chips = options.map((option, i) => {
1145
+ const input = h("input", {
1146
+ class: "rx-radio",
1147
+ type: "radio",
1148
+ name,
1149
+ checked: option.checked,
1150
+ disabled: option.disabled,
1151
+ "data-key": `${key}:${i}`
1152
+ });
1153
+ input.addEventListener("change", option.choose);
1154
+ return h("label", {
1155
+ class: "rx-chip",
1156
+ title: option.reason === "" ? void 0 : option.reason
1157
+ }, input, h("span", {}, option.text));
1158
+ });
1159
+ const legend = h("legend", { class: "rx-name" }, label);
1160
+ return h("fieldset", { class: "rx-hold" }, legend, h("div", { class: "rx-chips" }, ...chips), note === "" ? null : h("p", { class: "rx-note" }, note));
1161
+ }
1162
+ /** This function makes a slider over the values of a held dimension. */
1163
+ function stopSlider(control, key, change) {
1164
+ const values = control.options.map((option) => option.value);
1165
+ const index = Math.max(0, values.indexOf(control.value));
1166
+ const output = h("output", { class: "rx-value" }, formatValue(control.value));
1167
+ const input = h("input", {
1168
+ class: "rx-slider",
1169
+ type: "range",
1170
+ min: 0,
1171
+ max: values.length - 1,
1172
+ step: 1,
1173
+ value: index,
1174
+ disabled: !control.applies,
1175
+ "data-key": key,
1176
+ "aria-label": control.label,
1177
+ "aria-valuetext": formatValue(control.value)
1178
+ });
1179
+ input.addEventListener("input", () => {
1180
+ const value = values[Number(input.value)];
1181
+ output.textContent = formatValue(value);
1182
+ input.setAttribute("aria-valuetext", formatValue(value));
1183
+ change(value);
1184
+ });
1185
+ const head = h("div", { class: "rx-slider-head" }, h("span", { class: "rx-name" }, control.label), output);
1186
+ return h("div", { class: "rx-hold" }, head, input, control.note === "" ? null : h("p", { class: "rx-note" }, control.note));
1187
+ }
1188
+ /** This function shows each fault of a bad record, with its path. */
1189
+ function faultView(faults) {
1190
+ const items = faults.slice(0, MAX_FAULTS).map((fault) => h("li", {}, h("code", {}, fault.path === "" ? "(the record)" : fault.path), ` ${fault.message}`));
1191
+ const more = faults.length > MAX_FAULTS ? h("p", {}, `The list shows ${MAX_FAULTS} of ${faults.length} faults.`) : null;
1192
+ const title = faults.length === 1 ? "This record has 1 fault." : `This record has ${faults.length} faults.`;
1193
+ return h("div", {
1194
+ class: "rx-faults",
1195
+ role: "alert"
1196
+ }, h("p", { class: "rx-faults-title" }, title), h("ul", {}, ...items), more);
1197
+ }
1198
+ /**
1199
+ * This function shows a record of the format `response-records/1` in a host element. It reads the record only with
1200
+ * `parseResponseRecord`. A bad record shows its faults with their paths, and the function does not throw.
1201
+ *
1202
+ * @param host - The element of the explorer. The function replaces its children.
1203
+ * @param recordJson - The record as JSON text, or as parsed data.
1204
+ * @param options - The first state, the time of a surface and the clock.
1205
+ */
1206
+ function mountExplorer(host, recordJson, options = {}) {
1207
+ const parsed = openRecord(recordJson);
1208
+ if (!parsed.ok) {
1209
+ host.replaceChildren(h("div", { class: "response-explorer" }, faultView(parsed.faults)));
1210
+ return {
1211
+ state: () => null,
1212
+ destroy: () => host.replaceChildren()
1213
+ };
1214
+ }
1215
+ return mountRecord(host, parsed.record, options);
1216
+ }
1217
+ /** This function connects a good record to the DOM. */
1218
+ function mountRecord(host, record, options) {
1219
+ const { budgetMs = 50, now = () => performance.now() } = options;
1220
+ const mount = ++mounts;
1221
+ let state = settle(record, options.initial);
1222
+ let cap = 33;
1223
+ let frame = 0;
1224
+ let drawnWidth = 0;
1225
+ let view;
1226
+ const controls = h("div", { class: "rx-controls" });
1227
+ const panels = h("div", { class: "rx-panels" });
1228
+ const error = h("p", { class: "rx-error" });
1229
+ const model = h("p", { class: "rx-note" });
1230
+ const legend = h("div", { class: "rx-legend-box" });
1231
+ const readout = h("p", {
1232
+ class: "rx-readout",
1233
+ "aria-live": "polite"
1234
+ });
1235
+ const numbers = h("div", { class: "rx-numbers-body" });
1236
+ const details = h("details", { class: "rx-numbers" }, h("summary", {}, "Show the numbers"), numbers);
1237
+ const chart = h("div", { class: "rx-chart" }, error, panels, legend, readout, model, details);
1238
+ const root = h("div", { class: "response-explorer" }, controls, chart);
1239
+ const tell = (text) => {
1240
+ readout.textContent = text;
1241
+ };
1242
+ /** This function fills the table when the visitor opens it, so a closed table costs no time. */
1243
+ const drawNumbers = () => {
1244
+ numbers.replaceChildren(...details.open && view !== void 0 ? view.panels.map((panel) => h("div", { class: "rx-table-box scroll-cue" }, tableOf(view, panel))) : []);
1245
+ };
1246
+ /** This function draws the charts of the last view at the width of the host. */
1247
+ const drawCharts = () => {
1248
+ const current = view;
1249
+ if (current === void 0) return;
1250
+ const single = current.panels.length === 1;
1251
+ const available = panels.clientWidth > 0 ? panels.clientWidth : FALLBACK_WIDTH;
1252
+ const columns = single ? 1 : Math.min(current.panels.length, Math.max(1, Math.floor((available + 12) / 272)));
1253
+ const width = Math.floor((available - (columns - 1) * 12) / columns);
1254
+ drawnWidth = available;
1255
+ panels.replaceChildren(...current.panels.map((panel, i) => {
1256
+ const title = panel.title === "" ? null : h("figcaption", { class: "rx-panel-title" }, panel.title);
1257
+ const chartBox = panelChart(current, panel, width, `rx-band-${mount}-${i}`, tell);
1258
+ return h("figure", { class: "rx-panel" }, title, chartBox, h("p", { class: "rx-note" }, pointsText(panel)));
1259
+ }));
1260
+ };
1261
+ /** This function asks the core for a view, and it draws the view. */
1262
+ const drawView = () => {
1263
+ const next = viewOf(record, state, {
1264
+ stepsCap: cap,
1265
+ budgetMs,
1266
+ now
1267
+ });
1268
+ view = next;
1269
+ cap = next.nextCap;
1270
+ if (next.problem !== "") error.textContent = `The record refused this view: ${next.problem}`;
1271
+ else {
1272
+ const head = next.error === null ? null : h("strong", {}, "Held-out error. ");
1273
+ error.replaceChildren(...head === null ? [] : [head], next.errorText);
1274
+ }
1275
+ model.textContent = [next.modelText, next.gridText].filter((text) => text !== "").join(" ");
1276
+ legend.replaceChildren(legendOf(next));
1277
+ tell("Move over a chart, or focus it and use the arrow keys.");
1278
+ drawCharts();
1279
+ drawNumbers();
1280
+ };
1281
+ /** This function draws at the next frame. A slider gives many events in one frame. */
1282
+ const later = () => {
1283
+ if (frame !== 0) return;
1284
+ frame = requestAnimationFrame(() => {
1285
+ frame = 0;
1286
+ drawView();
1287
+ });
1288
+ };
1289
+ /** This function makes the controls of the state again, and it keeps the focus on the same control. */
1290
+ const drawControls = () => {
1291
+ const focused = document.activeElement instanceof HTMLElement ? document.activeElement.dataset["key"] : void 0;
1292
+ const update = (next) => {
1293
+ state = next;
1294
+ drawControls();
1295
+ drawView();
1296
+ };
1297
+ const measures = levelGroup("Measure", "measure", `rx-measure-${mount}`, record.measures.map((m) => ({
1298
+ text: m.label,
1299
+ checked: m.id === state.measure,
1300
+ disabled: false,
1301
+ reason: "",
1302
+ choose: () => update(setMeasure(record, state, m.id))
1303
+ })), "");
1304
+ const axes = h("div", { class: "rx-axes" }, dimensionSelect("Across", "x", axisChoices(record, state, "x"), state.x, null, (id) => update(setAxis(record, state, "x", id))), dimensionSelect("Up", "y", axisChoices(record, state, "y"), state.y, "None (a curve)", (id) => update(setAxis(record, state, "y", id))), dimensionSelect("Panels", "facet", facetChoices(record, state), state.facet, "One panel", (id) => update(setFacet(record, state, id))));
1305
+ const holds = holdControls(record, state).map((control) => {
1306
+ if (control.kind === "stops") return stopSlider(control, `hold:${control.id}`, (value) => {
1307
+ state = setHold(record, state, control.id, value);
1308
+ later();
1309
+ });
1310
+ const levels = control.options.map((option) => ({
1311
+ text: option.text,
1312
+ checked: option.value === control.value,
1313
+ disabled: option.disabled,
1314
+ reason: option.reason,
1315
+ choose: () => update(setHold(record, state, control.id, option.value))
1316
+ }));
1317
+ return levelGroup(control.label, `hold:${control.id}`, `rx-${mount}-${control.id}`, levels, control.note);
1318
+ });
1319
+ const held = holds.length === 0 ? [] : [h("p", { class: "rx-section" }, "Held values"), ...holds];
1320
+ controls.replaceChildren(measures, axes, ...held);
1321
+ if (focused !== void 0) [...controls.querySelectorAll("[data-key]")].find((el) => el.dataset["key"] === focused)?.focus();
1322
+ };
1323
+ details.addEventListener("toggle", drawNumbers);
1324
+ host.replaceChildren(root);
1325
+ drawControls();
1326
+ drawView();
1327
+ const observer = typeof ResizeObserver === "undefined" ? void 0 : new ResizeObserver(() => {
1328
+ if (panels.clientWidth > 0 && Math.abs(panels.clientWidth - drawnWidth) > 1) drawCharts();
1329
+ });
1330
+ observer?.observe(panels);
1331
+ return {
1332
+ state: () => state,
1333
+ destroy: () => {
1334
+ observer?.disconnect();
1335
+ if (frame !== 0) cancelAnimationFrame(frame);
1336
+ host.replaceChildren();
1337
+ }
1338
+ };
1339
+ }
1340
+ export { MAX_PANELS, MAX_STEPS, MIN_STEPS, READ_BUDGET, SURFACE_BUDGET_MS, axisChoices, facetChoices, holdControls, mountExplorer, nextStepsCap, numberTable, openRecord, planSteps, setAxis, setFacet, setHold, setMeasure, settle, viewOf };
1341
+
1342
+ //# sourceMappingURL=index.js.map