3594 lines
140 KiB
JavaScript
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} · ${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) => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
}[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"> · ${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) => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
}[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));
|