pele-animate 0.0.0-stage → 0.9.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/LICENSE +21 -0
- package/README.md +72 -2
- package/glyphs.js +22 -0
- package/package.json +20 -4
- package/pele-animate.css +45 -0
- package/pele-animate.js +155 -0
- package/preset-doverunner.js +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Kim
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,73 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pele-animate
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
- Idle state is the finished picture; `prefers-reduced-motion` turns the motion off. Light and dark themes.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm i pele-animate pele
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Use
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
import { mount } from 'pele';
|
|
23
|
+
import { watch } from 'pele-animate';
|
|
24
|
+
import { icons } from 'pele-animate/glyphs';
|
|
25
|
+
import 'pele-animate/preset-doverunner'; // optional extra glyphs
|
|
26
|
+
import 'pele-animate/style.css';
|
|
27
|
+
|
|
28
|
+
for (const el of document.querySelectorAll('pre.mermaid')) {
|
|
29
|
+
const src = el.textContent;
|
|
30
|
+
mount(el, src, { icons });
|
|
31
|
+
watch(el, src); // re-applies after Pele re-lays-out on resize
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Mermaid source:
|
|
36
|
+
|
|
37
|
+
```mermaid
|
|
38
|
+
flowchart LR
|
|
39
|
+
%% pa: 1=P 2=O 3=D
|
|
40
|
+
P@{ icon: "player", form: "square", h: 88, label: "Player" }
|
|
41
|
+
O@{ icon: "server", form: "square", h: 88, label: "OTT service" }
|
|
42
|
+
D@{ icon: "key", form: "square", h: 88, label: "DRM server" }
|
|
43
|
+
P <-->|auth token| O
|
|
44
|
+
P <-->|token / license| D
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`form: "square", h: 88` makes a large frameless glyph; leave both out for a small icon in a frame.
|
|
48
|
+
`%% pa: 1=P 2=O 3=D` pins nodes to steps (optional). Sequence diagrams animate actors, then messages in order.
|
|
49
|
+
|
|
50
|
+
## API
|
|
51
|
+
|
|
52
|
+
| Export | |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `watch(container, source?)` | Animate the SVG inside `container` (and every re-render of it); adds the scene panel, click and Enter replay |
|
|
55
|
+
| `animate(svg, { hover, source })` | Animate one SVG; you handle re-renders |
|
|
56
|
+
| `replay(svg)` | Play the intro again |
|
|
57
|
+
| `parseOrder(source)` | Read the `%% pa:` pins |
|
|
58
|
+
| `registerGlyphs(pack, strokeWidth = 1.1)` (`/glyphs`) | Add glyphs: `{ name: '<inner markup of a 24x24 svg>' }`; colors via `--pa-solid` / `--pa-light` |
|
|
59
|
+
| `icons` (`/glyphs`) | Pele `icons` resolver |
|
|
60
|
+
|
|
61
|
+
Generic glyphs: `player`, `server`, `key`, `document`, `shield`, `phone`. `preset-doverunner`: `package`, `token`, `lock`,
|
|
62
|
+
`database`, `bits-a`, `bits-b`.
|
|
63
|
+
|
|
64
|
+
## Theme
|
|
65
|
+
|
|
66
|
+
`watch()` adds class `pa-scene` to the container. Override `--pa-bg`, `--pa-solid`, `--pa-light` or any `--pele-*`
|
|
67
|
+
variable on it; defaults are `:where()` rules, so your stylesheet wins.
|
|
68
|
+
|
|
69
|
+
## Limits
|
|
70
|
+
|
|
71
|
+
- Relies on Pele's SVG structure (`.pele-node`, `.pele-edge`, `data-id`); `pele` is a peer dependency pinned to `^1.7.0`.
|
|
72
|
+
- Flowcharts and sequence diagrams only. No types yet.
|
|
73
|
+
- Glyph art is square-boxed; wide art is drawn as a wide glyph inside the square.
|
package/glyphs.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pele-animate",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.9.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
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
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); }
|
package/pele-animate.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
export function animate(svg, { hover = svg, source = '' } = {}) {
|
|
52
|
+
fitGlyphs(svg);
|
|
53
|
+
svg.querySelectorAll('.pa-dot').forEach((d) => d.remove());
|
|
54
|
+
svg.classList.add('pa-ready');
|
|
55
|
+
const nodes = [...svg.querySelectorAll('.pele-node')];
|
|
56
|
+
const edges = [...svg.querySelectorAll('.pele-edge')];
|
|
57
|
+
const ids = [...new Set(nodes.map((n) => n.dataset.id))];
|
|
58
|
+
const set = (el, t) => el?.style.setProperty('--d', `${t}s`);
|
|
59
|
+
const times = new Map(); // edge element -> start time
|
|
60
|
+
|
|
61
|
+
if (svg.classList.contains('pele-sequence')) {
|
|
62
|
+
const u = Math.min(UNIT, MAX_INTRO / Math.max(1, edges.length));
|
|
63
|
+
nodes.forEach((n) => set(n, 0));
|
|
64
|
+
svg.querySelectorAll('.pele-lifeline').forEach((l) => set(l, 0.2));
|
|
65
|
+
edges.forEach((e, i) => times.set(e, 0.5 + i * u));
|
|
66
|
+
} else {
|
|
67
|
+
const pairs = edges.map((e) => endpoints(e.dataset.id, ids));
|
|
68
|
+
const level = bfs(ids, pairs.filter(Boolean), parseOrder(source));
|
|
69
|
+
const u = Math.min(UNIT, MAX_INTRO / (Math.max(0, ...level.values()) + 1));
|
|
70
|
+
const k = u / UNIT;
|
|
71
|
+
nodes.forEach((n) => set(n, level.get(n.dataset.id) * u));
|
|
72
|
+
// Start after the source is up, but never so early that the arrow lands before its target appears (pinned steps).
|
|
73
|
+
edges.forEach((e, i) => {
|
|
74
|
+
const [s, d] = pairs[i] ?? [];
|
|
75
|
+
times.set(e, Math.max((level.get(s) ?? 0) * u + 0.3 * k, (level.get(d) ?? 0) * u - 0.5 * k));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const labels = new Map([...svg.querySelectorAll('.pele-edge-label')].map((l) => [l.dataset.id, l]));
|
|
80
|
+
const dots = [];
|
|
81
|
+
for (const [e, t] of times) {
|
|
82
|
+
const path = e.querySelector('path:not(.pele-marker)');
|
|
83
|
+
if (!path) continue;
|
|
84
|
+
path.style.setProperty('--len', path.getTotalLength());
|
|
85
|
+
set(path, t);
|
|
86
|
+
set(e.querySelector('.pele-marker'), t + 0.3);
|
|
87
|
+
set(labels.get(e.dataset.id), t);
|
|
88
|
+
const dot = document.createElementNS(NS, 'circle');
|
|
89
|
+
dot.setAttribute('class', 'pa-dot');
|
|
90
|
+
dot.setAttribute('r', '3');
|
|
91
|
+
dot.style.offsetPath = `path("${path.getAttribute('d')}")`;
|
|
92
|
+
e.append(dot);
|
|
93
|
+
dots.push([dot, t]);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Hover loop: tokens cross the edges in timeline order, then a pause, then again.
|
|
97
|
+
const cycle = Math.max(0, ...times.values()) + TOKEN + 0.8;
|
|
98
|
+
let running = [];
|
|
99
|
+
const play = () => {
|
|
100
|
+
if (reduced() || running.length) return;
|
|
101
|
+
running = dots.map(([dot, t]) => {
|
|
102
|
+
const a = t / cycle, b = (t + TOKEN) / cycle, e = 0.001;
|
|
103
|
+
return dot.animate(
|
|
104
|
+
[
|
|
105
|
+
{ offset: 0, opacity: 0, offsetDistance: '0%' },
|
|
106
|
+
{ offset: a, opacity: 0, offsetDistance: '0%' },
|
|
107
|
+
{ offset: a + e, opacity: 1, offsetDistance: '0%' },
|
|
108
|
+
{ offset: b, opacity: 1, offsetDistance: '100%' },
|
|
109
|
+
{ offset: b + e, opacity: 0, offsetDistance: '100%' },
|
|
110
|
+
{ offset: 1, opacity: 0, offsetDistance: '100%' },
|
|
111
|
+
],
|
|
112
|
+
{ duration: cycle * 1000, iterations: Infinity }
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
const stop = () => { running.forEach((a) => a.cancel()); running = []; };
|
|
117
|
+
hover.addEventListener('pointerenter', play);
|
|
118
|
+
hover.addEventListener('pointerleave', stop);
|
|
119
|
+
hover.addEventListener('focusin', play);
|
|
120
|
+
hover.addEventListener('focusout', stop);
|
|
121
|
+
|
|
122
|
+
if (!('IntersectionObserver' in window)) return svg.classList.add('pa-in');
|
|
123
|
+
svg.classList.remove('pa-in');
|
|
124
|
+
const io = new IntersectionObserver(([en]) => {
|
|
125
|
+
if (en.isIntersecting) { svg.classList.add('pa-in'); io.disconnect(); }
|
|
126
|
+
}, { threshold: 0.3 });
|
|
127
|
+
io.observe(svg);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// mount() re-renders on container resize; re-animate each new <svg>, skipping the intro once it has played.
|
|
131
|
+
export function watch(container, source = '') {
|
|
132
|
+
let played = false;
|
|
133
|
+
const run = () => {
|
|
134
|
+
const svg = container.querySelector('svg.pele:not(.pa-ready)');
|
|
135
|
+
if (!svg) return;
|
|
136
|
+
animate(svg, { hover: container, source });
|
|
137
|
+
if (played) svg.classList.add('pa-in', 'pa-done');
|
|
138
|
+
else svg.addEventListener('animationend', () => (played = true), { once: true });
|
|
139
|
+
};
|
|
140
|
+
new MutationObserver(run).observe(container, { childList: true });
|
|
141
|
+
container.classList.add('pa-scene');
|
|
142
|
+
container.tabIndex = 0;
|
|
143
|
+
container.title ||= 'Click to replay';
|
|
144
|
+
container.addEventListener('click', () => replay(container.querySelector('svg.pele')));
|
|
145
|
+
container.addEventListener('keydown', (e) => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), replay(container.querySelector('svg.pele'))));
|
|
146
|
+
run();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Play the intro again (removing pa-in/pa-done restarts the CSS animations).
|
|
150
|
+
export function replay(svg) {
|
|
151
|
+
if (!svg || reduced()) return;
|
|
152
|
+
svg.classList.remove('pa-in', 'pa-done');
|
|
153
|
+
void svg.getBoundingClientRect();
|
|
154
|
+
svg.classList.add('pa-in');
|
|
155
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
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}"/>`,
|
|
19
|
+
});
|
|
20
|
+
// Thinner line: the chips are small even at full box size.
|
|
21
|
+
registerGlyphs({ 'bits-a': chips('011010'), 'bits-b': chips('101101') }, 0.5);
|