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,400 @@
|
|
|
1
|
+
/* What the plots on a page have in common: where the pointer is over one,
|
|
2
|
+
* and the reading that follows it.
|
|
3
|
+
*
|
|
4
|
+
* These charts draw an SVG stretched to the card's width
|
|
5
|
+
* (`preserveAspectRatio: none`), which is what lets them be laid out in
|
|
6
|
+
* CSS and keep their stroke weights -- and which also means nothing drawn
|
|
7
|
+
* inside them keeps its shape. A circle marking the hovered sample would
|
|
8
|
+
* come out an ellipse, and a differently-shaped ellipse in every card. So
|
|
9
|
+
* everything that has to stay round or stay thin is HTML positioned over
|
|
10
|
+
* the plot in percentages, and the SVG holds only what is allowed to
|
|
11
|
+
* stretch: the line and the area under it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { fmt } from '/core/js/ui.js';
|
|
15
|
+
import { html, useRef, useState } from '/core/vendor/preact-htm.module.js';
|
|
16
|
+
|
|
17
|
+
/* How far from either edge the floating label is allowed to be centred.
|
|
18
|
+
* Past this it stops following the pointer and leans in, so a reading
|
|
19
|
+
* taken at the very start or end of a plot is still inside the card. */
|
|
20
|
+
const TIP_EDGE = 0.14;
|
|
21
|
+
|
|
22
|
+
/* Where the pointer is across a plot, as a fraction of its width.
|
|
23
|
+
*
|
|
24
|
+
* `null` when it is not over one at all, which is most of the time and is
|
|
25
|
+
* what tells every caller to draw none of this. Touch counts as a
|
|
26
|
+
* pointer: a finger dragged along the line reads it the same way, which
|
|
27
|
+
* on a phone is the only way to read it at all.
|
|
28
|
+
*/
|
|
29
|
+
export function useHover() {
|
|
30
|
+
const [at, setAt] = useState(null);
|
|
31
|
+
const box = useRef(null);
|
|
32
|
+
|
|
33
|
+
const track = (event) => {
|
|
34
|
+
const rect = box.current?.getBoundingClientRect();
|
|
35
|
+
if (!rect || rect.width <= 0) return;
|
|
36
|
+
const point = event.touches?.[0] || event;
|
|
37
|
+
const x = point.clientX - rect.left;
|
|
38
|
+
setAt(Math.min(1, Math.max(0, x / rect.width)));
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
at,
|
|
43
|
+
box,
|
|
44
|
+
/* Spread onto whatever element owns the plot's width. */
|
|
45
|
+
on: {
|
|
46
|
+
onMouseMove: track,
|
|
47
|
+
onMouseLeave: () => setAt(null),
|
|
48
|
+
onTouchStart: track,
|
|
49
|
+
onTouchMove: track,
|
|
50
|
+
onTouchEnd: () => setAt(null),
|
|
51
|
+
onTouchCancel: () => setAt(null),
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* Where the pointer is across the *drawing*, rather than across the box
|
|
57
|
+
* that holds it.
|
|
58
|
+
*
|
|
59
|
+
* A chart that insets what it draws by a few units at each end and
|
|
60
|
+
* then asks `useHover` -- which measures the whole box -- which sample the
|
|
61
|
+
* pointer was over. So the pointer's 0 was the box's left edge while the
|
|
62
|
+
* first sample sat a pad in from it, and the two disagreed by a pad's
|
|
63
|
+
* worth all the way across: at the left of the chart the marker was to the
|
|
64
|
+
* right of the cursor, at the right of it the marker was to the left, and
|
|
65
|
+
* in the middle they met. This is the same fraction measured against the
|
|
66
|
+
* region the samples are actually in.
|
|
67
|
+
*
|
|
68
|
+
* Clamped, because the pad itself is outside that region and a pointer in
|
|
69
|
+
* it is still pointing at the nearest end.
|
|
70
|
+
*/
|
|
71
|
+
export function acrossPlot(at, pad, width) {
|
|
72
|
+
if (at === null || at === undefined) return null;
|
|
73
|
+
const inner = width - 2 * pad;
|
|
74
|
+
if (inner <= 0) return null;
|
|
75
|
+
return Math.min(1, Math.max(0, (at * width - pad) / inner));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* The rule under the pointer, and the reading it is pointing at.
|
|
79
|
+
*
|
|
80
|
+
* `at` is where the pointer is; `x` is where the *sample* is, which is not
|
|
81
|
+
* the same thing -- the rule snaps to the reading it is naming, or the
|
|
82
|
+
* chart would claim a value at a moment nothing was measured. `y`, when a
|
|
83
|
+
* caller has one, puts a dot on the line itself.
|
|
84
|
+
*
|
|
85
|
+
* `rule` is for a chart that already shows what the pointer is on. A line
|
|
86
|
+
* has nothing to mark the moment with, so it gets the vertical rule; a bar
|
|
87
|
+
* chart lights up the bar itself, and a rule down the middle of a lit bar
|
|
88
|
+
* is a second answer to a question already answered -- one that, on a wide
|
|
89
|
+
* bar, sits half a bar away from the cursor and reads as a mistake.
|
|
90
|
+
*
|
|
91
|
+
* `below` puts the label under the plot instead of over it. The rule is
|
|
92
|
+
* the same either way: cover the least. A plot is a tall rectangle full of
|
|
93
|
+
* the shape being explained, so the label goes above it; a band is a strip
|
|
94
|
+
* eight pixels high with a card of readings above it and its own scale
|
|
95
|
+
* underneath, so the label goes below and covers two numbers that are
|
|
96
|
+
* printed on the label itself.
|
|
97
|
+
*/
|
|
98
|
+
export function Hovered({ x, y, heading, lines, rule = true, below = false }) {
|
|
99
|
+
const lean = Math.min(1 - TIP_EDGE, Math.max(TIP_EDGE, x));
|
|
100
|
+
return html`<div class="hover" aria-hidden="true">
|
|
101
|
+
${rule && html`<span class="rule" style=${`left:${(x * 100).toFixed(2)}%`}></span>`}
|
|
102
|
+
${y !== null &&
|
|
103
|
+
y !== undefined &&
|
|
104
|
+
html`<span
|
|
105
|
+
class="spot"
|
|
106
|
+
style=${`left:${(x * 100).toFixed(2)}%;top:${(y * 100).toFixed(2)}%`}
|
|
107
|
+
></span>`}
|
|
108
|
+
<span class=${below ? 'tip below' : 'tip'} style=${`left:${(lean * 100).toFixed(2)}%`}>
|
|
109
|
+
<span class="when">${heading}</span>
|
|
110
|
+
${lines.map((line) => html`<span class="what" key=${line}>${line}</span>`)}
|
|
111
|
+
</span>
|
|
112
|
+
</div>`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* A clock time, in the reader's own zone: a chart of the last half hour is
|
|
116
|
+
* read against the wall, not against an ISO stamp. */
|
|
117
|
+
export function clockTime(seconds) {
|
|
118
|
+
return new Date(seconds * 1000).toLocaleTimeString();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* --- a reading over time -------------------------------------------------
|
|
122
|
+
*
|
|
123
|
+
* The one plot both programs draw: what a number has been doing for as long
|
|
124
|
+
* as the page has been watching it, with the moments something was written
|
|
125
|
+
* marked on it. It answers the question no single live number can --
|
|
126
|
+
* "I changed something, did anything happen" -- because the device itself
|
|
127
|
+
* keeps no history a browser can ask for. Both had written it out, and the
|
|
128
|
+
* copies had not aged alike: one grew an area fill, a hover reading, a key
|
|
129
|
+
* and a scale, and the other was still four lines of SVG drawn in colours
|
|
130
|
+
* that no longer existed in either palette, which is a line the browser
|
|
131
|
+
* resolves to black and a chart that appears not to work at all.
|
|
132
|
+
*
|
|
133
|
+
* What is *not* here is where the samples come from. One program keeps its
|
|
134
|
+
* own in the tab as the charger reports; the other reads a window the
|
|
135
|
+
* server has been recording all along. That is the half that is genuinely
|
|
136
|
+
* different, and it stays with each of them.
|
|
137
|
+
*
|
|
138
|
+
* A chart may carry a second reading (`also`), drawn in its own colour on
|
|
139
|
+
* its own scale. Both programs wanted the same second one -- the
|
|
140
|
+
* temperature beside the power -- and both would otherwise have had to
|
|
141
|
+
* spend a whole card on a single flat line. They cannot share an axis,
|
|
142
|
+
* since a kilowatt and a degree have no common scale, so the key under the
|
|
143
|
+
* plot names each line and prints the range it is drawn against.
|
|
144
|
+
*/
|
|
145
|
+
|
|
146
|
+
/* The drawing's own units. It is stretched to the card's width, so these
|
|
147
|
+
* are a shape rather than a size -- see the note at the top of this file. */
|
|
148
|
+
const W = 600;
|
|
149
|
+
const H = 120;
|
|
150
|
+
const PAD = 6;
|
|
151
|
+
|
|
152
|
+
/* The narrowest window the chart will draw. Without it the first three
|
|
153
|
+
* samples would be stretched across the whole width, and a line whose shape
|
|
154
|
+
* changes because more of it arrived is a line that lies twice. */
|
|
155
|
+
const MIN_SPAN_S = 180;
|
|
156
|
+
|
|
157
|
+
/* The sample nearest a moment, which is what the pointer is really asking
|
|
158
|
+
* for. A chart of one reading every three seconds has gaps in it -- a
|
|
159
|
+
* paused refresh, a device that took a while to answer -- and a cursor that
|
|
160
|
+
* interpolated across one would be inventing a measurement. This names a
|
|
161
|
+
* reading that was actually taken. */
|
|
162
|
+
function nearest(samples, when) {
|
|
163
|
+
let best = samples[0];
|
|
164
|
+
for (const sample of samples) {
|
|
165
|
+
if (Math.abs(sample.t - when) < Math.abs(best.t - when)) best = sample;
|
|
166
|
+
}
|
|
167
|
+
return best;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
/* Only the readings that are numbers, oldest first. A device that did not
|
|
172
|
+
* answer for a field leaves a hole, and a hole drawn as zero is a fault the
|
|
173
|
+
* pack never had. */
|
|
174
|
+
function numbers(samples) {
|
|
175
|
+
return (samples || []).filter((s) => typeof s.v === 'number');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* The scale one trace is drawn on.
|
|
179
|
+
*
|
|
180
|
+
* Two rules, because there are two kinds of reading here. A *flow* -- the
|
|
181
|
+
* power into and out of a pack -- is drawn against zero: which side of the
|
|
182
|
+
* line it is on is half of what it says, and the area is filled from there.
|
|
183
|
+
* A *level* -- a temperature -- has no zero worth drawing: a pack sitting
|
|
184
|
+
* between 18 and 24 degrees on an axis that starts at zero is a flat line
|
|
185
|
+
* in the top quarter of the box, which is a chart of nothing. So a level
|
|
186
|
+
* gets a scale rounded outwards to the nearest step around what it did,
|
|
187
|
+
* and the scale is printed underneath, because a plot with a floating
|
|
188
|
+
* baseline exaggerates everything on it unless it says so.
|
|
189
|
+
*/
|
|
190
|
+
function scaleOf(values, { step, floor, zero }) {
|
|
191
|
+
const hi = Math.max(...values);
|
|
192
|
+
const lo = Math.min(...values);
|
|
193
|
+
if (zero) {
|
|
194
|
+
const top = Math.max(floor, Math.ceil(Math.max(hi, 0) / step) * step) || step;
|
|
195
|
+
return { top, bottom: lo < 0 ? -Math.ceil(-lo / step) * step : 0 };
|
|
196
|
+
}
|
|
197
|
+
const top = Math.ceil(hi / step) * step;
|
|
198
|
+
const bottom = Math.floor(lo / step) * step;
|
|
199
|
+
return top > bottom ? { top, bottom } : { top: bottom + step, bottom };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* One trace, worked out: what of it is in the window, the scale it needs,
|
|
203
|
+
* where a value sits on that scale, and how to say one in words. `null`
|
|
204
|
+
* when there is not enough of it in the window to draw a line at all,
|
|
205
|
+
* which is what a second reading the device stopped answering for does. */
|
|
206
|
+
function plotted(spec, x, from) {
|
|
207
|
+
const shown = spec.points.filter((s) => s.t >= from);
|
|
208
|
+
if (shown.length < 2) return null;
|
|
209
|
+
const values = shown.map((s) => s.v);
|
|
210
|
+
const { top, bottom } = scaleOf(values, spec);
|
|
211
|
+
const y = (v) => H - PAD - ((v - bottom) / (top - bottom)) * (H - 2 * PAD);
|
|
212
|
+
return {
|
|
213
|
+
...spec,
|
|
214
|
+
shown,
|
|
215
|
+
values,
|
|
216
|
+
top,
|
|
217
|
+
bottom,
|
|
218
|
+
y,
|
|
219
|
+
say: (v) => `${fmt(v, spec.digits)}${spec.unit ? ` ${spec.unit}` : ''}`,
|
|
220
|
+
/* A whole-numbered step means a whole-numbered axis: "2 kW full scale"
|
|
221
|
+
* rather than "2.00 kW full scale" under a line read to two places. */
|
|
222
|
+
scaleDigits: Number.isInteger(spec.step) ? 0 : spec.digits,
|
|
223
|
+
line: shown.map((s) => `${x(s.t).toFixed(1)},${y(s.v).toFixed(1)}`).join(' '),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/* What a trace's end of the scale says, under the plot. */
|
|
228
|
+
function scaleSaid(p) {
|
|
229
|
+
return p.zero && p.bottom >= 0
|
|
230
|
+
? `${fmt(p.top, p.scaleDigits)} ${p.unit} full scale`
|
|
231
|
+
: `${fmt(p.bottom, p.scaleDigits)}–${fmt(p.top, p.scaleDigits)} ${p.unit}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* What the pointer is over, if it is over anything: the reading nearest the
|
|
235
|
+
* moment under it, placed by where that reading actually is rather than by
|
|
236
|
+
* where the pointer is. */
|
|
237
|
+
export function Series({
|
|
238
|
+
samples,
|
|
239
|
+
marks = [],
|
|
240
|
+
unit = '',
|
|
241
|
+
digits = 1,
|
|
242
|
+
/* What the full scale is rounded up to. A ceiling that does not twitch:
|
|
243
|
+
* rounding to a whole kilowatt means the line's height means the same
|
|
244
|
+
* thing between one look and the next, instead of rescaling every time
|
|
245
|
+
* the car takes another 40 W. */
|
|
246
|
+
step = 1,
|
|
247
|
+
/* The smallest full scale. A pack drawing 40 W on an axis that ends at
|
|
248
|
+
* 40 W is a chart of noise drawn as a mountain range. */
|
|
249
|
+
floor = 0,
|
|
250
|
+
/* A second reading on the same time axis and its own scale: the same
|
|
251
|
+
* `{samples, unit, digits, step}`, plus the `label` the key names it by.
|
|
252
|
+
*
|
|
253
|
+
* On the same picture rather than in a card of its own because the
|
|
254
|
+
* question it answers is a question about the first one -- did that hour
|
|
255
|
+
* of charging warm anything up -- and two charts one above the other are
|
|
256
|
+
* read by remembering the top one. What keeps them apart is colour and
|
|
257
|
+
* the key under them, since they cannot share an axis: a kilowatt and a
|
|
258
|
+
* degree have no common scale, and pretending otherwise would be the one
|
|
259
|
+
* dishonest thing a chart can do. */
|
|
260
|
+
also = null,
|
|
261
|
+
label = '',
|
|
262
|
+
minSpanS = MIN_SPAN_S,
|
|
263
|
+
empty,
|
|
264
|
+
what = 'the reading',
|
|
265
|
+
}) {
|
|
266
|
+
/* Every hook first, and unconditionally: the empty chart below returns
|
|
267
|
+
* before the drawing does, and a hook behind an early return belongs to a
|
|
268
|
+
* different slot on the render that has samples. */
|
|
269
|
+
const hover = useHover();
|
|
270
|
+
const main = numbers(samples);
|
|
271
|
+
const other = also ? numbers(also.samples) : [];
|
|
272
|
+
if (main.length < 2) {
|
|
273
|
+
return html`<div class="chart empty-chart">${empty}</div>`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/* The window is the widest either trace needs. Measured across both so
|
|
277
|
+
* that a second reading the device only started answering for a minute
|
|
278
|
+
* ago does not crop an hour of the first one. */
|
|
279
|
+
const ends = [main[main.length - 1].t, ...(other.length ? [other[other.length - 1].t] : [])];
|
|
280
|
+
const starts = [main[0].t, ...(other.length ? [other[0].t] : [])];
|
|
281
|
+
const now = Math.max(...ends);
|
|
282
|
+
const span = Math.max(minSpanS, now - Math.min(...starts));
|
|
283
|
+
const from = now - span;
|
|
284
|
+
|
|
285
|
+
const x = (t) => PAD + ((t - from) / span) * (W - 2 * PAD);
|
|
286
|
+
const first = plotted(
|
|
287
|
+
{ points: main, unit, digits, step, floor, zero: true, label: label || what },
|
|
288
|
+
x,
|
|
289
|
+
from
|
|
290
|
+
);
|
|
291
|
+
const second = also
|
|
292
|
+
? plotted(
|
|
293
|
+
{
|
|
294
|
+
points: other,
|
|
295
|
+
unit: also.unit || '',
|
|
296
|
+
digits: also.digits ?? 1,
|
|
297
|
+
step: also.step ?? 1,
|
|
298
|
+
floor: 0,
|
|
299
|
+
zero: false,
|
|
300
|
+
label: also.label || 'the second reading',
|
|
301
|
+
},
|
|
302
|
+
x,
|
|
303
|
+
from
|
|
304
|
+
)
|
|
305
|
+
: null;
|
|
306
|
+
if (!first) {
|
|
307
|
+
return html`<div class="chart empty-chart">${empty}</div>`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const base = first.y(0).toFixed(1);
|
|
311
|
+
const left = x(first.shown[0].t).toFixed(1);
|
|
312
|
+
const area = `${left},${base} ${first.line} ${x(now).toFixed(1)},${base}`;
|
|
313
|
+
const ticks = marks.filter((m) => m.at >= from && m.at <= now);
|
|
314
|
+
|
|
315
|
+
const across = acrossPlot(hover.at, PAD, W);
|
|
316
|
+
const when = across === null ? null : from + across * span;
|
|
317
|
+
const under = when === null ? null : nearest(first.shown, when);
|
|
318
|
+
const beside = when === null || !second ? null : nearest(second.shown, when);
|
|
319
|
+
|
|
320
|
+
/* The plot is its own box so that the pointer's fraction across it is a
|
|
321
|
+
* fraction across the SVG, and so the cursor and the label -- which are
|
|
322
|
+
* HTML, because nothing drawn inside a stretched SVG keeps its shape --
|
|
323
|
+
* can be positioned in percentages of exactly the same rectangle. The
|
|
324
|
+
* scale under it is not part of that rectangle. */
|
|
325
|
+
return html`<div class="chart">
|
|
326
|
+
<div class="plot" ref=${hover.box} ...${hover.on}>
|
|
327
|
+
<svg
|
|
328
|
+
viewBox=${`0 0 ${W} ${H}`}
|
|
329
|
+
preserveAspectRatio="none"
|
|
330
|
+
role="img"
|
|
331
|
+
aria-label=${[
|
|
332
|
+
`${what} over the last ${Math.round(span / 60)} minutes, between ${first.say(
|
|
333
|
+
Math.min(...first.values)
|
|
334
|
+
)} and ${first.say(Math.max(...first.values))}`,
|
|
335
|
+
second &&
|
|
336
|
+
`${second.label} between ${second.say(Math.min(...second.values))} and ${second.say(
|
|
337
|
+
Math.max(...second.values)
|
|
338
|
+
)}`,
|
|
339
|
+
]
|
|
340
|
+
.filter(Boolean)
|
|
341
|
+
.join('; ')}
|
|
342
|
+
>
|
|
343
|
+
<polygon class="area" points=${area} />
|
|
344
|
+
${first.bottom < 0 &&
|
|
345
|
+
html`<line
|
|
346
|
+
class="axis"
|
|
347
|
+
x1=${PAD}
|
|
348
|
+
x2=${W - PAD}
|
|
349
|
+
y1=${base}
|
|
350
|
+
y2=${base}
|
|
351
|
+
vector-effect="non-scaling-stroke"
|
|
352
|
+
/>`}
|
|
353
|
+
<polyline class="line" points=${first.line} vector-effect="non-scaling-stroke" />
|
|
354
|
+
${second &&
|
|
355
|
+
html`<polyline
|
|
356
|
+
class="line alt"
|
|
357
|
+
points=${second.line}
|
|
358
|
+
vector-effect="non-scaling-stroke"
|
|
359
|
+
/>`}
|
|
360
|
+
${ticks.map(
|
|
361
|
+
(tick) => html`<line
|
|
362
|
+
class="mark"
|
|
363
|
+
key=${tick.at}
|
|
364
|
+
x1=${x(tick.at)}
|
|
365
|
+
x2=${x(tick.at)}
|
|
366
|
+
y1=${PAD}
|
|
367
|
+
y2=${H - PAD}
|
|
368
|
+
vector-effect="non-scaling-stroke"
|
|
369
|
+
>
|
|
370
|
+
<title>${tick.what}</title>
|
|
371
|
+
</line>`
|
|
372
|
+
)}
|
|
373
|
+
</svg>
|
|
374
|
+
${under &&
|
|
375
|
+
html`<${Hovered}
|
|
376
|
+
x=${x(under.t) / W}
|
|
377
|
+
y=${first.y(under.v) / H}
|
|
378
|
+
heading=${clockTime(under.t)}
|
|
379
|
+
lines=${[
|
|
380
|
+
second ? `${first.label} ${first.say(under.v)}` : first.say(under.v),
|
|
381
|
+
beside ? `${second.label} ${second.say(beside.v)}` : null,
|
|
382
|
+
].filter(Boolean)}
|
|
383
|
+
/>`}
|
|
384
|
+
</div>
|
|
385
|
+
<div class=${second ? 'ends keyed' : 'ends'}>
|
|
386
|
+
<span>${Math.round(span / 60)} min ago</span>
|
|
387
|
+
${second
|
|
388
|
+
? html`<span class="key"><i class="swatch"></i>${first.label}, ${scaleSaid(first)}</span>
|
|
389
|
+
<span class="key"
|
|
390
|
+
><i class="swatch alt"></i>${second.label}, ${scaleSaid(second)}</span
|
|
391
|
+
>`
|
|
392
|
+
: null}
|
|
393
|
+
${ticks.length > 0 &&
|
|
394
|
+
html`<span class="key"
|
|
395
|
+
><i class="tick"></i>${ticks.length} setting${ticks.length === 1 ? '' : 's'} written</span
|
|
396
|
+
>`}
|
|
397
|
+
${second ? null : html`<span>${scaleSaid(first)}</span>`}
|
|
398
|
+
</div>
|
|
399
|
+
</div>`;
|
|
400
|
+
}
|