@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/engine.js ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * VectorFlow — Engine
3
+ * Route resolution, ms precedence, step expansion.
4
+ */
5
+
6
+ /**
7
+ * Resolve the path from currentState to targetState using routes.
8
+ * Returns an array of state names to visit.
9
+ *
10
+ * @param {string} targetState
11
+ * @param {string} currentState
12
+ * @param {object} routes — normalized routes object
13
+ * @returns {string[]}
14
+ */
15
+ export function resolvePath(targetState, currentState, routes) {
16
+ const routeBlock = routes[targetState];
17
+ if (!routeBlock) {
18
+ return [targetState]; // fallback: direct
19
+ }
20
+
21
+ // Try specific path for current state
22
+ if (Array.isArray(routeBlock[currentState])) {
23
+ return routeBlock[currentState];
24
+ }
25
+
26
+ // Try wildcard
27
+ if (Array.isArray(routeBlock['*'])) {
28
+ return routeBlock['*'];
29
+ }
30
+
31
+ // Fallback: direct
32
+ return [targetState];
33
+ }
34
+
35
+ /**
36
+ * Normalize a path by removing a leading state that matches currentState
37
+ * to avoid a no-op step.
38
+ *
39
+ * @param {string[]} path
40
+ * @param {string} currentState
41
+ * @returns {string[]}
42
+ */
43
+ export function normalizePath(path, currentState) {
44
+ if (path.length > 0 && path[0] === currentState) {
45
+ return path.slice(1);
46
+ }
47
+ return path;
48
+ }
49
+
50
+ /**
51
+ * Resolve the ms for a step using the precedence chain:
52
+ * action.ms > routes[target].ms > json.ms > 120
53
+ *
54
+ * @param {number|undefined} actionMs
55
+ * @param {number|undefined} routeMs — routes[targetState].ms
56
+ * @param {number} jsonMs
57
+ * @returns {number}
58
+ */
59
+ export function resolveMs(actionMs, routeMs, jsonMs) {
60
+ if (typeof actionMs === 'number' && actionMs > 0) return actionMs;
61
+ if (typeof routeMs === 'number' && routeMs > 0) return routeMs;
62
+ if (typeof jsonMs === 'number' && jsonMs > 0) return jsonMs;
63
+ return 120;
64
+ }
65
+
66
+ /**
67
+ * Expand a sequence action into a list of normalized steps.
68
+ *
69
+ * @param {string[]} order — target states to visit
70
+ * @param {string} currentState
71
+ * @param {object} routes
72
+ * @param {number|undefined} actionMs
73
+ * @param {number} jsonMs
74
+ * @param {Set<string>} validStates — set of known state names
75
+ * @returns {{ steps: Array<{state: string, ms: number}>, finalState: string }}
76
+ */
77
+ export function expandSequence(order, currentState, routes, actionMs, jsonMs, validStates) {
78
+ const steps = [];
79
+ let current = currentState;
80
+
81
+ for (const targetState of order) {
82
+ // Validate state exists
83
+ if (validStates && !validStates.has(targetState)) {
84
+ throw new Error(`VectorFlow: State not found in SVG: "${targetState}"`);
85
+ }
86
+
87
+ const path = normalizePath(resolvePath(targetState, current, routes), current);
88
+
89
+ for (const nextState of path) {
90
+ if (validStates && !validStates.has(nextState)) {
91
+ throw new Error(`VectorFlow: State not found in SVG: "${nextState}"`);
92
+ }
93
+
94
+ const routeMs = routes[nextState]?.ms;
95
+ const ms = resolveMs(actionMs, routeMs, jsonMs);
96
+ steps.push({ state: nextState, ms });
97
+ current = nextState;
98
+ }
99
+ }
100
+
101
+ return { steps, finalState: current };
102
+ }
103
+
104
+ /**
105
+ * Expand a direct-mode action into steps (skip route resolution).
106
+ *
107
+ * @param {string[]} order
108
+ * @param {string} currentState
109
+ * @param {object} routes — still needed for ms lookup
110
+ * @param {number|undefined} actionMs
111
+ * @param {number} jsonMs
112
+ * @param {Set<string>} validStates
113
+ * @returns {{ steps: Array<{state: string, ms: number}>, finalState: string }}
114
+ */
115
+ export function expandDirect(order, currentState, routes, actionMs, jsonMs, validStates) {
116
+ const steps = [];
117
+ let current = currentState;
118
+
119
+ for (const targetState of order) {
120
+ if (validStates && !validStates.has(targetState)) {
121
+ throw new Error(`VectorFlow: State not found in SVG: "${targetState}"`);
122
+ }
123
+
124
+ // In direct mode, skip if already at target
125
+ if (targetState === current) continue;
126
+
127
+ const routeMs = routes[targetState]?.ms;
128
+ const ms = resolveMs(actionMs, routeMs, jsonMs);
129
+ steps.push({ state: targetState, ms });
130
+ current = targetState;
131
+ }
132
+
133
+ return { steps, finalState: current };
134
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,63 @@
1
+ declare type VectorFlowEvent = 'stateChange' | 'actionStart' | 'actionEnd' | 'error';
2
+
3
+ declare interface VectorFlowOptions {
4
+ /** An SVG DOM element or raw SVG string */
5
+ svgElement: SVGElement | string;
6
+ /** VectorFlow JSON config (object or string) */
7
+ json: VectorFlowConfig | string;
8
+ }
9
+
10
+ declare interface VectorFlowConfig {
11
+ name?: string;
12
+ initial_state: string;
13
+ ms?: number;
14
+ routes: Record<string, VectorFlowRoute>;
15
+ actions: Record<string, VectorFlowAction>;
16
+ }
17
+
18
+ declare interface VectorFlowRoute {
19
+ ms?: number;
20
+ [sourceState: string]: string[] | number | undefined;
21
+ }
22
+
23
+ declare interface VectorFlowAction {
24
+ type?: 'sequence' | 'loop';
25
+ order: string[];
26
+ ms?: number;
27
+ count?: number;
28
+ mode?: 'direct';
29
+ }
30
+
31
+ declare class VectorFlow {
32
+ constructor(options: VectorFlowOptions);
33
+
34
+ /** All discovered state names */
35
+ readonly states: string[];
36
+ /** Current active state */
37
+ readonly currentState: string;
38
+ /** Actions config from JSON */
39
+ readonly actions: Record<string, VectorFlowAction>;
40
+ /** The SVG element being animated */
41
+ readonly svgElement: SVGElement;
42
+ /** Whether an action is currently playing */
43
+ readonly isPlaying: boolean;
44
+ /** Name of the currently playing action */
45
+ readonly currentAction: string | null;
46
+
47
+ /** Play a named action. Auto-cancels any running action. */
48
+ play(actionName: string): Promise<void>;
49
+ /** Cancel current playback immediately. */
50
+ stop(): void;
51
+ /** Register an event listener. */
52
+ on(event: 'stateChange', callback: (state: string) => void): this;
53
+ on(event: 'actionStart', callback: (actionName: string) => void): this;
54
+ on(event: 'actionEnd', callback: (actionName: string) => void): this;
55
+ on(event: 'error', callback: (error: Error) => void): this;
56
+ /** Remove an event listener. */
57
+ off(event: VectorFlowEvent, callback: (...args: any[]) => void): this;
58
+ /** Clean up: stop playback, restore SVG, remove listeners. */
59
+ destroy(): void;
60
+ }
61
+
62
+ export default VectorFlow;
63
+ export { VectorFlow, VectorFlowOptions, VectorFlowConfig, VectorFlowRoute, VectorFlowAction, VectorFlowEvent };
package/src/index.js ADDED
@@ -0,0 +1,264 @@
1
+ /**
2
+ * VectorFlow — Main entry point
3
+ * Public API: VectorFlow class.
4
+ */
5
+
6
+ import {
7
+ parseAndValidateJSON,
8
+ discoverStates,
9
+ discoverParts,
10
+ setupLiveCharacter,
11
+ buildStateSnapshots,
12
+ } from './parser.js';
13
+
14
+ import {
15
+ expandSequence,
16
+ expandDirect,
17
+ } from './engine.js';
18
+
19
+ import { runAction } from './player.js';
20
+
21
+ /**
22
+ * VectorFlow — A JSON-driven SVG pose animation runner.
23
+ *
24
+ * Usage:
25
+ * const vf = new VectorFlow({ svgElement, json });
26
+ * vf.play('shuffle');
27
+ * vf.stop();
28
+ * vf.on('stateChange', (state) => console.log(state));
29
+ */
30
+ class VectorFlow {
31
+ /**
32
+ * @param {object} options
33
+ * @param {SVGElement|string} options.svgElement — An SVG DOM element or an SVG string
34
+ * @param {object|string} options.json — A VectorFlow JSON config (object or string)
35
+ */
36
+ constructor({ svgElement, json }) {
37
+ // Event listeners
38
+ this._listeners = {
39
+ stateChange: [],
40
+ actionStart: [],
41
+ actionEnd: [],
42
+ error: [],
43
+ };
44
+
45
+ // Current abort controller for cancellation
46
+ this._abortController = null;
47
+ this._currentActionName = null;
48
+
49
+ // Parse JSON
50
+ this._config = parseAndValidateJSON(json);
51
+
52
+ // Resolve SVG element
53
+ if (typeof svgElement === 'string') {
54
+ const container = document.createElement('div');
55
+ container.innerHTML = svgElement.trim();
56
+ this._svgElement = container.querySelector('svg');
57
+ if (!this._svgElement) {
58
+ throw new Error('VectorFlow: Provided SVG string does not contain an <svg> element.');
59
+ }
60
+ } else if (svgElement instanceof SVGElement) {
61
+ this._svgElement = svgElement;
62
+ } else {
63
+ throw new Error('VectorFlow: svgElement must be an SVG DOM element or an SVG string.');
64
+ }
65
+
66
+ // Discover states and parts
67
+ this._stateMap = discoverStates(this._svgElement);
68
+ this._stateParts = discoverParts(this._stateMap);
69
+ this._validStates = new Set(this._stateMap.keys());
70
+
71
+ // Validate initial_state exists
72
+ if (!this._validStates.has(this._config.initial_state)) {
73
+ const available = Array.from(this._validStates).join(', ');
74
+ throw new Error(
75
+ `VectorFlow: initial_state "${this._config.initial_state}" not found in SVG. Available states: ${available}`
76
+ );
77
+ }
78
+
79
+ // Build snapshots from original state groups (before hiding)
80
+ this._stateSnapshots = buildStateSnapshots(this._stateParts);
81
+
82
+ // Set up live character
83
+ const { liveGroup, liveParts } = setupLiveCharacter(
84
+ this._svgElement, this._stateMap, this._config.initial_state
85
+ );
86
+ this._liveGroup = liveGroup;
87
+ this._liveParts = liveParts;
88
+
89
+ // Track current state
90
+ this._currentState = this._config.initial_state;
91
+ }
92
+
93
+ // ---- Public API: Properties ----
94
+
95
+ /** Get all discovered state names. */
96
+ get states() {
97
+ return Array.from(this._validStates);
98
+ }
99
+
100
+ /** Get the current state name. */
101
+ get currentState() {
102
+ return this._currentState;
103
+ }
104
+
105
+ /** Get the actions config object. */
106
+ get actions() {
107
+ return { ...this._config.actions };
108
+ }
109
+
110
+ /** Get the SVG element being animated. */
111
+ get svgElement() {
112
+ return this._svgElement;
113
+ }
114
+
115
+ /** Whether an action is currently playing. */
116
+ get isPlaying() {
117
+ return this._abortController !== null && !this._abortController.signal.aborted;
118
+ }
119
+
120
+ /** Name of the currently playing action (null if none). */
121
+ get currentAction() {
122
+ return this._currentActionName;
123
+ }
124
+
125
+ // ---- Public API: Methods ----
126
+
127
+ /**
128
+ * Play a named action. Cancels any currently playing action.
129
+ * @param {string} actionName
130
+ * @returns {Promise<void>}
131
+ */
132
+ async play(actionName) {
133
+ const action = this._config.actions[actionName];
134
+ if (!action) {
135
+ const err = new Error(`VectorFlow: Unknown action "${actionName}"`);
136
+ this._emit('error', err);
137
+ throw err;
138
+ }
139
+
140
+ if (action.order.length === 0) {
141
+ console.warn(`VectorFlow: Action "${actionName}" has empty order — no-op.`);
142
+ return;
143
+ }
144
+
145
+ // Cancel any running action
146
+ this.stop();
147
+
148
+ // New abort controller
149
+ this._abortController = new AbortController();
150
+ this._currentActionName = actionName;
151
+ this._emit('actionStart', actionName);
152
+
153
+ try {
154
+ const finalState = await runAction(
155
+ action,
156
+ this._currentState,
157
+ this._config.routes,
158
+ this._config.ms,
159
+ this._validStates,
160
+ this._liveParts,
161
+ this._stateSnapshots,
162
+ this._abortController.signal,
163
+ (state) => {
164
+ this._currentState = state;
165
+ this._emit('stateChange', state);
166
+ },
167
+ { expandSequence, expandDirect }
168
+ );
169
+
170
+ if (finalState) {
171
+ this._currentState = finalState;
172
+ }
173
+
174
+ this._currentActionName = null;
175
+ this._emit('actionEnd', actionName);
176
+ } catch (err) {
177
+ if (err.name === 'AbortError') {
178
+ // Cancelled — not an error
179
+ return;
180
+ }
181
+ this._currentActionName = null;
182
+ this._emit('error', err);
183
+ throw err;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Stop/cancel any currently playing action.
189
+ */
190
+ stop() {
191
+ if (this._abortController) {
192
+ this._abortController.abort();
193
+ this._abortController = null;
194
+ if (this._currentActionName) {
195
+ const name = this._currentActionName;
196
+ this._currentActionName = null;
197
+ this._emit('actionEnd', name);
198
+ }
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Register an event listener.
204
+ * @param {'stateChange'|'actionStart'|'actionEnd'|'error'} event
205
+ * @param {function} callback
206
+ */
207
+ on(event, callback) {
208
+ if (!this._listeners[event]) {
209
+ this._listeners[event] = [];
210
+ }
211
+ this._listeners[event].push(callback);
212
+ return this;
213
+ }
214
+
215
+ /**
216
+ * Remove an event listener.
217
+ * @param {string} event
218
+ * @param {function} callback
219
+ */
220
+ off(event, callback) {
221
+ if (this._listeners[event]) {
222
+ this._listeners[event] = this._listeners[event].filter(cb => cb !== callback);
223
+ }
224
+ return this;
225
+ }
226
+
227
+ /**
228
+ * Clean up: stop playback, restore SVG state groups, remove live character.
229
+ */
230
+ destroy() {
231
+ this.stop();
232
+
233
+ // Show original state groups again
234
+ if (this._stateMap) {
235
+ for (const [, groupEl] of this._stateMap) {
236
+ groupEl.style.display = '';
237
+ }
238
+ }
239
+
240
+ // Remove live group
241
+ if (this._liveGroup && this._liveGroup.parentNode) {
242
+ this._liveGroup.remove();
243
+ }
244
+
245
+ this._listeners = { stateChange: [], actionStart: [], actionEnd: [], error: [] };
246
+ }
247
+
248
+ // ---- Private ----
249
+
250
+ _emit(event, data) {
251
+ if (this._listeners[event]) {
252
+ for (const cb of this._listeners[event]) {
253
+ try {
254
+ cb(data);
255
+ } catch (e) {
256
+ console.error(`VectorFlow: Error in "${event}" listener:`, e);
257
+ }
258
+ }
259
+ }
260
+ }
261
+ }
262
+
263
+ export default VectorFlow;
264
+ export { VectorFlow };
package/src/parser.js ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * VectorFlow — Parser
3
+ * JSON validation/normalization + SVG state/part discovery.
4
+ */
5
+
6
+ import { readSnapshot } from './utils.js';
7
+
8
+ /**
9
+ * Parse and validate a VectorFlow JSON config.
10
+ * Returns a normalized config object.
11
+ * Throws on invalid input.
12
+ */
13
+ export function parseAndValidateJSON(input) {
14
+ let json;
15
+ if (typeof input === 'string') {
16
+ try {
17
+ json = JSON.parse(input);
18
+ } catch (e) {
19
+ throw new Error(`VectorFlow: Invalid JSON — ${e.message}`);
20
+ }
21
+ } else if (typeof input === 'object' && input !== null) {
22
+ json = input;
23
+ } else {
24
+ throw new Error('VectorFlow: JSON input must be a string or object');
25
+ }
26
+
27
+ // Validate required fields
28
+ if (!json.initial_state || typeof json.initial_state !== 'string') {
29
+ throw new Error('VectorFlow: JSON must have a string "initial_state" field');
30
+ }
31
+ if (!json.routes || typeof json.routes !== 'object') {
32
+ throw new Error('VectorFlow: JSON must have a "routes" object');
33
+ }
34
+ if (!json.actions || typeof json.actions !== 'object') {
35
+ throw new Error('VectorFlow: JSON must have an "actions" object');
36
+ }
37
+
38
+ // Normalize top-level ms
39
+ const globalMs = (typeof json.ms === 'number' && json.ms > 0) ? json.ms : 120;
40
+
41
+ // Validate routes
42
+ const routes = {};
43
+ for (const [target, block] of Object.entries(json.routes)) {
44
+ if (typeof block !== 'object' || block === null) {
45
+ throw new Error(`VectorFlow: routes["${target}"] must be an object`);
46
+ }
47
+ routes[target] = {};
48
+ for (const [key, value] of Object.entries(block)) {
49
+ if (key === 'ms') {
50
+ if (typeof value !== 'number') {
51
+ throw new Error(`VectorFlow: routes["${target}"].ms must be a number`);
52
+ }
53
+ routes[target].ms = value;
54
+ } else {
55
+ if (!Array.isArray(value) || !value.every(s => typeof s === 'string')) {
56
+ throw new Error(`VectorFlow: routes["${target}"]["${key}"] must be an array of strings`);
57
+ }
58
+ routes[target][key] = value;
59
+ }
60
+ }
61
+ }
62
+
63
+ // Normalize actions
64
+ const actions = {};
65
+ for (const [name, action] of Object.entries(json.actions)) {
66
+ const type = (action.type === 'loop') ? 'loop' : 'sequence';
67
+
68
+ if (!Array.isArray(action.order) || action.order.length === 0) {
69
+ console.warn(`VectorFlow: action "${name}" has no valid order — will be a no-op`);
70
+ actions[name] = { type, order: [], ms: undefined, count: undefined, mode: undefined };
71
+ continue;
72
+ }
73
+
74
+ const ms = (typeof action.ms === 'number' && action.ms > 0) ? action.ms : undefined;
75
+ let count;
76
+ let mode;
77
+
78
+ if (type === 'loop') {
79
+ count = (typeof action.count === 'number' && action.count > 0) ? action.count : 99999;
80
+ mode = (typeof action.mode === 'string') ? action.mode : undefined;
81
+ }
82
+
83
+ actions[name] = { type, order: action.order, ms, count, mode };
84
+ }
85
+
86
+ return {
87
+ name: json.name || 'untitled',
88
+ initial_state: json.initial_state,
89
+ ms: globalMs,
90
+ routes,
91
+ actions,
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Discover state groups from an SVG element.
97
+ * Returns a Map: stateName → SVG <g> element.
98
+ * Throws if no state groups found.
99
+ */
100
+ export function discoverStates(svgElement) {
101
+ const stateGroups = svgElement.querySelectorAll('g[id^="state_"]');
102
+ if (stateGroups.length === 0) {
103
+ throw new Error('VectorFlow: No state groups found in SVG. Expected groups with id="state_{name}".');
104
+ }
105
+
106
+ const stateMap = new Map();
107
+ for (const g of stateGroups) {
108
+ const stateName = g.id.replace(/^state_/, '');
109
+ if (stateName) {
110
+ stateMap.set(stateName, g);
111
+ }
112
+ }
113
+
114
+ if (stateMap.size === 0) {
115
+ throw new Error('VectorFlow: No valid state groups found in SVG.');
116
+ }
117
+
118
+ return stateMap;
119
+ }
120
+
121
+ /**
122
+ * Discover parts from all state groups.
123
+ * Returns a Map: stateName → Map(partName → element).
124
+ * Validates that all states have identical part sets.
125
+ */
126
+ export function discoverParts(stateMap) {
127
+ const stateParts = new Map();
128
+ let referencePartNames = null;
129
+ let referenceStateName = null;
130
+
131
+ for (const [stateName, groupEl] of stateMap) {
132
+ const parts = new Map();
133
+ const partEls = groupEl.querySelectorAll('[data-part]');
134
+
135
+ for (const el of partEls) {
136
+ const partName = el.getAttribute('data-part');
137
+ if (partName) {
138
+ parts.set(partName, el);
139
+ }
140
+ }
141
+
142
+ if (parts.size === 0) {
143
+ throw new Error(`VectorFlow: State "${stateName}" has no parts (elements with data-part attribute).`);
144
+ }
145
+
146
+ // Validate matching part sets
147
+ const currentPartNames = Array.from(parts.keys()).sort().join(',');
148
+ if (referencePartNames === null) {
149
+ referencePartNames = currentPartNames;
150
+ referenceStateName = stateName;
151
+ } else if (currentPartNames !== referencePartNames) {
152
+ throw new Error(
153
+ `VectorFlow: Part mismatch between states. ` +
154
+ `State "${referenceStateName}" has parts [${referencePartNames}] ` +
155
+ `but state "${stateName}" has parts [${currentPartNames}].`
156
+ );
157
+ }
158
+
159
+ stateParts.set(stateName, parts);
160
+ }
161
+
162
+ return stateParts;
163
+ }
164
+
165
+ /**
166
+ * Set up the live character group.
167
+ * Clones the initial state, inserts into SVG, hides original state groups.
168
+ * Returns { liveGroup: element, liveParts: Map(partName → element) }.
169
+ */
170
+ export function setupLiveCharacter(svgElement, stateMap, initialState) {
171
+ const initialGroup = stateMap.get(initialState);
172
+ if (!initialGroup) {
173
+ const available = Array.from(stateMap.keys()).join(', ');
174
+ throw new Error(
175
+ `VectorFlow: initial_state "${initialState}" not found in SVG. Available states: ${available}`
176
+ );
177
+ }
178
+
179
+ // Remove any existing live group
180
+ const existingLive = svgElement.querySelector('#live_character');
181
+ if (existingLive) {
182
+ existingLive.remove();
183
+ }
184
+
185
+ // Clone initial state
186
+ const liveGroup = initialGroup.cloneNode(true);
187
+ liveGroup.id = 'live_character';
188
+ liveGroup.removeAttribute('style');
189
+ liveGroup.style.display = '';
190
+
191
+ // Insert live group into SVG
192
+ svgElement.appendChild(liveGroup);
193
+
194
+ // Hide all original state groups
195
+ for (const [, groupEl] of stateMap) {
196
+ groupEl.style.display = 'none';
197
+ }
198
+
199
+ // Build live parts map
200
+ const liveParts = new Map();
201
+ const partEls = liveGroup.querySelectorAll('[data-part]');
202
+ for (const el of partEls) {
203
+ const partName = el.getAttribute('data-part');
204
+ if (partName) {
205
+ liveParts.set(partName, el);
206
+ }
207
+ }
208
+
209
+ return { liveGroup, liveParts };
210
+ }
211
+
212
+ /**
213
+ * Build snapshots for all states and parts.
214
+ * Returns Map: stateName → Map(partName → snapshot).
215
+ */
216
+ export function buildStateSnapshots(stateParts) {
217
+ const snapshots = new Map();
218
+ for (const [stateName, parts] of stateParts) {
219
+ const partSnapshots = new Map();
220
+ for (const [partName, el] of parts) {
221
+ partSnapshots.set(partName, readSnapshot(el));
222
+ }
223
+ snapshots.set(stateName, partSnapshots);
224
+ }
225
+ return snapshots;
226
+ }