footprint-storydeck 0.4.0 → 0.5.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/CHANGELOG.md +32 -0
- package/PostView.jsx +1 -1
- package/README.md +144 -2
- package/SlideDeck.jsx +20 -1
- package/focus.css +2 -0
- package/focus.d.ts +31 -3
- package/focus.js +99 -21
- package/focusHtml.d.ts +9 -2
- package/focusHtml.js +116 -12
- package/index.d.ts +13 -3
- package/index.js +2 -1
- package/narrationHtml.d.ts +4 -1
- package/narrationHtml.js +6 -3
- package/package.json +88 -13
- package/plugin/skills/storydeck/SKILL.md +30 -6
- package/pptx.d.ts +75 -0
- package/pptx.js +313 -0
- package/pptxCheck.js +167 -0
- package/pptxParts.js +262 -0
- package/tour.d.ts +71 -0
- package/tour.js +206 -0
- package/video.js +8 -0
- package/xml.js +123 -0
- package/zip.js +155 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 — PowerPoint, the exploded view, and a tour for decks nobody presents
|
|
4
|
+
|
|
5
|
+
- **`footprint-storydeck/pptx`: the deck as a PowerPoint file.** `exportPptx` makes a slide per click: its settled
|
|
6
|
+
picture full-bleed, its notes as speaker notes (a paragraph per line), its headline or label as an off-page title
|
|
7
|
+
(the outline and screen readers read it, the audience does not see it), and a fade. With clips, each slide plays
|
|
8
|
+
its clip and moves on after it (plus `gap`): a narrated deck that runs by itself. It drives the browser through
|
|
9
|
+
the video's port (`deckStageDriver`). The export is a footprintjs flowchart, so every run leaves a record
|
|
10
|
+
(`narrative`), and each check is a `decide()` with its evidence. A clip that is missing, empty, not the sound its
|
|
11
|
+
name says, over `maxClip` or never voiced, a click without a 16:9 PNG, or a package that does not read back is
|
|
12
|
+
refused before a browser opens or a file is written, naming the fix. Media are shared by their bytes, never by
|
|
13
|
+
name. The ZIP, the PresentationML parts and the read-back are written by hand and exported (`zip`, `unzip`,
|
|
14
|
+
`pptxParts`, `checkPptx`). Opened in PowerPoint 16 with no repair prompt; every part validates against the
|
|
15
|
+
ISO/IEC 29500 schemas. It is pictures, notes and sound, not editable native shapes. footprintjs is an optional
|
|
16
|
+
peer dependency: `npm i footprintjs` beside StoryDeck to export; nothing else loads it.
|
|
17
|
+
- **`apart`, the exploded view.** A focus mover: named groups fly apart along the vectors you give
|
|
18
|
+
(`data-focus-apart="lid: 0 -120 | core: 160 0 | base: 0 120 1.1"`), one spring each, `data-focus-stagger` ms apart
|
|
19
|
+
(35), out in the order written and back in reverse on a later click without it; closing groups settle for one
|
|
20
|
+
more click. A click reschedules only the groups not yet at its target, in order, never sooner than a start they
|
|
21
|
+
were already waiting for, so the earlier groups lead. `focusRuntime` plays the springs as Web Animations, each deck
|
|
22
|
+
its own, so at rest a piece is exactly as drawn and the video renderer steps them; `SlideDeck` now installs it.
|
|
23
|
+
Two movers on one click are refused, naming the fix; a group's numbers are strict and its scale must be above 0
|
|
24
|
+
(for `place` too). Every `focusStep` look now carries `apart` (`null` without it), and every piece `apart`.
|
|
25
|
+
- **`footprint-storydeck/tour`: a tour for decks nobody presents.** `idleTour` plays a deck's stops through its own
|
|
26
|
+
`goTo` after 1.4 s with nobody touching it. A key, tap, click or real move hands the deck over until 45 s of quiet;
|
|
27
|
+
Escape or the "Stop tour" control (its name is its visible words; hidden while no tour can move in view) stops
|
|
28
|
+
every tour for the page view (WCAG 2.2.2). It waits while out of sight or while listen mode plays the deck
|
|
29
|
+
(`data-listening`, now set by `listenRuntime`), and never runs under reduced motion. Declared as
|
|
30
|
+
`<deck-stage data-tour>`, `SlideDeck`'s `tour`, or post.json's `tour`; `tourPlan` and `tourText` give the plan as
|
|
31
|
+
data and in one line.
|
|
32
|
+
- **Video:** the driver finishes click n−1's motion before stepping on, so a strip renders the same frames as the
|
|
33
|
+
full video. `focusRuntime`'s install flag is its version, so an older inlined runtime no longer blocks it.
|
|
34
|
+
|
|
3
35
|
## 0.4.0 — a path: the subject piece after piece along a route
|
|
4
36
|
|
|
5
37
|
- **A fourth kind of focus strategy, the order — how the subject comes on — and its first strategy, `path`.**
|
package/PostView.jsx
CHANGED
|
@@ -188,7 +188,7 @@ export default function PostView({ post }) {
|
|
|
188
188
|
) : null}
|
|
189
189
|
<span className="watch-lens-hint" title="Press Esc to return to Read" aria-hidden="true">Esc</span>
|
|
190
190
|
</div>
|
|
191
|
-
<SlideDeck steps={post.deckSteps} />
|
|
191
|
+
<SlideDeck steps={post.deckSteps} tour={post.tour} />
|
|
192
192
|
</>
|
|
193
193
|
) : null}
|
|
194
194
|
</div>
|
package/README.md
CHANGED
|
@@ -73,6 +73,7 @@ decision every click makes again, so it is data on the click, and the library do
|
|
|
73
73
|
| mover | `zoom` | the view closes in on the subject (`share`, `most`) |
|
|
74
74
|
| mover | `left` · `right` · `up` · `down` | the context steps aside that way, smaller (`scale`), and the subject grows into the room it leaves (`grow`, `fill`, `gap`, `align`; `move: 'aside'` keeps the subject where it is) |
|
|
75
75
|
| mover | `place` | named groups moved by the amounts you give (`groups: 'page floor: -90 -200 0.5 \| tl: 0 -230 1.1 @ 960 968'`): a layout you art-direct, still animated from wherever the last click left it |
|
|
76
|
+
| mover | `apart` | the exploded view: named groups fly apart along the vectors you give (`apart: 'lid: 0 -120 \| core: 160 0 \| base: 0 120 1.1'`), one spring each, `stagger` ms apart (35), and come back together on a later click without it — the last one out is the first one back |
|
|
76
77
|
| overlay | `blur` | everything outside the subject's frame is blurred; a frame (and a `label`) on it, or on the `rect`s you give |
|
|
77
78
|
| order | `path` | the subject comes on piece after piece along a route, as a request travels it — node, wire, node: each lit piece on it lights at its turn (`step` seconds apart, 0.35) and glows as it is reached; `route` names the order (default — and for an empty `route` — the click's `on`, or its `in`); a greyed piece on it takes no turn, and a route with no order at all (`on: ['*']`) is refused; a `line` ("agent → MCP → gateway") names the route, shown as the last turn starts. A deck's own `filter` on a path piece is not carried through the glow: it steps where the glow starts and ends |
|
|
78
79
|
|
|
@@ -93,6 +94,35 @@ on one `filter` would flash), its ring (`hot`) comes .3 s after its turn, and a
|
|
|
93
94
|
twin's turn and only then steps aside, so the wire is never missing. Under a new blur the whole path, line included,
|
|
94
95
|
waits .4 s for it, as what a click adds does.
|
|
95
96
|
|
|
97
|
+
**An exploded view, on a figure of parts.** A figure built of parts — an engine, a stack, a page and the layers
|
|
98
|
+
under it — explains itself best when the parts come apart: the audience sees what it is made of, then sees it whole
|
|
99
|
+
again. Name the parts as groups and the way each one leaves:
|
|
100
|
+
|
|
101
|
+
```html
|
|
102
|
+
<step data-focus="apart" data-focus-apart="lid: 0 -120 | core: 160 0 | base: 0 120 1.1"></step>
|
|
103
|
+
<step data-on="core"></step> <!-- no apart: the parts come back together, the core lit -->
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Each group has one spring, its value 0 together and 1 apart, a little under critical damping, so a part settles with
|
|
107
|
+
a hint of weight instead of stopping dead. The groups set off `stagger` ms apart (`data-focus-stagger`, 35) — out in
|
|
108
|
+
the order written, back in reverse — so the last one out is the first one back. A click that turns the parts round
|
|
109
|
+
mid-way reschedules only the groups not yet at its target (those turning round, and those still waiting to set off),
|
|
110
|
+
in order, each a stagger after the one before and never sooner than a start it was already waiting for — so the
|
|
111
|
+
order is kept: among them the earlier groups always lead, and a click that keeps a group's target keeps its time.
|
|
112
|
+
The order is what the library keeps; where the parts go is yours — vectors that cross make paths that cross, so give
|
|
113
|
+
each part its own way out. The click after a close keeps the closing groups on their springs one click more, so a
|
|
114
|
+
quick click lets them settle from wherever they are. A line or connector that carries a group's name
|
|
115
|
+
(`data-k="wire core"`) moves with that group, keeping its place against it. `apart` is a mover, so it goes with a look (the rest
|
|
116
|
+
greyed), an overlay and an order, and a click that also asks for `place` is refused, naming the fix; across clicks
|
|
117
|
+
the two compose: the parts fly out from the layout `place` left, and back into it.
|
|
118
|
+
|
|
119
|
+
The springs run in `focusRuntime` (the Watch lens, `SlideDeck`, installs it; a built page inlines it), each deck on
|
|
120
|
+
the page its own, played as Web Animations of the same `translate`/`scale` every mover uses (so a piece's own
|
|
121
|
+
transform, an SVG group's `transform` attribute included, is never overwritten), which means the video renderer
|
|
122
|
+
steps them like any animation. Nothing of a spring is written on the piece: at rest the animation is over and the
|
|
123
|
+
piece is exactly as drawn — closed, the figure is its static art. A jump, a step back, print and reduced motion land
|
|
124
|
+
at once, as every focus does.
|
|
125
|
+
|
|
96
126
|
The plan is plain data (each piece's state, what changed since the last click, how far it moved), so a
|
|
97
127
|
renderer only draws it. `footprint-storydeck/focus-html` writes it into slide HTML at build time and
|
|
98
128
|
`footprint-storydeck/focus.css` animates **only what changed, only on a forward click** — a jump or a step back
|
|
@@ -128,6 +158,56 @@ a piece (a dashed wire under a solid one) step aside while its twin is lit. A pi
|
|
|
128
158
|
from how it looked and where it stood; a blur that moves to another subject stays, and only its frame is
|
|
129
159
|
new; overlapping holes stay clear, and nothing outside the `area` is blurred.
|
|
130
160
|
|
|
161
|
+
## Tour: a deck that shows its point while nobody is using it
|
|
162
|
+
|
|
163
|
+
A deck left on a screen with nobody presenting it — at a booth, on a post's Watch lens, as a page's hero figure — is
|
|
164
|
+
mostly looked at by people who have not touched it yet. If it sits on its first slide, they never see what it is
|
|
165
|
+
for. So, left alone for a moment (1.4 s), it plays a short loop of **stops**, the clicks that make its point, and the
|
|
166
|
+
moment someone reaches for it, it hands itself over:
|
|
167
|
+
|
|
168
|
+
- it drives the **same targets a person would** — the deck's own `goTo`, so a stop one click on animates as that click
|
|
169
|
+
does (focus, `apart`'s springs and all) and a jump lands, as a person's jump does; there is no second animation to
|
|
170
|
+
keep in step;
|
|
171
|
+
- **accessibility first**: the moment someone uses it — a key, a tap, a click, a focus, a real move of the pointer
|
|
172
|
+
over it (looked at every 250 ms, so a resting mouse or the deck moving under it is no move, and a finger lifting off
|
|
173
|
+
is not someone leaving), or a move it did not make (the presenter window) — it hands itself over, and it does not
|
|
174
|
+
take the deck back soon: only after **45 s of quiet** (`resumeMs`), or once the deck has left sight and come back.
|
|
175
|
+
It then goes on from the stop they left it on, when that is a stop;
|
|
176
|
+
- **Escape**, or the small **"Stop tour"** control it shows while a tour can move in view (or the page's own element
|
|
177
|
+
marked `data-tour-stop`, which it uses instead), stops every tour for the rest of the page view (WCAG 2.2.2). Escape
|
|
178
|
+
is one key with three meanings on a StoryDeck page, each by what is running: while listen mode plays, it stops listen
|
|
179
|
+
mode, and only that; in `PostView`'s Watch lens it returns to Read; and wherever a tour runs it stops every tour on
|
|
180
|
+
the page — so in a touring Watch lens one Escape does both;
|
|
181
|
+
- it **waits while out of sight** — scrolled away (IntersectionObserver, reading the newest entry) or the page
|
|
182
|
+
hidden — and while listen mode plays the deck (the deck carries `data-listening`); under **reduced motion** there
|
|
183
|
+
is no tour.
|
|
184
|
+
|
|
185
|
+
The plan is plain data — the stops, each one's hold, the waits — that a person or an LLM can read, and that the
|
|
186
|
+
deck's notes can say in one line (`tourText`):
|
|
187
|
+
|
|
188
|
+
```js
|
|
189
|
+
import { idleTour, tourPlan, tourText } from 'footprint-storydeck/tour';
|
|
190
|
+
|
|
191
|
+
idleTour(document.querySelector('deck-stage'), { stops: '3 4 5 9:6s', hold: '3s' }); // clicks as the deck counts them
|
|
192
|
+
tourText({ stops: '3 4 5 9:6s' });
|
|
193
|
+
// "Left alone for 1.4 s, it tours itself: slide 3 for 3 s, then slide 4 for 3 s, then slide 5 for 3 s, then slide 9
|
|
194
|
+
// for 6 s, and round again. A key, tap, click or move hands it over until 45 s of quiet; Escape or "Stop tour" stops it."
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Declared on the deck instead, for a page built as HTML: `<deck-stage data-tour="3 4 5 9:6s" data-tour-hold="3s">` (and
|
|
198
|
+
`data-tour-idle`, `data-tour-resume`), with `(${idleTour})()` once in the page; a deck whose `data-tour` it cannot
|
|
199
|
+
read is left out with a console warning naming the fix, and the others still tour. A stop is held 300 ms or more. In
|
|
200
|
+
React, the Watch lens takes the same spec — `<SlideDeck steps={…} tour="3 4 5 9:6s" />` — and `PostView` passes the
|
|
201
|
+
post's own `tour` (post.json), so a post opts in where its structure is written. A figure that is not a deck tours
|
|
202
|
+
through its own function: stops are its names, `go` is what a person's click calls
|
|
203
|
+
(`idleTour(figure, { stops: ['lid', 'core'], go: (part) => show(part) })`). The tour's state rides on the element as `data-tour-state` (`waiting`,
|
|
204
|
+
`touring`, `paused`, `hidden`, `still`, `done`), so a page can say "touring — touch to explore"; the control is
|
|
205
|
+
`.sd-tour-stop`, styled as weakly as can be, so a page restyles it.
|
|
206
|
+
|
|
207
|
+
It lives on the Watch deck, not in the Scroll or Read lenses: their figure follows the reader's scroll position, so a
|
|
208
|
+
tour there would move the page under the reader's eyes; a deck is a place a person steps through, and the tour steps
|
|
209
|
+
through it the same way.
|
|
210
|
+
|
|
131
211
|
## Narration: the deck tells itself, in your voice
|
|
132
212
|
|
|
133
213
|
A talk lives on after the room: someone opens the deck alone, or it becomes a video. The words were in the
|
|
@@ -199,6 +279,66 @@ await renderVideo({
|
|
|
199
279
|
});
|
|
200
280
|
```
|
|
201
281
|
|
|
282
|
+
## PowerPoint: the deck as a .pptx
|
|
283
|
+
|
|
284
|
+
Conferences often ask for the talk as a PowerPoint file. `footprint-storydeck/pptx` (Node, no ffmpeg) exports one
|
|
285
|
+
through the same browser port as the video, so a deck that can be filmed can be exported:
|
|
286
|
+
|
|
287
|
+
- **one slide per click** — the click's settled picture (made forward, its entrance played to the end, as the
|
|
288
|
+
video's held frame) full-bleed on a 16:9 slide; its written notes as the speaker notes, a paragraph per note;
|
|
289
|
+
its headline (or label) as the slide's title, in the title placeholder just above the page, so the outline, the
|
|
290
|
+
slide list and a screen reader read it and the audience does not see it; a fade between slides;
|
|
291
|
+
- **a narrated deck that runs by itself** — with clips (as the video takes them, or found in the voice cache), each
|
|
292
|
+
slide carries its clip, which plays as the slide starts, and moves on after the clip plus `gap` (a click without
|
|
293
|
+
a clip after `noclip` plus `gap`, the video's timing). Without clips no slide moves on by itself: the speaker clicks.
|
|
294
|
+
|
|
295
|
+
**What it is, honestly:** pictures plus notes plus sound. A slide's words and shapes are in its picture, not
|
|
296
|
+
native PowerPoint text or shapes, so they cannot be edited there. Turning a deck's HTML into native shapes is a
|
|
297
|
+
possible later step.
|
|
298
|
+
|
|
299
|
+
**Why a flowchart.** The export is a [footprintjs](https://github.com/footprintjs/footPrint) flowchart —
|
|
300
|
+
PlanClicks → GatherClips → CheckClips → TimeTheDeck → ShootPictures → CheckPictures → WriteParts → Zip →
|
|
301
|
+
CheckPackage → Verdict → Deliver — so every run leaves a record that explains it, to a person or an LLM
|
|
302
|
+
(`done.narrative`). Each check is a `decide()` that records its evidence: a clip whose file is gone, one PowerPoint
|
|
303
|
+
cannot play (not a .wav, .m4a or .mp3; an empty file; or bytes that do not begin as their kind of file — RIFF…WAVE,
|
|
304
|
+
ID3 or an MP3 frame, ftyp), one over `maxClip` (300 s), a click that says words with no clip;
|
|
305
|
+
a click without a 16:9 PNG; a package that does not read back as planned. A refusal names each click and its fix,
|
|
306
|
+
carries the record (`error.narrative`), and writes nothing to `out`. The clips are checked before a browser opens.
|
|
307
|
+
footprintjs is an optional peer dependency: StoryDeck does not install it (a browser-only install stays small), so
|
|
308
|
+
install it beside StoryDeck to export — `npm i footprintjs` — and an export without it is refused, saying so. It is
|
|
309
|
+
loaded only when an export runs; the deck's browser runtime never loads it.
|
|
310
|
+
|
|
311
|
+
```js
|
|
312
|
+
import { chromium } from 'playwright-core';
|
|
313
|
+
import { deckStageDriver } from 'footprint-storydeck/video';
|
|
314
|
+
import { exportPptx } from 'footprint-storydeck/pptx'; // and `npm i footprintjs`: an optional peer, needed to export
|
|
315
|
+
|
|
316
|
+
const done = await exportPptx({
|
|
317
|
+
url: 'file:///…/deck.html', steps, out: 'out/my-talk.pptx', // the steps the video takes; or `out` a folder → <name>.pptx
|
|
318
|
+
driver: deckStageDriver({ chromium }),
|
|
319
|
+
});
|
|
320
|
+
// done: { file, slides, voiced, runtime (s the deck runs by itself, or null), bytes, clicks, narrative }
|
|
321
|
+
// [Condition]: No rules matched, fell back to default "Every clip is there, playable and within maxClip": TimeTheDeck.
|
|
322
|
+
// …or, refused: exportPptx: a clip is not ready: click 12: its clip lasts 412 s, over maxClip (300 s) — split its notes across clicks, or raise maxClip
|
|
323
|
+
|
|
324
|
+
// From the deck's clicks and the voice cache instead of steps (give `voice` the `spell`/`say` the clips were voiced with):
|
|
325
|
+
await exportPptx({ url, deck: clicks.map(c => ({ label: c.label, notes: c.notes })), voice: { cache: 'voice/cache', profile }, out: 'out', driver });
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
The file is written by hand, with no dependency: a small ZIP writer (`zlib`'s raw deflate and a CRC-32) and a
|
|
329
|
+
minimal PresentationML part set (ECMA-376) — content types, relationships, the presentation, one master and one
|
|
330
|
+
title-only layout, a theme for each master, the slides, a notes master and a notes page per slide, the media (a
|
|
331
|
+
sound said on two clicks is stored once, matched by its bytes) and the document properties. The pieces are
|
|
332
|
+
exported for pictures made elsewhere: `pptxParts({ slides })` gives the parts, `zip(parts)` the file, and
|
|
333
|
+
`checkPptx(bytes)` reads one back — the ZIP against its CRC-32s, every part well-formed and typed, every
|
|
334
|
+
relationship's target there, every `r:id` known, slide, master and layout ids in PowerPoint's ranges, shape ids
|
|
335
|
+
unique on each slide — and lists each slide's title, notes, pictures, sound and time. Each export runs that check
|
|
336
|
+
before it writes to `out`. The tests also run Info-ZIP's `unzip -t`; LibreOffice, where it is installed, opens
|
|
337
|
+
the file and prints it to PDF; and with `STORYDECK_OOXML_SCHEMAS` pointing at the ISO/IEC 29500 schemas, every part
|
|
338
|
+
is validated against them. Limits: a clip is embedded as it is (a .wav as voiced: about 3 MB a minute at 24 kHz
|
|
339
|
+
mono — convert to .m4a to shrink a long talk); no ZIP64, so a file stays under 4 GB; the slides advance by
|
|
340
|
+
themselves when the show's "use timings" is on (PowerPoint's default).
|
|
341
|
+
|
|
202
342
|
## Faster edits — what making one talk taught us
|
|
203
343
|
|
|
204
344
|
- **Cache by content, not by position.** A clip is keyed by its words and settings: inserting or removing a
|
|
@@ -277,7 +417,7 @@ const { theme, toggle, setTheme } = useTheme(); // flips an html class + persi
|
|
|
277
417
|
| Export | What |
|
|
278
418
|
|---|---|
|
|
279
419
|
| `PostView` | the lens controller (Read · Scroll · Watch toggle) |
|
|
280
|
-
| `BlogView` · `ScrollyView` · `SlideDeck` | the three lenses (take `sections` / `sections` / `steps`) |
|
|
420
|
+
| `BlogView` · `ScrollyView` · `SlideDeck` | the three lenses (take `sections` / `sections` / `steps`; `SlideDeck` also a `tour`) |
|
|
281
421
|
| `SlideFigure` | one slide rendered as a scaled static figure |
|
|
282
422
|
| `StoryDeckProvider` · `useBasePath` | inject deploy config (asset base path) |
|
|
283
423
|
| `useTheme` · `ThemeToggle` | headless theming + a default toggle |
|
|
@@ -287,10 +427,12 @@ const { theme, toggle, setTheme } = useTheme(); // flips an html class + persi
|
|
|
287
427
|
| `slugify` · `scopeDeckCss` | utilities |
|
|
288
428
|
| `planFocus` · `focusStep` · `readFocus` · `FOCUS` | focus: per click, what it is about and what happens to the rest (also `footprint-storydeck/focus`) |
|
|
289
429
|
| `readPieces` · `readStep` · `drawFocus` · `wrapStage` · `slideClass` · `focusOverlay` · `focusRuntime` | focus, written into slide HTML (also `footprint-storydeck/focus-html`; styles in `footprint-storydeck/focus.css`) |
|
|
430
|
+
| `idleTour` · `tourPlan` · `tourText` | tour: a deck (or a figure) that shows its point while nobody is using it, and its plan in data and in one line (also `footprint-storydeck/tour`) |
|
|
290
431
|
| `narrationText` · `writtenText` | narration: what a click says (also `footprint-storydeck/narration`); the rules beneath, sentences, captions and chapters are `footprint-narration`'s |
|
|
291
432
|
| `notesData` · `notesRuntime` · `listenRuntime` | narration in the page: notes, presenter window, listen mode (also `footprint-storydeck/narration-html`; styles in `footprint-storydeck/narration.css`) |
|
|
292
433
|
| `embedClips` | `footprint-storydeck/voice` (Node only): a deck's clips inside one page; the clip cache, the voice port and its local-kit adapter are `footprint-narration/voice`'s |
|
|
293
434
|
| `renderVideo` · `planVideo` · `deckStageDriver` · `ffmpegMajor` | `footprint-storydeck/video` (Node only, ffmpeg 5.0+): the deck as a narrated video, with captions and chapters |
|
|
435
|
+
| `exportPptx` · `pptxParts` · `zip` · `unzip` · `checkPptx` | `footprint-storydeck/pptx` (Node only): the deck as a PowerPoint file — a slide per click, its picture, notes and clip — through a footprintjs flowchart that records why |
|
|
294
436
|
|
|
295
437
|
Types ship beside the source in `index.d.ts` — hand-kept (there is no build to generate them from),
|
|
296
438
|
and here rather than in a consumer's shim, because a package's shape belongs in the package.
|
|
@@ -302,7 +444,7 @@ npm test # vitest
|
|
|
302
444
|
npm run test:cov
|
|
303
445
|
```
|
|
304
446
|
|
|
305
|
-
|
|
447
|
+
317 tests (and 2 that run where LibreOffice or the ECMA-376 schemas are found) · ~99% lines · coverage thresholds enforced (90% statements, 85% branches, 90% functions, 95% lines).
|
|
306
448
|
|
|
307
449
|
## License
|
|
308
450
|
|
package/SlideDeck.jsx
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { useBasePath } from './context';
|
|
4
|
+
import { idleTour } from './tour';
|
|
5
|
+
import { focusRuntime } from './focusHtml';
|
|
4
6
|
|
|
5
7
|
// Slide (watch) lens: the deck slides (as raw HTML strings, in order) mounted into <deck-stage>
|
|
6
8
|
// (the deck's own web component, loaded from /deck-stage.js). We set innerHTML imperatively so
|
|
7
9
|
// React doesn't fight the runtime once it restructures the stage; remounts auto-upgrade.
|
|
8
|
-
|
|
10
|
+
//
|
|
11
|
+
// It installs focusHtml.js · focusRuntime (once a page), as a built deck inlines it: only a forward click
|
|
12
|
+
// animates, and apart's springs play.
|
|
13
|
+
//
|
|
14
|
+
// `tour` (tour.js · idleTour) makes it a deck nobody has to present: left alone for a moment it shows
|
|
15
|
+
// its stops through the deck's own goTo, and a key, tap, click or move hands it over. It is the
|
|
16
|
+
// readable spec only ('3 4 5 9:6s', or {stops, hold, idleMs, resumeMs, loop}), so it can come from JSON.
|
|
17
|
+
export default function SlideDeck({ steps, tour }) {
|
|
9
18
|
const host = useRef(null);
|
|
10
19
|
const [copied, setCopied] = useState(false);
|
|
11
20
|
const basePath = useBasePath();
|
|
21
|
+
const tourKey = tour ? JSON.stringify(tour) : '';
|
|
12
22
|
|
|
13
23
|
// deck-stage keeps the current slide in location.hash (#1, #2, …), so the URL already points at
|
|
14
24
|
// the visible slide — copying window.location shares that exact slide (same scheme as the blog).
|
|
@@ -21,6 +31,7 @@ export default function SlideDeck({ steps }) {
|
|
|
21
31
|
useEffect(() => {
|
|
22
32
|
const el = host.current;
|
|
23
33
|
if (!el) return;
|
|
34
|
+
focusRuntime();
|
|
24
35
|
const inner = (steps || []).join('\n');
|
|
25
36
|
el.innerHTML = `<deck-stage no-rail width="1920" height="1080">${inner}</deck-stage>`;
|
|
26
37
|
if (!document.querySelector('script[data-deck-stage]')) {
|
|
@@ -31,6 +42,14 @@ export default function SlideDeck({ steps }) {
|
|
|
31
42
|
}
|
|
32
43
|
}, [steps]);
|
|
33
44
|
|
|
45
|
+
// After the deck effect, so a new deck (new steps) gets a new tour; keyed by the spec's text, not its identity.
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const stage = host.current?.querySelector('deck-stage');
|
|
48
|
+
if (!tourKey || !stage) return undefined;
|
|
49
|
+
const t = idleTour(stage, typeof tour === 'string' ? { stops: tour } : tour);
|
|
50
|
+
return () => t.stop();
|
|
51
|
+
}, [steps, tourKey]);
|
|
52
|
+
|
|
34
53
|
return (
|
|
35
54
|
<>
|
|
36
55
|
<div className="deck-shell deck-scope" ref={host} />
|
package/focus.css
CHANGED
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
.sd-moved { translate: var(--sd-dx, 0px) var(--sd-dy, 0px); scale: var(--sd-s, 1); }
|
|
27
27
|
.sd-origin-tl { transform-origin: 0 0; }
|
|
28
28
|
.sd-stage { position: absolute; inset: 0; transform-origin: 0 0; translate: var(--sd-tx, 0px) var(--sd-ty, 0px); scale: var(--sd-ts, 1); }
|
|
29
|
+
/* apart: a piece its group's spring moves is marked .sd-apart and has no CSS motion — focusRuntime plays the spring as
|
|
30
|
+
Web Animations of the same translate/scale; at rest it is the plain style above (together: the piece as drawn) */
|
|
29
31
|
|
|
30
32
|
/* the blur overlay: everything outside the holes, in the area, blurred and darkened; a frame on the subject */
|
|
31
33
|
.sd-veil { position: absolute; left: 0; top: 0; pointer-events: none; -webkit-backdrop-filter: blur(var(--sd-blur, 7px)); backdrop-filter: blur(var(--sd-blur, 7px));
|
package/focus.d.ts
CHANGED
|
@@ -60,10 +60,16 @@ export interface FocusContext {
|
|
|
60
60
|
*/
|
|
61
61
|
export interface FocusRoute { readonly names: readonly string[]; readonly step: number; readonly line?: string; readonly lineAt?: string | null }
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* A mover's groups going by a spring each (apart), `stagger` ms apart (0–1000): out in the order the groups are given,
|
|
65
|
+
* back in reverse. Checked, and refused (naming the strategy and the fix) when it is not this; only read.
|
|
66
|
+
*/
|
|
67
|
+
export interface FocusSpring { readonly stagger: number }
|
|
68
|
+
|
|
63
69
|
/** A focus strategy: a look (how the context looks), a mover (where things go), an overlay (what lies over the slide) or an order (how the subject comes on). */
|
|
64
70
|
export type FocusStrategy =
|
|
65
71
|
| { readonly name?: string; readonly kind: 'look'; readonly look: 'grey' | 'hide' | 'keep' }
|
|
66
|
-
| { readonly name?: string; readonly kind: 'mover'; place(ctx: FocusContext): { stage?: FocusGroup; subject?: FocusGroup; context?: FocusGroup; groups?: { names: readonly string[]; g: FocusGroup | null }[] } }
|
|
72
|
+
| { readonly name?: string; readonly kind: 'mover'; place(ctx: FocusContext): { stage?: FocusGroup; subject?: FocusGroup; context?: FocusGroup; groups?: { names: readonly string[]; g: FocusGroup | null }[]; spring?: FocusSpring } }
|
|
67
73
|
| { readonly name?: string; readonly kind: 'overlay'; cover(ctx: { subjectOnSlide: FocusBox | null; canvas: { w: number; h: number }; options: Readonly<Record<string, unknown>> }): { rects: number[][]; label?: string; out?: number[][] } | null }
|
|
68
74
|
| { readonly name?: string; readonly kind: 'order'; route(ctx: { names: readonly string[]; options: Readonly<Record<string, unknown>> }): FocusRoute };
|
|
69
75
|
|
|
@@ -93,7 +99,11 @@ export interface FocusPieceLook {
|
|
|
93
99
|
readonly hot: boolean;
|
|
94
100
|
readonly hotIn: boolean;
|
|
95
101
|
readonly move: FocusMove | null;
|
|
96
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* Where it moved from, when it moved since the last click. (A leaving piece's `move` is where it stood.) A piece on
|
|
104
|
+
* its group's spring (`apart`) has the spring's other end: where it stands together as its group opens — kept while
|
|
105
|
+
* the group stays open — and where it stood apart as its group closes.
|
|
106
|
+
*/
|
|
97
107
|
readonly from: FocusMove | null;
|
|
98
108
|
/** It stepped aside for its lit twin. */
|
|
99
109
|
readonly twinOff: boolean;
|
|
@@ -101,6 +111,12 @@ export interface FocusPieceLook {
|
|
|
101
111
|
readonly twinTurn: number | null;
|
|
102
112
|
/** On a path: the seconds its turn waits (0 for the first); null when it takes no turn — not on the route, or not lit. */
|
|
103
113
|
readonly path: number | null;
|
|
114
|
+
/**
|
|
115
|
+
* It goes by its group's spring on this click (apart): the group's index in the look's `apart.groups` — the page's
|
|
116
|
+
* focusRuntime moves it from `from` to `move`. Null for every other piece (one that enters, leaves, or is in no
|
|
117
|
+
* group that opens or closes; one whose group stays open but moved anyway glides as any mover's piece does).
|
|
118
|
+
*/
|
|
119
|
+
readonly apart: number | null;
|
|
104
120
|
}
|
|
105
121
|
|
|
106
122
|
export interface FocusLook {
|
|
@@ -111,6 +127,13 @@ export interface FocusLook {
|
|
|
111
127
|
readonly overlay: { readonly rects: readonly (readonly number[])[]; readonly label: string; readonly phase: 'in-new' | 'in' | 'out'; readonly frameNew: boolean } | null;
|
|
112
128
|
/** A path: how many turns, the seconds between two, when the last starts (done), and its line ('' for none) and where it goes (null: the area's bottom left); null when no lit piece took a turn. */
|
|
113
129
|
readonly path: { readonly turns: number; readonly step: number; readonly done: number; readonly line: string; readonly lineAt: string | null } | null;
|
|
130
|
+
/**
|
|
131
|
+
* apart: the groups that open on this click (to 1, in the order written), then those the last click opened that
|
|
132
|
+
* close now (to 0, was 1, in its order), then those the last click closed (to 0, was 0) — carried one click more, so
|
|
133
|
+
* a spring still closing settles from wherever it is; `was` is where the last click left a group (0 together, 1
|
|
134
|
+
* apart). Null when no group opens, stays open, closes or settles — as on every click of a deck without apart.
|
|
135
|
+
*/
|
|
136
|
+
readonly apart: { readonly stagger: number; readonly groups: readonly { readonly names: readonly string[]; readonly to: 0 | 1; readonly was: 0 | 1 }[] } | null;
|
|
114
137
|
readonly strategies: readonly string[];
|
|
115
138
|
/** The subject where it ends up, on the slide. */
|
|
116
139
|
readonly subject: FocusBox | null;
|
|
@@ -119,7 +142,12 @@ export interface FocusLook {
|
|
|
119
142
|
/** The state a click leaves, to start the next from. Opaque. */
|
|
120
143
|
export type FocusState = { readonly __focusState: true } & Record<string, unknown>;
|
|
121
144
|
|
|
122
|
-
|
|
145
|
+
/**
|
|
146
|
+
* The built-in strategies. `apart` (a mover) is the exploded view: its option `apart` — data-focus-apart — is
|
|
147
|
+
* 'names: dx dy [scale] | …' (slide px; each group scaled around its own centre), `stagger` the ms between two
|
|
148
|
+
* groups (35); a later click without it brings the groups back together, the last one out first.
|
|
149
|
+
*/
|
|
150
|
+
export const FOCUS: Readonly<Record<'grey' | 'hide' | 'keep' | 'zoom' | 'left' | 'right' | 'up' | 'down' | 'place' | 'apart' | 'path' | 'blur', FocusStrategy>>;
|
|
123
151
|
export function readFocus(spec: FocusSpec | undefined | null, shared?: Readonly<Record<string, unknown>>): { strategy: string; options: Record<string, unknown> }[];
|
|
124
152
|
export function focusStep(prev: FocusState | null, click: FocusClick, deck: FocusDeck): { state: FocusState; look: FocusLook };
|
|
125
153
|
/** `end` is null only when there are no clicks and no `start`. */
|
package/focus.js
CHANGED
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
// left · right · up · down (the context steps aside that way, smaller,
|
|
11
11
|
// and the subject grows into the room it leaves) ·
|
|
12
12
|
// place (named groups moved by the amounts you give — a layout you
|
|
13
|
-
// art-direct, still animated from wherever the last click left it)
|
|
13
|
+
// art-direct, still animated from wherever the last click left it) ·
|
|
14
|
+
// apart (the exploded view: named groups fly apart along the vectors you
|
|
15
|
+
// give, one spring each, staggered, and come back together on a later
|
|
16
|
+
// click without it — the last one out is the first one back)
|
|
14
17
|
// overlay what lies over the slide blur (everything outside the subject's frame is blurred; a frame,
|
|
15
18
|
// and a label if given, on the subject)
|
|
16
19
|
// order how the subject comes on all at once (the default) · path (piece after piece along a route —
|
|
@@ -74,14 +77,31 @@ function aside(side) {
|
|
|
74
77
|
}});
|
|
75
78
|
}
|
|
76
79
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Groups moved by amounts: 'names: dx dy scale [@ ox oy] | …' (or a list of such strings) — place's, and apart's
|
|
82
|
+
* without the origin ('names: dx dy [scale]', written in data-focus-apart).
|
|
83
|
+
*/
|
|
84
|
+
const NUMBER = '(-?(?:\\d+(?:\\.\\d*)?|\\.\\d+))'; // 12, -3.5, .5 — never '1.2.3' or '.'
|
|
85
|
+
const GROUP = new RegExp(`^([^:]+):\\s*${NUMBER}\\s+${NUMBER}(?:\\s+${NUMBER})?(?:\\s*@\\s*${NUMBER}\\s+${NUMBER})?$`);
|
|
86
|
+
function readGroups(v, who = 'place', origin = true) {
|
|
87
|
+
const form = origin ? 'names: dx dy scale [@ ox oy]' : 'names: dx dy [scale]';
|
|
88
|
+
const give = `focus "${who}": give groups, '${form} | …'${origin ? '' : ` (data-focus-${who}, or the option ${who})`}`;
|
|
89
|
+
if (v === undefined || v === null || v === '') throw new Error(give);
|
|
90
|
+
const groups = [].concat(v).flatMap(x => String(x).split('|')).map(g => g.trim()).filter(Boolean).map(g => {
|
|
91
|
+
const m = GROUP.exec(g);
|
|
92
|
+
if (!m || (!origin && m[5] !== undefined)) throw new Error(`focus "${who}": a group is '${form}', not "${g}"`);
|
|
93
|
+
const s = m[4] === undefined ? 1 : Number(m[4]);
|
|
94
|
+
if (!(s > 0)) throw new Error(`focus "${who}": a group's scale is a number above 0 (1 keeps its size), not ${m[4]} in "${g}"`);
|
|
95
|
+
return {names: m[1].trim().split(/\s+/), dx: Number(m[2]), dy: Number(m[3]), s, origin: m[5] === undefined ? null : [Number(m[5]), Number(m[6])]};
|
|
84
96
|
});
|
|
97
|
+
if (!groups.length) throw new Error(`${give} — not ${JSON.stringify(v)}`);
|
|
98
|
+
return groups;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A group move that puts the centre of the group's box (or the point `origin`, map px) where dx dy (slide px) and s say. */
|
|
102
|
+
function groupMove({names, dx, dy, s, origin}, boxOf, view) {
|
|
103
|
+
const box = boxOf(names); if (!box && !origin) return {names, g: null};
|
|
104
|
+
return {names, g: {o: origin ?? centre(box), s, t: [dx / view.s, dy / view.s]}};
|
|
85
105
|
}
|
|
86
106
|
|
|
87
107
|
/** Rectangles x y w h from an option: numbers, a string of them, or a list of either. */
|
|
@@ -108,11 +128,16 @@ export const FOCUS = Object.freeze({
|
|
|
108
128
|
* moves by dx, dy (slide px) and scales around its own centre, or around the point ox oy (map px) when given.
|
|
109
129
|
*/
|
|
110
130
|
place: Object.freeze({name: 'place', kind: 'mover', place({boxOf, view, options}) {
|
|
111
|
-
return {groups: readGroups(options.groups).map(
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
131
|
+
return {groups: readGroups(options.groups).map(g => groupMove(g, boxOf, view))};
|
|
132
|
+
}}),
|
|
133
|
+
/**
|
|
134
|
+
* The exploded view: `apart` is 'names: dx dy [scale] | names: …' — each named group (lines and connectors that
|
|
135
|
+
* carry one of its names come with it) flies out by dx, dy (slide px), scaled around its own centre, and comes back
|
|
136
|
+
* together on a later click without apart. Its way there is a spring per group (focusHtml.js · focusRuntime), the
|
|
137
|
+
* groups `stagger` (35) ms apart: out in the order written, back in reverse, so the last one out is the first one back.
|
|
138
|
+
*/
|
|
139
|
+
apart: Object.freeze({name: 'apart', kind: 'mover', place({boxOf, view, options}) {
|
|
140
|
+
return {groups: readGroups(options.apart, 'apart', false).map(g => groupMove(g, boxOf, view)), spring: {stagger: num(options.stagger, 35)}};
|
|
116
141
|
}}),
|
|
117
142
|
/**
|
|
118
143
|
* The subject comes on piece after piece along a route, as a request travels it: `route` names the pieces in
|
|
@@ -164,7 +189,7 @@ function pick(focus, strategies) {
|
|
|
164
189
|
const s = all[strategy];
|
|
165
190
|
if (!s) throw new Error(`no focus strategy "${strategy}" (there are ${Object.keys(all).join(', ')})`);
|
|
166
191
|
if (!KINDS.includes(s.kind)) throw new Error(`focus strategy "${strategy}" has kind ${JSON.stringify(s.kind)}: a strategy is a look, a mover, an overlay or an order`);
|
|
167
|
-
if (byKind[s.kind]) throw new Error(`a click takes one ${s.kind} at most: "${byKind[s.kind].name}" and "${strategy}" are both ${s.kind}s`);
|
|
192
|
+
if (byKind[s.kind]) throw new Error(`a click takes one ${s.kind} at most: "${byKind[s.kind].name}" and "${strategy}" are both ${s.kind}s — keep one, and give the other a click of its own`);
|
|
168
193
|
byKind[s.kind] = {s: {...s, name: s.name ?? strategy}, options, name: strategy};
|
|
169
194
|
}
|
|
170
195
|
return byKind;
|
|
@@ -186,6 +211,17 @@ function readRoute(r, strategy) {
|
|
|
186
211
|
return {names: [...names], step, line: line ?? '', lineAt: lineAt?.trim() ? lineAt : null}; // (a blank lineAt, an empty data-focus-line-at, is none)
|
|
187
212
|
}
|
|
188
213
|
|
|
214
|
+
/**
|
|
215
|
+
* What a mover's `spring` gave — its groups go by a spring each, `stagger` ms apart — checked like an order's route
|
|
216
|
+
* (a deck's own mover is held to what apart gives) and read into a new object.
|
|
217
|
+
*/
|
|
218
|
+
function readSpring(r, strategy) {
|
|
219
|
+
const shown = v => (typeof v === 'number' || v === undefined ? String(v) : JSON.stringify(v));
|
|
220
|
+
if (!r || typeof r !== 'object') throw new Error(`focus "${strategy}": spring is {stagger}, the ms between two groups — not ${shown(r)}`);
|
|
221
|
+
if (typeof r.stagger !== 'number' || !(r.stagger >= 0 && r.stagger <= 1000)) throw new Error(`focus "${strategy}": stagger is the ms between two groups setting off, a number from 0 to 1000 — not ${shown(r.stagger)}`);
|
|
222
|
+
return {stagger: r.stagger};
|
|
223
|
+
}
|
|
224
|
+
|
|
189
225
|
/** Where a group move puts one piece: its reference point (the centre, or the top-left when it has no size) moves by [dx, dy], and it scales by s around it. */
|
|
190
226
|
function placePiece(g, box) {
|
|
191
227
|
if (!g || !box) return null;
|
|
@@ -217,8 +253,10 @@ function changeOf(st, was, look, wasLook, entering, before) {
|
|
|
217
253
|
* fresh). `deck`: {pieces: [{keys, box, twin?}], strategies?, area?, view?, canvas?}. Returns {state, look}:
|
|
218
254
|
* the state to hand the next click, and the look to draw — per piece {state, was, change, look, fromLook, hot,
|
|
219
255
|
* hotIn, move, from, twinOff, twinTurn (stepping aside for a lit twin on a route: the seconds until that twin's
|
|
220
|
-
* turn; else null), path (a lit piece on a route: the seconds until its turn; else null)
|
|
221
|
-
*
|
|
256
|
+
* turn; else null), path (a lit piece on a route: the seconds until its turn; else null), apart (a piece that goes by
|
|
257
|
+
* its group's spring on this click: the group's index in the look's apart; else null)}, the stage's move, the
|
|
258
|
+
* overlay, the path ({turns, step, done, line, lineAt}, or null), the apart ({stagger, groups: [{names, to, was}]} —
|
|
259
|
+
* to 1 opens, to 0 closes, was is where the last click left it — or null) and which strategies were used.
|
|
222
260
|
*/
|
|
223
261
|
export function focusStep(prev, step, deck) {
|
|
224
262
|
const {pieces, strategies = {}} = deck, canvas = deck.canvas ?? {w: 1920, h: 1080};
|
|
@@ -250,8 +288,15 @@ export function focusStep(prev, step, deck) {
|
|
|
250
288
|
// boxOf(names): the box around the shown pieces of those names, for a mover that moves named groups (place).
|
|
251
289
|
const boxOf = l => union(all.filter(i => states[i] !== 'gone' && has(i, known(names([].concat(l)), 'focus group: '))).map(i => pieces[i].box));
|
|
252
290
|
const placed = chosen.mover ? chosen.mover.s.place({subject, context, area, view, canvas, boxOf, options: chosen.mover.options}) ?? {} : {};
|
|
253
|
-
// Named groups (later ones win a piece in two of them) take their pieces out of the subject and context groups.
|
|
254
|
-
|
|
291
|
+
// Named groups (later ones win a piece in two of them) take their pieces out of the subject and context groups. A
|
|
292
|
+
// mover whose groups go by a spring (apart) has each moved piece remember its group, so the next click knows what closes.
|
|
293
|
+
const spring = placed.spring === undefined || placed.spring === null ? null : readSpring(placed.spring, chosen.mover.name);
|
|
294
|
+
const springOf = new Map();
|
|
295
|
+
for (const {names: l, g} of placed.groups ?? []) for (const i of all) if (states[i] !== 'gone' && has(i, l)) {
|
|
296
|
+
inGroup.set(i, g ? {g} : 'still');
|
|
297
|
+
if (spring && g) springOf.set(i, l.join(' ')); else springOf.delete(i);
|
|
298
|
+
}
|
|
299
|
+
const springGroups = [...new Map((spring ? placed.groups ?? [] : []).filter(x => x.g).map(x => [x.names.join(' '), [...x.names]]))];
|
|
255
300
|
const stage = placed.stage ? {s: round(placed.stage.s, 4), x: round(placed.stage.o[0] * (1 - placed.stage.s) + placed.stage.t[0]), y: round(placed.stage.o[1] * (1 - placed.stage.s) + placed.stage.t[1])} : null;
|
|
256
301
|
const groupOf = i => { const k = inGroup.get(i); return k === 'subject' ? placed.subject : k === 'context' ? placed.context : k?.g ?? null; };
|
|
257
302
|
const moves = pieces.map((p, i) => states[i] === 'gone' ? null : placePiece(groupOf(i), p.box));
|
|
@@ -278,15 +323,45 @@ export function focusStep(prev, step, deck) {
|
|
|
278
323
|
// stood, so it fades out from there instead of jumping home in colour first.
|
|
279
324
|
const before = prev && !step.fresh ? prev : null;
|
|
280
325
|
const hotNow = new Set(all.filter(i => states[i] !== 'gone' && has(i, hot)));
|
|
326
|
+
// apart: the groups that open on this click (in the order written), then those the last click opened that close now
|
|
327
|
+
// (in its order, was 1), then those the last click closed (to 0, was 0): carried one click more, so a spring still
|
|
328
|
+
// closing settles from wherever it is instead of snapping home. A piece shown on both clicks goes by its group's
|
|
329
|
+
// spring; one whose group keeps its target but moved anyway (a new vector) glides as any mover's piece does.
|
|
330
|
+
const last = before?.spring ?? null, nowKeys = new Set(springGroups.map(([k]) => k));
|
|
331
|
+
const lastTo = new Map((last?.groups ?? []).map(g => [g.key, g.to]));
|
|
332
|
+
const springList = [...springGroups.map(([key, l]) => ({key, names: l, to: 1, was: lastTo.get(key) === 1 ? 1 : 0})),
|
|
333
|
+
...(last?.groups ?? []).filter(g => !nowKeys.has(g.key)).map(g => ({key: g.key, names: g.names, to: 0, was: g.to}))];
|
|
334
|
+
const springAt = new Map(springList.map((g, k) => [g.key, k]));
|
|
335
|
+
// Each shown piece's group: the one that opens it now, else the one it was in, while that group is still listed.
|
|
336
|
+
const member = pieces.map((_, i) => {
|
|
337
|
+
if (states[i] === 'gone') return null;
|
|
338
|
+
const had = before?.springOf?.[i] ?? null;
|
|
339
|
+
return springOf.get(i) ?? (had !== null && springAt.has(had) && !nowKeys.has(had) ? had : null);
|
|
340
|
+
});
|
|
341
|
+
const springIdx = (i, moved) => {
|
|
342
|
+
if (!before || member[i] === null || before.states[i] === 'gone') return null;
|
|
343
|
+
const k = springAt.get(member[i]);
|
|
344
|
+
return springList[k].to === springList[k].was && moved ? null : k;
|
|
345
|
+
};
|
|
346
|
+
// The other end of a piece's spring: where it stands together as its group opens, where it stood apart as it closes —
|
|
347
|
+
// carried while the group keeps its target, so a spring caught mid-way goes on from there.
|
|
348
|
+
const home = () => ({dx: 0, dy: 0, s: 1});
|
|
349
|
+
const end = pieces.map((_, i) => {
|
|
350
|
+
if (member[i] === null) return null;
|
|
351
|
+
const g = springList[springAt.get(member[i])];
|
|
352
|
+
return g.to === g.was ? before?.springEnd?.[i] ?? home() : before?.moves[i] ?? home();
|
|
353
|
+
});
|
|
281
354
|
const out = pieces.map((p, i) => {
|
|
282
355
|
const st = states[i], was = before ? before.states[i] : 'gone', wasLook = before?.looks?.[i] ?? null;
|
|
283
356
|
const change = changeOf(st, was, looks[i], wasLook, has(i, ins), before);
|
|
284
357
|
const leaving = change === 'leave', stood = before?.moves[i] ?? null;
|
|
285
358
|
const from = stood, moved = st !== 'gone' && was !== 'gone' && !!before && !sameMove(from, moves[i]);
|
|
359
|
+
// A piece on its group's spring has the spring's other end as `from`: together as it opens, apart as it closes.
|
|
360
|
+
const k = springIdx(i, moved), spun = k === null ? null : end[i];
|
|
286
361
|
return {state: st, was, change, look: leaving ? wasLook : looks[i], fromLook: change === 'relook' ? wasLook : null,
|
|
287
362
|
hot: hotNow.has(i), hotIn: hotNow.has(i) && (!before || !before.hot.has(i)),
|
|
288
|
-
move: leaving ? stood : st === 'gone' ? null : moves[i], from: moved ? from ??
|
|
289
|
-
twinTurn: leaving && twinOff.has(i) ? twinTurn(i) : null, path: delayOf(i)};
|
|
363
|
+
move: leaving ? stood : st === 'gone' ? null : moves[i], from: spun ?? (moved ? from ?? home() : null), twinOff: twinOff.has(i),
|
|
364
|
+
twinTurn: leaving && twinOff.has(i) ? twinTurn(i) : null, path: delayOf(i), apart: k};
|
|
290
365
|
});
|
|
291
366
|
const stageMoved = !!before && !sameStage(before.stage, stage);
|
|
292
367
|
// The blur: new only when the last click had none (a blur that moves to another subject stays, and only its
|
|
@@ -294,9 +369,12 @@ export function focusStep(prev, step, deck) {
|
|
|
294
369
|
const overlay = cover ? {rects: cover.rects, label: cover.label, phase: before?.cover ? 'in' : 'in-new', frameNew: !before?.cover || !sameCover(before.cover, cover)}
|
|
295
370
|
: before?.cover ? {rects: before.cover.out ?? before.cover.rects, label: '', phase: 'out', frameNew: false} : null;
|
|
296
371
|
return {
|
|
297
|
-
state: {vis, states, moves, stage, hot: hotNow, cover, looks
|
|
372
|
+
state: {vis, states, moves, stage, hot: hotNow, cover, looks,
|
|
373
|
+
spring: springList.some(g => g.to === 1 || g.was === 1) ? {stagger: (spring ?? last).stagger, groups: springList.filter(g => g.to === 1 || g.was === 1).map(({key, names: l, to}) => ({key, names: l, to}))} : null,
|
|
374
|
+
springOf: member, springEnd: end},
|
|
298
375
|
look: {pieces: out, stage: stage || stageMoved ? {move: stage, from: stageMoved ? before.stage ?? {s: 1, x: 0, y: 0} : null} : null, overlay,
|
|
299
376
|
path: turns ? {turns, step: order.step, done: round((turns - 1) * order.step, 3), line: order.line, lineAt: order.lineAt} : null,
|
|
377
|
+
apart: springList.length ? {stagger: (spring ?? last).stagger, groups: springList.map(({names: l, to, was}) => ({names: [...l], to, was}))} : null,
|
|
300
378
|
strategies: Object.values(chosen).map(c => c.name), subject: subjectOnSlide},
|
|
301
379
|
};
|
|
302
380
|
}
|
package/focusHtml.d.ts
CHANGED
|
@@ -10,7 +10,9 @@ export function readStep(attrs: string): FocusClick;
|
|
|
10
10
|
/**
|
|
11
11
|
* That HTML, each piece dressed for this click's look (classes from focus.css, its move as CSS variables). On a path, a
|
|
12
12
|
* piece's moment rides as `--sd-path-delay`: its turn (`sd-path`), or, stepping aside for its lit twin, that twin's
|
|
13
|
-
* (`sd-twin-turn`); focus.css adds .4 s under a new blur. A click without a path is drawn exactly as before.
|
|
13
|
+
* (`sd-twin-turn`); focus.css adds .4 s under a new blur. A click without a path is drawn exactly as before. A piece
|
|
14
|
+
* that goes by its group's spring (apart) is marked `sd-apart` (no CSS move: focusRuntime plays the spring) and
|
|
15
|
+
* carries `data-sd-apart="index to was stagger names…"`; a click without apart is drawn exactly as before.
|
|
14
16
|
*/
|
|
15
17
|
export function drawFocus(html: string, look: FocusLook): string;
|
|
16
18
|
/** The pieces inside the stage the view moves (a zoom); unchanged when nothing moves the view. Put it in the map's own box. */
|
|
@@ -19,5 +21,10 @@ export function wrapStage(inner: string, look: FocusLook): string;
|
|
|
19
21
|
export function slideClass(look: FocusLook): string;
|
|
20
22
|
/** The overlay of a click in slide px — the blur, its frame and label, and a path's line (once its route is done, .4 s later under a new blur) — put outside the map's box; '' when the click has none. */
|
|
21
23
|
export function focusOverlay(look: FocusLook, options?: { canvas?: { w: number; h: number }; area?: readonly [number, number, number, number] }): string;
|
|
22
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Runs in the page; call it once, at any time: only a forward click animates; a jump or a step back lands at once.
|
|
26
|
+
* It plays apart's springs (one per group, staggered, from wherever the last click left a group, mid-way too; each deck
|
|
27
|
+
* on the page its own) as Web Animations, so a group at rest is exactly as drawn; under reduced motion, or without
|
|
28
|
+
* Element.animate, it lands.
|
|
29
|
+
*/
|
|
23
30
|
export function focusRuntime(): void;
|