/** * 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} 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) => ` ${escapeHtml(labels.cluster || 'Cluster')} ${i + 1} · ${c.size} ${escapeHtml(profiles)} `).join('') + (rest > 0 ? `${escapeHtml(labels.more || '+ :count more').replace(':count', rest)}` : ''); 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 = [ `${escapeHtml(d.label || '')}`, ]; // Full name under the username, when there is one that differs. if (d.fullName) { rows.push(`${escapeHtml(d.fullName)}`); } rows.push( d.hiddenPartners > 0 ? `${escapeHtml(labels.partners || 'Partners')}: ${d.degree} ` + `(${escapeHtml(labels.hidden || 'hidden')}: ${d.hiddenPartners})` : `${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('
'); 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 ? `
${escapeHtml(labels.noMatchHint)}
` : ''; searchResults.innerHTML = `
${escapeHtml(labels.noMatch || 'No match')}
${hint}`; searchResults.hidden = false; return; } searchResults.innerHTML = matches.map((n) => ` `).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 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 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 element. const inner = source .replace(/^[\s\S]*?]*>/, '') .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: ' ' + '' + '' + '' + '' + '', body: `` + `` + `${inner}`, // 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( `` ); // 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( `` ); } }); 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( ` 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 node. labels.push( ` 0 ? ` stroke="${outline.color}" stroke-width="${(ow * 2).toFixed(2)}" ` + `paint-order="stroke fill" stroke-linejoin="round"` : '') + `>${esc(text)}` ); }); // --- chrome: caption top left, legend and settings underneath ---------- // // Every string is a real 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 = {}) => `${esc(str)}`; // Header band, for the same reason as the footer band below. if (HEADER > 0) { chrome.push( `` ); } // 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 // 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( `` ); // Hairline between the graph and the notes below it. chrome.push( `` ); 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( `` ); 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( `` ); 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( `` ); 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 ` ${logo ? logo.defs : ''} ${parts.join('\n')} ${labels.join('\n')} ${chrome.join('\n')} `; } /** 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));