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,676 @@
|
||||
/**
|
||||
* 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.
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user