Files

1017 lines
33 KiB
JavaScript

/**
* 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,
};