/** * 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 ; } return (
{ ! points && } { tip && (
{ tip.title }
) }
); }