@strategicprojects/rpic 0.10.0 → 0.11.1
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 +29 -0
- package/index.d.ts +8 -60
- package/index.js +4 -367
- package/package.json +7 -1
- package/pkg/rpic_wasm_bg.wasm +0 -0
- package/pkg/rpic_wasm_math_bg.wasm +0 -0
- package/player.d.ts +63 -0
- package/player.js +375 -0
package/README.md
CHANGED
|
@@ -16,10 +16,39 @@ document.querySelector('#stage').innerHTML = svg;
|
|
|
16
16
|
import { gsap } from 'gsap';
|
|
17
17
|
rpic.animate(document.querySelector('#stage'), animations, gsap);
|
|
18
18
|
|
|
19
|
+
// pre-rendered SVG (e.g. from `rpic --json` on the CLI)? Import just the
|
|
20
|
+
// player — a zero-import module that never touches the wasm compiler:
|
|
21
|
+
// import { animate } from '@strategicprojects/rpic/player';
|
|
22
|
+
|
|
19
23
|
// circuit library:
|
|
20
24
|
rpic.renderSvg('A:(0,0); B:(2,0)\nresistor(A,B)', { circuits: true });
|
|
21
25
|
```
|
|
22
26
|
|
|
27
|
+
### No bundler? Plain HTML via CDN
|
|
28
|
+
|
|
29
|
+
The package works straight from a CDN — compile in the page, or pre-render
|
|
30
|
+
with the CLI (`rpic --json fig.pic`) and import only the player (`animate()`
|
|
31
|
+
never touches the wasm):
|
|
32
|
+
|
|
33
|
+
```html
|
|
34
|
+
<div id="stage"></div>
|
|
35
|
+
<script type="module">
|
|
36
|
+
import { ready, compile, animate } from
|
|
37
|
+
'https://cdn.jsdelivr.net/npm/@strategicprojects/rpic@0.11.0/index.js';
|
|
38
|
+
import { gsap } from 'https://cdn.jsdelivr.net/npm/gsap@3.13.0/+esm';
|
|
39
|
+
await ready(); // the .wasm is fetched from the CDN
|
|
40
|
+
const { svg, animations } = compile('box "A"; arrow; box "B"\nanimate last box with "pop"');
|
|
41
|
+
const stage = document.querySelector('#stage');
|
|
42
|
+
stage.innerHTML = svg;
|
|
43
|
+
animate(stage, animations, gsap);
|
|
44
|
+
</script>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The [animate docs](https://rpic.dev/docs/extensions/animate#quick-start--plain-html-no-build-step)
|
|
48
|
+
carry the full recipe, including the pre-rendered variant with classic
|
|
49
|
+
`<script>` tags (with SRI hashes) and the plugin files `move`/`morph`/
|
|
50
|
+
`scramble`/`wiggle` need.
|
|
51
|
+
|
|
23
52
|
### Node
|
|
24
53
|
|
|
25
54
|
```js
|
package/index.d.ts
CHANGED
|
@@ -1,39 +1,11 @@
|
|
|
1
1
|
// Type definitions for rpic
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
duration: number;
|
|
10
|
-
/** replay count; -1 loops forever. Present only when set. */
|
|
11
|
-
repeat?: number;
|
|
12
|
-
/** reverse on each repeat. Present only when set. */
|
|
13
|
-
yoyo?: boolean;
|
|
14
|
-
/** GSAP easing name overriding the effect's default. Present only when set. */
|
|
15
|
-
ease?: string;
|
|
16
|
-
/** id of the object whose path a `move` follows. Present only for `move`. */
|
|
17
|
-
path?: string;
|
|
18
|
-
/** target colour for `highlight` (`#rrggbb` or a name). Present only when set. */
|
|
19
|
-
color?: string;
|
|
20
|
-
/** play the effect as an exit (reverse) instead of an entrance. */
|
|
21
|
-
out?: boolean;
|
|
22
|
-
/** entry direction for `slide` ("up" | "down" | "left" | "right"). */
|
|
23
|
-
from?: string;
|
|
24
|
-
/** id of the shape a `morph` tweens into. Present only for `morph`. */
|
|
25
|
-
morph?: string;
|
|
26
|
-
/** `type` reveal granularity: "word" splits by word, absent means by char. */
|
|
27
|
-
unit?: string;
|
|
28
|
-
/** custom scramble charset for `scramble` (`by "…"`). Present only when set. */
|
|
29
|
-
chars?: string;
|
|
30
|
-
/** oscillation count for `wiggle` (`wiggles <n>`). Present only when set. */
|
|
31
|
-
wiggles?: number;
|
|
32
|
-
/** `draw` reveal start as a stroke fraction (`from 40%` → 0.4). Present only when set. */
|
|
33
|
-
drawFrom?: number;
|
|
34
|
-
/** `draw` reveal end as a stroke fraction (`to 60%` → 0.6). Present only when set. */
|
|
35
|
-
drawTo?: number;
|
|
36
|
-
}
|
|
3
|
+
// The player (animate/interactive + Anim/Interaction) is typed in player.d.ts
|
|
4
|
+
// — its module has no wasm dependency and is importable on its own via the
|
|
5
|
+
// `./player` subpath. Re-exported here for the one-stop API.
|
|
6
|
+
import type { Anim, Interaction } from './player';
|
|
7
|
+
export type { Anim, Interaction } from './player';
|
|
8
|
+
export { animate, interactive } from './player';
|
|
37
9
|
|
|
38
10
|
export interface Diagnostic {
|
|
39
11
|
message: string;
|
|
@@ -60,6 +32,8 @@ export interface ObjectGeometry {
|
|
|
60
32
|
kind: string;
|
|
61
33
|
/** bounds in SVG user units (the viewBox space); `null` for invisible shapes */
|
|
62
34
|
bbox: { x: number; y: number; w: number; h: number } | null;
|
|
35
|
+
/** hyperlink URL from the `link` extension. Present only when set. */
|
|
36
|
+
link?: string;
|
|
63
37
|
/** 1-based source position of the statement that drew it, when known */
|
|
64
38
|
line?: number;
|
|
65
39
|
col?: number;
|
|
@@ -69,18 +43,6 @@ export interface ObjectGeometry {
|
|
|
69
43
|
file?: string;
|
|
70
44
|
}
|
|
71
45
|
|
|
72
|
-
export interface Interaction {
|
|
73
|
-
id: string;
|
|
74
|
-
/** currently always "drag" */
|
|
75
|
-
kind: string;
|
|
76
|
-
/** throw with momentum (needs InertiaPlugin). Present only when set. */
|
|
77
|
-
inertia?: boolean;
|
|
78
|
-
/** id of the shape whose box constrains dragging. Present only when set. */
|
|
79
|
-
bounds?: string;
|
|
80
|
-
/** axis lock: "x" | "y". Present only when set (absent = free drag). */
|
|
81
|
-
axis?: string;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
46
|
export interface Bundle {
|
|
85
47
|
svg: string;
|
|
86
48
|
animations: Anim[];
|
|
@@ -144,17 +106,3 @@ export function compile(src: string, opts?: CompileOptions): Bundle;
|
|
|
144
106
|
|
|
145
107
|
/** Compile and return only the SVG string. */
|
|
146
108
|
export function renderSvg(src: string, opts?: CompileOptions): string;
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Build and play a GSAP timeline from an animation manifest on the SVG inside
|
|
150
|
-
* `root`. Browser-only.
|
|
151
|
-
*/
|
|
152
|
-
export function animate(root: Element, animations: Anim[], gsap: unknown): unknown;
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Make objects draggable from the `interactions` manifest (the `draggable`
|
|
156
|
-
* directive). Pass GSAP's `Draggable` class (register it — and `InertiaPlugin`
|
|
157
|
-
* if any interaction uses `inertia`). Returns the created Draggable instances.
|
|
158
|
-
* Browser-only.
|
|
159
|
-
*/
|
|
160
|
-
export function interactive(root: Element, interactions: Interaction[], Draggable: unknown): unknown[];
|
package/index.js
CHANGED
|
@@ -91,370 +91,7 @@ export function renderSvg(src, opts) {
|
|
|
91
91
|
return compile(src, opts).svg;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
* @param {Array<{id:string,effect:string,start:number,duration:number,repeat?:number,yoyo?:boolean,ease?:string,path?:string,color?:string,out?:boolean,from?:string,morph?:string}>} animations
|
|
99
|
-
* @param {*} gsap the GSAP instance (register MotionPathPlugin for `move`, MorphSVGPlugin for `morph`)
|
|
100
|
-
* @returns the GSAP timeline
|
|
101
|
-
*/
|
|
102
|
-
export function animate(root, animations, gsap) {
|
|
103
|
-
const tl = gsap.timeline();
|
|
104
|
-
// GSAP's MotionPath/MorphSVG plugins need real <path> elements, but rpic
|
|
105
|
-
// emits <line>/<rect>/<circle>/<polygon>. Convert the shapes those effects
|
|
106
|
-
// reference to <path> up front — before any tween captures element refs, so
|
|
107
|
-
// a `draw` on the same shape traces the path instead of a detached primitive.
|
|
108
|
-
preconvertGeometry(root, animations);
|
|
109
|
-
for (const a of animations) {
|
|
110
|
-
const sel =
|
|
111
|
-
typeof CSS !== 'undefined' && CSS.escape ? '#' + CSS.escape(a.id) : `[id="${a.id}"]`;
|
|
112
|
-
const el = root.querySelector(sel);
|
|
113
|
-
if (!el) continue;
|
|
114
|
-
switch (a.effect) {
|
|
115
|
-
case 'fade':
|
|
116
|
-
enterExit(tl, el, withOverrides({ opacity: 0, duration: a.duration, ease: 'power1.out' }, a), a);
|
|
117
|
-
break;
|
|
118
|
-
case 'pop':
|
|
119
|
-
enterExit(
|
|
120
|
-
tl,
|
|
121
|
-
el,
|
|
122
|
-
withOverrides({ scale: 0, transformOrigin: '50% 50%', duration: a.duration, ease: 'back.out(1.7)' }, a),
|
|
123
|
-
a
|
|
124
|
-
);
|
|
125
|
-
break;
|
|
126
|
-
case 'slide':
|
|
127
|
-
slideIn(tl, el, a);
|
|
128
|
-
break;
|
|
129
|
-
case 'draw':
|
|
130
|
-
drawOn(el, a, tl);
|
|
131
|
-
break;
|
|
132
|
-
case 'move':
|
|
133
|
-
moveAlong(root, el, a, tl);
|
|
134
|
-
break;
|
|
135
|
-
case 'morph':
|
|
136
|
-
morphInto(root, el, a, tl);
|
|
137
|
-
break;
|
|
138
|
-
case 'highlight':
|
|
139
|
-
highlightWith(el, a, tl);
|
|
140
|
-
break;
|
|
141
|
-
case 'type':
|
|
142
|
-
typeReveal(el, a, tl);
|
|
143
|
-
break;
|
|
144
|
-
case 'scramble':
|
|
145
|
-
scrambleReveal(el, a, tl);
|
|
146
|
-
break;
|
|
147
|
-
case 'wiggle':
|
|
148
|
-
wiggleShake(el, a, tl);
|
|
149
|
-
break;
|
|
150
|
-
default:
|
|
151
|
-
enterExit(tl, el, withOverrides({ opacity: 0, duration: a.duration }, a), a);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
return tl;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Make objects draggable from the compile bundle's `interactions` array (the
|
|
159
|
-
* `draggable` directive). Pass GSAP's `Draggable` class (import it and
|
|
160
|
-
* `registerPlugin(Draggable)` — and `InertiaPlugin` if any interaction uses
|
|
161
|
-
* `inertia`). Returns the created Draggable instances.
|
|
162
|
-
* @param {Element} root
|
|
163
|
-
* @param {Array<{id:string,kind:string,inertia?:boolean,bounds?:string,axis?:string}>} interactions
|
|
164
|
-
* @param {*} Draggable
|
|
165
|
-
* @returns {Array}
|
|
166
|
-
*/
|
|
167
|
-
export function interactive(root, interactions, Draggable) {
|
|
168
|
-
if (!Draggable || !interactions || !interactions.length) return [];
|
|
169
|
-
const pick = (id) =>
|
|
170
|
-
root.querySelector(
|
|
171
|
-
typeof CSS !== 'undefined' && CSS.escape ? '#' + CSS.escape(id) : `[id="${id}"]`
|
|
172
|
-
);
|
|
173
|
-
const out = [];
|
|
174
|
-
for (const it of interactions) {
|
|
175
|
-
const el = pick(it.id);
|
|
176
|
-
if (!el) continue;
|
|
177
|
-
const vars = { type: it.axis || 'x,y', inertia: !!it.inertia };
|
|
178
|
-
if (it.bounds) {
|
|
179
|
-
const b = pick(it.bounds);
|
|
180
|
-
if (b) vars.bounds = b;
|
|
181
|
-
}
|
|
182
|
-
out.push(...Draggable.create(el, vars));
|
|
183
|
-
}
|
|
184
|
-
return out;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Entrances tween FROM the hidden `vars` to the natural state; `out` reverses
|
|
188
|
-
// it into an exit (TO the hidden state). Shared by fade/pop/slide.
|
|
189
|
-
function enterExit(tl, el, vars, a) {
|
|
190
|
-
return a.out ? tl.to(el, vars, a.start) : tl.from(el, vars, a.start);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// The `slide` effect: enter (or, with `out`, leave) by translating from a
|
|
194
|
-
// compass direction, offset by the element's own extent so it clears its slot.
|
|
195
|
-
function slideIn(tl, el, a) {
|
|
196
|
-
let bb = { width: 0, height: 0 };
|
|
197
|
-
try {
|
|
198
|
-
bb = el.getBBox();
|
|
199
|
-
} catch {
|
|
200
|
-
/* not yet laid out */
|
|
201
|
-
}
|
|
202
|
-
const off = {
|
|
203
|
-
left: { x: -(bb.width * 1.5 || 40) },
|
|
204
|
-
right: { x: bb.width * 1.5 || 40 },
|
|
205
|
-
up: { y: -(bb.height * 1.5 || 40) },
|
|
206
|
-
down: { y: bb.height * 1.5 || 40 },
|
|
207
|
-
}[a.from || 'left'];
|
|
208
|
-
enterExit(tl, el, withOverrides({ ...off, opacity: 0, duration: a.duration, ease: 'power2.out' }, a), a);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// The `type` effect: reveal a label a glyph (or word) at a time — a
|
|
212
|
-
// typewriter. The emitter split the label into `.rpic-ch` tspans; stagger
|
|
213
|
-
// their opacity so the whole reveal spans the effect's duration (each unit
|
|
214
|
-
// fades over one stagger step, the last finishing at `duration`). `out`
|
|
215
|
-
// reverses it into a staggered fade-out.
|
|
216
|
-
function typeReveal(el, a, tl) {
|
|
217
|
-
const units = el.querySelectorAll('.rpic-ch');
|
|
218
|
-
if (!units.length) return;
|
|
219
|
-
const step = a.duration / units.length;
|
|
220
|
-
const vars = withOverrides({ opacity: 0, duration: step, stagger: step, ease: 'none' }, a);
|
|
221
|
-
return a.out ? tl.to(units, vars, a.start) : tl.from(units, vars, a.start);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// The `scramble` effect: the label's glyphs cycle through random characters and
|
|
225
|
-
// resolve to the real text — a decode reveal. Needs GSAP's ScrambleTextPlugin
|
|
226
|
-
// registered. `{original}` tells the plugin to scramble the element's existing
|
|
227
|
-
// text, so no capture is needed; `out` scrambles it away to nothing.
|
|
228
|
-
function scrambleReveal(el, a, tl) {
|
|
229
|
-
const text = el.querySelector('text');
|
|
230
|
-
if (!text) return;
|
|
231
|
-
const chars = a.chars || 'upperCase';
|
|
232
|
-
const target = a.out ? '' : '{original}';
|
|
233
|
-
const vars = withOverrides(
|
|
234
|
-
{ duration: a.duration, scrambleText: { text: target, chars, speed: 1 }, ease: 'none' },
|
|
235
|
-
a
|
|
236
|
-
);
|
|
237
|
-
return tl.to(text, vars, a.start);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// The `wiggle` effect: a quick oscillating shake that returns to rest, to draw
|
|
241
|
-
// the eye to an object without moving it. Uses GSAP's CustomWiggle ease via its
|
|
242
|
-
// string syntax `wiggle(n)` (register CustomWiggle first); the tween rotates
|
|
243
|
-
// about the element's own centre and the ease settles it back to 0. An explicit
|
|
244
|
-
// `ease` overrides the wiggle.
|
|
245
|
-
function wiggleShake(el, a, tl) {
|
|
246
|
-
const n = a.wiggles || 6;
|
|
247
|
-
const vars = withOverrides(
|
|
248
|
-
{ rotation: 8, transformOrigin: '50% 50%', duration: a.duration, ease: `wiggle(${n})` },
|
|
249
|
-
a
|
|
250
|
-
);
|
|
251
|
-
return tl.fromTo(el, { rotation: 0 }, vars, a.start);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Fold the optional GSAP overrides (repeat/yoyo/ease) into a tween's vars.
|
|
255
|
-
// `ease` replaces the effect's default easing; repeat/yoyo loop the tween.
|
|
256
|
-
function withOverrides(vars, a) {
|
|
257
|
-
if (a.repeat) vars.repeat = a.repeat;
|
|
258
|
-
if (a.yoyo) vars.yoyo = true;
|
|
259
|
-
if (a.ease) vars.ease = a.ease;
|
|
260
|
-
return vars;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
264
|
-
|
|
265
|
-
// Build SVG path data (`d`) for the primitives rpic emits. GSAP's MotionPath
|
|
266
|
-
// and MorphSVG need a real <path>; passing a raw <line>/<rect>/<circle> throws
|
|
267
|
-
// "Expecting a <path> element or an SVG path data string".
|
|
268
|
-
function pathDataOf(el) {
|
|
269
|
-
const n = el.tagName.toLowerCase();
|
|
270
|
-
const f = (a) => parseFloat(el.getAttribute(a)) || 0;
|
|
271
|
-
if (n === 'path') return el.getAttribute('d');
|
|
272
|
-
if (n === 'line') return `M${f('x1')},${f('y1')} L${f('x2')},${f('y2')}`;
|
|
273
|
-
if (n === 'polyline' || n === 'polygon') {
|
|
274
|
-
const nums = (el.getAttribute('points') || '').trim().split(/[\s,]+/).map(Number);
|
|
275
|
-
if (nums.length < 4) return null;
|
|
276
|
-
let d = '';
|
|
277
|
-
for (let i = 0; i + 1 < nums.length; i += 2) d += `${i ? 'L' : 'M'}${nums[i]},${nums[i + 1]} `;
|
|
278
|
-
return n === 'polygon' ? d + 'Z' : d.trim();
|
|
279
|
-
}
|
|
280
|
-
if (n === 'rect') return `M${f('x')},${f('y')} h${f('width')} v${f('height')} h${-f('width')} Z`;
|
|
281
|
-
if (n === 'circle') {
|
|
282
|
-
const r = f('r'), cx = f('cx'), cy = f('cy');
|
|
283
|
-
return `M${cx - r},${cy} a${r},${r} 0 1,0 ${2 * r},0 a${r},${r} 0 1,0 ${-2 * r},0 Z`;
|
|
284
|
-
}
|
|
285
|
-
if (n === 'ellipse') {
|
|
286
|
-
const rx = f('rx'), ry = f('ry'), cx = f('cx'), cy = f('cy');
|
|
287
|
-
return `M${cx - rx},${cy} a${rx},${ry} 0 1,0 ${2 * rx},0 a${rx},${ry} 0 1,0 ${-2 * rx},0 Z`;
|
|
288
|
-
}
|
|
289
|
-
return null;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// Replace a primitive SVG element with an equivalent <path> (preserving paint),
|
|
293
|
-
// in place. Idempotent: a <path> is returned untouched.
|
|
294
|
-
function convertToPath(el) {
|
|
295
|
-
if (!el || el.tagName.toLowerCase() === 'path') return el;
|
|
296
|
-
const d = pathDataOf(el);
|
|
297
|
-
if (!d || !el.parentNode || typeof document === 'undefined') return el;
|
|
298
|
-
const p = document.createElementNS(SVG_NS, 'path');
|
|
299
|
-
p.setAttribute('d', d);
|
|
300
|
-
for (const attr of el.attributes) {
|
|
301
|
-
if (!/^(x1|y1|x2|y2|points|x|y|width|height|cx|cy|r|rx|ry)$/.test(attr.name)) {
|
|
302
|
-
p.setAttribute(attr.name, attr.value);
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
el.parentNode.replaceChild(p, el);
|
|
306
|
-
return p;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// Convert every shape a `move`/`morph` references to a <path> before any tween
|
|
310
|
-
// captures element references (so a concurrent `draw` traces the path, not a
|
|
311
|
-
// now-detached primitive).
|
|
312
|
-
function preconvertGeometry(root, animations) {
|
|
313
|
-
const ALL = 'path, polyline, line, rect, circle, ellipse, polygon';
|
|
314
|
-
const pick = (id, sel) => {
|
|
315
|
-
if (!id) return;
|
|
316
|
-
const g = root.querySelector(`[id="${id}"]`);
|
|
317
|
-
const prim = g && g.querySelector(sel);
|
|
318
|
-
if (prim) convertToPath(prim);
|
|
319
|
-
};
|
|
320
|
-
for (const a of animations || []) {
|
|
321
|
-
if (a.effect === 'move') pick(a.path, 'path, polyline, line');
|
|
322
|
-
if (a.effect === 'morph') {
|
|
323
|
-
pick(a.id, ALL);
|
|
324
|
-
pick(a.morph, ALL);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// The `highlight` effect: emphasise `el`. With a target colour, recolour and
|
|
330
|
-
// thicken its outline AND give the whole object a small scale pulse, so the
|
|
331
|
-
// emphasis reads at a glance rather than a bare colour swap; without a colour,
|
|
332
|
-
// a colour-free scale pulse. One-directional `.to()` — pair with `repeat 1
|
|
333
|
-
// yoyo` for a flash-and-return, or `repeat -1 yoyo` for a continuous pulse.
|
|
334
|
-
function highlightWith(el, a, tl) {
|
|
335
|
-
if (a.color) {
|
|
336
|
-
const shapes = el.querySelectorAll('path, polyline, line, rect, circle, ellipse, polygon');
|
|
337
|
-
tl.to(
|
|
338
|
-
shapes,
|
|
339
|
-
withOverrides({ stroke: a.color, strokeWidth: '+=2', duration: a.duration, ease: 'power1.inOut' }, a),
|
|
340
|
-
a.start
|
|
341
|
-
);
|
|
342
|
-
tl.to(
|
|
343
|
-
el,
|
|
344
|
-
withOverrides({ scale: 1.1, transformOrigin: '50% 50%', duration: a.duration, ease: 'power1.inOut' }, a),
|
|
345
|
-
a.start
|
|
346
|
-
);
|
|
347
|
-
} else {
|
|
348
|
-
tl.to(
|
|
349
|
-
el,
|
|
350
|
-
withOverrides({ scale: 1.12, transformOrigin: '50% 50%', duration: a.duration, ease: 'power1.inOut' }, a),
|
|
351
|
-
a.start
|
|
352
|
-
);
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// The `morph` effect: tween `el`'s outline into the shape of the object
|
|
357
|
-
// `a.morph` references, via GSAP's MorphSVGPlugin. The consumer must have
|
|
358
|
-
// registered it (`gsap.registerPlugin(MorphSVGPlugin)`); without it GSAP
|
|
359
|
-
// no-ops the morphSVG and the shape stays put. Both source and target were
|
|
360
|
-
// converted to <path> by preconvertGeometry.
|
|
361
|
-
function morphInto(root, el, a, tl) {
|
|
362
|
-
if (!a.morph) return;
|
|
363
|
-
const tsel =
|
|
364
|
-
typeof CSS !== 'undefined' && CSS.escape ? '#' + CSS.escape(a.morph) : `[id="${a.morph}"]`;
|
|
365
|
-
const target = root.querySelector(tsel);
|
|
366
|
-
const src = el.querySelector('path');
|
|
367
|
-
const dst = target && target.querySelector('path');
|
|
368
|
-
if (!src || !dst) return;
|
|
369
|
-
tl.to(src, withOverrides({ morphSVG: dst, duration: a.duration, ease: 'power1.inOut' }, a), a.start);
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// The `move` effect: travel `el` along the geometry of the object `a.path`
|
|
373
|
-
// references, via GSAP's MotionPathPlugin. The consumer must have registered
|
|
374
|
-
// it (`gsap.registerPlugin(MotionPathPlugin)`); without it GSAP no-ops the
|
|
375
|
-
// motionPath and the object simply stays put.
|
|
376
|
-
function moveAlong(root, el, a, tl) {
|
|
377
|
-
if (!a.path) return;
|
|
378
|
-
const psel =
|
|
379
|
-
typeof CSS !== 'undefined' && CSS.escape ? '#' + CSS.escape(a.path) : `[id="${a.path}"]`;
|
|
380
|
-
const group = root.querySelector(psel);
|
|
381
|
-
// The shaft was converted to a <path> by preconvertGeometry; skip the
|
|
382
|
-
// arrowhead <polygon> — we want the line the traveller rides, not the tip.
|
|
383
|
-
const pathEl = group && group.querySelector('path');
|
|
384
|
-
if (!pathEl) return;
|
|
385
|
-
// `align` maps the path into the traveller's coordinate space; a `move`
|
|
386
|
-
// tween is a `.to()` (current position → along the path), not a `.from()`.
|
|
387
|
-
tl.to(
|
|
388
|
-
el,
|
|
389
|
-
withOverrides(
|
|
390
|
-
{
|
|
391
|
-
motionPath: { path: pathEl, align: pathEl, alignOrigin: [0.5, 0.5], autoRotate: false },
|
|
392
|
-
duration: a.duration,
|
|
393
|
-
ease: 'none',
|
|
394
|
-
},
|
|
395
|
-
a
|
|
396
|
-
),
|
|
397
|
-
a.start
|
|
398
|
-
);
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
function drawOn(group, a, tl) {
|
|
402
|
-
// `draw from 40% to 60%` reveals only a sub-segment; absent ends default to
|
|
403
|
-
// 0 and 1. A range switches the trace to a dash-window animation (no plugin).
|
|
404
|
-
const rangeStart = a.drawFrom != null ? a.drawFrom : 0;
|
|
405
|
-
const rangeEnd = a.drawTo != null ? a.drawTo : 1;
|
|
406
|
-
const hasRange = a.drawFrom != null || a.drawTo != null;
|
|
407
|
-
const els = group.querySelectorAll('path, polyline, line, rect, circle, ellipse, polygon');
|
|
408
|
-
els.forEach((el) => {
|
|
409
|
-
// Filled, unstroked elements (arrowheads) can't be dash-traced — pop
|
|
410
|
-
// them in as the shaft reaches the tip instead. A partial draw that stops
|
|
411
|
-
// short of the end never reaches the tip, so leave the arrowhead hidden.
|
|
412
|
-
const fill = el.getAttribute('fill');
|
|
413
|
-
if (el.getAttribute('stroke-width') === '0' || (fill && fill !== 'none' && !el.getAttribute('stroke'))) {
|
|
414
|
-
if (hasRange && rangeEnd < 1) return;
|
|
415
|
-
tl.from(
|
|
416
|
-
el,
|
|
417
|
-
{ opacity: 0, scale: 0, transformOrigin: '50% 50%', duration: Math.min(0.2, a.duration * 0.4), ease: 'back.out(1.7)' },
|
|
418
|
-
a.start + a.duration * 0.8
|
|
419
|
-
);
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
let len = 0;
|
|
423
|
-
try {
|
|
424
|
-
len = el.getTotalLength();
|
|
425
|
-
} catch {
|
|
426
|
-
len = 0;
|
|
427
|
-
}
|
|
428
|
-
if (len > 0 && hasRange) {
|
|
429
|
-
// Reveal only [p0,p1] of the stroke by growing a dash window: a leading
|
|
430
|
-
// gap of p0, then a dash that stretches from 0 to (p1-p0). `out` retracts
|
|
431
|
-
// it back to nothing. dasharray `[0, p0, w, len]` shows exactly [p0,p0+w].
|
|
432
|
-
const p0 = rangeStart * len;
|
|
433
|
-
const p1 = rangeEnd * len;
|
|
434
|
-
const [w0, w1] = a.out ? [p1 - p0, 0] : [0, p1 - p0];
|
|
435
|
-
const set = (w) => el.setAttribute('stroke-dasharray', `0 ${p0} ${Math.max(0, w)} ${len}`);
|
|
436
|
-
set(w0);
|
|
437
|
-
const proxy = { w: w0 };
|
|
438
|
-
tl.to(
|
|
439
|
-
proxy,
|
|
440
|
-
withOverrides({ w: w1, duration: a.duration, ease: 'none', onUpdate: () => set(proxy.w) }, a),
|
|
441
|
-
a.start
|
|
442
|
-
);
|
|
443
|
-
} else if (len > 0) {
|
|
444
|
-
// `out` reverses the trace: the stroke retracts (dashoffset 0 → len).
|
|
445
|
-
const [begin, end] = a.out ? [0, len] : [len, 0];
|
|
446
|
-
tl.fromTo(
|
|
447
|
-
el,
|
|
448
|
-
{ strokeDasharray: len, strokeDashoffset: begin },
|
|
449
|
-
withOverrides({ strokeDashoffset: end, duration: a.duration, ease: 'none' }, a),
|
|
450
|
-
a.start
|
|
451
|
-
);
|
|
452
|
-
} else {
|
|
453
|
-
enterExit(tl, el, withOverrides({ opacity: 0, duration: a.duration }, a), a);
|
|
454
|
-
}
|
|
455
|
-
});
|
|
456
|
-
const texts = group.querySelectorAll('text');
|
|
457
|
-
if (texts.length) {
|
|
458
|
-
tl.from(texts, { opacity: 0, duration: a.duration * 0.6 }, a.start + a.duration * 0.4);
|
|
459
|
-
}
|
|
460
|
-
}
|
|
94
|
+
// The GSAP player lives in player.js — a zero-import module, so consumers
|
|
95
|
+
// with pre-rendered SVG can `import { animate } from '…/player.js'` and pull
|
|
96
|
+
// in nothing wasm-related. Re-exported here to keep the one-stop API.
|
|
97
|
+
export { animate, interactive } from './player.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@strategicprojects/rpic",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "The pic graphics language compiled to SVG with animation manifests, via WebAssembly. Browser + Node.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
@@ -11,12 +11,18 @@
|
|
|
11
11
|
"types": "./index.d.ts",
|
|
12
12
|
"default": "./index.js"
|
|
13
13
|
},
|
|
14
|
+
"./player": {
|
|
15
|
+
"types": "./player.d.ts",
|
|
16
|
+
"default": "./player.js"
|
|
17
|
+
},
|
|
14
18
|
"./pkg/rpic_wasm_bg.wasm": "./pkg/rpic_wasm_bg.wasm",
|
|
15
19
|
"./pkg/rpic_wasm_math_bg.wasm": "./pkg/rpic_wasm_math_bg.wasm"
|
|
16
20
|
},
|
|
17
21
|
"files": [
|
|
18
22
|
"index.js",
|
|
19
23
|
"index.d.ts",
|
|
24
|
+
"player.js",
|
|
25
|
+
"player.d.ts",
|
|
20
26
|
"pkg/rpic_wasm.js",
|
|
21
27
|
"pkg/rpic_wasm.d.ts",
|
|
22
28
|
"pkg/rpic_wasm_bg.wasm",
|
package/pkg/rpic_wasm_bg.wasm
CHANGED
|
Binary file
|
|
Binary file
|
package/player.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Type definitions for the standalone rpic player (player.js — zero imports,
|
|
2
|
+
// no wasm). `animate`/`interactive` are also re-exported by the package root.
|
|
3
|
+
|
|
4
|
+
export interface Anim {
|
|
5
|
+
id: string;
|
|
6
|
+
/** "fade" | "pop" | "draw" | "slide" | "move" | "highlight" | "morph" | "type" | "scramble" | "wiggle" */
|
|
7
|
+
effect: string;
|
|
8
|
+
/** absolute start time in seconds */
|
|
9
|
+
start: number;
|
|
10
|
+
duration: number;
|
|
11
|
+
/** replay count; -1 loops forever. Present only when set. */
|
|
12
|
+
repeat?: number;
|
|
13
|
+
/** reverse on each repeat. Present only when set. */
|
|
14
|
+
yoyo?: boolean;
|
|
15
|
+
/** GSAP easing name overriding the effect's default. Present only when set. */
|
|
16
|
+
ease?: string;
|
|
17
|
+
/** id of the object whose path a `move` follows. Present only for `move`. */
|
|
18
|
+
path?: string;
|
|
19
|
+
/** target colour for `highlight` (`#rrggbb` or a name). Present only when set. */
|
|
20
|
+
color?: string;
|
|
21
|
+
/** play the effect as an exit (reverse) instead of an entrance. */
|
|
22
|
+
out?: boolean;
|
|
23
|
+
/** entry direction for `slide` ("up" | "down" | "left" | "right"). */
|
|
24
|
+
from?: string;
|
|
25
|
+
/** id of the shape a `morph` tweens into. Present only for `morph`. */
|
|
26
|
+
morph?: string;
|
|
27
|
+
/** `type` reveal granularity: "word" splits by word, absent means by char. */
|
|
28
|
+
unit?: string;
|
|
29
|
+
/** custom scramble charset for `scramble` (`by "…"`). Present only when set. */
|
|
30
|
+
chars?: string;
|
|
31
|
+
/** oscillation count for `wiggle` (`wiggles <n>`). Present only when set. */
|
|
32
|
+
wiggles?: number;
|
|
33
|
+
/** `draw` reveal start as a stroke fraction (`from 40%` → 0.4). Present only when set. */
|
|
34
|
+
drawFrom?: number;
|
|
35
|
+
/** `draw` reveal end as a stroke fraction (`to 60%` → 0.6). Present only when set. */
|
|
36
|
+
drawTo?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Interaction {
|
|
40
|
+
id: string;
|
|
41
|
+
/** currently always "drag" */
|
|
42
|
+
kind: string;
|
|
43
|
+
/** throw with momentum (needs InertiaPlugin). Present only when set. */
|
|
44
|
+
inertia?: boolean;
|
|
45
|
+
/** id of the shape whose box constrains dragging. Present only when set. */
|
|
46
|
+
bounds?: string;
|
|
47
|
+
/** axis lock: "x" | "y". Present only when set (absent = free drag). */
|
|
48
|
+
axis?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build and play a GSAP timeline from an animation manifest on the SVG inside
|
|
53
|
+
* `root`. Browser-only.
|
|
54
|
+
*/
|
|
55
|
+
export function animate(root: Element, animations: Anim[], gsap: unknown): unknown;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Make objects draggable from the `interactions` manifest (the `draggable`
|
|
59
|
+
* directive). Pass GSAP's `Draggable` class (register it — and `InertiaPlugin`
|
|
60
|
+
* if any interaction uses `inertia`). Returns the created Draggable instances.
|
|
61
|
+
* Browser-only.
|
|
62
|
+
*/
|
|
63
|
+
export function interactive(root: Element, interactions: Interaction[], Draggable: unknown): unknown[];
|
package/player.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// rpic player — the GSAP timeline/interaction builder, standalone.
|
|
2
|
+
//
|
|
3
|
+
// This module has ZERO imports: it never touches the wasm compiler, so a
|
|
4
|
+
// page that pre-renders with the CLI (`rpic --json`) can import just this
|
|
5
|
+
// file (e.g. straight from a CDN) and fetch nothing wasm-related.
|
|
6
|
+
// `animate()`/`interactive()` only read the DOM + the manifest and drive the
|
|
7
|
+
// GSAP instance you pass in. Re-exported by index.js, so
|
|
8
|
+
// `import { animate } from '@strategicprojects/rpic'` keeps working.
|
|
9
|
+
/**
|
|
10
|
+
* Build a GSAP timeline from a drawing's animation manifest and play it on the
|
|
11
|
+
* SVG inside `root`. Browser-only (needs the DOM and a GSAP instance).
|
|
12
|
+
* @param {Element} root container holding the injected SVG
|
|
13
|
+
* @param {Array<{id:string,effect:string,start:number,duration:number,repeat?:number,yoyo?:boolean,ease?:string,path?:string,color?:string,out?:boolean,from?:string,morph?:string}>} animations
|
|
14
|
+
* @param {*} gsap the GSAP instance (register MotionPathPlugin for `move`, MorphSVGPlugin for `morph`)
|
|
15
|
+
* @returns the GSAP timeline
|
|
16
|
+
*/
|
|
17
|
+
export function animate(root, animations, gsap) {
|
|
18
|
+
const tl = gsap.timeline();
|
|
19
|
+
// GSAP's MotionPath/MorphSVG plugins need real <path> elements, but rpic
|
|
20
|
+
// emits <line>/<rect>/<circle>/<polygon>. Convert the shapes those effects
|
|
21
|
+
// reference to <path> up front — before any tween captures element refs, so
|
|
22
|
+
// a `draw` on the same shape traces the path instead of a detached primitive.
|
|
23
|
+
preconvertGeometry(root, animations);
|
|
24
|
+
for (const a of animations) {
|
|
25
|
+
const sel =
|
|
26
|
+
typeof CSS !== 'undefined' && CSS.escape ? '#' + CSS.escape(a.id) : `[id="${a.id}"]`;
|
|
27
|
+
const el = root.querySelector(sel);
|
|
28
|
+
if (!el) continue;
|
|
29
|
+
switch (a.effect) {
|
|
30
|
+
case 'fade':
|
|
31
|
+
enterExit(tl, el, withOverrides({ opacity: 0, duration: a.duration, ease: 'power1.out' }, a), a);
|
|
32
|
+
break;
|
|
33
|
+
case 'pop':
|
|
34
|
+
enterExit(
|
|
35
|
+
tl,
|
|
36
|
+
el,
|
|
37
|
+
withOverrides({ scale: 0, transformOrigin: '50% 50%', duration: a.duration, ease: 'back.out(1.7)' }, a),
|
|
38
|
+
a
|
|
39
|
+
);
|
|
40
|
+
break;
|
|
41
|
+
case 'slide':
|
|
42
|
+
slideIn(tl, el, a);
|
|
43
|
+
break;
|
|
44
|
+
case 'draw':
|
|
45
|
+
drawOn(el, a, tl);
|
|
46
|
+
break;
|
|
47
|
+
case 'move':
|
|
48
|
+
moveAlong(root, el, a, tl);
|
|
49
|
+
break;
|
|
50
|
+
case 'morph':
|
|
51
|
+
morphInto(root, el, a, tl);
|
|
52
|
+
break;
|
|
53
|
+
case 'highlight':
|
|
54
|
+
highlightWith(el, a, tl);
|
|
55
|
+
break;
|
|
56
|
+
case 'type':
|
|
57
|
+
typeReveal(el, a, tl);
|
|
58
|
+
break;
|
|
59
|
+
case 'scramble':
|
|
60
|
+
scrambleReveal(el, a, tl);
|
|
61
|
+
break;
|
|
62
|
+
case 'wiggle':
|
|
63
|
+
wiggleShake(el, a, tl);
|
|
64
|
+
break;
|
|
65
|
+
default:
|
|
66
|
+
enterExit(tl, el, withOverrides({ opacity: 0, duration: a.duration }, a), a);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return tl;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Make objects draggable from the compile bundle's `interactions` array (the
|
|
74
|
+
* `draggable` directive). Pass GSAP's `Draggable` class (import it and
|
|
75
|
+
* `registerPlugin(Draggable)` — and `InertiaPlugin` if any interaction uses
|
|
76
|
+
* `inertia`). Returns the created Draggable instances.
|
|
77
|
+
* @param {Element} root
|
|
78
|
+
* @param {Array<{id:string,kind:string,inertia?:boolean,bounds?:string,axis?:string}>} interactions
|
|
79
|
+
* @param {*} Draggable
|
|
80
|
+
* @returns {Array}
|
|
81
|
+
*/
|
|
82
|
+
export function interactive(root, interactions, Draggable) {
|
|
83
|
+
if (!Draggable || !interactions || !interactions.length) return [];
|
|
84
|
+
const pick = (id) =>
|
|
85
|
+
root.querySelector(
|
|
86
|
+
typeof CSS !== 'undefined' && CSS.escape ? '#' + CSS.escape(id) : `[id="${id}"]`
|
|
87
|
+
);
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const it of interactions) {
|
|
90
|
+
const el = pick(it.id);
|
|
91
|
+
if (!el) continue;
|
|
92
|
+
const vars = { type: it.axis || 'x,y', inertia: !!it.inertia };
|
|
93
|
+
if (it.bounds) {
|
|
94
|
+
const b = pick(it.bounds);
|
|
95
|
+
if (b) vars.bounds = b;
|
|
96
|
+
}
|
|
97
|
+
out.push(...Draggable.create(el, vars));
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Entrances tween FROM the hidden `vars` to the natural state; `out` reverses
|
|
103
|
+
// it into an exit (TO the hidden state). Shared by fade/pop/slide.
|
|
104
|
+
function enterExit(tl, el, vars, a) {
|
|
105
|
+
return a.out ? tl.to(el, vars, a.start) : tl.from(el, vars, a.start);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The `slide` effect: enter (or, with `out`, leave) by translating from a
|
|
109
|
+
// compass direction, offset by the element's own extent so it clears its slot.
|
|
110
|
+
function slideIn(tl, el, a) {
|
|
111
|
+
let bb = { width: 0, height: 0 };
|
|
112
|
+
try {
|
|
113
|
+
bb = el.getBBox();
|
|
114
|
+
} catch {
|
|
115
|
+
/* not yet laid out */
|
|
116
|
+
}
|
|
117
|
+
const off = {
|
|
118
|
+
left: { x: -(bb.width * 1.5 || 40) },
|
|
119
|
+
right: { x: bb.width * 1.5 || 40 },
|
|
120
|
+
up: { y: -(bb.height * 1.5 || 40) },
|
|
121
|
+
down: { y: bb.height * 1.5 || 40 },
|
|
122
|
+
}[a.from || 'left'];
|
|
123
|
+
enterExit(tl, el, withOverrides({ ...off, opacity: 0, duration: a.duration, ease: 'power2.out' }, a), a);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The `type` effect: reveal a label a glyph (or word) at a time — a
|
|
127
|
+
// typewriter. The emitter split the label into `.rpic-ch` tspans; stagger
|
|
128
|
+
// their opacity so the whole reveal spans the effect's duration (each unit
|
|
129
|
+
// fades over one stagger step, the last finishing at `duration`). `out`
|
|
130
|
+
// reverses it into a staggered fade-out.
|
|
131
|
+
function typeReveal(el, a, tl) {
|
|
132
|
+
const units = el.querySelectorAll('.rpic-ch');
|
|
133
|
+
if (!units.length) return;
|
|
134
|
+
const step = a.duration / units.length;
|
|
135
|
+
const vars = withOverrides({ opacity: 0, duration: step, stagger: step, ease: 'none' }, a);
|
|
136
|
+
return a.out ? tl.to(units, vars, a.start) : tl.from(units, vars, a.start);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The `scramble` effect: the label's glyphs cycle through random characters and
|
|
140
|
+
// resolve to the real text — a decode reveal. Needs GSAP's ScrambleTextPlugin
|
|
141
|
+
// registered. `{original}` tells the plugin to scramble the element's existing
|
|
142
|
+
// text, so no capture is needed; `out` scrambles it away to nothing.
|
|
143
|
+
function scrambleReveal(el, a, tl) {
|
|
144
|
+
const text = el.querySelector('text');
|
|
145
|
+
if (!text) return;
|
|
146
|
+
const chars = a.chars || 'upperCase';
|
|
147
|
+
const target = a.out ? '' : '{original}';
|
|
148
|
+
const vars = withOverrides(
|
|
149
|
+
{ duration: a.duration, scrambleText: { text: target, chars, speed: 1 }, ease: 'none' },
|
|
150
|
+
a
|
|
151
|
+
);
|
|
152
|
+
return tl.to(text, vars, a.start);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The `wiggle` effect: a quick oscillating shake that returns to rest, to draw
|
|
156
|
+
// the eye to an object without moving it. Uses GSAP's CustomWiggle ease via its
|
|
157
|
+
// string syntax `wiggle(n)` (register CustomWiggle first); the tween rotates
|
|
158
|
+
// about the element's own centre and the ease settles it back to 0. An explicit
|
|
159
|
+
// `ease` overrides the wiggle.
|
|
160
|
+
function wiggleShake(el, a, tl) {
|
|
161
|
+
const n = a.wiggles || 6;
|
|
162
|
+
const vars = withOverrides(
|
|
163
|
+
{ rotation: 8, transformOrigin: '50% 50%', duration: a.duration, ease: `wiggle(${n})` },
|
|
164
|
+
a
|
|
165
|
+
);
|
|
166
|
+
return tl.fromTo(el, { rotation: 0 }, vars, a.start);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Fold the optional GSAP overrides (repeat/yoyo/ease) into a tween's vars.
|
|
170
|
+
// `ease` replaces the effect's default easing; repeat/yoyo loop the tween.
|
|
171
|
+
function withOverrides(vars, a) {
|
|
172
|
+
if (a.repeat) vars.repeat = a.repeat;
|
|
173
|
+
if (a.yoyo) vars.yoyo = true;
|
|
174
|
+
if (a.ease) vars.ease = a.ease;
|
|
175
|
+
return vars;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
179
|
+
|
|
180
|
+
// Build SVG path data (`d`) for the primitives rpic emits. GSAP's MotionPath
|
|
181
|
+
// and MorphSVG need a real <path>; passing a raw <line>/<rect>/<circle> throws
|
|
182
|
+
// "Expecting a <path> element or an SVG path data string".
|
|
183
|
+
function pathDataOf(el) {
|
|
184
|
+
const n = el.tagName.toLowerCase();
|
|
185
|
+
const f = (a) => parseFloat(el.getAttribute(a)) || 0;
|
|
186
|
+
if (n === 'path') return el.getAttribute('d');
|
|
187
|
+
if (n === 'line') return `M${f('x1')},${f('y1')} L${f('x2')},${f('y2')}`;
|
|
188
|
+
if (n === 'polyline' || n === 'polygon') {
|
|
189
|
+
const nums = (el.getAttribute('points') || '').trim().split(/[\s,]+/).map(Number);
|
|
190
|
+
if (nums.length < 4) return null;
|
|
191
|
+
let d = '';
|
|
192
|
+
for (let i = 0; i + 1 < nums.length; i += 2) d += `${i ? 'L' : 'M'}${nums[i]},${nums[i + 1]} `;
|
|
193
|
+
return n === 'polygon' ? d + 'Z' : d.trim();
|
|
194
|
+
}
|
|
195
|
+
if (n === 'rect') return `M${f('x')},${f('y')} h${f('width')} v${f('height')} h${-f('width')} Z`;
|
|
196
|
+
if (n === 'circle') {
|
|
197
|
+
const r = f('r'), cx = f('cx'), cy = f('cy');
|
|
198
|
+
return `M${cx - r},${cy} a${r},${r} 0 1,0 ${2 * r},0 a${r},${r} 0 1,0 ${-2 * r},0 Z`;
|
|
199
|
+
}
|
|
200
|
+
if (n === 'ellipse') {
|
|
201
|
+
const rx = f('rx'), ry = f('ry'), cx = f('cx'), cy = f('cy');
|
|
202
|
+
return `M${cx - rx},${cy} a${rx},${ry} 0 1,0 ${2 * rx},0 a${rx},${ry} 0 1,0 ${-2 * rx},0 Z`;
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Replace a primitive SVG element with an equivalent <path> (preserving paint),
|
|
208
|
+
// in place. Idempotent: a <path> is returned untouched.
|
|
209
|
+
function convertToPath(el) {
|
|
210
|
+
if (!el || el.tagName.toLowerCase() === 'path') return el;
|
|
211
|
+
const d = pathDataOf(el);
|
|
212
|
+
if (!d || !el.parentNode || typeof document === 'undefined') return el;
|
|
213
|
+
const p = document.createElementNS(SVG_NS, 'path');
|
|
214
|
+
p.setAttribute('d', d);
|
|
215
|
+
for (const attr of el.attributes) {
|
|
216
|
+
if (!/^(x1|y1|x2|y2|points|x|y|width|height|cx|cy|r|rx|ry)$/.test(attr.name)) {
|
|
217
|
+
p.setAttribute(attr.name, attr.value);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
el.parentNode.replaceChild(p, el);
|
|
221
|
+
return p;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Convert every shape a `move`/`morph` references to a <path> before any tween
|
|
225
|
+
// captures element references (so a concurrent `draw` traces the path, not a
|
|
226
|
+
// now-detached primitive).
|
|
227
|
+
function preconvertGeometry(root, animations) {
|
|
228
|
+
const ALL = 'path, polyline, line, rect, circle, ellipse, polygon';
|
|
229
|
+
const pick = (id, sel) => {
|
|
230
|
+
if (!id) return;
|
|
231
|
+
const g = root.querySelector(`[id="${id}"]`);
|
|
232
|
+
const prim = g && g.querySelector(sel);
|
|
233
|
+
if (prim) convertToPath(prim);
|
|
234
|
+
};
|
|
235
|
+
for (const a of animations || []) {
|
|
236
|
+
if (a.effect === 'move') pick(a.path, 'path, polyline, line');
|
|
237
|
+
if (a.effect === 'morph') {
|
|
238
|
+
pick(a.id, ALL);
|
|
239
|
+
pick(a.morph, ALL);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// The `highlight` effect: emphasise `el`. With a target colour, recolour and
|
|
245
|
+
// thicken its outline AND give the whole object a small scale pulse, so the
|
|
246
|
+
// emphasis reads at a glance rather than a bare colour swap; without a colour,
|
|
247
|
+
// a colour-free scale pulse. One-directional `.to()` — pair with `repeat 1
|
|
248
|
+
// yoyo` for a flash-and-return, or `repeat -1 yoyo` for a continuous pulse.
|
|
249
|
+
function highlightWith(el, a, tl) {
|
|
250
|
+
if (a.color) {
|
|
251
|
+
const shapes = el.querySelectorAll('path, polyline, line, rect, circle, ellipse, polygon');
|
|
252
|
+
tl.to(
|
|
253
|
+
shapes,
|
|
254
|
+
withOverrides({ stroke: a.color, strokeWidth: '+=2', duration: a.duration, ease: 'power1.inOut' }, a),
|
|
255
|
+
a.start
|
|
256
|
+
);
|
|
257
|
+
tl.to(
|
|
258
|
+
el,
|
|
259
|
+
withOverrides({ scale: 1.1, transformOrigin: '50% 50%', duration: a.duration, ease: 'power1.inOut' }, a),
|
|
260
|
+
a.start
|
|
261
|
+
);
|
|
262
|
+
} else {
|
|
263
|
+
tl.to(
|
|
264
|
+
el,
|
|
265
|
+
withOverrides({ scale: 1.12, transformOrigin: '50% 50%', duration: a.duration, ease: 'power1.inOut' }, a),
|
|
266
|
+
a.start
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// The `morph` effect: tween `el`'s outline into the shape of the object
|
|
272
|
+
// `a.morph` references, via GSAP's MorphSVGPlugin. The consumer must have
|
|
273
|
+
// registered it (`gsap.registerPlugin(MorphSVGPlugin)`); without it GSAP
|
|
274
|
+
// no-ops the morphSVG and the shape stays put. Both source and target were
|
|
275
|
+
// converted to <path> by preconvertGeometry.
|
|
276
|
+
function morphInto(root, el, a, tl) {
|
|
277
|
+
if (!a.morph) return;
|
|
278
|
+
const tsel =
|
|
279
|
+
typeof CSS !== 'undefined' && CSS.escape ? '#' + CSS.escape(a.morph) : `[id="${a.morph}"]`;
|
|
280
|
+
const target = root.querySelector(tsel);
|
|
281
|
+
const src = el.querySelector('path');
|
|
282
|
+
const dst = target && target.querySelector('path');
|
|
283
|
+
if (!src || !dst) return;
|
|
284
|
+
tl.to(src, withOverrides({ morphSVG: dst, duration: a.duration, ease: 'power1.inOut' }, a), a.start);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// The `move` effect: travel `el` along the geometry of the object `a.path`
|
|
288
|
+
// references, via GSAP's MotionPathPlugin. The consumer must have registered
|
|
289
|
+
// it (`gsap.registerPlugin(MotionPathPlugin)`); without it GSAP no-ops the
|
|
290
|
+
// motionPath and the object simply stays put.
|
|
291
|
+
function moveAlong(root, el, a, tl) {
|
|
292
|
+
if (!a.path) return;
|
|
293
|
+
const psel =
|
|
294
|
+
typeof CSS !== 'undefined' && CSS.escape ? '#' + CSS.escape(a.path) : `[id="${a.path}"]`;
|
|
295
|
+
const group = root.querySelector(psel);
|
|
296
|
+
// The shaft was converted to a <path> by preconvertGeometry; skip the
|
|
297
|
+
// arrowhead <polygon> — we want the line the traveller rides, not the tip.
|
|
298
|
+
const pathEl = group && group.querySelector('path');
|
|
299
|
+
if (!pathEl) return;
|
|
300
|
+
// `align` maps the path into the traveller's coordinate space; a `move`
|
|
301
|
+
// tween is a `.to()` (current position → along the path), not a `.from()`.
|
|
302
|
+
tl.to(
|
|
303
|
+
el,
|
|
304
|
+
withOverrides(
|
|
305
|
+
{
|
|
306
|
+
motionPath: { path: pathEl, align: pathEl, alignOrigin: [0.5, 0.5], autoRotate: false },
|
|
307
|
+
duration: a.duration,
|
|
308
|
+
ease: 'none',
|
|
309
|
+
},
|
|
310
|
+
a
|
|
311
|
+
),
|
|
312
|
+
a.start
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function drawOn(group, a, tl) {
|
|
317
|
+
// `draw from 40% to 60%` reveals only a sub-segment; absent ends default to
|
|
318
|
+
// 0 and 1. A range switches the trace to a dash-window animation (no plugin).
|
|
319
|
+
const rangeStart = a.drawFrom != null ? a.drawFrom : 0;
|
|
320
|
+
const rangeEnd = a.drawTo != null ? a.drawTo : 1;
|
|
321
|
+
const hasRange = a.drawFrom != null || a.drawTo != null;
|
|
322
|
+
const els = group.querySelectorAll('path, polyline, line, rect, circle, ellipse, polygon');
|
|
323
|
+
els.forEach((el) => {
|
|
324
|
+
// Filled, unstroked elements (arrowheads) can't be dash-traced — pop
|
|
325
|
+
// them in as the shaft reaches the tip instead. A partial draw that stops
|
|
326
|
+
// short of the end never reaches the tip, so leave the arrowhead hidden.
|
|
327
|
+
const fill = el.getAttribute('fill');
|
|
328
|
+
if (el.getAttribute('stroke-width') === '0' || (fill && fill !== 'none' && !el.getAttribute('stroke'))) {
|
|
329
|
+
if (hasRange && rangeEnd < 1) return;
|
|
330
|
+
tl.from(
|
|
331
|
+
el,
|
|
332
|
+
{ opacity: 0, scale: 0, transformOrigin: '50% 50%', duration: Math.min(0.2, a.duration * 0.4), ease: 'back.out(1.7)' },
|
|
333
|
+
a.start + a.duration * 0.8
|
|
334
|
+
);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
let len = 0;
|
|
338
|
+
try {
|
|
339
|
+
len = el.getTotalLength();
|
|
340
|
+
} catch {
|
|
341
|
+
len = 0;
|
|
342
|
+
}
|
|
343
|
+
if (len > 0 && hasRange) {
|
|
344
|
+
// Reveal only [p0,p1] of the stroke by growing a dash window: a leading
|
|
345
|
+
// gap of p0, then a dash that stretches from 0 to (p1-p0). `out` retracts
|
|
346
|
+
// it back to nothing. dasharray `[0, p0, w, len]` shows exactly [p0,p0+w].
|
|
347
|
+
const p0 = rangeStart * len;
|
|
348
|
+
const p1 = rangeEnd * len;
|
|
349
|
+
const [w0, w1] = a.out ? [p1 - p0, 0] : [0, p1 - p0];
|
|
350
|
+
const set = (w) => el.setAttribute('stroke-dasharray', `0 ${p0} ${Math.max(0, w)} ${len}`);
|
|
351
|
+
set(w0);
|
|
352
|
+
const proxy = { w: w0 };
|
|
353
|
+
tl.to(
|
|
354
|
+
proxy,
|
|
355
|
+
withOverrides({ w: w1, duration: a.duration, ease: 'none', onUpdate: () => set(proxy.w) }, a),
|
|
356
|
+
a.start
|
|
357
|
+
);
|
|
358
|
+
} else if (len > 0) {
|
|
359
|
+
// `out` reverses the trace: the stroke retracts (dashoffset 0 → len).
|
|
360
|
+
const [begin, end] = a.out ? [0, len] : [len, 0];
|
|
361
|
+
tl.fromTo(
|
|
362
|
+
el,
|
|
363
|
+
{ strokeDasharray: len, strokeDashoffset: begin },
|
|
364
|
+
withOverrides({ strokeDashoffset: end, duration: a.duration, ease: 'none' }, a),
|
|
365
|
+
a.start
|
|
366
|
+
);
|
|
367
|
+
} else {
|
|
368
|
+
enterExit(tl, el, withOverrides({ opacity: 0, duration: a.duration }, a), a);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
const texts = group.querySelectorAll('text');
|
|
372
|
+
if (texts.length) {
|
|
373
|
+
tl.from(texts, { opacity: 0, duration: a.duration * 0.6 }, a.start + a.duration * 0.4);
|
|
374
|
+
}
|
|
375
|
+
}
|