@strategicprojects/rpic 0.7.1 → 0.8.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/index.js CHANGED
@@ -63,7 +63,9 @@ function ensure() {
63
63
  }
64
64
 
65
65
  /**
66
- * Compile pic source into `{ svg, animations, diagnostics, warnings, objects }` (throws on a pic error).
66
+ * Compile pic source into `{ svg, animations, diagnostics, warnings, objects }`
67
+ * (throws on a pic error). A top-level `scroll: true` is present when the source
68
+ * used `animate scroll` — a hint to scrub the timeline on scroll (see `animate`).
67
69
  * @param {string} src
68
70
  * @param {{circuits?: boolean, texlabels?: boolean}} [opts]
69
71
  */
@@ -93,12 +95,17 @@ export function renderSvg(src, opts) {
93
95
  * Build a GSAP timeline from a drawing's animation manifest and play it on the
94
96
  * SVG inside `root`. Browser-only (needs the DOM and a GSAP instance).
95
97
  * @param {Element} root container holding the injected SVG
96
- * @param {Array<{id:string,effect:string,start:number,duration:number}>} animations
97
- * @param {*} gsap the GSAP instance
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`)
98
100
  * @returns the GSAP timeline
99
101
  */
100
102
  export function animate(root, animations, gsap) {
101
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);
102
109
  for (const a of animations) {
103
110
  const sel =
104
111
  typeof CSS !== 'undefined' && CSS.escape ? '#' + CSS.escape(a.id) : `[id="${a.id}"]`;
@@ -106,25 +113,209 @@ export function animate(root, animations, gsap) {
106
113
  if (!el) continue;
107
114
  switch (a.effect) {
108
115
  case 'fade':
109
- tl.from(el, { opacity: 0, duration: a.duration, ease: 'power1.out' }, a.start);
116
+ enterExit(tl, el, withOverrides({ opacity: 0, duration: a.duration, ease: 'power1.out' }, a), a);
110
117
  break;
111
118
  case 'pop':
112
- tl.from(
119
+ enterExit(
120
+ tl,
113
121
  el,
114
- { scale: 0, transformOrigin: '50% 50%', duration: a.duration, ease: 'back.out(1.7)' },
115
- a.start
122
+ withOverrides({ scale: 0, transformOrigin: '50% 50%', duration: a.duration, ease: 'back.out(1.7)' }, a),
123
+ a
116
124
  );
117
125
  break;
126
+ case 'slide':
127
+ slideIn(tl, el, a);
128
+ break;
118
129
  case 'draw':
119
130
  drawOn(el, a, tl);
120
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;
121
141
  default:
122
- tl.from(el, { opacity: 0, duration: a.duration }, a.start);
142
+ enterExit(tl, el, withOverrides({ opacity: 0, duration: a.duration }, a), a);
123
143
  }
124
144
  }
125
145
  return tl;
126
146
  }
127
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
+
128
319
  function drawOn(group, a, tl) {
129
320
  const els = group.querySelectorAll('path, polyline, line, rect, circle, ellipse, polygon');
130
321
  els.forEach((el) => {
@@ -146,14 +337,16 @@ function drawOn(group, a, tl) {
146
337
  len = 0;
147
338
  }
148
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];
149
342
  tl.fromTo(
150
343
  el,
151
- { strokeDasharray: len, strokeDashoffset: len },
152
- { strokeDashoffset: 0, duration: a.duration, ease: 'none' },
344
+ { strokeDasharray: len, strokeDashoffset: begin },
345
+ withOverrides({ strokeDashoffset: end, duration: a.duration, ease: 'none' }, a),
153
346
  a.start
154
347
  );
155
348
  } else {
156
- tl.from(el, { opacity: 0, duration: a.duration }, a.start);
349
+ enterExit(tl, el, withOverrides({ opacity: 0, duration: a.duration }, a), a);
157
350
  }
158
351
  });
159
352
  const texts = group.querySelectorAll('text');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@strategicprojects/rpic",
3
- "version": "0.7.1",
3
+ "version": "0.8.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",
Binary file
Binary file