Files
timebank-cc-public/resources/js/stats-network.js
T

3594 lines
140 KiB
JavaScript

/**
* Trading network on the Stats page (Cytoscape.js, MIT).
*
* Follows the same contract as stats-charts.js: data arrives through data-*
* attributes and the chart re-initialises after a Livewire morph with the
* same debounce.
*
* Cytoscape + fcose is ~570 kB, which would almost double app.js. Hence the
* dynamic import() when the section is opened, so the rest of the site does
* not pay for it.
*/
const CANVAS_ATTR = 'data-stats-network';
// Small and only meaningful on this page, so it rides along with the module
// rather than joining the lazy Cytoscape chunk.
import {
showFrame, createPlayer, warmTimelapse, timelapseLayers, nodeMeasures,
} from './network-timelapse';
import { setupFullscreen } from './network-fullscreen';
let cytoscapePromise = null;
/** Load once, even when the section is opened repeatedly. */
function loadCytoscape() {
if (!cytoscapePromise) {
cytoscapePromise = Promise.all([
import('cytoscape'),
import('cytoscape-fcose'),
]).then(([cytoscape, fcose]) => {
const cy = cytoscape.default || cytoscape;
cy.use(fcose.default || fcose);
return cy;
});
}
return cytoscapePromise;
}
const TYPE_COLORS = {
User: '#14b8a6', // teal-500, matching balance.User elsewhere
Organization: '#d946ef', // fuchsia-500
Bank: '#f59e0b', // amber-500
};
/**
* Node size from the square root of the volume.
*
* Linear scaling is unusable here: the largest node holds ~44,000 hours against
* a median a fraction of that, so linear would make the hub hundreds of times
* bigger than everything else.
*/
function nodeSize(volume, maxVolume) {
if (!maxVolume) {
return 8;
}
// Exponent above 0.5 rather than a square root: a root lifts the low end,
// and with a median degree of 1 against a maximum of 134 that put almost
// every node in the same narrow band — the cloud read as one uniform mass.
// At 0.85 the many small nodes stay small and the busy ones stand out.
//
// The base is the floor: a node still has to be clickable.
const ratio = Math.pow(volume / maxVolume, 0.85);
// Whole scale pulled down: at 941 nodes in one frame the old 12-60px range
// filled the canvas with circles and left no room to see the edges between
// them. 6px still reads as a dot and stays clickable.
return 6 + ratio * 36;
}
/**
* Colour per transaction type, for the currency-flow view.
*
* Keyed on the canonical local type id (canonicalTypeSql folds the Cyclos ids
* onto these). Real exchange between members is warm; bookkeeping - currency
* entering, leaving or migrating - is cool, so the two read apart at a glance.
*/
const TYPE_EDGE_COLORS = {
// Near-black. Work is ~85% of all edges, so it carries the graph's shape;
// a neutral dark line lets the rarer, coloured types stand out against it.
1: '#1e293b', // Work slate-800
2: '#4338ca', // Gift indigo-700
3: '#16a34a', // Donation green-600
// Warm pair, both involving a bank. Yellow rather than amber for creation:
// amber sat only 18 deg from the red below, with 6 lightness points
// between them, which read as the same colour on a thin line.
4: '#eab308', // Currency creation yellow-500
// Bright red: currency LEAVING the network is the one event that should
// catch the eye. 100% saturation, unlike anything else here.
5: '#ff3b00', // Currency removal bright red
6: '#0891b2', // Migration cyan-600
};
/**
* Colours for the circulation view (Tailwind 500/400 shades, to sit with the
* type and region palettes already used here).
*/
const FLOW_COLORS = {
// Currency still moves through this profile: it paid someone within the
// window.
flowing: '#3b82f6', // blue-500
// Received hours and has not passed them on within the window, or is
// flagged inactive. This is where currency stalls.
stalled: '#9333ea', // purple-600
// Never paid anyone at all. Between the two on purpose: nothing stopped
// here, it never started.
neverPaid: '#22d3ee', // cyan-400
};
/**
* Classify every node by whether it is still passing currency on.
*
* Deliberately NOT a cycle analysis. An earlier version ran Tarjan and called
* a node circulating only if it sat in a strongly connected component, which
* produced answers nobody could act on: a profile paying an active partner was
* marked a dead end because the only thing funding it was a bank, so no loop
* closed. Hours returning by the same route, or travelling one way through a
* chain, are both fine - currency moving is the point, not the shape it traces.
*
* What matters is the end of the chain. A profile that receives hours and does
* not pass them on is where currency stalls, and how long it has to sit still
* before that counts is the caller's judgement, via the slider.
*
* Three states:
*
* - flowing: paid someone within the window. Currency moves through.
* - stalled: has paid before, but not within the window, or an admin
* flagged the profile inactive. Currency has stopped here.
* - neverPaid: has never paid anyone. Nothing stopped - it never started.
*
* @param {Array} nodes Node payloads, carrying days_since_paid and inactive.
* @param {Array} edges Unused now; kept so callers need not change.
* @param {number|null} stoppedAfterDays Null means no date test: only the
* inactive flag can mark a profile stalled.
* @returns {Map<string,{flow:string,net:number}>} class plus net hours held.
*/
function classifyFlow(nodes, edges, stoppedAfterDays = null, useInactiveFlag = false) {
// Net hours held, per node: what flowed in minus what flowed back out.
// Reported for a stalled node as the amount sitting still.
const netIn = new Map();
const bump = (id, amount) => netIn.set(id, (netIn.get(id) || 0) + amount);
const present = new Set(nodes.map((n) => n.id));
(edges || []).forEach((e) => {
const aIn = present.has(e.source);
const bIn = present.has(e.target);
const ab = e.a_to_b || 0;
const ba = e.b_to_a || 0;
if (ab > 0) {
if (aIn) { bump(e.source, -ab); }
if (bIn) { bump(e.target, ab); }
}
if (ba > 0) {
if (bIn) { bump(e.target, -ba); }
if (aIn) { bump(e.source, ba); }
}
});
const classes = new Map();
nodes.forEach((n) => {
const net = netIn.get(n.id) || 0;
const days = n.days_since_paid;
if (days === null || days === undefined) {
classes.set(n.id, { flow: 'neverPaid', net });
return;
}
// An admin flagging a profile inactive outranks any date - but only
// when the user asked to see that flag. Honouring it while the
// highlight is switched off produced red nodes with no visible cause:
// at ">60 months" the date test is off, so those two were the only red
// dots on screen and nothing explained them.
const stalled = (useInactiveFlag && n.inactive)
|| (stoppedAfterDays !== null && days > stoppedAfterDays);
classes.set(n.id, { flow: stalled ? 'stalled' : 'flowing', net });
});
return classes;
}
/** Distinguishable colours per region, for the region view. */
const REGION_COLORS = [
'#3b82f6', '#f97316', '#14b8a6', '#d946ef', '#84cc16',
'#8b5cf6', '#ec4899', '#22c55e', '#0ea5e9', '#eab308',
];
function regionPalette(nodes) {
const seen = [];
nodes.forEach((n) => {
if (n.region && !seen.includes(n.region)) {
seen.push(n.region);
}
});
const map = {};
seen.forEach((region, i) => {
map[region] = REGION_COLORS[i % REGION_COLORS.length];
});
return map;
}
/**
* Available layouts. All built into Cytoscape except fcose, so no extra
* dependencies.
*
* The options are tuned for this graph (hundreds of nodes, one dominant hub);
* the defaults produce unreadable results at that scale.
*/
const LAYOUTS = {
// Force-directed: clusters fall together on their own. The default.
//
// Always quality 'default'. 'draft' is ~13x faster but skips fcose's force
// refinement, and on the real graph that stacked 916 of 1173 nodes onto
// another node's exact position - every profile that traded with a single
// hub landed ON that hub. The graph looked incomplete, and a time-lapse
// showed no change because 128 of 131 newcomers appeared under existing
// dots. Measured cost of 'default' on the real 1173-node graph: ~4.8s.
force: {
name: 'fcose', quality: 'default', animate: false, randomize: true,
nodeRepulsion: 6000, idealEdgeLength: 60,
},
// Everything on one circle: shows how dense the connections are.
circle: {
name: 'circle', animate: false, padding: 30, avoidOverlap: true,
},
// Rings by degree: best-connected profiles in the centre. For a trading
// network that says more than an alphabetical circle.
concentric: {
name: 'concentric', animate: false, padding: 30, minNodeSpacing: 14,
concentric: (node) => node.degree(),
levelWidth: (nodes) => Math.max(1, nodes.maxDegree() / 6),
},
// Tree from the best-connected node: shows how the network branches.
tree: {
name: 'breadthfirst', animate: false, padding: 30, spacingFactor: 1.1,
directed: false, circle: false, grid: true,
},
// Grid: no structure, but every node equally visible.
grid: {
name: 'grid', animate: false, padding: 30, avoidOverlap: true,
},
};
/**
* Layout options, rooting the tree layouts at the best-connected node.
*/
function layoutOptions(name, cy) {
const base = LAYOUTS[name] || LAYOUTS.force;
if (base.name !== 'breadthfirst') {
return base;
}
const root = rootOf(cy);
return root ? { ...base, roots: root } : base;
}
/**
* Run a layout and stretch the settled result to the container's proportions.
*
* Every layout goes through here, so a graph always fills the frame rather than
* sitting as a clump in the middle. The stretch has to happen after
* 'layoutstop': the positions do not exist until the layout has settled.
*
* @param {Collection|Core} target Elements to lay out (the visible set).
*/
/**
* One layout worker for the page, created on first use.
*
* null once creating it has failed - no Worker support, or a CSP that forbids
* it - so later layouts go straight to the synchronous path.
*/
let layoutWorker;
let layoutJobs = 0;
const layoutWaiting = new Map();
function getLayoutWorker() {
if (layoutWorker !== undefined) {
return layoutWorker;
}
try {
if (import.meta.env.DEV) {
// Development (Vite dev server): two things stop a plain worker.
// The dev server does not bundle, so the worker source still has its
// `import` statements, which a classic worker cannot parse. And the
// script comes from the dev server's origin (127.0.0.1:5173), not
// the page's (localhost:8000), and a worker's script must be
// same-origin. Both failed as "layout worker failed" locally.
//
// So: a same-origin module worker from a blob, whose only line
// imports the real worker from the dev server. Cross-origin module
// imports are allowed, and the dev server sends the CORS header.
//
// The path is in a variable on purpose: a literal
// new URL('./...', import.meta.url) makes Vite's build copy the
// unbundled source into production as a stray asset.
const source = '/resources/js/network-layout-worker.js';
const href = new URL(source, import.meta.url).href;
const shim = URL.createObjectURL(
new Blob([`import ${JSON.stringify(href)};`], { type: 'text/javascript' })
);
layoutWorker = new Worker(shim, { type: 'module' });
} else {
// Production: a classic worker. Vite bundles it into one
// self-contained file, which older iOS versions also run - and
// which is what made the graph work on phones. Vite removes the
// development branch above from the build.
layoutWorker = new Worker(new URL('./network-layout-worker.js', import.meta.url));
}
layoutWorker.onmessage = (event) => {
const { id, positions, error } = event.data || {};
const job = layoutWaiting.get(id);
if (!job) {
return;
}
layoutWaiting.delete(id);
if (error) {
job.reject(new Error(error));
} else {
job.resolve(positions);
}
};
layoutWorker.onerror = (event) => {
// The worker itself failed (e.g. its script did not load): fail
// every waiting job, and stop using it.
const error = new Error((event && event.message) || 'layout worker failed');
layoutWaiting.forEach((job) => job.reject(error));
layoutWaiting.clear();
layoutWorker = null;
};
} catch (error) {
console.error('[network] no layout worker, laying out on the page instead', error);
layoutWorker = null;
}
return layoutWorker;
}
/**
* Lay out `target` in the worker. Resolves with positions by node id.
*
* Only plain data crosses over: ids, node sizes and connections, plus the
* layout options - which must therefore hold no functions (fcose's do not).
*/
function layoutInWorker(target, options) {
const worker = getLayoutWorker();
if (!worker) {
return Promise.reject(new Error('no layout worker'));
}
const nodes = target.nodes().map((node) => ({ id: node.id(), size: node.data('size') }));
const nodeIds = new Set(nodes.map((n) => n.id));
const edges = target.edges()
.filter((edge) => nodeIds.has(edge.source().id()) && nodeIds.has(edge.target().id()))
.map((edge) => ({ id: edge.id(), source: edge.source().id(), target: edge.target().id() }));
const id = ++layoutJobs;
return new Promise((resolve, reject) => {
layoutWaiting.set(id, { resolve, reject });
worker.postMessage({ id, nodes, edges, options });
});
}
function runLayout(cy, target, options, onSettled = null, reserve = null, onFallback = null) {
let settled = false;
const settle = () => {
// Once only: a layout that threw partway may already have bound its
// own 'layoutstop' before the fallback binds another.
if (settled || cy.destroyed()) {
return;
}
settled = true;
spreadToContainer(cy, target);
cy.fit(target, fitPadding(cy));
clearOverlay(cy, target, reserve);
if (onSettled) {
onSettled();
}
};
const runHere = (layoutOptionsToUse) => {
const layout = target.layout(layoutOptionsToUse);
layout.one('layoutstop', settle);
layout.run();
};
// The chosen layout failed. Fall back to rings by trading partners: no
// simulation, nothing that can diverge, so the graph is still drawn. The
// error goes on the page as well as the console, because the first report
// of this came from a phone, where there is no console to read.
const fallBack = (error) => {
if (cy.destroyed()) {
return;
}
console.error('[network] layout failed, falling back to a simpler one', error);
if (onFallback) {
onFallback(error);
}
runHere({ ...LAYOUTS.concentric });
};
// fcose goes to the worker. Run on the page it blocks for seconds - ~4.5s
// on the full graph on a desktop, several times that on a phone, where iOS
// stops a script that runs too long: production stayed on "Arranging 1196
// profiles..." for good on mobile, while desktop drew it fine.
if (options.name === 'fcose' && getLayoutWorker()) {
layoutInWorker(target, options)
.then((positions) => {
if (cy.destroyed()) {
return;
}
target.nodes().positions((node) => positions[node.id()] || node.position());
settle();
})
.catch(fallBack);
return;
}
// Other layouts are cheap, and without a worker there is no choice.
try {
runHere(options);
} catch (error) {
fallBack(error);
}
}
/**
* Padding for cy.fit(). In full screen the header floats over the top of the
* canvas, so the graph keeps clear of it - the same padding entering full
* screen uses (see setupFullscreen's onResize). Needed here as well because
* changing a time-lapse setting while in full screen rebuilds the graph there.
*/
function fitPadding(cy) {
const screen = cy.container()?.closest('[data-network-screen]');
const fullscreen = document.fullscreenElement || document.webkitFullscreenElement;
if (!screen || fullscreen !== screen) {
return 20;
}
const header = screen.querySelector('[data-network-header]');
return Math.max(20, (header ? header.offsetHeight : 0) + 8);
}
/**
* Keep the drawing clear of a panel floating over the canvas.
*
* The graph header sits ABOVE the canvas in the markup, so nothing can overlap
* it. The playback controls do not - they float over the bottom left corner,
* and cy.fit() pads all four sides equally, so nodes were drawn underneath them.
*
* Rather than shrink the whole graph to clear one corner, zoom out just enough
* that the reserved band is free and pan away from it. Nothing is done when the
* graph already clears the panel, which is the common case on a sparse graph.
*
* @param {object} reserve {width, height} of the panel, in pixels.
*/
function clearOverlay(cy, target, reserve) {
if (!reserve || !reserve.width || !reserve.height) {
return;
}
const bb = target.nodes().renderedBoundingBox();
if (!bb || !bb.w || !bb.h) {
return;
}
// The panel's band, with a small margin so nodes do not graze it.
const margin = 8;
const panelTop = cy.height() - reserve.height - margin;
const panelRight = reserve.width + margin;
// Only the nodes that actually reach into the corner matter.
if (bb.y2 <= panelTop || bb.x1 >= panelRight) {
return;
}
// Shrink about the container centre by whatever it takes for the drawing
// to fit in the space left above the panel, then lift it clear.
const usableH = panelTop - 20;
const factor = Math.min(1, usableH / bb.h);
if (factor < 1) {
cy.zoom({
level: cy.zoom() * factor,
renderedPosition: { x: cy.width() / 2, y: cy.height() / 2 },
});
}
const after = target.nodes().renderedBoundingBox();
const overlap = after.y2 - panelTop;
if (overlap > 0) {
cy.panBy({ x: 0, y: -overlap });
}
}
/**
* Stretch the settled layout to fill the container's proportions.
*
* Done by scaling the resulting positions, not through a boundingBox option:
* fcose derives its own bounding box from the node positions and ignores the
* option, and the circle layout takes its radius from Math.min(bb.w, bb.h), so
* a wider box leaves the circle unchanged. Scaling afterwards works for every
* layout, and turns that circle into an ellipse that follows the frame.
*
* @param {Collection} eles Nodes to reposition (the visible set).
*/
function spreadToContainer(cy, eles) {
const nodes = eles.nodes();
if (nodes.length < 2) {
return;
}
const bb = nodes.boundingBox();
if (!bb.w || !bb.h) {
return;
}
// Leave room for labels, which sit above the node and extend past it.
const targetW = cy.width() * 0.88;
const targetH = cy.height() * 0.82;
const scaleX = targetW / bb.w;
const scaleY = targetH / bb.h;
const cx = bb.x1 + bb.w / 2;
const cy0 = bb.y1 + bb.h / 2;
const centre = { x: cy.width() / 2, y: cy.height() / 2 };
nodes.positions((node) => {
const pos = node.position();
return {
x: centre.x + (pos.x - cx) * scaleX,
y: centre.y + (pos.y - cy0) * scaleY,
};
});
}
/**
* Push the visible nodes apart by a fixed factor, around their common centre.
*
* Separate from spreadToContainer(), which fits the layout to the frame and is
* therefore idempotent — pressing a button wired to it a second time would do
* nothing. This scales relative to the current positions, so each press opens
* up a dense cloud further.
*
* The viewport is deliberately left alone: cy.fit() afterwards would rescale
* the graph straight back to the same apparent size and the button would look
* broken. Spreading changes the spacing between nodes relative to their size,
* which is the point.
*
* @param {Collection} eles Nodes to reposition (the visible set).
* @param {number} factor Multiplier applied to each node's offset from centre.
*/
function spreadBy(cy, eles, factor) {
const nodes = eles.nodes().not('.hidden');
if (nodes.length < 2) {
return;
}
const bb = nodes.boundingBox();
const cx = bb.x1 + bb.w / 2;
const cy0 = bb.y1 + bb.h / 2;
nodes.positions((node) => {
const pos = node.position();
return {
x: cx + (pos.x - cx) * factor,
y: cy0 + (pos.y - cy0) * factor,
};
});
}
/**
* The best-connected node, used as the root for the tree layouts. Without an
* explicit root breadthfirst picks one itself, often a node at the edge, which
* makes the tree unreadably deep.
*/
function rootOf(cy) {
const hub = cy.nodes().not('.hidden').max((n) => n.degree());
return hub.ele || null;
}
/**
* Fill in the cluster legend once clustering has run.
*
* The clusters are computed in the browser (markovClustering), so the server
* cannot know their colours or sizes — the Blade renders an empty placeholder
* that this fills. Largest clusters first, because those carry the graph.
*/
function renderClusterLegend(wrapper, clusters, total) {
const host = control(wrapper, '[data-network-cluster-legend]');
if (!host) {
return;
}
if (clusters.length === 0) {
host.hidden = true;
return;
}
const labels = (() => {
try {
return JSON.parse(wrapper.getAttribute('data-network-labels') || '{}');
} catch (error) {
return {};
}
})();
const sorted = [...clusters].sort((a, b) => b.size - a.size);
const shown = sorted.slice(0, 8);
const rest = total - shown.length;
const profiles = labels.profiles || 'profiles';
host.innerHTML = shown.map((c, i) => `
<span class="inline-flex items-center gap-1.5 text-xs text-theme-primary">
<span class="inline-block w-3 h-3 rounded-full" style="background-color: ${c.color};"></span>
${escapeHtml(labels.cluster || 'Cluster')} ${i + 1} &middot; ${c.size} ${escapeHtml(profiles)}
</span>
`).join('') + (rest > 0
? `<span class="text-xs text-theme-secondary">${escapeHtml(labels.more || '+ :count more').replace(':count', rest)}</span>`
: '');
host.hidden = false;
}
/**
* Find a control belonging to this graph.
*
* Several controls (search, period, the drag checkbox, "spread out") sit with
* the other buttons ABOVE the data-stats-network wrapper, so a
* wrapper.querySelector() silently returns null and the control does nothing.
* Look inside the wrapper first, then fall back to the surrounding section.
*/
function control(wrapper, selector) {
return wrapper.querySelector(selector)
|| (wrapper.closest('[data-network-section]') || document).querySelector(selector);
}
/**
* Rewrite the subtext for one time-lapse frame.
*
* Counts come from the frame payload, not from the graph: showFrame() hides
* elements with `display`, while the ordinary subtext counts the `.hidden`
* class, so reading the graph would report the union's totals every year.
*
* @param {Element} wrapper
* @param {string} label Caption for the frame, e.g. 'Mar 2024'.
* @param {object} frame
*/
/**
* Put the play button in its loading or failed state.
*
* The playback controls sit under wire:ignore, so Livewire never resets them
* and the JS owns their state entirely: busy while a time-lapse is being
* built, ready once setPlayButton() hands over, and an explicit error when
* there is nothing to play. Before that last state existed, any failure left
* the spinner turning for ever with no sign of why.
*
* @param {Element} controls The [data-network-timelapse-controls] panel.
* @param {string} state 'busy' or 'error'.
* @param {string} label Tooltip and accessible name.
*/
function setPlaybackState(controls, state, label) {
const button = controls && controls.querySelector('[data-timelapse-play]');
if (!button) {
return;
}
const busy = state === 'busy';
const icons = {
play: controls.querySelector('[data-timelapse-icon-play]'),
pause: controls.querySelector('[data-timelapse-icon-pause]'),
busy: controls.querySelector('[data-timelapse-icon-busy]'),
};
button.disabled = true;
button.setAttribute('title', label);
button.setAttribute('aria-label', label);
// Busy spins; an error shows the (disabled, dimmed) play icon, so the
// button stops claiming that something is still happening.
if (icons.busy) {
icons.busy.classList.toggle('hidden', !busy);
}
if (icons.play) {
icons.play.classList.toggle('hidden', busy);
}
if (icons.pause) {
icons.pause.classList.add('hidden');
}
}
/** The latest 'Y-m-d' date in an ascending list that is on or before `till`. */
function lastOnOrBefore(dates, till) {
let lo = 0;
let hi = dates.length - 1;
let found = null;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (dates[mid] <= till) {
found = dates[mid];
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return found;
}
/**
* Give every node the state it had ON the frame's date, not today.
*
* The graph is built once, and its node data describes the present: days
* since last paid counted to today, inactive and removed as they are now. In a
* time-lapse that is wrong for every frame but the last - a profile marked
* inactive in 2025 showed a red outline in 2023, and one that paid last month
* showed as stalled in a frame from years before its first payment.
*
* Same rules as classifyFlow() and the server's profileStates(), applied at
* the frame's date:
* - removed: removed on or before the date
* - inactive: marked inactive on or before the date, and NOT removed by
* then - removal is the final state, as everywhere else
* - neverPaid: no outgoing payment on or before the date
* - stalled: no payment for longer than the "stopped" threshold by the
* date, or inactive while the highlight is on
*
* Writes only what changed: between two adjacent months a handful of nodes
* change state, not the 1173 on the full graph.
*
* @param {object} timeline {paid: {id: [dates]}, removed: {id: date}, inactive: {id: date}}
*/
function applyFrameStates(cy, frame, timeline, { view, stoppedAfterDays, markInactive }) {
if (!timeline || !frame || !frame.till) {
return;
}
const till = frame.till;
const tillMs = Date.parse(till);
const paid = timeline.paid || {};
const removedAt = timeline.removed || {};
const inactiveAt = timeline.inactive || {};
cy.batch(() => {
cy.nodes().forEach((node) => {
const id = node.id();
const removed = Boolean(removedAt[id]) && removedAt[id] <= till;
const inactive = !removed && Boolean(inactiveAt[id]) && inactiveAt[id] <= till;
if (node.hasClass('removed') !== removed) {
node.toggleClass('removed', removed);
}
if (node.hasClass('inactive') !== inactive) {
node.toggleClass('inactive', inactive);
}
// Only the circulation view colours by flow; outsiders stay grey.
if (view !== 'circulation' || node.hasClass('outside')) {
return;
}
const last = lastOnOrBefore(paid[id] || [], till);
let flow = 'neverPaid';
if (last !== null) {
// Both are UTC midnight, so this is a whole number of days.
const days = Math.round((tillMs - Date.parse(last)) / 86400000);
const stalled = (markInactive && inactive)
|| (stoppedAfterDays !== null && days > stoppedAfterDays);
flow = stalled ? 'stalled' : 'flowing';
}
if (node.data('flow') !== flow) {
node.data('flow', flow);
node.data('color', FLOW_COLORS[flow] || '#94a3b8');
}
});
});
}
/**
* Months per second for a speed-slider position (0-100).
*
* Logarithmic, from 0.5 to 30 months a second. On a linear scale the slow end
* - where the difference between 0.5 and 2 months a second is huge - would be
* squeezed into the first few pixels, while most of the track would be spent
* on fast speeds that barely differ. Here every step multiplies the speed by
* the same factor.
*
* 30 months a second is ~33ms a month, two display frames at 60Hz: a month
* then passes as a near-instant cut rather than a fade, which is the honest
* limit of the screen.
*/
const SPEED_MIN = 0.5;
const SPEED_MAX = 30;
function speedFromSlider(position) {
const t = Math.max(0, Math.min(100, Number(position) || 0)) / 100;
return SPEED_MIN * Math.pow(SPEED_MAX / SPEED_MIN, t);
}
/** How the readout shows a speed: one decimal while slow, whole numbers after. */
function formatSpeed(monthsPerSecond) {
return monthsPerSecond < 10
? `${monthsPerSecond.toFixed(1)}/s`
: `${Math.round(monthsPerSecond)}/s`;
}
function renderFrameSubtext(wrapper, label, frame) {
const note = control(wrapper, '[data-network-stagnant]');
if (!note) {
return;
}
// Layers are sets; older frames were arrays.
const count = (list) => (list instanceof Set ? list.size : (list || []).length);
const base = (note.dataset.drawnTemplate || '')
.replace(':nodes', count(frame.nodes))
.replace(':edges', count(frame.edges));
// Prefixed with the frame's month, because during playback that is the
// single most important thing the sentence can say.
//
// Always written and never hidden: a month with no trade still has to
// fill this line, or the header loses a row and the graph below it jumps.
// The label alone is enough when there is no sentence to add.
note.textContent = base ? `${label}: ${base}` : label;
note.hidden = false;
// Keep the SVG export's header in step with the screen.
const host = wrapper.querySelector('[data-network-host]');
if (host) {
host.dataset.networkSubtext = note.textContent;
}
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}
/** Minutes to hours, using the separator the rest of the page uses. */
/**
* Amounts are stored in minutes; render them in the platform's own currency.
*
* Was a hardcoded " u" suffix, which is Dutch for "uur" and wrong on every
* white-label instance and in every other locale. The symbol, its position and
* the thousand separator all come from timebank_config(), handed over in the
* wrapper's data-currency attribute.
*/
let CURRENCY = { symbol: 'H', atEnd: false, thousands: '.' };
function formatHours(minutes) {
const hours = (minutes || 0) / 60;
const rounded = hours >= 100 ? Math.round(hours) : Math.round(hours * 10) / 10;
let text = String(rounded).replace('.', ',');
if (CURRENCY.thousands) {
// Group the integer part only, so "2415,5" becomes "2.415,5".
const [whole, fraction] = text.split(',');
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, CURRENCY.thousands);
text = fraction ? `${grouped},${fraction}` : grouped;
}
return CURRENCY.atEnd
? `${text} ${CURRENCY.symbol}`
: `${CURRENCY.symbol} ${text}`;
}
/** Parse a data-* JSON attribute, returning null rather than throwing. */
function safeJson(value) {
try {
return value ? JSON.parse(value) : null;
} catch (error) {
return null;
}
}
function buildElements(data, hideHub, view, stoppedAfterDays = null, markInactive = false) {
const maxVolume = data.nodes.reduce((m, n) => Math.max(m, n.volume || 0), 0);
// The hub is the highest-degree node. On dev that is Lekkernassuh with 702
// partners against 7 at p90 — with it in place the layout becomes a star
// and everything else is unreadable.
const hub = data.nodes.reduce((h, n) => (!h || n.degree > h.degree ? n : h), null);
const drop = hideHub && hub ? hub.id : null;
const visible = data.nodes.filter((n) => n.id !== drop);
const regions = regionPalette(visible);
// Recount partners over the edges that SURVIVE, not the whole graph.
//
// The server counts degree across everything it returned, but the view
// then drops the hub here. Every one of the hub's partners kept a degree
// one too high - 700 of 941 nodes on dev, so "Dylan: 3 trading partners"
// with only 2 lines on screen. The count has to describe what is drawn.
//
// Also per distinct partner, not per edge: edges are split by transaction
// type, so a pair trading Work and Donation yields two edges and would
// otherwise count as two partners.
const survivingIds = new Set(visible.map((n) => n.id));
const partners = new Map();
(data.edges || []).forEach((e) => {
if (!survivingIds.has(e.source) || !survivingIds.has(e.target)) {
return;
}
if (!partners.has(e.source)) {
partners.set(e.source, new Set());
}
if (!partners.has(e.target)) {
partners.set(e.target, new Set());
}
partners.get(e.source).add(e.target);
partners.get(e.target).add(e.source);
});
visible.forEach((n) => {
// Keep the server's full count so the tooltip can say how many
// partners exist but are not drawn - hidden by the hub toggle, the
// degree range or the profile-type filter. A bare "2 partners" next to
// 2 lines is honest; silently dropping the rest is not.
n.totalDegree = n.degree;
n.degree = (partners.get(n.id) || new Set()).size;
});
// Computed on the visible set, after the hub is dropped: hiding the busiest
// node genuinely changes who is left in a cycle, so it must not be measured
// on the full graph and then applied to a smaller one.
const flow = view === 'circulation'
? classifyFlow(visible, data.edges || [], stoppedAfterDays, markInactive)
: new Map();
const maxDegree = visible.reduce((m, n) => Math.max(m, n.degree || 0), 0);
const nodes = visible.map((n) => {
// Size encodes whatever the view is about: volume for 'volume', the
// partner count for the others. Square-root scaled, because linear
// makes the largest node hundreds of times bigger than the median.
const size = view === 'volume'
? nodeSize(n.volume || 0, maxVolume)
: nodeSize(n.degree || 0, maxDegree);
// Outsiders always grey: they should look visibly different from
// profiles inside the circle.
const color = n.outside
? '#94a3b8'
: (view === 'circulation'
? FLOW_COLORS[(flow.get(n.id) || {}).flow] || '#94a3b8'
: (view === 'region'
? (regions[n.region] || '#64748b')
: (TYPE_COLORS[n.type] || '#64748b')));
return {
data: {
id: n.id,
// Pseudonymised labels are derived from the node id, which is
// the 'Type:id' profile key, so they match the numbers in the
// most active profiles table exactly. Searching the graph for
// the name shown in that table therefore finds this node.
// Already pseudonymised server-side when the toggle is on -
// the real name never reaches the browser now, so there is
// nothing left here to hide.
label: n.name,
// Nulled server-side while anonymising, so this is simply
// whatever the server was willing to send.
fullName: n.full_name || null,
type: n.type,
region: n.region || '',
bank: n.bank || '',
degree: n.degree,
hiddenPartners: Math.max((n.totalDegree || n.degree) - n.degree, 0),
volume: n.volume,
paid: n.paid || 0,
received: n.received || 0,
size,
color,
flow: (flow.get(n.id) || {}).flow || null,
flowNet: (flow.get(n.id) || {}).net || 0,
// Zoom-compensated decoration sizes; see rescaleDecorations().
borderW: 1,
outlineW: 1.5,
fontS: 14,
},
classes: [
n.outside ? 'outside' : '',
n.removed ? 'removed' : '',
n.inactive ? 'inactive' : '',
].filter(Boolean).join(' '),
};
});
const present = new Set(nodes.map((n) => n.data.id));
const maxEdgeVolume = data.edges.reduce((m, e) => Math.max(m, e.volume || 0), 0);
const visibleEdges = data.edges.filter((e) => present.has(e.source) && present.has(e.target));
/**
* Width from volume, with a low floor so a small flow stays just visible
* without smearing the dense parts of the graph shut.
*/
const flowWidth = (amount) => {
if (!maxEdgeVolume) {
return 0.5;
}
// Exponent above 0.5 steepens the curve the other way: a square root
// lifts the small flows so most lines end up looking alike. At 0.7 the
// thin ones stay thin and the heavy ones pull far ahead, which is what
// makes the busy relationships stand out.
const ratio = Math.pow((amount || 0) / maxEdgeVolume, 0.7);
return 0.2 + ratio * 12;
};
const edges = [];
/**
* Bend for one arrow, fanning out arrows that share a direction.
*
* Every flow edge used a hardcoded bend of 26, which was fine while a pair
* produced at most one arrow each way. Now that edges are split per
* transaction type, a pair trading Work AND Donation from A to B draws two
* arrows between the same nodes in the same direction - and they landed
* exactly on top of each other. 62 directions were affected.
*
* The first arrow in a direction keeps 26 so the common case looks
* unchanged; each further one steps out by 22.
*
* The opposite direction is NOT offset here: its source and target are
* swapped, so the same positive bend curves it to the other side of the
* axis. That is why both directions can share a bend value without
* colliding.
*/
const bendSeen = new Map();
const bendFor = (from, to) => {
const key = `${from}->${to}`;
const n = bendSeen.get(key) || 0;
bendSeen.set(key, n + 1);
return 26 + n * 22;
};
visibleEdges.forEach((e, i) => {
if (view !== 'flow') {
// Outside the flow view the relationship itself is the subject,
// not its direction: one undirected line, width from the
// transaction count.
edges.push({
data: {
id: `e${i}`,
// Frame key for the time-lapse: matches how the server
// splits edges, so showing a frame is a set lookup rather
// than a re-derivation. Always the server's own
// source/target order, never the drawn direction.
key: `${e.source}|${e.target}|${e.type_id}`,
source: e.source,
target: e.target,
tx: e.tx,
volume: e.volume,
// log2 scaled up: the previous 0.9 factor put 1 and 100
// transactions within a few pixels of each other.
// Squared log: log2 alone compresses 1 and 100 transactions
// into a few pixels of each other.
width: Math.min(0.2 + Math.pow(Math.log2(1 + e.tx), 1.5) * 0.55, 12),
// Curved like the flow view's arrows, so the graph reads
// the same whichever view you switch to. One line per pair
// here, so a constant bend is enough - no fanning needed.
bend: 26,
},
});
return;
}
// Flow view: payments and receipts as two separate lines. A single
// net arrow hides the difference between "exchanged 100 hours" and
// "barely traded" — both produce the same net figure.
//
// Both lines get the same bend value: because the return line has its
// source and target swapped, that bends it to the opposite side of the
// axis. Using + and - would put them on top of each other.
const aToB = e.a_to_b || 0;
const bToA = e.b_to_a || 0;
if (aToB > 0) {
edges.push({
data: {
id: `e${i}f`,
key: `${e.source}|${e.target}|${e.type_id}`,
source: e.source,
target: e.target,
tx: e.tx,
volume: aToB,
width: flowWidth(aToB),
bend: bendFor(e.source, e.target),
// Per-type colour; see TYPE_EDGE_COLORS.
edgeColor: TYPE_EDGE_COLORS[e.type_id] || '#64748b',
},
// 'mutual' when the pair also traded the other way, so the
// colour says something true. Previously 'forward'/'backward'
// drove the colour, but those only mean "first/second
// direction of this pair" - arbitrary. 64% of relationships
// are one-way and drew a single line whose colour therefore
// carried no meaning, while the legend claimed teal = paid and
// purple = received.
classes: 'directed flowtype',
});
}
if (bToA > 0) {
edges.push({
data: {
id: `e${i}b`,
// Server order, not the drawn direction: both halves of a
// mutual pair are one edge as far as the frames know.
key: `${e.source}|${e.target}|${e.type_id}`,
source: e.target,
target: e.source,
tx: e.tx,
volume: bToA,
width: flowWidth(bToA),
bend: bendFor(e.target, e.source),
// Per-type colour; see TYPE_EDGE_COLORS.
edgeColor: TYPE_EDGE_COLORS[e.type_id] || '#64748b',
},
classes: 'directed flowtype',
});
}
});
// The hub carries a display label too. It is dropped from the graph, so it
// never goes through the node mapping above, and the "X is hidden" note
// would otherwise print its real name while anonymisation was on.
const labelledHub = hub
? { ...hub, label: hub.name }
: null;
// Totals for the subtext under the caption. Counted from the same map the
// colours come from, so the sentence cannot disagree with the picture.
// Only positive net is summed: a dead end with a negative balance has paid
// out more than it took in, which is not currency sitting still.
let stagnantNodes = 0;
let stagnantMinutes = 0;
let neverPaidNodes = 0;
flow.forEach((value) => {
if (value.flow === 'neverPaid') {
neverPaidNodes += 1;
} else if (value.flow !== 'stalled') {
return;
}
stagnantNodes += 1;
if (value.net > 0) {
stagnantMinutes += value.net;
}
});
return {
elements: [...nodes, ...edges],
hub: labelledHub,
droppedHub: Boolean(drop),
regions,
stagnantNodes,
stagnantMinutes,
neverPaidNodes,
};
}
async function build(wrapper) {
const host = wrapper.querySelector('[data-network-host]');
if (!host) {
return;
}
// Every path that gives up has to say so: the placeholder reads "drawing
// the network", and leaving it up after a failure claims work is still in
// progress when nothing more will happen.
const giveUp = (message) => {
const placeholder = host.querySelector('[data-network-placeholder] span');
if (placeholder) {
placeholder.textContent = message;
}
};
/**
* Update the placeholder between phases.
*
* Deliberately NOT a timer. Building elements, laying out and clustering
* all run synchronously on the main thread - fcose alone takes ~4.9s on
* the full graph - so an interval could not fire while they run: the
* browser is blocked. Reporting at each phase boundary is the most a
* single-threaded renderer allows, and it is honest about what has
* actually finished.
*
* Returns a promise that yields to the browser, so the new text paints
* before the next blocking phase begins.
*/
const progress = (message) => {
giveUp(message);
// Two frames: one to apply the text, one to let it paint.
return new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(resolve));
});
};
// Named apart from the `labels` further down, which is read later in the
// same scope for the tooltip.
const statusLabels = safeJson(wrapper.getAttribute('data-network-labels')) || {};
let data;
try {
data = JSON.parse(wrapper.getAttribute(CANVAS_ATTR) || '{}');
} catch (error) {
giveUp(statusLabels.drawFailed || 'The network could not be drawn.');
return;
}
if (!data.nodes || data.nodes.length === 0) {
giveUp(statusLabels.drawEmpty || 'No network to draw for this selection.');
return;
}
// Do not rebuild when the data has not changed: a Livewire morph must not
// throw away a layout that has already been computed.
// Every setting that changes the drawing belongs in the signature: the
// wrapper's data-* attributes are refreshed by Livewire even though the
// host itself carries wire:ignore, so this is what decides a rebuild.
const signature = [
wrapper.getAttribute(CANVAS_ATTR),
wrapper.getAttribute('data-network-view'),
wrapper.getAttribute('data-network-layout'),
wrapper.getAttribute('data-hide-hub'),
// Part of the signature: without it, toggling anonymisation would keep
// the existing graph and go on showing real names.
wrapper.getAttribute('data-network-anonymize'),
// Without this, moving the "flow stopped after" slider would keep the
// existing graph and go on using the old threshold.
wrapper.getAttribute('data-network-stopped-after'),
// Changes the classification, not just the outline.
wrapper.getAttribute('data-network-mark-inactive'),
// Time-lapse lays out the union of every year instead of the single
// payload, so switching it either way is a different graph.
wrapper.getAttribute('data-network-timelapse'),
wrapper.getAttribute('data-network-timelapse-months'),
// Accumulate changes what every frame holds but not the month list, so
// without it here toggling the checkbox kept the old frames.
wrapper.getAttribute('data-network-timelapse-cumulative'),
// Month or week: a different set of steps altogether.
wrapper.getAttribute('data-network-timelapse-step'),
].join('|');
// Unchanged settings: keep the existing chart, and with it the user's zoom,
// pan and any focused node. Without this every Livewire round trip — a PDF
// export included — would throw that away.
if (host.dataset.networkSignature === signature && host._cy && !host._cy.destroyed()) {
return;
}
// A build for these exact settings is already running: let it finish.
//
// The signature above is only stored when a build COMPLETES, and a build
// awaits several times on the way - the Cytoscape import, the time-lapse
// data. Any Livewire round trip in that window fires morph.updated, and
// before this guard each one started a fresh build of the same graph. The
// time-lapse data call is itself a round trip, so every build spawned the
// next: switching the time-lapse on redrew the graph over and over.
if (host._buildingSignature === signature) {
return;
}
// Different settings supersede a build in progress. Each build checks
// after every await whether it is still the latest, and quietly stops if
// not, so two builds never fight over the same container.
const buildId = (host._buildId || 0) + 1;
host._buildId = buildId;
host._buildingSignature = signature;
const superseded = () => host._buildId !== buildId;
const currency = safeJson(wrapper.getAttribute('data-currency'));
if (currency) {
CURRENCY = currency;
}
const hideHub = wrapper.getAttribute('data-hide-hub') !== '0';
// flow | volume | cluster | region - decides colour, size and line style.
const view = wrapper.getAttribute('data-network-view') || 'volume';
// The layout is independent of the view: colour and size say what you are
// looking at, the layout says how it is arranged.
const layoutName = wrapper.getAttribute('data-network-layout') || 'force';
// Phase 1: the lazy import, ~570 kB. The only genuinely async wait here,
// and on a slow connection the longest.
await progress((statusLabels.loading || 'Loading...'));
const cytoscape = await loadCytoscape();
if (superseded()) {
return;
}
// Time-lapse: the graph laid out is the UNION of every year, not the
// payload in the attribute. Frames then show and hide elements in place.
//
// Fetched here, before buildElements, because the union has to go through
// exactly the same colouring, sizing and filtering as a normal graph -
// otherwise a node would change appearance between the static view and the
// animation for no reason the viewer could see.
const timelapse = wrapper.getAttribute('data-network-timelapse') === '1';
// [{key: 'YYYY-MM', label: 'Jan 2024'}, ...] - a monthly step, because a
// yearly one shared only 46-70% of its nodes between frames and read as a
// slideshow however it was faded.
const timelapseMonths = timelapse
? (safeJson(wrapper.getAttribute('data-network-timelapse-months')) || [])
: [];
// Livewire handle, for fetching frames on demand. Resolved from the
// component root rather than held globally, so it survives a morph.
const componentEl = wrapper.closest('[wire\\:id]');
const component = componentEl && window.Livewire
? window.Livewire.find(componentEl.getAttribute('wire:id'))
: null;
// Each step's {label, till}; the layers themselves are in `lapse`.
let frames = [];
let timelapseFailed = false;
// Dated facts for colouring each frame as of its own date; see
// applyFrameStates().
let timeline = null;
// Layers per step, rebuilt on demand from the server's changes; see
// timelapseLayers() in network-timelapse.js.
let lapse = null;
// Per step, the running traded time of the profiles that traded: sizes.
let volumes = null;
if (timelapse && component && timelapseMonths.length > 0) {
// The controls are under wire:ignore, so a rebuild has to put the
// button back into its loading state itself.
setPlaybackState(
control(wrapper, '[data-network-timelapse-controls]'),
'busy',
statusLabels.timelapseLoading || 'Loading the time-lapse...'
);
await progress(statusLabels.timelapseLoading || 'Loading the time-lapse...');
try {
// ONE call for the whole animation, not one per month. Every frame
// is cumulative, so a per-month call ran a full all-time query each
// time: ~1.1s x 36 = 42.5s before anything appeared. Built in a
// single pass server-side it is ~1.8s, and the frames carry ids
// only because the elements all live in `graph`.
const payload = await component.call('networkTimelapseData');
if (superseded()) {
return;
}
const graph = payload && payload.graph;
if (graph && (graph.nodes || []).length > 0) {
data = graph;
timeline = payload.timeline || null;
volumes = payload.volumes || null;
// Steps and their three layers (current, the month before,
// everything older), sent as changes and rebuilt on demand:
// expanding every step up front cost ~60 MB for ten years of
// weeks.
lapse = timelapseLayers(payload);
frames = Array.from({ length: lapse.count }, (_, i) => lapse.step(i));
}
} catch (error) {
// A failed fetch falls back to the ordinary single-period graph,
// which is still a usable picture, rather than to nothing - but
// says so, instead of leaving the play button spinning.
frames = [];
timelapseFailed = true;
console.error('[time-lapse] loading the frames failed', error);
}
}
// Off unless the page says otherwise, so a missing attribute can never
// silently reveal names.
// Days after which a profile counts as having stopped circulating. Read
// here, where the wrapper is in scope, and passed down.
const stoppedAfter = parseInt(wrapper.getAttribute('data-network-stopped-after'), 10);
// Phase 2: classify and build the elements. Now the node count is known,
// so the message can say how much is being drawn.
await progress((statusLabels.preparing || 'Preparing :count profiles...')
.replace(':count', (data.nodes || []).length));
if (superseded()) {
return;
}
const {
elements, hub, droppedHub, regions, stagnantNodes, stagnantMinutes,
neverPaidNodes,
} = buildElements(
data,
hideHub,
view,
Number.isFinite(stoppedAfter) ? stoppedAfter : null,
wrapper.getAttribute('data-network-mark-inactive') === '1'
);
if (host._cy) {
host._cy.destroy();
}
// Tear down everything the previous build wired up, not just its graph.
//
// A running time-lapse player outlives its graph: its timers kept firing
// against a destroyed instance. And every listener below used to be added
// again on each rebuild while the old ones stayed - after a couple of
// rebuilds one click on Play drove two players, one SVG click exported
// several files, and the document-level click handler grew without bound.
if (host._timelapse) {
host._timelapse.pause();
host._timelapse = null;
}
if (host._listeners) {
host._listeners.abort();
}
const listeners = new AbortController();
host._listeners = listeners;
/** addEventListener, removed automatically when the next build starts. */
const listen = (target, type, handler) => {
target.addEventListener(type, handler, { signal: listeners.signal });
};
// The placeholder stays until the LAYOUT has settled, not just until
// Cytoscape mounts. Removing it here showed the graph before fcose had
// positioned anything - every node stacked at the origin, which read as a
// few huge circles in the middle - and left the later progress messages
// with nothing to write into.
//
// Cytoscape appends its canvases rather than clearing the container, so
// the placeholder is lifted above them and given a background instead.
const placeholder = host.querySelector('[data-network-placeholder]');
if (placeholder) {
placeholder.style.position = 'absolute';
placeholder.style.inset = '0';
placeholder.style.zIndex = '20';
placeholder.style.background = '#ffffff';
}
const cy = cytoscape({
container: host,
elements,
style: [
{
selector: 'node',
style: {
'background-color': 'data(color)',
width: 'data(size)',
height: 'data(size)',
label: 'data(label)',
// Above the node rather than inside it: on a small node a
// centred label falls half outside.
'font-size': 'data(fontS)',
'font-weight': 600,
'text-valign': 'top',
'text-halign': 'center',
'text-margin-y': -6,
// Readable against both the light background and the edges
// underneath: a white outline around the letters.
color: '#0f172a',
'text-outline-color': '#ffffff',
// Thin outline: 3px turned the label into a white blob at
// small sizes. Enough to lift it off the edges beneath.
'text-outline-width': 'data(outlineW)',
'text-opacity': 0, // only on hover or selection
'border-width': 'data(borderW)',
'border-color': 'rgba(0,0,0,0.25)',
// No min-zoomed-font-size: rescaleDecorations() already
// divides the font size by the zoom, so a label renders at
// a constant screen size. Cytoscape's own threshold would
// compare against that compensated value and make labels
// flicker on and off while zooming.
},
},
{
// Closed by its owner: outline only, no fill. The profile is
// gone, so a solid dot would overstate what is still there -
// but its transactions remain part of the network's history.
selector: 'node.removed',
style: {
// Only the fill goes. The outline stays exactly as any
// other node's, so a removed profile reads as the same
// kind of thing - just hollow, because the profile itself
// is gone while its transactions remain.
'background-opacity': 0,
},
},
{
// Marked inactive but NOT removed: same fill and the same
// border width as any other profile, distinguished only by a
// red outline. Two different states - inactive is reversible,
// removed is not.
selector: 'node.inactive',
style: {
'border-color': '#dc2626',
},
},
{
selector: 'edge',
style: {
width: 'data(width)',
// Every line and arrow in every view at 80%, set here once:
// colours are solid and the transparency comes only from
// line-opacity, which Cytoscape applies to the arrowhead as
// well. (Was an rgba colour at 0.245 for the undirected
// views and 0.55 for the flow view.) The SVG export reads
// line-opacity too, so a file matches the screen.
'line-color': '#64748b',
'line-opacity': 0.8,
// Curved everywhere, matching the currency-flow view.
// Was 'haystack', which draws straight lines and cannot
// render an arrowhead at all - that limitation is why the
// flow view needed its own curve style in the first place.
// Arrowheads stay exclusive to edge.directed.
'curve-style': 'unbundled-bezier',
'control-point-distances': 'data(bend)',
'control-point-weights': 0.5,
},
},
{
selector: 'node:selected, node.hovered',
style: {
'text-opacity': 1,
'border-width': 2,
'border-color': '#0f172a',
'z-index': 9999,
},
},
{
// Neighbours get their names too: in a trading network "with
// whom" is the real question, not "who is this single dot".
selector: 'node.neighbour',
style: { 'text-opacity': 1, 'z-index': 9998 },
},
{
// The rest fades, so the neighbourhood stands out.
selector: '.faded',
style: { opacity: 0.15 },
},
{
// Focus mode (after a click): everything outside the
// neighbourhood is removed rather than faded, giving the
// neighbourhood room to breathe.
selector: '.hidden',
style: { display: 'none' },
},
{
// Search hit: a bold ring plus a permanent label, so it can be
// found among hundreds of nodes.
selector: 'node.found',
style: {
'text-opacity': 1,
// Black and only slightly heavier than the default: red is
// now the "marked inactive" marker, so the search hit has
// to be unmistakably a different thing. 2px against the
// default 1px reads clearly without swamping the ~6px
// smallest node.
'border-width': 2,
'border-color': '#000000',
'z-index': 10000,
},
},
{
// In focus every visible node keeps its name: the question is
// "who does this person trade with", which you cannot read if
// each dot has to be hovered separately.
selector: '.focused',
style: { 'text-opacity': 1 },
},
{
selector: 'edge.incident',
// Solid, so it takes the same 80% as every other line.
style: { 'line-color': '#0f172a' },
},
{
// Flow view: one line per direction. unbundled-bezier with its
// own bend per line keeps the two from overlapping; haystack
// does not support arrowheads.
selector: 'edge.directed',
style: {
'curve-style': 'unbundled-bezier',
'control-point-distances': 'data(bend)',
'control-point-weights': 0.5,
'target-arrow-shape': 'triangle',
// Constant, and deliberately so: Cytoscape multiplies the
// head by the line width, so this ratio is already strictly
// proportional. Raising it on thin lines to keep their
// heads visible was tried and reverted - the renderer does
// not scale the way that assumed, and heads ended up larger
// than the nodes, hiding the lines entirely.
//
// The SVG export clamps the head LENGTH instead, which it
// can do because it writes its own geometry.
'arrow-scale': 0.45,
},
},
{
// Colour by transaction type: each arrow is one type and
// carries that type's own volume, so a pair that traded Work
// and Donation shows two arrows the same way round.
selector: 'edge.flowtype',
style: {
'line-color': 'data(edgeColor)',
'target-arrow-color': 'data(edgeColor)',
// Opacity from the base edge rule: 80% everywhere.
},
},
{
// Outside the circle: grey and dashed, so it is immediately
// clear this is not a client (and carries no name).
selector: 'node.outside',
style: { 'border-style': 'dashed', 'border-width': 1.5, 'border-color': '#64748b' },
},
// Time-lapse: last month's frame, behind the current one. LAST in
// the sheet on purpose, so it overrides every colour rule above -
// the red inactive outline and the per-type edge colours included
// - and a ghost is wholly black and white. Opacity is set per
// element by the player, which fades ghosts in and out.
//
// z-index below the default 0 puts ghosts under the current
// frame's nodes and edges respectively. Cytoscape still draws
// every node above every edge, so a ghost node can sit over a
// current line; faint and grey (25%) it does not compete.
{
selector: 'node.ghost',
style: {
'background-color': 'data(ghostColor)',
'border-color': '#9ca3af',
'z-index': -1,
},
},
{
selector: 'edge.ghost',
style: {
'line-color': 'data(ghostColor)',
'target-arrow-color': 'data(ghostColor)',
'z-index': -1,
},
},
{
// The accumulated history: grey like the ghost (it carries the
// ghost class too), but behind it.
selector: '.history',
style: { 'z-index': -2 },
},
],
// A valid layout can only be chosen once the nodes exist (the tree
// needs a root), so start with 'preset' and run() afterwards.
layout: { name: 'preset' },
// Cytoscape renders at a reduced pixel ratio while the viewport moves
// (motionBlurPxRatio -> mbPxRBlurry), which is what makes a zoomed view
// look soft. Off here: these graphs are a few hundred nodes, well within
// what redraws smoothly at full resolution.
motionBlur: false,
// pixelRatio is deliberately not set: Cytoscape only honours a NUMBER
// (forcedPixelRatio), and with none given it uses the device's own
// density, which is what keeps a HiDPI screen sharp.
// Keep elements drawn during interaction rather than swapping in a
// cached low-resolution texture.
textureOnViewport: false,
minZoom: 0.05,
maxZoom: 8,
// Cytoscape's default is 1; 0.2 made scrolling painfully slow. Slightly
// above 1 so a few turns take you from overview to detail.
wheelSensitivity: 1.5,
});
// Keep outlines, borders and labels at a constant size on screen.
//
// Cytoscape scales every style property with the zoom, so a border drawn at
// 1px grows to 4px at 4x zoom. The whole picture then just gets bigger and
// zooming in reveals no extra detail. Dividing these widths by the zoom
// level holds them at their rendered size, so magnifying the graph actually
// separates the elements instead of inflating them.
//
// Only the decorations are compensated. Node sizes and line widths carry
// meaning (volume, transaction count) and must keep scaling, or zooming
// would change what the graph says.
// Sizes are held in data() and read by the stylesheet through data()
// mappings, so rescaling never rewrites the stylesheet itself. An earlier
// version called cy.style().selector('node').style({...}), which replaced
// the whole node rule and wiped out 'text-opacity: 0' — every label then
// appeared at once instead of on hover.
const BASE = { border: 1, outline: 1.5, font: 14 };
let zoomTimer = null;
const rescaleDecorations = () => {
if (cy.destroyed()) {
return;
}
// Only ever shrink, never enlarge: below 1x the division would inflate
// the decorations instead of holding them steady.
const zoom = Math.max(cy.zoom(), 1);
// Text is compensated at every zoom level, uncapped, so a label holds
// the same screen size however far you zoom in. Capping it made the
// label start growing again past the cap, because the divisor froze
// while the zoom kept climbing.
const textDivisor = zoom;
// Borders and outlines are capped: past 3x the compensation would make
// them thinner than a pixel and they would disappear. They do grow
// slowly beyond that, but a widening hairline is far less intrusive
// than a label that balloons over the graph.
const lineDivisor = Math.min(zoom, 3);
cy.batch(() => {
cy.nodes().forEach((node) => {
node.data('borderW', BASE.border / lineDivisor);
node.data('outlineW', BASE.outline / textDivisor);
node.data('fontS', BASE.font / textDivisor);
});
});
};
// Coalesced to the next frame rather than debounced. A timeout let the
// labels ride at the wrong size for the whole gesture and snap only after
// it ended; this is only a data write inside a batch, so it is cheap
// enough to track the zoom continuously.
const scheduleRedraw = () => {
if (zoomTimer !== null) {
return;
}
zoomTimer = requestAnimationFrame(() => {
zoomTimer = null;
rescaleDecorations();
});
};
cy.on('zoom', scheduleRedraw);
// The initial layout fits the graph to the container at whatever zoom that
// takes, so compensate once up front rather than waiting for the first
// user zoom.
rescaleDecorations();
// Only show labels where the mouse is: 325 labels at once is unreadable.
// Cytoscape has no built-in tooltip, so this is a custom element that
// follows the mouse. Inside the host, so it cannot fall outside the map.
const tip = document.createElement('div');
tip.className = 'stats-network-tip';
tip.setAttribute('hidden', '');
Object.assign(tip.style, {
position: 'absolute',
zIndex: '20',
pointerEvents: 'none',
background: 'rgba(15,23,42,0.94)',
color: '#f8fafc',
padding: '7px 9px',
borderRadius: '6px',
fontSize: '12px',
lineHeight: '1.45',
whiteSpace: 'nowrap',
boxShadow: '0 2px 8px rgba(0,0,0,0.25)',
});
if (getComputedStyle(host).position === 'static') {
host.style.position = 'relative';
}
host.appendChild(tip);
const focusNote = control(wrapper, '[data-network-focus]');
const labels = (() => {
try {
return JSON.parse(wrapper.getAttribute('data-network-labels') || '{}');
} catch (error) {
return {};
}
})();
cy.on('mouseover', 'node', (event) => {
const node = event.target;
const neighbourhood = node.closedNeighborhood();
cy.elements().difference(neighbourhood).addClass('faded');
node.addClass('hovered');
node.neighborhood('node').addClass('neighbour');
node.connectedEdges().addClass('incident');
const d = node.data();
const rows = [
`<strong>${escapeHtml(d.label || '')}</strong>`,
];
// Full name under the username, when there is one that differs.
if (d.fullName) {
rows.push(`<span class="opacity-80">${escapeHtml(d.fullName)}</span>`);
}
rows.push(
d.hiddenPartners > 0
? `${escapeHtml(labels.partners || 'Partners')}: ${d.degree} `
+ `<span class="opacity-70">(${escapeHtml(labels.hidden || 'hidden')}: ${d.hiddenPartners})</span>`
: `${escapeHtml(labels.partners || 'Partners')}: ${d.degree}`,
`${escapeHtml(labels.paid || 'Paid')}: ${formatHours(d.paid)}`,
`${escapeHtml(labels.received || 'Received')}: ${formatHours(d.received)}`,
`${escapeHtml(labels.total || 'Total')}: ${formatHours(d.volume)}`,
);
// Circulation view only: what the node does with the hours it sees, and
// for a dead end how many are sitting there unreturned.
if (d.flow) {
const flowLabel = (labels.flow || {})[d.flow];
if (flowLabel) {
rows.push(`${escapeHtml(labels.flowRole || 'Flow')}: ${escapeHtml(flowLabel)}`);
}
if (d.flow === 'stalled' && d.flowNet > 0) {
rows.push(`${escapeHtml(labels.stale || 'Stagnant')}: ${formatHours(d.flowNet)}`);
}
}
// Which bank network this profile belongs to. Shown for every node, so
// a reader can tell an outsider's circle from their own clients'.
if (d.bank) {
rows.push(`${escapeHtml(labels.bank || 'Bank')}: ${escapeHtml(d.bank)}`);
}
if (d.region) {
rows.push(escapeHtml(d.region));
}
tip.innerHTML = rows.join('<br>');
tip.removeAttribute('hidden');
});
cy.on('mousemove', (event) => {
if (tip.hasAttribute('hidden')) {
return;
}
const pos = event.renderedPosition || { x: 0, y: 0 };
const box = host.getBoundingClientRect();
// Flip at the edge, so the tooltip stays within view.
const left = pos.x + 14 + tip.offsetWidth > box.width
? pos.x - tip.offsetWidth - 14
: pos.x + 14;
const top = pos.y + 14 + tip.offsetHeight > box.height
? pos.y - tip.offsetHeight - 14
: pos.y + 14;
tip.style.left = `${Math.max(0, left)}px`;
tip.style.top = `${Math.max(0, top)}px`;
});
const clearHover = () => {
cy.elements().removeClass(HOVER_CLASSES);
tip.setAttribute('hidden', '');
};
cy.on('mouseout', 'node', clearHover);
// Leaving the graph fast - straight to a button - can skip the node's
// mouseout, leaving the rest of the graph faded until the next hover.
listen(host, 'mouseleave', clearHover);
// Clicking filters the view down to that node's neighbourhood: the node
// itself, its direct trading partners and the edges between them.
// Everything else disappears, so the layout can breathe again.
let focused = null;
const clearFocus = () => {
focused = null;
cy.elements().removeClass('hidden focused');
runLayout(cy, cy.elements(), layoutOptions(layoutName, cy));
if (focusNote) {
focusNote.hidden = true;
}
};
cy.on('tap', 'node', (event) => {
const node = event.target;
// Clicking the same node again brings the whole graph back.
if (focused === node.id()) {
clearFocus();
return;
}
focused = node.id();
const neighbourhood = node.closedNeighborhood();
cy.elements().addClass('hidden');
neighbourhood.removeClass('hidden').addClass('focused');
// Re-run the layout on the visible nodes only, otherwise they stay in
// their old positions with large gaps between them.
// In focus the neighbourhood is small enough for a force layout, even
// when the main view is a grid or circle: here it is the structure of
// this one circle that matters.
runLayout(cy, neighbourhood, {
name: 'fcose', quality: 'default', animate: false, randomize: true,
nodeRepulsion: 5000, idealEdgeLength: 90,
});
if (focusNote) {
const count = node.neighborhood('node').length;
focusNote.textContent = (focusNote.dataset.template || '')
.replace(':name', node.data('label') || '')
.replace(':count', count);
focusNote.hidden = false;
}
});
// --- Searching within the drawn graph ---
const searchInput = control(wrapper, '[data-network-search-input]');
const searchResults = control(wrapper, '[data-network-search-results]');
const clearHighlight = () => cy.elements().removeClass('found');
const revealNode = (node) => {
clearHighlight();
// Was the node hidden by an active focus? Restore everything first,
// otherwise we highlight something that cannot be seen.
if (node.hasClass('hidden')) {
clearFocus();
}
node.addClass('found');
// Zoom to the node itself: among hundreds of nodes a highlight without
// centring is still impossible to find.
cy.animate({ center: { eles: node }, zoom: 1.6 }, { duration: 300 });
};
if (searchInput && searchResults) {
const renderResults = (term) => {
const query = term.trim().toLowerCase();
if (query.length < 2) {
searchResults.hidden = true;
searchResults.innerHTML = '';
return;
}
// Ranked by how well the label matches, THEN by degree. Sorting on
// degree alone buried exact hits: "User 2" substring-matches 153
// labels ("User 263", "User 2050", ...), and since those hubs have
// far more partners than the profile actually being looked for,
// the exact match never reached the eight shown.
const rank = (label) => {
const value = label.toLowerCase();
if (value === query) {
return 0;
}
if (value.startsWith(query)) {
// A word boundary after the prefix beats a longer number:
// for "user 2", "User 2" scores here and "User 263" below.
return /^\s|^$/.test(value.slice(query.length)) ? 1 : 2;
}
return 3;
};
const matches = cy.nodes()
.filter((n) => !n.hasClass('outside')
&& String(n.data('label') || '').toLowerCase().includes(query))
.toArray()
.sort((a, b) => {
const byRank = rank(String(a.data('label') || ''))
- rank(String(b.data('label') || ''));
return byRank !== 0
? byRank
: (b.data('degree') || 0) - (a.data('degree') || 0);
})
.slice(0, 8);
if (matches.length === 0) {
// A profile can be in the stats tables and still not be a node
// here: the graph drops anyone below the partner threshold, and
// shows only the ticked profile types. Say so, rather than
// implying the profile does not exist.
const hint = labels.noMatchHint
? `<div class="px-3 pb-2 text-xs text-gray-500">${escapeHtml(labels.noMatchHint)}</div>`
: '';
searchResults.innerHTML =
`<div class="px-3 pt-2 pb-1 text-sm text-gray-500">${escapeHtml(labels.noMatch || 'No match')}</div>${hint}`;
searchResults.hidden = false;
return;
}
searchResults.innerHTML = matches.map((n) => `
<button type="button" data-node-id="${escapeHtml(n.id())}"
class="block w-full px-3 py-2 text-left text-sm hover:bg-gray-100">
<span class="font-semibold">${escapeHtml(n.data('label') || '')}</span>
<span class="text-gray-500"> &middot; ${n.data('degree')} ${escapeHtml(labels.partners || '')}</span>
</button>
`).join('');
searchResults.hidden = false;
};
listen(searchInput, 'input', (event) => renderResults(event.target.value));
listen(searchInput, 'search', (event) => {
// The 'x' in a type=search field clears it.
if (event.target.value === '') {
clearHighlight();
searchResults.hidden = true;
}
});
// mousedown rather than click: the field would otherwise lose focus
// first and the list would be hidden before the click arrives.
listen(searchResults, 'mousedown', (event) => {
const button = event.target.closest('[data-node-id]');
if (!button) {
return;
}
event.preventDefault();
const node = cy.getElementById(button.dataset.nodeId);
if (node && node.length) {
revealNode(node);
searchInput.value = node.data('label') || '';
searchResults.hidden = true;
}
});
// Enter highlights the first match straight away.
listen(searchInput, 'keydown', (event) => {
if (event.key !== 'Enter') {
return;
}
event.preventDefault();
const first = searchResults.querySelector('[data-node-id]');
if (first) {
const node = cy.getElementById(first.dataset.nodeId);
if (node && node.length) {
revealNode(node);
searchResults.hidden = true;
}
}
});
listen(document, 'click', (event) => {
if (!wrapper.contains(event.target)) {
searchResults.hidden = true;
}
});
}
// Clicking empty canvas clears the focus.
cy.on('tap', (event) => {
if (event.target === cy && focused) {
clearFocus();
}
});
// Dragging takes the direct trading partners along by default, so a dense
// clump can be pulled apart by hand.
//
// Through a checkbox rather than Shift: Cytoscape already uses Shift for
// box selection, so that key drew a selection rectangle instead of
// dragging.
// Each press opens the cloud up another 50%, or pulls it back in by the
// same step, so a dense centre can be separated and recovered. The two
// factors are exact inverses, so spread followed by shrink returns to the
// positions you started from. Decorations are rescaled afterwards because
// they compensate for zoom, which the spread itself leaves alone.
const SPREAD_STEP = 1.5;
[
['[data-network-spread]', SPREAD_STEP],
['[data-network-shrink]', 1 / SPREAD_STEP],
].forEach(([selector, factor]) => {
const button = control(wrapper, selector);
if (!button) {
return;
}
listen(button, 'click', (event) => {
event.preventDefault();
spreadBy(cy, cy.elements(), factor);
rescaleDecorations();
});
});
// Full screen: the graph with its header (caption, subtext, spread and
// full-screen buttons) and the playback panel - not the page around it.
// See network-fullscreen.js.
setupFullscreen({
screenBox: control(wrapper, '[data-network-screen]'),
frameBox: control(wrapper, '[data-network-frame]'),
headerBox: control(wrapper, '[data-network-header]'),
button: control(wrapper, '[data-network-fullscreen]'),
panel: () => control(wrapper, '[data-network-timelapse-controls]'),
signal: listeners.signal,
// The box has a new size once the browser has switched; only then can
// the canvas take it and the graph be framed again. Everything not
// click-focused away, so a time-lapse keeps its whole union in frame
// instead of jumping to one step.
onResize: () => {
if (cy.destroyed()) {
return;
}
cy.resize();
const shown = cy.elements().not('.hidden');
const panel = control(wrapper, '[data-network-timelapse-controls]');
// Clear of the header while it shows, rather than under it.
cy.fit(shown, fitPadding(cy));
clearOverlay(cy, shown, panel
? { width: panel.offsetWidth, height: panel.offsetHeight + 12 }
: null);
rescaleDecorations();
},
});
// SVG export of exactly what is on screen. Done here rather than through
// Livewire: a round trip would rebuild the graph and discard the view the
// user is asking to export - the same trap the PDF export hit.
const svgButton = control(wrapper, '[data-network-svg]');
if (svgButton) {
listen(svgButton, 'click', (event) => {
event.preventDefault();
// Same attribute the graph itself reads, so the export can never
// disagree with what is on screen.
const anonymising = wrapper.getAttribute('data-network-anonymize') === '1';
const meta = safeJson(wrapper.getAttribute('data-network-meta'));
// Merge in the browser-computed subtext, which the server payload
// cannot carry.
const hostEl = wrapper.querySelector('[data-network-host]');
// The subtext exactly as the page shows it at this moment, read
// from the visible line itself rather than a stored copy - during
// a time-lapse it changes with every frame, and the export must
// say what is on screen.
const note = control(wrapper, '[data-network-stagnant]');
const visible = note && !note.hidden ? note.textContent.trim() : '';
if (meta && visible) {
meta.subtext = visible;
} else if (meta && hostEl && hostEl.dataset.networkSubtext) {
meta.subtext = hostEl.dataset.networkSubtext;
}
// A time-lapse's header on screen is the caption and that subtext,
// which starts with the frame's month. The server's period line
// describes the page, not the frame on screen, so it is left out:
// the export shows the same header as the web view.
if (meta && hostEl && hostEl._timelapse) {
meta.period = null;
}
const svg = withoutHover(cy, () => networkToSvg(cy, !anonymising, meta));
if (!svg) {
return;
}
const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = (svgButton.dataset.filename || 'trading-network')
+ '-' + new Date().toISOString().slice(0, 10) + '.svg';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Revoked on the next tick: revoking immediately can cancel the
// download in some browsers before it has read the blob.
setTimeout(() => URL.revokeObjectURL(url), 1000);
});
}
const dragToggle = control(wrapper, '[data-network-drag-neighbours]');
const dragNeighbours = () => !dragToggle || dragToggle.checked;
let dragging = null;
cy.on('grab', 'node', (event) => {
const node = event.target;
if (!dragNeighbours()) {
dragging = null;
return;
}
// Capture positions on grab: during the drag we work from the offset,
// so the relative distances are preserved.
const origin = { ...node.position() };
const followers = node.neighborhood('node')
.filter((n) => !n.hasClass('hidden'))
.map((n) => ({ node: n, offset: { x: n.position('x') - origin.x, y: n.position('y') - origin.y } }));
dragging = { node, followers };
});
cy.on('drag', 'node', (event) => {
if (!dragging || dragging.node.id() !== event.target.id()) {
return;
}
const pos = dragging.node.position();
dragging.followers.forEach(({ node: follower, offset }) => {
follower.position({ x: pos.x + offset.x, y: pos.y + offset.y });
});
});
cy.on('free', 'node', () => {
dragging = null;
});
// Phase 3: the layout. fcose, the default, takes ~4.9s on the full
// graph and blocks the thread, so this message has to paint BEFORE it
// starts - which is what the await is for.
await progress((statusLabels.arranging || 'Arranging :count profiles...')
.replace(':count', cy.nodes().not('.hidden').length));
if (superseded()) {
cy.destroy();
return;
}
// The placeholder comes down only once the nodes have real positions.
//
// Belt and braces: a layout that never emits 'layoutstop' would otherwise
// leave the placeholder covering the finished graph for good, which is a
// worse failure than showing it a moment early.
const dropPlaceholder = () => {
if (placeholder && placeholder.isConnected) {
placeholder.remove();
}
};
// Two minutes, not 30s: the layout runs in a worker now, and on a slow phone
// it can legitimately take longer than 30s - lifting the cover then showed
// a graph whose nodes had no positions yet. Failures fall back on their own
// (see runLayout), so this only has to catch a layout that never ends.
setTimeout(dropPlaceholder, 120000);
// Measured, not assumed: the panel's width depends on the month label and
// the translated "Speed", which differ per locale. Only present during a
// time-lapse, so a static graph keeps the whole canvas.
const overlayPanel = timelapse
? control(wrapper, '[data-network-timelapse-controls]')
: null;
const reserve = overlayPanel
? {
width: overlayPanel.offsetWidth,
// The panel sits 12px (bottom-3) above the container floor.
height: overlayPanel.offsetHeight + 12,
}
: null;
// Time-lapse: the opening frame is applied INSIDE the layout callback,
// after fit() has framed the union but before the placeholder comes down.
//
// Not before the layout: cy.fit() and spreadToContainer() both measure
// boundingBox(), which respects `display`, so hiding the later months
// first would frame only the opening one - the graph would zoom in hard
// and every later frame would spill outside the viewport as it grew.
//
// Not after either: the union is every profile that ever traded, and left
// until later the whole accumulated network flashed up the instant the
// cover came off, then collapsed to the first month.
// The same options classifyFlow() was given, for colouring frames.
const frameStateOptions = {
view,
stoppedAfterDays: Number.isFinite(stoppedAfter) ? stoppedAfter : null,
markInactive: wrapper.getAttribute('data-network-mark-inactive') === '1',
};
const settleFirstFrame = (timelapse && frames.length > 0)
? () => {
showFrame(cy, lapse.layers(0));
applyFrameStates(cy, frames[0], timeline, frameStateOptions);
}
: null;
// Cleared on every build, and BEFORE the layout starts: the layout runs in a
// worker and may report a fallback later, which must not be wiped by this.
// Cleared on every build: without this the cluster warning stayed on
// screen after switching to a view that never produced it.
const warningNote = control(wrapper, '[data-network-warning]');
if (warningNote) {
const t = warningNote.querySelector('[data-network-warning-text]');
if (t) {
t.textContent = '';
}
// Emptied rather than hidden: it holds a reserved line, so hiding it
// would shift everything below by the height of one row.
warningNote.hidden = false;
}
// Resolved once nodes have their positions. The layout now runs in a worker,
// so anything that needs a laid-out graph - the time-lapse's opening frame
// and its Play button - waits for this rather than running straight after.
let markLayoutReady;
const layoutReady = new Promise((resolve) => {
markLayoutReady = resolve;
});
runLayout(
cy,
cy.elements(),
layoutOptions(layoutName, cy),
() => {
if (settleFirstFrame) {
settleFirstFrame();
}
dropPlaceholder();
markLayoutReady();
},
reserve,
(error) => {
const t = warningNote && warningNote.querySelector('[data-network-warning-text]');
if (t) {
t.textContent = `${statusLabels.layoutFallback || 'The usual arrangement failed, so a simpler one is shown.'} (${error.message || error})`;
}
}
);
// Cluster view: Markov clustering ships with Cytoscape itself, so no extra
// dependency. Runs on the visible graph, after filtering.
if (view === 'cluster') {
// Markov clustering grows at roughly n^3.5. Measured on this graph:
// 450 nodes 4.3s, 600 nodes 12.1s, 700 nodes 16.6s, 1173 nodes 117s,
// 1500 nodes 243s. It runs on the main thread, so past the ceiling the
// page simply froze and the graph never appeared, with the catch below
// swallowing any sign of why.
//
// 600 rather than 700: 12s is tolerable now that the progress message
// explains the wait, while 700 starts reading as broken. Raising this
// meaningfully needs a different algorithm - Louvain or label
// propagation run near-linearly - not a bigger number here.
const CLUSTER_NODE_LIMIT = 600;
const clusterable = cy.nodes().not('.hidden').length;
if (clusterable > CLUSTER_NODE_LIMIT) {
renderClusterLegend(wrapper, [], 0);
// In the header, under the subtext, in the warning colour: the
// graph IS drawn, only the colouring could not be computed, and
// the cluster legend below is the wrong place to say so.
const warn = control(wrapper, '[data-network-warning]');
const warnText = warn && warn.querySelector('[data-network-warning-text]');
if (warn && warnText) {
warnText.textContent = (wrapper.getAttribute('data-cluster-too-large') || '')
.replace(':count', clusterable)
.replace(':limit', CLUSTER_NODE_LIMIT);
}
} else {
// Phase 4: clustering, up to ~2.5s at the 450-node ceiling.
// Up to ~12s at the 600-node ceiling, and the thread is blocked
// throughout, so the message says so rather than looking hung.
await progress((statusLabels.clustering || 'Finding clusters in :count profiles, this can take a moment...')
.replace(':count', clusterable));
if (superseded()) {
cy.destroy();
return;
}
try {
const clusters = cy.elements().markovClustering();
const legend = [];
clusters.forEach((cluster, i) => {
const color = REGION_COLORS[i % REGION_COLORS.length];
const members = cluster.nodes().filter((n) => !n.hasClass('outside'));
members.forEach((node) => node.data('color', color));
// Single-node clusters say nothing and would flood the legend;
// they keep their colour but are summarised as one entry below.
if (members.length > 1) {
legend.push({ color, size: members.length });
}
});
renderClusterLegend(wrapper, legend, clusters.length);
} catch (error) {
// Clustering is a bonus; a failure must not make the graph
// unusable.
}
}
}
// Time-lapse controls. Bound after the layout so the first frame is shown
// on a graph whose positions are final.
//
// Driven entirely from here rather than through Livewire: a round trip per
// frame would rebuild the graph and throw away the union layout the whole
// animation depends on.
const timelapseControls = control(wrapper, '[data-network-timelapse-controls]');
if (timelapse && frames.length > 0 && timelapseControls) {
// Any failure here used to leave the spinner turning for ever.
try {
const playButton = timelapseControls.querySelector('[data-timelapse-play]');
const scrubber = timelapseControls.querySelector('[data-timelapse-scrubber]');
const label = timelapseControls.querySelector('[data-timelapse-year]');
const speedControl = timelapseControls.querySelector('[data-timelapse-speed]');
const playLabel = statusLabels.timelapsePlay || 'Play';
const pauseLabel = statusLabels.timelapsePause || 'Pause';
const playIcon = timelapseControls.querySelector('[data-timelapse-icon-play]');
const pauseIcon = timelapseControls.querySelector('[data-timelapse-icon-pause]');
const busyIcon = timelapseControls.querySelector('[data-timelapse-icon-busy]');
/**
* Show the icon for what the button will DO, not the current state.
*
* Both icons are in the markup and one is hidden, so no SVG is written
* from here. The accessible name has to move with the icon: without it
* the button would be an unlabelled glyph to a screen reader.
*/
const setPlayButton = (isPlaying) => {
if (!playButton) {
return;
}
const label = isPlaying ? pauseLabel : playLabel;
playButton.setAttribute('title', label);
playButton.setAttribute('aria-label', label);
if (playIcon && pauseIcon) {
playIcon.classList.toggle('hidden', isPlaying);
pauseIcon.classList.toggle('hidden', !isPlaying);
}
// The graph is drawn by the time this runs, so whatever the button
// showed while loading gives way to a real control.
if (busyIcon) {
busyIcon.classList.add('hidden');
}
playButton.disabled = false;
};
// Node size is derived from the union's maxima, which is what
// buildElements() used when it laid the graph out. Recomputing per
// frame would rescale the whole graph every month - a node would grow
// because the busiest node shrank, which is not what it means.
const frameMaxVolume = (data.nodes || [])
.reduce((m, n) => Math.max(m, n.volume || 0), 0);
const frameMaxDegree = (data.nodes || [])
.reduce((m, n) => Math.max(m, n.degree || 0), 0);
// The element lookup every step boundary uses, built now rather than
// on the first boundary, where it would be a hitch as playback starts.
warmTimelapse(cy);
// Sizes AS OF each step's date - partners or traded time up to then
// - against the all-time maxima above, so a node grows as the
// time-lapse moves on rather than starting at its final size.
const measures = nodeMeasures(cy, lapse, volumes);
const sizeOf = (id, i) => (view === 'volume'
? nodeSize(measures.volumeAt(id, i), frameMaxVolume)
: nodeSize(measures.degreeAt(id, i), frameMaxDegree));
// Steps per month, so the speed slider keeps meaning months per
// second: a weekly time-lapse plays at the same pace, only in
// smaller steps.
const stepsPerMonth = wrapper.getAttribute('data-network-timelapse-step') === 'week'
? 30.44 / 7
: 1;
const stepInterval = (monthsPerSecond) => 1000 / (monthsPerSecond * stepsPerMonth);
const player = createPlayer({
cy,
sizeOf,
changedAt: measures.changedAt,
count: frames.length,
layersAt: (i) => lapse.layers(i),
interval: stepInterval(speedFromSlider(speedControl ? speedControl.value : 36)),
// Playback loops, pausing on the last frame first. Without the
// pause the end is indistinguishable from the beginning and the
// loop just reads as a stutter.
holdAtEnd: 3000,
onFrame: (i) => {
const frame = frames[i];
// At the START of the transition, with the label: the states
// belong to the step being moved towards.
applyFrameStates(cy, frame, timeline, frameStateOptions);
if (label) {
label.textContent = frame.label || '';
}
if (scrubber) {
scrubber.value = i;
}
setPlayButton(player.isPlaying());
// Keep the subtext honest: it describes what is drawn, and
// during a time-lapse that is one window, not the whole period.
renderFrameSubtext(wrapper, frame.label || '', lapse.layers(i).current);
},
});
if (scrubber) {
scrubber.max = String(Math.max(0, frames.length - 1));
scrubber.value = '0';
listen(scrubber, 'input', () => {
// Scrubbing is an explicit take-over, so it pauses.
player.goTo(parseInt(scrubber.value, 10) || 0);
setPlayButton(false);
});
}
if (speedControl) {
listen(speedControl, 'input', () => {
const speed = speedFromSlider(speedControl.value);
player.setInterval(stepInterval(speed));
const readout = timelapseControls
.querySelector('[data-timelapse-speed-value]');
if (readout) {
readout.textContent = formatSpeed(speed);
}
});
}
if (playButton) {
listen(playButton, 'click', () => {
if (player.isPlaying()) {
player.pause();
setPlayButton(false);
} else {
player.play();
setPlayButton(true);
}
});
}
// After the layout, not now: it runs in a worker, and until it is
// done the nodes have no positions. Applying the frame first would
// also let the fit frame only the opening month (see
// settleFirstFrame). sizeOf only exists here, so this re-applies
// the frame to settle node sizes too.
layoutReady.then(() => {
if (cy.destroyed() || superseded()) {
return;
}
showFrame(cy, lapse.layers(0), (id) => sizeOf(id, 0));
setPlayButton(false);
});
if (label) {
label.textContent = frames[0].label || '';
}
// The first frame's subtext is written after the general subtext block
// below, which runs later in build() and would otherwise overwrite it.
host._timelapseFirst = {
year: frames[0].label || '',
frame: lapse.layers(0).current,
};
// Pause when the component is morphed away, so a running timer cannot
// keep poking a destroyed graph.
host._timelapse = player;
} catch (error) {
console.error('[time-lapse] setting up playback failed', error);
setPlaybackState(
timelapseControls,
'error',
statusLabels.timelapseFailed || 'The time-lapse could not be loaded.'
);
}
} else if (timelapse && timelapseControls) {
// Nothing to play: say which of the two reasons it is.
setPlaybackState(
timelapseControls,
'error',
timelapseFailed
? (statusLabels.timelapseFailed || 'The time-lapse could not be loaded.')
: (statusLabels.timelapseEmpty || 'Nothing to animate for this selection.')
);
} else if (host._timelapse) {
host._timelapse.pause();
host._timelapse = null;
}
host._cy = cy;
host.dataset.networkSignature = signature;
host._buildingSignature = null;
// Hand the live instance to the PDF exporter. It captures whatever is on
// screen — layout, colours, hidden hub, focus — so the export matches the
// view rather than a freshly computed graph.
window.statsNetworkChart = cy;
// Notice about the hidden hub, so a trimmed graph says so.
// Subtext under the caption: how much currency is sitting still. Only the
// circulation view classifies nodes, so it is blank everywhere else.
const stagnantNote = control(wrapper, '[data-network-stagnant]');
if (stagnantNote) {
// Always says what is on screen. The circulation view can add what the
// colours mean; every other view still states the size of the drawing,
// because "1173 nodes" is the first thing a reader needs and the graph
// itself never says it.
const shown = cy.nodes().not('.hidden').length;
const drawn = cy.edges().not('.hidden').length;
const base = (stagnantNote.dataset.drawnTemplate || '')
.replace(':nodes', shown)
.replace(':edges', drawn);
const extra = (view === 'circulation' && stagnantNodes > 0)
? (stagnantNote.dataset.template || '')
.replace(':count', stagnantNodes)
.replace(':never', neverPaidNodes)
.replace(':hours', formatHours(stagnantMinutes))
: '';
stagnantNote.textContent = [base, extra].filter(Boolean).join(' ');
// Never hidden: the element reserves a line so the header keeps one
// height, and collapsing it resized the graph underneath. It always
// has something to say - at minimum how much is drawn.
stagnantNote.hidden = false;
// The SVG export builds its header from the server-rendered meta, which
// cannot know this sentence: the counts come from the classifier, in
// the browser. Hand it over so the exported figure says the same thing
// the screen does.
host.dataset.networkSubtext = stagnantNote.textContent;
// Time-lapse: the sentence above describes the union of every year,
// which is not a year anyone traded in. Replace it with the first
// frame's own counts now that the element exists and is populated.
if (host._timelapseFirst) {
renderFrameSubtext(
wrapper, host._timelapseFirst.year, host._timelapseFirst.frame
);
host._timelapseFirst = null;
}
}
const note = control(wrapper, '[data-network-note]');
if (note && droppedHub && hub) {
note.textContent = note.dataset.template
// label only, never hub.name: falling back to the raw name would
// reintroduce the leak this label exists to prevent.
? note.dataset.template.replace(':name', hub.label || '').replace(':count', hub.degree)
: '';
note.hidden = false;
} else if (note) {
note.hidden = true;
}
window.statsNetwork = cy;
}
/**
* Render the network exactly as it appears on screen, for the PDF.
*
* full: false is what makes this the *current view* — with full: true Cytoscape
* draws the graph's whole bounding box instead, discarding pan and zoom (see
* its export-image: "draw the full bounds" versus "draw the current view").
* Nodes dragged to a new position, a focused neighbourhood and any hidden
* elements all carry over either way, because they are part of the rendered
* state rather than the viewport.
*
* The bitmap therefore matches the container's aspect ratio; the PDF slot uses
* the same 16:9 proportion so nothing is stretched.
*
* @returns {string|null} data URL, or null when there is no graph on screen.
*/
window.exportNetworkForPdf = function () {
// A snapshot taken on click wins: exporting goes through Livewire, which
// re-renders the component and rebuilds the graph from scratch. By the time
// the export handler asks for an image the live chart has been reset to its
// default view, so the user's zoom, pan and focus are already gone.
if (window.__networkSnapshot) {
const snapshot = window.__networkSnapshot;
window.__networkSnapshot = null;
return snapshot;
}
return captureNetwork();
};
/** Capture the live graph as it is rendered right now. */
/**
* Export the current view as an SVG vector file.
*
* Written by hand rather than through cytoscape-svg: that extension is GPLv3,
* and this project is MIT throughout. Building the document here also keeps
* labels as real <text> elements, so they stay selectable and editable in
* Illustrator or Inkscape instead of being flattened into outlines.
*
* Geometry and colours are read from the live elements, so whatever is on
* screen - view, layout, zoom, pan, a focused node, dragged positions - is
* what gets written out.
*
* @param {boolean} withLabels Write node labels. False while anonymising: the
* on-screen label is a pseudonym ("User 12"), and an exported file is
* the thing that actually gets shared, so it carries no names at all
* rather than numbered stand-ins.
*/
/**
* The platform logo for the SVG export: {defs, body}, or null without one.
*
* The logo arrives as the theme's inline SVG markup plus the colour to draw it
* in - the view colours itself through a CSS class, and a standalone file has
* no stylesheet, so the colour is set as a fill here and inherited by the
* paths. It is nested whole as an <svg> with its own viewBox, so its internal
* clip paths need no rewriting.
*
* The white outline of the printed posters is a filter rather than a second,
* stroked copy: a copy would duplicate the logo's internal ids. The filter
* grows the logo's own shape in white and lays the logo over it.
*/
function exportLogo(meta, width, header, pad) {
const source = meta && meta.logo && meta.logo.svg ? String(meta.logo.svg) : '';
const viewBox = /viewBox="([^"]+)"/.exec(source);
if (!source || !viewBox || header <= 0) {
return null;
}
const [, , vw, vh] = viewBox[1].trim().split(/[\s,]+/).map(Number);
if (!(vw > 0) || !(vh > 0)) {
return null;
}
// Everything inside the logo's own <svg> element.
const inner = source
.replace(/^[\s\S]*?<svg\b[^>]*>/, '')
.replace(/<\/svg>\s*$/, '');
// Fills the header band, with an 8px margin above and below.
const h = Math.max(20, header - 16);
const w = h * (vw / vh);
const x = width - pad - w;
const y = 8;
const color = String(meta.logo.color || '#000000').replace(/[^#0-9a-zA-Z(),. ]/g, '');
return {
defs: ' <filter id="logo-halo" x="-15%" y="-15%" width="130%" height="130%">'
+ '<feMorphology in="SourceAlpha" operator="dilate" radius="1.5" result="grown"/>'
+ '<feFlood flood-color="#ffffff"/>'
+ '<feComposite in2="grown" operator="in" result="halo"/>'
+ '<feMerge><feMergeNode in="halo"/><feMergeNode in="SourceGraphic"/></feMerge>'
+ '</filter>',
body: `<g filter="url(#logo-halo)">`
+ `<svg x="${x.toFixed(2)}" y="${y}" width="${w.toFixed(2)}" height="${h.toFixed(2)}" `
+ `viewBox="${viewBox[1]}" preserveAspectRatio="xMaxYMin meet" fill="${color}">`
+ `${inner}</svg></g>`,
// Where it sits, so the header text can wrap before it.
x,
bottom: y + h,
};
}
/**
* Width of a line of header text in the exported SVG, in pixels.
*
* Measured with the browser's own font metrics on a canvas, for the same font
* the SVG names, so wrapping matches what a viewer draws. Outside a browser
* (tests) an average character width stands in.
*/
let svgTextContext;
function svgTextWidth(text, size, weight = 400) {
if (svgTextContext === undefined) {
try {
svgTextContext = document.createElement('canvas').getContext('2d') || null;
} catch (error) {
svgTextContext = null;
}
}
if (svgTextContext) {
svgTextContext.font = `${weight} ${size}px Helvetica, Arial, sans-serif`;
return svgTextContext.measureText(text).width;
}
return String(text).length * size * (weight >= 600 ? 0.58 : 0.52);
}
/** Break text into lines no wider than maxWidth, at spaces. */
function wrapSvgText(text, size, weight, maxWidth) {
const words = String(text).split(/\s+/).filter(Boolean);
const lines = [];
let line = '';
words.forEach((word) => {
const candidate = line ? `${line} ${word}` : word;
// A single word wider than the space still gets its own line rather
// than being cut: it overruns, but nothing is lost.
if (line && svgTextWidth(candidate, size, weight) > maxWidth) {
lines.push(line);
line = word;
} else {
line = candidate;
}
});
if (line) {
lines.push(line);
}
return lines;
}
function networkToSvg(cy, withLabels = true, meta = null) {
if (!cy || cy.destroyed() || cy.nodes().length === 0) {
return null;
}
const width = cy.width();
const graphHeight = cy.height();
const pan = cy.pan();
const zoom = cy.zoom();
// Space reserved above for the caption and below for legend and settings.
// The graph itself keeps its own coordinates and is shifted down by
// HEADER, so the exported picture matches the screen exactly rather than
// being rescaled to make room.
const PAD = 16;
// The header's usual height, for one line each of caption, period and
// subtext. The logo is sized from THIS, not from the final height: sized
// from the wrapped header, more lines would make a larger logo, leaving
// less room for text, making more lines.
const baseHeader = meta
? (40 + (meta.period ? 12 : 0) + (meta.subtext ? 16 : 0))
: 0;
const logo = exportLogo(meta, width, baseHeader, PAD);
// Header text never runs behind the logo: every line wraps at a 12px gap
// before it (or at the right padding without one). A long flow-view
// caption on one line crossed the whole 1087px export, logo included.
const textRight = logo ? logo.x - 12 : width - PAD;
const headerLines = [];
if (meta) {
const blocks = [
{ text: meta.caption, size: 15, weight: 600, fill: '#0f172a' },
// The period sits directly under the caption: an exported figure
// that does not say what span it covers cannot be read once it has
// left the screen. Still repeated in the settings line below, the
// full technical record.
{ text: meta.period, size: 11, weight: 400, fill: '#64748b' },
{ text: meta.subtext, size: 10, weight: 400, fill: '#64748b' },
];
let y = 0;
blocks.filter((b) => b.text).forEach((block) => {
wrapSvgText(block.text, block.size, block.weight, textRight - PAD).forEach((line) => {
// The first baseline at 22, then one line height per line: 16
// for the small text, 20 for the caption's own continuation.
y = y === 0 ? 22 : y + block.size + 5;
headerLines.push({ ...block, text: line, y });
});
});
}
const lastBaseline = headerLines.length ? headerLines[headerLines.length - 1].y : 0;
const HEADER = meta
? Math.max(baseHeader, lastBaseline + 14, logo ? logo.bottom + 8 : 0)
: 0;
const legendRows = meta && meta.colors ? meta.colors.length : 0;
const directionRows = meta && meta.directions ? meta.directions.length : 0;
const nodeStyleRows = meta && meta.nodeStyles ? meta.nodeStyles.length : 0;
// The legend's explanation line ("Currency flow: Dot size is..."), first in
// the legend as on the page and in the PDF, wrapped to the page width.
const hintLines = meta && meta.hint
? wrapSvgText(meta.hint, 10, 400, width - 2 * PAD)
: [];
const FOOTER = meta
? 60 + hintLines.length * 14 + (hintLines.length ? 6 : 0)
+ Math.ceil(legendRows / 2) * 18
+ Math.ceil(directionRows / 2) * 18
+ Math.ceil(nodeStyleRows / 2) * 18
+ (meta.settings ? 34 : 0)
: 0;
const height = HEADER + graphHeight + FOOTER;
// Model space -> screen space, the same transform the canvas renderer uses.
const tx = (p) => ({ x: p.x * zoom + pan.x, y: p.y * zoom + pan.y + HEADER });
const esc = (v) => String(v).replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;',
}[c]));
// Cytoscape returns rgb()/rgba() strings; SVG wants a colour plus a
// separate opacity attribute.
const splitColor = (value) => {
const m = String(value).match(/rgba?\(([^)]+)\)/);
if (!m) {
return { color: value || '#000', alpha: 1 };
}
const parts = m[1].split(',').map((n) => parseFloat(n.trim()));
return {
color: `rgb(${parts[0]},${parts[1]},${parts[2]})`,
alpha: parts.length > 3 ? parts[3] : 1,
};
};
// An element's colour with its alpha. style() returns rgba() colours as
// plain rgb() - 'rgba(0,0,0,0.25)' comes back as 'rgb(0,0,0)' - so every
// node outline exported fully black instead of 25%. The parsed style
// value keeps the alpha as a fourth component.
const colorOf = (ele, name) => {
const value = ele.pstyle(name).value;
if (Array.isArray(value) && value.length >= 3) {
return {
color: `rgb(${value[0]},${value[1]},${value[2]})`,
alpha: value.length > 3 && Number.isFinite(value[3]) ? value[3] : 1,
};
}
return splitColor(ele.style(name));
};
const parts = [];
// Edges first so nodes sit on top, matching the canvas z-order.
cy.edges().forEach((edge) => {
if (edge.hasClass('hidden') || edge.style('display') === 'none') {
return;
}
const src = tx(edge.source().position());
const tgt = tx(edge.target().position());
const stroke = colorOf(edge, 'line-color');
const w = (parseFloat(edge.style('width')) || 1) * zoom;
// The colour's own alpha is not the whole story: line-opacity (80%
// for every line, arrowheads included) and the element's opacity
// (time-lapse fades and ghosts) multiply into it on the canvas. Read
// only the colour, an export drew every line fully opaque.
const styleNumber = (name) => {
const value = parseFloat(edge.style(name));
return Number.isFinite(value) ? value : 1;
};
const fade = styleNumber('line-opacity') * styleNumber('opacity');
// Curved edges carry a control-point distance; straight ones do not.
const bend = parseFloat(edge.style('control-point-distances')) || 0;
let d;
if (bend) {
const mx = (src.x + tgt.x) / 2;
const my = (src.y + tgt.y) / 2;
const dx = tgt.x - src.x;
const dy = tgt.y - src.y;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
// Perpendicular offset, scaled like everything else.
const cx = mx - (dy / len) * bend * zoom;
const cyy = my + (dx / len) * bend * zoom;
d = `M ${src.x.toFixed(2)} ${src.y.toFixed(2)} Q ${cx.toFixed(2)} ${cyy.toFixed(2)} ${tgt.x.toFixed(2)} ${tgt.y.toFixed(2)}`;
} else {
d = `M ${src.x.toFixed(2)} ${src.y.toFixed(2)} L ${tgt.x.toFixed(2)} ${tgt.y.toFixed(2)}`;
}
parts.push(
`<path d="${d}" fill="none" stroke="${stroke.color}" `
+ `stroke-opacity="${(stroke.alpha * fade).toFixed(3)}" stroke-width="${w.toFixed(2)}" `
+ `stroke-linecap="round"/>`
);
// Arrowhead, for the directed edges of the currency-flow view. Drawn as
// a filled triangle rather than an SVG marker: a marker inherits the
// line's stroke, but Cytoscape gives the head its own stronger colour
// (the lines are faded, the heads are not), which a marker cannot
// express without a separate definition per edge colour.
if (edge.style('target-arrow-shape') === 'triangle') {
const head = colorOf(edge, 'target-arrow-color');
// Head length, clamped directly rather than via the ratio.
//
// On the canvas the size has to be expressed as arrow-scale, a
// multiplier, and that ratio needs its own ceiling or a hairline
// asks for a scale that blows the head up. Here the geometry is
// written by hand, so the length can simply be clamped - no ratio,
// no ceiling fighting the floor. Without this the thinnest edges
// exported ~1.2px heads, invisible in print.
const scale = parseFloat(edge.style('arrow-scale')) || 1;
const size = Math.min(Math.max(w * 3 * scale, 3.5), 10);
// The tip is placed ON the curve, not along the straight tangent.
//
// Backing off along the tangent leaves the head floating beside a
// bent edge, because the curve arcs away from that line - measured
// at up to 5px adrift on the real graph. So walk back along the
// curve itself and take both the point and its tangent from there.
// A small constant clearance, not radius + head length: offsetting
// by the head length pushed the biggest arrows ~11px off their
// node while hairlines sat at 3.5px, so the heaviest lines looked
// detached. 2px is enough to keep the triangle off the circle,
// since the node is drawn over anything beneath it.
// The TIP clears the rim by GAP; the body of the triangle extends
// backwards from there along the line. Adding `size` here as well
// pushed the heaviest arrows 12px off their node while hairlines
// sat at 3.5px - the offset has to be independent of head size or
// big arrows always look detached.
const GAP = 1.5;
const targetR = ((parseFloat(edge.target().style('width')) || 10) * zoom) / 2;
const backoff = targetR + GAP;
let tipX;
let tipY;
let ux;
let uy;
if (bend) {
const mx = (src.x + tgt.x) / 2;
const my = (src.y + tgt.y) / 2;
const dx = tgt.x - src.x;
const dy = tgt.y - src.y;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
const cx = mx - (dy / len) * bend * zoom;
const cyy = my + (dx / len) * bend * zoom;
// Quadratic bezier: walk t back from the end until the point is
// at least `backoff` away from the target centre.
const at = (t) => {
const u = 1 - t;
return {
x: u * u * src.x + 2 * u * t * cx + t * t * tgt.x,
y: u * u * src.y + 2 * u * t * cyy + t * t * tgt.y,
};
};
// Binary search rather than a fixed 1% step: on a long curve
// one step of t covers several pixels, which left the gap
// varying between 1.5 and 5.3px depending on edge length.
let lo = 0;
let hi = 1;
for (let i = 0; i < 40; i += 1) {
const mid = (lo + hi) / 2;
const q = at(mid);
if (Math.hypot(q.x - tgt.x, q.y - tgt.y) > backoff) {
lo = mid;
} else {
hi = mid;
}
}
const t = lo;
const tip = at(t);
tipX = tip.x;
tipY = tip.y;
// Tangent of a quadratic bezier at t: 2(1-t)(C-P0) + 2t(P1-C).
const gx = 2 * (1 - t) * (cx - src.x) + 2 * t * (tgt.x - cx);
const gy = 2 * (1 - t) * (cyy - src.y) + 2 * t * (tgt.y - cyy);
const glen = Math.hypot(gx, gy) || 1;
ux = gx / glen;
uy = gy / glen;
} else {
const ax = tgt.x - src.x;
const ay = tgt.y - src.y;
const alen = Math.hypot(ax, ay) || 1;
ux = ax / alen;
uy = ay / alen;
tipX = tgt.x - ux * backoff;
tipY = tgt.y - uy * backoff;
}
// Two base corners, perpendicular to the tangent.
//
// The base is widened to at least the line width: at size * 0.5 a
// 7.6px line ended in a head only 1.3x as wide, which reads as a
// pinch rather than an arrow. 0.8 * size keeps the classic taper on
// thin lines, and the max() guarantees the head never ends up
// narrower than the stroke feeding it.
const bx = tipX - ux * size;
const by = tipY - uy * size;
const half = Math.max(size * 0.8, w * 1.15) / 2;
parts.push(
`<polygon points="${tipX.toFixed(2)},${tipY.toFixed(2)} `
+ `${(bx - uy * half).toFixed(2)},${(by + ux * half).toFixed(2)} `
+ `${(bx + uy * half).toFixed(2)},${(by - ux * half).toFixed(2)}" `
+ `fill="${head.color}" fill-opacity="${(head.alpha * fade).toFixed(3)}"/>`
);
}
});
const labels = [];
cy.nodes().forEach((node) => {
if (node.hasClass('hidden') || node.style('display') === 'none') {
return;
}
const pos = tx(node.position());
const r = ((parseFloat(node.style('width')) || 10) * zoom) / 2;
const fill = colorOf(node, 'background-color');
const border = colorOf(node, 'border-color');
const bw = (parseFloat(node.style('border-width')) || 0) * zoom;
// As for edges, the colour's alpha is not the whole story. A removed
// profile is hollow through background-opacity 0, and a time-lapse
// ghost is faded through the element's opacity; read only the colours
// and both exported as solid dots.
const nodeNumber = (name) => {
const value = parseFloat(node.style(name));
return Number.isFinite(value) ? value : 1;
};
const opacity = nodeNumber('opacity');
const fillAlpha = fill.alpha * nodeNumber('background-opacity') * opacity;
const borderAlpha = border.alpha * nodeNumber('border-opacity') * opacity;
parts.push(
`<circle cx="${pos.x.toFixed(2)}" cy="${pos.y.toFixed(2)}" r="${r.toFixed(2)}" `
+ (fillAlpha > 0
? `fill="${fill.color}" fill-opacity="${fillAlpha.toFixed(3)}"`
: 'fill="none"')
+ (bw > 0
? ` stroke="${border.color}" stroke-opacity="${borderAlpha.toFixed(3)}" stroke-width="${bw.toFixed(2)}"`
: '')
+ `/>`
);
// Labels are collected separately so they can be written last and sit
// above every dot, as they do on the canvas.
const text = withLabels ? node.data('label') : null;
if (!text) {
return;
}
const fontSize = (parseFloat(node.style('font-size')) || 14) * zoom;
const outline = colorOf(node, 'text-outline-color');
const ow = (parseFloat(node.style('text-outline-width')) || 0) * zoom;
const ty = pos.y - r - 6 * zoom;
// paint-order lets the white halo sit behind the glyphs while the
// element stays a single editable <text> node.
labels.push(
`<text x="${pos.x.toFixed(2)}" y="${ty.toFixed(2)}" text-anchor="middle" `
+ `font-family="Helvetica, Arial, sans-serif" font-size="${fontSize.toFixed(2)}" `
+ `font-weight="600" fill="#0f172a"`
+ (ow > 0
? ` stroke="${outline.color}" stroke-width="${(ow * 2).toFixed(2)}" `
+ `paint-order="stroke fill" stroke-linejoin="round"`
: '')
+ `>${esc(text)}</text>`
);
});
// --- chrome: caption top left, legend and settings underneath ----------
//
// Every string is a real <text> element, like the node labels, so the whole
// figure stays editable in Illustrator or Inkscape.
const chrome = [];
if (meta) {
const t = (x, y, str, opts = {}) =>
`<text x="${x.toFixed(2)}" y="${y.toFixed(2)}" `
+ `font-family="Helvetica, Arial, sans-serif" `
+ `font-size="${(opts.size || 11).toFixed(2)}" `
+ `font-weight="${opts.weight || 400}" `
+ `fill="${opts.fill || '#0f172a'}">${esc(str)}</text>`;
// Header band, for the same reason as the footer band below.
if (HEADER > 0) {
chrome.push(
`<rect x="0" y="0" width="${width}" height="${HEADER}" fill="#ffffff"/>`
);
}
// Caption, period and subtext, already wrapped clear of the logo; see
// the header layout above. A missing period (a time-lapse export has
// none) simply closes up, with no empty line in its place.
headerLines.forEach((line) => {
chrome.push(t(PAD, line.y, line.text, {
size: line.size,
weight: line.weight,
fill: line.fill,
}));
});
// The platform logo, top right, as on the printed posters. The theme's
// own inline SVG (see Stats::networkExportLogo()), nested as an <svg>
// so its viewBox and internal clip paths work unchanged, sized to the
// header band. After the header text, so a long subtext on a narrow
// export runs under it rather than over it; the white halo keeps it
// readable either way.
if (logo) {
chrome.push(logo.body);
}
// Opaque band behind the notes. The graph is clipped to its frame, so
// nothing should reach here anyway - but a node sitting exactly on the
// boundary would still bleed a few pixels into the text, and the band
// also separates the two areas visually.
const footTop = HEADER + graphHeight;
chrome.push(
`<rect x="0" y="${footTop}" width="${width}" height="${(height - footTop).toFixed(2)}" `
+ `fill="#ffffff"/>`
);
// Hairline between the graph and the notes below it.
chrome.push(
`<line x1="${PAD}" y1="${(footTop + 8).toFixed(2)}" x2="${(width - PAD).toFixed(2)}" `
+ `y2="${(footTop + 8).toFixed(2)}" stroke="#e2e8f0" stroke-width="1"/>`
);
let y = footTop + 28;
hintLines.forEach((line) => {
chrome.push(t(PAD, y, line, { size: 10, fill: '#334155' }));
y += 14;
});
if (hintLines.length) {
y += 6;
}
if (meta.colorMeaning) {
chrome.push(t(PAD, y, meta.colorMeaning, { size: 10, fill: '#64748b' }));
y += 16;
}
// Swatches in two columns, so a four-entry legend stays compact.
(meta.colors || []).forEach((item, i) => {
const col = i % 2;
const x = PAD + col * (width / 2 - PAD);
const row = Math.floor(i / 2);
const sy = y + row * 18;
chrome.push(
`<rect x="${x.toFixed(2)}" y="${(sy - 8).toFixed(2)}" width="10" height="10" `
+ `rx="5" fill="${item.color}"/>`
);
chrome.push(t(x + 16, sy, item.label, { size: 10 }));
});
y += Math.ceil((meta.colors || []).length / 2) * 18 + 6;
const meanings = [];
if (meta.sizeMeaning) {
meanings.push(`${meta.sizeLabel || 'Dot size'}: ${meta.sizeMeaning}`);
}
if (meta.widthMeaning) {
meanings.push(`${meta.widthLabel || 'Line thickness'}: ${meta.widthMeaning}`);
}
if (meanings.length) {
chrome.push(t(PAD, y, meanings.join(' · '), { size: 10, fill: '#64748b' }));
y += 18;
}
// Node outline styles, when a toggle draws them. Circles, not bars:
// these describe nodes, while the direction entries below describe
// edges.
(meta.nodeStyles || []).forEach((item, i) => {
const col = i % 2;
const x = PAD + col * (width / 2 - PAD);
const row = Math.floor(i / 2);
const sy = y + row * 18;
// rgba() in a stroke attribute is SVG 2 only - Illustrator and
// older renderers drop it silently, leaving an invisible outline.
// splitColor() turns it into colour + stroke-opacity, which is
// understood everywhere.
const ring = splitColor(item.color);
chrome.push(
`<circle cx="${(x + 5).toFixed(2)}" cy="${(sy - 3).toFixed(2)}" r="5" `
+ `fill="${item.hollow ? 'none' : '#94a3b8'}" stroke="${ring.color}" `
+ `stroke-opacity="${ring.alpha.toFixed(3)}" `
+ `stroke-width="2"${item.dashed ? ' stroke-dasharray="3 2"' : ''}/>`
);
chrome.push(t(x + 16, sy, item.label, { size: 10 }));
});
if ((meta.nodeStyles || []).length) {
y += Math.ceil(meta.nodeStyles.length / 2) * 18 + 6;
}
// Direction entries, only populated by the currency-flow view. Drawn
// as short line swatches rather than dots: these describe edges, not
// nodes, and the PDF legend already shows them the same way.
(meta.directions || []).forEach((item, i) => {
const col = i % 2;
const x = PAD + col * (width / 2 - PAD);
const row = Math.floor(i / 2);
const sy = y + row * 18;
chrome.push(
`<rect x="${x.toFixed(2)}" y="${(sy - 5).toFixed(2)}" width="16" height="3" `
+ `rx="1.5" fill="${item.color}"/>`
);
chrome.push(t(x + 22, sy, item.label, { size: 10 }));
});
if ((meta.directions || []).length) {
y += Math.ceil(meta.directions.length / 2) * 18 + 6;
}
if (meta.settings) {
chrome.push(t(PAD, y, meta.settings, { size: 9, fill: '#94a3b8' }));
}
}
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" `
+ `viewBox="0 0 ${width} ${height}">
<rect width="100%" height="100%" fill="#ffffff"/>
<defs>
<clipPath id="graph-frame">
<rect x="0" y="${HEADER}" width="${width}" height="${graphHeight}"/>
</clipPath>
${logo ? logo.defs : ''}
</defs>
<g clip-path="url(#graph-frame)">
${parts.join('\n')}
${labels.join('\n')}
</g>
${chrome.join('\n')}
</svg>
`;
}
/** The classes a node hover adds; see the mouseover handler. */
const HOVER_CLASSES = 'faded neighbour hovered incident';
/**
* Run `fn` with any hover highlight lifted, then put it back.
*
* Hovering a node fades everything else to 15%. That is a pointer effect, not
* part of the picture: an export taken while it was on (moving to the export
* button does not always fire the node's mouseout) came out with every line
* at 12% and one neighbourhood at full strength. A click-focus (`focused`,
* `hidden`) is a deliberate choice and is kept.
*/
function withoutHover(cy, fn) {
const hovered = {};
HOVER_CLASSES.split(' ').forEach((cls) => {
hovered[cls] = cy.elements(`.${cls}`);
});
cy.batch(() => cy.elements().removeClass(HOVER_CLASSES));
try {
return fn();
} finally {
cy.batch(() => {
Object.entries(hovered).forEach(([cls, eles]) => eles.addClass(cls));
});
}
}
function captureNetwork() {
try {
const cy = window.statsNetworkChart;
if (!cy || cy.destroyed() || cy.nodes().length === 0) {
return null;
}
const PDF_SCALE = 2;
return withoutHover(cy, () => cy.png({
output: 'base64uri',
// White rather than transparent: a transparent PNG turns black in
// dompdf.
bg: '#ffffff',
// The viewport as the user left it: pan, zoom and any dragged
// positions included.
full: false,
scale: PDF_SCALE,
}));
} catch (error) {
return null;
}
}
/**
* Snapshot the graph the instant a PDF button is pressed.
*
* Capture happens in the capture phase, before Livewire's own click handler
* starts the round trip that rebuilds the graph.
*/
if (typeof document !== 'undefined') {
document.addEventListener('click', (event) => {
if (event.target.closest('[data-network-pdf-trigger]')) {
window.__networkSnapshot = captureNetwork();
}
}, true);
}
/**
* Set up the time-lapse end-date picker once it is in the page.
*
* laravel-flatpickr only initialises pickers present at DOMContentLoaded, and
* this one only exists while the time-lapse is on - it appears after a
* Livewire morph. Done here rather than left to the SelectPeriod component's
* own morph hook, which happens to do the same but lives on a different part
* of the page and could be removed without anyone noticing this depended on
* it.
*/
function initTimelapsePicker() {
if (!window.LaravelFlatpickr) {
return;
}
document.querySelectorAll('[data-network-timelapse-end] .flatpickr-input').forEach((el) => {
if (!el._flatpickr) {
window.LaravelFlatpickr.initializeFlatpickr(el);
}
});
}
function buildAll() {
document.querySelectorAll(`[${CANVAS_ATTR}]`).forEach((wrapper) => {
build(wrapper).catch((error) => {
// A build that throws must not leave its in-flight marker behind:
// the guard in build() would then refuse every later build with the
// same settings, and the graph could never be drawn again.
const host = wrapper.querySelector('[data-network-host]');
if (host) {
host._buildingSignature = null;
}
console.error('[network] drawing failed', error);
// Replace the last progress message ("Arranging N profiles..."),
// which otherwise claims work is still going on. The error text is
// included because on a phone there is no console to read it in.
const message = host && host.querySelector('[data-network-placeholder] span');
if (message) {
const labels = safeJson(wrapper.getAttribute('data-network-labels')) || {};
message.textContent = `${labels.drawFailed || 'The network could not be drawn.'} (${(error && error.message) || error})`;
}
});
});
}
document.addEventListener('DOMContentLoaded', () => setTimeout(buildAll, 100));
document.addEventListener('livewire:init', () => {
let timer = null;
let pickerQueued = false;
Livewire.hook('morph.updated', () => {
clearTimeout(timer);
timer = setTimeout(buildAll, 300);
// The picker needs no debounce: it should be usable the moment it
// appears, not 300ms later. One frame lets the morph finish first.
if (!pickerQueued) {
pickerQueued = true;
requestAnimationFrame(() => {
pickerQueued = false;
initTimelapsePicker();
});
}
});
});
document.addEventListener('livewire:navigated', () => setTimeout(buildAll, 100));