@px-petals/roku.motion-web 0.0.0-stage → 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.
@@ -0,0 +1,367 @@
1
+ /**
2
+ * The state machine engine for dotLottie machines, as Roku's Motion runs it
3
+ * (runtime/roku/components/motion/state/state.bs): it answers "which state
4
+ * next?" and runs actions against a values table. It holds no state of its
5
+ * own; the flow owns the current state, the values and the pending events.
6
+ */
7
+
8
+ /**
9
+ * @typedef {{ initial: string, states: any[], inputs?: any[], interactions?: any[], layers?: any[] }} Graph
10
+ * @typedef {Record<string, any>} Values input name → value
11
+ * @typedef {Record<string, boolean>} Events pending events, name → true
12
+ * @typedef {{ changed: boolean, events: string[], focus: any[] }} Effects `focus`: the focus actions (FocusTarget, FocusMove, FocusClear) to run once the values are set
13
+ * @typedef {{ elapsed: number, length: number }} Clock how long the current state has played, and how long its timeline is, in ms
14
+ */
15
+
16
+ /**
17
+ * @param {any} graph
18
+ * @returns {boolean}
19
+ */
20
+ export function exists(graph) {
21
+ return !!graph && Array.isArray(graph.states) && typeof graph.initial === 'string'
22
+ }
23
+
24
+ /**
25
+ * @param {Graph} graph
26
+ * @param {string} name
27
+ */
28
+ export function find(graph, name) {
29
+ for (let n = 0; n < graph.states.length; n++) if (graph.states[n].name === name) return graph.states[n]
30
+ return null
31
+ }
32
+
33
+ /**
34
+ * The graphs of a machine's layers, each with the machine's inputs and
35
+ * interactions, which layers share. A machine without `layers` is one layer.
36
+ *
37
+ * @param {Graph} graph
38
+ * @returns {Graph[]}
39
+ */
40
+ export function layersOf(graph) {
41
+ const layers = graph.layers
42
+ if (!layers || layers.length < 2) return [ graph ]
43
+ return layers.map((/** @type {any} */ layer, /** @type {number} */ n) => (n === 0 ? graph : { initial: layer.initial, states: layer.states, inputs: graph.inputs, interactions: graph.interactions }))
44
+ }
45
+
46
+ /**
47
+ * Every input's authored value; events have none.
48
+ *
49
+ * @param {Graph} graph
50
+ * @returns {Values}
51
+ */
52
+ export function defaults(graph) {
53
+ /** @type {Values} */
54
+ const values = {}
55
+ const inputs = graph.inputs || []
56
+ for (let n = 0; n < inputs.length; n++) if (inputs[n].type !== 'Event') values[inputs[n].name] = inputs[n].value
57
+ return values
58
+ }
59
+
60
+ /**
61
+ * @param {Graph} graph
62
+ * @param {string} name
63
+ */
64
+ export function isEvent(graph, name) {
65
+ const inputs = graph.inputs || []
66
+ for (let n = 0; n < inputs.length; n++) if (inputs[n].name === name) return inputs[n].type === 'Event'
67
+ return false
68
+ }
69
+
70
+ // Transitions
71
+ // ■■■■■■■■■■■
72
+
73
+ /**
74
+ * Global states' transitions are checked first, then the current state's;
75
+ * the first whose guards all pass wins. A transition back into the current
76
+ * state from a global one is skipped, or it would fire forever.
77
+ *
78
+ * @param {Graph} graph
79
+ * @param {string} current
80
+ * @param {Values} values
81
+ * @param {Events} events
82
+ * @param {Clock | null} [clock] the current state's time, for exit times; without it exit times are not checked
83
+ * @returns {string} the next state, or ""
84
+ */
85
+ export function nextState(graph, current, values, events, clock) {
86
+ const transition = takeTransition(graph, current, values, events, clock || null)
87
+ return transition ? transition.toState : ''
88
+ }
89
+
90
+ /**
91
+ * The transition `nextState` takes, whole: its `durationMs` is how long the
92
+ * outgoing and incoming states cross-fade.
93
+ *
94
+ * @param {Graph} graph
95
+ * @param {string} current
96
+ * @param {Values} values
97
+ * @param {Events} events
98
+ * @param {Clock | null} clock
99
+ * @returns {any} the transition, or null
100
+ */
101
+ export function takeTransition(graph, current, values, events, clock) {
102
+ for (let n = 0; n < graph.states.length; n++) {
103
+ const state = graph.states[n]
104
+ if (state.type !== 'GlobalState') continue
105
+ const transition = firstPassing(state.transitions, values, events, current, clock)
106
+ if (transition) return transition
107
+ }
108
+
109
+ const state = find(graph, current)
110
+ return state ? firstPassing(state.transitions, values, events, '', clock) : null
111
+ }
112
+
113
+ /**
114
+ * When a transition's exit time is reached, in ms into its state.
115
+ *
116
+ * @param {any} transition
117
+ * @param {number} length the state's timeline, in ms
118
+ * @returns {number} 0 for a transition without an exit time
119
+ */
120
+ export function exitMs(transition, length) {
121
+ const exit = transition.exitTime
122
+ if (!exit) return 0
123
+ return typeof exit.ms === 'number' ? exit.ms : (exit.fraction || 0) * length
124
+ }
125
+
126
+ /**
127
+ * The earliest exit time among a state's transitions after `after`, in ms.
128
+ *
129
+ * @param {any} state
130
+ * @param {number} length the state's timeline, in ms
131
+ * @param {number} after an exit time already reached, or -1
132
+ * @returns {number} -1 when no exit time is left
133
+ */
134
+ export function nextExit(state, length, after) {
135
+ let next = -1
136
+ const list = (state && state.transitions) || []
137
+ for (let n = 0; n < list.length; n++) {
138
+ if (!list[n].exitTime) continue
139
+ const at = exitMs(list[n], length)
140
+ if (at > after && (next < 0 || at < next)) next = at
141
+ }
142
+ return next
143
+ }
144
+
145
+ /**
146
+ * @param {any[]} transitions
147
+ * @param {Values} values
148
+ * @param {Events} events
149
+ * @param {string} skip a target to pass over, or ""
150
+ * @param {Clock | null} clock
151
+ */
152
+ function firstPassing(transitions, values, events, skip, clock) {
153
+ const list = transitions || []
154
+ for (let n = 0; n < list.length; n++) {
155
+ const transition = list[n]
156
+ if (transition.toState === skip) continue
157
+
158
+ // with a clock, an exit time is checked exactly and replaces the guard that waits for the whole timeline
159
+ const isTimed = !!clock && !!transition.exitTime
160
+ if (isTimed && clock.elapsed < exitMs(transition, clock.length)) continue
161
+ if (guardsPass(transition.guards, values, events, isTimed)) return transition
162
+ }
163
+ return null
164
+ }
165
+
166
+ /**
167
+ * @param {any[]} guards no guards always pass
168
+ * @param {Values} values
169
+ * @param {Events} events
170
+ * @param {boolean} isTimed the transition's exit time is checked apart; its `exit` guard is skipped
171
+ */
172
+ function guardsPass(guards, values, events, isTimed) {
173
+ const list = guards || []
174
+ for (let n = 0; n < list.length; n++) {
175
+ if (isTimed && list[n].exit === true) continue
176
+ if (!guardPasses(list[n], values, events)) return false
177
+ }
178
+ return true
179
+ }
180
+
181
+ /**
182
+ * A guard compares only values of its own type, as the Roku engine must.
183
+ *
184
+ * @param {any} guard
185
+ * @param {Values} values
186
+ * @param {Events} events
187
+ */
188
+ function guardPasses(guard, values, events) {
189
+ if (guard.type === 'Event') return events[guard.inputName] === true
190
+
191
+ const value = values[guard.inputName]
192
+ const expected = resolve(guard.compareTo, values)
193
+ const condition = guard.conditionType
194
+
195
+ const isNumeric = guard.type === 'Numeric' && typeof value === 'number' && typeof expected === 'number'
196
+ if (isNumeric) return compare(value, condition, expected)
197
+
198
+ const isBoolean = guard.type === 'Boolean' && typeof value === 'boolean' && typeof expected === 'boolean'
199
+ const isString = guard.type === 'String' && typeof value === 'string' && typeof expected === 'string'
200
+ if (!isBoolean && !isString) return false
201
+
202
+ return condition === 'NotEqual' ? value !== expected : value === expected
203
+ }
204
+
205
+ /**
206
+ * @param {number} a
207
+ * @param {string} condition
208
+ * @param {number} b
209
+ */
210
+ function compare(a, condition, b) {
211
+ switch (condition) {
212
+ case 'NotEqual': return a !== b
213
+ case 'GreaterThan': return a > b
214
+ case 'GreaterThanOrEqual': return a >= b
215
+ case 'LessThan': return a < b
216
+ case 'LessThanOrEqual': return a <= b
217
+ default: return a === b
218
+ }
219
+ }
220
+
221
+ // Actions
222
+ // ■■■■■■■
223
+
224
+ /**
225
+ * Runs actions against the values. SetFrame, SetProgress and SetTheme have
226
+ * nothing to act on here and are skipped, as on Roku.
227
+ *
228
+ * @param {any[]} actions
229
+ * @param {Graph} graph
230
+ * @param {Values} values updated in place
231
+ * @param {Events} events updated in place: Fire adds a pending event
232
+ * @returns {Effects}
233
+ */
234
+ export function perform(actions, graph, values, events) {
235
+ /** @type {Effects} */
236
+ const effects = { changed: false, events: [], focus: [] }
237
+ const list = actions || []
238
+ for (let n = 0; n < list.length; n++) runOne(list[n], graph, values, events, effects)
239
+ return effects
240
+ }
241
+
242
+ /**
243
+ * @param {any} action
244
+ * @param {Graph} graph
245
+ * @param {Values} values
246
+ * @param {Events} events
247
+ * @param {Effects} effects
248
+ */
249
+ function runOne(action, graph, values, events, effects) {
250
+ const name = action.inputName
251
+ const current = values[name]
252
+ switch (action.type) {
253
+ case 'Fire':
254
+ events[name] = true
255
+ break
256
+ case 'FireCustomEvent':
257
+ effects.events.push(action.value)
258
+ break
259
+ case 'FocusTarget':
260
+ case 'FocusMove':
261
+ case 'FocusClear':
262
+ effects.focus.push(action)
263
+ break
264
+ case 'OpenUrl':
265
+ effects.events.push('openUrl:' + action.url)
266
+ break
267
+ case 'Toggle':
268
+ if (typeof current === 'boolean') setValue(values, name, !current, effects)
269
+ break
270
+ case 'Increment':
271
+ case 'Decrement':
272
+ if (typeof current === 'number') setValue(values, name, current + stepSize(action, values), effects)
273
+ break
274
+ case 'Reset':
275
+ setValue(values, name, defaults(graph)[name], effects)
276
+ break
277
+ case 'SetBoolean':
278
+ case 'SetNumeric':
279
+ case 'SetString':
280
+ setValue(values, name, resolve(action.value, values), effects)
281
+ break
282
+ default:
283
+ }
284
+ }
285
+
286
+ /**
287
+ * The action's value, or 1 when it names no number; negative to decrement.
288
+ *
289
+ * @param {any} action
290
+ * @param {Values} values
291
+ */
292
+ function stepSize(action, values) {
293
+ const size = resolve(action.value, values)
294
+ const step = typeof size === 'number' ? size : 1
295
+ return action.type === 'Decrement' ? -step : step
296
+ }
297
+
298
+ /**
299
+ * A literal, or "$name" naming another input.
300
+ *
301
+ * @param {any} value
302
+ * @param {Values} values
303
+ */
304
+ function resolve(value, values) {
305
+ return typeof value === 'string' && value.charAt(0) === '$' ? values[value.slice(1)] : value
306
+ }
307
+
308
+ /**
309
+ * @param {Values} values
310
+ * @param {string} name
311
+ * @param {any} value
312
+ * @param {Effects} effects
313
+ */
314
+ function setValue(values, name, value, effects) {
315
+ if (value === undefined || value === null) return
316
+ if (values[name] === value) return
317
+ values[name] = value
318
+ effects.changed = true
319
+ }
320
+
321
+ // Interactions
322
+ // ■■■■■■■■■■■■
323
+
324
+ /**
325
+ * Every action the matching interactions run, in order.
326
+ *
327
+ * @param {Graph} graph
328
+ * @param {string} kind "Click", "OnComplete", or a pointer interaction ("PointerEnter", ...)
329
+ * @param {string} stateName for lifecycle interactions; "" matches any
330
+ * @param {string} [target] a pointer interaction's target `rid`; "" for those without a target; omitted matches any
331
+ * @returns {any[]}
332
+ */
333
+ export function interactionActions(graph, kind, stateName, target) {
334
+ const actions = []
335
+ const list = graph.interactions || []
336
+ for (let n = 0; n < list.length; n++) {
337
+ const interaction = list[n]
338
+ const isStateMatch = stateName === '' || typeof interaction.stateName !== 'string' || interaction.stateName === stateName
339
+ const isTargetMatch = target === undefined || (interaction.target ? interaction.target.rid : '') === target
340
+ if (interaction.type === kind && isStateMatch && isTargetMatch) for (let k = 0; k < interaction.actions.length; k++) actions.push(interaction.actions[k])
341
+ }
342
+ return actions
343
+ }
344
+
345
+ /** The interactions a pointer sets off */
346
+ const POINTER_TYPES = [ 'Click', 'PointerDown', 'PointerUp', 'PointerEnter', 'PointerExit', 'PointerMove' ]
347
+
348
+ /**
349
+ * Where the pointer interactions listen: each target's `rid`, or "" for the
350
+ * listeners without one, which watch the whole artboard, and the types on it.
351
+ *
352
+ * @param {Graph} graph
353
+ * @returns {Record<string, string[]>}
354
+ */
355
+ export function pointerTargetsOf(graph) {
356
+ /** @type {Record<string, string[]>} */
357
+ const found = {}
358
+ const list = graph.interactions || []
359
+ for (let n = 0; n < list.length; n++) {
360
+ const interaction = list[n]
361
+ if (POINTER_TYPES.indexOf(interaction.type) < 0) continue
362
+ const rid = interaction.target ? interaction.target.rid : ''
363
+ if (!found[rid]) found[rid] = []
364
+ if (found[rid].indexOf(interaction.type) < 0) found[rid].push(interaction.type)
365
+ }
366
+ return found
367
+ }