devicectl-core 0.1.0__py3-none-any.whl

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 (47) hide show
  1. devicectl/__init__.py +18 -0
  2. devicectl/cli/__init__.py +1 -0
  3. devicectl/cli/command.py +95 -0
  4. devicectl/cli/exits.py +32 -0
  5. devicectl/cli/fanout.py +142 -0
  6. devicectl/cli/main.py +69 -0
  7. devicectl/cli/output.py +299 -0
  8. devicectl/cli/parser.py +80 -0
  9. devicectl/cli/report.py +86 -0
  10. devicectl/cli/target.py +26 -0
  11. devicectl/clock.py +57 -0
  12. devicectl/devtools/__init__.py +6 -0
  13. devicectl/devtools/frontlint.py +935 -0
  14. devicectl/devtools/htmcheck.py +396 -0
  15. devicectl/devtools/rendercheck.py +384 -0
  16. devicectl/doctor.py +112 -0
  17. devicectl/errors.py +68 -0
  18. devicectl/fields.py +564 -0
  19. devicectl/meta.py +64 -0
  20. devicectl/paths.py +40 -0
  21. devicectl/progress.py +77 -0
  22. devicectl/report.py +67 -0
  23. devicectl/testing.py +199 -0
  24. devicectl/trace.py +333 -0
  25. devicectl/web/__init__.py +1 -0
  26. devicectl/web/agents.py +94 -0
  27. devicectl/web/events.py +171 -0
  28. devicectl/web/http.py +243 -0
  29. devicectl/web/progress.py +101 -0
  30. devicectl/web/server.py +1013 -0
  31. devicectl/web/static/core.css +3034 -0
  32. devicectl/web/static/js/api.js +198 -0
  33. devicectl/web/static/js/band.js +640 -0
  34. devicectl/web/static/js/chart.js +400 -0
  35. devicectl/web/static/js/drafts.js +312 -0
  36. devicectl/web/static/js/notify.js +272 -0
  37. devicectl/web/static/js/panels.js +432 -0
  38. devicectl/web/static/js/shell.js +672 -0
  39. devicectl/web/static/js/trace.js +133 -0
  40. devicectl/web/static/js/ui.js +1139 -0
  41. devicectl/web/static/vendor/preact-htm.module.js +27 -0
  42. devicectl/web/worker.py +697 -0
  43. devicectl_core-0.1.0.dist-info/METADATA +131 -0
  44. devicectl_core-0.1.0.dist-info/RECORD +47 -0
  45. devicectl_core-0.1.0.dist-info/WHEEL +4 -0
  46. devicectl_core-0.1.0.dist-info/licenses/LICENSE +287 -0
  47. devicectl_core-0.1.0.dist-info/licenses/NOTICE +13 -0
@@ -0,0 +1,640 @@
1
+ /* A reading, the limits it has to stay inside, and a way to move them.
2
+ *
3
+ * A number on its own does not say whether anything is wrong: 42 °C is
4
+ * fine under a 60 °C over-temperature protection and a fault under a 40 °C
5
+ * one, and 3.45 V per cell is the middle of the curve on one pack and an
6
+ * over-voltage trip on the next. The reading and the setpoints only mean
7
+ * something against each other, so they are drawn in one picture rather
8
+ * than left as a row here and two settings on another tab.
9
+ *
10
+ * Both programs had grown one of these and neither could be used by the
11
+ * other: the charger's was a `.tempband` in its own stylesheet that read
12
+ * two named fields, the battery's was a `Band` of inline styles that took
13
+ * three numbers and drew them. Neither could show more than two limits,
14
+ * and a cell has four -- an over-voltage protection, the voltage it comes
15
+ * back at, and the same pair underneath -- so the battery drew none of
16
+ * them. This is one widget with as many limits as the device has.
17
+ *
18
+ * Every one of those numbers is written above the track, not only offered
19
+ * on hover. A picture that says "the pack is comfortably inside its
20
+ * limits" is worth little if reading *which* limits means chasing a
21
+ * pointer across it, and a hover label says nothing at all to a finger on
22
+ * a phone or to somebody reading a screenshot.
23
+ *
24
+ * And the limits are *draggable*. The numbers are also on a settings tab,
25
+ * in a table, as they have to be -- a number is how you set 3.650 exactly.
26
+ * But somebody deciding where an over-voltage protection belongs is
27
+ * looking at where the cells actually sit, and the picture that shows them
28
+ * that is the place to put the setpoint. Dragging never writes: it edits
29
+ * the caller's draft, the same draft the table edits, and the card's own
30
+ * Apply is still what sends anything to the device.
31
+ */
32
+
33
+ import { Hovered } from '/core/js/chart.js';
34
+ import { fixed } from '/core/js/ui.js';
35
+ import { html, useLayoutEffect, useRef, useState } from '/core/vendor/preact-htm.module.js';
36
+
37
+ function clamp(value, low, high) {
38
+ return Math.min(high, Math.max(low, value));
39
+ }
40
+
41
+ function has(value) {
42
+ return value !== null && value !== undefined && !Number.isNaN(value);
43
+ }
44
+
45
+ /* How many decimals a step implies, so that dragging in steps of 0.001
46
+ * yields 3.451 rather than 3.4510000000000005. A step written in
47
+ * exponential notation has no decimals to count, and falls back to the
48
+ * decimals the caller is displaying. */
49
+ function placesOf(step, digits) {
50
+ const written = String(step);
51
+ if (written.includes('e') || written.includes('E')) return digits;
52
+ return written.split('.')[1]?.length ?? 0;
53
+ }
54
+
55
+ /* One draggable setpoint, or one that is only drawn.
56
+ *
57
+ * `side` is what the limit protects against -- `low` is a floor, `high` a
58
+ * ceiling, anything else a mark that is neither -- and is what decides the
59
+ * calm stretch in the middle: the band where no protection trips runs from
60
+ * the highest floor to the lowest ceiling. `soft` is for the release
61
+ * point beside a protection, the voltage or temperature the device lets
62
+ * the pack work again at. A release is not a limit -- the calm band
63
+ * reaches past it to the protection itself -- so it is drawn as a thin
64
+ * tick and left out of that sum.
65
+ */
66
+ function Grip({
67
+ handle,
68
+ at,
69
+ low,
70
+ high,
71
+ digits,
72
+ unit,
73
+ movable,
74
+ hold,
75
+ onGrab,
76
+ onKey,
77
+ onShow,
78
+ onHide,
79
+ }) {
80
+ const cls = [
81
+ 'grip',
82
+ handle.side === 'low' || handle.side === 'high' ? handle.side : 'mark',
83
+ handle.soft ? 'soft' : '',
84
+ has(handle.saved) && handle.value !== handle.saved ? 'pending' : '',
85
+ movable ? '' : 'fixed',
86
+ ]
87
+ .filter(Boolean)
88
+ .join(' ');
89
+ const style = `left:${(at * 100).toFixed(3)}%`;
90
+ if (!movable) {
91
+ return html`<span class=${cls} style=${style} aria-hidden="true"></span>`;
92
+ }
93
+ return html`<button
94
+ type="button"
95
+ class=${cls}
96
+ style=${style}
97
+ role="slider"
98
+ aria-label=${handle.label}
99
+ aria-valuemin=${low}
100
+ aria-valuemax=${high}
101
+ aria-valuenow=${handle.value}
102
+ aria-valuetext=${`${fixed(handle.value, digits)}${unit ? ` ${unit}` : ''}`}
103
+ ref=${hold}
104
+ onPointerDown=${onGrab}
105
+ onKeyDown=${onKey}
106
+ onFocus=${onShow}
107
+ onBlur=${onHide}
108
+ ></button>`;
109
+ }
110
+
111
+ /* --- the numbers, written out above the track ----------------------------
112
+ *
113
+ * Eight setpoints and a reading is nine numbers to fit along one strip,
114
+ * and on a phone the strip is three hundred pixels. They are laid out in
115
+ * as many rows as it takes for none of them to sit on top of another,
116
+ * nearest the track first, with a hairline from the track up to any number
117
+ * that had to be lifted off it.
118
+ *
119
+ * The width of a label has to be guessed rather than measured: measuring
120
+ * means rendering, reading the boxes back and rendering again, and the
121
+ * second render lands after the first has been painted. A digit at this
122
+ * size is about seven pixels wide and the guess is deliberately a little
123
+ * generous, because the cost of over-estimating is a row that did not have
124
+ * to be there and the cost of under-estimating is two numbers printed on
125
+ * top of each other.
126
+ */
127
+ const CHAR = 7;
128
+ const CHROME = 22;
129
+ const AIRPX = 8;
130
+ const ROW = 15;
131
+ /* What the track is taken to be before it has been measured -- one render,
132
+ * on a page that has not been shown yet. Narrow, so that the first paint
133
+ * errs towards a row too many rather than a collision. */
134
+ const UNMEASURED = 280;
135
+
136
+ /* The width of the track, in pixels, kept current as the window is
137
+ * resized: the same eight numbers need one row across a desk and three
138
+ * across a phone. */
139
+ function useWidth(ref) {
140
+ const [width, setWidth] = useState(0);
141
+ useLayoutEffect(() => {
142
+ const node = ref.current;
143
+ if (!node) return undefined;
144
+ const read = () => setWidth(node.getBoundingClientRect().width);
145
+ read();
146
+ if (typeof ResizeObserver !== 'function') {
147
+ window.addEventListener('resize', read);
148
+ return () => window.removeEventListener('resize', read);
149
+ }
150
+ const watch = new ResizeObserver(read);
151
+ watch.observe(node);
152
+ return () => watch.disconnect();
153
+ }, []);
154
+ return width;
155
+ }
156
+
157
+ /* Where each label goes: its row, and how far along the track it is
158
+ * written. A label belongs over its own mark, except at the ends, where
159
+ * being over the mark would mean half of it outside the picture; those are
160
+ * pushed in far enough to be read. */
161
+ function lay(clusters, width) {
162
+ const rows = [];
163
+ const marks = [];
164
+ for (const cluster of [...clusters].sort((a, b) => a.at - b.at)) {
165
+ const half = (cluster.text.length * CHAR + CHROME) / 2 / width;
166
+ const x = clamp(cluster.at, Math.min(half, 0.5), Math.max(1 - half, 0.5));
167
+ let row = rows.findIndex((edge) => x - half >= edge);
168
+ if (row < 0) {
169
+ rows.push(0);
170
+ row = rows.length - 1;
171
+ }
172
+ rows[row] = x + half + AIRPX / width;
173
+ marks.push({ ...cluster, x, row });
174
+ }
175
+ return { marks, rows: rows.length };
176
+ }
177
+
178
+ /* Where a fraction of the scale is, across the strip the numbers are laid
179
+ * out over.
180
+ *
181
+ * The setpoints and the needle are placed inside the track, and the track
182
+ * has a one-pixel border: a fraction of it is a fraction of the width less
183
+ * two pixels, starting one pixel in. The numbers above it were placed on
184
+ * the whole width, so a hairline dropped from a lifted number landed up to
185
+ * a pixel and a half to the right of the dot it belonged to at the top of
186
+ * the scale -- enough, on a one-pixel line, to be plainly beside the dot
187
+ * rather than on it. This is the track's own arithmetic, done out here. */
188
+ const TRACK_EDGE_PX = 1;
189
+
190
+ function along(fraction) {
191
+ const px = TRACK_EDGE_PX * (1 - 2 * fraction);
192
+ return `calc(${(fraction * 100).toFixed(3)}% + ${px.toFixed(3)}px)`;
193
+ }
194
+
195
+ function tagClass(mark) {
196
+ return [
197
+ 'tag',
198
+ mark.points.some((point) => point.kind === 'read') ? 'read' : '',
199
+ mark.points.some((point) => point.pending) ? 'pending' : '',
200
+ mark.points.length > 1 ? 'many' : '',
201
+ ]
202
+ .filter(Boolean)
203
+ .join(' ');
204
+ }
205
+
206
+ /* The mark beside a number, drawn as whatever it is on the track below:
207
+ * a filled dot for a protection, a hollow one for the release beside it, a
208
+ * grey one for a setpoint that protects against nothing, a bar for the
209
+ * reading. A label carrying two of them is two things set to the same
210
+ * value, which is otherwise one number over one dot with another dot
211
+ * hidden underneath it. */
212
+ function pipClass(point) {
213
+ return ['pip', point.kind, point.soft ? 'soft' : ''].filter(Boolean).join(' ');
214
+ }
215
+
216
+ export function Band({
217
+ min,
218
+ max,
219
+ step = 1,
220
+ digits = 1,
221
+ unit = '',
222
+ now = null,
223
+ nowLabel = 'now',
224
+ spread = null,
225
+ spreadLabel = 'reading',
226
+ handles = [],
227
+ onChange = null,
228
+ label = '',
229
+ caption = null,
230
+ foot = null,
231
+ }) {
232
+ const plot = useRef(null);
233
+ /* Which setpoint the pointer has hold of. A ref rather than state
234
+ * because every move event reads it, and a move that had to wait for a
235
+ * render to know what it was dragging would drop the first few. */
236
+ const dragging = useRef(null);
237
+ /* The grips themselves, by key. A pointer that takes hold of one hands
238
+ * it the keyboard as well -- see `grab` -- and handing the keyboard to
239
+ * something means having the element to hand it to. */
240
+ const grips = useRef(new Map());
241
+ // Which setpoint to name in the label: the one being dragged, or the one
242
+ // the keyboard is on. Both are "this is the one you are working with".
243
+ const [held, setHeld] = useState(null);
244
+ const [pointer, setPointer] = useState(null);
245
+ const width = useWidth(plot);
246
+
247
+ const size = max - min;
248
+ const place = (value) => (size > 0 ? clamp((value - min) / size, 0, 1) : 0);
249
+ const num = (value) => fixed(value, digits);
250
+ const say = (value) => `${num(value)}${unit ? ` ${unit}` : ''}`;
251
+ /* How coarse one setpoint is, which is not always how coarse the band
252
+ * is. A band is drawn to one scale and its setpoints need not share a
253
+ * resolution: a JK board keeps its cell voltages to a millivolt and the
254
+ * voltage balancing starts at to ten, on the same volts. Move the
255
+ * coarse one by the fine one's step and the value it is written back at
256
+ * rounds the move straight back out -- a keypress that does nothing at
257
+ * all, on the setpoint least able to be dragged to where it belongs. */
258
+ const stepOf = (handle) => (has(handle.step) && handle.step > 0 ? handle.step : step);
259
+ const grain = (handle) => fixed(stepOf(handle), placesOf(stepOf(handle), digits));
260
+
261
+ const set = handles.filter((h) => has(h.value));
262
+ const editable = (handle) => Boolean(onChange) && !handle.fixed;
263
+ const floors = set.filter((h) => h.side === 'low' && !h.soft).map((h) => h.value);
264
+ const ceilings = set.filter((h) => h.side === 'high' && !h.soft).map((h) => h.value);
265
+ /* The calm stretch is only drawn where there is something to be outside
266
+ * of. A card of voltages the pack is *meant* to reach -- where a charge
267
+ * stops, what counts as full -- has no floor and no ceiling, and filling
268
+ * its whole track green would say the opposite of what it means: that
269
+ * every value on the scale is fine, on a scale drawn precisely because
270
+ * some of them are not. */
271
+ const guarded = floors.length > 0 || ceilings.length > 0;
272
+ const safeFrom = floors.length ? Math.max(...floors) : min;
273
+ const safeTo = ceilings.length ? Math.min(...ceilings) : max;
274
+ const under = has(now) && floors.length > 0 && now < safeFrom;
275
+ const over = has(now) && ceilings.length > 0 && now > safeTo;
276
+ const ranged = Boolean(spread) && has(spread?.low) && has(spread?.high);
277
+
278
+ /* Everything that has a place on the track: every setpoint, and the
279
+ * reading -- one value on a pack, a stretch on its cells, because
280
+ * sixteen cells are not one voltage and their average hides the one that
281
+ * is running away. */
282
+ const points = set.map((handle) => ({
283
+ key: handle.key,
284
+ value: handle.value,
285
+ text: num(handle.value),
286
+ kind: handle.side === 'low' || handle.side === 'high' ? handle.side : 'mark',
287
+ soft: Boolean(handle.soft),
288
+ pending: has(handle.saved) && handle.value !== handle.saved,
289
+ heading: handle.label,
290
+ lines: [
291
+ say(handle.value),
292
+ has(handle.saved) && handle.value !== handle.saved ? `was ${say(handle.saved)}` : null,
293
+ handle.note || null,
294
+ ].filter(Boolean),
295
+ }));
296
+ if (has(now)) {
297
+ points.push({
298
+ key: '.now',
299
+ value: now,
300
+ text: num(now),
301
+ kind: 'read',
302
+ soft: false,
303
+ pending: false,
304
+ heading: nowLabel,
305
+ lines: [say(now), under ? 'below the band' : over ? 'above the band' : null].filter(Boolean),
306
+ });
307
+ }
308
+ if (ranged) {
309
+ points.push({
310
+ key: '.spread',
311
+ value: (spread.low + spread.high) / 2,
312
+ text: `${num(spread.low)} … ${num(spread.high)}`,
313
+ kind: 'read',
314
+ soft: false,
315
+ pending: false,
316
+ heading: spreadLabel,
317
+ lines: [`${say(spread.low)} … ${say(spread.high)}`],
318
+ });
319
+ }
320
+
321
+ /* Two setpoints holding the same value are two dots drawn on top of one
322
+ * another under a single number, which reads as one setpoint unless it
323
+ * says otherwise -- and "the release is still where the protection is"
324
+ * is exactly the thing somebody setting these needs to see. So numbers
325
+ * that agree are written once, with a mark for each of the things at
326
+ * that value. */
327
+ const clusters = [];
328
+ const byText = new Map();
329
+ for (const point of points) {
330
+ const found = byText.get(point.text);
331
+ if (found) {
332
+ found.points.push(point);
333
+ continue;
334
+ }
335
+ const made = { key: point.key, text: point.text, at: place(point.value), points: [point] };
336
+ byText.set(point.text, made);
337
+ clusters.push(made);
338
+ }
339
+
340
+ /* What the pointer can be told about, each at its own place on the
341
+ * track. The label names whichever of them it is nearest, which is what
342
+ * makes hovering the track answer "what is it now" and hovering a
343
+ * setpoint answer "what is this one set to" -- and, where several of
344
+ * them sit together, names all of them rather than whichever of the two
345
+ * happened to be a pixel nearer. */
346
+ const features = clusters.map((cluster) =>
347
+ cluster.points.length === 1
348
+ ? { ...cluster, heading: cluster.points[0].heading, lines: cluster.points[0].lines }
349
+ : {
350
+ ...cluster,
351
+ heading: `${cluster.text}${unit ? ` ${unit}` : ''}`,
352
+ lines: cluster.points.map((point) =>
353
+ point.pending ? `${point.heading} (unsent)` : point.heading
354
+ ),
355
+ }
356
+ );
357
+
358
+ const { marks, rows } = lay(clusters, width || UNMEASURED);
359
+
360
+ const nearest = (fraction) => {
361
+ let best = null;
362
+ for (const feature of features) {
363
+ const gap = Math.abs(feature.at - fraction);
364
+ if (best === null || gap < best.gap) best = { gap, feature };
365
+ }
366
+ return best?.feature ?? null;
367
+ };
368
+
369
+ const fractionOf = (event) => {
370
+ const rect = plot.current?.getBoundingClientRect();
371
+ if (!rect || rect.width <= 0) return null;
372
+ return clamp((event.clientX - rect.left) / rect.width, 0, 1);
373
+ };
374
+
375
+ const floorOf = (handle) => (has(handle.min) ? Math.max(min, handle.min) : min);
376
+ const ceilingOf = (handle) => (has(handle.max) ? Math.min(max, handle.max) : max);
377
+
378
+ const snap = (handle, raw) => {
379
+ const by = stepOf(handle);
380
+ const inside = clamp(raw, floorOf(handle), ceilingOf(handle));
381
+ const stepped = clamp(Math.round(inside / by) * by, floorOf(handle), ceilingOf(handle));
382
+ return Number(stepped.toFixed(placesOf(by, digits)));
383
+ };
384
+
385
+ const moveTo = (handle, fraction) => {
386
+ const wanted = snap(handle, min + fraction * size);
387
+ if (wanted !== handle.value) onChange(handle.key, wanted);
388
+ };
389
+
390
+ /* Anywhere on the track takes hold of the nearest setpoint, not only the
391
+ * dot itself. A dot is nine pixels wide and a fingertip is not, and the
392
+ * question being asked -- "put the protection about here" -- is answered
393
+ * by where the pointer went down, whichever mark was closest to it. */
394
+ const grab = (event, only) => {
395
+ if (!onChange) return;
396
+ const fraction = fractionOf(event);
397
+ if (fraction === null) return;
398
+ const movable = handles.filter((h) => editable(h) && has(h.value));
399
+ if (!movable.length) return;
400
+ const wanted = only
401
+ ? movable.find((h) => h.key === only)
402
+ : movable.reduce((best, h) =>
403
+ Math.abs(place(h.value) - fraction) < Math.abs(place(best.value) - fraction) ? h : best
404
+ );
405
+ if (!wanted) return;
406
+ event.preventDefault();
407
+ /* A press on a grip runs this twice -- once from the grip, once from
408
+ * the track it bubbles up to -- and the second pass picks the nearest
409
+ * setpoint rather than the one that was pressed. Those are the same
410
+ * setpoint except where two sit on one value, which is precisely the
411
+ * case somebody is trying to pull apart. So a press that named its
412
+ * grip keeps it. */
413
+ if (only) event.stopPropagation();
414
+ /* Pressing a grip is how somebody says which setpoint they mean, so it
415
+ * is also how the keyboard is aimed: the grip takes focus, and the
416
+ * arrow keys go on moving the same setpoint after the hand lets go.
417
+ * Dragging puts a setpoint roughly where it belongs and cannot do
418
+ * better than a pixel -- which is 0.02 V on a cell band -- and the
419
+ * last three decimals are then a few taps rather than a trip to the
420
+ * table underneath. `preventDefault` above is what stops the text
421
+ * under the pointer being selected as it moves; it also stops the
422
+ * button being focused, so focus is given here instead. */
423
+ grips.current.get(wanted.key)?.focus?.({ preventScroll: true });
424
+ dragging.current = wanted.key;
425
+ setHeld(wanted.key);
426
+ setPointer(fraction);
427
+ plot.current?.setPointerCapture?.(event.pointerId);
428
+ moveTo(wanted, fraction);
429
+ };
430
+
431
+ const move = (event) => {
432
+ const fraction = fractionOf(event);
433
+ if (fraction === null) return;
434
+ setPointer(fraction);
435
+ const key = dragging.current;
436
+ if (key === null) return;
437
+ const handle = handles.find((h) => h.key === key);
438
+ if (handle) moveTo(handle, fraction);
439
+ };
440
+
441
+ const release = (event) => {
442
+ if (dragging.current === null) return;
443
+ plot.current?.releasePointerCapture?.(event.pointerId);
444
+ const key = dragging.current;
445
+ dragging.current = null;
446
+ // Let go of the label only if the grip let go of the keyboard. The
447
+ // one still holding it is still the one being worked on, and its
448
+ // label is where the step the arrow keys move by is written.
449
+ if (grips.current.get(key) !== document.activeElement) setHeld(null);
450
+ };
451
+
452
+ /* The keyboard moves a setpoint too. A picture that can only be worked
453
+ * with a mouse is a picture that half the settings on the page cannot be
454
+ * reached through, and the arrow keys are what a slider answers to
455
+ * everywhere else on the page. */
456
+ const key = (handle) => (event) => {
457
+ const by =
458
+ {
459
+ ArrowLeft: -1,
460
+ ArrowDown: -1,
461
+ ArrowRight: 1,
462
+ ArrowUp: 1,
463
+ PageDown: -10,
464
+ PageUp: 10,
465
+ }[event.key] ?? 0;
466
+ if (by) {
467
+ event.preventDefault();
468
+ onChange(handle.key, snap(handle, handle.value + by * stepOf(handle)));
469
+ return;
470
+ }
471
+ if (event.key === 'Home' || event.key === 'End') {
472
+ event.preventDefault();
473
+ onChange(handle.key, snap(handle, event.key === 'Home' ? -Infinity : Infinity));
474
+ }
475
+ };
476
+
477
+ // The one being worked on wins over the one the pointer happens to be
478
+ // nearest: while a setpoint is being dragged, the label is about it.
479
+ const shown = held
480
+ ? (features.find((f) => f.points.some((point) => point.key === held)) ?? null)
481
+ : pointer === null
482
+ ? null
483
+ : nearest(pointer);
484
+ /* And while one is being worked on, its label says what the keyboard
485
+ * would do to it. Nothing else on the page can say this: the arrow keys
486
+ * work on any slider anywhere, and a reader who has just dragged one has
487
+ * no reason to suspect that the thing they dragged is also the thing
488
+ * that will take 0.001 V off the value. Writing the step out is also
489
+ * the only place the page says what the step *is*. */
490
+ const holding = held ? (handles.find((handle) => handle.key === held) ?? null) : null;
491
+ const hint =
492
+ holding && editable(holding)
493
+ ? `arrow keys: ±${grain(holding)}${unit ? ` ${unit}` : ''}`
494
+ : null;
495
+ const reading = has(now)
496
+ ? `${say(now)}, `
497
+ : ranged
498
+ ? `${say(spread.low)} to ${say(spread.high)}, `
499
+ : '';
500
+ /* The numbers above the track are the same numbers as these, so they are
501
+ * written for the reader who cannot see them rather than read out twice
502
+ * over. */
503
+ const spoken = [
504
+ `${label ? `${label}: ` : ''}${reading}between ${say(min)} and ${say(max)}`,
505
+ ...set
506
+ .filter((handle) => !editable(handle))
507
+ .map((handle) => `${handle.label} ${say(handle.value)}`),
508
+ ].join('; ');
509
+
510
+ return html`<div class="chart band">
511
+ ${caption ? html`<div class="cap">${caption}</div>` : null}
512
+ ${marks.length
513
+ ? html`<div class="marks" style=${`height:${rows * ROW}px`} aria-hidden="true">
514
+ ${marks
515
+ .filter((mark) => mark.row > 0)
516
+ .map(
517
+ (mark) => html`<span
518
+ class="stem"
519
+ key=${`stem:${mark.key}`}
520
+ style=${`left:${along(mark.at)};height:${mark.row * ROW}px`}
521
+ ></span>`
522
+ )}
523
+ ${marks.map(
524
+ (mark) => html`<span
525
+ class=${tagClass(mark)}
526
+ key=${mark.key}
527
+ style=${`left:${along(mark.x)};bottom:${mark.row * ROW}px`}
528
+ title=${mark.points.map((point) => point.heading).join(' · ')}
529
+ >
530
+ ${mark.points.map(
531
+ (point) => html`<span class=${pipClass(point)} key=${point.key}></span>`
532
+ )}
533
+ ${mark.text}
534
+ </span>`
535
+ )}
536
+ </div>`
537
+ : null}
538
+ <div
539
+ class=${`plot${onChange ? ' settable' : ''}`}
540
+ ref=${plot}
541
+ onPointerDown=${(event) => grab(event, null)}
542
+ onPointerMove=${move}
543
+ onPointerUp=${release}
544
+ onPointerCancel=${release}
545
+ onPointerLeave=${() => dragging.current === null && setPointer(null)}
546
+ >
547
+ <div class="track" role="img" aria-label=${spoken}>
548
+ ${guarded &&
549
+ safeTo > safeFrom &&
550
+ html`<span
551
+ class="safe"
552
+ style=${`left:${(place(safeFrom) * 100).toFixed(3)}%;right:${(100 - place(safeTo) * 100).toFixed(3)}%`}
553
+ ></span>`}
554
+ ${ranged &&
555
+ html`<span
556
+ class="spread"
557
+ style=${`left:${(place(spread.low) * 100).toFixed(3)}%;right:${(100 - place(spread.high) * 100).toFixed(3)}%`}
558
+ ></span>`}
559
+ ${has(now) &&
560
+ html`<span
561
+ class=${`needle${under || over ? ' bad' : ''}`}
562
+ style=${`left:${(place(now) * 100).toFixed(3)}%`}
563
+ ></span>`}
564
+ ${set.map(
565
+ (handle) => html`<${Grip}
566
+ key=${handle.key}
567
+ handle=${handle}
568
+ at=${place(handle.value)}
569
+ low=${floorOf(handle)}
570
+ high=${ceilingOf(handle)}
571
+ digits=${digits}
572
+ unit=${unit}
573
+ movable=${editable(handle)}
574
+ hold=${(node) => {
575
+ if (node) grips.current.set(handle.key, node);
576
+ else grips.current.delete(handle.key);
577
+ }}
578
+ onGrab=${(event) => grab(event, handle.key)}
579
+ onKey=${key(handle)}
580
+ onShow=${() => setHeld(handle.key)}
581
+ onHide=${() => dragging.current === null && setHeld(null)}
582
+ />`
583
+ )}
584
+ </div>
585
+ ${shown &&
586
+ html`<${Hovered}
587
+ x=${shown.at}
588
+ y=${null}
589
+ rule=${false}
590
+ below=${true}
591
+ heading=${shown.heading}
592
+ lines=${hint ? [...shown.lines, hint] : shown.lines}
593
+ />`}
594
+ </div>
595
+ <div class="scale">
596
+ <span>${say(min)}</span>
597
+ ${foot ? html`<span class="mid">${foot}</span>` : null}
598
+ <span>${say(max)}</span>
599
+ </div>
600
+ </div>`;
601
+ }
602
+
603
+ /* How much wider than the numbers on it the track is drawn, when the
604
+ * caller does not say. It is what a setpoint can be dragged into: room to
605
+ * move, without the scale opening out so far that the setpoints crowd
606
+ * together in the middle of it. Past the end of it there is the number in
607
+ * the row, which is how an exact value is set in any case. */
608
+ const AIR = 0.15;
609
+
610
+ /* The track the setpoints and the reading are drawn on.
611
+ *
612
+ * The ends are not the device's own limits. A charger whose alarm is set
613
+ * to 100 °C on a track that stops at 80 would draw that setpoint off the
614
+ * end of its own picture, or -- worse -- pinned to the end, which is a
615
+ * control lying about what it is set to. So the track always reaches
616
+ * every number it has to show, with a little air either side.
617
+ *
618
+ * What it is measured from matters more than it looks. Measure it from
619
+ * the values being dragged and the scale moves under the hand doing the
620
+ * dragging: pull a setpoint inward and everything else slides outward to
621
+ * fill the space it left. Worse, at the very end of the track the value
622
+ * *is* the end, so each move would push the end out and the next move
623
+ * would land further out again -- a setpoint that climbs on its own for as
624
+ * long as the pointer is held against the edge. So a caller measures this
625
+ * from what the device is holding, and widens the result for anything a
626
+ * draft has put outside it: the ends then stand still while a setpoint is
627
+ * dragged between them, and a value typed into the row beside the picture
628
+ * is still somewhere on it.
629
+ */
630
+ export function trackFor(values, { min = null, max = null, pad = null, round = 1 } = {}) {
631
+ const known = values.filter(has);
632
+ const low = Math.min(...(has(min) ? [min] : []), ...known);
633
+ const high = Math.max(...(has(max) ? [max] : []), ...known);
634
+ if (!Number.isFinite(low) || !Number.isFinite(high)) return null;
635
+ const air = has(pad) ? pad : (high - low) * AIR || round;
636
+ return {
637
+ min: Math.floor((low - air) / round) * round,
638
+ max: Math.ceil((high + air) / round) * round,
639
+ };
640
+ }