Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
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;
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user