/**
* Visitors over time, with page views as grey bars behind them on their own
* right-hand scale (D6). Hourly for "Today", daily otherwise; the interval
* comes from the payload. While the last hour or day is still running, the
* line's last segment is dashed. chart.js is bundled via npm (no CDN, .org
* rule). Colours are Core's tokens, read off the DOM at create time.
*
* All bucket times are UTC, matching the stored ts, and hourly labels say so.
*/
import { useEffect, useRef, useState } from 'react';
import {
BarController,
BarElement,
Chart,
LineController,
LineElement,
PointElement,
LinearScale,
CategoryScale,
Filler,
Tooltip,
} from 'chart.js';
import { __ } from '@wordpress/i18n';
import { useApi } from './api';
import { useUi } from './ui-context';
import LoadError from './load-error';
Chart.register( BarController, BarElement, LineController, LineElement, PointElement, LinearScale, CategoryScale, Filler, Tooltip );
// Core's tokens, with the same values as a fallback while an older Core is active.
const CHART = {
'--saddle-chart-ink': '#d000c4',
'--saddle-chart-ink-soft': 'rgba(208, 0, 196, 0.14)',
'--saddle-chart-grid': '#dcdcde',
'--saddle-chart-text': '#646970',
'--pp-border': '#dcdcde',
};
function tickLabel( ts, interval ) {
const d = new Date( ts * 1000 );
return 'hour' === interval
? d.toLocaleTimeString( undefined, { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'UTC' } )
: d.toLocaleDateString( undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' } );
}
function tipTitle( ts, interval ) {
const d = new Date( ts * 1000 );
return 'hour' === interval
? tickLabel( ts, interval ) + ' UTC'
: d.toLocaleDateString( undefined, { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' } );
}
// A vertical hairline through the hovered bucket. Inline plugin, no new dep.
const crosshair = {
id: 'saddleAnalyticsCrosshair',
afterDatasetsDraw( chart, _a, opts ) {
const active = chart.tooltip?.getActiveElements?.() || [];
if ( ! active.length ) {
return;
}
const { ctx, chartArea } = chart;
ctx.save();
ctx.beginPath();
ctx.lineWidth = 1;
ctx.strokeStyle = opts.color;
ctx.moveTo( active[ 0 ].element.x, chartArea.top );
ctx.lineTo( active[ 0 ].element.x, chartArea.bottom );
ctx.stroke();
ctx.restore();
},
};
export default function Timeseries( { win, filters } ) {
const { Skeleton } = useUi();
const canvasRef = useRef( null );
const chartRef = useRef( null );
const [ tip, setTip ] = useState( null );
const { data, error, retry } = useApi( 'timeseries', { ...win, filters } );
const points = data?.points;
const interval = data?.interval;
// No `from` means the window ends now, so its last bucket is still filling.
const running = ! win?.from;
useEffect( () => {
if ( ! points || ! canvasRef.current ) {
return;
}
const css = window.getComputedStyle( canvasRef.current );
const token = ( name ) => css.getPropertyValue( name ).trim() || CHART[ name ];
const ink = token( '--saddle-chart-ink' );
const text = token( '--saddle-chart-text' );
const font = { family: css.fontFamily, size: 12 };
const last = points.length - 1;
chartRef.current?.destroy();
setTip( null );
chartRef.current = new Chart( canvasRef.current, {
type: 'line',
plugins: [ crosshair ],
data: {
labels: points.map( ( p ) => tickLabel( p.ts, interval ) ),
datasets: [
{
label: __( 'Visitors', 'saddle-analytics' ),
data: points.map( ( p ) => p.visitors ),
yAxisID: 'y',
order: 0,
cubicInterpolationMode: 'monotone',
fill: true,
borderColor: ink,
backgroundColor: token( '--saddle-chart-ink-soft' ),
borderWidth: 2,
segment: {
borderDash: ( ctx ) => ( running && ctx.p1DataIndex === last ? [ 4, 4 ] : undefined ),
},
pointRadius: 0,
pointHoverRadius: 5,
pointHoverBorderWidth: 2,
pointHoverBackgroundColor: '#ffffff',
pointHoverBorderColor: ink,
hitRadius: 12,
},
{
type: 'bar',
label: __( 'Page views', 'saddle-analytics' ),
data: points.map( ( p ) => p.pageviews ?? 0 ),
yAxisID: 'y1',
order: 1,
backgroundColor: token( '--pp-border' ),
borderRadius: 2,
maxBarThickness: 24,
categoryPercentage: 0.7,
barPercentage: 0.9,
},
],
},
options: {
animation: false,
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
saddleAnalyticsCrosshair: { color: token( '--saddle-chart-grid' ) },
tooltip: {
enabled: false,
external: ( { chart, tooltip } ) => {
if ( ! tooltip.opacity || ! tooltip.dataPoints?.length ) {
setTip( null );
return;
}
const p = points[ tooltip.dataPoints[ 0 ].dataIndex ];
const next = {
x: Math.min( Math.max( tooltip.caretX, 84 ), chart.width - 84 ),
y: tooltip.caretY,
title: tipTitle( p.ts, interval ),
visitors: p.visitors,
pageviews: p.pageviews ?? 0,
};
// chart.js calls external on every draw; only re-render on a real move.
setTip( ( prev ) =>
prev && prev.x === next.x && prev.y === next.y && prev.title === next.title ? prev : next
);
},
},
},
scales: {
y: {
beginAtZero: true,
ticks: { precision: 0, maxTicksLimit: 5, color: text, font },
grid: { color: token( '--saddle-chart-grid' ) },
border: { display: false },
},
y1: {
position: 'right',
beginAtZero: true,
ticks: { precision: 0, maxTicksLimit: 5, color: text, font },
grid: { display: false },
border: { display: false },
},
x: {
ticks: { color: text, font, maxRotation: 0, autoSkip: true, maxTicksLimit: points.length > 45 ? 8 : 10 },
grid: { display: false },
},
},
},
} );
}, [ points, interval, running ] );
useEffect( () => () => chartRef.current?.destroy(), [] );
if ( error ) {
return