pele-animate 0.10.0 → 0.11.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/README.md CHANGED
@@ -1,74 +1,114 @@
1
- # pele-animate
2
-
3
- Animated, illustrated SVG scenes from plain Mermaid code. A small post-processor for
4
- [Pele](https://github.com/obsidianmd/pele) (MIT), which renders the Mermaid; this adds the motion and the pictures.
5
- Unofficial add-on; not affiliated with or endorsed by the Pele or Obsidian teams.
6
- The source stays valid Mermaid, so GitHub or Obsidian still show a normal diagram.
7
-
8
- - Nodes appear in order (BFS from the sources, or pinned with a `%% pa:` comment), edges draw in, labels fade in.
9
- - Hover or focus: tokens travel the edges in sequence. Click or Enter replays the intro.
10
- - `@{ icon: "name" }` nodes become illustrations from a glyph pack. Unknown names fall back to a plain Pele box.
11
- - Sequence diagrams: participant boxes in a row get the tallest height (Pele sizes each to its own text). Group and box titles are bold.
12
- - Idle state is the finished picture; `prefers-reduced-motion` turns the motion off. Light and dark themes.
13
-
14
- ## Install
15
-
16
- ```sh
17
- npm i pele-animate pele
18
- ```
19
-
20
- ## Use
21
-
22
- ```js
23
- import { mount } from 'pele';
24
- import { watch } from 'pele-animate';
25
- import { icons } from 'pele-animate/glyphs';
26
- import 'pele-animate/preset-doverunner'; // optional extra glyphs
27
- import 'pele-animate/style.css';
28
-
29
- for (const el of document.querySelectorAll('pre.mermaid')) {
30
- const src = el.textContent;
31
- mount(el, src, { icons });
32
- watch(el, src); // re-applies after Pele re-lays-out on resize
33
- }
34
- ```
35
-
36
- Mermaid source:
37
-
38
- ```mermaid
39
- flowchart LR
40
- %% pa: 1=P 2=O 3=D
41
- P@{ icon: "player", form: "square", h: 88, label: "Player" }
42
- O@{ icon: "server", form: "square", h: 88, label: "OTT service" }
43
- D@{ icon: "key", form: "square", h: 88, label: "DRM server" }
44
- P <-->|auth token| O
45
- P <-->|token / license| D
46
- ```
47
-
48
- `form: "square", h: 88` makes a large frameless glyph; leave both out for a small icon in a frame.
49
- `%% pa: 1=P 2=O 3=D` pins nodes to steps (optional). Sequence diagrams animate actors, then messages in order.
50
-
51
- ## API
52
-
53
- | Export | |
54
- |---|---|
55
- | `watch(container, source?)` | Animate the SVG inside `container` (and every re-render of it); adds the scene panel, click and Enter replay |
56
- | `animate(svg, { hover, source })` | Animate one SVG; you handle re-renders |
57
- | `replay(svg)` | Play the intro again |
58
- | `parseOrder(source)` | Read the `%% pa:` pins |
59
- | `registerGlyphs(pack, strokeWidth = 1.1)` (`/glyphs`) | Add glyphs: `{ name: '<inner markup of a 24x24 svg>' }`; colors via `--pa-solid` / `--pa-light` |
60
- | `icons` (`/glyphs`) | Pele `icons` resolver |
61
-
62
- Generic glyphs: `player`, `server`, `key`, `document`, `shield`, `phone`, `cloud`. `preset-doverunner`: `package`, `token`, `lock`,
63
- `database`, `video`, `media`, `encoder`, `storage`, `plugin`, `variants`, `watermark`, `bits-a`, `bits-b`.
64
-
65
- ## Theme
66
-
67
- `watch()` adds class `pa-scene` to the container. Override `--pa-bg`, `--pa-solid`, `--pa-light` or any `--pele-*`
68
- variable on it; defaults are `:where()` rules, so your stylesheet wins.
69
-
70
- ## Limits
71
-
72
- - Relies on Pele's SVG structure (`.pele-node`, `.pele-edge`, `data-id`); `pele` is a peer dependency pinned to `^1.7.0`.
73
- - Flowcharts and sequence diagrams only. No types yet.
74
- - Glyph art is square-boxed; wide art is drawn as a wide glyph inside the square.
1
+ # pele-animate
2
+
3
+ Animated, illustrated SVG scenes from plain Mermaid code. A small post-processor for
4
+ [Pele](https://github.com/obsidianmd/pele) (MIT), which renders the Mermaid; this adds the motion and the pictures.
5
+ Unofficial add-on; not affiliated with or endorsed by the Pele or Obsidian teams.
6
+ The source stays valid Mermaid, so GitHub or Obsidian still show a normal diagram.
7
+
8
+ - Nodes appear in order (BFS from the sources, or pinned with a `%% pa:` comment), edges draw in, labels fade in.
9
+ - Hover or focus: tokens travel the edges in sequence. The intro plays once on scroll-in. Click (or Enter) enters step mode: previous / play-pause / next buttons, with Space (play/pause), ←/→ (step) and Esc (leave).
10
+ - `@{ icon: "name" }` nodes become illustrations from a glyph pack. Unknown names fall back to a plain Pele box.
11
+ - Sequence diagrams: participant boxes in a row get the tallest height (Pele sizes each to its own text). Group and box titles are bold.
12
+ - Idle state is the finished picture; `prefers-reduced-motion` turns the motion off. Light and dark themes.
13
+
14
+ ## Compare
15
+
16
+ The same Mermaid source, three renderers. The pele-animate images loop: the intro draws in, then tokens travel the edges (as on hover).
17
+ Regenerate with `examples/capture.sh`.
18
+
19
+ ```mermaid
20
+ flowchart LR
21
+ A[Player] -->|auth token| B[OTT service]
22
+ A -->|license request| C[DRM server]
23
+ C -->|license| A
24
+ ```
25
+
26
+ | Renderer | Result |
27
+ |---|---|
28
+ | Mermaid | <img src="https://raw.githubusercontent.com/dkdev24/pele-animate/main/examples/m1.png" width="560" alt="Flowchart rendered by Mermaid"> |
29
+ | Pele | <img src="https://raw.githubusercontent.com/dkdev24/pele-animate/main/examples/p1.png" width="560" alt="Flowchart rendered by Pele"> |
30
+ | pele-animate | <img src="https://raw.githubusercontent.com/dkdev24/pele-animate/main/examples/a1.gif" width="560" alt="Animated flowchart: nodes appear, edges draw in, tokens travel"> |
31
+
32
+ ```mermaid
33
+ sequenceDiagram
34
+ Player->>OTT: sign in
35
+ OTT-->>Player: token
36
+ Player->>DRM: token
37
+ DRM-->>Player: license
38
+ ```
39
+
40
+ | Renderer | Result |
41
+ |---|---|
42
+ | Mermaid | <img src="https://raw.githubusercontent.com/dkdev24/pele-animate/main/examples/m2.png" width="560" alt="Sequence diagram rendered by Mermaid"> |
43
+ | Pele | <img src="https://raw.githubusercontent.com/dkdev24/pele-animate/main/examples/p2.png" width="560" alt="Sequence diagram rendered by Pele"> |
44
+ | pele-animate | <img src="https://raw.githubusercontent.com/dkdev24/pele-animate/main/examples/a2.gif" width="560" alt="Animated sequence diagram: messages appear in order, tokens travel"> |
45
+
46
+ With glyphs (`@{ icon }` nodes, `%% pa: 1=P 2=O 3=D` pins the order):
47
+
48
+ <img src="https://raw.githubusercontent.com/dkdev24/pele-animate/main/examples/a3.gif" width="560" alt="Animated scene with illustrated Player, OTT service and DRM server">
49
+
50
+ ## Install
51
+
52
+ ```sh
53
+ npm i pele-animate pele
54
+ ```
55
+
56
+ ## Use
57
+
58
+ ```js
59
+ import { mount } from 'pele';
60
+ import { watch } from 'pele-animate';
61
+ import { icons } from 'pele-animate/glyphs';
62
+ import 'pele-animate/preset-doverunner'; // optional extra glyphs
63
+ import 'pele-animate/style.css';
64
+
65
+ for (const el of document.querySelectorAll('pre.mermaid')) {
66
+ const src = el.textContent;
67
+ mount(el, src, { icons });
68
+ watch(el, src); // re-applies after Pele re-lays-out on resize
69
+ }
70
+ ```
71
+
72
+ Mermaid source:
73
+
74
+ ```mermaid
75
+ flowchart LR
76
+ %% pa: 1=P 2=O 3=D
77
+ P@{ icon: "player", form: "square", h: 88, label: "Player" }
78
+ O@{ icon: "server", form: "square", h: 88, label: "OTT service" }
79
+ D@{ icon: "key", form: "square", h: 88, label: "DRM server" }
80
+ P <-->|auth token| O
81
+ P <-->|token / license| D
82
+ ```
83
+
84
+ `form: "square", h: 88` makes a large frameless glyph; leave both out for a small icon in a frame.
85
+ `%% pa: 1=P 2=O 3=D` pins nodes to steps (optional). Sequence diagrams animate actors, then messages in order.
86
+
87
+ ## API
88
+
89
+ | Export | |
90
+ |---|---|
91
+ | `watch(container, source?)` | Animate the SVG inside `container` (and every re-render of it); adds the scene panel and step mode (click / Enter); pass `{ controls: false }` as a third argument for click-to-replay instead |
92
+ | `animate(svg, { hover, source })` | Animate one SVG; you handle re-renders |
93
+ | `replay(svg)` | Play the intro again |
94
+ | `parseOrder(source)` | Read the `%% pa:` pins |
95
+ | `registerGlyphs(pack, strokeWidth = 1.1)` (`/glyphs`) | Add glyphs: `{ name: '<inner markup of a 24x24 svg>' }`; colors via `--pa-solid` / `--pa-light` |
96
+ | `icons` (`/glyphs`) | Pele `icons` resolver |
97
+
98
+ Generic glyphs: `player`, `server`, `key`, `document`, `shield`, `phone`, `cloud`. `preset-doverunner`: `package`, `token`, `lock`,
99
+ `database`, `video`, `media`, `encoder`, `storage`, `plugin`, `variants`, `watermark`, `bits-a`, `bits-b`.
100
+
101
+ ## Theme
102
+
103
+ `watch()` adds class `pa-scene` to the container. Override `--pa-bg`, `--pa-solid`, `--pa-light` or any `--pele-*`
104
+ variable on it; defaults are `:where()` rules, so your stylesheet wins.
105
+
106
+ ## Limits
107
+
108
+ - Relies on Pele's SVG structure (`.pele-node`, `.pele-edge`, `data-id`); `pele` is a peer dependency pinned to `^1.7.0`.
109
+ - Flowcharts and sequence diagrams only. No types yet.
110
+ - Glyph art is square-boxed; wide art is drawn as a wide glyph inside the square.
111
+
112
+ ## Changelog
113
+
114
+ See [CHANGELOG.md](CHANGELOG.md).
package/glyphs.js CHANGED
@@ -1,23 +1,23 @@
1
- // Glyph registry. A glyph is the inner markup of a 24x24 SVG; colors come from --pa-* variables so packs can theme.
2
- // Pass `icons` to pele's render()/mount() options: mount(el, text, { icons }).
3
- export const S = 'var(--pa-solid,#4F378B)';
4
- export const L = 'var(--pa-light,#d9d0f0)';
5
- export const W = '#fff';
6
- const reg = new Map();
7
-
8
- // Pele draws icons with stroke-width 2 in a 24 box, which turns heavy once a node is sized up; glyphs get a thin line instead.
9
- export function registerGlyphs(pack, strokeWidth = 1.1) {
10
- for (const [name, markup] of Object.entries(pack)) reg.set(name, `<g stroke-width="${strokeWidth}">${markup}</g>`);
11
- }
12
- // Returning null makes pele fall back to its default node.
13
- export const icons = (name) => reg.get(name) ?? null;
14
-
15
- registerGlyphs({
16
- player: `<defs><linearGradient id="pa-g-screen" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#6b4fb5"/><stop offset="1" stop-color="#2c1f52"/></linearGradient></defs><rect x="2" y="4" width="20" height="13" rx="2" fill="url(#pa-g-screen)" stroke="${S}"/><path d="M10 7.8v5.4l4.8-2.7z" fill="${W}" stroke="none"/><path d="M9 20.5h6M12 17v3.5" stroke="${S}"/>`,
17
- server: `<rect x="3" y="3.5" width="18" height="7" rx="2" fill="${L}" stroke="${S}"/><rect x="3" y="13.5" width="18" height="7" rx="2" fill="${L}" stroke="${S}"/><circle cx="7" cy="7" r="1" fill="${S}" stroke="none"/><circle cx="7" cy="17" r="1" fill="${S}" stroke="none"/><path d="M12 7h6M12 17h6" stroke="${S}"/>`,
18
- key: `<circle cx="8" cy="12" r="4.5" fill="${L}" stroke="${S}"/><circle cx="8" cy="12" r="1.4" fill="${S}" stroke="none"/><path d="M12.5 12H21M18 12v3.5M21 12v2.5" stroke="${S}"/>`,
19
- document: `<path d="M6 2.5h8l4.5 4.5v14a1 1 0 0 1-1 1h-11.5a1 1 0 0 1-1-1v-17.5a1 1 0 0 1 1-1z" fill="${L}" stroke="${S}"/><path d="M14 2.5V7h4.5" fill="none" stroke="${S}"/><path d="M8.5 12h7M8.5 15.5h7" stroke="${S}"/>`,
20
- shield: `<path d="M12 2.5l8 3v6c0 5-3.4 8.5-8 10-4.6-1.5-8-5-8-10v-6z" fill="${S}" stroke="none"/><path d="M8.5 12l2.6 2.6 4.6-5" fill="none" stroke="${W}" stroke-width="2.2"/>`,
21
- phone: `<rect x="6.5" y="2" width="11" height="20" rx="2.5" fill="${W}" stroke="${S}"/><path d="M10.5 3.8h3" stroke="${S}"/><rect x="8.5" y="6" width="7" height="4" rx="1" fill="${S}" stroke="none"/><path d="M8.5 12.5h7M8.5 15h7M8.5 17.5h4" stroke="${S}"/><path d="M10.5 20.2h3" stroke="${S}"/>`,
22
- cloud: `<path d="M7 18a4 4 0 0 1-.6-7.96A5.5 5.5 0 0 1 17 8.5a4.75 4.75 0 0 1 .5 9.5z" fill="${L}" stroke="${S}"/>`,
23
- });
1
+ // Glyph registry. A glyph is the inner markup of a 24x24 SVG; colors come from --pa-* variables so packs can theme.
2
+ // Pass `icons` to pele's render()/mount() options: mount(el, text, { icons }).
3
+ export const S = 'var(--pa-solid,#4F378B)';
4
+ export const L = 'var(--pa-light,#d9d0f0)';
5
+ export const W = '#fff';
6
+ const reg = new Map();
7
+
8
+ // Pele draws icons with stroke-width 2 in a 24 box, which turns heavy once a node is sized up; glyphs get a thin line instead.
9
+ export function registerGlyphs(pack, strokeWidth = 1.1) {
10
+ for (const [name, markup] of Object.entries(pack)) reg.set(name, `<g stroke-width="${strokeWidth}">${markup}</g>`);
11
+ }
12
+ // Returning null makes pele fall back to its default node.
13
+ export const icons = (name) => reg.get(name) ?? null;
14
+
15
+ registerGlyphs({
16
+ player: `<defs><linearGradient id="pa-g-screen" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#6b4fb5"/><stop offset="1" stop-color="#2c1f52"/></linearGradient></defs><rect x="2" y="4" width="20" height="13" rx="2" fill="url(#pa-g-screen)" stroke="${S}"/><path d="M10 7.8v5.4l4.8-2.7z" fill="${W}" stroke="none"/><path d="M9 20.5h6M12 17v3.5" stroke="${S}"/>`,
17
+ server: `<rect x="3" y="3.5" width="18" height="7" rx="2" fill="${L}" stroke="${S}"/><rect x="3" y="13.5" width="18" height="7" rx="2" fill="${L}" stroke="${S}"/><circle cx="7" cy="7" r="1" fill="${S}" stroke="none"/><circle cx="7" cy="17" r="1" fill="${S}" stroke="none"/><path d="M12 7h6M12 17h6" stroke="${S}"/>`,
18
+ key: `<circle cx="8" cy="12" r="4.5" fill="${L}" stroke="${S}"/><circle cx="8" cy="12" r="1.4" fill="${S}" stroke="none"/><path d="M12.5 12H21M18 12v3.5M21 12v2.5" stroke="${S}"/>`,
19
+ document: `<path d="M6 2.5h8l4.5 4.5v14a1 1 0 0 1-1 1h-11.5a1 1 0 0 1-1-1v-17.5a1 1 0 0 1 1-1z" fill="${L}" stroke="${S}"/><path d="M14 2.5V7h4.5" fill="none" stroke="${S}"/><path d="M8.5 12h7M8.5 15.5h7" stroke="${S}"/>`,
20
+ shield: `<path d="M12 2.5l8 3v6c0 5-3.4 8.5-8 10-4.6-1.5-8-5-8-10v-6z" fill="${S}" stroke="none"/><path d="M8.5 12l2.6 2.6 4.6-5" fill="none" stroke="${W}" stroke-width="2.2"/>`,
21
+ phone: `<rect x="6.5" y="2" width="11" height="20" rx="2.5" fill="${W}" stroke="${S}"/><path d="M10.5 3.8h3" stroke="${S}"/><rect x="8.5" y="6" width="7" height="4" rx="1" fill="${S}" stroke="none"/><path d="M8.5 12.5h7M8.5 15h7M8.5 17.5h4" stroke="${S}"/><path d="M10.5 20.2h3" stroke="${S}"/>`,
22
+ cloud: `<path d="M7 18a4 4 0 0 1-.6-7.96A5.5 5.5 0 0 1 17 8.5a4.75 4.75 0 0 1 .5 9.5z" fill="${L}" stroke="${S}"/>`,
23
+ });
package/package.json CHANGED
@@ -1,22 +1,22 @@
1
- {
2
- "name": "pele-animate",
3
- "version": "0.10.0",
4
- "description": "Animated, illustrated SVG scenes from plain Mermaid code, as a post-processor for Pele.",
5
- "type": "module",
6
- "license": "MIT",
7
- "homepage": "https://github.com/dkdev24/pele-animate#readme",
8
- "publishConfig": { "access": "public" },
9
- "author": "Daniel Kim",
10
- "repository": { "type": "git", "url": "git+https://github.com/dkdev24/pele-animate.git" },
11
- "keywords": ["mermaid", "pele", "svg", "diagram", "animation"],
12
- "exports": {
13
- ".": "./pele-animate.js",
14
- "./glyphs": "./glyphs.js",
15
- "./preset-doverunner": "./preset-doverunner.js",
16
- "./style.css": "./pele-animate.css"
17
- },
18
- "files": ["pele-animate.js", "pele-animate.css", "glyphs.js", "preset-doverunner.js", "README.md", "LICENSE"],
19
- "sideEffects": ["*.css", "./preset-doverunner.js"],
20
- "peerDependencies": { "pele": "^1.7.0" },
21
- "devDependencies": { "pele": "^1.7.0" }
22
- }
1
+ {
2
+ "name": "pele-animate",
3
+ "version": "0.11.0",
4
+ "description": "Animated, illustrated SVG scenes from plain Mermaid code, as a post-processor for Pele.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/dkdev24/pele-animate#readme",
8
+ "publishConfig": { "access": "public" },
9
+ "author": "Daniel Kim",
10
+ "repository": { "type": "git", "url": "git+https://github.com/dkdev24/pele-animate.git" },
11
+ "keywords": ["mermaid", "pele", "svg", "diagram", "animation"],
12
+ "exports": {
13
+ ".": "./pele-animate.js",
14
+ "./glyphs": "./glyphs.js",
15
+ "./preset-doverunner": "./preset-doverunner.js",
16
+ "./style.css": "./pele-animate.css"
17
+ },
18
+ "files": ["pele-animate.js", "pele-animate.css", "glyphs.js", "preset-doverunner.js", "README.md", "LICENSE"],
19
+ "sideEffects": ["*.css", "./preset-doverunner.js"],
20
+ "peerDependencies": { "pele": "^1.7.0" },
21
+ "devDependencies": { "pele": "^1.7.0" }
22
+ }
package/pele-animate.css CHANGED
@@ -1,48 +1,65 @@
1
- /* Draw-in once when scrolled into view; a dot travels each edge while hovered/focused. Idle = finished picture. */
2
- .pa-ready:not(.pa-in) .pele-node,
3
- .pa-ready:not(.pa-in) .pele-edge-label,
4
- .pa-ready:not(.pa-in) .pele-lifeline,
5
- .pa-ready:not(.pa-in) .pele-marker { opacity: 0; }
6
- .pa-ready:not(.pa-in) .pele-edge > path:not(.pele-marker) { stroke-dasharray: var(--len); stroke-dashoffset: var(--len); }
7
-
8
- /* Keyframes carry the dash, so edges that are dashed in the diagram go back to their own dash pattern afterwards. */
9
- .pa-ready.pa-in .pele-node,
10
- .pa-ready.pa-in .pele-edge-label,
11
- .pa-ready.pa-in .pele-lifeline,
12
- .pa-ready.pa-in .pele-marker { animation: pa-fade .4s ease-out backwards; animation-delay: var(--d, 0s); }
13
- .pa-ready.pa-in .pele-edge > path:not(.pele-marker) { animation: pa-draw .4s ease-out backwards; animation-delay: var(--d, 0s); }
14
- .pa-ready.pa-done * { animation: none !important; }
15
- @keyframes pa-fade { from { opacity: 0; } }
16
- @keyframes pa-draw { from { stroke-dasharray: var(--len); stroke-dashoffset: var(--len); } to { stroke-dasharray: var(--len); stroke-dashoffset: 0; } }
17
-
18
- .pa-dot { fill: var(--pele-accent, #4F378B); opacity: 0; offset-rotate: 0deg; }
19
-
20
- @media (prefers-reduced-motion: reduce) {
21
- .pa-ready * { animation: none !important; }
22
- .pa-ready .pele-node, .pa-ready .pele-edge-label, .pa-ready .pele-lifeline, .pa-ready .pele-marker { opacity: 1 !important; }
23
- .pa-ready .pele-edge > path { stroke-dasharray: none; stroke-dashoffset: 0 !important; }
24
- }
25
-
26
- /* Scene frame: tinted panel like the Lab product cards. Override any --pa-* / --pele-* on the container to retheme. */
27
- :where(.pa-scene) {
28
- --pa-bg: #efeafa; --pa-solid: #4F378B; --pa-light: #e4dcf6;
29
- --pele-bg: transparent; --pele-surface: #fff; --pele-surface-alt: var(--pa-light);
30
- --pele-border: color-mix(in srgb, var(--pa-solid) 40%, transparent);
31
- --pele-line: color-mix(in srgb, var(--pa-solid) 65%, transparent);
32
- --pele-accent: var(--pa-solid); --pele-fg: #1f1a33;
33
- }
34
- .pa-scene { background: var(--pa-bg); border-radius: 16px; padding: 1.25rem; margin: 1.5rem 0; display: flex; justify-content: center; overflow-x: auto; cursor: pointer; }
35
- @media (max-width: 600px) { .pa-scene { padding: .75rem; } }
36
- @media (prefers-color-scheme: dark) {
37
- :where(:root:not([data-theme="light"]) .pa-scene) { --pa-bg: #241d3b; --pa-solid: #b9a6ff; --pa-light: #3a2f5e; --pele-surface: #2f2650; --pele-fg: #ece8ff; }
38
- }
39
- :where(:root[data-theme="dark"] .pa-scene) { --pa-bg: #241d3b; --pa-solid: #b9a6ff; --pa-light: #3a2f5e; --pele-surface: #2f2650; --pele-fg: #ece8ff; }
40
- .pa-scene:focus-visible { outline: 2px solid var(--pa-solid); outline-offset: 2px; }
41
-
42
- /* Big icon nodes: the glyph is the picture, the frame rect goes away. */
43
- .pa-art > rect:first-child { fill: none; stroke: none; }
44
- /* Edge labels sit on the line; give them the panel color so the line doesn't strike through the text. */
45
- .pa-scene .pele-edge-label rect { fill: var(--pa-bg); }
46
-
47
- /* Group and box titles read as headings. */
48
- :where(.pa-scene) .pele-cluster-label { font-weight: 700; }
1
+ /* Draw-in once when scrolled into view; a dot travels each edge while hovered/focused. Idle = finished picture. */
2
+ .pa-ready:not(.pa-in):not(.pa-step) .pele-node,
3
+ .pa-ready:not(.pa-in):not(.pa-step) .pele-edge-label,
4
+ .pa-ready:not(.pa-in):not(.pa-step) .pele-lifeline,
5
+ .pa-ready:not(.pa-in):not(.pa-step) .pele-marker { opacity: 0; }
6
+ .pa-ready:not(.pa-in):not(.pa-step) .pele-edge > path:not(.pele-marker) { stroke-dasharray: var(--len); stroke-dashoffset: var(--len); }
7
+
8
+ /* Keyframes carry the dash, so edges that are dashed in the diagram go back to their own dash pattern afterwards. */
9
+ .pa-ready.pa-in:not(.pa-step) .pele-node,
10
+ .pa-ready.pa-in:not(.pa-step) .pele-edge-label,
11
+ .pa-ready.pa-in:not(.pa-step) .pele-lifeline,
12
+ .pa-ready.pa-in:not(.pa-step) .pele-marker { animation: pa-fade .4s ease-out backwards; animation-delay: var(--d, 0s); }
13
+ .pa-ready.pa-in:not(.pa-step) .pele-edge > path:not(.pele-marker) { animation: pa-draw .4s ease-out backwards; animation-delay: var(--d, 0s); }
14
+ .pa-ready.pa-done:not(.pa-step) * { animation: none !important; }
15
+ @keyframes pa-fade { from { opacity: 0; } }
16
+ @keyframes pa-draw { from { stroke-dasharray: var(--len); stroke-dashoffset: var(--len); } to { stroke-dasharray: var(--len); stroke-dashoffset: 0; } }
17
+
18
+ .pa-dot { fill: var(--pele-accent, #4F378B); opacity: 0; offset-rotate: 0deg; }
19
+
20
+ @media (prefers-reduced-motion: reduce) {
21
+ .pa-ready * { animation: none !important; }
22
+ .pa-ready:not(.pa-step) .pele-node, .pa-ready:not(.pa-step) .pele-edge-label, .pa-ready:not(.pa-step) .pele-lifeline, .pa-ready:not(.pa-step) .pele-marker { opacity: 1 !important; }
23
+ .pa-ready .pele-edge > path { stroke-dasharray: none; stroke-dashoffset: 0 !important; }
24
+ }
25
+
26
+ /* Step mode: elements are shown by step; the ones just revealed fade / draw in. */
27
+ .pa-step .pa-hid { opacity: 0; }
28
+ .pa-step .pa-dot { display: none; }
29
+ .pa-step .pa-pop { animation: pa-fade .4s ease-out backwards; }
30
+ .pa-step .pele-edge > path:not(.pele-marker).pa-pop { animation-name: pa-draw; }
31
+ .pa-step .pele-edge > .pele-marker.pa-pop { animation-delay: .3s; }
32
+
33
+ /* Scene frame: tinted panel like the Lab product cards. Override any --pa-* / --pele-* on the container to retheme. */
34
+ :where(.pa-scene) {
35
+ --pa-bg: #efeafa; --pa-solid: #4F378B; --pa-light: #e4dcf6;
36
+ --pele-bg: transparent; --pele-surface: #fff; --pele-surface-alt: var(--pa-light);
37
+ --pele-border: color-mix(in srgb, var(--pa-solid) 40%, transparent);
38
+ --pele-line: color-mix(in srgb, var(--pa-solid) 65%, transparent);
39
+ --pele-accent: var(--pa-solid); --pele-fg: #1f1a33;
40
+ }
41
+ .pa-scene { background: var(--pa-bg); border-radius: 16px; padding: 1.25rem; margin: 1.5rem 0; display: flex; justify-content: center; overflow-x: auto; cursor: pointer; }
42
+ @media (max-width: 600px) { .pa-scene { padding: .75rem; } }
43
+ @media (prefers-color-scheme: dark) {
44
+ :where(:root:not([data-theme="light"]) .pa-scene) { --pa-bg: #241d3b; --pa-solid: #b9a6ff; --pa-light: #3a2f5e; --pele-surface: #2f2650; --pele-fg: #ece8ff; }
45
+ }
46
+ :where(:root[data-theme="dark"] .pa-scene) { --pa-bg: #241d3b; --pa-solid: #b9a6ff; --pa-light: #3a2f5e; --pele-surface: #2f2650; --pele-fg: #ece8ff; }
47
+ .pa-scene:focus-visible { outline: 2px solid var(--pa-solid); outline-offset: 2px; }
48
+
49
+ /* Big icon nodes: the glyph is the picture, the frame rect goes away. */
50
+ .pa-art > rect:first-child { fill: none; stroke: none; }
51
+ /* Edge labels sit on the line; give them the panel color so the line doesn't strike through the text. */
52
+ .pa-scene .pele-edge-label rect { fill: var(--pa-bg); }
53
+
54
+ /* Group and box titles read as headings. */
55
+ :where(.pa-scene) .pele-cluster-label { font-weight: 700; }
56
+
57
+ /* Step controls: previous | play/pause | next, shown under the diagram in step mode. */
58
+ .pa-scene.pa-stepping { flex-direction: column; align-items: center; gap: .75rem; }
59
+ .pa-controls { display: flex; align-items: center; gap: .125rem; padding: .25rem; border-radius: 999px; cursor: default;
60
+ background: var(--pele-surface); color: var(--pele-fg); border: 1px solid var(--pele-border); font: 500 .8rem/1 system-ui, sans-serif; }
61
+ .pa-controls button { width: 2rem; height: 2rem; padding: 0; border: 0; border-radius: 50%; background: none; color: inherit; font: inherit; font-size: 1rem; cursor: pointer; }
62
+ .pa-controls button:hover:not(:disabled) { background: var(--pele-surface-alt); }
63
+ .pa-controls button:disabled { opacity: .35; cursor: default; }
64
+ .pa-controls button:focus-visible { outline: 2px solid var(--pa-solid); }
65
+ .pa-count { min-width: 4ch; padding: 0 .5rem; text-align: center; font-variant-numeric: tabular-nums; }
package/pele-animate.js CHANGED
@@ -1,184 +1,292 @@
1
- // Post-processor for SVG produced by pele's render()/mount(). Timeline: nodes appear by BFS level, each edge draws from its
2
- // source once the source is up, the target appears as the edge lands. Sequence diagrams: actors, then messages in order.
3
- const NS = 'http://www.w3.org/2000/svg';
4
- const UNIT = 0.8; // seconds per BFS level / message
5
- const MAX_INTRO = 3; // a long diagram squeezes its steps so the intro never runs past about this many seconds
6
- const TOKEN = 0.5; // seconds a token takes to cross an edge
7
- const reduced = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
8
-
9
- // `%% pa: 1=A,B 2=C` pins nodes to steps (1-based); returns Map(nodeId -> level). Everything else keeps its BFS level.
10
- export function parseOrder(source = '') {
11
- const m = source.match(/^\s*%%\s*pa:\s*(.+)$/m);
12
- const levels = new Map();
13
- for (const [, step, ids] of (m?.[1] ?? '').matchAll(/(\d+)\s*=\s*([^\s]+)/g))
14
- for (const id of ids.split(',')) levels.set(id.trim(), Number(step) - 1);
15
- return levels;
16
- }
17
-
18
- function endpoints(edgeId, ids) {
19
- for (const s of ids) for (const d of ids) if (new RegExp(`^L_${s}_${d}_\\d+$`).test(edgeId)) return [s, d];
20
- return null;
21
- }
22
-
23
- function bfs(ids, pairs, pinned) {
24
- const level = new Map(pinned);
25
- const hasIn = new Set(pairs.map(([, d]) => d));
26
- let queue = ids.filter((i) => !hasIn.has(i));
27
- if (!queue.length && ids.length) queue = [ids[0]];
28
- for (const q of queue) if (!level.has(q)) level.set(q, 0);
29
- while (queue.length) {
30
- const next = [];
31
- for (const s of queue)
32
- for (const [a, d] of pairs)
33
- if (a === s && !level.has(d)) { level.set(d, level.get(s) + 1); next.push(d); }
34
- queue = next;
35
- }
36
- for (const i of ids) if (!level.has(i)) level.set(i, 0);
37
- return level;
38
- }
39
-
40
- // Spike: icon nodes sized with `h: 88` get a frame of that size; let the glyph fill it and hide the frame so the glyph is the art.
41
- function fitGlyphs(svg) {
42
- svg.querySelectorAll('.pele-node').forEach((n) => {
43
- const g = n.querySelector(':scope > svg.pele-icon');
44
- const frame = g?.previousElementSibling;
45
- if (!g || frame?.tagName !== 'rect' || +frame.getAttribute('width') < 60) return;
46
- for (const a of ['x', 'y', 'width', 'height']) g.setAttribute(a, frame.getAttribute(a));
47
- n.classList.add('pa-art');
48
- });
49
- }
50
-
51
- // Pele sizes each sequence participant box to its own text, which leaves ragged rows. Give every box in a row the tallest height:
52
- // header boxes grow upward (bottoms stay on the lifeline start), footer boxes grow downward (tops stay on the lifeline end).
53
- function equalizeActors(svg) {
54
- if (!svg.classList.contains('pele-sequence') || svg.dataset.paEqualized) return;
55
- svg.dataset.paEqualized = '1';
56
- for (const footer of [false, true]) {
57
- const boxes = [...svg.querySelectorAll('g.pele-actor-participant')].filter((g) => g.classList.contains('pele-actor-footer') === footer);
58
- const rects = boxes.map((g) => g.querySelector('rect')).filter(Boolean);
59
- if (rects.length < 2) continue;
60
- const num = (r, a) => +r.getAttribute(a);
61
- const heights = rects.map((r) => num(r, 'height'));
62
- const max = Math.max(...heights);
63
- const top = Math.min(...rects.map((r) => num(r, 'y')));
64
- const bottom = Math.max(...rects.map((r) => num(r, 'y') + num(r, 'height')));
65
- boxes.forEach((g, i) => {
66
- const grow = max - heights[i];
67
- if (!grow) return;
68
- rects[i].setAttribute('y', String(footer ? top : bottom - max));
69
- rects[i].setAttribute('height', String(max));
70
- const dy = footer ? grow / 2 : -grow / 2; // re-centre the label
71
- g.querySelectorAll('text, tspan').forEach((t) => {
72
- const y = t.getAttribute('y');
73
- if (y !== null) t.setAttribute('y', String(+y + dy));
74
- });
75
- });
76
- }
77
- }
78
-
79
- export function animate(svg, { hover = svg, source = '' } = {}) {
80
- fitGlyphs(svg);
81
- equalizeActors(svg);
82
- svg.querySelectorAll('.pa-dot').forEach((d) => d.remove());
83
- svg.classList.add('pa-ready');
84
- const nodes = [...svg.querySelectorAll('.pele-node')];
85
- const edges = [...svg.querySelectorAll('.pele-edge')];
86
- const ids = [...new Set(nodes.map((n) => n.dataset.id))];
87
- const set = (el, t) => el?.style.setProperty('--d', `${t}s`);
88
- const times = new Map(); // edge element -> start time
89
-
90
- if (svg.classList.contains('pele-sequence')) {
91
- const u = Math.min(UNIT, MAX_INTRO / Math.max(1, edges.length));
92
- nodes.forEach((n) => set(n, 0));
93
- svg.querySelectorAll('.pele-lifeline').forEach((l) => set(l, 0.2));
94
- edges.forEach((e, i) => times.set(e, 0.5 + i * u));
95
- } else {
96
- const pairs = edges.map((e) => endpoints(e.dataset.id, ids));
97
- const level = bfs(ids, pairs.filter(Boolean), parseOrder(source));
98
- const u = Math.min(UNIT, MAX_INTRO / (Math.max(0, ...level.values()) + 1));
99
- const k = u / UNIT;
100
- nodes.forEach((n) => set(n, level.get(n.dataset.id) * u));
101
- // Start after the source is up, but never so early that the arrow lands before its target appears (pinned steps).
102
- edges.forEach((e, i) => {
103
- const [s, d] = pairs[i] ?? [];
104
- times.set(e, Math.max((level.get(s) ?? 0) * u + 0.3 * k, (level.get(d) ?? 0) * u - 0.5 * k));
105
- });
106
- }
107
-
108
- const labels = new Map([...svg.querySelectorAll('.pele-edge-label')].map((l) => [l.dataset.id, l]));
109
- const dots = [];
110
- for (const [e, t] of times) {
111
- const path = e.querySelector('path:not(.pele-marker)');
112
- if (!path) continue;
113
- path.style.setProperty('--len', path.getTotalLength());
114
- set(path, t);
115
- set(e.querySelector('.pele-marker'), t + 0.3);
116
- set(labels.get(e.dataset.id), t);
117
- const dot = document.createElementNS(NS, 'circle');
118
- dot.setAttribute('class', 'pa-dot');
119
- dot.setAttribute('r', '3');
120
- dot.style.offsetPath = `path("${path.getAttribute('d')}")`;
121
- e.append(dot);
122
- dots.push([dot, t]);
123
- }
124
-
125
- // Hover loop: tokens cross the edges in timeline order, then a pause, then again.
126
- const cycle = Math.max(0, ...times.values()) + TOKEN + 0.8;
127
- let running = [];
128
- const play = () => {
129
- if (reduced() || running.length) return;
130
- running = dots.map(([dot, t]) => {
131
- const a = t / cycle, b = (t + TOKEN) / cycle, e = 0.001;
132
- return dot.animate(
133
- [
134
- { offset: 0, opacity: 0, offsetDistance: '0%' },
135
- { offset: a, opacity: 0, offsetDistance: '0%' },
136
- { offset: a + e, opacity: 1, offsetDistance: '0%' },
137
- { offset: b, opacity: 1, offsetDistance: '100%' },
138
- { offset: b + e, opacity: 0, offsetDistance: '100%' },
139
- { offset: 1, opacity: 0, offsetDistance: '100%' },
140
- ],
141
- { duration: cycle * 1000, iterations: Infinity }
142
- );
143
- });
144
- };
145
- const stop = () => { running.forEach((a) => a.cancel()); running = []; };
146
- hover.addEventListener('pointerenter', play);
147
- hover.addEventListener('pointerleave', stop);
148
- hover.addEventListener('focusin', play);
149
- hover.addEventListener('focusout', stop);
150
-
151
- if (!('IntersectionObserver' in window)) return svg.classList.add('pa-in');
152
- svg.classList.remove('pa-in');
153
- const io = new IntersectionObserver(([en]) => {
154
- if (en.isIntersecting) { svg.classList.add('pa-in'); io.disconnect(); }
155
- }, { threshold: 0.3 });
156
- io.observe(svg);
157
- }
158
-
159
- // mount() re-renders on container resize; re-animate each new <svg>, skipping the intro once it has played.
160
- export function watch(container, source = '') {
161
- let played = false;
162
- const run = () => {
163
- const svg = container.querySelector('svg.pele:not(.pa-ready)');
164
- if (!svg) return;
165
- animate(svg, { hover: container, source });
166
- if (played) svg.classList.add('pa-in', 'pa-done');
167
- else svg.addEventListener('animationend', () => (played = true), { once: true });
168
- };
169
- new MutationObserver(run).observe(container, { childList: true });
170
- container.classList.add('pa-scene');
171
- container.tabIndex = 0;
172
- container.title ||= 'Click to replay';
173
- container.addEventListener('click', () => replay(container.querySelector('svg.pele')));
174
- container.addEventListener('keydown', (e) => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), replay(container.querySelector('svg.pele'))));
175
- run();
176
- }
177
-
178
- // Play the intro again (removing pa-in/pa-done restarts the CSS animations).
179
- export function replay(svg) {
180
- if (!svg || reduced()) return;
181
- svg.classList.remove('pa-in', 'pa-done');
182
- void svg.getBoundingClientRect();
183
- svg.classList.add('pa-in');
184
- }
1
+ // Post-processor for SVG produced by pele's render()/mount(). Timeline: nodes appear by BFS level, each edge draws from its
2
+ // source once the source is up, the target appears as the edge lands. Sequence diagrams: actors, then messages in order.
3
+ const NS = 'http://www.w3.org/2000/svg';
4
+ const UNIT = 0.8; // seconds per BFS level / message
5
+ const MAX_INTRO = 3; // a long diagram squeezes its steps so the intro never runs past about this many seconds
6
+ const TOKEN = 0.5; // seconds a token takes to cross an edge
7
+ const reduced = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
8
+
9
+ // `%% pa: 1=A,B 2=C` pins nodes to steps (1-based); returns Map(nodeId -> level). Everything else keeps its BFS level.
10
+ export function parseOrder(source = '') {
11
+ const m = source.match(/^\s*%%\s*pa:\s*(.+)$/m);
12
+ const levels = new Map();
13
+ for (const [, step, ids] of (m?.[1] ?? '').matchAll(/(\d+)\s*=\s*([^\s]+)/g))
14
+ for (const id of ids.split(',')) levels.set(id.trim(), Number(step) - 1);
15
+ return levels;
16
+ }
17
+
18
+ function endpoints(edgeId, ids) {
19
+ for (const s of ids) for (const d of ids) if (new RegExp(`^L_${s}_${d}_\\d+$`).test(edgeId)) return [s, d];
20
+ return null;
21
+ }
22
+
23
+ function bfs(ids, pairs, pinned) {
24
+ const level = new Map(pinned);
25
+ const hasIn = new Set(pairs.map(([, d]) => d));
26
+ let queue = ids.filter((i) => !hasIn.has(i));
27
+ if (!queue.length && ids.length) queue = [ids[0]];
28
+ for (const q of queue) if (!level.has(q)) level.set(q, 0);
29
+ while (queue.length) {
30
+ const next = [];
31
+ for (const s of queue)
32
+ for (const [a, d] of pairs)
33
+ if (a === s && !level.has(d)) { level.set(d, level.get(s) + 1); next.push(d); }
34
+ queue = next;
35
+ }
36
+ for (const i of ids) if (!level.has(i)) level.set(i, 0);
37
+ return level;
38
+ }
39
+
40
+ // Spike: icon nodes sized with `h: 88` get a frame of that size; let the glyph fill it and hide the frame so the glyph is the art.
41
+ function fitGlyphs(svg) {
42
+ svg.querySelectorAll('.pele-node').forEach((n) => {
43
+ const g = n.querySelector(':scope > svg.pele-icon');
44
+ const frame = g?.previousElementSibling;
45
+ if (!g || frame?.tagName !== 'rect' || +frame.getAttribute('width') < 60) return;
46
+ for (const a of ['x', 'y', 'width', 'height']) g.setAttribute(a, frame.getAttribute(a));
47
+ n.classList.add('pa-art');
48
+ });
49
+ }
50
+
51
+ // Pele sizes each sequence participant box to its own text, which leaves ragged rows. Give every box in a row the tallest height:
52
+ // header boxes grow upward (bottoms stay on the lifeline start), footer boxes grow downward (tops stay on the lifeline end).
53
+ function equalizeActors(svg) {
54
+ if (!svg.classList.contains('pele-sequence') || svg.dataset.paEqualized) return;
55
+ svg.dataset.paEqualized = '1';
56
+ for (const footer of [false, true]) {
57
+ const boxes = [...svg.querySelectorAll('g.pele-actor-participant')].filter((g) => g.classList.contains('pele-actor-footer') === footer);
58
+ const rects = boxes.map((g) => g.querySelector('rect')).filter(Boolean);
59
+ if (rects.length < 2) continue;
60
+ const num = (r, a) => +r.getAttribute(a);
61
+ const heights = rects.map((r) => num(r, 'height'));
62
+ const max = Math.max(...heights);
63
+ const top = Math.min(...rects.map((r) => num(r, 'y')));
64
+ const bottom = Math.max(...rects.map((r) => num(r, 'y') + num(r, 'height')));
65
+ boxes.forEach((g, i) => {
66
+ const grow = max - heights[i];
67
+ if (!grow) return;
68
+ rects[i].setAttribute('y', String(footer ? top : bottom - max));
69
+ rects[i].setAttribute('height', String(max));
70
+ const dy = footer ? grow / 2 : -grow / 2; // re-centre the label
71
+ g.querySelectorAll('text, tspan').forEach((t) => {
72
+ const y = t.getAttribute('y');
73
+ if (y !== null) t.setAttribute('y', String(+y + dy));
74
+ });
75
+ });
76
+ }
77
+ }
78
+
79
+ export function animate(svg, { hover = svg, source = '' } = {}) {
80
+ fitGlyphs(svg);
81
+ equalizeActors(svg);
82
+ svg.querySelectorAll('.pa-dot').forEach((d) => d.remove());
83
+ svg.classList.add('pa-ready');
84
+ const nodes = [...svg.querySelectorAll('.pele-node')];
85
+ const edges = [...svg.querySelectorAll('.pele-edge')];
86
+ const ids = [...new Set(nodes.map((n) => n.dataset.id))];
87
+ const set = (el, t) => el?.style.setProperty('--d', `${t}s`);
88
+ const times = new Map(); // edge element -> start time
89
+ const step = new Map(); // node / edge element -> step index (BFS level, or message number in a sequence)
90
+
91
+ if (svg.classList.contains('pele-sequence')) {
92
+ const u = Math.min(UNIT, MAX_INTRO / Math.max(1, edges.length));
93
+ nodes.forEach((n) => set(n, 0));
94
+ svg.querySelectorAll('.pele-lifeline').forEach((l) => set(l, 0.2));
95
+ edges.forEach((e, i) => { times.set(e, 0.5 + i * u); step.set(e, i + 1); });
96
+ nodes.forEach((n) => step.set(n, 0));
97
+ } else {
98
+ const pairs = edges.map((e) => endpoints(e.dataset.id, ids));
99
+ const level = bfs(ids, pairs.filter(Boolean), parseOrder(source));
100
+ const u = Math.min(UNIT, MAX_INTRO / (Math.max(0, ...level.values()) + 1));
101
+ const k = u / UNIT;
102
+ nodes.forEach((n) => { set(n, level.get(n.dataset.id) * u); step.set(n, level.get(n.dataset.id)); });
103
+ // Start after the source is up, but never so early that the arrow lands before its target appears (pinned steps).
104
+ edges.forEach((e, i) => {
105
+ const [s, d] = pairs[i] ?? [];
106
+ step.set(e, Math.max(level.get(s) ?? 0, level.get(d) ?? 0));
107
+ times.set(e, Math.max((level.get(s) ?? 0) * u + 0.3 * k, (level.get(d) ?? 0) * u - 0.5 * k));
108
+ });
109
+ }
110
+
111
+ const labels = new Map([...svg.querySelectorAll('.pele-edge-label')].map((l) => [l.dataset.id, l]));
112
+
113
+ // Step mode: each step's elements are revealed together; [element, parts that animate in].
114
+ const groups = new Map();
115
+ const add = (n, el, pop = [el]) => el && (groups.get(n) ?? groups.set(n, []).get(n)).push([el, pop]);
116
+ nodes.forEach((n) => add(step.get(n), n));
117
+ svg.querySelectorAll('.pele-lifeline').forEach((l) => add(0, l));
118
+ for (const e of edges) { add(step.get(e), e, [...e.querySelectorAll('path')]); add(step.get(e), labels.get(e.dataset.id)); }
119
+ svg.paSteps = [...groups].sort((a, b) => a[0] - b[0]).map(([, items]) => items);
120
+
121
+ const dots = [];
122
+ for (const [e, t] of times) {
123
+ const path = e.querySelector('path:not(.pele-marker)');
124
+ if (!path) continue;
125
+ path.style.setProperty('--len', path.getTotalLength());
126
+ set(path, t);
127
+ set(e.querySelector('.pele-marker'), t + 0.3);
128
+ set(labels.get(e.dataset.id), t);
129
+ const dot = document.createElementNS(NS, 'circle');
130
+ dot.setAttribute('class', 'pa-dot');
131
+ dot.setAttribute('r', '3');
132
+ dot.style.offsetPath = `path("${path.getAttribute('d')}")`;
133
+ e.append(dot);
134
+ dots.push([dot, t]);
135
+ }
136
+
137
+ // Hover loop: tokens cross the edges in timeline order, then a pause, then again.
138
+ const cycle = Math.max(0, ...times.values()) + TOKEN + 0.8;
139
+ let running = [];
140
+ const play = () => {
141
+ if (reduced() || running.length || svg.classList.contains('pa-step')) return;
142
+ running = dots.map(([dot, t]) => {
143
+ const a = t / cycle, b = (t + TOKEN) / cycle, e = 0.001;
144
+ return dot.animate(
145
+ [
146
+ { offset: 0, opacity: 0, offsetDistance: '0%' },
147
+ { offset: a, opacity: 0, offsetDistance: '0%' },
148
+ { offset: a + e, opacity: 1, offsetDistance: '0%' },
149
+ { offset: b, opacity: 1, offsetDistance: '100%' },
150
+ { offset: b + e, opacity: 0, offsetDistance: '100%' },
151
+ { offset: 1, opacity: 0, offsetDistance: '100%' },
152
+ ],
153
+ { duration: cycle * 1000, iterations: Infinity }
154
+ );
155
+ });
156
+ };
157
+ const stop = () => { running.forEach((a) => a.cancel()); running = []; };
158
+ hover.addEventListener('pointerenter', play);
159
+ hover.addEventListener('pointerleave', stop);
160
+ hover.addEventListener('focusin', play);
161
+ hover.addEventListener('focusout', stop);
162
+
163
+ if (!('IntersectionObserver' in window)) return svg.classList.add('pa-in');
164
+ svg.classList.remove('pa-in');
165
+ const io = new IntersectionObserver(([en]) => {
166
+ if (en.isIntersecting) { svg.classList.add('pa-in'); io.disconnect(); }
167
+ }, { threshold: 0.3 });
168
+ io.observe(svg);
169
+ }
170
+
171
+ const STEP_MS = 1200; // autoplay pace in step mode
172
+
173
+ // Show steps 0..k and hide the rest; `pop` animates the elements of step k.
174
+ function showStep(svg, k, pop) {
175
+ svg.classList.add('pa-step');
176
+ svg.querySelectorAll('.pa-pop').forEach((el) => el.classList.remove('pa-pop'));
177
+ svg.paSteps.forEach((items, i) => items.forEach(([el]) => el.classList.toggle('pa-hid', i > k)));
178
+ void svg.getBoundingClientRect();
179
+ if (pop) svg.paSteps[k].forEach(([, parts]) => parts.forEach((p) => p.classList.add('pa-pop')));
180
+ }
181
+
182
+ function leaveStep(svg) {
183
+ svg.classList.remove('pa-step');
184
+ svg.querySelectorAll('.pa-hid, .pa-pop').forEach((el) => el.classList.remove('pa-hid', 'pa-pop'));
185
+ svg.classList.add('pa-in', 'pa-done');
186
+ }
187
+
188
+ // mount() re-renders on container resize; re-animate each new <svg>, skipping the intro once it has played.
189
+ // Click (or Enter/Space) enters step mode: previous | play/pause | next, Space, ←/→, Esc to leave. `controls: false` keeps click-to-replay.
190
+ export function watch(container, source = '', { controls = true } = {}) {
191
+ let played = false;
192
+ let k = -1; // current step; -1 = not in step mode
193
+ let timer = 0;
194
+ const cur = () => container.querySelector('svg.pele');
195
+ const bar = document.createElement('div');
196
+ bar.className = 'pa-controls';
197
+ bar.setAttribute('role', 'toolbar');
198
+ bar.setAttribute('aria-label', 'Step through the diagram');
199
+ bar.title = ''; // an empty title stops the container's tooltip showing through the gaps
200
+ bar.innerHTML = '<button type="button" data-a="prev" aria-label="Previous step" title="Previous step (\u2190)">\u2039</button>'
201
+ + '<button type="button" data-a="play"></button>'
202
+ + '<button type="button" data-a="next" aria-label="Next step" title="Next step (\u2192)">\u203A</button>'
203
+ + '<span class="pa-count" aria-live="polite" title="Current step"></span>'
204
+ + '<button type="button" data-a="exit" aria-label="Exit step mode" title="Exit step mode (Esc)">\u2715\uFE0E</button>';
205
+ const btn = (a) => bar.querySelector(`[data-a=${a}]`);
206
+ const last = () => (cur()?.paSteps?.length ?? 1) - 1;
207
+ const ui = () => {
208
+ btn('prev').disabled = k <= 0;
209
+ btn('next').disabled = k >= last();
210
+ btn('play').textContent = timer ? '\u275A\u275A' : '\u25B6\uFE0E';
211
+ const label = timer ? 'Pause (Space)' : 'Play (Space)';
212
+ btn('play').setAttribute('aria-label', label);
213
+ btn('play').title = label;
214
+ bar.querySelector('.pa-count').textContent = `${k + 1} / ${last() + 1}`;
215
+ };
216
+ const pause = () => { clearTimeout(timer); timer = 0; };
217
+ const go = (n) => {
218
+ const svg = cur();
219
+ if (!svg?.paSteps) return;
220
+ const to = Math.max(0, Math.min(last(), n));
221
+ showStep(svg, to, to > k);
222
+ k = to;
223
+ ui();
224
+ };
225
+ const tick = () => {
226
+ if (k >= last()) { pause(); return ui(); }
227
+ go(k + 1);
228
+ timer = setTimeout(tick, STEP_MS);
229
+ };
230
+ const toggle = () => {
231
+ if (timer) { pause(); return ui(); }
232
+ if (k >= last()) go(0);
233
+ timer = setTimeout(tick, STEP_MS);
234
+ ui();
235
+ };
236
+ const manual = (n) => { pause(); go(n); };
237
+ const enter = () => {
238
+ played = true;
239
+ container.classList.add('pa-stepping');
240
+ container.append(bar);
241
+ go(0);
242
+ };
243
+ const exit = () => {
244
+ pause();
245
+ k = -1;
246
+ bar.remove();
247
+ container.classList.remove('pa-stepping');
248
+ if (cur()) leaveStep(cur());
249
+ container.focus();
250
+ };
251
+ const actions = { prev: () => manual(k - 1), next: () => manual(k + 1), play: toggle, exit };
252
+ bar.addEventListener('click', (e) => {
253
+ e.stopPropagation(); // else the container's click would re-enter step mode right after Exit
254
+ actions[e.target.closest('button')?.dataset.a]?.();
255
+ });
256
+
257
+ const run = () => {
258
+ if (k >= 0 && !bar.isConnected) container.append(bar); // a re-render may have wiped it
259
+ const svg = container.querySelector('svg.pele:not(.pa-ready)');
260
+ if (!svg) return;
261
+ animate(svg, { hover: container, source });
262
+ if (played) svg.classList.add('pa-in', 'pa-done');
263
+ else svg.addEventListener('animationend', () => (played = true), { once: true });
264
+ if (k >= 0) { k = Math.min(k, last()); showStep(svg, k, false); ui(); }
265
+ };
266
+ new MutationObserver(run).observe(container, { childList: true });
267
+ container.classList.add('pa-scene');
268
+ container.tabIndex = 0;
269
+ container.title ||= controls ? 'Click to step through' : 'Click to replay';
270
+ if (!controls) {
271
+ container.addEventListener('click', () => replay(cur()));
272
+ container.addEventListener('keydown', (e) => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), replay(cur())));
273
+ } else {
274
+ container.addEventListener('click', () => k < 0 && enter());
275
+ container.addEventListener('keydown', (e) => {
276
+ const key = e.key;
277
+ if (e.target.closest('button') && (key === 'Enter' || key === ' ')) return; // native button activation
278
+ if (k < 0) return (key === 'Enter' || key === ' ') && (e.preventDefault(), enter());
279
+ const act = { ' ': toggle, ArrowRight: () => manual(k + 1), ArrowLeft: () => manual(k - 1), Escape: exit }[key];
280
+ if (act) { e.preventDefault(); act(); }
281
+ });
282
+ }
283
+ run();
284
+ }
285
+
286
+ // Play the intro again (removing pa-in/pa-done restarts the CSS animations).
287
+ export function replay(svg) {
288
+ if (!svg || reduced()) return;
289
+ svg.classList.remove('pa-in', 'pa-done');
290
+ void svg.getBoundingClientRect();
291
+ svg.classList.add('pa-in');
292
+ }
@@ -1,28 +1,28 @@
1
- // DoveRunner vocabulary (DRM / forensic watermarking / packaging). Add on top of the generic pack in glyphs.js.
2
- import { registerGlyphs, S, L, W } from './glyphs.js';
3
-
4
- // A row of six video-segment chips; bit 1 = filled, bit 0 = outline.
5
- const chips = (bits) =>
6
- [...bits]
7
- .map((b, i) => {
8
- const x = 1.1 + i * 3.8;
9
- const on = b === '1';
10
- return `<rect x="${x}" y="8" width="2.8" height="8" rx=".6" fill="${on ? S : W}" stroke="${S}"/><text x="${x + 1.4}" y="13.5" font-size="3.4" text-anchor="middle" fill="${on ? W : S}" stroke="none" font-family="monospace">${b}</text>`;
11
- })
12
- .join('');
13
-
14
- registerGlyphs({
15
- package: `<path d="M12 2.5l8.5 4.5v10L12 21.5 3.5 17V7z" fill="${L}" stroke="${S}"/><path d="M3.5 7L12 11.5 20.5 7M12 11.5v10" fill="none" stroke="${S}"/><path d="M8 4.8l8.5 4.5" stroke="${S}"/>`,
16
- token: `<path d="M3 7.5A1.5 1.5 0 0 1 4.5 6h15A1.5 1.5 0 0 1 21 7.5V10a2 2 0 0 0 0 4v2.5a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 16.5V14a2 2 0 0 0 0-4z" fill="${L}" stroke="${S}"/><circle cx="9" cy="12" r="2.2" fill="${S}" stroke="none"/><path d="M13.5 10.5h4.5M13.5 13.5h3" stroke="${S}"/>`,
17
- lock: `<path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5" fill="none" stroke="${S}"/><rect x="5" y="10.5" width="14" height="10" rx="2" fill="${S}" stroke="none"/><circle cx="12" cy="15" r="1.5" fill="${W}" stroke="none"/><path d="M12 15.5v2.2" stroke="${W}"/>`,
18
- video: `<rect x="2" y="6" width="13" height="12" rx="2" fill="${L}" stroke="${S}"/><path d="M15 10.8l6-3.6v9.6l-6-3.6z" fill="${S}" stroke="${S}"/><circle cx="5.6" cy="9.4" r="1" fill="${S}" stroke="none"/>`,
19
- media: `<rect x="2" y="4" width="15" height="11" rx="2" fill="${L}" stroke="${S}"/><path d="M7.6 7.2v5.6l4.6-2.8z" fill="${S}" stroke="none"/><ellipse cx="14.8" cy="19" rx="2.3" ry="1.8" fill="${S}" stroke="none"/><path d="M17 19V9.5q3 .2 3.6 3.4" fill="none" stroke="${S}"/>`,
20
- storage: `<ellipse cx="12" cy="6" rx="8" ry="2.6" fill="${L}" stroke="${S}"/><path d="M4 6l2.4 13.4c.4 1.3 3 2.1 5.6 2.1s5.2-.8 5.6-2.1L20 6" fill="${L}" stroke="${S}"/><path d="M4.6 9.6c1.6 1.2 4.2 1.8 7.4 1.8s5.8-.6 7.4-1.8" fill="none" stroke="${S}"/>`,
21
- plugin: `<path d="M4 8h4.5a2.2 2.2 0 1 1 4.4 0H17v4.1a2.2 2.2 0 1 1 0 4.4V20H4z" fill="${L}" stroke="${S}"/><path d="M17 8h3v12h-3" fill="none" stroke="${S}"/>`,
22
- variants: `<rect x="2.5" y="3" width="14" height="9.5" rx="2" fill="${L}" stroke="${S}"/><rect x="7.5" y="11.5" width="14" height="9.5" rx="2" fill="${W}" stroke="${S}"/><text x="9.5" y="10.4" font-size="6.5" font-weight="700" text-anchor="middle" fill="${S}" stroke="none">A</text><text x="14.5" y="19" font-size="6.5" font-weight="700" text-anchor="middle" fill="${S}" stroke="none">B</text>`,
23
- watermark: `<rect x="2.5" y="4" width="19" height="16" rx="2" fill="${L}" stroke="${S}"/><path d="M12 7.4c2.9 3.3 4.2 5 4.2 7a4.2 4.2 0 0 1-8.4 0c0-2 1.3-3.7 4.2-7z" fill="${S}" stroke="none"/><path d="M10.2 14.6a1.9 1.9 0 0 0 1.4 1.9" fill="none" stroke="${W}"/>`,
24
- encoder: `<rect x="1.5" y="4" width="9" height="16" rx="1.5" fill="${L}" stroke="${S}"/><rect x="4.3" y="7" width="3.4" height="10" rx=".6" fill="${W}" stroke="${S}"/><path d="M2.9 6.5h.9M2.9 10.5h.9M2.9 14.5h.9M8.2 6.5h.9M8.2 10.5h.9M8.2 14.5h.9" stroke="${S}"/><path d="M12 12h3.5M14 10l2 2-2 2" fill="none" stroke="${S}"/><rect x="17.6" y="6.2" width="2.4" height="2.8" rx=".5" fill="${S}" stroke="${S}"/><rect x="20.4" y="6.2" width="2.4" height="2.8" rx=".5" fill="${W}" stroke="${S}"/><rect x="17.6" y="10.6" width="2.4" height="2.8" rx=".5" fill="${W}" stroke="${S}"/><rect x="20.4" y="10.6" width="2.4" height="2.8" rx=".5" fill="${S}" stroke="${S}"/><rect x="17.6" y="15" width="2.4" height="2.8" rx=".5" fill="${S}" stroke="${S}"/><rect x="20.4" y="15" width="2.4" height="2.8" rx=".5" fill="${S}" stroke="${S}"/>`,
25
- database: `<path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6" fill="${L}" stroke="${S}"/><ellipse cx="12" cy="6" rx="8" ry="3" fill="${L}" stroke="${S}"/><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" fill="none" stroke="${S}"/>`,
26
- });
27
- // Thinner line: the chips are small even at full box size.
28
- registerGlyphs({ 'bits-a': chips('011010'), 'bits-b': chips('101101') }, 0.5);
1
+ // DoveRunner vocabulary (DRM / forensic watermarking / packaging). Add on top of the generic pack in glyphs.js.
2
+ import { registerGlyphs, S, L, W } from './glyphs.js';
3
+
4
+ // A row of six video-segment chips; bit 1 = filled, bit 0 = outline.
5
+ const chips = (bits) =>
6
+ [...bits]
7
+ .map((b, i) => {
8
+ const x = 1.1 + i * 3.8;
9
+ const on = b === '1';
10
+ return `<rect x="${x}" y="8" width="2.8" height="8" rx=".6" fill="${on ? S : W}" stroke="${S}"/><text x="${x + 1.4}" y="13.5" font-size="3.4" text-anchor="middle" fill="${on ? W : S}" stroke="none" font-family="monospace">${b}</text>`;
11
+ })
12
+ .join('');
13
+
14
+ registerGlyphs({
15
+ package: `<path d="M12 2.5l8.5 4.5v10L12 21.5 3.5 17V7z" fill="${L}" stroke="${S}"/><path d="M3.5 7L12 11.5 20.5 7M12 11.5v10" fill="none" stroke="${S}"/><path d="M8 4.8l8.5 4.5" stroke="${S}"/>`,
16
+ token: `<path d="M3 7.5A1.5 1.5 0 0 1 4.5 6h15A1.5 1.5 0 0 1 21 7.5V10a2 2 0 0 0 0 4v2.5a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 16.5V14a2 2 0 0 0 0-4z" fill="${L}" stroke="${S}"/><circle cx="9" cy="12" r="2.2" fill="${S}" stroke="none"/><path d="M13.5 10.5h4.5M13.5 13.5h3" stroke="${S}"/>`,
17
+ lock: `<path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5" fill="none" stroke="${S}"/><rect x="5" y="10.5" width="14" height="10" rx="2" fill="${S}" stroke="none"/><circle cx="12" cy="15" r="1.5" fill="${W}" stroke="none"/><path d="M12 15.5v2.2" stroke="${W}"/>`,
18
+ video: `<rect x="2" y="6" width="13" height="12" rx="2" fill="${L}" stroke="${S}"/><path d="M15 10.8l6-3.6v9.6l-6-3.6z" fill="${S}" stroke="${S}"/><circle cx="5.6" cy="9.4" r="1" fill="${S}" stroke="none"/>`,
19
+ media: `<rect x="2" y="4" width="15" height="11" rx="2" fill="${L}" stroke="${S}"/><path d="M7.6 7.2v5.6l4.6-2.8z" fill="${S}" stroke="none"/><ellipse cx="14.8" cy="19" rx="2.3" ry="1.8" fill="${S}" stroke="none"/><path d="M17 19V9.5q3 .2 3.6 3.4" fill="none" stroke="${S}"/>`,
20
+ storage: `<ellipse cx="12" cy="6" rx="8" ry="2.6" fill="${L}" stroke="${S}"/><path d="M4 6l2.4 13.4c.4 1.3 3 2.1 5.6 2.1s5.2-.8 5.6-2.1L20 6" fill="${L}" stroke="${S}"/><path d="M4.6 9.6c1.6 1.2 4.2 1.8 7.4 1.8s5.8-.6 7.4-1.8" fill="none" stroke="${S}"/>`,
21
+ plugin: `<path d="M4 8h4.5a2.2 2.2 0 1 1 4.4 0H17v4.1a2.2 2.2 0 1 1 0 4.4V20H4z" fill="${L}" stroke="${S}"/><path d="M17 8h3v12h-3" fill="none" stroke="${S}"/>`,
22
+ variants: `<rect x="2.5" y="3" width="14" height="9.5" rx="2" fill="${L}" stroke="${S}"/><rect x="7.5" y="11.5" width="14" height="9.5" rx="2" fill="${W}" stroke="${S}"/><text x="9.5" y="10.4" font-size="6.5" font-weight="700" text-anchor="middle" fill="${S}" stroke="none">A</text><text x="14.5" y="19" font-size="6.5" font-weight="700" text-anchor="middle" fill="${S}" stroke="none">B</text>`,
23
+ watermark: `<rect x="2.5" y="4" width="19" height="16" rx="2" fill="${L}" stroke="${S}"/><path d="M12 7.4c2.9 3.3 4.2 5 4.2 7a4.2 4.2 0 0 1-8.4 0c0-2 1.3-3.7 4.2-7z" fill="${S}" stroke="none"/><path d="M10.2 14.6a1.9 1.9 0 0 0 1.4 1.9" fill="none" stroke="${W}"/>`,
24
+ encoder: `<rect x="1.5" y="4" width="9" height="16" rx="1.5" fill="${L}" stroke="${S}"/><rect x="4.3" y="7" width="3.4" height="10" rx=".6" fill="${W}" stroke="${S}"/><path d="M2.9 6.5h.9M2.9 10.5h.9M2.9 14.5h.9M8.2 6.5h.9M8.2 10.5h.9M8.2 14.5h.9" stroke="${S}"/><path d="M12 12h3.5M14 10l2 2-2 2" fill="none" stroke="${S}"/><rect x="17.6" y="6.2" width="2.4" height="2.8" rx=".5" fill="${S}" stroke="${S}"/><rect x="20.4" y="6.2" width="2.4" height="2.8" rx=".5" fill="${W}" stroke="${S}"/><rect x="17.6" y="10.6" width="2.4" height="2.8" rx=".5" fill="${W}" stroke="${S}"/><rect x="20.4" y="10.6" width="2.4" height="2.8" rx=".5" fill="${S}" stroke="${S}"/><rect x="17.6" y="15" width="2.4" height="2.8" rx=".5" fill="${S}" stroke="${S}"/><rect x="20.4" y="15" width="2.4" height="2.8" rx=".5" fill="${S}" stroke="${S}"/>`,
25
+ database: `<path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6" fill="${L}" stroke="${S}"/><ellipse cx="12" cy="6" rx="8" ry="3" fill="${L}" stroke="${S}"/><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" fill="none" stroke="${S}"/>`,
26
+ });
27
+ // Thinner line: the chips are small even at full box size.
28
+ registerGlyphs({ 'bits-a': chips('011010'), 'bits-b': chips('101101') }, 0.5);