ixfx
Version:
A framework for programming interactivity
853 lines (844 loc) • 22 kB
JavaScript
import {
resolveLogOption
} from "./chunk-N6YIY4CM.js";
import {
SimpleEventEmitter,
intervalToMs
} from "./chunk-72EKR3DZ.js";
import {
randomElement,
shuffle,
unique
} from "./chunk-LVLPUJZY.js";
import {
defaultComparer
} from "./chunk-LKZ4HZTV.js";
import {
clamp
} from "./chunk-QAEJS6HO.js";
import {
__export
} from "./chunk-L5EJU35C.js";
// src/flow/StateMachine.ts
var StateMachine_exports = {};
__export(StateMachine_exports, {
WithEvents: () => StateMachineWithEvents,
cloneState: () => cloneState,
driver: () => init2,
fromList: () => fromList,
fromListBidirectional: () => fromListBidirectional,
init: () => init,
isDone: () => isDone,
isValidTransition: () => isValidTransition,
next: () => next,
normaliseTargets: () => normaliseTargets,
possible: () => possible,
possibleTargets: () => possibleTargets,
reset: () => reset,
to: () => to,
validateMachine: () => validateMachine,
validateTransition: () => validateTransition
});
// src/flow/Execute.ts
var run = async (expressions, opts = {}, args) => {
const results = [];
const compareFn = opts.rank ?? defaultComparer;
let expressionsArray = Array.isArray(expressions) ? expressions : [expressions];
if (opts.shuffle) expressionsArray = shuffle(expressionsArray);
for (let i = 0; i < expressionsArray.length; i++) {
const exp = expressionsArray[i];
let r;
if (typeof exp === "function") {
r = await exp(args);
} else {
r = exp;
}
if (r !== void 0) {
results.push(r);
results.sort(compareFn);
}
if (typeof opts.stop !== "undefined") {
if (opts.stop(r, results)) {
break;
}
}
}
if (opts.filter) {
return results.filter(opts.filter);
}
return results;
};
var runSingle = async (expressions, opts = {}, args) => {
const results = await run(expressions, opts, args);
if (!results) return;
if (results.length === 0) return;
const at = opts.at ?? -1;
return results.at(at);
};
// src/flow/StateMachineDriver.ts
async function init2(machine, handlersOrOpts) {
const opts = Array.isArray(handlersOrOpts) ? {
handlers: handlersOrOpts
} : handlersOrOpts;
const debug = resolveLogOption(opts.debug, {
category: `StateMachineDriver`
});
const byState = /* @__PURE__ */ new Map();
for (const h of opts.handlers) {
const ifBlock = Array.isArray(h.if) ? h.if : [h.if];
for (const state of ifBlock) {
if (typeof state !== `string`) {
throw new TypeError(
`Expected single or array of strings for the 'if' field. Got: '${typeof state}'.`
);
}
if (byState.has(state)) {
throw new Error(
`Multiple handlers defined for state '${state}'. There should be at most one.`
);
}
byState.set(state, h);
}
}
const runOpts = {
// Rank results by score
rank: (a, b) => {
return defaultComparer(a.score ?? 0, b.score ?? 0);
},
shuffle: opts.shuffleHandlers ?? false
};
let sm = init(machine);
for (const [ifState] of byState) {
if (typeof sm.machine[ifState] === `undefined` && ifState !== `__fallback`) {
throw new Error(
`StateMachineDriver handler references a state ('${ifState}') which is not defined on the machine. Therefore this handler will never run.'`
);
}
}
const run2 = async () => {
debug(`Run. State: ${sm.value}`);
const state = sm.value;
let handler = byState.get(state);
if (handler === void 0) {
debug(` No handler for state '${state}', trying __fallback`);
handler = byState.get(`__fallback`);
}
if (handler === void 0) {
debug(` No __fallback handler`);
return;
}
const runOptionsForHandler = handler.resultChoice === `first` ? {
...runOpts,
stop: (latest) => {
if (!latest) return false;
if (`reset` in latest) return true;
if (`next` in latest && latest.next !== void 0) return true;
return false;
}
} : runOpts;
const results = await run(
handler.then,
runOptionsForHandler,
sm
);
debug(
` In state '${sm.value}' results: ${results.length}. Choice: ${handler.resultChoice}`
);
let r;
switch (handler.resultChoice ?? `highest`) {
case `highest`: {
r = results.at(-1);
break;
}
case `first`: {
r = results[0];
break;
}
case `lowest`: {
r = results.at(0);
break;
}
case `random`: {
r = randomElement(results);
break;
}
default: {
throw new Error(
`Unknown 'resultChoice' option: ${handler.resultChoice}. Expected highest, first, lowest or random`
);
}
}
debug(` Chosen result: ${JSON.stringify(r)}`);
if (r?.reset) {
sm = reset(sm);
} else if (r && r.next) {
if (typeof r.next === `boolean`) {
sm = next(sm);
} else {
debug(JSON.stringify(results));
sm = to(sm, r.next);
}
}
return sm;
};
return {
reset: () => {
sm = reset(sm);
},
getValue: () => sm.value,
run: run2,
to: (state) => {
sm = to(sm, state);
return sm;
}
};
}
// src/flow/Stopwatch.ts
var Stopwatch_exports = {};
__export(Stopwatch_exports, {
infinity: () => infinity,
interval: () => interval,
once: () => once,
since: () => since
});
// src/flow/Timer.ts
function hasElapsed(elapsed) {
const t = relative(intervalToMs(elapsed, 0), { timer: elapsedMillisecondsAbsolute(), clampValue: true });
return () => t.isDone;
}
function ofTotal(duration, opts = {}) {
const totalMs = intervalToMs(duration);
if (!totalMs) throw new Error(`Param 'duration' not valid`);
const timerOpts = {
...opts,
timer: elapsedMillisecondsAbsolute()
};
let t;
return () => {
if (!t) {
t = relative(totalMs, timerOpts);
}
return t.elapsed;
};
}
function ofTotalTicks(totalTicks, opts = {}) {
const timerOpts = {
...opts,
timer: elapsedTicksAbsolute()
};
let t;
return () => {
if (!t) {
t = relative(totalTicks, timerOpts);
}
return t.elapsed;
};
}
var timerAlwaysDone = () => ({
elapsed: 1,
isDone: true,
reset() {
},
mod(amt) {
}
});
var timerNeverDone = () => ({
elapsed: 0,
isDone: false,
reset() {
},
mod() {
}
});
var relative = (total, options = {}) => {
if (!Number.isFinite(total)) {
return timerAlwaysDone();
} else if (Number.isNaN(total)) {
return timerNeverDone();
}
const clampValue = options.clampValue ?? false;
const wrapValue = options.wrapValue ?? false;
if (clampValue && wrapValue) throw new Error(`clampValue and wrapValue cannot both be enabled`);
let modulationAmount = 1;
const timer = options.timer ?? elapsedMillisecondsAbsolute();
let lastValue = 0;
const computeElapsed = (value) => {
lastValue = value;
let v = value / (total * modulationAmount);
if (clampValue) v = clamp(v);
else if (wrapValue && v >= 1) v = v % 1;
return v;
};
return {
mod(amt) {
modulationAmount = amt;
},
get isDone() {
return computeElapsed(lastValue) >= 1;
},
get elapsed() {
return computeElapsed(timer.elapsed);
},
reset: () => {
timer.reset();
}
};
};
var frequencyTimer = (frequency, options = {}) => {
const timer = options.timer ?? elapsedMillisecondsAbsolute();
const cyclesPerSecond = frequency / 1e3;
let modulationAmount = 1;
const computeElapsed = () => {
const v = timer.elapsed * (cyclesPerSecond * modulationAmount);
const f = v - Math.floor(v);
if (f < 0) {
throw new Error(
`Unexpected cycle fraction less than 0. Elapsed: ${v} f: ${f}`
);
}
if (f > 1) {
throw new Error(
`Unexpected cycle fraction more than 1. Elapsed: ${v} f: ${f}`
);
}
return f;
};
return {
mod: (amt) => {
modulationAmount = amt;
},
reset: () => {
timer.reset();
},
get isDone() {
return computeElapsed() >= 1;
},
get elapsed() {
return computeElapsed();
}
};
};
var elapsedMillisecondsAbsolute = () => {
let start = performance.now();
return {
/**
* Reset timer
*/
reset: () => {
start = performance.now();
},
/**
* Returns elapsed time since start
*/
get elapsed() {
return performance.now() - start;
}
};
};
var elapsedTicksAbsolute = () => {
let start = 0;
return {
/**
* Reset ticks to 0. The next call to `elapsed` will return 1.
*/
reset: () => {
start = 0;
},
/**
* Get current ticks without incrementing.
*/
get peek() {
return start;
},
/**
* Returns the number of elapsed ticks as well as
* incrementing the tick count.
*
* Minimum is 1
*
* Use {@link peek} to get the current ticks without incrementing.
*/
get elapsed() {
return ++start;
}
};
};
var timerWithFunction = (fn, timer) => {
if (typeof fn !== `function`) throw new Error(`Param 'fn' should be a function. Got: ${typeof fn}`);
let startCount = 1;
return {
get elapsed() {
return timer.elapsed;
},
get isDone() {
return timer.isDone;
},
get runState() {
if (timer.isDone) return `idle`;
return `scheduled`;
},
/**
* Returns 1 if it has been created, returns +1 for each additional time the timer has been reset.
*/
get startCount() {
return startCount;
},
get startCountTotal() {
return startCount;
},
compute: () => {
const elapsed = timer.elapsed;
return fn(elapsed);
},
reset: () => {
timer.reset();
startCount++;
}
};
};
// src/flow/Stopwatch.ts
var since = () => {
const start = performance.now();
return () => {
return performance.now() - start;
};
};
var interval = () => {
let start = performance.now();
return () => {
const now = performance.now();
const x = now - start;
start = now;
return x;
};
};
var once = () => {
const start = Date.now();
let stoppedAt = 0;
return () => {
if (stoppedAt === 0) {
stoppedAt = Date.now() - start;
}
return stoppedAt;
};
};
var infinity = () => {
return () => {
return Number.POSITIVE_INFINITY;
};
};
// src/flow/StateMachineWithEvents.ts
var StateMachineWithEvents = class extends SimpleEventEmitter {
#sm;
#smInitial;
#debug;
#isDoneNeedsFiring = false;
#isDone = false;
#changedAt = infinity();
/**
* Create a state machine with initial state, description and options
* @param m Machine description
* @param opts Options for machine (defaults to `{debug:false}`)
*/
constructor(m, opts = {}) {
super();
this.#debug = opts.debug ?? false;
this.#sm = init(m, opts.initial);
this.#smInitial = cloneState(this.#sm);
}
#setIsDone(v) {
if (this.#isDone === v) return;
this.#isDone = v;
if (v) {
this.#isDoneNeedsFiring = true;
setTimeout(() => {
if (!this.#isDoneNeedsFiring) return;
this.#isDoneNeedsFiring = false;
this.fireEvent(`stop`, { state: this.#sm.value });
}, 2);
} else {
this.#isDoneNeedsFiring = false;
}
}
/**
* Return a list of possible states from current state.
*
* If list is empty, no states are possible. Otherwise lists
* possible states, including 'null' for terminal
*/
get statesPossible() {
return possible(this.#sm);
}
/**
* Return a list of all defined states
*/
get statesDefined() {
return Object.keys(this.#sm.machine);
}
/**
* Moves to the next state if possible. If multiple states are possible, it will use the first.
* If machine is finalised, no error is thrown and null is returned.
*
* @returns {(string|null)} Returns new state, or null if machine is finalised
*/
next() {
const p = possible(this.#sm);
if (p.length === 0) return null;
this.state = p[0];
return p[0];
}
/**
* Returns _true_ if state machine is in its final state
*
* @returns
*/
get isDone() {
return isDone(this.#sm);
}
/**
* Resets machine to initial state
*/
reset() {
this.#setIsDone(false);
this.#sm = cloneState(this.#smInitial);
this.#changedAt = since();
}
/**
* Throws if it's not valid to transition to `newState`
* @param newState
* @returns
*/
validateTransition(newState) {
validateTransition(this.#sm, newState);
}
/**
* Returns _true_ if `newState` is valid transition from current state.
* Use {@link validateTransition} if you want an explanation for the _false_ results.
* @param newState
* @returns
*/
isValid(newState) {
return isValidTransition(this.#sm, newState);
}
/**
* Gets or sets state. Throws an error if an invalid transition is attempted.
* Use `isValid()` to check validity without changing.
*
* If `newState` is the same as current state, the request is ignored silently.
*/
set state(newState) {
const priorState = this.#sm.value;
if (newState === this.#sm.value) return;
this.#sm = to(this.#sm, newState);
if (this.#debug) {
console.log(`StateMachine: ${priorState} -> ${newState}`);
}
this.#changedAt = since();
setTimeout(() => {
this.fireEvent(`change`, { newState, priorState });
}, 1);
if (isDone(this.#sm)) this.#setIsDone(true);
}
get state() {
return this.#sm.value;
}
/**
* Returns timestamp when state was last changed.
* See also `elapsed`
*/
get changedAt() {
return this.#changedAt();
}
/**
* Returns milliseconds elapsed since last state change.
* See also `changedAt`
*/
get elapsed() {
return this.#changedAt();
}
};
// src/flow/StateMachine.ts
var cloneState = (toClone) => {
return Object.freeze({
value: toClone.value,
visited: [...toClone.visited],
machine: toClone.machine
});
};
var init = (stateMachine, initialState) => {
const [machine, machineValidationError] = validateMachine(stateMachine);
if (!machine) throw new Error(machineValidationError);
const state = (
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
initialState ?? Object.keys(machine.states)[0]
);
if (machine.states[state] === void 0) {
throw new TypeError(`Initial state not found`);
}
const transitions = validateAndNormaliseTransitions(machine.states);
if (transitions === void 0) {
throw new Error(`Could not normalise transitions`);
}
return Object.freeze({
value: state,
visited: [],
machine: Object.fromEntries(transitions)
});
};
var reset = (sm) => {
return init(sm.machine);
};
var validateMachine = (smOrTransitions) => {
if (smOrTransitions === void 0) {
return [void 0, `Parameter undefined`];
}
if (smOrTransitions === null) {
return [void 0, `Parameter null`];
}
if (`states` in smOrTransitions) {
return [smOrTransitions, ``];
}
if (typeof smOrTransitions === `object`) {
return [
{
// @ts-expect-error
states: smOrTransitions
},
``
];
}
return [
void 0,
`Unexpected type: ${typeof smOrTransitions}. Expected object`
];
};
var isDone = (sm) => {
return possible(sm).length === 0;
};
var possibleTargets = (sm) => {
validateMachineState(sm);
const fromS = sm.machine[sm.value];
if (fromS.length === 1 && fromS[0].state === null) return [];
return fromS;
};
var possible = (sm) => {
const targets = possibleTargets(sm);
return targets.map((v) => v.state);
};
var normaliseTargets = (targets) => {
const normaliseSingleTarget = (target) => {
if (target === null) return { state: null };
if (typeof target === `string`) {
return {
state: target
};
} else if (typeof target === `object` && `state` in target) {
const targetState = target.state;
if (typeof targetState !== `string`) {
throw new TypeError(
`Target 'state' field is not a string. Got: ${typeof targetState}`
);
}
if (`preconditions` in target) {
return {
state: targetState,
preconditions: target.preconditions
};
}
return { state: targetState };
} else {
throw new Error(
`Unexpected type: ${typeof target}. Expected string or object with 'state' field.`
);
}
};
if (Array.isArray(targets)) {
let containsNull = false;
const mapResults = targets.map((t) => {
const r = normaliseSingleTarget(t);
if (!r) throw new Error(`Invalid target`);
containsNull = containsNull || r.state === null;
return r;
});
if (containsNull && mapResults.length > 1) {
throw new Error(`Cannot have null as an possible state`);
}
return mapResults;
} else {
const target = normaliseSingleTarget(targets);
if (!target) return;
return [target];
}
};
var validateAndNormaliseTransitions = (d) => {
const returnMap = /* @__PURE__ */ new Map();
for (const [topLevelState, topLevelTargets] of Object.entries(d)) {
if (typeof topLevelState === `undefined`) {
throw new TypeError(`Top-level undefined state`);
}
if (typeof topLevelTargets === `undefined`) {
throw new TypeError(`Undefined target state for ${topLevelState}`);
}
if (returnMap.has(topLevelState)) {
throw new Error(`State defined twice: ${topLevelState}`);
}
if (topLevelState.includes(` `)) {
throw new Error(`State names cannot contain spaces`);
}
returnMap.set(topLevelState, []);
}
for (const [topLevelState, topLevelTargets] of Object.entries(d)) {
const targets = normaliseTargets(topLevelTargets);
if (targets === void 0) throw new Error(`Could not normalise target`);
if (targets !== null) {
const seenStates = /* @__PURE__ */ new Set();
for (const target of targets) {
if (seenStates.has(target.state)) {
throw new Error(
`Target state '${target.state}' already exists for '${topLevelState}'`
);
}
seenStates.add(target.state);
if (target.state === null) continue;
if (!returnMap.has(target.state)) {
throw new Error(
`Target state '${target.state}' is not defined as a top-level state. Defined under: '${topLevelState}'`
);
}
}
returnMap.set(topLevelState, targets);
}
}
return returnMap;
};
var validateMachineState = (state) => {
if (state === void 0) {
throw new TypeError(`Parameter 'state' is undefined`);
}
if (typeof state.value !== `string`) {
throw new TypeError(`Existing state is not a string`);
}
};
var to = (sm, toState) => {
validateMachineState(sm);
validateTransition(sm, toState);
return Object.freeze({
value: toState,
machine: sm.machine,
visited: unique([sm.visited, [sm.value]])
});
};
var next = (sm) => {
const first = possibleTargets(sm).at(0);
if (!first || first.state === null) {
throw new Error(
`Not possible to move to a next state from '${sm.value}`
);
}
return to(sm, first.state);
};
var isValidTransition = (sm, toState) => {
try {
validateTransition(sm, toState);
return true;
} catch {
return false;
}
};
var validateTransition = (sm, toState) => {
if (toState === null) throw new Error(`Cannot transition to null state`);
if (toState === void 0) {
throw new Error(`Cannot transition to undefined state`);
}
if (typeof toState !== `string`) {
throw new TypeError(
`Parameter 'toState' should be a string. Got: ${typeof toState}`
);
}
const p = possible(sm);
if (p.length === 0) throw new Error(`Machine is in terminal state`);
if (!p.includes(toState)) {
throw new Error(
`Target state '${toState}' not available at current state '${sm.value}'. Possible states: ${p.join(`, `)}`
);
}
};
var fromList = (...states) => {
const t = {};
if (!Array.isArray(states)) throw new Error(`Expected array of strings`);
if (states.length <= 2) throw new Error(`Expects at least two states`);
for (let index = 0; index < states.length; index++) {
const s = states[index];
if (typeof s !== `string`) {
throw new TypeError(
`Expected array of strings. Got type '${typeof s}' at index ${index}`
);
}
t[s] = index === states.length - 1 ? null : states[index + 1];
}
return t;
};
var fromListBidirectional = (...states) => {
const t = {};
if (!Array.isArray(states)) throw new Error(`Expected array of strings`);
if (states.length < 2) throw new Error(`Expects at least two states`);
for (const [index, s] of states.entries()) {
if (typeof s !== `string`) {
throw new TypeError(
`Expected array of strings. Got type '${typeof s}' at index ${index}`
);
}
t[s] = [];
}
for (let index = 0; index < states.length; index++) {
const v = t[states[index]];
if (index === states.length - 1) {
if (states.length > 1) {
v.push(states[index - 1]);
} else {
t[states[index]] = null;
}
} else {
v.push(states[index + 1]);
if (index > 0) v.push(states[index - 1]);
}
}
return t;
};
export {
hasElapsed,
ofTotal,
ofTotalTicks,
timerAlwaysDone,
timerNeverDone,
relative,
frequencyTimer,
elapsedMillisecondsAbsolute,
elapsedTicksAbsolute,
timerWithFunction,
since,
interval,
once,
infinity,
Stopwatch_exports,
run,
runSingle,
init2 as init,
StateMachineWithEvents,
cloneState,
init as init2,
reset,
validateMachine,
isDone,
possibleTargets,
possible,
normaliseTargets,
to,
next,
isValidTransition,
validateTransition,
fromList,
fromListBidirectional,
StateMachine_exports
};
//# sourceMappingURL=chunk-LHELVIWO.js.map