PluginProbe
M Chart / 2.3.1
M Chart v2.3.1
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.3.1, at components/class-m-chart-chartjs.php

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