/** * Balance distribution of an organization's trading partners, on its own * Reports page. * * Reads counts per balance band from a data-* attribute, exactly as * stats-charts.js does. The data is aggregate only — a count per band per * profile type — so nothing here can identify an individual partner. */ import { Chart, registerables } from 'chart.js'; Chart.register(...registerables); /** * One chart per profile type, each on its own y-axis — the same split the * platform statistics page uses. Organizations are far fewer than users, so on * a shared axis their bars round away to nothing. */ const CHARTS = [ { canvasId: 'reportBalanceDistributionChart', dataAttribute: 'data-report-balance-distribution', field: 'User', // teal-500, matching balance.User on the platform statistics page // so the same metric is the same colour wherever it appears. color: '#14b8a6', }, { canvasId: 'reportBalanceDistributionOrgChart', dataAttribute: 'data-report-balance-distribution-org', field: 'Organization', // fuchsia-500, matching balance.Organization. color: '#d946ef', }, ]; function buildChart(config) { const canvas = document.getElementById(config.canvasId); if (!canvas || typeof 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; } let seriesLabels = {}; try { seriesLabels = JSON.parse(wrapper.getAttribute('data-series-labels') || '{}'); } catch (error) { seriesLabels = {}; } // Rebuild only when the data actually changed, so a Livewire morph does not // destroy and recreate a working chart on every interaction. const signature = wrapper.getAttribute(config.dataAttribute); if (canvas.dataset.chartSignature === signature && Chart.getChart?.(canvas)) { return; } const existing = Chart.getChart ? Chart.getChart(canvas) : null; if (existing) { existing.destroy(); } // A single series per chart: the blade only renders this canvas when the // type has profiles, so there is nothing to filter here. const datasets = [{ label: seriesLabels[config.field] || config.field, data: rows.map((row) => row[config.field] || 0), backgroundColor: config.color, borderColor: config.color, borderWidth: 1, }]; canvas.chart = new Chart(canvas.getContext('2d'), { type: 'bar', data: { labels: rows.map((row) => row.label), datasets, }, options: { responsive: true, maintainAspectRatio: false, layout: { padding: { bottom: 16 } }, plugins: { // No legend: one series per chart, named by the heading above // it, so a one-item key would be pure noise. legend: { display: false }, }, scales: { y: { beginAtZero: true, ticks: { precision: 0 } }, x: { // The axis keeps its offset, so every bar sits fully inside // the plot area — with offset:false the first and last bars // are centred on the axis edges and rendered half cut off. // // Only the gridlines move to the band boundaries, which is // what puts each bar between two lines. grid.offset is a // separate setting from the scale's own offset. grid: { display: true, drawOnChartArea: true, offset: false, color: 'rgba(0, 0, 0, 0.10)', }, ticks: { padding: 8, autoSkip: false, maxRotation: 60, minRotation: 60, font: { size: 10 }, }, }, }, }, }); canvas.dataset.chartSignature = signature; } function buildAll() { CHARTS.forEach(buildChart); } // The canvas is rendered by Livewire, so DOMContentLoaded alone is too early: // it fires before the component's markup exists. Matching the pattern the other // report charts use — a short delay on load, plus livewire:init (which fires // before the first render) rather than livewire:initialized. document.addEventListener('DOMContentLoaded', () => { setTimeout(buildAll, 100); }); // Livewire replaces the canvas on re-render, so rebuild after each morph. document.addEventListener('livewire:init', () => { let morphTimer = null; Livewire.hook('morph.updated', () => { clearTimeout(morphTimer); morphTimer = setTimeout(buildAll, 300); }); }); // wire:navigate swaps the page without a fresh DOMContentLoaded. document.addEventListener('livewire:navigated', () => { setTimeout(buildAll, 100); }); /** * Render one distribution chart to an offscreen canvas for the PDF. * * Offscreen specifically: a Livewire morph can detach the live canvas mid * export, and DomPDF runs with isRemoteEnabled = false so the image has to be * inlined as a data URL. */ window.exportBalanceDistributionChartForPdf = async function (canvasId) { try { const canvas = document.getElementById(canvasId); if (!canvas) { return null; } const original = Chart.getChart ? Chart.getChart(canvas) : null; if (!original) { return null; } // Rendered at the size the PDF displays it, times a device-pixel // factor. A 900x400 bitmap shown in a 674x300 slot is stretched // horizontally, which distorted every axis label. const PDF_SCALE = 2; const cssWidth = 674; const cssHeight = 300; const offscreen = document.createElement('canvas'); offscreen.width = cssWidth * PDF_SCALE; offscreen.height = cssHeight * PDF_SCALE; const offscreenChart = new Chart(offscreen.getContext('2d'), { type: original.config.type, data: JSON.parse(JSON.stringify(original.config.data)), options: { ...JSON.parse(JSON.stringify(original.config.options || {})), responsive: false, maintainAspectRatio: false, animation: false, devicePixelRatio: PDF_SCALE, }, }); // Give Chart.js a frame to paint before reading the bitmap. await new Promise((resolve) => setTimeout(resolve, 300)); const image = offscreen.toDataURL('image/png', 1.0); offscreenChart.destroy(); return image; } catch (error) { return null; } };