split-flap-board 0.0.1 → 0.0.4
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/README.md +452 -1
- package/dist/cjs/SplitFlapBoard.js +61 -0
- package/dist/cjs/index.js +1 -7
- package/dist/cjs/lib/board.js +1 -0
- package/dist/cjs/lib/flap.js +1 -0
- package/dist/cjs/lib/spool-layout.js +1 -0
- package/dist/cjs/spools/SplitFlapSpool.js +18 -0
- package/dist/cjs/spools/SplitFlapSpoolBase.js +16 -0
- package/dist/cjs/spools/SplitFlapSpoolMinimal.js +121 -0
- package/dist/cjs/spools/SplitFlapSpoolRealistic.js +156 -0
- package/dist/cjs/spools/presets.js +1 -0
- package/dist/esm/SplitFlapBoard.js +61 -0
- package/dist/esm/index.js +1 -5
- package/dist/esm/lib/board.js +1 -0
- package/dist/esm/lib/flap.js +1 -0
- package/dist/esm/lib/spool-layout.js +1 -0
- package/dist/esm/spools/SplitFlapSpool.js +18 -0
- package/dist/esm/spools/SplitFlapSpoolBase.js +16 -0
- package/dist/esm/spools/SplitFlapSpoolMinimal.js +121 -0
- package/dist/esm/spools/SplitFlapSpoolRealistic.js +156 -0
- package/dist/esm/spools/presets.js +1 -0
- package/dist/types/SplitFlapBoard.d.ts +28 -0
- package/dist/types/index.d.ts +4 -2
- package/dist/types/lib/board.d.ts +30 -0
- package/dist/types/lib/flap.d.ts +3 -0
- package/dist/types/lib/index.d.ts +3 -0
- package/dist/types/lib/spool-layout.d.ts +16 -0
- package/dist/types/spools/SplitFlapSpool.d.ts +25 -0
- package/dist/types/spools/SplitFlapSpoolBase.d.ts +52 -0
- package/dist/types/spools/SplitFlapSpoolMinimal.d.ts +11 -0
- package/dist/types/spools/SplitFlapSpoolRealistic.d.ts +39 -0
- package/dist/types/spools/index.d.ts +5 -0
- package/dist/types/spools/presets.d.ts +4 -0
- package/dist/types/types.d.ts +30 -0
- package/package.json +13 -4
- package/dist/cjs/index.js.map +0 -1
- package/dist/esm/index.js.map +0 -1
- package/dist/types/index.d.ts.map +0 -1
package/README.md
CHANGED
|
@@ -1 +1,452 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/builder-group/community/develop/packages/split-flap-board/.github/banner.svg" alt="split-flap-board banner">
|
|
3
|
+
</h1>
|
|
4
|
+
|
|
5
|
+
<p align="left">
|
|
6
|
+
<a href="https://github.com/builder-group/community/blob/develop/LICENSE">
|
|
7
|
+
<img src="https://img.shields.io/github/license/builder-group/community.svg?label=license&style=flat&colorA=293140&colorB=FDE200" alt="GitHub License"/>
|
|
8
|
+
</a>
|
|
9
|
+
<a href="https://www.npmjs.com/package/split-flap-board">
|
|
10
|
+
<img src="https://img.shields.io/bundlephobia/minzip/split-flap-board.svg?label=minzipped%20size&style=flat&colorA=293140&colorB=FDE200" alt="NPM bundle minzipped size"/>
|
|
11
|
+
</a>
|
|
12
|
+
<a href="https://www.npmjs.com/package/split-flap-board">
|
|
13
|
+
<img src="https://img.shields.io/npm/dt/split-flap-board.svg?label=downloads&style=flat&colorA=293140&colorB=FDE200" alt="NPM total downloads"/>
|
|
14
|
+
</a>
|
|
15
|
+
<a href="https://discord.gg/w4xE3bSjhQ">
|
|
16
|
+
<img src="https://img.shields.io/discord/795291052897992724.svg?label=&logo=discord&logoColor=000000&color=293140&labelColor=FDE200" alt="Join Discord"/>
|
|
17
|
+
</a>
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
Web component that simulates a split-flap display, the mechanical boards found in airports and train stations. Built with [Lit](https://lit.dev/), works in any framework or plain HTML.
|
|
21
|
+
|
|
22
|
+
## How a Split-Flap Display Works
|
|
23
|
+
|
|
24
|
+
> [How a Split-Flap Display Works (YouTube)](https://www.youtube.com/watch?v=UAQJJAQSg_g)
|
|
25
|
+
|
|
26
|
+
A split-flap display (also called a "Solari board") works through purely mechanical means:
|
|
27
|
+
|
|
28
|
+
- A **spool** (drum) holds a series of **flaps** (thin cards), each printed with the top half of one character on the front and the bottom half of a different character on the back.
|
|
29
|
+
- A **stepper motor** rotates the spool precisely. As each flap passes vertical, gravity pulls it down, snapping it against a **backstop** and creating the characteristic clacking sound.
|
|
30
|
+
- This reveal happens one flap at a time, so going from `A` to `Z` means cycling through every character in between. The order of the flaps on the spool is fixed at manufacture.
|
|
31
|
+
- A **hall effect sensor** and magnet on the spool give the controller a consistent home position, so it always knows which character is showing even after a power cycle.
|
|
32
|
+
|
|
33
|
+
In code, each `<split-flap-spool>` mirrors this: it holds a sequence of **flaps** and steps forward through them to reach a target key, never backward.
|
|
34
|
+
|
|
35
|
+
## Core Concepts
|
|
36
|
+
|
|
37
|
+
### Flap
|
|
38
|
+
|
|
39
|
+
A flap is one card on the spool, the atomic unit of content. Four built-in types:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// Character (default)
|
|
43
|
+
{ type: 'char'; value: string; color?: string; bg?: string; fontSize?: string; fontFamily?: string; fontWeight?: string }
|
|
44
|
+
|
|
45
|
+
// Solid color
|
|
46
|
+
{ type: 'color'; value: string } // any CSS color
|
|
47
|
+
|
|
48
|
+
// Image
|
|
49
|
+
{ type: 'image'; src: string; alt?: string }
|
|
50
|
+
|
|
51
|
+
// Custom - top and bottom halves rendered independently (Lit TemplateResult)
|
|
52
|
+
{ type: 'custom'; key: string; top: TemplateResult; bottom: TemplateResult }
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The `key` field is optional on all types except `custom`. When omitted it defaults to the natural identifier: `value` for char and color, `src` for image.
|
|
56
|
+
|
|
57
|
+
Char flaps default to `2rem` monospace bold. Override per-flap via the object, or rebuild the spool with a new `fontSize` to change all at once:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
const bigSpool = charSpool.map((f) =>
|
|
61
|
+
f.type === 'char' ? { ...f, fontSize: '3rem', fontWeight: '400' } : f
|
|
62
|
+
);
|
|
63
|
+
spool.flaps = bigSpool;
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Spool
|
|
67
|
+
|
|
68
|
+
A spool is an ordered array of flaps, the sequence a `<split-flap-spool>` steps through. Define it once, reference it anywhere.
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
type TSpool = TFlap[];
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The library ships built-in spools:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { charSpool, numericSpool } from 'split-flap-board';
|
|
78
|
+
|
|
79
|
+
// charSpool → [' ', A-Z, 0-9, . - / :]
|
|
80
|
+
// numericSpool → [' ', 0-9]
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Custom spools are just arrays:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
const statusSpool: TSpool = [
|
|
87
|
+
{ type: 'color', value: '#111', key: 'off' },
|
|
88
|
+
{ type: 'color', value: '#16a34a', key: 'green' },
|
|
89
|
+
{ type: 'color', value: '#dc2626', key: 'red' },
|
|
90
|
+
{ type: 'color', value: '#f59e0b', key: 'yellow' }
|
|
91
|
+
];
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
You can mix flap types within a spool:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
const mixedSpool: TSpool = [
|
|
98
|
+
{ type: 'char', value: ' ' },
|
|
99
|
+
{ type: 'image', src: '/icons/check.svg', key: 'check' },
|
|
100
|
+
{ type: 'color', value: '#16a34a', key: 'green' },
|
|
101
|
+
{ type: 'char', value: '!' }
|
|
102
|
+
];
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Spools grid vs. target grid
|
|
106
|
+
|
|
107
|
+
A board has two separate grids:
|
|
108
|
+
|
|
109
|
+
- **`spools`** - a `TSpool[][]` defining what each spool unit CAN show. Typically set once at init.
|
|
110
|
+
- **`grid`** - a `string[][]` of target keys defining what each unit SHOWS right now. Updated freely at runtime.
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { charSpool, spoolGrid } from 'split-flap-board';
|
|
114
|
+
|
|
115
|
+
// spoolGrid(spool, cols, rows) fills a uniform TSpool[][]
|
|
116
|
+
board.spools = spoolGrid(charSpool, 10, 3);
|
|
117
|
+
|
|
118
|
+
// per-column: pass an array of spools, one per column (shorter arrays repeat)
|
|
119
|
+
board.spools = spoolGrid([charSpool, charSpool, statusSpool], 3, 2);
|
|
120
|
+
|
|
121
|
+
// fully custom: build the 2D array directly
|
|
122
|
+
board.spools = [
|
|
123
|
+
[charSpool, charSpool, statusSpool],
|
|
124
|
+
[charSpool, charSpool, statusSpool]
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
// grid: target keys, updated freely at runtime
|
|
128
|
+
board.grid = [
|
|
129
|
+
['H', 'E', 'green'],
|
|
130
|
+
['L', 'O', 'red']
|
|
131
|
+
];
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Board dimensions are inferred from `spools`.
|
|
135
|
+
|
|
136
|
+
## Usage
|
|
137
|
+
|
|
138
|
+
### Single spool
|
|
139
|
+
|
|
140
|
+
```html
|
|
141
|
+
<script type="module">
|
|
142
|
+
import 'split-flap-board';
|
|
143
|
+
</script>
|
|
144
|
+
|
|
145
|
+
<split-flap-spool value="A"></split-flap-spool>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
```js
|
|
149
|
+
const spool = document.querySelector('split-flap-spool');
|
|
150
|
+
spool.value = 'Z'; // steps forward: A → B → ... → Z
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Switch to the realistic look:
|
|
154
|
+
|
|
155
|
+
```html
|
|
156
|
+
<split-flap-spool variant="realistic" value="A"></split-flap-spool>
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Custom spool:
|
|
160
|
+
|
|
161
|
+
```js
|
|
162
|
+
import { statusSpool } from './my-spools';
|
|
163
|
+
|
|
164
|
+
spool.flaps = statusSpool;
|
|
165
|
+
spool.value = 'green';
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Board
|
|
169
|
+
|
|
170
|
+
```html
|
|
171
|
+
<script type="module">
|
|
172
|
+
import 'split-flap-board';
|
|
173
|
+
</script>
|
|
174
|
+
|
|
175
|
+
<split-flap-board></split-flap-board>
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
```js
|
|
179
|
+
import { fromLines } from 'split-flap-board';
|
|
180
|
+
|
|
181
|
+
const board = document.querySelector('split-flap-board');
|
|
182
|
+
const { spools, grid } = fromLines(['HELLO WORLD'], 11);
|
|
183
|
+
board.spools = spools;
|
|
184
|
+
board.grid = grid;
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Updating at runtime
|
|
188
|
+
|
|
189
|
+
Only `grid` needs to change for content updates. Assign a new array reference:
|
|
190
|
+
|
|
191
|
+
```js
|
|
192
|
+
// refresh content - spools stay the same
|
|
193
|
+
board.grid = [['G', 'O', 'O', 'D', 'B', 'Y', 'E', ' ', ' ', ' ', ' ']];
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Multi-row board
|
|
197
|
+
|
|
198
|
+
```js
|
|
199
|
+
import { fromLines } from 'split-flap-board';
|
|
200
|
+
|
|
201
|
+
const { spools, grid } = fromLines(
|
|
202
|
+
['BA123 LHR 18:30 BOARDING', 'LH456 FRA 19:15 ON TIME ', 'AF789 CDG 19:45 DELAYED '],
|
|
203
|
+
26
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
board.spools = spools;
|
|
207
|
+
board.grid = grid;
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Mixed spools per column
|
|
211
|
+
|
|
212
|
+
```js
|
|
213
|
+
import { charSpool, spoolGrid } from 'split-flap-board';
|
|
214
|
+
|
|
215
|
+
const statusSpool = [
|
|
216
|
+
{ type: 'color', value: '#111', key: 'off' },
|
|
217
|
+
{ type: 'color', value: '#16a34a', key: 'green' },
|
|
218
|
+
{ type: 'color', value: '#dc2626', key: 'red' }
|
|
219
|
+
];
|
|
220
|
+
|
|
221
|
+
board.spools = spoolGrid([charSpool, charSpool, charSpool, charSpool, statusSpool], 5, 2);
|
|
222
|
+
|
|
223
|
+
board.grid = [
|
|
224
|
+
['G', 'A', 'T', 'E', 'green'],
|
|
225
|
+
['B', '1', '2', '3', 'red']
|
|
226
|
+
];
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Colored rows
|
|
230
|
+
|
|
231
|
+
```js
|
|
232
|
+
import { fromLines } from 'split-flap-board';
|
|
233
|
+
|
|
234
|
+
const { spools, grid } = fromLines(
|
|
235
|
+
[
|
|
236
|
+
{ text: 'BA123 LHR BOARDING', bg: '#16a34a', color: '#fff' },
|
|
237
|
+
{ text: 'LH456 FRA ON TIME ' },
|
|
238
|
+
{ text: 'AF789 CDG DELAYED ', bg: '#dc2626', color: '#fff' }
|
|
239
|
+
],
|
|
240
|
+
26
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
board.spools = spools;
|
|
244
|
+
board.grid = grid;
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### React
|
|
248
|
+
|
|
249
|
+
```tsx
|
|
250
|
+
import { useEffect, useRef } from 'react';
|
|
251
|
+
import { fromLines } from 'split-flap-board';
|
|
252
|
+
|
|
253
|
+
declare global {
|
|
254
|
+
namespace JSX {
|
|
255
|
+
interface IntrinsicElements {
|
|
256
|
+
'split-flap-spool': React.HTMLAttributes<HTMLElement> & { value?: string; variant?: string };
|
|
257
|
+
'split-flap-board': React.HTMLAttributes<HTMLElement>;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function DeparturesBoard() {
|
|
263
|
+
const ref = useRef<HTMLElement>(null);
|
|
264
|
+
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
if (ref.current == null) return;
|
|
267
|
+
const { spools, grid } = fromLines(['DEPARTURES'], 10);
|
|
268
|
+
(ref.current as any).spools = spools;
|
|
269
|
+
(ref.current as any).grid = grid;
|
|
270
|
+
}, []);
|
|
271
|
+
|
|
272
|
+
return <split-flap-board ref={ref} />;
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## API Reference
|
|
277
|
+
|
|
278
|
+
### `<split-flap-spool>`
|
|
279
|
+
|
|
280
|
+
| Property | Type | Default | Description |
|
|
281
|
+
| ------------------ | -------------------------- | ----------- | ------------------------------------------------------------------------------ |
|
|
282
|
+
| `variant` | `'minimal' \| 'realistic'` | `'minimal'` | Which visual variant to render. |
|
|
283
|
+
| `value` | `string` | `' '` | Target flap key. Steps forward through the flaps until it reaches this key. |
|
|
284
|
+
| `flaps` | `TSpool` | `charSpool` | The ordered sequence of flaps this spool holds. |
|
|
285
|
+
| `speed` | `number` | `60` | Milliseconds per flap step. |
|
|
286
|
+
| `visibleSideCount` | `number` | `-1` | Realistic variant only. Limits how many flaps render on each side of the drum. |
|
|
287
|
+
|
|
288
|
+
#### Variants
|
|
289
|
+
|
|
290
|
+
| Variant | Element | Description |
|
|
291
|
+
| ------------- | ------------------------------ | ---------------------------------------------------------------- |
|
|
292
|
+
| `'minimal'` | `<split-flap-spool-minimal>` | Clean card renderer that only draws the active flap. |
|
|
293
|
+
| `'realistic'` | `<split-flap-spool-realistic>` | 3D drum renderer that places multiple flaps around the cylinder. |
|
|
294
|
+
|
|
295
|
+
The variant elements can also be used directly if you prefer not to use the wrapper.
|
|
296
|
+
|
|
297
|
+
### `<split-flap-board>`
|
|
298
|
+
|
|
299
|
+
| Property | Type | Default | Description |
|
|
300
|
+
| ------------------ | -------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
301
|
+
| `spools` | `TSpool[][]` | — | 2D spool configuration, one per cell. Board dimensions are inferred from this. Always assign a new array reference to update. |
|
|
302
|
+
| `grid` | `string[][]` | `[]` | 2D array of target keys. Always assign a new array reference to trigger a re-render. |
|
|
303
|
+
| `speed` | `number` | `60` | Flip speed in milliseconds forwarded to every child spool. |
|
|
304
|
+
| `variant` | `'minimal' \| 'realistic'` | `'minimal'` | Visual variant forwarded to every child spool. |
|
|
305
|
+
| `visibleSideCount` | `number` | `-1` | Forwarded to child spools. Only affects the `realistic` variant. |
|
|
306
|
+
|
|
307
|
+
### Events
|
|
308
|
+
|
|
309
|
+
#### `<split-flap-spool>`
|
|
310
|
+
|
|
311
|
+
| Event | Detail | Description |
|
|
312
|
+
| --------- | ------------------- | --------------------------------------------------------------------------------- |
|
|
313
|
+
| `settled` | `{ value: string }` | Fired when the spool becomes idle. `value` is the flap key it actually landed on. |
|
|
314
|
+
|
|
315
|
+
#### `<split-flap-board>`
|
|
316
|
+
|
|
317
|
+
| Event | Detail | Description |
|
|
318
|
+
| --------------- | ---------------------- | --------------------------------------------------------------------- |
|
|
319
|
+
| `board-settled` | `{ grid: string[][] }` | Fired when every rendered spool is idle for the current board inputs. |
|
|
320
|
+
|
|
321
|
+
```js
|
|
322
|
+
const spool = document.querySelector('split-flap-spool');
|
|
323
|
+
spool.addEventListener('settled', (e) => console.log('landed on', e.detail.value));
|
|
324
|
+
board.addEventListener('board-settled', (e) => console.log('board done', e.detail.grid));
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### `spoolGrid(spool, cols, rows)`
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
function spoolGrid(spool: TSpool | TSpool[], cols: number, rows: number): TSpool[][];
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Creates a `TSpool[][]` for use with `board.spools`.
|
|
334
|
+
|
|
335
|
+
```ts
|
|
336
|
+
// Uniform - same spool for every cell
|
|
337
|
+
spoolGrid(charSpool, 10, 3);
|
|
338
|
+
|
|
339
|
+
// Per-column - pass an array where index = column; shorter arrays repeat
|
|
340
|
+
spoolGrid([charSpool, charSpool, statusSpool], 3, 2);
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
### `fromLines(lines, cols)`
|
|
344
|
+
|
|
345
|
+
```ts
|
|
346
|
+
function fromLines(
|
|
347
|
+
lines: (string | { text: string; bg?: string; color?: string })[],
|
|
348
|
+
cols: number
|
|
349
|
+
): { spools: TSpool[][]; grid: string[][] };
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Creates a char `spools` grid and a `grid` of target keys from an array of strings. Each string is one row, padded with spaces or truncated to `cols`. Rows with `bg`/`color` get those values baked into their char flaps.
|
|
353
|
+
|
|
354
|
+
```ts
|
|
355
|
+
const { spools, grid } = fromLines(
|
|
356
|
+
['HELLO WORLD', { text: 'BOARDING', bg: '#16a34a', color: '#fff' }],
|
|
357
|
+
11
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
board.spools = spools;
|
|
361
|
+
board.grid = grid;
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### CSS Custom Properties
|
|
365
|
+
|
|
366
|
+
Set these on the board to theme all spools at once, or override on individual spools via CSS selectors.
|
|
367
|
+
|
|
368
|
+
```css
|
|
369
|
+
/* Board panel */
|
|
370
|
+
split-flap-board {
|
|
371
|
+
--sfb-board-bg: #1c1c1c; /* panel and frame background */
|
|
372
|
+
--sfb-board-padding: 10px; /* inset spacing around the cell grid */
|
|
373
|
+
--sfb-board-radius: 8px; /* corner radius of the panel itself */
|
|
374
|
+
--sfb-gap: 3px; /* gap between spool cells */
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/* Shared (flap) */
|
|
378
|
+
split-flap-board {
|
|
379
|
+
--sfb-flap-bg: #111; /* flap background */
|
|
380
|
+
--sfb-flap-color: #f5f0e0; /* flap text color */
|
|
381
|
+
--sfb-flap-radius: 4px; /* corner radius on each flap */
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/* Minimal variant */
|
|
385
|
+
split-flap-board {
|
|
386
|
+
--sfb-spool-width: 1.2em; /* explicit cell width */
|
|
387
|
+
--sfb-spool-height: 2em; /* explicit cell height */
|
|
388
|
+
--sfb-fold-color: #0a0a0a; /* center crease color */
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/* Realistic variant */
|
|
392
|
+
split-flap-board {
|
|
393
|
+
--sfb-spool-width: 1em; /* flap width; defaults to 1× font-size */
|
|
394
|
+
--sfb-spool-height: 2em; /* flap height; defaults to 2× font-size */
|
|
395
|
+
--sfb-drum-radius: 0px; /* cylinder radius; 0 keeps the flip flat */
|
|
396
|
+
--sfb-crease: 1px; /* gap between the two flap halves */
|
|
397
|
+
--sfb-perspective: 400px; /* CSS perspective depth */
|
|
398
|
+
--sfb-view-transform: none; /* e.g. rotateY(-30deg) */
|
|
399
|
+
--sfb-max-step-angle: 1turn; /* per-step angle cap; 8deg tightens small spools */
|
|
400
|
+
--sfb-flap-border: #2a2a2a; /* border on each flap card; separates adjacent cells */
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/* Per-spool override */
|
|
404
|
+
split-flap-spool.highlight {
|
|
405
|
+
--sfb-flap-bg: #16a34a;
|
|
406
|
+
--sfb-flap-color: #fff;
|
|
407
|
+
}
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
## Behavior
|
|
411
|
+
|
|
412
|
+
### Initial state
|
|
413
|
+
|
|
414
|
+
Before `value` is set, a `<split-flap-spool>` shows the first flap in its sequence (index 0). For `charSpool` that is a space. This mirrors the physical home position the hall effect sensor establishes on power-up.
|
|
415
|
+
|
|
416
|
+
### Animation
|
|
417
|
+
|
|
418
|
+
Each flap step plays a fold animation where the top half falls away, revealing the next card underneath. The animation duration is derived from `speed` so it always fits within one step interval. No separate property is needed.
|
|
419
|
+
|
|
420
|
+
### Unknown key
|
|
421
|
+
|
|
422
|
+
If `value` is set to a key that does not exist in `flaps`, the spool does not start a new search and no error is thrown. If that happens during an in-flight animation, the current flip finishes and `settled` reports the flap the spool actually landed on.
|
|
423
|
+
|
|
424
|
+
### Retargeting during motion
|
|
425
|
+
|
|
426
|
+
If `value` changes to another valid key while the spool is already moving, the spool keeps its current forward motion and retargets to the newest valid key. It does not snap backward or restart from the beginning.
|
|
427
|
+
|
|
428
|
+
### Spool changes during motion
|
|
429
|
+
|
|
430
|
+
If `flaps` changes while the spool is moving, the component remaps the currently visible flap by key into the new spool, clears stale animation bookkeeping, and continues from the new coherent state.
|
|
431
|
+
|
|
432
|
+
### Grid size mismatch
|
|
433
|
+
|
|
434
|
+
If `grid` has more rows or columns than `spools`, the extra entries are ignored. If `grid` is smaller than `spools`, spools without a matching target key stay on their current flap. No errors are thrown.
|
|
435
|
+
|
|
436
|
+
### Forward-Only Stepping
|
|
437
|
+
|
|
438
|
+
Because a spool only rotates forward, the number of steps depends on the distance ahead in the spool, wrapping around if needed.
|
|
439
|
+
|
|
440
|
+
```
|
|
441
|
+
'A' → 'C' = 2 steps
|
|
442
|
+
'Z' → 'B' = 3 steps (wraps: Z → ' ' → A → B)
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
This applies to all flap types. Keep the order of your spool in mind when designing update sequences. The closer two keys are in the spool, the faster the transition.
|
|
446
|
+
|
|
447
|
+
## Resources & References
|
|
448
|
+
|
|
449
|
+
- [How a Split-Flap Display Works (YouTube)](https://www.youtube.com/watch?v=UAQJJAQSg_g)
|
|
450
|
+
- [Lit](https://lit.dev/)
|
|
451
|
+
- [Scott Bezek's open-source split-flap hardware](https://github.com/scottbez1/splitflap)
|
|
452
|
+
- [@ybhrdwj on X](https://x.com/ybhrdwj/status/2037110274696896687) - the tweet that started this :)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";var p=require("lit"),i=require("lit/decorators.js");require("./spools/SplitFlapSpool.js");var n=Object.defineProperty,b=Object.getOwnPropertyDescriptor,l=(d,e,r,s)=>{for(var t=s>1?void 0:s?b(e,r):e,o=d.length-1,a;o>=0;o--)(a=d[o])&&(t=(s?a(e,r,t):a(t))||t);return s&&t&&n(e,r,t),t};exports.SplitFlapBoard=class extends p.LitElement{constructor(){super(...arguments),this.spools=[],this.grid=[],this.speed=60,this.variant="minimal",this.visibleSideCount=-1,this._pendingSettle=!1}updated(e){super.updated(e),(e.has("spools")||e.has("grid"))&&(this._pendingSettle=!0,Promise.resolve().then(()=>this._checkAllSettled()))}_getSpoolEls(){return Array.from(this.renderRoot.querySelectorAll("split-flap-spool"))}_checkAllSettled(){if(!this._pendingSettle)return;const e=this._getSpoolEls();e.length!==0&&e.every(r=>r.isSettled)&&(this._pendingSettle=!1,this._dispatchBoardSettled(e))}_dispatchBoardSettled(e){let r=0;const s=this.spools.map(t=>t.map(()=>{var o,a;return(a=(o=e[r++])==null?void 0:o.currentValue)!=null?a:""}));this.dispatchEvent(new CustomEvent("board-settled",{detail:{grid:s},bubbles:!0,composed:!0}))}render(){return p.html`
|
|
2
|
+
${this.spools.map((e,r)=>p.html`
|
|
3
|
+
<div class="board-row" style="z-index: ${r+1}">
|
|
4
|
+
${e.map((s,t)=>{var o,a;return p.html`
|
|
5
|
+
<split-flap-spool
|
|
6
|
+
.flaps=${s}
|
|
7
|
+
.value=${(a=(o=this.grid[r])==null?void 0:o[t])!=null?a:""}
|
|
8
|
+
.speed=${this.speed}
|
|
9
|
+
.variant=${this.variant}
|
|
10
|
+
.visibleSideCount=${this.visibleSideCount}
|
|
11
|
+
@settled=${()=>this._checkAllSettled()}
|
|
12
|
+
></split-flap-spool>
|
|
13
|
+
`})}
|
|
14
|
+
</div>
|
|
15
|
+
`)}
|
|
16
|
+
`}},exports.SplitFlapBoard.styles=p.css`
|
|
17
|
+
:host {
|
|
18
|
+
display: inline-flex;
|
|
19
|
+
position: relative;
|
|
20
|
+
flex-direction: column;
|
|
21
|
+
box-sizing: border-box;
|
|
22
|
+
/* Inset shadows for side rails; render below all children so frame bars
|
|
23
|
+
* appear at the same visual level. */
|
|
24
|
+
box-shadow:
|
|
25
|
+
inset 14px 0 18px rgba(0, 0, 0, 0.55),
|
|
26
|
+
inset -14px 0 18px rgba(0, 0, 0, 0.55);
|
|
27
|
+
border-radius: var(--sfb-board-radius, 8px);
|
|
28
|
+
background: var(--sfb-board-bg, #1c1c1c);
|
|
29
|
+
padding: var(--sfb-board-padding, 10px);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* Absolute overlay so the outer frame ring adds no layout space. */
|
|
33
|
+
:host::after {
|
|
34
|
+
position: absolute;
|
|
35
|
+
z-index: 10000;
|
|
36
|
+
inset: 0;
|
|
37
|
+
border: 10px solid var(--sfb-board-bg, #1c1c1c);
|
|
38
|
+
border-radius: var(--sfb-board-radius, 8px);
|
|
39
|
+
pointer-events: none;
|
|
40
|
+
content: '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* Frame bar caps the top padding of each row. z-index increases per row in
|
|
44
|
+
* render() so each bar sits above the drum content of the row above it. */
|
|
45
|
+
.board-row {
|
|
46
|
+
display: flex;
|
|
47
|
+
position: relative;
|
|
48
|
+
gap: var(--sfb-gap, 3px);
|
|
49
|
+
padding-block: 12px;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.board-row::before {
|
|
53
|
+
position: absolute;
|
|
54
|
+
z-index: 1;
|
|
55
|
+
inset: 0 0 auto 0;
|
|
56
|
+
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.65);
|
|
57
|
+
background: var(--sfb-board-bg, #1c1c1c);
|
|
58
|
+
height: 10px;
|
|
59
|
+
content: '';
|
|
60
|
+
}
|
|
61
|
+
`,l([i.property({type:Array})],exports.SplitFlapBoard.prototype,"spools",2),l([i.property({type:Array})],exports.SplitFlapBoard.prototype,"grid",2),l([i.property({type:Number})],exports.SplitFlapBoard.prototype,"speed",2),l([i.property({type:String})],exports.SplitFlapBoard.prototype,"variant",2),l([i.property({type:Number})],exports.SplitFlapBoard.prototype,"visibleSideCount",2),exports.SplitFlapBoard=l([i.customElement("split-flap-board")],exports.SplitFlapBoard);
|
package/dist/cjs/index.js
CHANGED
|
@@ -1,7 +1 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
function helloWorld() {
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
exports.helloWorld = helloWorld;
|
|
7
|
-
//# sourceMappingURL=index.js.map
|
|
1
|
+
"use strict";var l=require("./lib/board.js"),o=require("./lib/flap.js"),e=require("./lib/spool-layout.js"),i=require("./SplitFlapBoard.js"),r=require("./spools/presets.js"),p=require("./spools/SplitFlapSpool.js"),t=require("./spools/SplitFlapSpoolBase.js"),a=require("./spools/SplitFlapSpoolMinimal.js"),S=require("./spools/SplitFlapSpoolRealistic.js");exports.fromLines=l.fromLines,exports.spoolGrid=l.spoolGrid,exports.getFlapKey=o.getFlapKey,exports.getMaxVisibleSideCount=e.getMaxVisibleSideCount,exports.getRenderedFlaps=e.getRenderedFlaps,exports.getSignedCircularOffset=e.getSignedCircularOffset,Object.defineProperty(exports,"SplitFlapBoard",{enumerable:!0,get:function(){return i.SplitFlapBoard}}),exports.charSpool=r.charSpool,exports.colorSpool=r.colorSpool,exports.numericSpool=r.numericSpool,Object.defineProperty(exports,"SplitFlapSpool",{enumerable:!0,get:function(){return p.SplitFlapSpool}}),exports.SplitFlapSpoolBase=t.SplitFlapSpoolBase,Object.defineProperty(exports,"SplitFlapSpoolMinimal",{enumerable:!0,get:function(){return a.SplitFlapSpoolMinimal}}),Object.defineProperty(exports,"SplitFlapSpoolRealistic",{enumerable:!0,get:function(){return S.SplitFlapSpoolRealistic}});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";var i=require("../spools/presets.js"),g=Object.defineProperty,h=Object.getOwnPropertySymbols,m=Object.prototype.hasOwnProperty,v=Object.prototype.propertyIsEnumerable,y=(t,r,e)=>r in t?g(t,r,{enumerable:!0,configurable:!0,writable:!0,value:e}):t[r]=e,u=(t,r)=>{for(var e in r||(r={}))m.call(r,e)&&y(t,e,r[e]);if(h)for(var e of h(r))v.call(r,e)&&y(t,e,r[e]);return t};function d(t,r,e){const o=t.length>0&&Array.isArray(t[0])?t:Array.from({length:r},()=>t);return Array.from({length:e},()=>Array.from({length:r},(l,a)=>{var p;return(p=o[a%o.length])!=null?p:i.charSpool}))}function A(t,r){const e=[],o=[];for(const l of t){const a=typeof l=="object",p=a?l.text:l,n=a?l.bg:void 0,s=a?l.color:void 0,f=p.toUpperCase().padEnd(r," ").slice(0,r).split("");if(n!=null||s!=null){const b=f.map(()=>i.charSpool.map(c=>c.type!=="char"?c:u(u(u({},c),n!=null?{bg:n}:{}),s!=null?{color:s}:{})));e.push(b)}else e.push(Array.from({length:r},()=>i.charSpool));o.push(f)}return{spools:e,grid:o}}exports.fromLines=A,exports.spoolGrid=d;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";function r(e){if(e.key!=null)return e.key;switch(e.type){case"char":case"color":return e.value;case"image":return e.src;case"custom":return e.key}}exports.getFlapKey=r;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";function s(n,t,e){if(e<=0)return 0;const a=(n-t+e)%e;if(a===0)return 0;const i=a-e;return Math.abs(i)<=Math.abs(a)?i:a}function u(n,t){const e=Math.floor(n/2);return t==null||t<0?e:Math.min(Math.floor(t),e)}function f(n,t,{visibleSideCount:e,renderCenter:a=t}={}){const i=u(n.length,e);return n.map((r,d)=>{const l=s(d,t,n.length);return{flap:r,actualIndex:d,offset:l,renderedIndex:a+l}}).filter(({offset:r})=>Math.abs(r)<=i).sort((r,d)=>r.renderedIndex-d.renderedIndex).map(({flap:r,actualIndex:d,renderedIndex:l})=>({flap:r,actualIndex:d,renderedIndex:l}))}exports.getMaxVisibleSideCount=u,exports.getRenderedFlaps=f,exports.getSignedCircularOffset=s;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";var o=require("lit"),r=require("lit/decorators.js"),S=require("./presets.js"),n=require("../lib/flap.js");require("./SplitFlapSpoolMinimal.js"),require("./SplitFlapSpoolRealistic.js");var v=Object.defineProperty,h=Object.getOwnPropertyDescriptor,i=(s,t,e,p)=>{for(var l=p>1?void 0:p?h(t,e):t,a=s.length-1,u;a>=0;a--)(u=s[a])&&(l=(p?u(t,e,l):u(l))||l);return p&&l&&v(t,e,l),l};exports.SplitFlapSpool=class extends o.LitElement{constructor(){super(...arguments),this.variant="minimal",this.value=" ",this.flaps=S.charSpool,this.speed=60,this.visibleSideCount=-1}get currentValue(){var t,e;return(e=(t=this._getActiveSpool())==null?void 0:t.currentValue)!=null?e:this.flaps[0]!=null?n.getFlapKey(this.flaps[0]):void 0}get isSettled(){var t,e;return(e=(t=this._getActiveSpool())==null?void 0:t.isSettled)!=null?e:!0}hasKey(t){var e,p;return(p=(e=this._getActiveSpool())==null?void 0:e.hasKey(t))!=null?p:this.flaps.some(l=>n.getFlapKey(l)===t)}_getActiveSpool(){return this.renderRoot.querySelector("split-flap-spool-minimal, split-flap-spool-realistic")}render(){return this.variant==="realistic"?o.html`
|
|
2
|
+
<split-flap-spool-realistic
|
|
3
|
+
.value=${this.value}
|
|
4
|
+
.flaps=${this.flaps}
|
|
5
|
+
.speed=${this.speed}
|
|
6
|
+
.visibleSideCount=${this.visibleSideCount}
|
|
7
|
+
></split-flap-spool-realistic>
|
|
8
|
+
`:o.html`
|
|
9
|
+
<split-flap-spool-minimal
|
|
10
|
+
.value=${this.value}
|
|
11
|
+
.flaps=${this.flaps}
|
|
12
|
+
.speed=${this.speed}
|
|
13
|
+
></split-flap-spool-minimal>
|
|
14
|
+
`}},exports.SplitFlapSpool.styles=o.css`
|
|
15
|
+
:host {
|
|
16
|
+
display: contents;
|
|
17
|
+
}
|
|
18
|
+
`,i([r.property({type:String})],exports.SplitFlapSpool.prototype,"variant",2),i([r.property({type:String})],exports.SplitFlapSpool.prototype,"value",2),i([r.property({type:Array})],exports.SplitFlapSpool.prototype,"flaps",2),i([r.property({type:Number})],exports.SplitFlapSpool.prototype,"speed",2),i([r.property({type:Number})],exports.SplitFlapSpool.prototype,"visibleSideCount",2),exports.SplitFlapSpool=i([r.customElement("split-flap-spool")],exports.SplitFlapSpool);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";var n=require("lit"),u=require("lit/decorators.js"),y=require("lit/directives/style-map.js"),I=require("./presets.js"),l=require("../lib/flap.js"),f=Object.defineProperty,v=Object.getOwnPropertySymbols,T=Object.prototype.hasOwnProperty,F=Object.prototype.propertyIsEnumerable,x=(s,e,t)=>e in s?f(s,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):s[e]=t,o=(s,e)=>{for(var t in e||(e={}))T.call(e,t)&&x(s,t,e[t]);if(v)for(var t of v(e))F.call(e,t)&&x(s,t,e[t]);return s},_=(s,e,t,r)=>{for(var i=void 0,h=s.length-1,p;h>=0;h--)(p=s[h])&&(i=p(e,t,i)||i);return i&&f(e,t,i),i};class a extends n.LitElement{constructor(){super(...arguments),this.value=" ",this.flaps=I.charSpool,this.speed=60,this._currentIndex=0,this._prevIndex=0,this._stepping=!1,this._targetIndex=-1,this._stepTimer=null,this._animTimer=null,this._animEndsAt=0}get _animDur(){return Math.max(Math.floor(this.speed*.85),1)}updated(e){super.updated(e),e.has("flaps")&&this._syncIndicesToFlaps(e.get("flaps")),(e.has("value")||e.has("flaps"))&&this._startStepping()}disconnectedCallback(){super.disconnectedCallback(),this._clearTimers()}get currentFlap(){var e;return(e=this.flaps[this._currentIndex])!=null?e:this.flaps[0]}get currentValue(){return this.currentFlap!=null?l.getFlapKey(this.currentFlap):void 0}get isSettled(){return!this._stepping&&this._targetIndex===-1&&this._stepTimer==null&&this._animTimer==null}hasKey(e){return this.flaps.some(t=>l.getFlapKey(t)===e)}_startStepping(){if(!this.flaps.length){this._resetForEmptyFlaps();return}const e=this.flaps.findIndex(r=>l.getFlapKey(r)===this.value),t=this._stepping||this._stepTimer!=null;if(e===-1){this._targetIndex=-1,t&&this._scheduleAdvanceOrSettle(this._getRemainingAnimTime());return}if(this._targetIndex=e,this._currentIndex===this._targetIndex){t?this._scheduleAdvanceOrSettle(this._getRemainingAnimTime()):this._targetIndex=-1;return}t||this._doStep()}_doStep(){if(!this.flaps.length){this._resetForEmptyFlaps();return}const e=(this._currentIndex+1)%this.flaps.length;this._prevIndex=this._currentIndex,this._currentIndex=e,this._stepping=!0,this._animEndsAt=Date.now()+this._animDur,this._animTimer!=null&&clearTimeout(this._animTimer),this._animTimer=setTimeout(()=>{this._stepping=!1,this._animTimer=null,this._animEndsAt=0},this._animDur);const t=this._currentIndex!==this._targetIndex?this.speed:this._getRemainingAnimTime();this._scheduleAdvanceOrSettle(t)}_scheduleAdvanceOrSettle(e){this._stepTimer!=null&&clearTimeout(this._stepTimer),this._stepTimer=setTimeout(()=>{if(this._stepTimer=null,this._targetIndex!==-1&&this._currentIndex!==this._targetIndex){this._doStep();return}this._finishSettling()},Math.max(e,0))}_finishSettling(){if(this._stepping){this._scheduleAdvanceOrSettle(this._getRemainingAnimTime());return}this._targetIndex=-1;const e=this.currentValue;e!=null&&this.dispatchEvent(new CustomEvent("settled",{detail:{value:e},bubbles:!0,composed:!0}))}_getRemainingAnimTime(){return this._stepping?Math.max(this._animEndsAt-Date.now(),1):0}_resetForEmptyFlaps(){this._clearTimers(),this._targetIndex=-1,this._currentIndex=0,this._prevIndex=0,this._stepping=!1}_syncIndicesToFlaps(e){var t,r;if(!this.flaps.length){this._resetForEmptyFlaps();return}if(e==null||!e.length){this._currentIndex=0,this._prevIndex=0,this._targetIndex=-1,this._clearTimers(),this._stepping=!1;return}const i=(t=e[this._currentIndex])!=null?t:e[0],h=(r=e[this._prevIndex])!=null?r:i,p=i!=null?l.getFlapKey(i):void 0,c=h!=null?l.getFlapKey(h):p;this._clearTimers(),this._stepping=!1,this._targetIndex=-1;const m=p!=null?this.flaps.findIndex(d=>l.getFlapKey(d)===p):-1,g=c!=null?this.flaps.findIndex(d=>l.getFlapKey(d)===c):-1;this._currentIndex=m>=0?m:0,this._prevIndex=g>=0?g:this._currentIndex}_clearTimers(){this._stepTimer!=null&&(clearTimeout(this._stepTimer),this._stepTimer=null),this._animTimer!=null&&(clearTimeout(this._animTimer),this._animTimer=null),this._animEndsAt=0}_renderHalf(e,t){var r;switch(e.type){case"char":return n.html`
|
|
2
|
+
<div
|
|
3
|
+
class="char-inner"
|
|
4
|
+
style=${y.styleMap(o(o(o(o(o({},e.color!=null?{color:e.color}:{}),e.bg!=null?{background:e.bg}:{}),e.fontSize!=null?{fontSize:e.fontSize}:{}),e.fontFamily!=null?{fontFamily:e.fontFamily}:{}),e.fontWeight!=null?{fontWeight:e.fontWeight}:{}))}
|
|
5
|
+
>
|
|
6
|
+
${e.value}
|
|
7
|
+
</div>
|
|
8
|
+
`;case"color":return n.html`<div class="color-fill" style="background: ${e.value}"></div>`;case"image":return n.html`<img class="image-fill" src=${e.src} alt=${(r=e.alt)!=null?r:""} />`;case"custom":return n.html`<div class="custom-fill">${e[t]}</div>`}}_getFlaps(){var e;const t=this.currentFlap;return t==null?null:{current:t,prev:(e=this.flaps[this._prevIndex])!=null?e:t}}_renderCard(){const e=this._getFlaps();if(e==null)return n.nothing;const{current:t,prev:r}=e,i=this._stepping?r:t;return n.html`
|
|
9
|
+
<div class="half top">${this._renderHalf(t,"top")}</div>
|
|
10
|
+
<div class="half bottom">${this._renderHalf(i,"bottom")}</div>
|
|
11
|
+
|
|
12
|
+
${this._stepping?n.html`
|
|
13
|
+
<div class="half top flipping">${this._renderHalf(r,"top")}</div>
|
|
14
|
+
<div class="half bottom flipping">${this._renderHalf(t,"bottom")}</div>
|
|
15
|
+
`:n.nothing}
|
|
16
|
+
`}}_([u.property({type:String})],a.prototype,"value"),_([u.property({type:Array})],a.prototype,"flaps"),_([u.property({type:Number})],a.prototype,"speed"),_([u.state()],a.prototype,"_currentIndex"),_([u.state()],a.prototype,"_prevIndex"),_([u.state()],a.prototype,"_stepping"),exports.SplitFlapSpoolBase=a;
|