/** * Time-lapse for the trading network: step the graph through time, a month or * a week at a time, so a growing or shrinking network can be watched rather * than inferred. * * Separate from stats-network.js because it answers a different question. That * module draws one graph as well as it can; this one holds a graph still while * time moves underneath it. * * Position stability: the layout runs ONCE over the union of every step, and * each step only shows and hides elements at positions that never move. * * Each step has three layers, always measured in months whatever the step * (see StatsService::networkTimelapse()): `current`, `ghost` (the month * before) and `back` (everything older). They arrive as changes per step and * are rebuilt on demand here - never all at once: ten years of weekly steps * would be ~60 MB of sets in the browser. */ /** Keep every Nth layer in full, so a jump rebuilds at most N-1 steps. */ const CHECKPOINT_EVERY = 16; /** How many rebuilt layers to keep for reuse; playback only needs neighbours. */ const RECENT_LAYERS = 8; /** * One layer over all steps, rebuilt on demand from its changes. * * Moving forward one step costs one list of changes; a jump (scrubbing) starts * from the nearest checkpoint. Memory stays at the checkpoints plus a few * recent layers instead of every step in full. */ class LayerStream { /** @param {Array} deltas [{nodes_added, nodes_removed, edges_added, edges_removed}] per step */ constructor(deltas) { this.deltas = deltas || []; this.checkpoints = new Map(); this.recent = new Map(); const nodes = new Set(); const edges = new Set(); this.deltas.forEach((delta, i) => { LayerStream.apply(nodes, edges, delta); if (i % CHECKPOINT_EVERY === 0) { this.checkpoints.set(i, { nodes: new Set(nodes), edges: new Set(edges) }); } }); } static apply(nodes, edges, delta) { (delta.nodes_removed || []).forEach((id) => nodes.delete(id)); (delta.nodes_added || []).forEach((id) => nodes.add(id)); (delta.edges_removed || []).forEach((key) => edges.delete(key)); (delta.edges_added || []).forEach((key) => edges.add(key)); } get length() { return this.deltas.length; } /** The layer at step i, as {nodes: Set, edges: Set}. Treat as read-only. */ at(i) { if (i < 0 || i >= this.deltas.length) { return null; } if (this.recent.has(i)) { return this.recent.get(i); } let layer; if (this.checkpoints.has(i)) { layer = this.checkpoints.get(i); } else { // From the step before when it is at hand - the playback case - // otherwise from the nearest checkpoint. const from = this.recent.has(i - 1) ? i - 1 : Math.floor(i / CHECKPOINT_EVERY) * CHECKPOINT_EVERY; const base = this.recent.get(from) || this.checkpoints.get(from); const nodes = new Set(base.nodes); const edges = new Set(base.edges); for (let k = from + 1; k <= i; k++) { LayerStream.apply(nodes, edges, this.deltas[k]); } layer = { nodes, edges }; } this.recent.set(i, layer); if (this.recent.size > RECENT_LAYERS) { this.recent.delete(this.recent.keys().next().value); } return layer; } } /** * The time-lapse payload as steps and layers. * * @param {object} payload {frames, ghosts, history} as sent by the server; * frames also carry each step's label and date. * @returns {{count: number, step: Function, layers: Function}} */ function timelapseLayers(payload) { const frames = (payload && payload.frames) || []; const current = new LayerStream(frames); const ghost = payload && payload.ghosts ? new LayerStream(payload.ghosts) : null; const back = payload && payload.history ? new LayerStream(payload.history) : null; return { count: frames.length, /** {label, till} of step i. */ step: (i) => (frames[i] ? { label: frames[i].label, till: frames[i].till } : null), /** {current, ghost, back} of step i; ghost and back are null when accumulating. */ layers: (i) => ({ current: current.at(i), ghost: ghost ? ghost.at(i) : null, back: back ? back.at(i) : null, }), }; } const keyCache = new WeakMap(); function frameKeys(frame) { // Layers from a LayerStream already are sets. if (frame && frame.nodes instanceof Set) { return frame; } // Built once per frame, not at every boundary: an accumulated frame holds // ~1100 nodes and ~2300 edges, and rebuilding three such sets at each // month was part of what made a month boundary a dropped frame. let keys = keyCache.get(frame); if (!keys) { keys = { nodes: new Set((frame.nodes || []).map((n) => n.id)), // Frames rebuilt from deltas carry edges as their keys already; // older-style frames carry {source, target, type_id}. edges: new Set((frame.edges || []).map((e) => (typeof e === 'string' ? e : `${e.source}|${e.target}|${e.type_id}`))), }; keyCache.set(frame, keys); } return keys; } /** * Build the element lookup every transition uses, up front. * * Built lazily it cost the FIRST step boundary a visible hitch as playback * starts; here it is part of loading instead. */ function warmTimelapse(cy) { elementIndex(cy); } /** * Graph elements by frame key, built once per graph. * * Edges map to a LIST: the flow view draws one relationship as two directed * edges, and both carry the same frame key. */ function elementIndex(cy) { let index = cy.scratch('_timelapseIndex'); if (!index) { index = { nodes: new Map(), edges: new Map() }; cy.nodes().forEach((node) => index.nodes.set(node.id(), node)); cy.edges().forEach((edge) => { const key = edge.data('key'); if (!index.edges.has(key)) { index.edges.set(key, []); } index.edges.get(key).push(edge); }); cy.scratch('_timelapseIndex', index); } return index; } /** * Set an element's visibility, skipping the write when nothing changes. * * Every style write marks the element dirty for Cytoscape's next repaint, and * a write to an edge's endpoint forces its geometry to be recomputed. Most of * the graph is identical between two adjacent months, so rewriting all 3576 * elements at every boundary was work the renderer then had to absorb. The * last value written is kept in the element's scratch pad, which is cheaper * to read than the computed style. */ function setShown(element, shown, opacity = shown ? 1 : 0) { const state = element.scratch('_timelapse') || {}; if (state.shown !== shown) { element.style('display', shown ? 'element' : 'none'); state.shown = shown; } if (state.opacity !== opacity) { element.style('opacity', opacity); state.opacity = opacity; } element.scratch('_timelapse', state); } /** * Size changes smaller than this are left until they add up. * * Resizing a node recomputes every edge attached to it, and the node that * grows most is always the busiest hub - ~700 edges on the full graph. In a * cumulative time-lapse a node grows by under a pixel a month (measured: max * 0.98px, median 0.01px), which nobody can see, so rewriting it every month * cost a full hub-edge recompute for nothing. The change is applied once it * is large enough to show. */ const SIZE_STEP = 0.5; function setSize(node, size) { if (!Number.isFinite(size)) { return; } const current = node.data('size'); if (!Number.isFinite(current) || Math.abs(current - size) >= SIZE_STEP) { node.data('size', size); } } /** * The layers of a time-lapse frame, front to back. * * current this month's trade full colour * ghost last month's, not traded this month 25%, grey * history everything up to two months back 15%, grey * (per-month frames only) * hidden * * An element sits in the frontmost layer it qualifies for, so a profile * trading this month is in colour even if it also traded years ago. */ const GHOST_OPACITY = 0.25; const HISTORY_OPACITY = 0.15; const TIER_OPACITY = { current: 1, ghost: GHOST_OPACITY, history: HISTORY_OPACITY, hidden: 0, }; /** * The grey of the same brightness as a colour. * * "Completely desaturated" rather than one flat grey: a dark node stays dark * and a light one light, so the ghost still reads as the same picture, just * drained of colour. Rec. 709 luma weights; hex and rgb()/rgba() in, hex out. */ function toGrey(color) { let r; let g; let b; const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(String(color || '').trim()); if (hex) { const h = hex[1].length === 3 ? hex[1].split('').map((c) => c + c).join('') : hex[1]; r = parseInt(h.slice(0, 2), 16); g = parseInt(h.slice(2, 4), 16); b = parseInt(h.slice(4, 6), 16); } else { const rgb = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i.exec(String(color || '')); if (!rgb) { return '#94a3b8'; } [r, g, b] = rgb.slice(1, 4).map(Number); } const y = Math.round(0.2126 * r + 0.7152 * g + 0.0722 * b); const part = y.toString(16).padStart(2, '0'); return `#${part}${part}${part}`; } /** * Put an element in a layer's colour and depth. * * Ghost and history share the grey style (class `ghost`); history adds class * `history`, which only sends it further back. The grey is taken from the * colour the element has when it first turns grey, so it is that month's * colour drained, not some later state's. `hidden` changes no classes: an * element fading out keeps the look it had, and is reset when it is hidden. */ function setTier(element, tier) { const state = element.scratch('_timelapse') || {}; const grey = tier === 'ghost' || tier === 'history'; if (state.tier === tier) { if (grey && element.data('ghostColor') !== state.grey) { element.data('ghostColor', state.grey); } return; } const wasGrey = state.tier === 'ghost' || state.tier === 'history'; if (grey && !wasGrey) { state.grey = toGrey(baseColor(element)); element.data('ghostColor', state.grey); } else if (grey && element.data('ghostColor') !== state.grey) { // Stopped part way through a colour blend (a jump while fading). element.data('ghostColor', state.grey); } if (tier !== 'hidden') { element.toggleClass('ghost', grey); element.toggleClass('history', tier === 'history'); } state.tier = tier; element.scratch('_timelapse', state); } /** Back to a plain element once hidden, so nothing grey lingers into reuse. */ function clearTier(element) { const state = element.scratch('_timelapse') || {}; element.removeClass('ghost history'); state.tier = 'hidden'; element.scratch('_timelapse', state); } /** Which layer an element is in, given the frame and what lies behind it. */ function tierOf(key, frameSet, prevSet, backSet) { if (frameSet.has(key)) { return 'current'; } if (prevSet && prevSet.has(key)) { return 'ghost'; } if (backSet && backSet.has(key)) { return 'history'; } return 'hidden'; } /** * Show one step on an already-laid-out graph. * * Nothing moves: elements are shown or hidden in place, so a node that traded * in 2022 and again in 2025 sits in the same spot both times. * * Instant, with no transition. Used for the opening step, for scrubbing, and as * the reference the fast transition path is tested against. * * @param {object} cy Cytoscape instance holding the union. * @param {object} layers {current, ghost, back} - see timelapseLayers(). * @param {Function} sizeOf Optional: node size by node id. */ function showFrame(cy, layers, sizeOf = null) { const current = frameKeys(layers.current); const ghost = layers.ghost ? frameKeys(layers.ghost) : null; const back = layers.back ? frameKeys(layers.back) : null; const place = (element, tier) => { if (tier === 'hidden') { setShown(element, false); clearTier(element); return; } setTier(element, tier); setShown(element, true, TIER_OPACITY[tier]); }; cy.batch(() => { cy.nodes().forEach((node) => { const id = node.id(); place(node, tierOf(id, current.nodes, ghost && ghost.nodes, back && back.nodes)); // Sizes settle here, never per animation frame: see SIZE_STEP. // Every shown node, ghosts and history included: all of them are // sized as of this step's date. if (sizeOf && (node.scratch('_timelapse') || {}).shown) { setSize(node, sizeOf(id)); } }); cy.edges().forEach((edge) => { const key = edge.data('key'); place(edge, tierOf(key, current.edges, ghost && ghost.edges, back && back.edges)); }); }); } /** * Work out once what a transition has to touch. * * Every element moves from its layer before (`before`: {current, ghost, back}) * to its layer after. Only elements whose layer CHANGES are touched, so a step * costs what changed, not the size of the graph: newcomers fade in, leavers * turn grey and sink to the ghost, ghosts sink into the history, and without a * history (accumulating) they expire. * * WORKS FROM SET DIFFERENCES. The first version walked every element here - * 3576 on the full graph - and settling walked them all again: 34-49ms per * boundary, up to 71ms, in one animation frame, which was the stutter. * Candidates are the members of the current and ghost layers on either side, * plus whatever entered or left the history; anything else is in the same * layer on both sides and already settled. * * Assumes the graph is showing `before` settled - true after showFrame() with * it and after finishTransition(). * * Starts from what is ON SCREEN, not from the layer's settled opacity: during * playback fades overlap several steps, so an element can still be half way * through its previous fade when the next step retargets it. * * Colour blends too: an element turning grey keeps the `ghost` class with its * ghostColor walking from colour to grey, and one turning back to colour walks * the other way and drops the class at the end. Only elements whose look * changes blend, a few dozen a step, so this stays cheap. */ function prepareTransition(cy, before, after) { const b = { current: frameKeys(before.current), ghost: before.ghost ? frameKeys(before.ghost) : null, back: before.back ? frameKeys(before.back) : null, }; const a = { current: frameKeys(after.current), ghost: after.ghost ? frameKeys(after.ghost) : null, back: after.back ? frameKeys(after.back) : null, }; const index = elementIndex(cy); // {element, from: opacity, to: opacity} const arriving = []; const departing = []; // The subset of `departing` that ends hidden. const expiring = []; const each = (kind, key, fn) => { if (kind === 'nodes') { const node = index.nodes.get(key); if (node) { fn(node); } } else { (index.edges.get(key) || []).forEach(fn); } }; cy.batch(() => { ['nodes', 'edges'].forEach((kind) => { const set = (layer) => (layer ? layer[kind] : null); const candidates = new Set(); [a.current, b.current, a.ghost, b.ghost].forEach((layer) => { if (layer) { layer[kind].forEach((key) => candidates.add(key)); } }); // What entered or left the history: large sets, so compared // rather than added wholesale. const backBefore = set(b.back); const backAfter = set(a.back); if (backAfter) { backAfter.forEach((key) => { if (!backBefore || !backBefore.has(key)) { candidates.add(key); } }); } if (backBefore) { backBefore.forEach((key) => { if (!backAfter || !backAfter.has(key)) { candidates.add(key); } }); } candidates.forEach((key) => { const was = tierOf(key, b.current[kind], set(b.ghost), backBefore); const will = tierOf(key, a.current[kind], set(a.ghost), backAfter); if (was === will) { return; } const start = TIER_OPACITY[was]; const end = TIER_OPACITY[will]; each(kind, key, (element) => { const state = element.scratch('_timelapse') || {}; const from = state.shown ? state.opacity : 0; const item = { element, from, to: end, colorFrom: null, colorTo: null, tierAtEnd: null }; // A fade to hidden keeps the look it has. if (will !== 'hidden') { const shownColor = displayedColor(element); const willGrey = will === 'ghost' || will === 'history'; if (willGrey || !element.hasClass('ghost')) { setTier(element, will); } else { // Back to colour: stay grey-classed while the // colour walks back, and switch at the end. item.tierAtEnd = will; } const target = willGrey ? (element.scratch('_timelapse') || {}).grey : baseColor(element); if (element.hasClass('ghost') && shownColor !== target) { element.data('ghostColor', shownColor); item.colorFrom = shownColor; item.colorTo = target; } } setShown(element, true, from); if (end > start) { arriving.push(item); } else { departing.push(item); if (will === 'hidden') { expiring.push(item); } } }); }); }); }); return { cy, arriving, departing, expiring }; } /** * Settle a finished transition: the graph then shows `after` exactly as * showFrame() would leave it - but by touching only what the plan moved. */ function finishTransition(plan) { plan.cy.batch(() => { [...plan.arriving, ...plan.departing].forEach((item) => settleItem(item)); }); } /** * Advance a prepared transition to position `t` (0-1), everything in step. * * The player fades each element on its own clock instead (see createFader); * this is the same arithmetic for one moment of one plan. */ function applyTransition({ cy, arriving, departing }, t) { cy.batch(() => { arriving.forEach((item) => applyItem(item, t)); departing.forEach((item) => applyItem(item, t)); }); } /** * What every node measures AS OF each step: its trading partners and its * total traded time up to that step's date. * * Node size encodes one of these, and the all-time totals made every node its * final size from the first frame - a profile that only started trading in * the last year was already a big dot in year one. * * Partners are counted here, from the layers, exactly as the static graph * counts them (distinct partners over the drawn edges, so a hidden hub is not * counted). Everything traded up to a step is in its current, ghost or history * layer, and after the first step an edge can only join through the current * layer - the newest trade is always current. Time comes from the server * (`volumes`: per step, the new running totals of the profiles that traded). * * Kept per node as the steps where a value changed, so any step is a binary * search away: scrubbing needs no replay. * * @returns {{degreeAt: Function, volumeAt: Function, changedAt: Function}} */ function nodeMeasures(cy, lapse, volumes = null) { const index = elementIndex(cy); const degree = new Map(); const volume = new Map(); const changedAt = []; const record = (map, id, step, value) => { let series = map.get(id); if (!series) { series = { steps: [], values: [] }; map.set(id, series); } if (series.steps[series.steps.length - 1] === step) { series.values[series.values.length - 1] = value; } else { series.steps.push(step); series.values.push(value); } }; const seenEdges = new Set(); const seenPairs = new Set(); const partners = new Map(); for (let i = 0; i < lapse.count; i++) { const changed = new Set(); const layers = lapse.layers(i); const sources = i === 0 ? [layers.current, layers.ghost, layers.back] : [layers.current]; sources.forEach((layer) => { if (!layer) { return; } frameKeys(layer).edges.forEach((key) => { if (seenEdges.has(key)) { return; } seenEdges.add(key); (index.edges.get(key) || []).forEach((edge) => { const a = edge.source().id(); const b = edge.target().id(); const pair = a < b ? `${a}|${b}` : `${b}|${a}`; if (seenPairs.has(pair)) { return; } seenPairs.add(pair); [a, b].forEach((id) => { const count = (partners.get(id) || 0) + 1; partners.set(id, count); record(degree, id, i, count); changed.add(id); }); }); }); }); const totals = volumes ? volumes[i] : null; if (totals) { Object.keys(totals).forEach((id) => { record(volume, id, i, Number(totals[id]) || 0); changed.add(id); }); } changedAt.push([...changed]); } // The value at the last change on or before step `i`; 0 before the first. const lookup = (map, id, i) => { const series = map.get(id); if (!series || series.steps[0] > i) { return 0; } let lo = 0; let hi = series.steps.length - 1; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (series.steps[mid] <= i) { lo = mid; } else { hi = mid - 1; } } return series.values[lo]; }; return { degreeAt: (id, i) => lookup(degree, id, i), volumeAt: (id, i) => lookup(volume, id, i), changedAt: (i) => changedAt[i] || [], }; } /** One element of a transition at position `t` (0-1, already eased). */ function applyItem(item, t) { // Exactly `to` at the end: from + (to - from) * 1 can land a rounding // error away, and the settled state must equal a full redraw. setShown(item.element, true, t >= 1 ? item.to : item.from + (item.to - item.from) * t); if (item.colorFrom) { item.element.data('ghostColor', mixColor(item.colorFrom, item.colorTo, t)); } } /** One element of a transition at its end: final opacity, look and layer. */ function settleItem(item) { const { element } = item; applyItem(item, 1); if (item.tierAtEnd) { setTier(element, item.tierAtEnd); } if (item.to === 0) { setShown(element, false); clearTier(element); } } /** The colour an element has when not grey. */ function baseColor(element) { return element.isNode() ? element.data('color') : (element.data('edgeColor') || '#64748b'); } /** The colour an element is drawn in right now. */ function displayedColor(element) { return element.hasClass('ghost') ? element.data('ghostColor') : baseColor(element); } /** Parse #rgb / #rrggbb / rgb() into [r, g, b], or null. */ function parseColor(color) { const text = String(color || '').trim(); const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(text); if (hex) { const h = hex[1].length === 3 ? hex[1].split('').map((c) => c + c).join('') : hex[1]; return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)); } const rgb = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i.exec(text); return rgb ? rgb.slice(1, 4).map(Number) : null; } /** The colour `t` of the way from `a` to `b`, as #rrggbb. */ function mixColor(a, b, t) { const from = parseColor(a); const to = parseColor(b); if (!from || !to) { return t < 0.5 ? a : b; } return '#' + from .map((c, i) => Math.round(c + (to[i] - c) * t).toString(16).padStart(2, '0')) .join(''); } /** * Fades that run on their own clock, independent of the step pace. * * A weekly time-lapse steps every ~100ms at the default speed; a fade that had * to fit inside one step read as a cut. Here every element fades over its own * duration from the moment its step starts, so fades overlap the next few * steps. A step that retargets an element mid-fade replaces its fade, starting * from where it is on screen (see prepareTransition), so nothing jumps. */ function createFader(cy) { const fading = new Map(); return { add(plan, now, duration) { [...plan.arriving, ...plan.departing].forEach((item) => { fading.set(item.element, { item, start: now, duration }); }); }, /** Advance every fade to `now`; true while any is still running. */ tick(now) { if (fading.size === 0) { return false; } cy.batch(() => { fading.forEach(({ item, start, duration }, element) => { const raw = Math.min(1, (now - start) / duration); if (raw >= 1) { settleItem(item); fading.delete(element); } else { applyItem(item, raw * raw * (3 - 2 * raw)); } }); }); return fading.size > 0; }, /** Jump every running fade to its end. */ settle() { cy.batch(() => fading.forEach(({ item }) => settleItem(item))); fading.clear(); }, /** Forget running fades; the caller redraws (showFrame) anyway. */ clear() { fading.clear(); }, }; } /** * How long an element's fade takes at a given step interval. * * At least one step, so a slow time-lapse fades across its whole step as it * always did; for fast steps up to FADE_MS, but never more than FADE_STEPS * steps, so a fast month-by-month run does not smear years together and the * number of fades running at once stays bounded. */ const FADE_MS = 450; const FADE_STEPS = 4; function fadeDuration(interval) { return Math.max(interval, Math.min(FADE_MS, interval * FADE_STEPS)); } /** * Drive the animation. * * Everything is in memory - the whole time-lapse arrives in one call - so the * player is synchronous: step i's layers come from `layersAt(i)`. * * One requestAnimationFrame loop does both jobs: it starts a step every * `interval` ms (at most one per display frame) and advances the fades, which * run on their own clock (see createFader) and so carry over into the next * steps. Pausing stops the steps and lets the running fades finish. * * @param {object} opts.cy Cytoscape instance holding the union. * @param {number} opts.count Number of steps. * @param {Function} opts.layersAt Step index -> {current, ghost, back}. * @param {number} opts.interval Milliseconds per step. * @param {Function} opts.onFrame Called with the step index as each shows. * @param {Function} opts.sizeOf Node size by (node id, step index). * @param {Function} opts.changedAt Step index -> ids whose size changed then; * only those are resized as playback moves. */ function createPlayer({ cy, count, layersAt, interval: initialInterval = 450, onFrame = null, sizeOf = null, changedAt = null, holdAtEnd = 3000, loop = true, }) { // Mutable: the speed control changes it while playing. let interval = initialInterval; let index = 0; let frameHandle = null; let playing = false; // When the next step (or, at the end, the restart) is due. let nextAt = 0; const fader = createFader(cy); const report = () => { if (onFrame) { onFrame(index); } }; /** Jump straight to a step, with no transition. Used for scrubbing. */ const show = (i) => { fader.clear(); index = Math.max(0, Math.min(i, count - 1)); showFrame(cy, layersAt(index), sizeOf ? (id) => sizeOf(id, index) : null); report(); }; /** Grow or shrink the nodes whose measure changed at step `i`. */ const resize = (i) => { if (!sizeOf || !changedAt) { return; } const nodes = elementIndex(cy).nodes; cy.batch(() => { changedAt(i).forEach((id) => { const node = nodes.get(id); if (node) { setSize(node, sizeOf(id, i)); } }); }); }; const loopFrame = (now) => { frameHandle = null; if (playing && now >= nextAt) { if (index >= count - 1) { // At the end: the hold has passed, start over. The pause says // "this is where it ends"; looping straight back made the last // step look like the first. if (loop) { show(0); nextAt = now + interval; } else { playing = false; report(); } } else { // Computed once per step, not per display frame. const plan = prepareTransition(cy, layersAt(index), layersAt(index + 1)); // The label changes at the START of the fade, so it names the // step being moved towards. index += 1; resize(index); report(); fader.add(plan, now, fadeDuration(interval)); // Behind by more than a step (a busy tab): pick up from now // rather than racing through the backlog. nextAt = Math.max(nextAt + interval, now); if (index >= count - 1) { nextAt = now + fadeDuration(interval) + holdAtEnd; } } } const fadingOn = fader.tick(now); if (playing || fadingOn) { frameHandle = requestAnimationFrame(loopFrame); } }; const run = () => { if (frameHandle === null) { frameHandle = requestAnimationFrame(loopFrame); } }; return { play() { if (playing || count === 0) { return; } playing = true; if (index >= count - 1) { show(0); } nextAt = performance.now(); run(); }, // The running fades finish on their own; no new step starts. pause: () => { playing = false; }, isPlaying: () => playing, // Takes effect on the next step rather than interrupting the one in // flight, which would make the graph jump. setInterval: (ms) => { // One display frame at the fastest. interval = Math.max(16, ms); }, goTo: (i) => { playing = false; show(i); }, currentIndex: () => index, }; } export { LayerStream, timelapseLayers, frameKeys, nodeMeasures, showFrame, prepareTransition, applyTransition, finishTransition, warmTimelapse, createPlayer, };