@nitesh-tyagi/vectorflow 1.0.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/src/player.js ADDED
@@ -0,0 +1,246 @@
1
+ /**
2
+ * VectorFlow — Player
3
+ * Animation playback using anime.js, with cancel/interrupt support.
4
+ */
5
+
6
+ import anime from 'animejs/lib/anime.es.js';
7
+ import { readSnapshot, lerpColor, parseTransform, buildTransform, lerp } from './utils.js';
8
+
9
+ /**
10
+ * Animate a single step: transition all live parts to target state.
11
+ * Returns a Promise that resolves when the animation completes.
12
+ *
13
+ * @param {Map<string, Element>} liveParts — partName → live element
14
+ * @param {Map<string, object>} targetSnapshots — partName → snapshot for target state
15
+ * @param {number} ms — animation duration
16
+ * @param {AbortSignal} signal — for cancellation
17
+ * @returns {Promise<void>}
18
+ */
19
+ export function playStep(liveParts, targetSnapshots, ms, signal) {
20
+ return new Promise((resolve, reject) => {
21
+ if (signal?.aborted) {
22
+ reject(new DOMException('Aborted', 'AbortError'));
23
+ return;
24
+ }
25
+
26
+ const animations = [];
27
+ let completedCount = 0;
28
+ const totalParts = liveParts.size;
29
+
30
+ if (totalParts === 0) {
31
+ resolve();
32
+ return;
33
+ }
34
+
35
+ for (const [partName, liveEl] of liveParts) {
36
+ const targetSnap = targetSnapshots.get(partName);
37
+ if (!targetSnap) continue;
38
+
39
+ // Read current snapshot from the live element
40
+ const currentSnap = readSnapshot(liveEl);
41
+
42
+ // Build anime.js animation targets
43
+ const animeConfig = {
44
+ targets: liveEl,
45
+ duration: ms,
46
+ easing: 'linear',
47
+ complete: () => {
48
+ completedCount++;
49
+ if (completedCount >= totalParts) {
50
+ resolve();
51
+ }
52
+ },
53
+ };
54
+
55
+ // Animate numeric attributes
56
+ for (const [attr, targetVal] of Object.entries(targetSnap.numericAttrs)) {
57
+ const currentVal = currentSnap.numericAttrs[attr] ?? targetVal;
58
+ if (currentVal !== targetVal) {
59
+ animeConfig[attr] = targetVal;
60
+ }
61
+ }
62
+
63
+ // Animate colors using update callback
64
+ const colorAnimations = [];
65
+ for (const [prop, targetColor] of Object.entries(targetSnap.colors)) {
66
+ const currentColor = currentSnap.colors[prop];
67
+ if (currentColor && currentColor !== targetColor) {
68
+ colorAnimations.push({ prop, from: currentColor, to: targetColor });
69
+ } else if (!currentColor && targetColor) {
70
+ // Set directly if no current
71
+ liveEl.style.setProperty(prop, targetColor);
72
+ }
73
+ }
74
+
75
+ // Animate transform
76
+ let transformAnim = null;
77
+ if (targetSnap.transform || currentSnap.transform) {
78
+ const fromT = parseTransform(currentSnap.transform);
79
+ const toT = parseTransform(targetSnap.transform);
80
+ if (JSON.stringify(fromT) !== JSON.stringify(toT)) {
81
+ transformAnim = { from: fromT, to: toT };
82
+ }
83
+ }
84
+
85
+ // If we have color or transform animations, use the update callback
86
+ if (colorAnimations.length > 0 || transformAnim) {
87
+ const progressObj = { t: 0 };
88
+ const colorTransformAnim = anime({
89
+ targets: progressObj,
90
+ t: 1,
91
+ duration: ms,
92
+ easing: 'linear',
93
+ update: () => {
94
+ const t = progressObj.t;
95
+ // Colors
96
+ for (const { prop, from, to } of colorAnimations) {
97
+ const interpolated = lerpColor(from, to, t);
98
+ liveEl.style.setProperty(prop, interpolated);
99
+ }
100
+ // Transform
101
+ if (transformAnim) {
102
+ const { from, to } = transformAnim;
103
+ const interpolated = {
104
+ translateX: lerp(from.translateX, to.translateX, t),
105
+ translateY: lerp(from.translateY, to.translateY, t),
106
+ scaleX: lerp(from.scaleX, to.scaleX, t),
107
+ scaleY: lerp(from.scaleY, to.scaleY, t),
108
+ rotate: lerp(from.rotate, to.rotate, t),
109
+ };
110
+ const transformStr = buildTransform(interpolated);
111
+ if (transformStr) {
112
+ liveEl.setAttribute('transform', transformStr);
113
+ } else {
114
+ liveEl.removeAttribute('transform');
115
+ }
116
+ }
117
+ },
118
+ });
119
+ animations.push(colorTransformAnim);
120
+ }
121
+
122
+ // Create the main numeric attributes animation
123
+ const hasNumericChanges = Object.keys(animeConfig).some(
124
+ k => !['targets', 'duration', 'easing', 'complete'].includes(k)
125
+ );
126
+
127
+ if (hasNumericChanges) {
128
+ const anim = anime(animeConfig);
129
+ animations.push(anim);
130
+ } else {
131
+ // If no numeric changes, still count this part as complete
132
+ animeConfig.complete();
133
+ }
134
+ }
135
+
136
+ // Handle abort
137
+ const onAbort = () => {
138
+ for (const anim of animations) {
139
+ anime.remove(anim.targets || anim);
140
+ if (anim.pause) anim.pause();
141
+ }
142
+ reject(new DOMException('Aborted', 'AbortError'));
143
+ };
144
+
145
+ if (signal) {
146
+ signal.addEventListener('abort', onAbort, { once: true });
147
+ }
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Play a sequence of steps.
153
+ *
154
+ * @param {Array<{state: string, ms: number}>} steps
155
+ * @param {Map<string, Element>} liveParts
156
+ * @param {Map<string, Map<string, object>>} stateSnapshots — stateName → partName → snapshot
157
+ * @param {AbortSignal} signal
158
+ * @param {function(string)} onStateChange — callback when a step completes
159
+ * @returns {Promise<string>} — resolves with the final state
160
+ */
161
+ export async function playSteps(steps, liveParts, stateSnapshots, signal, onStateChange) {
162
+ let lastState = null;
163
+
164
+ for (const step of steps) {
165
+ if (signal?.aborted) {
166
+ throw new DOMException('Aborted', 'AbortError');
167
+ }
168
+
169
+ const targetSnapshots = stateSnapshots.get(step.state);
170
+ if (!targetSnapshots) {
171
+ throw new Error(`VectorFlow: No snapshots for state "${step.state}"`);
172
+ }
173
+
174
+ await playStep(liveParts, targetSnapshots, step.ms, signal);
175
+ lastState = step.state;
176
+
177
+ if (onStateChange) {
178
+ onStateChange(step.state);
179
+ }
180
+ }
181
+
182
+ return lastState;
183
+ }
184
+
185
+ /**
186
+ * Run a full action (sequence or loop).
187
+ *
188
+ * @param {object} action — normalized action object
189
+ * @param {string} currentState
190
+ * @param {object} routes
191
+ * @param {number} jsonMs
192
+ * @param {Set<string>} validStates
193
+ * @param {Map<string, Element>} liveParts
194
+ * @param {Map<string, Map<string, object>>} stateSnapshots
195
+ * @param {AbortSignal} signal
196
+ * @param {function(string)} onStateChange
197
+ * @param {object} expandFns — { expandSequence, expandDirect } from engine
198
+ * @returns {Promise<string>} — final state
199
+ */
200
+ export async function runAction(
201
+ action, currentState, routes, jsonMs, validStates,
202
+ liveParts, stateSnapshots, signal, onStateChange, expandFns
203
+ ) {
204
+ const { expandSequence, expandDirect } = expandFns;
205
+
206
+ if (action.type === 'loop') {
207
+ const count = action.count || 99999;
208
+ let current = currentState;
209
+
210
+ for (let i = 0; i < count; i++) {
211
+ if (signal?.aborted) {
212
+ throw new DOMException('Aborted', 'AbortError');
213
+ }
214
+
215
+ let result;
216
+ if (action.mode === 'direct') {
217
+ result = expandDirect(action.order, current, routes, action.ms, jsonMs, validStates);
218
+ } else {
219
+ result = expandSequence(action.order, current, routes, action.ms, jsonMs, validStates);
220
+ }
221
+
222
+ if (result.steps.length > 0) {
223
+ const finalState = await playSteps(
224
+ result.steps, liveParts, stateSnapshots, signal, onStateChange
225
+ );
226
+ current = finalState || current;
227
+ }
228
+ }
229
+
230
+ return current;
231
+ } else {
232
+ // sequence (default)
233
+ const result = expandSequence(
234
+ action.order, currentState, routes, action.ms, jsonMs, validStates
235
+ );
236
+
237
+ if (result.steps.length > 0) {
238
+ const finalState = await playSteps(
239
+ result.steps, liveParts, stateSnapshots, signal, onStateChange
240
+ );
241
+ return finalState || currentState;
242
+ }
243
+
244
+ return currentState;
245
+ }
246
+ }
package/src/utils.js ADDED
@@ -0,0 +1,221 @@
1
+ /**
2
+ * VectorFlow — Utility helpers
3
+ * Color parsing, attribute reading, interpolation helpers.
4
+ */
5
+
6
+ /**
7
+ * Parse a hex color string to { r, g, b } (0-255).
8
+ * Supports #RGB, #RRGGBB.
9
+ */
10
+ export function hexToRgb(hex) {
11
+ if (!hex || hex === 'none') return null;
12
+ hex = hex.trim().replace('#', '');
13
+ if (hex.length === 3) {
14
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
15
+ }
16
+ const num = parseInt(hex, 16);
17
+ if (isNaN(num)) return null;
18
+ return {
19
+ r: (num >> 16) & 255,
20
+ g: (num >> 8) & 255,
21
+ b: num & 255,
22
+ };
23
+ }
24
+
25
+ /**
26
+ * Convert { r, g, b } back to a hex string.
27
+ */
28
+ export function rgbToHex({ r, g, b }) {
29
+ const toHex = (n) => Math.round(Math.max(0, Math.min(255, n)))
30
+ .toString(16)
31
+ .padStart(2, '0');
32
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
33
+ }
34
+
35
+ /**
36
+ * Linearly interpolate between two numbers.
37
+ */
38
+ export function lerp(a, b, t) {
39
+ return a + (b - a) * t;
40
+ }
41
+
42
+ /**
43
+ * Linearly interpolate between two colors (hex strings).
44
+ * Returns a hex string.
45
+ */
46
+ export function lerpColor(colorA, colorB, t) {
47
+ const a = hexToRgb(colorA);
48
+ const b = hexToRgb(colorB);
49
+ if (!a || !b) return colorB || colorA || '#000000';
50
+ return rgbToHex({
51
+ r: lerp(a.r, b.r, t),
52
+ g: lerp(a.g, b.g, t),
53
+ b: lerp(a.b, b.b, t),
54
+ });
55
+ }
56
+
57
+ /**
58
+ * Numeric SVG attributes we want to interpolate.
59
+ */
60
+ export const NUMERIC_ATTRS = ['x', 'y', 'width', 'height', 'rx', 'ry', 'cx', 'cy', 'r', 'opacity'];
61
+
62
+ /**
63
+ * Style properties that may contain colors.
64
+ */
65
+ export const COLOR_PROPS = ['fill', 'stroke'];
66
+
67
+ /**
68
+ * Read a numeric attribute from an SVG element.
69
+ * Falls back to computed style, then to defaultVal.
70
+ */
71
+ export function readNumericAttr(el, attr, defaultVal = 0) {
72
+ // Try direct attribute first
73
+ const raw = el.getAttribute(attr);
74
+ if (raw !== null && raw !== '') {
75
+ const val = parseFloat(raw);
76
+ if (!isNaN(val)) return val;
77
+ }
78
+ return defaultVal;
79
+ }
80
+
81
+ /**
82
+ * Read a color property from an SVG element.
83
+ * Checks inline style first, then attribute, then computed style.
84
+ * Returns a hex color string or null.
85
+ */
86
+ export function readColorProp(el, prop) {
87
+ // 1. Inline style
88
+ const styleVal = el.style.getPropertyValue(prop);
89
+ if (styleVal && styleVal !== 'none') {
90
+ return normalizeColor(styleVal);
91
+ }
92
+ // 2. Direct attribute
93
+ const attrVal = el.getAttribute(prop);
94
+ if (attrVal && attrVal !== 'none') {
95
+ return normalizeColor(attrVal);
96
+ }
97
+ // 3. Computed style
98
+ const computed = window.getComputedStyle(el).getPropertyValue(prop);
99
+ if (computed && computed !== 'none') {
100
+ return normalizeColor(computed);
101
+ }
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * Normalize a color value to hex. Handles:
107
+ * - hex: #fff, #ff0000
108
+ * - rgb(...): rgb(255, 0, 0)
109
+ */
110
+ export function normalizeColor(val) {
111
+ if (!val || val === 'none') return null;
112
+ val = val.trim();
113
+
114
+ // Already hex
115
+ if (val.startsWith('#')) return val;
116
+
117
+ // rgb(r, g, b) or rgb(r,g,b)
118
+ const match = val.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/i);
119
+ if (match) {
120
+ return rgbToHex({
121
+ r: parseInt(match[1], 10),
122
+ g: parseInt(match[2], 10),
123
+ b: parseInt(match[3], 10),
124
+ });
125
+ }
126
+
127
+ // Named colors — use a canvas trick
128
+ if (typeof document !== 'undefined') {
129
+ const ctx = document.createElement('canvas').getContext('2d');
130
+ ctx.fillStyle = val;
131
+ const resolved = ctx.fillStyle; // returns hex
132
+ if (resolved.startsWith('#')) return resolved;
133
+ }
134
+
135
+ return val;
136
+ }
137
+
138
+ /**
139
+ * Read all animatable snapshot from an SVG element.
140
+ * Returns { numericAttrs: {attr: value}, colors: {prop: hexString}, transform: string|null }
141
+ */
142
+ export function readSnapshot(el) {
143
+ const snapshot = {
144
+ numericAttrs: {},
145
+ colors: {},
146
+ transform: null,
147
+ };
148
+
149
+ // Numeric attributes
150
+ for (const attr of NUMERIC_ATTRS) {
151
+ const raw = el.getAttribute(attr);
152
+ if (raw !== null && raw !== '') {
153
+ const val = parseFloat(raw);
154
+ if (!isNaN(val)) {
155
+ snapshot.numericAttrs[attr] = val;
156
+ }
157
+ }
158
+ }
159
+
160
+ // Colors
161
+ for (const prop of COLOR_PROPS) {
162
+ const color = readColorProp(el, prop);
163
+ if (color) {
164
+ snapshot.colors[prop] = color;
165
+ }
166
+ }
167
+
168
+ // Transform
169
+ const transform = el.getAttribute('transform');
170
+ if (transform) {
171
+ snapshot.transform = transform;
172
+ }
173
+
174
+ return snapshot;
175
+ }
176
+
177
+ /**
178
+ * Parse a simple SVG transform string into components.
179
+ * Only handles translate, scale, rotate as simple cases.
180
+ * Returns an object like { translateX, translateY, scaleX, scaleY, rotate }.
181
+ */
182
+ export function parseTransform(transformStr) {
183
+ const result = { translateX: 0, translateY: 0, scaleX: 1, scaleY: 1, rotate: 0 };
184
+ if (!transformStr) return result;
185
+
186
+ const translateMatch = transformStr.match(/translate\(\s*([-\d.]+)\s*[,\s]\s*([-\d.]+)\s*\)/);
187
+ if (translateMatch) {
188
+ result.translateX = parseFloat(translateMatch[1]);
189
+ result.translateY = parseFloat(translateMatch[2]);
190
+ }
191
+
192
+ const scaleMatch = transformStr.match(/scale\(\s*([-\d.]+)(?:\s*[,\s]\s*([-\d.]+))?\s*\)/);
193
+ if (scaleMatch) {
194
+ result.scaleX = parseFloat(scaleMatch[1]);
195
+ result.scaleY = scaleMatch[2] !== undefined ? parseFloat(scaleMatch[2]) : result.scaleX;
196
+ }
197
+
198
+ const rotateMatch = transformStr.match(/rotate\(\s*([-\d.]+)/);
199
+ if (rotateMatch) {
200
+ result.rotate = parseFloat(rotateMatch[1]);
201
+ }
202
+
203
+ return result;
204
+ }
205
+
206
+ /**
207
+ * Build a transform string from parsed components.
208
+ */
209
+ export function buildTransform({ translateX, translateY, scaleX, scaleY, rotate }) {
210
+ const parts = [];
211
+ if (translateX !== 0 || translateY !== 0) {
212
+ parts.push(`translate(${translateX},${translateY})`);
213
+ }
214
+ if (rotate !== 0) {
215
+ parts.push(`rotate(${rotate})`);
216
+ }
217
+ if (scaleX !== 1 || scaleY !== 1) {
218
+ parts.push(`scale(${scaleX},${scaleY})`);
219
+ }
220
+ return parts.length > 0 ? parts.join(' ') : null;
221
+ }