@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/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/vectorflow.umd.js +2 -0
- package/dist/vectorflow.umd.js.map +1 -0
- package/package.json +42 -0
- package/src/engine.js +134 -0
- package/src/index.d.ts +63 -0
- package/src/index.js +264 -0
- package/src/parser.js +226 -0
- package/src/player.js +246 -0
- package/src/utils.js +221 -0
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
|
+
}
|