1017 lines
33 KiB
JavaScript
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,
|
|
};
|