677 lines
29 KiB
JavaScript
677 lines
29 KiB
JavaScript
/**
|
|
* 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.<id> 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.
|
|
}
|
|
}
|
|
};
|