PluginProbe ʕ •ᴥ•ʔ
Independent Analytics – WordPress Analytics Plugin / 2.14.10
Independent Analytics – WordPress Analytics Plugin v2.14.10
2.15.0 2.14.10 trunk 1.1 1.10 1.10.1 1.11 1.12 1.13 1.14 1.15 1.16 1.17 1.17.1 1.17.2 1.17.3 1.17.4 1.18 1.18.1 1.19.0 1.19.1 1.2 1.20.0 1.21.0 1.22.0 1.22.1 1.23.0 1.23.1 1.24.0 1.24.1 1.25.0 1.25.1 1.26.0 1.27.0 1.28.0 1.28.1 1.28.2 1.28.3 1.29.0 1.3 1.30.0 1.30.1 1.4 1.5 1.6 1.7 1.8 1.9 2.0.0 2.0.1 2.1.4 2.1.5 2.1.6 2.10.0 2.10.1 2.10.2 2.10.3 2.10.4 2.11.0 2.11.1 2.11.10 2.11.2 2.11.3 2.11.4 2.11.5 2.11.6 2.11.7 2.11.8 2.11.9 2.12.0 2.12.1 2.12.2 2.13.1 2.13.2 2.13.5 2.13.6 2.14.0 2.14.1 2.14.2 2.14.4 2.14.6 2.14.7 2.14.8 2.14.9 2.2.0 2.2.1 2.3.1 2.3.2 2.4.2 2.4.3 2.5.0 2.5.1 2.6.0 2.6.1 2.6.2 2.6.3 2.6.4 2.7.0 2.7.1 2.7.2 2.7.3 2.8.2 2.8.3 2.8.4 2.8.5 2.8.6 2.8.7 2.8.8 2.8.9 2.9.2 2.9.3 2.9.4 2.9.5 2.9.6 2.9.7
independent-analytics / javascript-source / controllers / chart_controller.js
independent-analytics / javascript-source / controllers Last commit date
overview 1 year ago campaign_builder_controller.js 1 year ago chart_controller.js 2 months ago chart_interval_controller.js 2 years ago clipboard_controller.js 1 year ago copy_report_controller.js 2 years ago create_report_controller.js 1 year ago delete_data_controller.js 1 year ago delete_report_controller.js 2 years ago easepick_controller.js 2 years ago examiner_controller.js 2 months ago examiner_header_controller.js 11 months ago export_overview_controller.js 9 months ago export_reports_controller.js 2 years ago filters_controller.js 6 months ago group_controller.js 2 years ago import_reports_controller.js 2 years ago journey_controller.js 6 months ago map_controller.js 9 months ago migration_redirect_controller.js 2 years ago modal_controller.js 2 years ago pause_emails_controller.js 6 months ago pie_chart_controller.js 6 months ago plugin_group_options_controller.js 1 year ago pruner_controller.js 5 months ago quick_stats_controller.js 1 year ago real_time_controller.js 6 months ago refresh_overview_controller.js 1 year ago rename_report_controller.js 2 years ago report_controller.js 2 months ago reset_analytics_controller.js 1 year ago reset_overview_controller.js 1 year ago save_report_controller.js 2 years ago select_input_controller.js 2 years ago set_favorite_report_controller.js 2 years ago sort_controller.js 2 years ago sortable_reports_controller.js 2 years ago table_columns_controller.js 2 years ago tooltip_controller.js 6 months ago woocommerce_settings_controller.js 1 year ago
chart_controller.js
545 lines
1 import { Controller } from "@hotwired/stimulus";
2 import corsairPlugin from "../chart_plugins/corsair_plugin";
3 import { Chart, registerables } from "chart.js";
4 import color from "color";
5 import { isDarkMode } from "../utils/appearance";
6
7 Chart.register(...registerables);
8
9 export default class extends Controller {
10 static targets = ["canvas", "primaryMetricSelect", "secondaryMetricSelect", "adaptiveWidthSelect"];
11
12 static values = {
13 labels: Array,
14 data: Object,
15 locale: String,
16 currency: {
17 type: String,
18 default: "USD",
19 },
20 isSkeleton: {
21 type: Boolean,
22 default: false,
23 },
24 isPreview: {
25 type: Boolean,
26 default: false,
27 },
28 showLegend: {
29 type: Boolean,
30 default: false,
31 },
32 disableDarkMode: {
33 type: Boolean,
34 default: false,
35 },
36 primaryChartMetricId: String,
37 primaryChartMetricName: String,
38 secondaryChartMetricId: String,
39 secondaryChartMetricName: String,
40 hasMultipleDatasets: Number,
41 };
42
43 metricGroups = [
44 {
45 metrics: ["views", "visitors", "sessions"],
46 format: "int",
47 },
48 {
49 metrics: ["clicks"],
50 format: "int",
51 },
52 {
53 metrics: ["average_session_duration"],
54 format: "time",
55 },
56 {
57 metrics: ["bounce_rate"],
58 format: "percent",
59 },
60 {
61 metrics: ["views_per_session"],
62 format: "float",
63 },
64 {
65 metrics: ["wc_orders", "wc_refunds"],
66 format: "int",
67 },
68 {
69 metrics: ["wc_gross_sales", "wc_refunded_amount", "wc_net_sales"],
70 format: "whole_currency",
71 },
72 {
73 metrics: ["wc_conversion_rate"],
74 format: "percent",
75 },
76 {
77 metrics: ["wc_earnings_per_visitor"],
78 format: "currency",
79 },
80 {
81 metrics: ["wc_average_order_volume"],
82 format: "whole_currency",
83 },
84 {
85 metrics: ["form_submissions"],
86 prefix_to_include: "form_submissions_for_",
87 format: "int",
88 },
89 {
90 metrics: ["form_conversion_rate"],
91 prefix_to_include: "form_conversion_rate_for_",
92 format: "percent",
93 },
94 ];
95
96 connect() {
97 if (!this.isPreviewValue) {
98 this.updateMetricSelectWidth(this.primaryMetricSelectTarget);
99 // Only show the secondary metric select if there is a second metric to select
100 if (this.hasMultipleDatasetsValue === 1) {
101 this.updateMetricSelectWidth(this.secondaryMetricSelectTarget);
102 }
103 }
104 this.createChart();
105 this.updateChart();
106 }
107
108 disconnect() {
109 clearInterval(this.loadingInterval);
110 if (this.chart) {
111 this.chart.destroy();
112 this.chart = null;
113 }
114 }
115
116 getLocale() {
117 // Validate the locale
118 try {
119 new Intl.NumberFormat(this.localeValue);
120
121 return this.localeValue;
122 } catch (e) {
123 return "en-US";
124 }
125 }
126
127 hasSecondaryMetric() {
128 return this.hasSecondaryChartMetricIdValue && this.secondaryChartMetricIdValue && this.secondaryChartMetricIdValue !== "no_comparison";
129 }
130
131 tooltipTitle(tooltip) {
132 const label = JSON.parse(tooltip[0].label);
133
134 return label.tooltipLabel;
135 }
136
137 getGroupByMetricId(metricId) {
138 return this.metricGroups.find((group) => {
139 return group.metrics.includes(metricId) || (group.prefix_to_include && metricId.startsWith(group.prefix_to_include));
140 });
141 }
142
143 formatValueForMetric(metricId, value) {
144 const group = this.getGroupByMetricId(metricId);
145
146 switch (group.format) {
147 case "whole_currency":
148 return new Intl.NumberFormat(this.localeValue, {
149 style: "currency",
150 currency: this.currencyValue,
151 currencyDisplay: "narrowSymbol",
152 minimumFractionDigits: 0,
153 maximumFractionDigits: 0,
154 }).format(value / 100);
155 case "currency":
156 return new Intl.NumberFormat(this.localeValue, {
157 style: "currency",
158 currency: this.currencyValue,
159 currencyDisplay: "narrowSymbol",
160 minimumFractionDigits: 2,
161 maximumFractionDigits: 2,
162 }).format(value / 100);
163 case "percent":
164 return new Intl.NumberFormat(this.localeValue, {
165 style: "percent",
166 maximumFractionDigits: 2,
167 }).format(value / 100);
168 case "time":
169 const minutes = Math.floor(value / 60);
170 const seconds = value % 60;
171
172 return minutes.toString().padStart(2, "0") + ":" + seconds.toString().padStart(2, "0");
173 case "int":
174 return new Intl.NumberFormat(this.localeValue, {
175 maximumFractionDigits: 0,
176 }).format(value);
177 case "float":
178 return new Intl.NumberFormat(this.localeValue, {
179 maximumFractionDigits: 2,
180 }).format(value);
181 default:
182 return value;
183 }
184 }
185
186 tooltipLabel = (tooltip) => {
187 if (typeof tooltip.dataset.tooltipLabel === "function") {
188 return tooltip.dataset.tooltipLabel(tooltip);
189 }
190
191 return tooltip.dataset.label + ": " + this.formatValueForMetric(tooltip.dataset.id, tooltip.raw);
192 };
193
194 tickText(value) {
195 const label = JSON.parse(this.getLabelForValue(value));
196
197 return label.tick;
198 }
199
200 /**
201 * This works because we have a separate hidden select with a single option. When we set the newly
202 * selected option as it's only option, we can see exactly how long the select needs to be
203 */
204 updateMetricSelectWidth(element) {
205 const option = element.options[element.selectedIndex];
206
207 // The intersection observer was added to improve support for the examiner which operates
208 // in an iFrame. Without this, the adaptiveWidthSelectTarget has a width of zero when
209 // this code runs.
210
211 const observer = new IntersectionObserver((entries, observer) => {
212 entries.forEach((entry) => {
213 if (entry.isIntersecting) {
214 this.adaptiveWidthSelectTarget[0].innerHTML = option.innerText;
215 element.style.width = this.adaptiveWidthSelectTarget.getBoundingClientRect().width + "px";
216 observer.disconnect();
217 }
218 });
219 });
220
221 observer.observe(this.adaptiveWidthSelectTarget);
222
223 element.parentElement.classList.add("visible");
224 }
225
226 hasSharedAxis(metricId, otherMetricId) {
227 const group = this.getGroupByMetricId(metricId);
228 const otherGroup = this.getGroupByMetricId(otherMetricId);
229
230 return JSON.stringify(group) === JSON.stringify(otherGroup);
231 }
232
233 changePrimaryMetric(e) {
234 const element = e.target;
235 this.primaryChartMetricIdValue = element.value;
236 this.primaryChartMetricNameValue = element.options[element.selectedIndex].innerText;
237 this.updateMetricSelectWidth(element);
238 this.updateChart();
239
240 Array.from(this.secondaryMetricSelectTarget.querySelectorAll("option")).forEach((option) => {
241 option.toggleAttribute("disabled", option.value === element.value);
242 });
243
244 document.dispatchEvent(
245 new CustomEvent("iawp:changePrimaryChartMetric", {
246 detail: {
247 primaryChartMetricId: element.value,
248 },
249 }),
250 );
251 }
252
253 changeSecondaryMetric(e) {
254 const element = e.target;
255 const hasSelectedSecondaryMetric = element.value !== "";
256
257 if (hasSelectedSecondaryMetric) {
258 this.secondaryChartMetricIdValue = element.value;
259 this.secondaryChartMetricNameValue = element.options[element.selectedIndex].innerText;
260 } else {
261 this.secondaryChartMetricIdValue = "";
262 this.secondaryChartMetricNameValue = "";
263 }
264
265 this.updateMetricSelectWidth(element);
266 this.updateChart();
267
268 Array.from(this.primaryMetricSelectTarget.querySelectorAll("option")).forEach((option) => {
269 option.toggleAttribute("disabled", option.value === element.value);
270 });
271
272 document.dispatchEvent(
273 new CustomEvent("iawp:changeSecondaryChartMetric", {
274 detail: {
275 secondaryChartMetricId: hasSelectedSecondaryMetric ? element.value : null,
276 },
277 }),
278 );
279 }
280
281 updateChart() {
282 const primaryDataset = this.chart.data.datasets[0];
283
284 primaryDataset.id = this.primaryChartMetricIdValue;
285 primaryDataset.label = this.primaryChartMetricNameValue;
286 primaryDataset.data = this.dataValue[this.primaryChartMetricIdValue];
287
288 const isEmptyPrimaryDataset = primaryDataset.data.every((value) => value === 0);
289 this.chart.options.scales["y"].suggestedMax = isEmptyPrimaryDataset ? 10 : null;
290 this.chart.options.scales["y"].beginAtZero = primaryDataset.id !== "bounce_rate";
291
292 if (this.isSkeletonValue) {
293 this.chart.options.scales["y"].suggestedMax = 10;
294
295 const loadingPattern = [2, 4, 6, 6, 4, 2];
296
297 primaryDataset.data = primaryDataset.data.map(function (value, index) {
298 return loadingPattern[index % loadingPattern.length];
299 });
300
301 clearInterval(this.loadingInterval);
302 this.loadingInterval = setInterval(() => {
303 loadingPattern.unshift(loadingPattern.pop());
304
305 primaryDataset.data = primaryDataset.data.map(function (value, index) {
306 return loadingPattern[index % loadingPattern.length];
307 });
308
309 this.chart.update();
310 }, 1500);
311 }
312
313 // Always start by removing the secondary dataset
314 if (this.chart.data.datasets.length > 1) {
315 this.chart.data.datasets.pop();
316 }
317
318 if (this.hasSecondaryMetric() && !this.isSkeletonValue) {
319 const id = this.secondaryChartMetricIdValue;
320 const name = this.secondaryChartMetricNameValue;
321 const data = this.dataValue[id];
322 const axisId = this.hasSharedAxis(this.primaryChartMetricIdValue, id) ? "y" : "defaultRight";
323
324 this.chart.data.datasets.push(this.makeDataset(id, name, data, axisId, "rgba(246,157,10)"));
325
326 const isEmptySecondaryDataset = data.every((value) => value === 0);
327 this.chart.options.scales["defaultRight"].suggestedMax = isEmptySecondaryDataset ? 10 : null;
328 this.chart.options.scales["defaultRight"].beginAtZero = id !== "bounce_rate";
329 }
330
331 this.chart.update();
332 }
333
334 makeDataset(id, name, data, axisId, colorValue, isPrimary = false) {
335 const accentColor = color(colorValue);
336
337 return {
338 id: id,
339 label: name,
340 data: data,
341 borderColor: accentColor.string(),
342 backgroundColor: accentColor.alpha(0.1).string(),
343 pointBackgroundColor: accentColor.string(),
344 tension: 0.4,
345 yAxisID: axisId,
346 fill: true,
347 order: isPrimary ? 1 : 0, // Stack orange on top of purple
348 };
349 }
350
351 shouldUseDarkMode() {
352 return isDarkMode() && !this.disableDarkModeValue;
353 }
354
355 createChart() {
356 Chart.defaults.font.family =
357 '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"';
358 const labels = this.labelsValue;
359
360 const primaryMetricDataset = this.makeDataset(
361 this.primaryChartMetricIdValue,
362 this.primaryChartMetricNameValue,
363 this.dataValue[this.primaryChartMetricIdValue],
364 "y",
365 this.isSkeletonValue ? "rgba(247, 245, 250)" : "rgba(108,70,174)",
366 true,
367 );
368
369 const data = {
370 labels: labels,
371 datasets: [primaryMetricDataset].filter((dataset) => dataset !== null),
372 };
373
374 const options = {
375 locale: this.getLocale(),
376 maintainAspectRatio: this.isPreviewValue ? false : true,
377 aspectRatio: 3,
378 onResize(chart, { width }) {
379 if (document.documentElement.clientWidth <= 782 && chart.options.aspectRatio === 3) {
380 chart.options.aspectRatio = 1.5;
381 chart.update();
382 } else if (document.documentElement.clientWidth > 782 && chart.options.aspectRatio === 1.5) {
383 chart.options.aspectRatio = 3;
384 chart.update();
385 }
386 },
387 hover: {
388 mode: this.isSkeletonValue ? null : "nearest",
389 },
390 interaction: {
391 intersect: false,
392 mode: "index",
393 },
394 scales: {
395 y: {
396 border: {
397 color: "#DEDAE6",
398 dash: [2, 4],
399 },
400 grid: {
401 color: this.shouldUseDarkMode() ? "#676173" : "#DEDAE6",
402 tickColor: "#DEDAE6",
403 display: true,
404 drawOnChartArea: true,
405 },
406 beginAtZero: true,
407 suggestedMax: null,
408 ticks: {
409 color: this.shouldUseDarkMode() ? "#ffffff" : "#6D6A73",
410 font: {
411 size: 14,
412 weight: 400,
413 },
414 precision: 0,
415 callback: (value, index, values) => {
416 return this.formatValueForMetric(this.primaryChartMetricIdValue, value);
417 },
418 },
419 },
420 defaultRight: {
421 position: "right",
422 display: "auto",
423 border: {
424 color: "#DEDAE6",
425 dash: [2, 4],
426 },
427 grid: {
428 color: this.shouldUseDarkMode() ? "#9a95a6" : "#DEDAE6",
429 tickColor: "#DEDAE6",
430 display: true,
431 drawOnChartArea: false,
432 },
433 beginAtZero: true,
434 suggestedMax: null,
435 ticks: {
436 color: this.shouldUseDarkMode() ? "#ffffff" : "#6D6A73",
437 font: {
438 size: 14,
439 weight: 400,
440 },
441 precision: 0,
442 callback: (value, index, values) => {
443 if (this.hasSecondaryMetric()) {
444 return this.formatValueForMetric(this.secondaryChartMetricIdValue, value);
445 } else {
446 return value;
447 }
448 },
449 },
450 },
451 x: {
452 border: {
453 color: "#DEDAE6",
454 },
455 grid: {
456 tickColor: "#DEDAE6",
457 display: true,
458 drawOnChartArea: false,
459 },
460 ticks: {
461 color: this.shouldUseDarkMode() ? "#ffffff" : "#6D6A73",
462 autoSkip: true,
463 autoSkipPadding: 16,
464 maxRotation: 0,
465 // maxTicksLimit: 20,
466 font: {
467 size: 14,
468 weight: 400,
469 },
470 callback: this.tickText,
471 },
472 },
473 },
474 plugins: {
475 mode: String, // 'light' or 'dark'
476 legend: {
477 display: this.showLegendValue,
478 align: "start",
479 labels: {
480 boxHeight: 14,
481 boxWidth: 14,
482 useBorderRadius: true,
483 borderRadius: 7,
484 padding: 8,
485 color: this.shouldUseDarkMode() ? "#DEDAE6" : "#676173",
486 generateLabels: (chart) => {
487 const original = Chart.defaults.plugins.legend.labels.generateLabels(chart);
488 return original.map((label) => ({
489 ...label,
490 fillStyle: label.strokeStyle,
491 lineWidth: 0,
492 }));
493 },
494 },
495 },
496 corsair: {
497 dash: [2, 4],
498 color: "#777",
499 width: 1,
500 },
501 tooltip: {
502 enabled: this.isSkeletonValue ? false : true,
503 itemSort: (a, b) => {
504 return a.datasetIndex < b.datasetIndex ? -1 : 1;
505 },
506 callbacks: {
507 title: this.tooltipTitle,
508 label: this.tooltipLabel,
509 },
510 },
511 },
512 elements: {
513 point: {
514 radius: 4,
515 },
516 },
517 };
518
519 const config = {
520 type: "line",
521 data: data,
522 options: options,
523 plugins: [
524 corsairPlugin,
525 {
526 beforeInit(chart) {
527 const originalFit = chart.legend.fit;
528
529 chart.legend.fit = function () {
530 // Call the original function and bind scope in order to use `this` correctly inside it
531 originalFit.bind(chart.legend)();
532
533 this.height += 16;
534 };
535 },
536 },
537 ],
538 };
539
540 if (!this.chart) {
541 this.chart = new Chart(this.canvasTarget, config);
542 }
543 }
544 }
545