PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / trunk
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses vtrunk
4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.9.1 4.3.9 4.3.8 4.3.7 4.1.6.9 4.1.6.9.1 4.1.6.9.2 4.1.6.9.3 4.1.6.9.4 4.1.7 4.1.7.1 4.1.7.2 4.1.7.3 4.1.7.3.1 4.1.7.3.2 4.2.0 4.2.1 All 138 releases
learnpress / assets / src / js / admin / statistics / chart.js

chart.js in LearnPress – WordPress LMS Plugin for Create and Sell Online Courses trunk, at assets/src/js/admin/statistics/chart.js

238 lines 6.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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