PluginProbe ʕ •ᴥ•ʔ
Independent Analytics – WordPress Analytics Plugin / 2.14.2
Independent Analytics – WordPress Analytics Plugin v2.14.2
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 6 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 9 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 6 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
507 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 isPreview: {
21 type: Boolean,
22 default: false,
23 },
24 showLegend: {
25 type: Boolean,
26 default: false,
27 },
28 disableDarkMode: {
29 type: Boolean,
30 default: false,
31 },
32 primaryChartMetricId: String,
33 primaryChartMetricName: String,
34 secondaryChartMetricId: String,
35 secondaryChartMetricName: String,
36 hasMultipleDatasets: Number
37 }
38
39 metricGroups = [{
40 metrics: ['views', 'visitors', 'sessions'],
41 format: 'int'
42 }, {
43 metrics: ['clicks'],
44 format: 'int'
45 }, {
46 metrics: ['average_session_duration'],
47 format: 'time'
48 }, {
49 metrics: ['bounce_rate'],
50 format: 'percent'
51 }, {
52 metrics: ['views_per_session'],
53 format: 'float'
54 }, {
55 metrics: ['wc_orders', 'wc_refunds'],
56 format: 'int'
57 }, {
58 metrics: ['wc_gross_sales', 'wc_refunded_amount', 'wc_net_sales'],
59 format: 'whole_currency'
60 }, {
61 metrics: ['wc_conversion_rate'],
62 format: 'percent'
63 }, {
64 metrics: ['wc_earnings_per_visitor'],
65 format: 'currency'
66 }, {
67 metrics: ['wc_average_order_volume'],
68 format: 'whole_currency'
69 }, {
70 metrics: ['form_submissions'],
71 prefix_to_include: 'form_submissions_for_',
72 format: 'int'
73 }, {
74 metrics: ['form_conversion_rate'],
75 prefix_to_include: 'form_conversion_rate_for_',
76 format: 'percent'
77 }]
78
79
80 connect() {
81 if (!this.isPreviewValue) {
82 this.updateMetricSelectWidth(this.primaryMetricSelectTarget)
83 // Only show the secondary metric select if there is a second metric to select
84 if (this.hasMultipleDatasetsValue === 1) {
85 this.updateMetricSelectWidth(this.secondaryMetricSelectTarget)
86 }
87 }
88 this.createChart()
89 this.updateChart()
90 }
91
92 disconnect() {
93 if(this.chart) {
94 this.chart.destroy()
95 this.chart = null
96 }
97 }
98
99 getLocale() {
100 // Validate the locale
101 try {
102 new Intl.NumberFormat(this.localeValue)
103
104 return this.localeValue
105 } catch (e) {
106 return 'en-US'
107 }
108 }
109
110 hasSecondaryMetric() {
111 return this.hasSecondaryChartMetricIdValue && this.secondaryChartMetricIdValue && this.secondaryChartMetricIdValue !== 'no_comparison'
112 }
113
114 tooltipTitle(tooltip) {
115 const label = JSON.parse(tooltip[0].label)
116
117 return label.tooltipLabel
118 }
119
120 getGroupByMetricId(metricId) {
121 return this.metricGroups.find(group => {
122 return group.metrics.includes(metricId) || (group.prefix_to_include && metricId.startsWith(group.prefix_to_include))
123 })
124 }
125
126 formatValueForMetric(metricId, value) {
127 const group = this.getGroupByMetricId(metricId)
128
129 switch (group.format) {
130 case 'whole_currency':
131 return new Intl.NumberFormat(this.localeValue, {
132 style: 'currency',
133 currency: this.currencyValue,
134 currencyDisplay: 'narrowSymbol',
135 minimumFractionDigits: 0,
136 maximumFractionDigits: 0,
137 }).format(value / 100);
138 case 'currency':
139 return new Intl.NumberFormat(this.localeValue, {
140 style: 'currency',
141 currency: this.currencyValue,
142 currencyDisplay: 'narrowSymbol',
143 minimumFractionDigits: 2,
144 maximumFractionDigits: 2,
145 }).format(value / 100);
146 case 'percent':
147 return new Intl.NumberFormat(this.localeValue, {
148 style: 'percent',
149 maximumFractionDigits: 2,
150 }).format(value / 100);
151 case 'time':
152 const minutes = Math.floor(value / 60);
153 const seconds = value % 60
154
155 return minutes.toString().padStart(2, '0') + ':' + seconds.toString().padStart(2, '0');
156 case 'int':
157 return new Intl.NumberFormat(this.localeValue, {
158 maximumFractionDigits: 0
159 }).format(value);
160 case 'float':
161 return new Intl.NumberFormat(this.localeValue, {
162 maximumFractionDigits: 2
163 }).format(value);
164 default:
165 return value
166 }
167 }
168
169 tooltipLabel = (tooltip) => {
170 if (typeof tooltip.dataset.tooltipLabel === 'function') {
171 return tooltip.dataset.tooltipLabel(tooltip)
172 }
173
174 return tooltip.dataset.label + ': ' + this.formatValueForMetric(tooltip.dataset.id, tooltip.raw)
175 }
176
177 tickText(value) {
178 const label = JSON.parse(this.getLabelForValue(value))
179
180 return label.tick
181 }
182
183 /**
184 * This works because we have a separate hidden select with a single option. When we set the newly
185 * selected option as it's only option, we can see exactly how long the select needs to be
186 */
187 updateMetricSelectWidth(element) {
188 const option = element.options[element.selectedIndex]
189
190 // The intersection observer was added to improve support for the examiner which operates
191 // in an iFrame. Without this, the adaptiveWidthSelectTarget has a width of zero when
192 // this code runs.
193
194 const observer = new IntersectionObserver((entries, observer) => {
195 entries.forEach(entry => {
196 if (entry.isIntersecting) {
197 this.adaptiveWidthSelectTarget[0].innerHTML = option.innerText
198 element.style.width = this.adaptiveWidthSelectTarget.getBoundingClientRect().width + 'px'
199 observer.disconnect()
200 }
201 });
202 });
203
204 observer.observe(this.adaptiveWidthSelectTarget);
205
206 element.parentElement.classList.add('visible');
207 }
208
209 hasSharedAxis(metricId, otherMetricId) {
210 const group = this.getGroupByMetricId(metricId)
211 const otherGroup = this.getGroupByMetricId(otherMetricId)
212
213 return JSON.stringify(group) === JSON.stringify(otherGroup)
214 }
215
216
217 changePrimaryMetric(e) {
218 const element = e.target
219 this.primaryChartMetricIdValue = element.value
220 this.primaryChartMetricNameValue = element.options[element.selectedIndex].innerText
221 this.updateMetricSelectWidth(element)
222 this.updateChart()
223
224 Array.from(this.secondaryMetricSelectTarget.querySelectorAll('option')).forEach((option) => {
225 option.toggleAttribute('disabled', option.value === element.value)
226 })
227
228 document.dispatchEvent(
229 new CustomEvent('iawp:changePrimaryChartMetric', {
230 detail: {
231 primaryChartMetricId: element.value
232 }
233 })
234 )
235 }
236
237 changeSecondaryMetric(e) {
238 const element = e.target
239 const hasSelectedSecondaryMetric = element.value !== ''
240
241 if (hasSelectedSecondaryMetric) {
242 this.secondaryChartMetricIdValue = element.value
243 this.secondaryChartMetricNameValue = element.options[element.selectedIndex].innerText
244 } else {
245 this.secondaryChartMetricIdValue = ''
246 this.secondaryChartMetricNameValue = ''
247 }
248
249 this.updateMetricSelectWidth(element)
250 this.updateChart()
251
252 Array.from(this.primaryMetricSelectTarget.querySelectorAll('option')).forEach((option) => {
253 option.toggleAttribute('disabled', option.value === element.value)
254 })
255
256 document.dispatchEvent(
257 new CustomEvent('iawp:changeSecondaryChartMetric', {
258 detail: {
259 secondaryChartMetricId: hasSelectedSecondaryMetric ? element.value : null
260 }
261 })
262 )
263 }
264
265 updateChart() {
266 const primaryDataset = this.chart.data.datasets[0]
267
268 primaryDataset.id = this.primaryChartMetricIdValue
269 primaryDataset.data = this.dataValue[this.primaryChartMetricIdValue]
270 primaryDataset.label = this.primaryChartMetricNameValue
271
272 const isEmptyPrimaryDataset = primaryDataset.data.every((value) => value === 0)
273 this.chart.options.scales['y'].suggestedMax = isEmptyPrimaryDataset ? 10 : null
274 this.chart.options.scales['y'].beginAtZero = primaryDataset.id !== 'bounce_rate'
275
276 // Always start by removing the secondary dataset
277 if (this.chart.data.datasets.length > 1) {
278 this.chart.data.datasets.pop()
279 }
280
281 if (this.hasSecondaryMetric()) {
282 const id = this.secondaryChartMetricIdValue
283 const name = this.secondaryChartMetricNameValue
284 const data = this.dataValue[id]
285 const axisId = this.hasSharedAxis(this.primaryChartMetricIdValue, id) ? 'y' : 'defaultRight'
286
287 this.chart.data.datasets.push(
288 this.makeDataset(id, name, data, axisId, 'rgba(246,157,10)')
289 )
290
291 const isEmptySecondaryDataset = data.every((value) => value === 0)
292 this.chart.options.scales['defaultRight'].suggestedMax = isEmptySecondaryDataset ? 10 : null
293 this.chart.options.scales['defaultRight'].beginAtZero = id !== 'bounce_rate'
294 }
295
296 this.chart.update();
297 }
298
299 makeDataset(id, name, data, axisId, colorValue, isPrimary = false) {
300 const accentColor = color(colorValue)
301
302 return {
303 id: id,
304 label: name,
305 data: data,
306 borderColor: accentColor.string(),
307 backgroundColor: accentColor.alpha(.1).string(),
308 pointBackgroundColor: accentColor.string(),
309 tension: 0.4,
310 yAxisID: axisId,
311 fill: true,
312 order: isPrimary ? 1 : 0, // Stack orange on top of purple
313 }
314 }
315
316 shouldUseDarkMode() {
317 return isDarkMode() && !this.disableDarkModeValue
318 }
319
320 createChart() {
321 Chart.defaults.font.family = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"';
322 const labels = this.labelsValue
323
324 const primaryMetricDataset = this.makeDataset(
325 this.primaryChartMetricIdValue,
326 this.primaryChartMetricNameValue,
327 this.dataValue[this.primaryChartMetricIdValue],
328 'y',
329 'rgba(108,70,174)',
330 true
331 )
332
333 const data = {
334 labels: labels,
335 datasets: ([
336 primaryMetricDataset
337 ]).filter((dataset) => dataset !== null)
338 };
339
340 const options = {
341 locale: this.getLocale(),
342 maintainAspectRatio: this.isPreviewValue ? false : true,
343 aspectRatio: 3,
344 onResize(chart, { width }) {
345 if(document.documentElement.clientWidth <= 782 && chart.options.aspectRatio === 3) {
346 chart.options.aspectRatio = 1.5
347 chart.update()
348 } else if(document.documentElement.clientWidth > 782 && chart.options.aspectRatio === 1.5) {
349 chart.options.aspectRatio = 3
350 chart.update()
351 }
352 },
353 interaction: {
354 intersect: false,
355 mode: 'index'
356 },
357 scales: {
358 y: {
359 border: {
360 color: '#DEDAE6',
361 dash: [2, 4]
362 },
363 grid: {
364 color: this.shouldUseDarkMode() ? '#676173' : '#DEDAE6',
365 tickColor: '#DEDAE6',
366 display: true,
367 drawOnChartArea: true,
368 },
369 beginAtZero: true,
370 suggestedMax: null,
371 ticks: {
372 color: this.shouldUseDarkMode() ? '#ffffff' : '#6D6A73',
373 font: {
374 size: 14,
375 weight: 400,
376 },
377 precision: 0,
378 callback: (value, index, values) => {
379 return this.formatValueForMetric(this.primaryChartMetricIdValue, value)
380 },
381 },
382 },
383 defaultRight: {
384 position: 'right',
385 display: 'auto',
386 border: {
387 color: '#DEDAE6',
388 dash: [2, 4]
389 },
390 grid: {
391 color: this.shouldUseDarkMode() ? '#9a95a6' : '#DEDAE6',
392 tickColor: '#DEDAE6',
393 display: true,
394 drawOnChartArea: false,
395 },
396 beginAtZero: true,
397 suggestedMax: null,
398 ticks: {
399 color: this.shouldUseDarkMode() ? '#ffffff' : '#6D6A73',
400 font: {
401 size: 14,
402 weight: 400,
403 },
404 precision: 0,
405 callback: (value, index, values) => {
406 if (this.hasSecondaryMetric()) {
407 return this.formatValueForMetric(this.secondaryChartMetricIdValue, value)
408 } else {
409 return value
410 }
411 },
412 },
413 },
414 x: {
415 border: {
416 color: '#DEDAE6',
417 },
418 grid: {
419 tickColor: '#DEDAE6',
420 display: true,
421 drawOnChartArea: false,
422 },
423 ticks: {
424 color: this.shouldUseDarkMode() ? '#ffffff' : '#6D6A73',
425 autoSkip: true,
426 autoSkipPadding: 16,
427 maxRotation: 0,
428 // maxTicksLimit: 20,
429 font: {
430 size: 14,
431 weight: 400
432 },
433 callback: this.tickText
434 },
435 },
436 },
437 plugins: {
438 mode: String, // 'light' or 'dark'
439 legend: {
440 display: this.showLegendValue,
441 align: 'start',
442 labels: {
443 boxHeight: 14,
444 boxWidth: 14,
445 useBorderRadius: true,
446 borderRadius: 7,
447 padding: 8,
448 color: this.shouldUseDarkMode() ? '#DEDAE6' : '#676173',
449 generateLabels: (chart) => {
450 const original = Chart.defaults.plugins.legend.labels.generateLabels(chart);
451 return original.map(label => ({
452 ...label,
453 fillStyle: label.strokeStyle,
454 lineWidth: 0,
455 }));
456 }
457 }
458 },
459 corsair: {
460 dash: [2, 4],
461 color: '#777',
462 width: 1
463 },
464 tooltip: {
465 itemSort: (a, b) => {
466 return a.datasetIndex < b.datasetIndex ? -1 : 1
467 },
468 callbacks: {
469 title: this.tooltipTitle,
470 label: this.tooltipLabel,
471 }
472 }
473 },
474 elements: {
475 point: {
476 radius: 4
477 }
478 }
479 }
480
481 const config = {
482 type: 'line',
483 data: data,
484 options: options,
485 plugins: [
486 corsairPlugin,
487 {
488 beforeInit(chart) {
489 const originalFit = chart.legend.fit;
490
491 chart.legend.fit = function () {
492 // Call the original function and bind scope in order to use `this` correctly inside it
493 originalFit.bind(chart.legend)();
494
495 this.height += 16;
496 }
497 }
498 }
499 ],
500 };
501
502 if(!this.chart) {
503 this.chart = new Chart(this.canvasTarget, config);
504 }
505 }
506 }
507