tactile-odometer 1.0.0 → 1.1.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.
Files changed (3) hide show
  1. package/README.md +190 -176
  2. package/element.mjs +60 -0
  3. package/package.json +7 -3
package/README.md CHANGED
@@ -1,176 +1,190 @@
1
- # tactile-odometer
2
-
3
- A mechanical drum counter for the web that carries like the real thing.
4
-
5
- On a real odometer a drum only moves while the one to its right is carrying,
6
- so 1,099 → 1,100 rolls the last three drums over together, right at the end of
7
- the units' turn. Most animated counters move every digit on its own, so
8
- nothing carries. This one does.
9
-
10
- Vanilla ES module and CSS, no dependencies, no build step. MIT.
11
-
12
- **[Play with it →](https://tactile-ui.me/objects/odometer)**
13
-
14
- <!-- demo gif: 1,099 → 1,100, then +37 -->
15
-
16
- ## Install
17
-
18
- ```sh
19
- npm install tactile-odometer
20
- ```
21
-
22
- Or straight from a CDN:
23
-
24
- ```html
25
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tactile-odometer/odometer.css">
26
- <script type="module">
27
- import Odometer from 'https://cdn.jsdelivr.net/npm/tactile-odometer/odometer.mjs';
28
- </script>
29
- ```
30
-
31
- ## Use
32
-
33
- ```html
34
- <div id="seats" style="width: 480px; height: 160px"></div>
35
-
36
- <script type="module">
37
- // with a bundler; without one, use the CDN URLs above
38
- import Odometer from 'tactile-odometer';
39
- import 'tactile-odometer/odometer.css';
40
-
41
- const seats = new Odometer(document.getElementById('seats'), { value: 1099 });
42
-
43
- seats.setValue(1100); // rolls, and carries
44
- seats.setValue(4821.75); // springs there; fast drums spin, slow ones roll
45
- seats.count(1); // a live tally, +1 a second (0 stops it)
46
- seats.update({ finish: 'brass' });
47
- seats.destroy(); // removes everything it added
48
- </script>
49
- ```
50
-
51
- It works in React, Vue, Svelte or anything else that can hand it an element:
52
- create it in an effect or `onMount`, call `destroy()` on unmount.
53
-
54
- ### Options
55
-
56
- | Option | Default | What it does |
57
- |---|---|---|
58
- | `value` | `0` | The number to start on |
59
- | `decimals` | `0` | Drums after the decimal point (0 to 6) |
60
- | `prefix`, `suffix` | `''` | Printed on the plate either side of the window: `$`, `km`, `seats left` |
61
- | `locale` | `'en-US'` | Group and decimal marks, grouping pattern and digit glyphs, from `Intl.NumberFormat` |
62
- | `grouping` | `true` | Fixed plates between drums: 1,099 rather than 1099 |
63
- | `digits` | `1` | Whole-number drums to reserve, so a growing number doesn't move the layout |
64
- | `zeros` | `false` | Print 001,099 like a car odometer, instead of blanks |
65
- | `finish` | `'ink'` | `ink`, `bone`, `steel` or `brass`, each in its own housing |
66
- | `red` | `'none'` | Signal-red drums: `last`, or all the `decimals` (the classic tenths) |
67
- | `bezel` | `'slot'` | One `slot` across the drums, `cells` like a tally counter, or `bare` to sit in a line of text |
68
- | `stiffness` | `140` | How hard the value is pulled to a new number |
69
- | `damping` | `0.85` | Below 1 it overshoots and winds back; 1 and above it arrives without passing |
70
- | `maxSpin` | `24` | Digits per second; past this a drum is drawn spinning at the cap, blurred, instead of strobing |
71
- | `count` | `0` | A live tally per second, in steps of the last drum; negative counts down |
72
- | `wear` | `0.35` | Drums a little off their detents, figures a little off register |
73
- | `light` | `315` | Where the lamp is, in degrees (0 is above, clockwise) |
74
- | `size` | `'fit'` | `'fit'` fills the host and follows it; a number is the numeral size in px |
75
- | `label` | `''` | Engraved into the plate under the window |
76
- | `sound` | `false` | A soft tick per step of the last drum (a whirr when it spins), once the page has had a click or key press |
77
- | `announce` | `true` | Announce the landed value to screen readers; `false` for an ambient tally |
78
-
79
- ### Methods
80
-
81
- - `setValue(n, { instant })`: spring to a new value, or jump with `instant: true`
82
- - `count(rate)`: tally `rate` per second; `count(0)` stops
83
- - `update(options)`: change any option
84
- - `destroy()`: stop and remove everything
85
- - `value`, `state`: the target value, and `running` or `asleep`
86
-
87
- ## How the carry works
88
-
89
- Only the last drum is driven. Every other drum moves only while the drum to
90
- its right passes 9 → 0. The first carry takes the last tenth of the units'
91
- step, and every carry above it turns in lock-step with the drum below, which is
92
- why the carries all roll together at the end.
93
-
94
- That rule is one pure function of a single continuous value:
95
-
96
- ```js
97
- export function drumPositions(value, count, decimals = 0, carry = CARRY) {
98
- const n = scaled(value, decimals);
99
- const out = new Array(count);
100
- if (count < 1) return out;
101
- let below = n % 10;
102
- out[0] = below;
103
- for (let k = 1; k < count; k += 1) {
104
- const w = k === 1 ? carry : 1;
105
- const digit = Math.floor(n / 10 ** k) % 10;
106
- below = digit + clamp((below - 10 + w) / w, 0, 1);
107
- out[k] = below;
108
- }
109
- return out;
110
- }
111
- ```
112
-
113
- The value itself is a spring, so the whole counter animates one number and
114
- every drum follows from this. `scaled()` removes float dust, so 10.1 at two
115
- decimals is 1010, not 1009.9999999999999.
116
-
117
- The rest:
118
-
119
- - **The light doesn't turn.** A drum is a cylinder on a horizontal axle, so
120
- the light on it depends only on height in the window, not on how far it has
121
- turned. One overlay per drum, computed once from the lamp; the numerals roll
122
- through it.
123
- - **Real text.** The faces are DOM text on a ten-sided CSS 3D drum, so the
124
- figures are crisp and properly foreshortened.
125
- - **The numerals** are Big Shoulders Bold, bundled, so the drums print the same
126
- figures everywhere. The font loads `block` and the figures are re-centred on
127
- it once it lands, so nothing jumps.
128
-
129
- ## Accessibility and cost
130
-
131
- - The drums are `aria-hidden`. The number is a visually hidden text element,
132
- announced politely once the value has landed: never every frame.
133
- - Reduced motion: the value changes instantly, nothing spins, no frame is
134
- requested.
135
- - It costs nothing at rest. The loop runs only while a value is travelling,
136
- sleeps once it lands, sleeps between the steps of a slow tally, and pauses
137
- off screen and in a hidden tab. A tab that comes back after two seconds
138
- can't blow up the spring.
139
-
140
- ## Compared
141
-
142
- | | tactile-odometer | odometer.js (HubSpot) | NumberFlow |
143
- |---|---|---|---|
144
- | 1,099 → 1,100 | tens and hundreds carry together at the end of the units' turn | every digit column slides at once, over the same duration | each digit animates on its own |
145
- | Dependencies | none | none | one small one (`esm-env`), plus React, Vue and Svelte packages |
146
- | Screen readers | real text, announced once when it lands | not built in | described as accessible |
147
- | Reduced motion | instant change | not built in | respected by default |
148
- | Big jumps | fast drums blur at a spin cap, slow ones roll | each column slides through its ribbon | digits transition |
149
-
150
- The odometer.js column is from its source (`animateSlide` builds one ribbon
151
- per digit column and slides them together). The NumberFlow column is from its
152
- docs and npm, and from watching it; its docs don't describe the carry. If
153
- anything here is wrong, open an issue and I'll fix the table.
154
-
155
- ## Tests
156
-
157
- ```sh
158
- npm test
159
- ```
160
-
161
- The pure parts (the carry, the spring, formatting, fitting, the light) are
162
- tested in Node with `node:test`.
163
-
164
- ## Where it's from
165
-
166
- It's the free piece of [Tactile UI](https://tactile-ui.me), a library of
167
- interface objects that work like the hardware they're drawn from: a pin
168
- screen, a Chladni plate, a detent dial, a cassette. The rest are paid; this
169
- one is MIT, for everyone.
170
-
171
- Made by [Ciprian Titire](https://titi.re).
172
-
173
- ## Licence
174
-
175
- MIT. The numerals, `big-shoulders-bold.woff2`, are under the SIL Open Font
176
- License 1.1; see `BigShoulders-OFL.txt`.
1
+ # tactile-odometer
2
+
3
+ A mechanical drum counter for the web that carries like the real thing.
4
+
5
+ On a real odometer a drum only moves while the one to its right is carrying,
6
+ so 1,099 → 1,100 rolls the last three drums over together, right at the end of
7
+ the units' turn. Most animated counters move every digit on its own, so
8
+ nothing carries. This one does.
9
+
10
+ Vanilla ES module and CSS, no dependencies, no build step. MIT.
11
+
12
+ **[Play with it →](https://tactile-ui.me/objects/odometer)**
13
+
14
+ ![1,099 rolling to 1,100: the last three drums carry together](demo.gif)
15
+
16
+ ## Install
17
+
18
+ ```sh
19
+ npm install tactile-odometer
20
+ ```
21
+
22
+ Or straight from a CDN:
23
+
24
+ ```html
25
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tactile-odometer/odometer.css">
26
+ <script type="module">
27
+ import Odometer from 'https://cdn.jsdelivr.net/npm/tactile-odometer/odometer.mjs';
28
+ </script>
29
+ ```
30
+
31
+ ## Use
32
+
33
+ ```html
34
+ <div id="seats" style="width: 480px; height: 160px"></div>
35
+
36
+ <script type="module">
37
+ // with a bundler; without one, use the CDN URLs above
38
+ import Odometer from 'tactile-odometer';
39
+ import 'tactile-odometer/odometer.css';
40
+
41
+ const seats = new Odometer(document.getElementById('seats'), { value: 1099 });
42
+
43
+ seats.setValue(1100); // rolls, and carries
44
+ seats.setValue(4821.75); // springs there; fast drums spin, slow ones roll
45
+ seats.count(1); // a live tally, +1 a second (0 stops it)
46
+ seats.update({ finish: 'brass' });
47
+ seats.destroy(); // removes everything it added
48
+ </script>
49
+ ```
50
+
51
+ It works in React, Vue, Svelte or anything else that can hand it an element:
52
+ create it in an effect or `onMount`, call `destroy()` on unmount.
53
+
54
+ ### As a custom element
55
+
56
+ ```html
57
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tactile-odometer/odometer.css">
58
+ <script type="module" src="https://cdn.jsdelivr.net/npm/tactile-odometer/element.mjs"></script>
59
+
60
+ <tactile-odometer value="1099" digits="4" finish="brass" style="width:480px;height:160px"></tactile-odometer>
61
+ ```
62
+
63
+ Every option below is an attribute of the same name (`maxSpin` is `max-spin`).
64
+ Set `el.value = 1100` or the `value` attribute to roll it; `el.odometer` is the
65
+ Odometer itself. It's made when the element joins the page and destroyed when
66
+ it leaves.
67
+
68
+ ### Options
69
+
70
+ | Option | Default | What it does |
71
+ |---|---|---|
72
+ | `value` | `0` | The number to start on |
73
+ | `decimals` | `0` | Drums after the decimal point (0 to 6) |
74
+ | `prefix`, `suffix` | `''` | Printed on the plate either side of the window: `$`, `km`, `seats left` |
75
+ | `locale` | `'en-US'` | Group and decimal marks, grouping pattern and digit glyphs, from `Intl.NumberFormat` |
76
+ | `grouping` | `true` | Fixed plates between drums: 1,099 rather than 1099 |
77
+ | `digits` | `1` | Whole-number drums to reserve, so a growing number doesn't move the layout |
78
+ | `zeros` | `false` | Print 001,099 like a car odometer, instead of blanks |
79
+ | `finish` | `'ink'` | `ink`, `bone`, `steel` or `brass`, each in its own housing |
80
+ | `red` | `'none'` | Signal-red drums: `last`, or all the `decimals` (the classic tenths) |
81
+ | `bezel` | `'slot'` | One `slot` across the drums, `cells` like a tally counter, or `bare` to sit in a line of text |
82
+ | `stiffness` | `140` | How hard the value is pulled to a new number |
83
+ | `damping` | `0.85` | Below 1 it overshoots and winds back; 1 and above it arrives without passing |
84
+ | `maxSpin` | `24` | Digits per second; past this a drum is drawn spinning at the cap, blurred, instead of strobing |
85
+ | `count` | `0` | A live tally per second, in steps of the last drum; negative counts down |
86
+ | `wear` | `0.35` | Drums a little off their detents, figures a little off register |
87
+ | `light` | `315` | Where the lamp is, in degrees (0 is above, clockwise) |
88
+ | `size` | `'fit'` | `'fit'` fills the host and follows it; a number is the numeral size in px |
89
+ | `label` | `''` | Engraved into the plate under the window |
90
+ | `sound` | `false` | A soft tick per step of the last drum (a whirr when it spins), once the page has had a click or key press |
91
+ | `announce` | `true` | Announce the landed value to screen readers; `false` for an ambient tally |
92
+
93
+ ### Methods
94
+
95
+ - `setValue(n, { instant })`: spring to a new value, or jump with `instant: true`
96
+ - `count(rate)`: tally `rate` per second; `count(0)` stops
97
+ - `update(options)`: change any option
98
+ - `destroy()`: stop and remove everything
99
+ - `value`, `state`: the target value, and `running` or `asleep`
100
+
101
+ ## How the carry works
102
+
103
+ Only the last drum is driven. Every other drum moves only while the drum to
104
+ its right passes 9 → 0. The first carry takes the last tenth of the units'
105
+ step, and every carry above it turns in lock-step with the drum below, which is
106
+ why the carries all roll together at the end.
107
+
108
+ That rule is one pure function of a single continuous value:
109
+
110
+ ```js
111
+ export function drumPositions(value, count, decimals = 0, carry = CARRY) {
112
+ const n = scaled(value, decimals);
113
+ const out = new Array(count);
114
+ if (count < 1) return out;
115
+ let below = n % 10;
116
+ out[0] = below;
117
+ for (let k = 1; k < count; k += 1) {
118
+ const w = k === 1 ? carry : 1;
119
+ const digit = Math.floor(n / 10 ** k) % 10;
120
+ below = digit + clamp((below - 10 + w) / w, 0, 1);
121
+ out[k] = below;
122
+ }
123
+ return out;
124
+ }
125
+ ```
126
+
127
+ The value itself is a spring, so the whole counter animates one number and
128
+ every drum follows from this. `scaled()` removes float dust, so 10.1 at two
129
+ decimals is 1010, not 1009.9999999999999.
130
+
131
+ The rest:
132
+
133
+ - **The light doesn't turn.** A drum is a cylinder on a horizontal axle, so
134
+ the light on it depends only on height in the window, not on how far it has
135
+ turned. One overlay per drum, computed once from the lamp; the numerals roll
136
+ through it.
137
+ - **Real text.** The faces are DOM text on a ten-sided CSS 3D drum, so the
138
+ figures are crisp and properly foreshortened.
139
+ - **The numerals** are Big Shoulders Bold, bundled, so the drums print the same
140
+ figures everywhere. The font loads `block` and the figures are re-centred on
141
+ it once it lands, so nothing jumps.
142
+
143
+ ## Accessibility and cost
144
+
145
+ - The drums are `aria-hidden`. The number is a visually hidden text element,
146
+ announced politely once the value has landed: never every frame.
147
+ - Reduced motion: the value changes instantly, nothing spins, no frame is
148
+ requested.
149
+ - It costs nothing at rest. The loop runs only while a value is travelling,
150
+ sleeps once it lands, sleeps between the steps of a slow tally, and pauses
151
+ off screen and in a hidden tab. A tab that comes back after two seconds
152
+ can't blow up the spring.
153
+
154
+ ## Compared
155
+
156
+ | | tactile-odometer | odometer.js (HubSpot) | NumberFlow |
157
+ |---|---|---|---|
158
+ | 1,099 → 1,100 | tens and hundreds carry together at the end of the units' turn | every digit column slides at once, over the same duration | each digit animates on its own |
159
+ | Dependencies | none | none | one small one (`esm-env`), plus React, Vue and Svelte packages |
160
+ | Screen readers | real text, announced once when it lands | not built in | described as accessible |
161
+ | Reduced motion | instant change | not built in | respected by default |
162
+ | Big jumps | fast drums blur at a spin cap, slow ones roll | each column slides through its ribbon | digits transition |
163
+
164
+ The odometer.js column is from its source (`animateSlide` builds one ribbon
165
+ per digit column and slides them together). The NumberFlow column is from its
166
+ docs and npm, and from watching it; its docs don't describe the carry. If
167
+ anything here is wrong, open an issue and I'll fix the table.
168
+
169
+ ## Tests
170
+
171
+ ```sh
172
+ npm test
173
+ ```
174
+
175
+ The pure parts (the carry, the spring, formatting, fitting, the light) are
176
+ tested in Node with `node:test`.
177
+
178
+ ## Where it's from
179
+
180
+ It's the free piece of [Tactile UI](https://tactile-ui.me), a library of
181
+ interface objects that work like the hardware they're drawn from: a pin
182
+ screen, a Chladni plate, a detent dial, a cassette. The rest are paid; this
183
+ one is MIT, for everyone.
184
+
185
+ Made by [Ciprian Titire](https://titi.re).
186
+
187
+ ## Licence
188
+
189
+ MIT. The numerals, `big-shoulders-bold.woff2`, are under the SIL Open Font
190
+ License 1.1; see `BigShoulders-OFL.txt`.
package/element.mjs ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * <tactile-odometer>: the Odometer as a custom element, for plain HTML,
3
+ * Angular, Astro, Rails or anything else that renders elements.
4
+ *
5
+ * <script type="module" src="tactile-odometer/element.mjs"></script>
6
+ * <tactile-odometer value="1099" digits="4" style="width:480px;height:160px"></tactile-odometer>
7
+ *
8
+ * el.value = 1100; // or el.setAttribute('value', 1100): rolls, and carries
9
+ * el.odometer.count(1); // the Odometer itself, for anything else
10
+ *
11
+ * Every option is an attribute of the same name (see the README). Numbers are
12
+ * read as numbers; grouping, zeros, sound and announce are true unless set to
13
+ * "false". The counter is made when the element joins the page and destroyed
14
+ * when it leaves. The stylesheet is still yours to load.
15
+ */
16
+ import Odometer from './odometer.mjs';
17
+
18
+ const NUMBERS = ['decimals', 'digits', 'stiffness', 'damping', 'maxSpin', 'count', 'wear', 'light', 'size'];
19
+ const STRINGS = ['prefix', 'suffix', 'locale', 'finish', 'red', 'bezel', 'label'];
20
+ const BOOLEANS = ['grouping', 'zeros', 'sound', 'announce'];
21
+ // Attributes are lower-cased by HTML, so maxSpin is written max-spin.
22
+ const attr = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
23
+
24
+ export class TactileOdometer extends HTMLElement {
25
+ static observedAttributes = ['value', ...[...NUMBERS, ...STRINGS, ...BOOLEANS].map(attr)];
26
+ #odometer = null;
27
+
28
+ /** The Odometer inside, once the element is on the page. */
29
+ get odometer() { return this.#odometer; }
30
+ get value() { return this.#odometer?.value ?? Number(this.getAttribute('value') ?? 0); }
31
+ set value(v) { this.setAttribute('value', String(v)); }
32
+
33
+ #options() {
34
+ const o = {};
35
+ for (const k of NUMBERS) if (this.hasAttribute(attr(k))) o[k] = k === 'size' && this.getAttribute('size') === 'fit' ? 'fit' : Number(this.getAttribute(attr(k)));
36
+ for (const k of STRINGS) if (this.hasAttribute(attr(k))) o[k] = this.getAttribute(attr(k));
37
+ for (const k of BOOLEANS) if (this.hasAttribute(attr(k))) o[k] = this.getAttribute(attr(k)) !== 'false';
38
+ return o;
39
+ }
40
+
41
+ connectedCallback() {
42
+ if (this.#odometer) return;
43
+ if (!this.style.display) this.style.display = 'block';
44
+ this.#odometer = new Odometer(this, { ...this.#options(), value: Number(this.getAttribute('value') ?? 0) });
45
+ }
46
+
47
+ disconnectedCallback() {
48
+ this.#odometer?.destroy();
49
+ this.#odometer = null;
50
+ }
51
+
52
+ attributeChangedCallback(name, old, value) {
53
+ if (!this.#odometer || old === value) return;
54
+ if (name === 'value') this.#odometer.setValue(Number(value));
55
+ else this.#odometer.update(this.#options());
56
+ }
57
+ }
58
+
59
+ if (!customElements.get('tactile-odometer')) customElements.define('tactile-odometer', TactileOdometer);
60
+ export default TactileOdometer;
package/package.json CHANGED
@@ -1,16 +1,18 @@
1
1
  {
2
2
  "name": "tactile-odometer",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "A mechanical drum counter for the web that carries like the real thing: 1,099 to 1,100 rolls three drums together. Vanilla JS, no dependencies.",
5
5
  "type": "module",
6
6
  "main": "./odometer.mjs",
7
7
  "exports": {
8
8
  ".": "./odometer.mjs",
9
9
  "./odometer.css": "./odometer.css",
10
- "./big-shoulders-bold.woff2": "./big-shoulders-bold.woff2"
10
+ "./big-shoulders-bold.woff2": "./big-shoulders-bold.woff2",
11
+ "./element": "./element.mjs"
11
12
  },
12
13
  "files": [
13
14
  "odometer.mjs",
15
+ "element.mjs",
14
16
  "odometer.css",
15
17
  "big-shoulders-bold.woff2",
16
18
  "BigShoulders-OFL.txt",
@@ -30,7 +32,9 @@
30
32
  "ticker",
31
33
  "drum-counter",
32
34
  "vanilla-js",
33
- "no-dependencies"
35
+ "no-dependencies",
36
+ "custom-element",
37
+ "web-component"
34
38
  ],
35
39
  "license": "MIT",
36
40
  "author": "Ciprian Titire (https://titi.re)",