Skip to content

Instantly share code, notes, and snippets.

@gordonbrander
Last active August 21, 2018 11:57
Show Gist options
  • Star 8 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save gordonbrander/8920062 to your computer and use it in GitHub Desktop.
Save gordonbrander/8920062 to your computer and use it in GitHub Desktop.
Minimal FRP events and behaviors
// Minimal FRP Behaviors and Events.
// An event function is any function of shape `function (next) { ... }` where
// `next(value)` is a callback to be called by event function. Transformations
// of event are accomplished by wrapping event with another event function,
// and consuming original event within (CPS).
// A behavior is any function of shape `function (time) { ... }`, where
// `time` is current time. Behaviors may capture state, return value from time,
// or be constant. Behaviors must always return a value, but value may
// change state. A function `snapshot` is provided to call behaviors with
// current monotonic time. You should always call behaviors with `snapshot`.
// Note: events are "push" data structures and behaviors are "pull"
// datastructures. This could be rather nice because behaviors are only
// calculated upon request. Events drive behavior calculation and view updates.
// Goals:
// * Updates to behaviors are driven by input events
// * Screen renders are driven by consuming behaviors during animation frame.
// Create monotonic micro timer. If `performance.now` is not supported, `now`
// will fake it by adding small offsets to `Date.now`.
function makeNow() {
// If window.performance.now is supported, return it wrapped in a function.
if ('now' in window.performance) return (function nowMonotonic() {
return window.performance.now();
});
// Otherwise, shim it with a timer that is monotonic, but technically
// innacurate for milisecond fractions.
// Note that offset of more than 3 decemal places of accuracy causes issues.
// Most likely some precision problem with JS numbers.
var offset = 0.001;
var beginning = Date.now();
var prev = 0;
// For browsers that do not support true monotonic time create a faked
return (function nowFakedMonotonic() {
// Peg time start at program start.
var curr = Date.now() - beginning;
// Add offsets until time is monotonic.
while (curr <= prev) curr = curr + offset;
// Return time and memoize our offset time as last time returned.
return (prev = curr);
});
}
var now = makeNow();
// Get snapshot value of behavior (any function of time) at "now" in program
// execution time.
//
// Behaviors are any "function of time" -- e.g. a function that takes a time
// and returns a value. Note that behavior may ignore time given.
// Behaviors are useful for storing and retrieving state.
function snapshot(behave) {
// If behavior is a function, invoke it with current time and return value.
// All other values are treated as constant behaviors. Return them as-is.
//
// Note that time passed to `behave` is monotonic and starts at beginning of
// program execution (not Unix Epoch).
return typeof behave === 'function' ? behave(now()) : behave;
}
// Create a behavior function from an event function and an initial value.
// Returns a behavior who's value changes with event.
// (events, initial) => behavior
function stepper(events, initial) {
var valueAtLastStep = initial;
events(function nextStep(value) {
valueAtLastStep = value;
});
return (function behaveAtLastStep() {
return valueAtLastStep;
});
}
// Lift a function `f` to become a of discrete value arguments, invoking it with snapshot
// value of a behavior.
//
// Returns a behavior representing return value of function applied with
// current behavior value.
function lift(f, behave) {
return (function behaveLift(now) {
return f(snapshot(behave));
});
}
// Lift a function `f` of 2 arguments. Returns a behavior.
function lift2(f, behaveA, behaveB) {
return (function behaveLift(now) {
return f(snapshot(behaveA), snapshot(behaveB));
});
}
// Lift a function `f` of 3 arguments. Returns a behavior.
function lift3(f, behaveA, behaveB, behaveC) {
return (function behaveLift(now) {
return f(snapshot(behaveA), snapshot(behaveB), snapshot(behaveC));
});
}
// Lift a function of `n` arguments, invoking it with current values of an array
// of behaviors. Returns a behavior.
function liftN(f, behaviors) {
return (function behaveLiftN(now) {
return f.apply(null, behaviors.map(snapshot));
});
}
// Create a stateless event transformer which will create a transformed event
// function according to `rule(extra, next, value)` provided.
function transformer(rule) {
// Return `transform` function of shape `transform(event, extra)`.
return (function transform(events, extra) {
// Transform function returns a new event function.
return (function eventsTransformed(next) {
// eventsTransformed wraps and consumes `events`, transforming each
// value with `rule`.
events(function nextTransform(value) {
rule(extra, next, value);
});
});
});
}
var map = transformer(function mapRule(a2b, next, value) {
next(a2b(value));
});
var filter = transformer(function filterRule(predicate, next, value) {
if (predicate(v)) next(value);
});
var reject = transformer(function rejectRule(predicate, next, value) {
if (!predicate(v)) next(value);
});
// Past-dependant reduction.
// Returns an event function containing result of each step of reduction.
function foldp(events, step, initial) {
return (function eventsFoldp(next) {
// Note: reductions is stateful, so we make sure to re-capture accumulated
// state every time `eventsReductions` is called.
var accumulated = initial;
events(function nextReduction(value) {
next(accumulated = step(accumulated, value));
});
});
}
// Accumulate a value as a behavior. Initial value is treated as "step 0".
function reduced(events, step, initial) {
return stepper(foldp(events, step, initial), initial);
}
function id(thing) {
return thing;
}
// Sample value of `behave` every time an event happens in `events`.
// An optional `assemble` function allows you to generate value from behavior
// value and event value. Returns new event function.
function sample(behave, events, assemble) {
assemble = assemble || id;
return (function eventsSampled(next) {
events(function nextSample(value) {
// Assemble value from sampled behavior and current event value.
next(assemble(snapshot(behave), value));
});
});
}
// Assert against past value.
// Returns a new event containing all values for which `assert(a, b)`
// returns true.
function assertp(events, assert) {
return (function eventsAssertp(next) {
var prev = null;
events(function nextAssertp(value) {
// Note that first call to assert will always have a null left.
if (assert(prev, value)) next(value);
prev = value;
});
});
}
function assertDifferent(a, b) {
return a !== b;
}
// Check for changes in `behave` whenever an event occurs in `events`.
// Returns an events function that will trigger `next` every time value of
// `behave` has changed since last event in `trigger`.
function check(behave, events) {
return assertp(sample(behave, events), assertDifferent);
}
// Delay an event by one event, essentially shifting events "forward".
// Returns an events function.
function prior(events) {
return (function eventsPrior(next) {
// Use eventsPrevious as token to represent "hasn't any value yet".
var prev = eventsPrior;
events(function nextCurrent(value) {
if (prev !== eventsPrior) next(prev);
prev = value;
});
});
}
// Return an events function containing events from `eventsLeft`, while behavior
// function `isLeft` is true and events from `eventsRight` while `isLeft`
// is false.
function multiplex(isLeft, eventsLeft, eventsRight) {
return (function eventsMultiplexed(next) {
eventsLeft(function nextLeft(value) {
if (snapshot(isLeft)) next(value);
});
eventsRight(function nextRight(value) {
if (!snapshot(isLeft)) next(value);
});
});
}
// Call function with value as a side-effect. Returns value.
function callWith(value, f) {
f(value);
return value;
}
// Merge array of event functions into a single event, with discrete event
// occurances ordered by time.
function merge(arrayOfEvents) {
return (function eventsMerged(next) {
arrayOfEvents.reduce(callWith, next);
});
}
// Zip 2 events functions by order of events, returning a new events function
// that contains the results of `assemble`.
// Returns an events function.
function zipWith(eventsLeft, eventsRight, assemble) {
return (function eventsZippedWith(next) {
// Create 2 buffer arrays to manage older items.
var bufferLeft = [];
var bufferRight = [];
// Assemble will always be called with a `valueLeft` and a `valueRight`
// argument. If one is missing, the other will be kept buffered until
// the next event comes in.
eventsLeft(function nextBufferLeft(valueLeft) {
// If there are any buffered right values, shift off the first one in,
// and assemble it with left.
if (bufferRight.length) next(assemble(valueLeft, bufferRight.shift()));
// Otherwise buffer until its mate comes along.
else bufferLeft.push(valueLeft);
});
eventsRight(function nextBufferRight(valueRight) {
if (bufferLeft.length) next(assemble(bufferLeft.shift(), valueRight));
else bufferRight.push(valueRight);
});
});
}
// Return a new behavior function that will act as `behaveLeft` until an event
// occurs in events function `events`. From then on, behavior will act
// as `behaveRight`.
function until(events, behaveLeft, behaveRight) {
var isFired = false;
events(function nextEvent() {
isFired = true;
});
return (function behaveUntil() {
return snapshot(!isFired ? behaveLeft : behaveRight);
});
}
// Return a new behavior function that will toggle between `behaveLeft` and
// `behaveRight` every time an event occurs in event function `events`.
function toggle(events, behaveLeft, behaveRight) {
var isLeft = true;
events(function nextEvent() {
isLeft = !isLeft;
});
return (function behaveToggled() {
return snapshot(isLeft ? behaveLeft : behaveRight);
});
}
// Create a behavior from 2 events functions. Events occuring in `onEvents`
// will switch behavior state to true. Events occuring in `offEvents` will
// switch behavior state to false.
function switcher(onEvents, offEvents, initial) {
var isOn = !!initial;
onEvents(function nextOnEvent() {
isOn = true;
});
offEvents(function nextOffEvent() {
isOn = false;
});
return (function behaveSwitch() {
return isOn;
});
}
// Create a behavior containing times since last reset triggered by
// `resetEvents`.
function timer(resetEvents) {
var begin = 0;
resetEvents(function nextReset() {
begin = now();
});
return (function behaveTimer(time) {
return time - begin;
});
}
// Transform an events function, ensuring it will fire no more than once every
// given `ms`. Returns a throttled events function.
function throttle(events, ms) {
return (function eventsThrottled(next) {
var last = -Infinity;
events(function nextThrottle(value) {
if ((now() - last) < ms) next(value);
});
});
}
// Turn an array of behaviors into a behavior of arrays of values.
function all(arrayOfBehaviors) {
return (function behaveAll(time) {
return arrayOfBehaviors.map(snapshot);
});
}
// Event and behavior sources
// -----------------------------------------------------------------------------
// I don't have to support multiple consumers at every step. Instead, support
// them at source when needed.
//
// Note that with behaviors, you don't have to worry about missing events as
// much because behaviors always have values. There may have been events before
// you began listening, but you don't know and don't care (which is the
// reality anyway -- the world didn't begin at program start).
// Split an event into multiple "virtual" events. Returns an event function that
// may be called by `n` consumers before actual event is kicked off.
function split(events, n) {
var nexts = [];
function nextSplit(value) {
return nexts.reduce(callWith, value);
}
return (function eventsSplit(next) {
// Throw error if all demuxed sources have already been used.
if (nexts.length === n) throw Error('No more demuxed sources available');
nexts.push(next);
// If we have pushed in the requested number of consumers, start
// accumulation of source.
if (nexts.length === n) events(nextSplit);
});
}
// Hub a source event so it is only consumed once. Occurances of original event
// will be dispatched to every callback.
//
// Note that callbacks added after event consumption starts will miss
// earlier events.
function hub(events) {
var nexts = [];
var isStarted = false;
return (function eventsHubbed(next) {
nexts.push(next);
// Kick off source event if not done yet.
if (!isStarted) {
events(function nextDispatchToHub(value) {
nexts.reduce(callWith, value);
});
isStarted = true;
}
});
}
// Create an events function containing all DOM events of `name` on `element`.
// Returns an event function.
function on(element, name, useCapture) {
// Note that each call to resulting event function will attach a new listener.
// If you want to share a single listener between multiple consumers,
// transform `eventsOnDomEvents` with `hub()`.
return (function eventsOnDomEvents(next) {
element.addEventListener(name, next, !!useCapture);
});
}
// Select any available requestAnimationFrame.
// [raf]: https://developer.mozilla.org/en-US/docs/Web/API/window.requestAnimationFrame
var requestAnimationFrame = (
window.requestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.msRequestAnimationFrame
);
// Create an events function containing occurances of animation frames.
// Useful for sheduling writes to DOM using something like `changes`.
function animationFrames() {
// Hub event to share frame callback between many consumers.
return (function eventsOnFrame(next) {
// Kick off animation frame loop.
requestAnimationFrame(function nextFrame(time) {
// Call `next()` with time.
next(time);
requestAnimationFrame(nextFrame);
});
});
}
// Determine if mouse is currently being pressed on element.
// Returns a behavior.
function pressed(element) {
return switcher(on(element, 'mousedown'), on(element, 'mouseup'), false);
}
function toCoordsFromEvent(box, event) {
box[0] = event.clientX;
box[1] = event.clientY;
return box;
}
var slice = Array.slice;
function coords(element) {
var moveEvents = on(element, 'mousemove');
// Reuse coords array at every reduction.
var mouseCoords = foldp(moveEvents, toCoordsFromEvent, [0, 0]);
// Upon behavior evaluation, slice result of behavior.
return lift(slice, stepper(mouseCoords, [0, 0]));
}
function getChangedTouches(event) {
return event.changedTouches;
}
// Returns an array behavior function of touches currently on the screen.
function touches(element) {
var start = on(element, 'touchstart');
var move = on(element, 'touchmove');
return stepper(map(merge([start, move]), getChangedTouches), []);
}
function easeInBy(power, factor) {
return Math.pow(factor, power);
}
function easeOutBy(power, factor) {
return 1 - Math.pow(1 - factor, power);
}
function easeInOutBy(power, factor) {
return factor < 0.5 ?
Math.pow(factor * 2, power) / 2 :
1 - (Math.pow((1 - factor) * 2, power) / 2);
}
var easeInQuad = easeInBy.bind(null, 2);
var easeOutQuad = easeOutBy.bind(null, 2);
var easeInOutQuad = easeInOutBy.bind(null, 2);
var easeInCubic = easeInBy.bind(null, 3);
var easeOutCubic = easeOutBy.bind(null, 3);
var easeInOutCubic = easeInOutBy.bind(null, 3);
var easeInQuart = easeInBy.bind(null, 4);
var easeOutQuart = easeOutBy.bind(null, 4);
var easeInOutQuart = easeInOutBy.bind(null, 4);
var easeInQuint = easeInBy.bind(null, 5);
var easeOutQuint = easeOutBy.bind(null, 5);
var easeInOutQuint = easeInOutBy.bind(null, 5);
// Create a behavior containing a series of interpolated "echoes" between
// [0..1]. Every time an event occurs in `events`, the resulting behavior will
// be reset to `0`, then ease back to `1` within given `duration`. An easing
// curve may be defined via `ease` function.
//
// Returns a behavior function of values `[0..1]`.
function interpolate(resetEvents, duration, ease) {
ease = (ease || id);
// Initialize reset variable. Last time reset is `-Infinity` (never).
var reset = -Infinity;
resetEvents(function nextReset() {
// Reset at time of event occurance.
reset = now();
});
return (function behaveInterpolated(time) {
if (time > (reset + duration)) return 1;
if (time < reset) return 0;
return ease((time - reset) / duration);
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment