tactile-odometer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/odometer.mjs ADDED
@@ -0,0 +1,1100 @@
1
+ /**
2
+ * Odometer — TACTILE UI.
3
+ *
4
+ * A mechanical drum counter as a number display: numerals printed on drums
5
+ * that turn behind a window, the way an odometer, a tally counter or a petrol
6
+ * pump register shows a number. Three mechanisms:
7
+ *
8
+ * 1. The carry. Only the last drum is driven; every other drum moves only
9
+ * while the drum to its right passes from 9 to 0. The first carry takes
10
+ * the last tenth of the last drum's step, and every carry above it turns
11
+ * in lock-step with the drum below, so 1,099 → 1,100 rolls the tens and
12
+ * the hundreds over TOGETHER at the very end of the units' turn. A number
13
+ * ticker that tweens each digit on its own cannot do that, and it is the
14
+ * whole reason this reads as a machine. `drumPositions` is that rule as a
15
+ * pure function of one continuous value.
16
+ * 2. A spring on the value, not on the digits. A new value is a target; the
17
+ * continuous value springs toward it (optional overshoot), and every drum
18
+ * follows from the carry rule. A drum turning faster than the spin cap is
19
+ * drawn turning AT the cap, smeared: a jump of millions spins the low drums
20
+ * as a blur while the high ones visibly roll, and all of them land exactly.
21
+ * 3. Light that does not turn. A drum is a cylinder on a horizontal axle, so
22
+ * the light on it depends only on height in the window, never on how far
23
+ * it has turned. One overlay per drum, computed once from the lamp, gives
24
+ * the darkening toward the top and bottom and the specular band along the
25
+ * drum; the numerals roll through it. The faces are real DOM text on a
26
+ * ten-sided CSS 3D drum, so the figures are crisp and truly foreshortened.
27
+ *
28
+ * It costs nothing at rest: the loop runs only while a value is travelling,
29
+ * sleeps once it lands — a slow tally sleeps between its steps too — pauses off
30
+ * screen and in a hidden tab, and a two-second frame gap cannot blow up the
31
+ * spring.
32
+ *
33
+ * The housing is a machined plate in a material that belongs to the drums
34
+ * (black anodised aluminium for ink drums, satin steel for bone, blackened
35
+ * brass worn bright at the edges for brass): diamond-cut chamfers lit by the
36
+ * one lamp, countersunk screws, an optional paint-filled engraved label, and a
37
+ * cover glass with the lamp's reflection in it and a refracting edge.
38
+ *
39
+ * `size: 'fit'` (the default) fills the host's box — a preview stage, a tile,
40
+ * a phone — and follows it; a number fixes the numeral size and shrinks only
41
+ * when the host is narrower.
42
+ *
43
+ * The number is text. A visually hidden element holds the formatted value and
44
+ * announces it politely once it has LANDED — never each frame; while counting
45
+ * it stops announcing and is kept current for anyone who reads it; a counter
46
+ * set `announce: false` (a tally beside the real content) never announces. The
47
+ * drums are aria-hidden. Reduced motion: the value changes instantly, nothing spins,
48
+ * no frame is requested.
49
+ *
50
+ * The numerals are Big Shoulders Bold (SIL OFL 1.1), shipped beside the CSS as
51
+ * WOFF2 with its licence, so the drums print the same figures on every
52
+ * platform. No dependencies, no image. The exported helpers are pure and
53
+ * tested in Node.
54
+ */
55
+
56
+ export const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
57
+
58
+ /** How much of the last drum's step the first carry takes. The brief: the last tenth. */
59
+ export const CARRY = 0.1;
60
+
61
+ /** More drums than a double can count exactly would lie about the last digit. */
62
+ export const MAX_DRUMS = 15;
63
+
64
+ const wrap10 = (p) => ((p % 10) + 10) % 10;
65
+ /** A change of drum position folded into [-5, 5): 9.9 → 0.1 is +0.2, not -9.8. */
66
+ export const wrapDelta = (d) => wrap10(d + 5) - 5;
67
+
68
+ /** |value| in units of the last drum, with binary dust removed: 10.1 at two decimals is 1010, not 1009.9999999999999. */
69
+ export function scaled(value, decimals = 0) {
70
+ const n = Math.abs(value) * 10 ** decimals;
71
+ const r = Math.round(n);
72
+ return Math.abs(n - r) < 1e-6 ? r : n;
73
+ }
74
+
75
+ /** The value rounded to what the drums can show. */
76
+ export function quantise(value, decimals = 0) {
77
+ const f = 10 ** decimals;
78
+ const q = Math.round(value * f) / f;
79
+ return q === 0 ? 0 : q; // never -0: it would print a sign
80
+ }
81
+
82
+ /**
83
+ * Every drum's position in [0, 10) for a continuous value; index 0 is the
84
+ * last (least significant) drum. The last drum turns with the value. Drum k
85
+ * turns only while drum k-1 passes from 9 to 0: the first carry over the last
86
+ * `carry` of that step, every carry above it in lock-step with the drum below.
87
+ */
88
+ export function drumPositions(value, count, decimals = 0, carry = CARRY) {
89
+ const n = scaled(value, decimals);
90
+ const out = new Array(count);
91
+ if (count < 1) return out;
92
+ let below = n % 10;
93
+ out[0] = below;
94
+ for (let k = 1; k < count; k += 1) {
95
+ const w = k === 1 ? carry : 1;
96
+ const digit = Math.floor(n / 10 ** k) % 10;
97
+ below = digit + clamp((below - 10 + w) / w, 0, 1);
98
+ out[k] = below;
99
+ }
100
+ return out;
101
+ }
102
+
103
+ /** How many drums a value needs: its integer digits (at least one) plus the decimals. */
104
+ export function drumsFor(value, decimals = 0) {
105
+ const whole = Math.floor(Math.abs(value));
106
+ return (whole >= 1 ? String(whole).length : 1) + decimals;
107
+ }
108
+
109
+ /**
110
+ * Which drums print their zero. A leading drum — nothing but zeros at and
111
+ * above it — has a blank where its 0 would be, so 89 on six drums reads "89",
112
+ * not "000089". The units and the decimals always print.
113
+ */
114
+ export function blankZeros(value, count, decimals = 0) {
115
+ const n = scaled(value, decimals);
116
+ return Array.from({ length: count }, (_, k) => k > decimals && n < 10 ** k);
117
+ }
118
+
119
+ /**
120
+ * Left to right: the sign plate, then drums and fixed separator plates. A
121
+ * group separator stands to the right of the integer drum whose place is a
122
+ * locale break (3, 6, 9… or 3, 5, 7… in en-IN); the decimal plate stands to
123
+ * the right of the units drum.
124
+ */
125
+ export function slotsFor(count, decimals, breaks = [], signed = false) {
126
+ const out = signed ? [{ kind: 'sign' }] : [];
127
+ for (let k = count - 1; k >= 0; k -= 1) {
128
+ out.push({ kind: 'drum', k });
129
+ const j = k - decimals;
130
+ if (j > 0 && breaks.includes(j)) out.push({ kind: 'group', k });
131
+ if (k === decimals && decimals > 0) out.push({ kind: 'decimal', k });
132
+ }
133
+ return out;
134
+ }
135
+
136
+ /**
137
+ * A locale's number, taken apart: the group and decimal marks, the minus
138
+ * sign, the ten digit glyphs the drums are printed with, and where the group
139
+ * breaks fall. Read from Intl.NumberFormat parts, so en-IN's lakh grouping,
140
+ * fr-FR's narrow space and ar-EG's digits all come from the platform.
141
+ */
142
+ export function numberStyle(locale = 'en-US', decimals = 0, grouping = true) {
143
+ let nf;
144
+ try {
145
+ nf = new Intl.NumberFormat(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, useGrouping: grouping });
146
+ } catch {
147
+ nf = new Intl.NumberFormat('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals, useGrouping: grouping });
148
+ }
149
+ const loc = nf.resolvedOptions().locale;
150
+ const parts = nf.formatToParts(888888888888888n);
151
+ const ints = parts.filter((p) => p.type === 'integer').map((p) => p.value.length);
152
+ const breaks = [];
153
+ let acc = 0;
154
+ for (let i = ints.length - 1; i > 0; i -= 1) {
155
+ acc += ints[i];
156
+ breaks.push(acc);
157
+ }
158
+ const group = parts.find((p) => p.type === 'group')?.value ?? '';
159
+ const decimal = new Intl.NumberFormat(loc, { minimumFractionDigits: 1 }).formatToParts(1.5)
160
+ .find((p) => p.type === 'decimal')?.value ?? '.';
161
+ const minus = new Intl.NumberFormat(loc).formatToParts(-1).find((p) => p.type === 'minusSign')?.value ?? '-';
162
+ const plain = new Intl.NumberFormat(loc, { useGrouping: false });
163
+ const digits = Array.from({ length: 10 }, (_, i) => plain.format(i));
164
+ return { nf, locale: loc, group, decimal, minus, digits, breaks };
165
+ }
166
+
167
+ /** The value as a sentence reads it: sign, prefix, the locale's number, suffix. */
168
+ export function formatValue(value, style, prefix = '', suffix = '') {
169
+ const body = style.nf.format(Math.abs(value));
170
+ return `${value < 0 ? style.minus : ''}${prefix}${body}${suffix}`;
171
+ }
172
+
173
+ /**
174
+ * Advance the value's spring. The step is clamped to 1/30 s and sub-stepped
175
+ * at 1/120 s: a background tab that returns with a 2.5 s frame gap advances
176
+ * one sane frame instead of launching the drums.
177
+ */
178
+ export function stepSpring(s, target, dt, stiffness = 140, damping = 0.85) {
179
+ const c = 2 * damping * Math.sqrt(stiffness);
180
+ let left = clamp(dt, 0, 1 / 30);
181
+ while (left > 1e-6) {
182
+ const h = Math.min(left, 1 / 120);
183
+ s.v += (stiffness * (target - s.x) - c * s.v) * h;
184
+ s.x += s.v * h;
185
+ left -= h;
186
+ }
187
+ return s;
188
+ }
189
+
190
+ /** Landed: within a thousandth of the last drum's step, and still. */
191
+ export function landed(s, target, decimals = 0) {
192
+ const lsd = 10 ** -decimals;
193
+ const tol = Math.max(lsd * 1e-3, Math.abs(target) * 1e-15);
194
+ return Math.abs(target - s.x) <= tol && Math.abs(s.v) < lsd * 0.02;
195
+ }
196
+
197
+ /** How smeared a drum is from its speed in digits per second: none below a third of the cap, all of it at the cap. */
198
+ export function smear(speed, cap) {
199
+ const t = clamp((speed - cap / 3) / (cap - cap / 3), 0, 1);
200
+ return t * t * (3 - 2 * t);
201
+ }
202
+
203
+ /**
204
+ * A drum turning faster than the cap is DRAWN turning at the cap: past it a
205
+ * frame skips more than half a digit, the figures strobe and can even appear
206
+ * to run backwards. Returns the drawn position and whether it free-runs.
207
+ */
208
+ export function spin(phase, truePos, speed, dir, cap, dt) {
209
+ if (speed > cap && dt > 0) return { pos: wrap10(phase + dir * cap * dt), free: true };
210
+ return { pos: wrap10(truePos), free: false };
211
+ }
212
+
213
+ /** Screen direction toward the lamp: 0° is from above, clockwise; y points down. */
214
+ export function lampOf(light) {
215
+ const t = (light * Math.PI) / 180;
216
+ return { lx: Math.sin(t), ly: -Math.cos(t) };
217
+ }
218
+
219
+ // Drum geometry, in em of the numeral size. Ten faces: the radius follows
220
+ // from the face (the chord between two numerals).
221
+ export const FACE = 1.12;
222
+ export const RADIUS = FACE / (2 * Math.tan(Math.PI / 10));
223
+ export const WINDOW = 1.56;
224
+
225
+ /** The family name the stylesheet gives the bundled face. */
226
+ export const FACE_FAMILY = 'Odometer Figures';
227
+
228
+ /**
229
+ * The numeral size that fills a box: the object's width and height are
230
+ * linear in its numeral size (every length is in em), plus a margin of
231
+ * `pad` em on each side for the plate's shadow. A missing dimension (0) does
232
+ * not constrain; with neither, `fallback`.
233
+ */
234
+ export function fitSize(availW, availH, perW, perH, { pad = 0.22, min = 8, max = 400, fallback = 72 } = {}) {
235
+ const byW = availW > 0 && perW > 0 ? availW / (perW + 2 * pad) : Infinity;
236
+ const byH = availH > 0 && perH > 0 ? availH / (perH + 2 * pad) : Infinity;
237
+ const s = Math.min(byW, byH);
238
+ return clamp(Number.isFinite(s) ? Math.floor(s * 10) / 10 : fallback, min, max);
239
+ }
240
+
241
+ /**
242
+ * When the next step of a tally is due, in ms after `t0`: the tally adds one
243
+ * last-drum step every |1 / rate| s (value per second).
244
+ */
245
+ export function nextStepAt(t0, now, rate, decimals = 0) {
246
+ const every = (10 ** -decimals / Math.abs(rate)) * 1000;
247
+ return t0 + (Math.floor((now - t0) / every + 1e-9) + 1) * every;
248
+ }
249
+
250
+ const smoothstep = (a, b, x) => {
251
+ const t = clamp((x - a) / (b - a), 0, 1);
252
+ return t * t * (3 - 2 * t);
253
+ };
254
+
255
+ /**
256
+ * The light on a drum from the top of the window (0) to the bottom (1):
257
+ * `dark` is how much black to lay over the printed surface, `light` how much
258
+ * lamp. Lambert from the lamp 38° off the line of sight, a specular band where
259
+ * the drum's normal bisects lamp and eye, and occlusion where the window's lip
260
+ * hides the drum as it curves away.
261
+ */
262
+ export function drumLight(light, gloss = 0.2, samples = 21, radius = RADIUS, half = WINDOW / 2) {
263
+ const { lx, ly } = lampOf(light);
264
+ const E = Math.sin((38 * Math.PI) / 180);
265
+ const Z = Math.cos((38 * Math.PI) / 180);
266
+ const L = [lx * E, ly * E, Z];
267
+ // The band lies where the normal (in the drum's plane of turn) is nearest
268
+ // the half-vector. A lamp to the side still lights the band; it is fainter.
269
+ const hy = L[1];
270
+ const hz = L[2] + 1;
271
+ const hl = Math.hypot(hy, hz);
272
+ const side = 1 - 0.45 * Math.abs(lx);
273
+ const out = [];
274
+ for (let i = 0; i < samples; i += 1) {
275
+ const at = i / (samples - 1);
276
+ const y = (-1 + 2 * at) * half;
277
+ const s = clamp(-y / radius, -1, 1);
278
+ const c = Math.sqrt(1 - s * s);
279
+ const ny = -s;
280
+ const dif = Math.max(0, ny * L[1] + c * L[2]) / Z;
281
+ const spec = gloss * side * Math.max(0, (ny * hy + c * hz) / hl) ** 90;
282
+ const ao = smoothstep(0.42, 1, Math.abs(y) / half) * 0.72;
283
+ const b = dif * (1 - ao);
284
+ out.push({ at, dark: clamp(1 - b, 0, 0.94), light: clamp((b - 1) * 0.45 + spec, 0, 1) });
285
+ }
286
+ return out;
287
+ }
288
+
289
+ // ------------------------------------------------------------------ the object
290
+
291
+ let probe;
292
+ /**
293
+ * How far to move a figure down so its ink, not its line box, sits on the
294
+ * drum's centre line. Digits stand on the baseline, above the descender
295
+ * space, so a centred line box puts them high: the next figure peeks in at
296
+ * the bottom of the window and none at the top. Measured from the font the
297
+ * page actually resolved — the bundled face once it has loaded, a fallback
298
+ * before — and measured again when the face arrives.
299
+ */
300
+ function opticalLift(cs) {
301
+ try {
302
+ probe ??= document.createElement('canvas').getContext('2d');
303
+ probe.font = `${cs.fontWeight} 100px ${cs.fontFamily}`;
304
+ const m = probe.measureText('0123456789');
305
+ const A = m.fontBoundingBoxAscent / 100;
306
+ const D = m.fontBoundingBoxDescent / 100;
307
+ const gA = m.actualBoundingBoxAscent / 100;
308
+ const gD = m.actualBoundingBoxDescent / 100;
309
+ const lift = (D - A + gA - gD) / 2;
310
+ return Number.isFinite(lift) ? clamp(lift, -0.2, 0.2) : 0;
311
+ } catch {
312
+ return 0;
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Housings. `base` is the plate's body, `light`/`dark` its lit and shaded
318
+ * tones; `edge`/`edgeDark` the metal a chamfer cuts down to (a diamond-cut
319
+ * edge on anodised aluminium is bright bare aluminium, on blackened brass it
320
+ * is brass); `ink` the paint in the engraving; `screw` the screws' metal.
321
+ */
322
+ export const PLATES = Object.freeze({
323
+ anodised: Object.freeze({ base: '#232427', light: '#5e626a', dark: '#0a0b0d', ink: '#e9e2d1', edge: '#f4f6f8', edgeDark: '#2b2e33', screw: '#c9ced4', brush: 0.5 }),
324
+ steel: Object.freeze({ base: '#a8adb3', light: '#f4f6f8', dark: '#3d4247', ink: '#1b1d20', edge: '#ffffff', edgeDark: '#565b61', screw: '#bfc4ca', brush: 0.7 }),
325
+ graphite: Object.freeze({ base: '#393b40', light: '#8e939a', dark: '#111214', ink: '#e2e5e9', edge: '#e9ecef', edgeDark: '#2a2c30', screw: '#c4c9cf', brush: 0.55 }),
326
+ blackened: Object.freeze({ base: '#2a241b', light: '#6b5838', dark: '#0e0b07', ink: '#dcb96f', edge: '#f6dfa0', edgeDark: '#4e3810', screw: '#c7a256', brush: 0.5 }),
327
+ });
328
+
329
+ /**
330
+ * Drum finishes, each in the housing it would be sold in. `red` is the
331
+ * signal drum (the classic odometer tenths).
332
+ */
333
+ export const FINISHES = Object.freeze({
334
+ ink: Object.freeze({ drum: '#1a1b1d', figure: '#ece4d2', plate: 'anodised', gloss: 0.34, tint: '255, 250, 240' }),
335
+ bone: Object.freeze({ drum: '#e6dfd0', figure: '#17181a', plate: 'steel', gloss: 0.14, tint: '255, 252, 245' }),
336
+ steel: Object.freeze({ drum: '#5e646b', figure: '#f6f8f9', plate: 'graphite', gloss: 0.5, tint: '240, 246, 252' }),
337
+ brass: Object.freeze({ drum: '#b48a3a', figure: '#1f1507', plate: 'blackened', gloss: 0.55, tint: '255, 236, 190' }),
338
+ });
339
+
340
+ export const SIGNAL = Object.freeze({ drum: '#e0482b', figure: '#fff3e6' });
341
+
342
+ export const BEZELS = Object.freeze(['slot', 'cells', 'bare']);
343
+ export const REDS = Object.freeze(['none', 'last', 'decimals']);
344
+
345
+ const DEFAULTS = Object.freeze({
346
+ value: 0,
347
+ decimals: 0,
348
+ prefix: '',
349
+ suffix: '',
350
+ locale: 'en-US',
351
+ grouping: true,
352
+ digits: 1,
353
+ zeros: false,
354
+ finish: 'ink',
355
+ red: 'none',
356
+ bezel: 'slot',
357
+ stiffness: 140,
358
+ damping: 0.85,
359
+ maxSpin: 24,
360
+ count: 0,
361
+ wear: 0.35,
362
+ light: 315,
363
+ size: 'fit',
364
+ label: '',
365
+ sound: false,
366
+ announce: true,
367
+ });
368
+
369
+ function normalise(opts, previous) {
370
+ const o = { ...DEFAULTS, ...previous, ...opts };
371
+ o.decimals = clamp(Math.round(+o.decimals || 0), 0, 6);
372
+ o.digits = clamp(Math.round(+o.digits || 1), 1, MAX_DRUMS - o.decimals);
373
+ o.prefix = String(o.prefix ?? '').slice(0, 8);
374
+ o.suffix = String(o.suffix ?? '').slice(0, 24);
375
+ o.label = String(o.label ?? '').slice(0, 32);
376
+ o.locale = String(o.locale || 'en-US');
377
+ o.grouping = o.grouping !== false;
378
+ o.zeros = !!o.zeros;
379
+ if (!FINISHES[o.finish]) o.finish = 'ink';
380
+ if (!REDS.includes(o.red)) o.red = 'none';
381
+ if (!BEZELS.includes(o.bezel)) o.bezel = 'slot';
382
+ o.stiffness = clamp(+o.stiffness || 140, 10, 1000);
383
+ o.damping = clamp(+o.damping || 0.85, 0.2, 2);
384
+ o.maxSpin = clamp(+o.maxSpin || 24, 3, 120);
385
+ o.count = Number.isFinite(+o.count) ? +o.count : 0;
386
+ o.wear = clamp(+o.wear || 0, 0, 1);
387
+ o.light = ((+o.light || 0) % 360 + 360) % 360;
388
+ // 'fit' (or 0) fills the host; a number is the numeral size in px.
389
+ o.size = +o.size > 0 ? clamp(+o.size, 8, 400) : 'fit';
390
+ o.sound = !!o.sound;
391
+ // false: an ambient counter (a tally beside the real content) is read in place, never announced.
392
+ o.announce = o.announce !== false;
393
+ return o;
394
+ }
395
+
396
+ /** The largest magnitude the drums can hold at these decimals. */
397
+ const limitFor = (decimals) => (10 ** (MAX_DRUMS - decimals) - 1) / 10 ** decimals;
398
+
399
+ /** A stable pseudo-random number in [0, 1) per drum and purpose: wear must not change between renders. */
400
+ const hash = (i, salt) => {
401
+ const x = Math.sin(i * 12.9898 + salt * 78.233) * 43758.5453;
402
+ return x - Math.floor(x);
403
+ };
404
+
405
+ const STRUCTURE = ['decimals', 'locale', 'grouping', 'digits', 'zeros', 'finish', 'red', 'bezel', 'prefix', 'suffix', 'label', 'wear'];
406
+
407
+ const el = (tag, cls, text) => {
408
+ const n = document.createElement(tag);
409
+ if (cls) n.className = cls;
410
+ if (text !== undefined) n.textContent = text;
411
+ return n;
412
+ };
413
+
414
+ /**
415
+ * Put a drum's faces where position `pos` puts them. Each face carries its
416
+ * whole transform under a flat parent with perspective: nesting the faces in
417
+ * a rotating preserve-3d drum is the obvious build, and Chrome culls the face
418
+ * above the axle in it at some viewport sizes (a black cap on every drum at 0).
419
+ * Only faces that can reach the window are placed: a face spans ±18° and the
420
+ * window shows ±27° of the drum, so nothing past 45° is ever seen. Hiding the
421
+ * rest matters — every face kept visible costs a layer and a repaint.
422
+ */
423
+ function turn(faces, pos) {
424
+ for (let i = 0; i < 10; i += 1) {
425
+ const a = wrapDelta(pos - i) * 36; // degrees from the front, in [-180, 180)
426
+ const f = faces[i];
427
+ const show = Math.abs(a) < 47;
428
+ if (show !== f.show) {
429
+ f.el.style.visibility = show ? '' : 'hidden';
430
+ f.show = show;
431
+ }
432
+ if (show) f.el.style.transform = `translateZ(calc(var(--od-r) * -1)) rotateX(${a.toFixed(3)}deg) translateZ(var(--od-r))`;
433
+ }
434
+ }
435
+
436
+ const hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
437
+ const mixHex = (a, b, t) => {
438
+ const pa = hex(a);
439
+ const pb = hex(b);
440
+ return `rgb(${pa.map((v, i) => Math.round(v + (pb[i] - v) * t)).join(', ')})`;
441
+ };
442
+
443
+ /**
444
+ * How much lamp a 45° chamfer takes, per side (top, right, bottom, left).
445
+ * `recess` is a window cut into the plate, whose walls face inward; otherwise
446
+ * the plate's own raised edge, whose walls face out. Same lamp as the drums.
447
+ */
448
+ export function bevel(light, recess) {
449
+ const { lx, ly } = lampOf(light);
450
+ const E = Math.sin((38 * Math.PI) / 180);
451
+ const Z = Math.cos((38 * Math.PI) / 180);
452
+ const s = recess ? 1 : -1;
453
+ const k = Math.SQRT1_2;
454
+ const lit = (nx, ny) => clamp(k * (nx * lx * E + ny * ly * E) + k * Z, 0, 1);
455
+ return [lit(0, s), lit(-s, 0), lit(0, -s), lit(s, 0)];
456
+ }
457
+
458
+ /**
459
+ * The chamfer colours, per side (top, right, bottom, left): each side's angle
460
+ * to the lamp mixes the plate's bare-metal edge from dark to lit. `raise` is
461
+ * the plate's outer edge, `recess` the window's walls.
462
+ */
463
+ export function chamfers(light, plate = 'anodised') {
464
+ const p = PLATES[plate] ?? PLATES.anodised;
465
+ const tone = (b) => mixHex(p.edgeDark, p.edge, 0.7 * clamp((b - 0.3) / 0.62, 0, 1) ** 1.3);
466
+ return { raise: bevel(light, false).map(tone), recess: bevel(light, true).map(tone) };
467
+ }
468
+
469
+ const rgba = (rgb, a) => `rgba(${rgb}, ${a.toFixed(3)})`;
470
+ const f3 = (n) => Number(n.toFixed(3));
471
+
472
+ export class Odometer {
473
+ constructor(host, opts = {}) {
474
+ if (!host) throw new Error('Odometer needs a host element');
475
+ this.host = host;
476
+ this.o = normalise(opts);
477
+ this.mq = typeof matchMedia === 'function' ? matchMedia('(prefers-reduced-motion: reduce)') : null;
478
+ this.reduced = !!this.mq?.matches;
479
+
480
+ this.target = this.#clampValue(this.o.value);
481
+ this.s = { x: this.target, v: 0 };
482
+ this.visible = false;
483
+ this.raf = 0;
484
+ this.last = 0;
485
+ this.lastInt = Math.floor(scaled(this.target, this.o.decimals));
486
+ this.lastTick = 0;
487
+ this.lastText = 0;
488
+ this.counting = null;
489
+ this.timer = 0;
490
+
491
+ const root = el('div', 'od');
492
+ root.dataset.object = 'odometer';
493
+ root.dataset.state = 'asleep';
494
+ // The number is text: this is what a screen reader reads, copies and hears.
495
+ this.text = el('span', 'od-text');
496
+ this.text.setAttribute('aria-live', this.o.announce ? 'polite' : 'off');
497
+ this.text.setAttribute('aria-atomic', 'true');
498
+ this.plate = el('div', 'od-plate');
499
+ this.plate.setAttribute('aria-hidden', 'true');
500
+ root.append(this.text, this.plate);
501
+ this.root = root;
502
+ // The first value is content, not news: it is in place before the live
503
+ // region reaches the page, so nothing announces a number nobody changed.
504
+ this.style = numberStyle(this.o.locale, this.o.decimals, this.o.grouping);
505
+ this.text.textContent = formatValue(this.target, this.style, this.o.prefix, this.o.suffix);
506
+ host.append(root);
507
+
508
+ this.on = {
509
+ motion: (e) => {
510
+ this.reduced = e.matches;
511
+ if (this.reduced) this.#jump();
512
+ this.#restartCount();
513
+ this.#wake();
514
+ },
515
+ visibility: () => {
516
+ if (document.hidden) this.#sleep();
517
+ else this.#wake();
518
+ },
519
+ gesture: () => this.#armAudio(),
520
+ };
521
+ this.mq?.addEventListener?.('change', this.on.motion);
522
+ document.addEventListener('visibilitychange', this.on.visibility);
523
+
524
+ this.#build();
525
+ this.#paint(0);
526
+ if (this.o.sound) this.#listenForGesture();
527
+ // The face arrives after the first build (it is invisible until then, not
528
+ // a fallback that jumps): measure the figures again once it has.
529
+ document.fonts?.load?.(`700 1em "${FACE_FAMILY}"`).then(() => {
530
+ if (this.root.isConnected) { this.#dress(); this.#fit(); }
531
+ }, () => {});
532
+
533
+ this.io = new IntersectionObserver(([entry]) => {
534
+ this.visible = entry.isIntersecting;
535
+ this.seen = true;
536
+ if (this.visible) this.#wake();
537
+ });
538
+ this.io.observe(root);
539
+ // Fit: the counter fills its host and follows it. A fixed size keeps its
540
+ // numeral size and shrinks only when the host is narrower.
541
+ this.ro = new ResizeObserver(() => this.#fit());
542
+ this.ro.observe(host);
543
+
544
+ if (this.o.count) this.count(this.o.count);
545
+ }
546
+
547
+ /** The value the drums are going to (or showing, once landed). */
548
+ get value() { return this.target; }
549
+
550
+ get state() { return this.root.dataset.state; }
551
+
552
+ /**
553
+ * Roll to a new value. `instant` jumps without spinning; so does reduced
554
+ * motion, and so does a counter known to be off screen or in a hidden tab:
555
+ * nobody can watch that roll, and its text must not wait for them to.
556
+ */
557
+ setValue(v, { instant = false } = {}) {
558
+ const t = this.#clampValue(v);
559
+ if (!Number.isFinite(t)) return this;
560
+ this.target = t;
561
+ if (this.counting) {
562
+ this.counting.base = t;
563
+ this.counting.t0 = performance.now();
564
+ }
565
+ if (instant || this.reduced || (this.seen && !this.visible) || document.hidden) {
566
+ this.#jump();
567
+ return this;
568
+ }
569
+ this.#grow();
570
+ this.#wake();
571
+ return this;
572
+ }
573
+
574
+ /**
575
+ * A live tally: add `rate` (value per second, negative counts down) in
576
+ * whole steps of the last drum, until count(0). Each step rolls like any
577
+ * other change; fast enough, the steps blend into a steady spin.
578
+ */
579
+ count(rate = 1) {
580
+ const r = Number.isFinite(+rate) ? +rate : 0;
581
+ this.o.count = r;
582
+ this.counting = r ? { rate: r, base: this.target, t0: performance.now() } : null;
583
+ this.#live();
584
+ this.#restartCount();
585
+ this.#wake();
586
+ return this;
587
+ }
588
+
589
+ /** Change options in place. A `value` rolls to it; everything else redraws. */
590
+ update(opts = {}) {
591
+ const prev = this.o;
592
+ this.o = normalise(opts, this.o);
593
+ if (STRUCTURE.some((k) => prev[k] !== this.o[k])) {
594
+ this.target = this.#clampValue(this.target);
595
+ this.s.x = this.#clampValue(this.s.x);
596
+ this.#build();
597
+ } else if (prev.light !== this.o.light || prev.size !== this.o.size) {
598
+ this.#dress();
599
+ this.#fit();
600
+ }
601
+ this.#paint(0);
602
+ if (this.o.sound && !prev.sound) this.#listenForGesture();
603
+ if (this.o.announce !== prev.announce) this.#live();
604
+ if ('count' in opts && this.o.count !== (this.counting?.rate ?? 0)) this.count(this.o.count);
605
+ if ('value' in opts && this.#clampValue(opts.value) !== this.target) this.setValue(opts.value);
606
+ else this.#announce();
607
+ return this;
608
+ }
609
+
610
+ destroy() {
611
+ cancelAnimationFrame(this.raf);
612
+ clearInterval(this.timer);
613
+ clearTimeout(this.stepTimer);
614
+ this.io.disconnect();
615
+ this.ro.disconnect();
616
+ this.mq?.removeEventListener?.('change', this.on.motion);
617
+ document.removeEventListener('visibilitychange', this.on.visibility);
618
+ this.#stopListening();
619
+ this.audio?.close?.();
620
+ this.root.remove();
621
+ }
622
+
623
+ // ---------------------------------------------------------------- values
624
+
625
+ #clampValue(v) {
626
+ const lim = limitFor(this.o.decimals);
627
+ return quantise(clamp(Number.isFinite(+v) ? +v : 0, -lim, lim), this.o.decimals);
628
+ }
629
+
630
+ /** Straight to the target: reduced motion, `instant`, or a restart. */
631
+ #jump() {
632
+ this.s.x = this.target;
633
+ this.s.v = 0;
634
+ this.#grow();
635
+ this.#shrink();
636
+ this.#paint(0);
637
+ this.#announce();
638
+ this.lastInt = Math.floor(scaled(this.target, this.o.decimals));
639
+ }
640
+
641
+ // ---------------------------------------------------------------- the build
642
+
643
+ /** Drums and plates for the current count, sign and locale. */
644
+ #build() {
645
+ const o = this.o;
646
+ this.style = numberStyle(o.locale, o.decimals, o.grouping);
647
+ this.count_ = Math.min(MAX_DRUMS, Math.max(
648
+ o.digits + o.decimals, drumsFor(this.target, o.decimals), drumsFor(this.s.x, o.decimals),
649
+ ));
650
+ this.signed = this.target < 0 || this.s.x < 0;
651
+ const root = this.root;
652
+ root.dataset.bezel = o.bezel;
653
+ root.dataset.finish = o.finish;
654
+
655
+ const win = el('div', 'od-window');
656
+ this.drums = [];
657
+ this.seps = [];
658
+ this.signEl = null;
659
+ // Born in the state they show: a blank toggled after the first style pass
660
+ // would fade out on page load, a ghost "00" in the leading drums.
661
+ const blanks = o.zeros ? null : blankZeros(this.s.x, this.count_, o.decimals);
662
+ for (const slot of slotsFor(this.count_, o.decimals, o.grouping ? this.style.breaks : [], this.signed)) {
663
+ if (slot.kind === 'drum') win.append(this.#drum(slot.k, !!blanks?.[slot.k]));
664
+ else {
665
+ const glyph = slot.kind === 'sign' ? this.style.minus : slot.kind === 'group' ? this.style.group : this.style.decimal;
666
+ const sep = el('span', `od-sep od-${slot.kind}`);
667
+ sep.append(el('span', 'od-glyph', glyph));
668
+ const shown = slot.kind === 'sign' ? this.s.x < 0 : slot.kind === 'group' ? !blanks?.[slot.k] : true;
669
+ sep.classList.toggle('od-off', !shown);
670
+ if (slot.kind === 'sign') this.signEl = sep;
671
+ if (slot.kind === 'group') this.seps.push({ el: sep, k: slot.k, shown });
672
+ win.append(sep);
673
+ }
674
+ }
675
+ win.append(el('span', 'od-glass'));
676
+ this.plate.replaceChildren(win);
677
+ if (o.prefix) this.plate.append(el('span', 'od-prefix', o.prefix));
678
+ if (o.suffix) this.plate.append(el('span', /^\s*[^\p{L}\p{N}]{1,2}\s*$/u.test(o.suffix) ? 'od-suffix od-mark' : 'od-suffix', o.suffix));
679
+ // A paint-filled engraving under the window, ruled out to its edges.
680
+ if (o.label) {
681
+ const label = el('span', 'od-label');
682
+ label.append(el('span', 'od-label-t', o.label));
683
+ this.plate.append(label);
684
+ }
685
+ // Two countersunk screws, each driven home at its own angle.
686
+ if (o.bezel !== 'bare') {
687
+ for (const side of ['l', 'r']) {
688
+ const screw = el('span', `od-screw od-screw-${side}`);
689
+ screw.style.setProperty('--od-slot', `${Math.round(hash(side === 'l' ? 1 : 2, 7) * 180)}deg`);
690
+ this.plate.append(screw);
691
+ }
692
+ }
693
+ this.window = win;
694
+ this.#dress();
695
+ this.#fit();
696
+ }
697
+
698
+ #drum(k, blank) {
699
+ const o = this.o;
700
+ const cell = el('span', 'od-cell');
701
+ const red = o.red === 'last' ? k === 0 : o.red === 'decimals' ? (o.decimals ? k < o.decimals : k === 0) : false;
702
+ if (red) cell.classList.add('od-red');
703
+ const rot = el('span', 'od-rot');
704
+ const w = o.wear;
705
+ const faces = [];
706
+ for (let i = 0; i < 10; i += 1) {
707
+ const face = el('span', 'od-face');
708
+ faces.push({ el: face, show: null });
709
+ // Print registration: every figure was stamped a hair off true.
710
+ const ink = el('span', 'od-ink');
711
+ ink.style.transform = `translate(${f3((hash(k * 10 + i, 3) - 0.5) * 0.03 * w)}em, ${f3((hash(k * 10 + i, 4) - 0.5) * 0.026 * w)}em)`;
712
+ ink.style.setProperty('--ink', String(f3(1 - hash(k * 10 + i, 5) * 0.22 * w)));
713
+ ink.append(el('span', 'od-n', this.style.digits[i]), el('span', 'od-m', this.style.digits[i]));
714
+ face.append(ink);
715
+ rot.append(face);
716
+ }
717
+ cell.append(rot, el('span', 'od-lens'));
718
+ // Wear: each drum sits a little off its detent and has a little play on
719
+ // the axle. Stable per drum, so the number never shimmers between renders.
720
+ const drum = {
721
+ k,
722
+ cell,
723
+ rot,
724
+ faces,
725
+ zero: faces[0].el,
726
+ offset: (hash(k, 1) - 0.5) * 0.09 * w,
727
+ shown: NaN,
728
+ phase: 0,
729
+ blur: 0,
730
+ b: -1,
731
+ blank,
732
+ free: false,
733
+ };
734
+ drum.zero.classList.toggle('od-blank', blank);
735
+ cell.style.marginLeft = `${f3((hash(k, 2) - 0.5) * 0.024 * w)}em`;
736
+ this.drums[k] = drum;
737
+ return cell;
738
+ }
739
+
740
+ /** Everything the lamp and the finish decide, as custom properties. */
741
+ #dress() {
742
+ const o = this.o;
743
+ const fin = FINISHES[o.finish];
744
+ const plate = PLATES[fin.plate];
745
+ const { lx, ly } = lampOf(o.light);
746
+ const s = this.root.style;
747
+ const set = (k, v) => s.setProperty(k, v);
748
+ set('--od-drum', fin.drum);
749
+ set('--od-figure', fin.figure);
750
+ set('--od-signal', SIGNAL.drum);
751
+ set('--od-signal-figure', SIGNAL.figure);
752
+ set('--od-metal', plate.base);
753
+ set('--od-metal-light', plate.light);
754
+ set('--od-metal-dark', plate.dark);
755
+ set('--od-plate-ink', plate.ink);
756
+ set('--od-edge', plate.edge);
757
+ set('--od-edge-dark', plate.edgeDark);
758
+ set('--od-screw', plate.screw);
759
+ set('--od-brush', String(plate.brush));
760
+
761
+ const shade = drumLight(o.light, fin.gloss);
762
+ const stops = (f) => shade.map((p) => `${f(p)} ${(p.at * 100).toFixed(1)}%`).join(', ');
763
+ set('--od-shade', `linear-gradient(180deg, ${stops((p) => `rgba(0, 0, 0, ${p.dark.toFixed(3)})`)})`);
764
+ set('--od-spec', `linear-gradient(180deg, ${stops((p) => rgba(fin.tint, p.light))})`);
765
+
766
+ const e = (n) => `${f3(n)}em`;
767
+ // In numeral-size units, for the small text on the plate whose own em is tiny.
768
+ const u = (n) => `calc(var(--od-size) * ${f3(n)})`;
769
+ const ang = Math.round(o.light + 180);
770
+ set('--od-lift', e(opticalLift(getComputedStyle(this.root))));
771
+ // Diamond-cut chamfers: the window's walls and the plate's edge are cut
772
+ // through the finish to bare metal, each side lit by the lamp.
773
+ // Each side takes its own tone from its angle to the lamp; over that, the
774
+ // specular hot spot, brightest at one corner and gone by the middle.
775
+ const cut = chamfers(o.light, fin.plate);
776
+ set('--od-recess', cut.recess.join(' '));
777
+ set('--od-raise', cut.raise.join(' '));
778
+ const hot = (deg) => `linear-gradient(${deg}deg, rgba(255, 255, 255, 0.62) 0%, rgba(255, 255, 255, 0.22) 22%, rgba(255, 255, 255, 0) 52%)`;
779
+ set('--od-raise-hot', hot(ang));
780
+ set('--od-recess-hot', hot(Math.round(o.light)));
781
+ // Edges facing the lamp are lit, the far ones dark; shadows fall away from it.
782
+ set('--od-rim', `linear-gradient(90deg, rgba(${lx < 0 ? '255, 255, 255' : '0, 0, 0'}, ${f3(0.1 + 0.12 * Math.abs(lx))}) 0, transparent 9%, transparent 91%, rgba(${lx < 0 ? '0, 0, 0' : '255, 255, 255'}, ${f3(0.1 + 0.12 * Math.abs(lx))}) 100%)`);
783
+ // A plate on a surface: a tight contact shadow, then the soft one the lamp throws.
784
+ set('--od-plate-shadow', [
785
+ `${e(-lx * 0.012)} ${e(-ly * 0.012)} ${e(0.018)} rgba(0, 0, 0, 0.5)`,
786
+ `${e(-lx * 0.05)} ${e(-ly * 0.05)} ${e(0.1)} rgba(0, 0, 0, 0.26)`,
787
+ `${e(-lx * 0.16)} ${e(-ly * 0.16)} ${e(0.42)} rgba(0, 0, 0, 0.3)`,
788
+ ].join(', '));
789
+ // Brushed metal lights as a band across its grain, on the lamp's side, and
790
+ // falls off away from the lamp.
791
+ const band = Math.round(50 + lx * 26);
792
+ set('--od-plate-sheen', [
793
+ `linear-gradient(90deg, rgba(255, 255, 255, 0) ${band - 26}%, rgba(255, 255, 255, 0.075) ${band}%, rgba(255, 255, 255, 0) ${band + 26}%)`,
794
+ `linear-gradient(${ang}deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.03) 36%, rgba(0, 0, 0, 0.04) 60%, rgba(0, 0, 0, 0.26))`,
795
+ ].join(', '));
796
+ set('--od-chamfer', [
797
+ `${e(-lx * 0.016)} ${e(-ly * 0.016)} 0 rgba(255, 255, 255, 0.1)`,
798
+ `${e(lx * 0.016)} ${e(ly * 0.016)} 0 rgba(0, 0, 0, 0.45)`,
799
+ ].join(', '));
800
+ // The window's own walls: the lip on the lamp side throws a shadow onto
801
+ // the drums, the far wall catches a thread of light. Then the cover
802
+ // glass's edge: a bright rim where it meets the frame, a dark refraction
803
+ // band just inside it, and the green of float glass seen through its
804
+ // thickness.
805
+ set('--od-wall', [
806
+ `inset ${e(-lx * 0.05)} ${e(-ly * 0.05)} ${e(0.07)} rgba(0, 0, 0, 0.66)`,
807
+ `inset ${e(lx * 0.012)} ${e(ly * 0.012)} 0 rgba(255, 255, 255, 0.1)`,
808
+ `inset 0 0 0 ${e(0.007)} rgba(255, 255, 255, 0.12)`,
809
+ `inset 0 0 0 ${e(0.022)} rgba(0, 0, 0, 0.2)`,
810
+ `inset 0 0 ${e(0.07)} ${e(0.018)} rgba(120, 170, 150, 0.1)`,
811
+ ].join(', '));
812
+ // The cover glass: the lamp's reflection is a softbox, a pane of light
813
+ // with a clean far edge, lying across the lamp's corner of the glass; a
814
+ // faint second pane from the room; and a sliver of the lamp along the
815
+ // glass's lamp-side edges, seen through the glass's thickness.
816
+ set('--od-glass', [
817
+ `linear-gradient(${ang}deg, rgba(255, 255, 255, 0.02) 0%, rgba(255, 255, 255, 0.12) 17%, rgba(255, 255, 255, 0.05) 31%, rgba(255, 255, 255, 0) 31.6%)`,
818
+ `linear-gradient(${ang}deg, rgba(255, 255, 255, 0) 52%, rgba(255, 255, 255, 0.028) 53%, rgba(255, 255, 255, 0.012) 60%, rgba(255, 255, 255, 0) 60.5%)`,
819
+ `linear-gradient(${ly <= 0 ? 180 : 0}deg, rgba(255, 255, 255, ${f3(0.04 + 0.12 * Math.abs(ly))}), rgba(255, 255, 255, 0) ${e(0.05)})`,
820
+ `linear-gradient(${lx <= 0 ? 90 : 270}deg, rgba(255, 255, 255, ${f3(0.02 + 0.08 * Math.abs(lx))}), rgba(255, 255, 255, 0) ${e(0.04)})`,
821
+ ].join(', '));
822
+ // Paint-filled engraving: the lip nearest the lamp shades the groove,
823
+ // the far wall catches the light.
824
+ set('--od-engrave', `${u(-lx * 0.009)} ${u(-ly * 0.009)} 0 rgba(255, 255, 255, 0.22), ${u(lx * 0.007)} ${u(ly * 0.007)} 0 rgba(0, 0, 0, 0.55)`);
825
+ set('--od-groove', `inset ${u(lx * 0.006)} ${u(ly * 0.006)} 0 rgba(0, 0, 0, 0.6), inset ${u(-lx * 0.006)} ${u(-ly * 0.006)} 0 rgba(255, 255, 255, 0.2)`);
826
+ set('--od-screw-hl', `${Math.round(50 + lx * 30)}% ${Math.round(50 + ly * 30)}%`);
827
+ set('--od-screw-sink', `inset ${e(lx * 0.012)} ${e(ly * 0.012)} ${e(0.014)} rgba(0, 0, 0, 0.75), inset ${e(-lx * 0.008)} ${e(-ly * 0.008)} 0 rgba(255, 255, 255, 0.16)`);
828
+ set('--od-screw-shadow', `${e(-lx * 0.01)} ${e(-ly * 0.01)} ${e(0.014)} rgba(0, 0, 0, 0.7)`);
829
+
830
+ // One camera for the whole window: each drum's vanishing point is the
831
+ // window's centre, so the outer drums are seen very slightly from the side.
832
+ const px = parseFloat(getComputedStyle(this.root).fontSize) || 72;
833
+ const mid = this.window.offsetWidth / 2;
834
+ for (const d of this.drums) {
835
+ if (d) d.rot.style.perspectiveOrigin = `${f3((mid - d.cell.offsetLeft) / px)}em 50%`;
836
+ }
837
+ }
838
+
839
+ /**
840
+ * Fit mode fills the host's box; a fixed size shrinks only to fit its
841
+ * width. The host is measured with the counter taken out of it: a host that
842
+ * takes its size from its content has none of its own, and the counter must
843
+ * not chase itself — it falls back to 72px numerals.
844
+ */
845
+ #fit() {
846
+ if (!this.root.isConnected) return;
847
+ const host = this.host;
848
+ const cs = getComputedStyle(host);
849
+ const pad = (k) => parseFloat(cs[k]) || 0;
850
+ const now = parseFloat(this.root.style.getPropertyValue('--od-size')) || 72;
851
+ const perW = this.root.offsetWidth / now;
852
+ const perH = this.root.offsetHeight / now;
853
+ let want;
854
+ if (this.o.size === 'fit') {
855
+ const shown = this.root.style.display;
856
+ this.root.style.display = 'none';
857
+ const w = host.clientWidth - pad('paddingLeft') - pad('paddingRight');
858
+ const h = host.clientHeight - pad('paddingTop') - pad('paddingBottom');
859
+ this.root.style.display = shown;
860
+ want = fitSize(w, h, perW, perH);
861
+ } else {
862
+ const avail = host.clientWidth - pad('paddingLeft') - pad('paddingRight');
863
+ want = avail > 0 && perW > 0 ? Math.min(this.o.size, Math.floor((avail / perW) * 10) / 10) : this.o.size;
864
+ }
865
+ if (Math.abs(want - now) > 0.05 || !this.root.style.getPropertyValue('--od-size')) {
866
+ this.root.style.setProperty('--od-size', `${Math.max(8, want)}px`);
867
+ }
868
+ }
869
+
870
+ /** More drums (or a sign plate) the moment a travelling value needs them. */
871
+ #grow() {
872
+ const need = Math.min(MAX_DRUMS, Math.max(drumsFor(this.target, this.o.decimals), drumsFor(this.s.x, this.o.decimals)));
873
+ const signed = this.target < 0 || this.s.x < 0;
874
+ if (need > this.count_ || (signed && !this.signed)) this.#build();
875
+ }
876
+
877
+ /** And fewer once it has landed, never below the reserved `digits`. */
878
+ #shrink() {
879
+ const need = Math.min(MAX_DRUMS, Math.max(this.o.digits + this.o.decimals, drumsFor(this.target, this.o.decimals)));
880
+ if (need < this.count_ || (this.signed && this.target >= 0)) this.#build();
881
+ }
882
+
883
+ // ---------------------------------------------------------------- drawing
884
+
885
+ #paint(dt) {
886
+ const o = this.o;
887
+ const x = this.s.x;
888
+ const pos = drumPositions(x, this.count_, o.decimals);
889
+ const lsdSpeed = Math.abs(this.s.v) * 10 ** o.decimals;
890
+ // |x| is what the drums show, so below zero they turn the other way.
891
+ const dir = Math.sign(this.s.v) * (x < 0 ? -1 : 1);
892
+ const cap = o.maxSpin;
893
+ const blanks = o.zeros ? null : blankZeros(x, this.count_, o.decimals);
894
+ let moving = false;
895
+ for (const d of this.drums) {
896
+ const speed = lsdSpeed / 10 ** d.k;
897
+ const r = spin(d.phase, pos[d.k] + d.offset, speed, dir, cap, dt);
898
+ let want;
899
+ if (r.free) want = 1;
900
+ else {
901
+ // A carry is fast even when the value is slow: measure what the drum
902
+ // actually did this frame, so a flick over is smeared too.
903
+ const moved = dt > 0 && Number.isFinite(d.shown) ? Math.abs(wrapDelta(r.pos - d.shown)) / dt : 0;
904
+ want = d.free ? 1 : smear(Math.max(speed, moved), cap);
905
+ }
906
+ d.free = r.free;
907
+ d.phase = r.pos;
908
+ d.blur = want >= d.blur || dt === 0 ? want : d.blur + (want - d.blur) * (1 - Math.exp(-dt / 0.07));
909
+ if (d.blur < 0.01) d.blur = 0;
910
+ if (d.blur) moving = true;
911
+ if (!(Math.abs(r.pos - d.shown) < 1e-5)) {
912
+ turn(d.faces, r.pos);
913
+ d.shown = r.pos;
914
+ }
915
+ const b = Math.round(d.blur * 50) / 50;
916
+ if (b !== d.b) {
917
+ d.rot.style.setProperty('--b', String(b));
918
+ d.b = b;
919
+ }
920
+ const blank = blanks ? blanks[d.k] : false;
921
+ if (blank !== d.blank) {
922
+ d.zero.classList.toggle('od-blank', blank);
923
+ d.blank = blank;
924
+ }
925
+ }
926
+ for (const sep of this.seps) {
927
+ const shown = !blanks || !blanks[sep.k];
928
+ if (shown !== sep.shown) {
929
+ sep.el.classList.toggle('od-off', !shown);
930
+ sep.shown = shown;
931
+ }
932
+ }
933
+ if (this.signEl) this.signEl.classList.toggle('od-off', !(x < 0 || (x === 0 && this.target < 0)));
934
+ return moving;
935
+ }
936
+
937
+ /** Announced politely unless counting (a tally is kept current, not read out) or told not to. */
938
+ #live() {
939
+ this.text.setAttribute('aria-live', this.counting || !this.o.announce ? 'off' : 'polite');
940
+ }
941
+
942
+ /** Politely, and only a landed value: never a frame's worth of digits. */
943
+ #announce() {
944
+ const t = formatValue(this.target, this.style, this.o.prefix, this.o.suffix);
945
+ if (this.text.textContent !== t) this.text.textContent = t;
946
+ }
947
+
948
+ // ---------------------------------------------------------------- the loop
949
+
950
+ #countTarget(now) {
951
+ const c = this.counting;
952
+ const lsd = 10 ** -this.o.decimals;
953
+ const steps = Math.floor((Math.abs(c.rate) * (now - c.t0)) / 1000 / lsd + 1e-9);
954
+ return this.#clampValue(c.base + Math.sign(c.rate) * steps * lsd);
955
+ }
956
+
957
+ /** Counting under reduced motion: the tally still advances, by jumps, with no frames. */
958
+ #restartCount() {
959
+ clearInterval(this.timer);
960
+ this.timer = 0;
961
+ if (!this.counting || !this.reduced) return;
962
+ const lsd = 10 ** -this.o.decimals;
963
+ const every = Math.max(100, (lsd / Math.abs(this.counting.rate)) * 1000);
964
+ this.timer = setInterval(() => {
965
+ this.target = this.#countTarget(performance.now());
966
+ this.s.x = this.target;
967
+ this.#grow();
968
+ this.#paint(0);
969
+ this.text.textContent = formatValue(this.target, this.style, this.o.prefix, this.o.suffix);
970
+ }, every);
971
+ }
972
+
973
+ #frame = (t) => {
974
+ this.raf = 0;
975
+ const dt = this.last ? (t - this.last) / 1000 : 1 / 60;
976
+ this.last = t;
977
+ const o = this.o;
978
+ if (this.counting) {
979
+ const next = this.#countTarget(performance.now());
980
+ if (next !== this.target) {
981
+ this.target = next;
982
+ this.#grow();
983
+ }
984
+ // Kept current for anyone who reads it, announced to no one.
985
+ if (t - this.lastText > 1000) {
986
+ this.lastText = t;
987
+ this.text.textContent = formatValue(this.target, this.style, o.prefix, o.suffix);
988
+ }
989
+ }
990
+ stepSpring(this.s, this.target, dt, o.stiffness, o.damping);
991
+ const done = !this.counting && landed(this.s, this.target, o.decimals);
992
+ if (done) {
993
+ this.s.x = this.target;
994
+ this.s.v = 0;
995
+ }
996
+ const n = Math.floor(scaled(this.s.x, o.decimals));
997
+ if (n !== this.lastInt) {
998
+ this.lastInt = n;
999
+ this.#tick(Math.abs(this.s.v) * 10 ** o.decimals);
1000
+ }
1001
+ const smeared = this.#paint(dt);
1002
+ if (done && !smeared) {
1003
+ this.#shrink();
1004
+ this.#paint(0);
1005
+ this.#announce();
1006
+ this.#sleep();
1007
+ return;
1008
+ }
1009
+ // A slow tally has landed its step and the next is a while off: sleep
1010
+ // until it is due instead of drawing still drums at 60 Hz.
1011
+ if (this.counting && !smeared && landed(this.s, this.target, o.decimals)) {
1012
+ const now = performance.now();
1013
+ const wait = nextStepAt(this.counting.t0, now, this.counting.rate, o.decimals) - now;
1014
+ if (wait > 60) {
1015
+ this.s.x = this.target;
1016
+ this.s.v = 0;
1017
+ this.#paint(0);
1018
+ this.text.textContent = formatValue(this.target, this.style, o.prefix, o.suffix);
1019
+ this.#sleep();
1020
+ this.stepTimer = setTimeout(() => { this.stepTimer = 0; this.#wake(); }, wait);
1021
+ return;
1022
+ }
1023
+ }
1024
+ if (!this.visible || document.hidden) this.#sleep();
1025
+ else this.raf = requestAnimationFrame(this.#frame);
1026
+ };
1027
+
1028
+ #wake() {
1029
+ clearTimeout(this.stepTimer);
1030
+ this.stepTimer = 0;
1031
+ if (this.reduced || this.raf || !this.visible || document.hidden) return;
1032
+ const idle = !this.counting && landed(this.s, this.target, this.o.decimals) && this.drums.every((d) => !d.blur);
1033
+ if (idle) return;
1034
+ this.root.dataset.state = 'running';
1035
+ this.last = 0;
1036
+ this.raf = requestAnimationFrame(this.#frame);
1037
+ }
1038
+
1039
+ #sleep() {
1040
+ cancelAnimationFrame(this.raf);
1041
+ clearTimeout(this.stepTimer);
1042
+ this.stepTimer = 0;
1043
+ this.raf = 0;
1044
+ this.root.dataset.state = 'asleep';
1045
+ }
1046
+
1047
+ // ---------------------------------------------------------------- sound
1048
+
1049
+ /** The audio context may only start inside a user gesture, so wait for one. */
1050
+ #listenForGesture() {
1051
+ if (this.audio || this.listening) return;
1052
+ this.listening = true;
1053
+ addEventListener('pointerdown', this.on.gesture, true);
1054
+ addEventListener('keydown', this.on.gesture, true);
1055
+ }
1056
+
1057
+ #stopListening() {
1058
+ if (!this.listening) return;
1059
+ this.listening = false;
1060
+ removeEventListener('pointerdown', this.on.gesture, true);
1061
+ removeEventListener('keydown', this.on.gesture, true);
1062
+ }
1063
+
1064
+ #armAudio() {
1065
+ this.#stopListening();
1066
+ try {
1067
+ const Ctx = globalThis.AudioContext ?? globalThis.webkitAudioContext;
1068
+ if (!Ctx || this.audio) return;
1069
+ this.audio = new Ctx();
1070
+ const len = Math.floor(this.audio.sampleRate * 0.016);
1071
+ this.noise = this.audio.createBuffer(1, len, this.audio.sampleRate);
1072
+ const ch = this.noise.getChannelData(0);
1073
+ for (let i = 0; i < len; i += 1) ch[i] = (Math.random() * 2 - 1) * (1 - i / len) ** 4;
1074
+ } catch { /* no audio device: the counter must still count */ }
1075
+ }
1076
+
1077
+ /** A soft tick per step of the last drum, never more than one per 45 ms; a spin whirrs rather than rattles. */
1078
+ #tick(speed) {
1079
+ if (!this.o.sound || !this.audio || this.audio.state !== 'running') return;
1080
+ const now = performance.now();
1081
+ if (now - this.lastTick < 45) return;
1082
+ this.lastTick = now;
1083
+ try {
1084
+ const ctx = this.audio;
1085
+ const src = ctx.createBufferSource();
1086
+ src.buffer = this.noise;
1087
+ src.playbackRate.value = 0.9 + Math.random() * 0.2;
1088
+ const band = ctx.createBiquadFilter();
1089
+ band.type = 'bandpass';
1090
+ band.frequency.value = 1900;
1091
+ band.Q.value = 3.2;
1092
+ const gain = ctx.createGain();
1093
+ gain.gain.value = 0.16 / (1 + speed / 30);
1094
+ src.connect(band).connect(gain).connect(ctx.destination);
1095
+ src.start();
1096
+ } catch { /* never let sound break the display */ }
1097
+ }
1098
+ }
1099
+
1100
+ export default Odometer;