PluginProbe
M Chart / 2.2.2
M Chart v2.2.2
2.3.2 2.3.1 2.3 2.2.2 2.2.1 2.2 trunk 1.0 1.1 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.10 1.10.1 1.11 1.11.1 1.11.2 1.12 1.2 1.2.1 1.3 1.3.1 1.3.2 All 53 releases
m-chart / components / class-m-chart-chartjs.php

class-m-chart-chartjs.php in M Chart 2.2.2, at components/class-m-chart-chartjs.php

1,732 lines 53.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if ( ! defined( 'ABSPATH' ) ) {
4 exit;
5 }
6
7 class M_Chart_Chartjs {
8 public $library = 'chartjs';
9 public $library_name = 'Chart.js';
10 public $value_labels_limit = 15;
11 public $value_labels_div = 10;
12 public $original_labels;
13 public $post;
14 public $post_meta;
15 public $args;
16 public $type_options = [
17 'line',
18 'spline',
19 'area',
20 'column',
21 'stacked-column',
22 'bar',
23 'stacked-bar',
24 'pie',
25 'doughnut',
26 'scatter',
27 'bubble',
28 'radar',
29 'radar-area',
30 'polar',
31 'treemap',
32 'boxplot',
33 'violin',
34 ];
35 public $type_option_names = [];
36 public $theme_directories;
37 public $colors = [
38 '#ed6d85', // Pink
39 '#f7d06b', // Yellow
40 '#f2a354', // Orange
41 '#56a0e5', // Blue
42 '#6cbebf', // Turquoise
43 '#47494b', // Gray
44 ];
45 public $points;
46 public $chart_types = [
47 'column' => 'bar',
48 'stacked-column' => 'bar',
49 'bar' => 'bar',
50 'stacked-bar' => 'bar',
51 'pie' => 'pie',
52 'doughnut' => 'doughnut',
53 'line' => 'line',
54 'spline' => 'line',
55 'area' => 'line',
56 'scatter' => 'scatter',
57 'bubble' => 'bubble',
58 'radar' => 'radar',
59 'radar-area' => 'radar',
60 'polar' => 'polarArea',
61 'treemap' => 'treemap',
62 'boxplot' => 'boxplot',
63 'violin' => 'violin',
64 ];
65 public $helpers_loaded = false;
66
67 /**
68 * Constructor
69 */
70 public function __construct() {
71 add_filter( 'm_chart_image_support', [ $this, 'm_chart_image_support' ], 10, 2 );
72 add_filter( 'm_chart_iframe_scripts', [ $this, 'm_chart_iframe_scripts' ], 10, 2 );
73 add_action( 'm_chart_get_chart_begin', [ $this, 'm_chart_get_chart_begin' ], 10, 2 );
74
75 $this->points = self::get_points_defaults();
76
77 $this->theme_directories = [
78 get_stylesheet_directory() . '/m-chart-chartjs-themes/', // Child theme
79 get_template_directory() . '/m-chart-chartjs-themes/', // Parent theme
80 __DIR__ . '/chartjs-themes/',
81 ];
82
83 $this->type_option_names = [
84 'line' => esc_html__( 'Line', 'm-chart' ),
85 'spline' => esc_html__( 'Spline', 'm-chart' ),
86 'area' => esc_html__( 'Area', 'm-chart' ),
87 'column' => esc_html__( 'Column', 'm-chart' ),
88 'stacked-column' => esc_html__( 'Stacked Column', 'm-chart' ),
89 'bar' => esc_html__( 'Bar', 'm-chart' ),
90 'stacked-bar' => esc_html__( 'Stacked Bar', 'm-chart' ),
91 'pie' => esc_html__( 'Pie', 'm-chart' ),
92 'doughnut' => esc_html__( 'Doughnut', 'm-chart' ),
93 'scatter' => esc_html__( 'Scatter', 'm-chart' ),
94 'bubble' => esc_html__( 'Bubble', 'm-chart' ),
95 'radar' => esc_html__( 'Radar', 'm-chart' ),
96 'radar-area' => esc_html__( 'Radar Area', 'm-chart' ),
97 'polar' => esc_html__( 'Polar', 'm-chart' ),
98 'treemap' => esc_html__( 'Treemap', 'm-chart' ),
99 'boxplot' => esc_html__( 'Boxplot', 'm-chart' ),
100 'violin' => esc_html__( 'Violin', 'm-chart' ),
101 ];
102 }
103
104 /**
105 * Canonical Chart.js options scaffold that get_chart_args() builds on top of.
106 *
107 * Pure static defaults with no post-meta dependencie
108 * Extensions read this to get the same literals m-chart will apply at render time
109 *
110 * @return array
111 */
112 public static function get_chart_options_defaults() {
113 return [
114 // Override Chart.js's global default text color so axis ticks, legend labels, and tooltip text always pass WCAG 2.1 AA contrast on white
115 'color' => '#222222',
116 'plugins' => [
117 'title' => [
118 'color' => '#222222',
119 'font' => [
120 'size' => 21,
121 'weight' => 'normal',
122 ],
123 'padding' => [
124 'bottom' => 15,
125 ],
126 ],
127 'subtitle' => [
128 'color' => '#222222',
129 'font' => [
130 'size' => 18,
131 'weight' => 'normal',
132 ],
133 'padding' => [
134 'bottom' => 15,
135 ],
136 ],
137 'legend' => [
138 'position' => 'bottom',
139 'labels' => [
140 'color' => '#222222',
141 'font' => [
142 'weight' => 'bold',
143 ],
144 'usePointStyle' => true,
145 ],
146 ],
147 'tooltip' => [
148 'enabled' => true,
149 ],
150 'datalabels' => [
151 'color' => '#222222',
152 'font' => [
153 'weight' => 'bold',
154 ],
155 'offset' => 3,
156 ],
157 ],
158 'layout' => [
159 'padding' => 16,
160 ],
161 'responsive' => true,
162 'maintainAspectRatio' => false,
163 ];
164 }
165
166 /**
167 * Canonical per-dataset point defaults cycle.
168 *
169 * Each entry's `point` array is what core assigns to a dataset's `elements` slot for line/scatter/radar charts
170 * Extensions read this to get the same literals m-chart will apply at render time
171 *
172 * @return array
173 */
174 public static function get_points_defaults() {
175 return [
176 [
177 'point' => [
178 'pointStyle' => 'circle',
179 'hoverRadius' => 7,
180 'hitRadius' => 13,
181 ],
182 ],
183 [
184 'point' => [
185 'pointStyle' => 'rectRot',
186 'hoverRadius' => 7,
187 'hitRadius' => 13,
188 ],
189 ],
190 [
191 'point' => [
192 'pointStyle' => 'rect',
193 'hoverRadius' => 7,
194 'hitRadius' => 13,
195 ],
196 ],
197 [
198 'point' => [
199 'pointStyle' => 'triangle',
200 'hoverRadius' => 7,
201 'hitRadius' => 13,
202 ],
203 ],
204 [
205 'point' => [
206 'pointStyle' => 'triangle',
207 'rotation' => 180,
208 'hoverRadius' => 7,
209 'hitRadius' => 13,
210 ],
211 ],
212 ];
213 }
214
215 /**
216 * Canonical chartjs-chart-treemap dataset defaults (style-only).
217 *
218 * Returns the static color/font/spacing values core writes onto every treemap dataset
219 * Per-instance flags (`captions.display`, `labels.display`) are intentionally omitted
220 *
221 * Extensions read this to get the same literals m-chart will apply at render time
222 *
223 * @return array
224 */
225 public static function get_treemap_dataset_defaults() {
226 return [
227 'spacing' => 3,
228 'borderWidth' => 0,
229 'borderColor' => '#ffffff',
230 'hoverBorderWidth' => 0,
231 'hoverBorderColor' => '#ffffff',
232 'captions' => [
233 'align' => 'left',
234 'color' => '#000000',
235 'font' => [ 'weight' => 'bold' ],
236 'padding' => 4,
237 'overflow' => 'fit',
238 ],
239 'labels' => [
240 'color' => '#000000',
241 'font' => [ 'weight' => 'bold' ],
242 'align' => 'center',
243 'position' => 'middle',
244 'overflow' => 'fit',
245 ],
246 ];
247 }
248
249 /**
250 * Get the necessary chart data for a given chart and assign it the right class vars
251 *
252 * @param int $post_id the WP ID for the chart post
253 * @param array $args any args we want to override the defaults for
254 */
255 public function get_chart_data( $post_id, $args ) {
256 $this->args = wp_parse_args( $args, m_chart()->get_chart_default_args );
257 $this->post = get_post( $post_id );
258
259 // If the post wasn't valid might as well stop here
260 if ( ! $this->post ) {
261 return;
262 }
263
264 $this->post_meta = m_chart()->get_post_meta( $this->post->ID );
265 }
266
267 /**
268 * Hook: m_chart_get_chart_begin — enqueue Chart.js plugin scripts when a Chart.js chart is about to render
269 *
270 * Fires once per render via M_Chart::get_chart() right before the template loads
271 *
272 * @param int $post_id The chart post ID
273 * @param array $args The chart shortcode args
274 */
275 public function m_chart_get_chart_begin( $post_id, $args ) {
276 // Bail if this chart isn't using the Chart.js library
277 $library = m_chart()->get_post_meta( $post_id, 'library' );
278
279 if ( $library !== $this->library ) {
280 return;
281 }
282
283 // Ensure post_meta is populated so enqueue_chartjs_plugins() can read 'type' for the treemap/boxplot/violin branches
284 $this->post_meta = m_chart()->get_post_meta( $post_id );
285 $this->enqueue_chartjs_plugins();
286 }
287
288 /**
289 * Enqueue any Chart.js plugins that we'll need
290 */
291 public function enqueue_chartjs_plugins() {
292 wp_enqueue_script( 'chartjs-helper' );
293 wp_enqueue_script( 'chartjs-datalabels' );
294
295 $type = isset( $this->post_meta['type'] ) ? $this->post_meta['type'] : '';
296
297 if ( 'treemap' === $type ) {
298 wp_enqueue_script( 'chartjs-treemap' );
299 }
300
301 if ( 'boxplot' === $type || 'violin' === $type ) {
302 wp_enqueue_script( 'chartjs-boxplot' );
303 }
304 }
305
306 /**
307 * Returns an arary of all settings and data needed to build a chart
308 *
309 * @param int $post_id WP post ID of the post you want chart args for
310 * @param array $args any args we want to override the defaults for
311 * @param bool $force optional param to force a rebuild of the data even if a cache was found
312 * @param bool $cache optional param to override the default behavior to cache the results
313 *
314 * @return string URL to the plugin directory with path if parameter was passed
315 */
316 public function get_chart_args( $post_id, $args, $force = false, $cache = true ) {
317 // There's a ton of work that goes into generating the chart args so we cache them
318 $cache_key = $post_id . '-chart-args';
319
320 if ( ! $force && $chart_args = wp_cache_get( $cache_key, m_chart()->slug ) ) {
321 // The width can be set via the args so we'll override whatever the cache has with the arg value
322 $chart_args['graph']['width'] = isset( $this->args['width'] ) && is_numeric( $this->args['width'] ) ? $this->args['width'] : '';
323 return $chart_args;
324 }
325
326 if ( ! $this->args || ! $this->post || ! $this->post_meta ) {
327 $this->get_chart_data( $post_id, $args );
328 }
329
330 // Run the parse class on the data
331 $data = isset( $this->post_meta['data']['sets'][0] ) ? $this->post_meta['data']['sets'][0] : [];
332 m_chart()->parse()->parse_data( $data, $this->post_meta['parse_in'] );
333
334 $type = $this->post_meta['type'];
335
336 $defaults = self::get_chart_options_defaults();
337
338 $chart_args = [
339 'type' => $this->chart_types[ $this->post_meta['type'] ],
340 'options' => [
341 'plugins' => [
342 // @TODO Figure out how to support subtitles in Chart.js
343 'title' => array_merge(
344 $defaults['plugins']['title'],
345 [
346 'display' => true,
347 'text' => $this->esc_title( apply_filters( 'the_title', $this->post->post_title, $this->post->ID ) ),
348 ]
349 ),
350 'legend' => array_merge(
351 $defaults['plugins']['legend'],
352 [
353 'display' => $this->post_meta['legend'] ? true : false,
354 ]
355 ),
356 'tooltip' => $defaults['plugins']['tooltip'],
357 ],
358 'layout' => $defaults['layout'],
359 'responsive' => $defaults['responsive'],
360 'maintainAspectRatio' => $defaults['maintainAspectRatio'],
361 'locale' => m_chart()->get_settings( 'locale' ),
362 ],
363 ];
364
365 // Subtitles are handled by a plugin so we have to conditionally set these values
366 if ( '' != $this->post_meta['subtitle'] ) {
367 $chart_args['options']['plugins']['title']['padding']['bottom'] = 10;
368
369 $chart_args['options']['plugins']['subtitle'] = array_merge(
370 $defaults['plugins']['subtitle'],
371 [
372 'display' => true,
373 'text' => $this->esc_title( $this->post_meta['subtitle'] ),
374 ]
375 );
376 }
377
378 // If we're in the admin panel we need to bump up the devicePixelRatio to get a better image
379 if ( is_admin() ) {
380 $chart_args['options']['devicePixelRatio'] = m_chart()->get_settings( 'image_multiplier' );
381 }
382
383 if (
384 'pie' != $chart_args['type']
385 && 'doughnut' != $chart_args['type']
386 && 'radar' != $chart_args['type']
387 && 'polarArea' != $chart_args['type']
388 && 'treemap' != $chart_args['type']
389 ) {
390 $chart_args['options']['scales']['x']['grid']['borderWidth'] = 0;
391 $chart_args['options']['scales']['y']['grid']['borderWidth'] = 0;
392 }
393
394 if ( $this->post_meta['shared'] ) {
395 $chart_args['options']['plugins']['tooltip']['mode'] = 'index';
396 $chart_args['options']['interaction']['mode'] = 'index';
397 }
398
399 // Treemap has no meaningful series legend; force it off regardless of stored meta
400 if ( 'treemap' === $chart_args['type'] ) {
401 $chart_args['options']['plugins']['legend']['display'] = false;
402 }
403
404 // Forcing a minimum value prevents the built in fudging which sometimes looks weird
405 if (
406 $this->post_meta['y_min']
407 && (
408 'line' == $this->post_meta['type']
409 || 'spline' == $this->post_meta['type']
410 || 'area' == $this->post_meta['type']
411 )
412 ) {
413 $chart_args['options']['scales']['y']['min'] = $this->post_meta['y_min_value'];
414 }
415
416 // Flag boxplot/violin charts that opt in to auto-tightened y-axis bounds
417 // The MChartHelper runtime plugin reads this flag and computes bounds from the data
418 if (
419 ! empty( $this->post_meta['constrain_y_axis'] )
420 && ( 'boxplot' === $this->post_meta['type'] || 'violin' === $this->post_meta['type'] )
421 ) {
422 if ( ! isset( $chart_args['options']['plugins']['mchart'] ) ) {
423 $chart_args['options']['plugins']['mchart'] = [];
424 }
425
426 $chart_args['options']['plugins']['mchart']['constrain_y_axis'] = true;
427 }
428
429 // In-chart source attribution — MChartHelper.afterDraw paints the source line bottom-left of the canvas
430 // and afterEvent wires up the clickable behavior when source_url is present
431 if (
432 ! empty( $this->post_meta['include_source'] )
433 && '' !== $this->post_meta['source']
434 ) {
435 if ( ! isset( $chart_args['options']['plugins']['mchart'] ) ) {
436 $chart_args['options']['plugins']['mchart'] = [];
437 }
438
439 $chart_args['options']['plugins']['mchart']['source'] = $this->post_meta['source'];
440 $chart_args['options']['plugins']['mchart']['source_url'] = $this->post_meta['source_url'];
441 $chart_args['options']['plugins']['mchart']['include_source'] = true;
442
443 // Push the bottom of the plot area up so the source line has room to render
444 $chart_args['options']['layout']['padding'] = [
445 'top' => 16,
446 'right' => 16,
447 'bottom' => 36,
448 'left' => 16,
449 ];
450 }
451
452 $chart_args['data']['labels'] = $this->get_value_labels_array();
453
454 if (
455 'pie' != $chart_args['type']
456 && 'doughnut' != $chart_args['type']
457 && 'radar' != $chart_args['type']
458 && 'polarArea' != $chart_args['type']
459 && 'treemap' != $chart_args['type']
460 ) {
461 $chart_args = $this->add_axis_labels( $chart_args );
462 }
463
464 if (
465 'bar' == $this->post_meta['type']
466 || 'stacked-bar' == $this->post_meta['type']
467 ) {
468 $chart_args['options']['indexAxis'] = 'y';
469 $chart_args['options']['scales']['y']['grid']['display'] = false;
470 $chart_args['options']['scales']['y']['grid']['borderWidth'] = 0;
471 } elseif (
472 'pie' != $chart_args['type']
473 && 'doughnut' != $chart_args['type']
474 && 'radar' != $chart_args['type']
475 && 'polarArea' != $chart_args['type']
476 && 'treemap' != $chart_args['type']
477 ) {
478 $chart_args['options']['scales']['x']['grid']['display'] = false;
479 }
480
481 if (
482 'stacked-bar' == $this->post_meta['type']
483 || 'stacked-column' == $this->post_meta['type']
484 ) {
485 $chart_args['options']['scales']['x']['stacked'] = true;
486 $chart_args['options']['scales']['y']['stacked'] = true;
487 }
488
489 $chart_args = $this->add_data_sets( $chart_args );
490
491 // Add some stuff for the helper plugin
492 $chart_args['locale'] = m_chart()->get_settings( 'locale' );
493 $chart_args['options']['plugins']['m-chart-helper']['labels_pos'] = m_chart()->parse()->value_labels_position;
494
495 // Chart.js 3.x.x requires at least some form of data set (even if it's empty) or the chart object doesn't get generated
496 if ( ! isset( $chart_args['data']['datasets'] ) ) {
497 $chart_args['data']['datasets'] = [
498 [
499 'label' => '',
500 'data' => [],
501 ],
502 ];
503 }
504
505 // Apply the theme
506 if ( $theme = $this->get_theme( $this->post_meta['theme'] ) ) {
507 if ( isset( $theme['colors'] ) ) {
508 $this->colors = $theme['colors'];
509 unset( $theme['colors'] );
510 }
511
512 if ( isset( $theme['points'] ) ) {
513 $this->points = $theme['points'];
514 unset( $theme['points'] );
515 }
516 }
517
518 $this->colors = apply_filters( 'm_chart_chartjs_colors', $this->colors, $this->post, $this->post_meta );
519 $this->points = apply_filters( 'm_chart_chartjs_points', $this->points, $this->post, $this->post_meta );
520
521 // It's possible to have more data points/sets than colors or point styles so we need to perform a modulo operation when iterating through them
522 $color_count = count( $this->colors );
523 $point_count = count( $this->points );
524
525 $use_per_point_colors = ! empty( $this->post_meta['data_point_colors'] );
526
527 // Apply colors and point styles, yes this kind of sucks, but so does the Chart.js system for handling this stuff
528 if ( 'treemap' === $chart_args['type'] && isset( $chart_args['data']['datasets'][0]['tree'] ) ) {
529 $ds = &$chart_args['data']['datasets'][0];
530
531 if ( ! empty( $ds['mChartTreemapHierarchical'] ) ) {
532 // Hierarchical: build a stable map from each unique top-level group VALUE to a palette color in first-seen order
533 // This allows downstream JS to paint top groups + alpha-shaded descendants
534 $top_field = $ds['mChartTopGroupField'];
535 $top_colors = [];
536 $top_rgb = [];
537
538 foreach ( $ds['tree'] as $entry ) {
539 if ( ! isset( $entry[ $top_field ] ) ) {
540 continue;
541 }
542
543 $top_id = $entry[ $top_field ];
544
545 if ( isset( $top_colors[ $top_id ] ) ) {
546 continue;
547 }
548
549 $hex = $this->colors[ count( $top_colors ) % $color_count ];
550
551 $top_colors[ $top_id ] = $hex;
552 $top_rgb[ $top_id ] = $this->hex_to_rgb( $hex );
553 }
554
555 $ds['mChartTopGroupColors'] = $top_colors;
556 $ds['mChartTopGroupRgb'] = $top_rgb;
557 } else {
558 $values = wp_list_pluck( $ds['tree'], 'value' );
559 $max = ! empty( $values ) ? max( $values ) : 0;
560 $max = $max > 0 ? $max : 1;
561
562 $colors = [];
563
564 if ( $use_per_point_colors ) {
565 foreach ( $values as $i => $_v ) {
566 $colors[] = $this->colors[ $i % $color_count ];
567 }
568 } else {
569 $rgb = $this->hex_to_rgb( $this->colors[0] );
570
571 foreach ( $values as $v ) {
572 // Floor at 0.15 so the smallest rectangles still render visibly
573 $alpha = max( 0.15, min( 1, $v / $max ) );
574 $colors[] = sprintf( 'rgba(%d, %d, %d, %.3f)', $rgb['red'], $rgb['green'], $rgb['blue'], $alpha );
575 }
576 }
577
578 $ds['mChartColors'] = $colors;
579 }
580
581 unset( $ds );
582 } elseif (
583 isset( $chart_args['data']['datasets'] )
584 && ( 'boxplot' === $chart_args['type'] || 'violin' === $chart_args['type'] )
585 ) {
586 $is_single_dataset = 1 === count( $chart_args['data']['datasets'] );
587 $mean_radius = ! empty( $this->post_meta['mean_point'] ) ? 3 : 0;
588 $item_radius = ! empty( $this->post_meta['sample_points'] ) ? 3 : 0;
589
590 foreach ( $chart_args['data']['datasets'] as $key => $dataset ) {
591 if ( $use_per_point_colors && $is_single_dataset ) {
592 // Per-box palette cycling for the single-dataset case
593 $count = isset( $dataset['data'] ) && is_array( $dataset['data'] ) ? count( $dataset['data'] ) : 0;
594 $bg_per_box = [];
595 $bd_per_box = [];
596
597 for ( $i = 0; $i < $count; $i++ ) {
598 $hex = $this->colors[ $i % $color_count ];
599 $rgb = $this->hex_to_rgb( $hex );
600 $bg_per_box[] = sprintf( 'rgba(%d, %d, %d, 0.5)', $rgb['red'], $rgb['green'], $rgb['blue'] );
601 $bd_per_box[] = $hex;
602 }
603
604 $chart_args['data']['datasets'][ $key ]['backgroundColor'] = $bg_per_box;
605 $chart_args['data']['datasets'][ $key ]['borderColor'] = $bd_per_box;
606 $chart_args['data']['datasets'][ $key ]['outlierBackgroundColor'] = $bd_per_box;
607 $chart_args['data']['datasets'][ $key ]['outlierBorderColor'] = $bd_per_box;
608 $chart_args['data']['datasets'][ $key ]['meanBackgroundColor'] = $bd_per_box;
609 $chart_args['data']['datasets'][ $key ]['meanBorderColor'] = $bd_per_box;
610 } else {
611 $hex = $this->colors[ $key % $color_count ];
612 $rgb = $this->hex_to_rgb( $hex );
613
614 $chart_args['data']['datasets'][ $key ]['backgroundColor'] = sprintf( 'rgba(%d, %d, %d, 0.5)', $rgb['red'], $rgb['green'], $rgb['blue'] );
615 $chart_args['data']['datasets'][ $key ]['borderColor'] = $hex;
616 $chart_args['data']['datasets'][ $key ]['outlierBackgroundColor'] = $hex;
617 $chart_args['data']['datasets'][ $key ]['outlierBorderColor'] = $hex;
618 $chart_args['data']['datasets'][ $key ]['meanBackgroundColor'] = $hex;
619 $chart_args['data']['datasets'][ $key ]['meanBorderColor'] = $hex;
620 }
621
622 $chart_args['data']['datasets'][ $key ]['borderWidth'] = 2;
623
624 // Outlier dots: solid filled circles in the box's color, no border
625 $chart_args['data']['datasets'][ $key ]['outlierRadius'] = 3;
626 $chart_args['data']['datasets'][ $key ]['outlierBorderWidth'] = 0;
627
628 // Mean dot: same visual style as outliers; toggle via mean_point meta
629 $chart_args['data']['datasets'][ $key ]['meanRadius'] = $mean_radius;
630 $chart_args['data']['datasets'][ $key ]['meanBorderWidth'] = 0;
631
632 // Item dots (raw value distribution overlay): light gray, no border, smaller; toggle via sample_points
633 $chart_args['data']['datasets'][ $key ]['itemRadius'] = $item_radius;
634 $chart_args['data']['datasets'][ $key ]['itemBorderWidth'] = 0;
635 $chart_args['data']['datasets'][ $key ]['itemBackgroundColor'] = 'rgba(160, 160, 160, 0.5)';
636 }
637 } elseif (
638 isset( $chart_args['data']['datasets'] )
639 && ( 'bar' == $chart_args['type'] || 'horizontalBar' == $chart_args['type'] )
640 ) {
641 $is_single_series = 1 === count( $chart_args['data']['datasets'] );
642 $is_stacked = 'stacked-column' == $this->post_meta['type'] || 'stacked-bar' == $this->post_meta['type'];
643
644 foreach ( $chart_args['data']['datasets'] as $key => $dataset ) {
645 if ( $use_per_point_colors && $is_single_series && ! $is_stacked ) {
646 // Each bar/column gets a different color cycled from the palette
647 $per_point = [];
648 $count = isset( $dataset['data'] ) && is_array( $dataset['data'] ) ? count( $dataset['data'] ) : 0;
649
650 for ( $i = 0; $i < $count; $i++ ) {
651 $per_point[] = $this->colors[ $i % $color_count ];
652 }
653
654 $chart_args['data']['datasets'][ $key ]['backgroundColor'] = $per_point;
655 } else {
656 $chart_args['data']['datasets'][ $key ]['backgroundColor'] = $this->colors[ $key % $color_count ];
657 }
658
659 if ( true == $this->post_meta['labels'] ) {
660 if ( $is_stacked ) {
661 $chart_args['data']['datasets'][ $key ]['datalabels'] = [
662 'align' => 'center',
663 'anchor' => 'center',
664 'color' => '#ffffff',
665 ];
666 } else {
667 $chart_args['data']['datasets'][ $key ]['datalabels'] = [
668 'align' => 'end',
669 'anchor' => 'end',
670 // When color per data point is on match each label to its bar so the per-point colors carry through
671 'color' => ( $use_per_point_colors && $is_single_series ) ? $per_point : $this->colors[ $key % $color_count ],
672 ];
673 }
674 }
675 }
676 } elseif (
677 isset( $chart_args['data']['datasets'] )
678 && (
679 'pie' == $chart_args['type']
680 || 'doughnut' == $chart_args['type']
681 )
682 ) {
683 foreach ( $chart_args['data']['datasets'][0]['data'] as $key => $data ) {
684 $chart_args['data']['datasets'][0]['backgroundColor'][ $key ] = $this->colors[ $key % $color_count ];
685
686 if ( true == $this->post_meta['labels'] ) {
687 $chart_args['data']['datasets'][0]['datalabels'] = [
688 'align' => 'end',
689 'anchor' => 'end',
690 'color' => $this->colors,
691 ];
692 }
693 }
694 } elseif (
695 isset( $chart_args['data']['datasets'] )
696 && 'polarArea' == $chart_args['type']
697 ) {
698 $chart_args['data']['datasets'][0]['backgroundColor'] = $this->colors;
699
700 if ( true == $this->post_meta['labels'] ) {
701 $chart_args['data']['datasets'][0]['datalabels'] = [
702 'align' => 'end',
703 'anchor' => 'end',
704 'color' => $this->colors,
705 ];
706 }
707 } elseif ( isset( $chart_args['data']['datasets'] ) ) {
708 foreach ( $chart_args['data']['datasets'] as $key => $dataset ) {
709 $color = $this->colors[ $key % $color_count ];
710
711 $chart_args['data']['datasets'][ $key ]['backgroundColor'] = $color;
712 $chart_args['data']['datasets'][ $key ]['borderColor'] = $color;
713
714 if ( 'spline' == $this->post_meta['type'] ) {
715 $chart_args['data']['datasets'][ $key ]['tension'] = 0.25;
716 } else {
717 $chart_args['data']['datasets'][ $key ]['tension'] = 0;
718 }
719
720 if (
721 'line' == $this->post_meta['type']
722 || 'spline' == $this->post_meta['type']
723 || 'area' == $this->post_meta['type']
724 || 'radar' == $this->post_meta['type']
725 || 'radar-area' == $this->post_meta['type']
726 || 'scatter' == $this->post_meta['type']
727 ) {
728 $chart_args['data']['datasets'][ $key ]['elements'] = $this->points[ $key % $point_count ];
729 }
730
731 if (
732 'area' == $this->post_meta['type']
733 || 'bubble' == $this->post_meta['type']
734 || 'radar-area' == $this->post_meta['type']
735 ) {
736 $rgb = $this->hex_to_rgb( $color );
737
738 $chart_args['data']['datasets'][ $key ]['backgroundColor'] = 'rgba( ' . implode( ', ', $rgb ) . ', .5 )';
739 $chart_args['data']['datasets'][ $key ]['elements']['point']['backgroundColor'] = $color;
740 $chart_args['data']['datasets'][ $key ]['fill'] = true;
741 } else {
742 $chart_args['data']['datasets'][ $key ]['fill'] = false;
743 }
744
745 if ( true == $this->post_meta['labels'] ) {
746 $chart_args['data']['datasets'][ $key ]['datalabels'] = [
747 'align' => 'end',
748 'anchor' => 'end',
749 'color' => $color,
750 ];
751 }
752 }
753 }
754
755 // Data labels are handled by a plugin so we have to conditionally set these values
756 $chart_args['options']['plugins']['datalabels']['display'] = false;
757
758 if (
759 true == $this->post_meta['labels']
760 && 'treemap' !== $chart_args['type']
761 && 'boxplot' !== $chart_args['type']
762 && 'violin' !== $chart_args['type']
763 ) {
764 $chart_args['options']['plugins']['datalabels'] = array_merge(
765 $defaults['plugins']['datalabels'],
766 [ 'display' => 'auto' ]
767 );
768 }
769
770 if ( $theme ) {
771 $chart_args = m_chart()->array_merge_recursive( $chart_args, $theme );
772 }
773
774 // Expose the full resolved color palette so plugins can use it
775 $chart_args['colors'] = $this->colors;
776
777 $chart_args = apply_filters( 'm_chart_chart_args', $chart_args, $this->post, $this->post_meta, $this->args );
778
779 // Re-index the datasets so the list is always a JSON array
780 // Skipped null sets upstream can leave non-sequential keys which json_encode would turn into an object and crash Chart.js
781 // Only the outer order is re-keyed so each dataset keeps its own label and data
782 if ( isset( $chart_args['data']['datasets'] ) ) {
783 $chart_args['data']['datasets'] = array_values( $chart_args['data']['datasets'] );
784 }
785
786 // Same treatment for the labels
787 // A filter that unsets entries would otherwise leave non-sequential keys
788 // Only act when it's a flat list since the LABELS_BOTH map is intentionally associative
789 if ( isset( $chart_args['data']['labels'] ) && is_array( $chart_args['data']['labels'] ) && ! isset( $chart_args['data']['labels'][ M_Chart_Parse::LABELS_FIRST_ROW ] ) ) {
790 $chart_args['data']['labels'] = array_values( $chart_args['data']['labels'] );
791 }
792
793 // Set the cache, we'll regenerate this when someone updates the post
794 if ( $cache ) {
795 wp_cache_set( $cache_key, $chart_args, m_chart()->slug );
796 }
797
798 // Clear out all of the class vars so the next chart instance starts fresh
799 $this->args = null;
800 $this->post = null;
801 $this->post_meta = null;
802
803 return $chart_args;
804 }
805
806 /**
807 * Hook to the m_chart_update_post_meta action and refresh the chart args cache
808 *
809 * @param int $post_id WP post ID of the post you want chart args for
810 * @param array $parsed_meta the parsed chart meta passed by the action hook
811 */
812 public function m_chart_update_post_meta( $post_id, $parsed_meta ) {
813 // Refresh arg cache
814 $this->args = m_chart()->get_chart_default_args;
815 $this->post = get_post( $post_id );
816
817 $this->post_meta = $parsed_meta;
818
819 $this->get_chart_args( $post_id, [], true );
820 }
821
822 /**
823 * Hook to the m_chart_image_support filter and indicate that Chart.js supports images
824 *
825 * @param string $supports_images yes/no whether the library supports image generation
826 * @param string $library the library in question
827 *
828 * @return string $supports_images yes/no whether the library supports image generation
829 */
830 public function m_chart_image_support( $supports_images, $library ) {
831 if ( $library != $this->library ) {
832 return $supports_images;
833 }
834
835 return 'yes';
836 }
837
838 /**
839 * Returns the value labels array
840 *
841 * @return array an array of the value labels need for the active chart
842 */
843
844 public function get_value_labels_array() {
845 $value_labels = m_chart()->parse()->value_labels;
846
847 // In LABELS_BOTH mode value_labels is an associative map keyed by first_row / first_column
848 // We must return the relevant sub-array as a flat list
849 // Gating on the position (rather than isset of one sub-key) avoids returning the whole map when a side has no labels
850 // e.g. an empty A1 with a value in B1 - returning the map there would json_encode to an object and crash Chart.js (n.slice)
851 if ( M_Chart_Parse::LABELS_BOTH === m_chart()->parse()->value_labels_position ) {
852 $label_key = M_Chart_Parse::PARSE_ROWS == $this->post_meta['parse_in']
853 ? M_Chart_Parse::LABELS_FIRST_ROW
854 : M_Chart_Parse::LABELS_FIRST_COLUMN;
855
856 return $value_labels[ $label_key ] ?? [];
857 }
858
859 return $value_labels;
860 }
861
862 /**
863 * Handle adding units to axis labels and/or flipping axis labels on bar chart
864 *
865 * @param array the current array of chart args
866 *
867 * @return array the chart args array with axis labels (and units) added to it
868 */
869 public function add_axis_labels( $chart_args ) {
870 // Force tick + axis-title text color so dark text always passes WCAG 2.1 AA contrast
871 $chart_args['options']['scales']['x']['ticks']['color'] = '#222222';
872 $chart_args['options']['scales']['y']['ticks']['color'] = '#222222';
873
874 // Note the additional layer in the array: [0] its needed for Chart.js to see the label settings
875 $chart_args['options']['scales']['x']['title'] = [
876 'color' => '#222222',
877 'display' => '' == $this->post_meta['x_title'] ? false : true,
878 'text' => $this->esc_title( $this->post_meta['x_title'] ),
879 ];
880
881 // We've got x axis units so we'll add them to the axis label
882 if ( '' != $this->post_meta['x_units'] ) {
883 $chart_args['options']['scales']['x']['title']['display'] = true;
884
885 $units = get_term_by( 'slug', $this->post_meta['x_units'], m_chart()->slug . '-units' );
886 $x_units = '' != $this->post_meta['x_title'] ? ' (' . $units->name . ')' : $units->name;
887
888 $chart_args['options']['scales']['x']['title']['text'] .= $x_units;
889 }
890
891 $chart_args['options']['scales']['y']['title'] = [
892 'color' => '#222222',
893 'display' => '' == $this->post_meta['y_title'] ? false : true,
894 'text' => $this->esc_title( $this->post_meta['y_title'] ),
895 ];
896
897 // We've got y axis units so we'll add them to the axis label
898 if ( '' != $this->post_meta['y_units'] ) {
899 $chart_args['options']['scales']['y']['title']['display'] = true;
900
901 $units = get_term_by( 'slug', $this->post_meta['y_units'], m_chart()->slug . '-units' );
902 $y_units = '' != $this->post_meta['y_title'] ? ' (' . $units->name . ')' : $units->name;
903
904 $chart_args['options']['scales']['y']['title']['text'] .= $y_units;
905 }
906
907 return $chart_args;
908 }
909
910 /**
911 * Handle adding data sets to the chart args
912 *
913 * @param array the current array of chart args
914 *
915 * @return array the chart args array with data sets added to it
916 */
917 public function add_data_sets( $chart_args ) {
918 // When Chart.js encounters an empty data value it stops so we set them to NULL
919 $data_array = array_map( [ $this, 'fix_null_values' ], m_chart()->parse()->set_data );
920 $raw_data = m_chart()->parse()->raw_data;
921
922 if ( 'boxplot' === $this->post_meta['type'] || 'violin' === $this->post_meta['type'] ) {
923 $sheets = isset( $this->post_meta['data']['sets'] ) && is_array( $this->post_meta['data']['sets'] ) ? $this->post_meta['data']['sets'] : [];
924 $set_names = isset( $this->post_meta['set_names'] ) ? $this->post_meta['set_names'] : [];
925 $parse_in = $this->post_meta['parse_in'];
926
927 $primary_categories = null;
928 $datasets = [];
929
930 foreach ( $sheets as $sheet_index => $sheet ) {
931 $rows = ( 'columns' === $parse_in ) ? $this->transpose_sheet( $sheet ) : $sheet;
932 $rows = $this->strip_empty_rows( $rows );
933
934 $categories = [];
935 $values_array = [];
936 $dataset_prefix = '';
937 $dataset_suffix = '';
938 $affixes_set = false;
939
940 foreach ( $rows as $row ) {
941 if ( ! is_array( $row ) ) {
942 continue;
943 }
944
945 $category = isset( $row[0] ) ? trim( (string) $row[0] ) : '';
946
947 if ( '' === $category ) {
948 continue;
949 }
950
951 $values = [];
952 $cell_count = count( $row );
953
954 for ( $i = 1; $i < $cell_count; $i++ ) {
955 $cell = isset( $row[ $i ] ) ? (string) $row[ $i ] : '';
956
957 if ( '' === trim( $cell ) ) {
958 continue;
959 }
960
961 $parsed = m_chart()->parse()->parse_data_point( $cell );
962
963 if ( null === $parsed->value ) {
964 continue;
965 }
966
967 $values[] = (float) $parsed->value;
968
969 if ( ! $affixes_set ) {
970 $dataset_prefix = $parsed->prefix;
971 $dataset_suffix = $parsed->suffix;
972 $affixes_set = true;
973 }
974 }
975
976 if ( empty( $values ) ) {
977 continue;
978 }
979
980 $categories[] = $category;
981 $values_array[] = $values;
982 }
983
984 if ( empty( $values_array ) ) {
985 continue;
986 }
987
988 if ( null === $primary_categories ) {
989 $primary_categories = $categories;
990 }
991
992 // Pass raw value arrays to the library so it computes both stats AND the KDE
993 // The violin controller needs the KDE to draw its density curve
994 $datasets[] = [
995 'label' => isset( $set_names[ $sheet_index ] ) && '' !== $set_names[ $sheet_index ] ? $set_names[ $sheet_index ] : ( 'Sheet ' . ( $sheet_index + 1 ) ),
996 'data' => $values_array,
997 'mChartIsBoxplot' => true,
998 'mChartDatasetPrefix' => $dataset_prefix,
999 'mChartDatasetSuffix' => $dataset_suffix,
1000 ];
1001 }
1002
1003 $chart_args['data']['labels'] = null !== $primary_categories ? $primary_categories : [];
1004 $chart_args['data']['datasets'] = $datasets;
1005
1006 return $chart_args;
1007 }
1008
1009 if ( 'treemap' === $this->post_meta['type'] ) {
1010 $raw_sheet = isset( $this->post_meta['data']['sets'][0] ) ? $this->post_meta['data']['sets'][0] : [];
1011 $hierarchy = $this->build_treemap_hierarchy( $raw_sheet, $this->post_meta['parse_in'] );
1012
1013 if ( null !== $hierarchy ) {
1014 $treemap_defaults = self::get_treemap_dataset_defaults();
1015 $treemap_defaults['captions']['display'] = true;
1016 $treemap_defaults['labels']['display'] = (bool) $this->post_meta['labels'];
1017
1018 $dataset_defaults = apply_filters(
1019 'm_chart_treemap_dataset_defaults',
1020 $treemap_defaults,
1021 $this->post,
1022 $this->args
1023 );
1024
1025 $chart_args['data']['labels'] = [];
1026 $chart_args['data']['datasets'] = [
1027 array_merge(
1028 $dataset_defaults,
1029 [
1030 'tree' => $hierarchy['tree'],
1031 'key' => $hierarchy['key'],
1032 'groups' => $hierarchy['groups'],
1033 'mChartIsTreemap' => true,
1034 'mChartTreemapHierarchical' => true,
1035 'mChartTopGroupField' => $hierarchy['top_field'],
1036 'mChartDatasetPrefix' => $hierarchy['dataset_prefix'],
1037 'mChartDatasetSuffix' => $hierarchy['dataset_suffix'],
1038 ]
1039 ),
1040 ];
1041
1042 return $chart_args;
1043 }
1044
1045 $tree = [];
1046
1047 foreach ( $chart_args['data']['labels'] as $key => $label ) {
1048 if ( ! isset( $data_array[ $key ] ) ) {
1049 continue;
1050 }
1051
1052 // raw_data entries are M_Chart_Parsed_Data_Point objects produced by parse_data_point()
1053 $raw = isset( $raw_data[ $key ] ) && $raw_data[ $key ] instanceof M_Chart_Parsed_Data_Point ? $raw_data[ $key ] : null;
1054
1055 $tree[] = [
1056 'label' => $label,
1057 'value' => is_numeric( $data_array[ $key ] ) ? (float) $data_array[ $key ] : 0,
1058 'prefix' => $raw ? $raw->prefix : '',
1059 'suffix' => $raw ? $raw->suffix : '',
1060 'text' => $raw ? $raw->text : null,
1061 ];
1062 }
1063
1064 $treemap_defaults = self::get_treemap_dataset_defaults();
1065 // Flat (single-level) data — captions don't render, so reset
1066 // the array to just the off flag and let the styling drop.
1067 $treemap_defaults['captions'] = [ 'display' => false ];
1068 $treemap_defaults['labels']['display'] = (bool) $this->post_meta['labels'];
1069
1070 $dataset_defaults = apply_filters(
1071 'm_chart_treemap_dataset_defaults',
1072 $treemap_defaults,
1073 $this->post,
1074 $this->args
1075 );
1076
1077 $chart_args['data']['labels'] = [];
1078 $chart_args['data']['datasets'] = [
1079 array_merge(
1080 $dataset_defaults,
1081 [
1082 'tree' => $tree,
1083 'key' => 'value',
1084 'rawData' => $tree,
1085 'mChartIsTreemap' => true,
1086 ]
1087 ),
1088 ];
1089
1090 return $chart_args;
1091 }
1092
1093 if (
1094 'pie' == $this->post_meta['type']
1095 || 'doughnut' == $chart_args['type']
1096 || 'polar' == $this->post_meta['type']
1097 || M_Chart_Parse::LABELS_BOTH != m_chart()->parse()->value_labels_position
1098 && (
1099 'scatter' != $this->post_meta['type']
1100 && 'bubble' != $this->post_meta['type']
1101 && 'radar' != $this->post_meta['type']
1102 && 'radar-area' != $this->post_meta['type']
1103 )
1104 ) {
1105 foreach ( $chart_args['data']['labels'] as $key => $label ) {
1106 if ( isset( $data_array[ $key ] ) ) {
1107 $chart_args['data']['datasets'][0]['data'][] = $data_array[ $key ];
1108 $chart_args['data']['datasets'][0]['rawData'][] = isset( $raw_data[ $key ] ) ? $raw_data[ $key ] : '';
1109 }
1110 }
1111 } elseif (
1112 'radar' == $this->post_meta['type']
1113 || 'radar-area' == $this->post_meta['type']
1114 ) {
1115 $set_names = $this->post_meta['set_names'];
1116
1117 foreach ( $this->post_meta['data']['sets'] as $key => $data ) {
1118 $parse = m_chart()->parse()->parse_data( $data, $this->post_meta['parse_in'] );
1119
1120 $data_array = array_map( [ $this, 'fix_null_values' ], $parse->set_data );
1121
1122 $chart_args['data']['datasets'][ $key ] = [
1123 'label' => isset( $set_names[ $key ] ) ? $set_names[ $key ] : 'Sheet 1',
1124 'data' => $data_array,
1125 'rawData' => $parse->raw_data,
1126 ];
1127 }
1128 } elseif ( 'scatter' == $this->post_meta['type'] ) {
1129 $set_names = $this->post_meta['set_names'];
1130
1131 foreach ( $this->post_meta['data']['sets'] as $key => $data ) {
1132 $parse = m_chart()->parse()->parse_data( $data, $this->post_meta['parse_in'] );
1133
1134 $data_array = array_map( [ $this, 'fix_null_values' ], $parse->set_data );
1135
1136 $new_data_array = [];
1137
1138 if ( M_Chart_Parse::LABELS_BOTH == $parse->value_labels_position ) {
1139 if ( M_Chart_Parse::PARSE_COLUMNS == $this->post_meta['parse_in'] ) {
1140 // PARSE_COLUMNS: set_data is [x_values_array, y_values_array] — zip by index
1141 foreach ( ( $data_array[0] ?? [] ) as $i => $x ) {
1142 $new_data_array[] = [
1143 'x' => $x,
1144 'y' => $data_array[1][ $i ],
1145 'label' => $parse->value_labels[ M_Chart_Parse::LABELS_FIRST_COLUMN ][ $i ] ?? '',
1146 ];
1147 }
1148 } else {
1149 // PARSE_ROWS: set_data is [[x1,y1], [x2,y2], ...] — one entry per point
1150 foreach ( $data_array as $data_key => $data ) {
1151 $new_data_array[] = [
1152 'x' => $data[0],
1153 'y' => $data[1],
1154 'label' => $parse->value_labels[ M_Chart_Parse::LABELS_FIRST_COLUMN ][ $data_key ] ?? '',
1155 ];
1156 }
1157 }
1158 } else {
1159 foreach ( $data_array as $data_key => $data ) {
1160 if ( $data_key % 2 ) {
1161 continue;
1162 }
1163
1164 if ( ! isset( $data_array[ $data_key + 1 ] ) ) {
1165 continue;
1166 }
1167
1168 $new_data_array[] = [
1169 'x' => $data,
1170 'y' => $data_array[ $data_key + 1 ],
1171 ];
1172 }
1173 }
1174
1175 $chart_args['data']['datasets'][ $key ] = [
1176 'label' => isset( $set_names[ $key ] ) ? $set_names[ $key ] : 'Sheet 1',
1177 'data' => $new_data_array,
1178 'rawData' => $parse->raw_data,
1179 ];
1180 }
1181 } elseif ( 'bubble' == $this->post_meta['type'] ) {
1182 $set_names = $this->post_meta['set_names'];
1183
1184 foreach ( $this->post_meta['data']['sets'] as $key => $data ) {
1185 $parse = m_chart()->parse()->parse_data( $data, $this->post_meta['parse_in'] );
1186
1187 $data_array = array_map( [ $this, 'fix_null_values' ], $parse->set_data );
1188
1189 $new_data_array = [];
1190
1191 if ( M_Chart_Parse::LABELS_BOTH == $parse->value_labels_position ) {
1192 if ( M_Chart_Parse::PARSE_COLUMNS == $this->post_meta['parse_in'] ) {
1193 // PARSE_COLUMNS: set_data is [x_values_array, y_values_array, r_values_array] — zip by index
1194 foreach ( $data_array[0] as $i => $x ) {
1195 $new_data_array[] = [
1196 'x' => $x,
1197 'y' => $data_array[1][ $i ],
1198 'r' => $data_array[2][ $i ],
1199 'label' => $parse->value_labels[ M_Chart_Parse::LABELS_FIRST_COLUMN ][ $i ] ?? '',
1200 ];
1201 }
1202 } else {
1203 // PARSE_ROWS: set_data is [[x1,y1,r1], [x2,y2,r2], ...] — one entry per point
1204 foreach ( $data_array as $data_key => $data ) {
1205 $new_data_array[] = [
1206 'x' => $data[0],
1207 'y' => $data[1],
1208 'r' => $data[2],
1209 'label' => $parse->value_labels[ M_Chart_Parse::LABELS_FIRST_COLUMN ][ $data_key ] ?? '',
1210 ];
1211 }
1212 }
1213 } else {
1214 foreach ( $data_array as $data_key => $data ) {
1215 if ( $data_key % 3 ) {
1216 continue;
1217 }
1218
1219 if ( ! isset( $data_array[ $data_key + 1 ] ) || ! isset( $data_array[ $data_key + 2 ] ) ) {
1220 continue;
1221 }
1222
1223 $new_data_array[] = [
1224 'x' => $data,
1225 'y' => $data_array[ $data_key + 1 ],
1226 'r' => $data_array[ $data_key + 2 ],
1227 ];
1228 }
1229 }
1230
1231 $chart_args['data']['datasets'][ $key ] = [
1232 'label' => isset( $set_names[ $key ] ) ? $set_names[ $key ] : 'Sheet 1',
1233 'data' => $new_data_array,
1234 'rawData' => $parse->raw_data,
1235 ];
1236 }
1237 } else {
1238 $set_data = [];
1239
1240 $label_key = ( $this->post_meta['parse_in'] == M_Chart_Parse::PARSE_ROWS ) ? M_Chart_Parse::LABELS_FIRST_COLUMN : M_Chart_Parse::LABELS_FIRST_ROW;
1241
1242 foreach ( $data_array as $key => $data_chunk ) {
1243 $set_data[ $key ] = [
1244 'label' => m_chart()->parse()->value_labels[ $label_key ][ $key ] ?? '',
1245 'data' => [],
1246 'rawData' => isset( $raw_data[ $key ] ) ? $raw_data[ $key ] : [],
1247 ];
1248
1249 if ( is_array( $data_chunk ) ) {
1250 foreach ( $data_chunk as $data ) {
1251 $set_data[ $key ]['data'][] = $data;
1252 }
1253 } else {
1254 $set_data[ $key ]['data'] = [];
1255 }
1256 }
1257
1258 $chart_args['data']['datasets'] = $set_data;
1259 }
1260
1261 return $chart_args;
1262 }
1263
1264 /**
1265 * Hook to the m_chart_iframe_scripts filter and add additional scripts if needed
1266 *
1267 * @param array $scripts an array of scripts needed for the iframe to render the chart
1268 * @param int $post_id WP post ID of the chart being displayed
1269 *
1270 * @return array $scripts an array of scripts needed for the iframe to render the chart
1271 */
1272 public function m_chart_iframe_scripts( $scripts, $post_id ) {
1273 $library = m_chart()->get_post_meta( $post_id, 'library' );
1274
1275 // If Chart.js isn't the library type we'll stop here
1276 if ( $library != $this->library ) {
1277 return $scripts;
1278 }
1279
1280 $type = m_chart()->get_post_meta( $post_id, 'type' );
1281
1282 $scripts[] = 'chartjs-helper';
1283 $scripts[] = 'chartjs-datalabels';
1284
1285 if ( 'treemap' === $type ) {
1286 $scripts[] = 'chartjs-treemap';
1287 }
1288
1289 if ( 'boxplot' === $type || 'violin' === $type ) {
1290 $scripts[] = 'chartjs-boxplot';
1291 }
1292
1293 // Return the scripts
1294 return $scripts;
1295 }
1296
1297 /**
1298 * Helper function escapes and modifies text/title values
1299 *
1300 * @param string an string you want to use in Chart.js
1301 *
1302 * @return string an escaped and modified string
1303 */
1304 public function esc_title( $string ) {
1305 $string = html_entity_decode( $string, ENT_QUOTES );
1306
1307 $find = [
1308 "\n",
1309 "\r",
1310 '<br><br>',
1311 '—',
1312 '–',
1313 ];
1314
1315 $replace = [
1316 '<br />',
1317 '<br />',
1318 '<br />',
1319 '-',
1320 '-',
1321 ];
1322
1323 $string = str_replace( $find, $replace, $string );
1324
1325 // Strip any markup that entity-decoding may have resurrected
1326 // keep only the <br /> line break markers this method intentionally emits
1327 // wp_strip_all_tags rather than wp_kses since wp_kses re-encodes entities (& becomes &amp;)
1328 // and Chart.js paints these strings with fillText which does no HTML decoding
1329 // The markers hide behind a token during the strip since strip_tags would eat them (and any null byte placeholder)
1330 $string = str_replace( '<br />', '{{m-chart-br}}', $string );
1331 $string = wp_strip_all_tags( $string );
1332 $string = str_replace( '{{m-chart-br}}', '<br />', $string );
1333
1334 return $string;
1335 }
1336
1337 /**
1338 * Helper function sets empty values to NULL so that Chart.js handles them correctly
1339 *
1340 * @param string/int a data value
1341 *
1342 * @return int/null the integer value or NULL if the value was not numeric
1343 */
1344 public function fix_null_values( $value ) {
1345 if ( is_array( $value ) ) {
1346 return array_map( [ $this, 'fix_null_values' ], $value );
1347 }
1348
1349 if ( ! is_numeric( $value ) ) {
1350 return null;
1351 }
1352
1353 return $value;
1354 }
1355
1356 /**
1357 * Detect and build a hierarchical tree config for a treemap from a raw spreadsheet
1358 * Returns null when the sheet is flat (<=2 columns/rows after orientation normalization) so the caller can fall through to the existing flat treemap path
1359 *
1360 * @param array $sheet Raw sheet, array of rows in Jspreadsheet getData() shape
1361 * @param string $parse_in 'rows' or 'columns'
1362 *
1363 * @return array|null { 'tree', 'key', 'groups', 'top_field', 'dataset_prefix', 'dataset_suffix' } or null
1364 */
1365 public function build_treemap_hierarchy( $sheet, $parse_in ) {
1366 if ( ! is_array( $sheet ) || count( $sheet ) < 2 ) {
1367 return null;
1368 }
1369
1370 $rows = ( 'columns' === $parse_in ) ? $this->transpose_sheet( $sheet ) : $sheet;
1371
1372 $rows = array_values( array_filter( $rows, function( $r ) {
1373 if ( ! is_array( $r ) ) {
1374 return false;
1375 }
1376 foreach ( $r as $c ) {
1377 if ( '' !== trim( (string) $c ) ) {
1378 return true;
1379 }
1380 }
1381 return false;
1382 } ) );
1383
1384 if ( count( $rows ) < 2 ) {
1385 return null;
1386 }
1387
1388 // Effective column count: the rightmost column with any non-empty cell across all rows
1389 // Jspreadsheet pads sheets out to a minimum width (37 cols by default)
1390 // A raw max-width reading would include trailing padding columns that aren't real .data
1391 $col_count = 0;
1392 foreach ( $rows as $row ) {
1393 if ( ! is_array( $row ) ) {
1394 continue;
1395 }
1396 for ( $i = count( $row ) - 1; $i >= 0; $i-- ) {
1397 if ( '' !== trim( (string) $row[ $i ] ) ) {
1398 if ( $i + 1 > $col_count ) {
1399 $col_count = $i + 1;
1400 }
1401 break;
1402 }
1403 }
1404 }
1405
1406 if ( $col_count < 3 ) {
1407 return null;
1408 }
1409
1410 // Header detection — empty top-left cell signals "row 0 contains field-name headers"
1411 // Mirrors the LABELS_BOTH convention used by line/bar/scatter chart types
1412 $has_headers = isset( $rows[0][0] ) && '' === trim( (string) $rows[0][0] );
1413
1414 if ( $has_headers ) {
1415 $headers = array_map( function( $h ) { return trim( (string) $h ); }, $rows[0] );
1416 $data_rows = array_slice( $rows, 1 );
1417 $value_field = isset( $headers[ $col_count - 1 ] ) && '' !== $headers[ $col_count - 1 ] ? $headers[ $col_count - 1 ] : 'value';
1418 $group_fields = [];
1419
1420 // Outermost group field (col 0) is always synthesized
1421 // The corner cell IS the empty-headers signal so there's no name there to read
1422 $group_fields[] = 'group_1';
1423
1424 // Inner grouping fields (cols 1..col_count-2) take their names from row 0
1425 for ( $i = 1; $i < $col_count - 1; $i++ ) {
1426 $group_fields[] = isset( $headers[ $i ] ) && '' !== $headers[ $i ] ? $headers[ $i ] : 'group_' . ( $i + 1 );
1427 }
1428 } else {
1429 $data_rows = $rows;
1430 $value_field = 'value';
1431 $group_fields = [];
1432
1433 for ( $i = 0; $i < $col_count - 1; $i++ ) {
1434 $group_fields[] = 'group_' . ( $i + 1 );
1435 }
1436 }
1437
1438 $group_fields = $this->disambiguate_field_names( $group_fields, $value_field );
1439
1440 $tree = [];
1441 $dataset_prefix = '';
1442 $dataset_suffix = '';
1443 $affixes_set = false;
1444
1445 foreach ( $data_rows as $row ) {
1446 $entry = [];
1447 $skip = false;
1448
1449 for ( $i = 0; $i < $col_count - 1; $i++ ) {
1450 $cell = isset( $row[ $i ] ) ? trim( (string) $row[ $i ] ) : '';
1451
1452 if ( '' === $cell ) {
1453 $skip = true;
1454 break;
1455 }
1456
1457 $entry[ $group_fields[ $i ] ] = $cell;
1458 }
1459
1460 if ( $skip ) {
1461 continue;
1462 }
1463
1464 $value_cell = isset( $row[ $col_count - 1 ] ) ? (string) $row[ $col_count - 1 ] : '';
1465 $parsed = m_chart()->parse()->parse_data_point( $value_cell );
1466
1467 if ( null === $parsed->value ) {
1468 continue;
1469 }
1470
1471 $entry[ $value_field ] = (float) $parsed->value;
1472 $entry['prefix'] = $parsed->prefix;
1473 $entry['suffix'] = $parsed->suffix;
1474
1475 if ( ! $affixes_set ) {
1476 $dataset_prefix = $parsed->prefix;
1477 $dataset_suffix = $parsed->suffix;
1478 $affixes_set = true;
1479 }
1480
1481 $tree[] = $entry;
1482 }
1483
1484 if ( empty( $tree ) ) {
1485 return null;
1486 }
1487
1488 return [
1489 'tree' => $tree,
1490 'key' => $value_field,
1491 'groups' => $group_fields,
1492 'top_field' => $group_fields[0],
1493 'dataset_prefix' => $dataset_prefix,
1494 'dataset_suffix' => $dataset_suffix,
1495 ];
1496 }
1497
1498 /**
1499 * Transpose a 2D sheet so columns become rows
1500 * Normalizes parse_in='columns' to a row-oriented shape for hierarchical treemap construction
1501 *
1502 * @param array $sheet array of rows
1503 *
1504 * @return array array of rows (transposed)
1505 */
1506 private function transpose_sheet( $sheet ) {
1507 if ( ! is_array( $sheet ) || empty( $sheet ) ) {
1508 return [];
1509 }
1510
1511 $row_count = count( $sheet );
1512 $col_count = 0;
1513
1514 foreach ( $sheet as $row ) {
1515 if ( is_array( $row ) ) {
1516 $col_count = max( $col_count, count( $row ) );
1517 }
1518 }
1519
1520 $out = [];
1521
1522 for ( $c = 0; $c < $col_count; $c++ ) {
1523 $new_row = [];
1524
1525 for ( $r = 0; $r < $row_count; $r++ ) {
1526 $new_row[] = isset( $sheet[ $r ][ $c ] ) ? $sheet[ $r ][ $c ] : '';
1527 }
1528
1529 $out[] = $new_row;
1530 }
1531
1532 return $out;
1533 }
1534
1535 /**
1536 * Ensure no two field names collide and that none collide with the value field Appends _2, _3, etc. as needed to disambiguate
1537 *
1538 * @param array $group_fields array of group field names
1539 * @param string $value_field the value field name
1540 *
1541 * @return array deduplicated group fields
1542 */
1543 private function disambiguate_field_names( $group_fields, $value_field ) {
1544 $seen = [ $value_field => 1 ];
1545 $out = [];
1546
1547 foreach ( $group_fields as $name ) {
1548 $candidate = $name;
1549 $suffix = 2;
1550
1551 while ( isset( $seen[ $candidate ] ) ) {
1552 $candidate = $name . '_' . $suffix;
1553 $suffix++;
1554 }
1555
1556 $seen[ $candidate ] = 1;
1557
1558 $out[] = $candidate;
1559 }
1560
1561 return $out;
1562 }
1563
1564 /**
1565 * Drop fully-empty rows from a 2D sheet (matches the filter used by build_treemap_hierarchy)
1566 *
1567 * @param array $sheet array of rows
1568 *
1569 * @return array sheet with empty rows removed and indices reset
1570 */
1571 private function strip_empty_rows( $sheet ) {
1572 if ( ! is_array( $sheet ) ) {
1573 return [];
1574 }
1575
1576 return array_values( array_filter( $sheet, function( $row ) {
1577 if ( ! is_array( $row ) ) {
1578 return false;
1579 }
1580
1581 foreach ( $row as $cell ) {
1582 if ( '' !== trim( (string) $cell ) ) {
1583 return true;
1584 }
1585 }
1586
1587 return false;
1588 } ) );
1589 }
1590
1591 /**
1592 * Helper function takes a hex color value and returns an array of RGB values to match
1593 *
1594 * @param string a hex color value
1595 *
1596 * @return array the color as seperate RGB values
1597 */
1598 public function hex_to_rgb( $hex ) {
1599 // Make sure the hex string is a proper hex string
1600 $hex = preg_replace( '#[^0-9A-Fa-f]#', '', $hex );
1601 $rgb = [];
1602
1603 switch ( strlen( $hex ) ) {
1604 case 6:
1605 // If a proper hex code, convert using bitwise operation, no overhead... faster
1606 $color_value = hexdec( $hex );
1607
1608 $rgb['red'] = 0xFF & ( $color_value >> 0x10 );
1609 $rgb['green'] = 0xFF & ( $color_value >> 0x8 );
1610 $rgb['blue'] = 0xFF & $color_value;
1611 break;
1612
1613 case 3:
1614 // If shorthand notation we need to do some string manipulations
1615 $rgb['red'] = hexdec( str_repeat( substr( $hex, 0, 1 ), 2 ) );
1616 $rgb['green'] = hexdec( str_repeat( substr( $hex, 1, 1 ), 2 ) );
1617 $rgb['blue'] = hexdec( str_repeat( substr( $hex, 2, 1 ), 2 ) );
1618 break;
1619
1620 default:
1621 // Invalid hex color code so we return false
1622 return false;
1623 }
1624
1625 return $rgb;
1626 }
1627
1628 /**
1629 * Get all themes available from the various theme directories
1630 *
1631 * @return array an array of themes
1632 */
1633 public function get_themes() {
1634 $themes = [];
1635
1636 foreach ( $this->theme_directories as $directory ) {
1637 $themes = array_merge( $themes, $this->_get_themes_readdir( $directory ) );
1638 }
1639
1640 /**
1641 * Filter the Chart.js theme list before it's returned to admin consumers (chart-editor dropdown, settings-page default-theme select)
1642 * Extensions can append entries shaped like the built-in theme objects
1643 * At minimum `slug` and `name` are required for the dropdowns
1644 * `file` and `options` may be empty if the extension applies the theme via the `m_chart_chart_args` filter instead of a theme file on disk
1645 *
1646 * @param array $themes Array of theme objects ( slug, name, file, options ).
1647 */
1648 return apply_filters( 'm_chart_chartjs_themes', $themes );
1649 }
1650
1651 /**
1652 * Returns the theme options for a given theme
1653 *
1654 * @param string a theme slug
1655 *
1656 * @return string/boolean requested theme options or false if they could not be found
1657 */
1658 private function get_theme( $slug ) {
1659 foreach ( $this->theme_directories as $directory ) {
1660 if ( ! $themes = $this->_get_themes_readdir( $directory ) ) {
1661 continue;
1662 }
1663
1664 foreach ( $themes as $theme ) {
1665 if ( $theme->slug == $slug ) {
1666 return $theme->options;
1667 }
1668 }
1669 }
1670
1671 return false;
1672 }
1673
1674 /**
1675 * Get all themes from a given directory
1676 *
1677 * @param string a path to a server directory
1678 *
1679 * @return array an array of all the themes available in a given directory
1680 */
1681 private function _get_themes_readdir( $theme_base ) {
1682 static $cache = [];
1683
1684 if ( isset( $cache[ $theme_base ] ) ) {
1685 return $cache[ $theme_base ];
1686 }
1687
1688 // Sanity check to make sure we have a real directory
1689 if ( ! is_dir( $theme_base ) ) {
1690 return $cache[ $theme_base ] = [];
1691 }
1692
1693 $theme_dir = new DirectoryIterator( $theme_base );
1694 $themes = [];
1695
1696 foreach ( $theme_dir as $file ) {
1697 if ( ! $file->isFile() || ! preg_match( '#.php$#i', $file->getFilename() ) ) {
1698 continue;
1699 }
1700
1701 $theme_data = implode( '', file( $theme_base . $file ) );
1702
1703 if ( preg_match( '|Theme Name:(.*)$|mi', $theme_data, $name ) ) {
1704 $name = trim( _cleanup_header_comment( $name[1] ) );
1705 }
1706
1707 if ( isset( $name ) && '' != $name ) {
1708 $file = basename( $file );
1709 $options = require $theme_base . $file;
1710
1711 // Skip any theme file that doesn't return an array of options
1712 if ( ! is_array( $options ) ) {
1713 continue;
1714 }
1715
1716 $themes[ $file ] = (object) [
1717 'slug' => substr( $file, 0, -4 ),
1718 'name' => $name,
1719 'file' => $file,
1720 'options' => $options,
1721 ];
1722 }
1723 }
1724
1725 asort( $themes );
1726
1727 $cache[ $theme_base ] = $themes;
1728
1729 return $themes;
1730 }
1731 }
1732