213 lines
7.0 KiB
JavaScript
213 lines
7.0 KiB
JavaScript
/**
|
|
* 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;
|
|
}
|
|
};
|