/** * Charts for the platform Stats view. * * Follows the same contract as return-ratio-chart.js / account-balances-chart.js: * data arrives via data-* attributes, charts re-initialise on Livewire morphs * (debounced), and PDF export renders to an offscreen canvas so a morph cannot * detach the live canvas mid-export. */ // NOTE: globalName must never equal a canvas id — the browser auto-creates a // window. reference to the element itself, which would shadow the chart // handle and make `.destroy()` blow up on a DOM node. const CHARTS = [ { key: 'volume', canvasId: 'statsVolumeChart', dataAttribute: 'data-stats-volume-chart', globalName: 'statsChartVolume', // Stacked: each bar is one month's total volume, split by type. stacked: true, // Series are transaction-type names, which vary per platform, so they // come from the server rather than being listed here. dynamicSeries: true, }, // One chart per profile type, each on its own y-axis: users peak around 60 // a month where organizations peak at 2. Banks are excluded server-side — // a few setup-time banks would be lone spikes on a timeline about members // joining. { key: 'registrations', // Half-width slot in the PDF; see chartToPng(). pdfWidth: 331, pdfHeight: 124, // flatter than the screen's 16:9 — see chartToPng() canvasId: 'statsRegistrationsChart', dataAttribute: 'data-stats-registrations-chart', globalName: 'statsChartRegistrations', series: [{ field: 'User', label: 'User' }], }, { key: 'registrationsOrg', // Half-width slot in the PDF; see chartToPng(). pdfWidth: 331, pdfHeight: 124, // flatter than the screen's 16:9 — see chartToPng() canvasId: 'statsRegistrationsOrgChart', dataAttribute: 'data-stats-registrations-org-chart', globalName: 'statsChartRegistrationsOrg', series: [{ field: 'Organization', label: 'Organization' }], }, { // How many profiles stood marked as inactive at each month's end. // Cumulative, so a line rather than bars: these arrive in bursts (one // migration marked ~1.000 profiles inactive in a single month), and a // per-month bar chart of that is one spike beside years of zero. key: 'inactivations', type: 'line', // Full page width in the PDF — it sits alone on its row. pdfWidth: 674, pdfHeight: 200, canvasId: 'statsInactivationsChart', dataAttribute: 'data-stats-inactivations-chart', globalName: 'statsChartInactivations', series: [ { field: 'count', label: 'Profiles marked as inactive' }, // Hours, not a count, so it gets the right-hand axis — the two // share a chart but not a unit. { field: 'balance', label: 'Total balance', axis: 'y1', currency: true }, // Cumulative, like the inactive count, now that incomplete_at // gives the state a timestamp. { field: 'incomplete', label: 'Incomplete profiles' }, ], // A second axis in hours, drawn on the right. secondAxis: { id: 'y1', currency: true }, // The running total hides the month's own additions; show them. // The label comes from data-footer-label, so it is translated. footerField: 'added', }, { // Histogram: how many profiles sit in each balance band. Users and // organizations get one chart each, because organizations are so much // fewer that on a shared axis their bars round away to nothing. // // Banks are excluded server-side — a bank's balance is the mirror of // everyone else's and would sit alone in an outer band. key: 'balance', // Banded histogram: gridlines mark the band edges, not bar centres. bandedAxis: true, // Half-width slot in the PDF; see chartToPng(). // Full page width now that these stack one per row. Flatter than // 16:9 so both charts fit one page — the banded labels need width, // not height. pdfWidth: 674, pdfHeight: 200, canvasId: 'statsBalanceChart', dataAttribute: 'data-stats-balance-chart', globalName: 'statsChartBalance', series: [{ field: 'User', label: 'User' }], }, { key: 'balanceOrg', // Banded histogram: gridlines mark the band edges, not bar centres. bandedAxis: true, // Half-width slot in the PDF; see chartToPng(). // Full page width now that these stack one per row. Flatter than // 16:9 so both charts fit one page — the banded labels need width, // not height. pdfWidth: 674, pdfHeight: 200, canvasId: 'statsBalanceOrgChart', dataAttribute: 'data-stats-balance-org-chart', globalName: 'statsChartBalanceOrg', series: [{ field: 'Organization', label: 'Organization' }], }, // One chart per engagement metric, each on its own y-axis: reactions peak // around 3.900 a month where posts peak at 2, so a shared axis renders the // smaller series below a pixel and they read as missing. { key: 'engagementPosts', // Half-width slot in the PDF; see chartToPng(). pdfWidth: 331, pdfHeight: 124, // flatter than the screen's 16:9 — see chartToPng() canvasId: 'statsEngagementPostsChart', dataAttribute: 'data-stats-engagement-posts-chart', globalName: 'statsChartEngagementPosts', series: [{ field: 'posts', label: 'Posts' }], }, { key: 'engagementCalls', // Half-width slot in the PDF; see chartToPng(). pdfWidth: 331, pdfHeight: 124, // flatter than the screen's 16:9 — see chartToPng() canvasId: 'statsEngagementCallsChart', dataAttribute: 'data-stats-engagement-calls-chart', globalName: 'statsChartEngagementCalls', series: [{ field: 'calls', label: 'Calls' }], }, { key: 'engagementReactions', // Half-width slot in the PDF; see chartToPng(). pdfWidth: 331, pdfHeight: 124, // flatter than the screen's 16:9 — see chartToPng() canvasId: 'statsEngagementReactionsChart', dataAttribute: 'data-stats-engagement-reactions-chart', globalName: 'statsChartEngagementReactions', series: [{ field: 'reactions', label: 'Reactions' }], }, { key: 'engagementMessages', // Half-width slot in the PDF; see chartToPng(). pdfWidth: 331, pdfHeight: 124, // flatter than the screen's 16:9 — see chartToPng() canvasId: 'statsEngagementMessagesChart', dataAttribute: 'data-stats-engagement-messages-chart', globalName: 'statsChartEngagementMessages', series: [{ field: 'messages', label: 'Messages' }], }, ]; /** * Fallback only. The real palette comes from the tag-category colours stored in * the database, passed in via data-series-colors so the chart matches the * colours used for tags elsewhere in the app. */ const SERIES_COLORS_FALLBACK = ['#f87171', '#4ade80', '#60a5fa', '#c084fc']; /** How far to darken the palette — the raw tag colours are too bright for charts. */ const COLOR_DIM_FACTOR = 0.72; function hexToRgbParts(hex) { const clean = String(hex).replace('#', ''); const full = clean.length === 3 ? clean.split('').map((c) => c + c).join('') : clean; if (!/^[0-9a-f]{6}$/i.test(full)) { return null; } return [ parseInt(full.slice(0, 2), 16), parseInt(full.slice(2, 4), 16), parseInt(full.slice(4, 6), 16), ]; } /** Darken a hex colour toward black, leaving non-hex values untouched. */ function dim(color, factor = COLOR_DIM_FACTOR) { const parts = hexToRgbParts(color); if (!parts) { return color; } const [r, g, b] = parts.map((v) => Math.round(v * factor)); return `rgb(${r}, ${g}, ${b})`; } /** Translucent variant, used for line-chart fills. */ function withAlpha(color, alpha) { const parts = hexToRgbParts(color); if (!parts) { return color; } const [r, g, b] = parts; return `rgba(${r}, ${g}, ${b}, ${alpha})`; } function themeColor(fallback) { const value = getComputedStyle(document.documentElement) .getPropertyValue('--color-primary-500') .trim(); return value ? `rgb(${value})` : fallback; } function buildChart(config) { const canvas = document.getElementById(config.canvasId); if (!canvas || typeof window.Chart === 'undefined') { return; } const wrapper = canvas.closest(`[${config.dataAttribute}]`); if (!wrapper) { return; } let rows; try { rows = JSON.parse(wrapper.getAttribute(config.dataAttribute) || '[]'); } catch (error) { return; } if (!Array.isArray(rows) || rows.length === 0) { return; } const decimalFormat = wrapper.getAttribute('data-decimal-format') === '1'; const signature = `${wrapper.getAttribute(config.dataAttribute)}|${decimalFormat}`; // Nothing changed since the last build — leave the live chart alone. Without // this, every Livewire morph destroys a working chart and rebuilds it, and a // failure anywhere in between leaves an empty canvas behind. const liveChart = window.Chart.getChart?.(canvas); if (canvas.dataset.chartSignature === signature && liveChart) { // Re-point the global before bailing out. The PDF export reads charts // via window[globalName], and a morph that leaves the canvas untouched // would otherwise leave that reference stale — so every chart exported // as null and the PDF came out with no images at all. window[config.globalName] = liveChart; return; } // Destroy whatever instance currently owns this canvas — not just the one we // last stored. Chart.js refuses to reuse a canvas that still belongs to // another chart, and a stale instance would silently block every re-render. const existing = window.Chart.getChart ? window.Chart.getChart(canvas) : null; if (existing) { existing.destroy(); } else if (typeof window[config.globalName]?.destroy === 'function') { window[config.globalName].destroy(); } const isVolume = config.key === 'volume'; // Each chart gets its own tag-category colour from the server so no two // charts on the page look alike; the theme grey is only a fallback. const color = dim(wrapper.getAttribute('data-chart-color') || themeColor('rgb(59, 130, 246)')); // Mirror tbFormat()/the decimal toggle so the axis reads like every other // amount on the platform. Registrations are a plain profile count, so they // deliberately get no currency notation. const currencySymbol = wrapper.getAttribute('data-currency-symbol') || ''; const currencyAtEnd = wrapper.getAttribute('data-currency-position-end') === '1'; const thousandSeparator = wrapper.getAttribute('data-currency-thousand-separator') || ''; const groupThousands = (whole) => (thousandSeparator ? String(whole).replace(/\B(?=(\d{3})+(?!\d))/g, thousandSeparator) : String(whole)); const withCurrency = (value) => { if (!currencySymbol) { return value; } return currencyAtEnd ? `${value} ${currencySymbol}` : `${currencySymbol} ${value}`; }; /** * @param {number} hours * @param {boolean} trimWholeHours drop the ":00" on exact hours (axis ticks) */ const formatHours = (hours, trimWholeHours = false) => { if (decimalFormat) { return withCurrency(`${hours.toFixed(2).replace('.', ',')} h.`); } const negative = hours < 0; const abs = Math.abs(hours); const wholeHours = Math.floor(abs); const minutes = Math.round((abs - wholeHours) * 60); // Guard the 59.6 -> :60 rounding case. const carried = minutes === 60 ? wholeHours + 1 : wholeHours; const rest = minutes === 60 ? 0 : minutes; const sign = negative ? '-' : ''; const grouped = groupThousands(carried); // Axis labels read better as "H 1.500" than "H 1.500:00". Only exact // hours are trimmed — a tick with real minutes still shows them, so no // value is ever displayed as something it isn't. if (trimWholeHours && rest === 0) { return withCurrency(`${sign}${grouped}`); } return withCurrency(`${sign}${grouped}:${String(rest).padStart(2, '0')}`); }; // Multi-series charts (engagement) list their fields; single-series charts // derive one dataset. Amounts are stored in minutes, so volume shows hours. // Labels (locale-aware) and colours (tag-category palette) both come from // the server rather than being duplicated here. let seriesLabels = {}; let seriesColors = {}; try { seriesLabels = JSON.parse(wrapper.getAttribute('data-series-labels') || '{}'); seriesColors = JSON.parse(wrapper.getAttribute('data-series-colors') || '{}'); } catch (error) { seriesLabels = {}; seriesColors = {}; } const chartType = config.type || 'bar'; const isLine = chartType === 'line'; // Dynamic-series charts derive their series from the data keys (anything // that isn't a bookkeeping field), so new transaction types appear without // a code change. const series = config.dynamicSeries ? Object.keys(rows[0] || {}) .filter((key) => !['label', 'ym', 'count', 'volume'].includes(key)) .map((field) => ({ field, label: field })) : config.series; // Does anything actually sit on the second axis? When that series is all // zeroes it is dropped entirely, along with its axis: a flat line on the // baseline says nothing, and leaving the dataset while removing the scale // makes Chart.js invent an unconfigured axis (a stray -1..1 range). const secondAxisHasData = !config.secondAxis || (series || []).some( (serie) => serie.axis === config.secondAxis.id && rows.some((row) => Number(row[serie.field]) !== 0) ); const chartedSeries = (series || []).filter( (serie) => secondAxisHasData || !config.secondAxis || serie.axis !== config.secondAxis.id ); const datasets = series ? chartedSeries.map((serie, index) => { const rawColor = seriesColors[serie.field] || SERIES_COLORS_FALLBACK[index % SERIES_COLORS_FALLBACK.length]; const serieColor = dim(rawColor); return { label: seriesLabels[serie.field] || serie.label, data: rows.map((row) => row[serie.field] || 0), // A line needs a visible stroke and a translucent fill; bars use // the flat colour for both. backgroundColor: isLine ? withAlpha(rawColor, 0.15) : serieColor, borderColor: serieColor, borderWidth: isLine ? 2 : 1, ...(isLine ? { // stepLike series keep straight segments: Bézier smoothing // overshoots at a sharp corner and draws a rise just before // a drop that never happened. tension: config.stepLike === true ? 0 : 0.3, // No dots: with a point per month the line reads as a set // of readings rather than a continuous quantity. The hover // marker still appears, so values stay inspectable. pointRadius: 0, pointHoverRadius: 4, fill: false, } : {}), ...(serie.axis ? { yAxisID: serie.axis } : {}), ...(serie.dashed ? { borderDash: [6, 4] } : {}), }; }) : [{ label: isVolume ? 'volume' : 'count', data: rows.map((row) => (isVolume ? (row.volume || 0) / 60 : row.count || 0)), backgroundColor: color, borderColor: color, borderWidth: 1, }]; window[config.globalName] = new window.Chart(canvas.getContext('2d'), { type: chartType, data: { labels: rows.map((row) => row.label), datasets, }, options: { responsive: true, maintainAspectRatio: false, // Descenders in the tick labels (Jan, Aug, Sep) sit right against // the legend below them, so the axis gets its own breathing room // rather than relying on the canvas padding alone. layout: { padding: { bottom: 16 } }, plugins: { // A legend only earns its space when there is more than one // series to tell apart. The balance charts are one series each // under their own heading, so a one-item key is pure noise. legend: datasets.length > 1 ? { display: true, position: 'bottom', align: 'start', labels: { boxWidth: 12, usePointStyle: true, padding: 16 } } : { display: false }, tooltip: { // Gather every segment of the hovered bar, so the stacked // total in the footer reflects the whole column. mode: config.stacked === true ? 'index' : 'nearest', intersect: config.stacked !== true, callbacks: { // The axis shows each band's lower bound only, so the // tooltip carries the full range the bar covers. title: (items) => { const row = rows[items[0]?.dataIndex]; return row?.range || items[0]?.label || ''; }, label: (context) => { const value = context.parsed.y; // Volume series are hours and need currency notation // even when the chart is split per type. // A per-series currency flag covers the mixed-unit // chart, where only one of the two is hours. const serieMeta = chartedSeries?.[context.datasetIndex]; const asHours = isVolume || serieMeta?.currency === true; const shown = asHours ? formatHours(value) : `${value}`; // Same reasoning as the legend: naming the series // only helps when there is more than one. return datasets.length > 1 ? `${context.dataset.label}: ${shown}` : shown; }, // On a stacked chart the bar height is the month's // total, so show it — otherwise the reader can only see // the segment they happen to be hovering. footer: (items) => { if (items.length === 0) { return undefined; } // Cumulative charts: the plotted value is a running // total, so the month's own change is invisible // without saying it. A flat stretch then reads as // "nothing happened" rather than as missing data. if (config.footerField) { const row = rows[items[0].dataIndex]; const added = row?.[config.footerField] || 0; // Translated server-side; the bare number is a // safe fallback if the attribute is absent. const footerLabel = wrapper.getAttribute('data-footer-label'); return footerLabel ? `${footerLabel}: +${added}` : `+${added}`; } if (config.stacked !== true) { return undefined; } const total = items.reduce((sum, item) => sum + (item.parsed.y || 0), 0); return `${'\u2211'} ${isVolume ? formatHours(total) : total}`; }, }, }, }, scales: { y: { beginAtZero: true, // Volume stacks: the transaction types partition one month's // total, so the stacked height is a real quantity. The other // charts count unrelated things, where a stack would invent // a total that means nothing. stacked: config.stacked === true, ticks: isVolume ? { callback: (value) => formatHours(value, true) } : { precision: 0 }, }, // Opt-in right-hand axis, for a chart plotting two different // units — a count and an amount of hours — where a shared // scale would make the smaller series unreadable. // // Hidden when every value on it is zero: Chart.js then // autoscales to an arbitrary tiny range (H 0 to H 1 in // six-minute steps), which reads as a real measurement of // something when it is just an empty series. ...(config.secondAxis && secondAxisHasData ? { [config.secondAxis.id]: { position: 'right', beginAtZero: true, // The left axis owns the gridlines; a second set drawn // at different intervals just adds visual noise. grid: { drawOnChartArea: false }, ticks: config.secondAxis.currency ? { callback: (value) => formatHours(value, true) } : { precision: 0 }, }, } : {}), x: { stacked: config.stacked === true, // The scale keeps its offset so no bar is clipped at the // axis edges; only grid.offset below moves the lines onto // the band boundaries. // Vertical gridlines everywhere: they tie each label to its // bar, which is the whole point of labelling every one. grid: { display: true, drawOnChartArea: true, // Histograms (bandedAxis) put lines on the band // boundaries so each bar sits between two; timelines // keep centred lines, since a month label belongs under // its own bar. offset: config.bandedAxis !== true, color: 'rgba(0, 0, 0, 0.10)', }, ticks: { padding: 8, // Every bar keeps its label. autoSkip dropped two in // three, leaving more bars than labels and no way to // tell which was which — so labels turn vertical // instead, which also costs no horizontal space. autoSkip: false, // 60 degrees: readable at a glance without the neck // tilt of fully vertical text. The labels are short // enough now (a single bound, not a range) that they // fit at this angle. maxRotation: 60, minRotation: 60, font: { size: 10 }, }, }, }, }, }); canvas.dataset.chartSignature = signature; } function initializeStatsCharts() { CHARTS.forEach((config) => { try { buildChart(config); } catch (error) { // Never break the rest of the page, but don't fail silently either — // an empty chart box is otherwise impossible to diagnose. console.error(`[stats-charts] ${config.canvasId} failed to render:`, error); } }); } document.addEventListener('DOMContentLoaded', () => { setTimeout(initializeStatsCharts, 100); }); document.addEventListener('livewire:init', () => { let morphTimer = null; Livewire.hook('morph.updated', () => { clearTimeout(morphTimer); morphTimer = setTimeout(initializeStatsCharts, 300); }); Livewire.on('openStatsPdf', (url) => { window.open(Array.isArray(url) ? url[0] : url, '_blank'); }); }); /** Render one live chart into an offscreen canvas and return a PNG data URL. */ async function chartToPng(config) { // Prefer the live instance owning the canvas over the global: the global is // a convenience handle and can go stale, while Chart.getChart() is the // authority on what is actually rendered. const canvas = document.getElementById(config.canvasId); const chart = (canvas && window.Chart.getChart?.(canvas)) || window[config.globalName]; if (!chart) { return null; } try { // Render at the size the PDF actually displays, times a device-pixel // factor. Deliberately flatter than the 16:9 used on screen: a print // report has a fixed page budget, and 16:9 here runs the export to a // third page for no gain — the shape suits a 12-month timeline anyway. A 900px-wide bitmap shown in a 331px column is downscaled // 2.7x, which is what made the axis labels unreadable — and forcing a // fixed height onto that aspect ratio squashed the plot. const PDF_SCALE = 2; const cssWidth = config.pdfWidth || 674; const cssHeight = config.pdfHeight || 180; const offscreen = document.createElement('canvas'); offscreen.width = cssWidth * PDF_SCALE; offscreen.height = cssHeight * PDF_SCALE; // Named exportConfig, not config: the parameter is already called that. const exportConfig = JSON.parse(JSON.stringify({ type: chart.config.type, data: chart.config.data, options: chart.config.options, })); exportConfig.options = { ...exportConfig.options, responsive: false, maintainAspectRatio: false, animation: false, devicePixelRatio: PDF_SCALE, }; const offscreenChart = new window.Chart(offscreen.getContext('2d'), exportConfig); await new Promise((resolve) => setTimeout(resolve, 300)); const image = offscreen.toDataURL('image/png', 1.0); offscreenChart.destroy(); return image; } catch (error) { return null; } } function findStatsComponent() { if (!window.Livewire) { return null; } const root = document.getElementById('stats-component'); const element = root ? root.closest('[wire\\:id]') || root : null; if (element && element.getAttribute('wire:id')) { return window.Livewire.find(element.getAttribute('wire:id')); } return null; } window.exportStatsPdf = async function () { const component = findStatsComponent(); if (!component) { return; } const images = {}; for (const config of CHARTS) { const png = await chartToPng(config); if (png) { images[config.key] = png; } } // The trading network lives in its own module and is captured as rendered, // so the PDF shows the same layout and view the user is looking at. if (typeof window.exportNetworkForPdf === 'function') { const network = window.exportNetworkForPdf(); if (network) { images.network = network; } } // The counts sentence under the caption is computed by the classifier, in // the browser, so the server cannot rebuild it for the PDF. Sent with the // images because that is the channel that already exists; the component // splits it back out. const host = document.querySelector('[data-network-host]'); if (host && host.dataset.networkSubtext) { images.networkSubtext = host.dataset.networkSubtext; } try { await component.call('exportPdf', images); } catch (error) { // Fall back to a data-only PDF. try { await component.call('exportPdf', {}); } catch (innerError) { // Nothing further we can do client-side. } } };