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.
- devicectl/__init__.py +18 -0
- devicectl/cli/__init__.py +1 -0
- devicectl/cli/command.py +95 -0
- devicectl/cli/exits.py +32 -0
- devicectl/cli/fanout.py +142 -0
- devicectl/cli/main.py +69 -0
- devicectl/cli/output.py +299 -0
- devicectl/cli/parser.py +80 -0
- devicectl/cli/report.py +86 -0
- devicectl/cli/target.py +26 -0
- devicectl/clock.py +57 -0
- devicectl/devtools/__init__.py +6 -0
- devicectl/devtools/frontlint.py +935 -0
- devicectl/devtools/htmcheck.py +396 -0
- devicectl/devtools/rendercheck.py +384 -0
- devicectl/doctor.py +112 -0
- devicectl/errors.py +68 -0
- devicectl/fields.py +564 -0
- devicectl/meta.py +64 -0
- devicectl/paths.py +40 -0
- devicectl/progress.py +77 -0
- devicectl/report.py +67 -0
- devicectl/testing.py +199 -0
- devicectl/trace.py +333 -0
- devicectl/web/__init__.py +1 -0
- devicectl/web/agents.py +94 -0
- devicectl/web/events.py +171 -0
- devicectl/web/http.py +243 -0
- devicectl/web/progress.py +101 -0
- devicectl/web/server.py +1013 -0
- devicectl/web/static/core.css +3034 -0
- devicectl/web/static/js/api.js +198 -0
- devicectl/web/static/js/band.js +640 -0
- devicectl/web/static/js/chart.js +400 -0
- devicectl/web/static/js/drafts.js +312 -0
- devicectl/web/static/js/notify.js +272 -0
- devicectl/web/static/js/panels.js +432 -0
- devicectl/web/static/js/shell.js +672 -0
- devicectl/web/static/js/trace.js +133 -0
- devicectl/web/static/js/ui.js +1139 -0
- devicectl/web/static/vendor/preact-htm.module.js +27 -0
- devicectl/web/worker.py +697 -0
- devicectl_core-0.1.0.dist-info/METADATA +131 -0
- devicectl_core-0.1.0.dist-info/RECORD +47 -0
- devicectl_core-0.1.0.dist-info/WHEEL +4 -0
- devicectl_core-0.1.0.dist-info/licenses/LICENSE +287 -0
- 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
|
+
}
|