Files

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.
}
}
};