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