@strategicprojects/rpic 0.9.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
@@ -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,29 +1,11 @@
1
1
  // Type definitions for rpic
2
2
 
3
- export interface Anim {
4
- id: string;
5
- /** "fade" | "pop" | "draw" | "slide" | "move" | "highlight" | "morph" */
6
- effect: string;
7
- /** absolute start time in seconds */
8
- start: number;
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
- }
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';
27
9
 
28
10
  export interface Diagnostic {
29
11
  message: string;
@@ -50,6 +32,8 @@ export interface ObjectGeometry {
50
32
  kind: string;
51
33
  /** bounds in SVG user units (the viewBox space); `null` for invisible shapes */
52
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;
53
37
  /** 1-based source position of the statement that drew it, when known */
54
38
  line?: number;
55
39
  col?: number;
@@ -62,6 +46,8 @@ export interface ObjectGeometry {
62
46
  export interface Bundle {
63
47
  svg: string;
64
48
  animations: Anim[];
49
+ /** `draggable` targets for the host to wire GSAP Draggable. Present only when non-empty. */
50
+ interactions?: Interaction[];
65
51
  /** lines emitted by pic `print` statements */
66
52
  diagnostics: string[];
67
53
  /** non-fatal compiler warnings for accepted but suspicious input */
@@ -120,9 +106,3 @@ export function compile(src: string, opts?: CompileOptions): Bundle;
120
106
 
121
107
  /** Compile and return only the SVG string. */
122
108
  export function renderSvg(src: string, opts?: CompileOptions): string;
123
-
124
- /**
125
- * Build and play a GSAP timeline from an animation manifest on the SVG inside
126
- * `root`. Browser-only.
127
- */
128
- export function animate(root: Element, animations: Anim[], gsap: unknown): unknown;
package/index.js CHANGED
@@ -91,266 +91,7 @@ export function renderSvg(src, opts) {
91
91
  return compile(src, opts).svg;
92
92
  }
93
93
 
94
- /**
95
- * Build a GSAP timeline from a drawing's animation manifest and play it on the
96
- * SVG inside `root`. Browser-only (needs the DOM and a GSAP instance).
97
- * @param {Element} root container holding the injected SVG
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
- default:
142
- enterExit(tl, el, withOverrides({ opacity: 0, duration: a.duration }, a), a);
143
- }
144
- }
145
- return tl;
146
- }
147
-
148
- // Entrances tween FROM the hidden `vars` to the natural state; `out` reverses
149
- // it into an exit (TO the hidden state). Shared by fade/pop/slide.
150
- function enterExit(tl, el, vars, a) {
151
- return a.out ? tl.to(el, vars, a.start) : tl.from(el, vars, a.start);
152
- }
153
-
154
- // The `slide` effect: enter (or, with `out`, leave) by translating from a
155
- // compass direction, offset by the element's own extent so it clears its slot.
156
- function slideIn(tl, el, a) {
157
- let bb = { width: 0, height: 0 };
158
- try {
159
- bb = el.getBBox();
160
- } catch {
161
- /* not yet laid out */
162
- }
163
- const off = {
164
- left: { x: -(bb.width * 1.5 || 40) },
165
- right: { x: bb.width * 1.5 || 40 },
166
- up: { y: -(bb.height * 1.5 || 40) },
167
- down: { y: bb.height * 1.5 || 40 },
168
- }[a.from || 'left'];
169
- enterExit(tl, el, withOverrides({ ...off, opacity: 0, duration: a.duration, ease: 'power2.out' }, a), a);
170
- }
171
-
172
- // Fold the optional GSAP overrides (repeat/yoyo/ease) into a tween's vars.
173
- // `ease` replaces the effect's default easing; repeat/yoyo loop the tween.
174
- function withOverrides(vars, a) {
175
- if (a.repeat) vars.repeat = a.repeat;
176
- if (a.yoyo) vars.yoyo = true;
177
- if (a.ease) vars.ease = a.ease;
178
- return vars;
179
- }
180
-
181
- const SVG_NS = 'http://www.w3.org/2000/svg';
182
-
183
- // Build SVG path data (`d`) for the primitives rpic emits. GSAP's MotionPath
184
- // and MorphSVG need a real <path>; passing a raw <line>/<rect>/<circle> throws
185
- // "Expecting a <path> element or an SVG path data string".
186
- function pathDataOf(el) {
187
- const n = el.tagName.toLowerCase();
188
- const f = (a) => parseFloat(el.getAttribute(a)) || 0;
189
- if (n === 'path') return el.getAttribute('d');
190
- if (n === 'line') return `M${f('x1')},${f('y1')} L${f('x2')},${f('y2')}`;
191
- if (n === 'polyline' || n === 'polygon') {
192
- const nums = (el.getAttribute('points') || '').trim().split(/[\s,]+/).map(Number);
193
- if (nums.length < 4) return null;
194
- let d = '';
195
- for (let i = 0; i + 1 < nums.length; i += 2) d += `${i ? 'L' : 'M'}${nums[i]},${nums[i + 1]} `;
196
- return n === 'polygon' ? d + 'Z' : d.trim();
197
- }
198
- if (n === 'rect') return `M${f('x')},${f('y')} h${f('width')} v${f('height')} h${-f('width')} Z`;
199
- if (n === 'circle') {
200
- const r = f('r'), cx = f('cx'), cy = f('cy');
201
- return `M${cx - r},${cy} a${r},${r} 0 1,0 ${2 * r},0 a${r},${r} 0 1,0 ${-2 * r},0 Z`;
202
- }
203
- if (n === 'ellipse') {
204
- const rx = f('rx'), ry = f('ry'), cx = f('cx'), cy = f('cy');
205
- return `M${cx - rx},${cy} a${rx},${ry} 0 1,0 ${2 * rx},0 a${rx},${ry} 0 1,0 ${-2 * rx},0 Z`;
206
- }
207
- return null;
208
- }
209
-
210
- // Replace a primitive SVG element with an equivalent <path> (preserving paint),
211
- // in place. Idempotent: a <path> is returned untouched.
212
- function convertToPath(el) {
213
- if (!el || el.tagName.toLowerCase() === 'path') return el;
214
- const d = pathDataOf(el);
215
- if (!d || !el.parentNode || typeof document === 'undefined') return el;
216
- const p = document.createElementNS(SVG_NS, 'path');
217
- p.setAttribute('d', d);
218
- for (const attr of el.attributes) {
219
- if (!/^(x1|y1|x2|y2|points|x|y|width|height|cx|cy|r|rx|ry)$/.test(attr.name)) {
220
- p.setAttribute(attr.name, attr.value);
221
- }
222
- }
223
- el.parentNode.replaceChild(p, el);
224
- return p;
225
- }
226
-
227
- // Convert every shape a `move`/`morph` references to a <path> before any tween
228
- // captures element references (so a concurrent `draw` traces the path, not a
229
- // now-detached primitive).
230
- function preconvertGeometry(root, animations) {
231
- const ALL = 'path, polyline, line, rect, circle, ellipse, polygon';
232
- const pick = (id, sel) => {
233
- if (!id) return;
234
- const g = root.querySelector(`[id="${id}"]`);
235
- const prim = g && g.querySelector(sel);
236
- if (prim) convertToPath(prim);
237
- };
238
- for (const a of animations || []) {
239
- if (a.effect === 'move') pick(a.path, 'path, polyline, line');
240
- if (a.effect === 'morph') {
241
- pick(a.id, ALL);
242
- pick(a.morph, ALL);
243
- }
244
- }
245
- }
246
-
247
- // The `highlight` effect: emphasise `el`. With a target colour, recolour and
248
- // thicken its outline AND give the whole object a small scale pulse, so the
249
- // emphasis reads at a glance rather than a bare colour swap; without a colour,
250
- // a colour-free scale pulse. One-directional `.to()` — pair with `repeat 1
251
- // yoyo` for a flash-and-return, or `repeat -1 yoyo` for a continuous pulse.
252
- function highlightWith(el, a, tl) {
253
- if (a.color) {
254
- const shapes = el.querySelectorAll('path, polyline, line, rect, circle, ellipse, polygon');
255
- tl.to(
256
- shapes,
257
- withOverrides({ stroke: a.color, strokeWidth: '+=2', duration: a.duration, ease: 'power1.inOut' }, a),
258
- a.start
259
- );
260
- tl.to(
261
- el,
262
- withOverrides({ scale: 1.1, transformOrigin: '50% 50%', duration: a.duration, ease: 'power1.inOut' }, a),
263
- a.start
264
- );
265
- } else {
266
- tl.to(
267
- el,
268
- withOverrides({ scale: 1.12, transformOrigin: '50% 50%', duration: a.duration, ease: 'power1.inOut' }, a),
269
- a.start
270
- );
271
- }
272
- }
273
-
274
- // The `morph` effect: tween `el`'s outline into the shape of the object
275
- // `a.morph` references, via GSAP's MorphSVGPlugin. The consumer must have
276
- // registered it (`gsap.registerPlugin(MorphSVGPlugin)`); without it GSAP
277
- // no-ops the morphSVG and the shape stays put. Both source and target were
278
- // converted to <path> by preconvertGeometry.
279
- function morphInto(root, el, a, tl) {
280
- if (!a.morph) return;
281
- const tsel =
282
- typeof CSS !== 'undefined' && CSS.escape ? '#' + CSS.escape(a.morph) : `[id="${a.morph}"]`;
283
- const target = root.querySelector(tsel);
284
- const src = el.querySelector('path');
285
- const dst = target && target.querySelector('path');
286
- if (!src || !dst) return;
287
- tl.to(src, withOverrides({ morphSVG: dst, duration: a.duration, ease: 'power1.inOut' }, a), a.start);
288
- }
289
-
290
- // The `move` effect: travel `el` along the geometry of the object `a.path`
291
- // references, via GSAP's MotionPathPlugin. The consumer must have registered
292
- // it (`gsap.registerPlugin(MotionPathPlugin)`); without it GSAP no-ops the
293
- // motionPath and the object simply stays put.
294
- function moveAlong(root, el, a, tl) {
295
- if (!a.path) return;
296
- const psel =
297
- typeof CSS !== 'undefined' && CSS.escape ? '#' + CSS.escape(a.path) : `[id="${a.path}"]`;
298
- const group = root.querySelector(psel);
299
- // The shaft was converted to a <path> by preconvertGeometry; skip the
300
- // arrowhead <polygon> — we want the line the traveller rides, not the tip.
301
- const pathEl = group && group.querySelector('path');
302
- if (!pathEl) return;
303
- // `align` maps the path into the traveller's coordinate space; a `move`
304
- // tween is a `.to()` (current position → along the path), not a `.from()`.
305
- tl.to(
306
- el,
307
- withOverrides(
308
- {
309
- motionPath: { path: pathEl, align: pathEl, alignOrigin: [0.5, 0.5], autoRotate: false },
310
- duration: a.duration,
311
- ease: 'none',
312
- },
313
- a
314
- ),
315
- a.start
316
- );
317
- }
318
-
319
- function drawOn(group, a, tl) {
320
- const els = group.querySelectorAll('path, polyline, line, rect, circle, ellipse, polygon');
321
- els.forEach((el) => {
322
- // Filled, unstroked elements (arrowheads) can't be dash-traced — pop
323
- // them in as the shaft reaches the tip instead.
324
- const fill = el.getAttribute('fill');
325
- if (el.getAttribute('stroke-width') === '0' || (fill && fill !== 'none' && !el.getAttribute('stroke'))) {
326
- tl.from(
327
- el,
328
- { opacity: 0, scale: 0, transformOrigin: '50% 50%', duration: Math.min(0.2, a.duration * 0.4), ease: 'back.out(1.7)' },
329
- a.start + a.duration * 0.8
330
- );
331
- return;
332
- }
333
- let len = 0;
334
- try {
335
- len = el.getTotalLength();
336
- } catch {
337
- len = 0;
338
- }
339
- if (len > 0) {
340
- // `out` reverses the trace: the stroke retracts (dashoffset 0 → len).
341
- const [begin, end] = a.out ? [0, len] : [len, 0];
342
- tl.fromTo(
343
- el,
344
- { strokeDasharray: len, strokeDashoffset: begin },
345
- withOverrides({ strokeDashoffset: end, duration: a.duration, ease: 'none' }, a),
346
- a.start
347
- );
348
- } else {
349
- enterExit(tl, el, withOverrides({ opacity: 0, duration: a.duration }, a), a);
350
- }
351
- });
352
- const texts = group.querySelectorAll('text');
353
- if (texts.length) {
354
- tl.from(texts, { opacity: 0, duration: a.duration * 0.6 }, a.start + a.duration * 0.4);
355
- }
356
- }
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.9.0",
3
+ "version": "0.11.0",
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",
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
+ }