Snapshot of main at 9f39a6e1e (2026-11-02)

This commit is contained in:
Ronald Huynen committed 2026-11-02 10:41:47 +01:00
commit 6ccf1792d7
2580 files changed
+1040202

No files matched your search

@@ -0,0 +1,212 @@
/**
* 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;
}
};