| 1 |
/** |
| 2 |
* Line chart renderer wrapping Chart.js — single or dual-axis. |
| 3 |
* |
| 4 |
* Pure renderer: no fetching, no state mutation. Reuses an existing chart |
| 5 |
* instance ( Chart.getChart ) instead of recreating, like the legacy |
| 6 |
* initStatisticChart did. |
| 7 |
* |
| 8 |
* @since 4.4.2 |
| 9 |
* @version 1.0.0 |
| 10 |
*/ |
| 11 |
|
| 12 |
import Chart from 'chart.js/auto'; |
| 13 |
import { getStatsConfig, getStatsI18n } from './api.js'; |
| 14 |
|
| 15 |
const DEFAULT_COLORS = [ '#2271b1', '#00a32a' ]; |
| 16 |
const EMPTY_STATE_CLASS = 'lp-stats-chart-empty'; |
| 17 |
|
| 18 |
/** |
| 19 |
* Show/hide the empty state next to the canvas. |
| 20 |
* |
| 21 |
* @param {Element} canvas |
| 22 |
* @param {boolean} show |
| 23 |
*/ |
| 24 |
const toggleEmptyState = ( canvas, show ) => { |
| 25 |
const wrapper = canvas.parentElement; |
| 26 |
if ( ! wrapper ) { |
| 27 |
return; |
| 28 |
} |
| 29 |
|
| 30 |
let elEmpty = wrapper.querySelector( `.${ EMPTY_STATE_CLASS }` ); |
| 31 |
if ( show && ! elEmpty ) { |
| 32 |
elEmpty = document.createElement( 'p' ); |
| 33 |
elEmpty.className = EMPTY_STATE_CLASS; |
| 34 |
elEmpty.textContent = getStatsI18n( 'noData', 'No data for this period.' ); |
| 35 |
wrapper.appendChild( elEmpty ); |
| 36 |
} |
| 37 |
|
| 38 |
if ( elEmpty ) { |
| 39 |
elEmpty.style.display = show ? '' : 'none'; |
| 40 |
} |
| 41 |
canvas.style.display = show ? 'none' : 'block'; |
| 42 |
}; |
| 43 |
|
| 44 |
/** |
| 45 |
* One-off locale date formatting. Fine for a handful of dates ( labels, a |
| 46 |
* custom-range caption ); for per-label chart axes build a single formatter |
| 47 |
* and reuse it instead ( see granularityLabelFormatter ). |
| 48 |
* |
| 49 |
* @param {Date} date |
| 50 |
* @param {Object} options Intl.DateTimeFormat options. |
| 51 |
* @return {string} |
| 52 |
*/ |
| 53 |
export const intlFormat = ( date, options ) => |
| 54 |
new Intl.DateTimeFormat( undefined, options ).format( date ); |
| 55 |
|
| 56 |
/** |
| 57 |
* Do all 'Y-m-d' day labels fall inside a single calendar month? |
| 58 |
* When they cross a month boundary the axis must show the month, otherwise |
| 59 |
* "30, 1, 2" is ambiguous. |
| 60 |
* |
| 61 |
* @param {Array} labels |
| 62 |
* @return {boolean} |
| 63 |
*/ |
| 64 |
const daysWithinOneMonth = ( labels ) => { |
| 65 |
const months = labels.map( ( label ) => String( label ).slice( 0, 7 ) ); |
| 66 |
return months.every( ( m ) => m === months[ 0 ] ); |
| 67 |
}; |
| 68 |
|
| 69 |
/** |
| 70 |
* Axis label formatter for a chart payload's `granularity` marker |
| 71 |
* ( PeriodRange->granularity, set server-side by PeriodResolver ): |
| 72 |
* |
| 73 |
* - hour int 0–23 → "14h" |
| 74 |
* - day 'Y-m-d' → "Tue 14" ( ≤ 7 points, single month ) / "Jul 14" |
| 75 |
* - month 'mm-YYYY' → "Jul 26"-style short month + 2-digit year |
| 76 |
* |
| 77 |
* The returned closure captures a single Intl formatter ( built once here, not |
| 78 |
* per label ), so a 90-point chart formats against one instance. Unparsable |
| 79 |
* labels pass through untouched — never throws. |
| 80 |
* |
| 81 |
* @param {string} granularity Marker from the payload. |
| 82 |
* @param {Array} labels Full label set ( picks the day format density ). |
| 83 |
* @return {Function|null} ( label ) => string, or null for unknown markers. |
| 84 |
*/ |
| 85 |
export const granularityLabelFormatter = ( granularity, labels = [] ) => { |
| 86 |
switch ( granularity ) { |
| 87 |
case 'hour': |
| 88 |
return ( label ) => `${ label }h`; |
| 89 |
|
| 90 |
case 'day': { |
| 91 |
// Weekday reads best for a short, single-month range; anything |
| 92 |
// crossing a month shows the month so labels like "Jun 30 / Jul 1" |
| 93 |
// stay unambiguous. |
| 94 |
const options = |
| 95 |
labels.length <= 7 && daysWithinOneMonth( labels ) |
| 96 |
? { weekday: 'short', day: 'numeric' } |
| 97 |
: { month: 'short', day: 'numeric' }; |
| 98 |
const fmt = new Intl.DateTimeFormat( undefined, options ); |
| 99 |
return ( label ) => { |
| 100 |
const date = new Date( `${ label }T00:00:00` ); |
| 101 |
return isNaN( date.getTime() ) ? String( label ) : fmt.format( date ); |
| 102 |
}; |
| 103 |
} |
| 104 |
|
| 105 |
case 'month': { |
| 106 |
// Labels are 'mm-YYYY'. |
| 107 |
const fmt = new Intl.DateTimeFormat( undefined, { month: 'short', year: '2-digit' } ); |
| 108 |
return ( label ) => { |
| 109 |
const parts = String( label ).split( '-' ); |
| 110 |
const month = parseInt( parts[ 0 ], 10 ); |
| 111 |
if ( 2 === parts.length && month >= 1 && month <= 12 ) { |
| 112 |
return fmt.format( new Date( parseInt( parts[ 1 ], 10 ), month - 1, 1 ) ); |
| 113 |
} |
| 114 |
return String( label ); |
| 115 |
}; |
| 116 |
} |
| 117 |
|
| 118 |
default: |
| 119 |
// Unknown markers render as-is; Chart.js stringifies them for the axis. |
| 120 |
return null; |
| 121 |
} |
| 122 |
}; |
| 123 |
|
| 124 |
/** |
| 125 |
* Render (or update) a line chart. |
| 126 |
* |
| 127 |
* @param {string} canvasSelector e.g. '#net-sales-chart-content'. |
| 128 |
* @param {Object} chartData { labels, datasets: [ { label, data, color, yAxisID } ], xLabel, |
| 129 |
* granularity? — enables the shared axis label formatter }. |
| 130 |
* @param {Object} config { yCurrency?: boolean (default true when 2 datasets), |
| 131 |
* formatLabel?: ( label, index ) => string — overrides granularity }. |
| 132 |
* @return {Chart|null} Chart instance, or null when canvas missing / no data. |
| 133 |
*/ |
| 134 |
export const renderLineChart = ( canvasSelector, chartData = {}, config = {} ) => { |
| 135 |
const canvas = document.querySelector( canvasSelector ); |
| 136 |
if ( ! canvas ) { |
| 137 |
return null; |
| 138 |
} |
| 139 |
|
| 140 |
const { datasets = [], xLabel = '', granularity = '' } = chartData; |
| 141 |
let { labels = [] } = chartData; |
| 142 |
const hasData = |
| 143 |
datasets.length > 0 && |
| 144 |
datasets.some( ( dataset ) => ( dataset.data || [] ).length > 0 ); |
| 145 |
|
| 146 |
if ( ! hasData ) { |
| 147 |
const existing = Chart.getChart( canvas ); |
| 148 |
if ( existing ) { |
| 149 |
existing.destroy(); |
| 150 |
} |
| 151 |
toggleEmptyState( canvas, true ); |
| 152 |
return null; |
| 153 |
} |
| 154 |
|
| 155 |
toggleEmptyState( canvas, false ); |
| 156 |
|
| 157 |
const formatLabel = |
| 158 |
'function' === typeof config.formatLabel |
| 159 |
? config.formatLabel |
| 160 |
: granularityLabelFormatter( granularity, labels ); |
| 161 |
if ( formatLabel ) { |
| 162 |
labels = labels.map( ( label, index ) => formatLabel( label, index ) ); |
| 163 |
} |
| 164 |
|
| 165 |
const isDual = datasets.length > 1; |
| 166 |
const yCurrency = config.yCurrency ?? isDual; |
| 167 |
const currencySymbol = getStatsConfig().currencySymbol || ''; |
| 168 |
|
| 169 |
const chartDatasets = datasets.map( ( dataset, index ) => { |
| 170 |
const color = dataset.color || DEFAULT_COLORS[ index % DEFAULT_COLORS.length ]; |
| 171 |
return { |
| 172 |
label: dataset.label || '', |
| 173 |
data: dataset.data || [], |
| 174 |
borderColor: color, |
| 175 |
backgroundColor: color, |
| 176 |
borderWidth: 2, |
| 177 |
yAxisID: dataset.yAxisID || ( isDual && index > 0 ? 'y1' : 'y' ), |
| 178 |
}; |
| 179 |
} ); |
| 180 |
|
| 181 |
const scales = { |
| 182 |
y: { |
| 183 |
min: 0, |
| 184 |
position: 'left', |
| 185 |
ticks: yCurrency |
| 186 |
? { callback: ( value ) => currencySymbol + value } |
| 187 |
: {}, |
| 188 |
}, |
| 189 |
x: { |
| 190 |
title: { |
| 191 |
display: !! xLabel, |
| 192 |
text: xLabel, |
| 193 |
align: 'end', |
| 194 |
}, |
| 195 |
}, |
| 196 |
}; |
| 197 |
|
| 198 |
if ( isDual ) { |
| 199 |
scales.y1 = { |
| 200 |
min: 0, |
| 201 |
position: 'right', |
| 202 |
grid: { drawOnChartArea: false }, |
| 203 |
ticks: { precision: 0 }, |
| 204 |
}; |
| 205 |
} |
| 206 |
|
| 207 |
const existing = Chart.getChart( canvas ); |
| 208 |
if ( existing ) { |
| 209 |
// Axis set changed (1 ↔ 2 lines) is easier rebuilt than migrated. |
| 210 |
if ( existing.data.datasets.length !== chartDatasets.length ) { |
| 211 |
existing.destroy(); |
| 212 |
} else { |
| 213 |
existing.data.labels = labels; |
| 214 |
chartDatasets.forEach( ( dataset, index ) => { |
| 215 |
existing.data.datasets[ index ].data = dataset.data; |
| 216 |
existing.data.datasets[ index ].label = dataset.label; |
| 217 |
} ); |
| 218 |
existing.config.options.scales.x.title.text = xLabel; |
| 219 |
existing.update(); |
| 220 |
return existing; |
| 221 |
} |
| 222 |
} |
| 223 |
|
| 224 |
return new Chart( canvas, { |
| 225 |
type: 'line', |
| 226 |
data: { labels, datasets: chartDatasets }, |
| 227 |
options: { |
| 228 |
responsive: true, |
| 229 |
maintainAspectRatio: false, |
| 230 |
aspectRatio: 0.8, |
| 231 |
plugins: { |
| 232 |
legend: { display: isDual }, |
| 233 |
}, |
| 234 |
scales, |
| 235 |
}, |
| 236 |
} ); |
| 237 |
}; |
| 238 |
|