PluginProbe ʕ •ᴥ•ʔ
Independent Analytics – WordPress Analytics Plugin / 2.15.2
Independent Analytics – WordPress Analytics Plugin v2.15.2
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 / real_time_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 2 weeks 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 1 day 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 weeks 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
real_time_controller.js
704 lines
1 import { Controller } from "@hotwired/stimulus";
2 import { Chart, registerables } from "chart.js";
3 import htmlLegendPlugin from "../chart_plugins/html_legend_plugin";
4 import corsairPlugin from "../chart_plugins/corsair_plugin";
5 import { isDarkMode } from "../utils/appearance";
6 import { autoAnimate } from "@formkit/auto-animate";
7
8 Chart.register(...registerables);
9 Chart.defaults.font.family =
10 '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"';
11
12 export default class extends Controller {
13 static targets = [
14 "pauseButton",
15 "chartMinutesButton",
16 "chartTitle",
17 "realTimeChart",
18 "visitorMessage",
19 "viewsList",
20 "referrersList",
21 "geoList",
22 "campaignsList",
23 "devicesList",
24 "summaryFilter",
25 "summaryWithFilterText",
26 ];
27 static values = {
28 chartData: Object,
29 nonce: String,
30 locale: String,
31 startingAt: Number,
32 chartMinutes: Number,
33 isTabVisible: {
34 type: Boolean,
35 default: true,
36 },
37 isLive: {
38 type: Boolean,
39 default: true,
40 },
41 };
42
43 filter = null;
44
45 pausedAt = null;
46
47 connect() {
48 document.addEventListener("visibilitychange", this.handleTabVisibilityChange);
49
50 this.initializeChart();
51
52 this.startInterval()
53 this.restartProgressAnimation();
54
55 autoAnimate(this.viewsListTarget);
56 autoAnimate(this.referrersListTarget);
57 autoAnimate(this.geoListTarget);
58 autoAnimate(this.campaignsListTarget);
59 autoAnimate(this.devicesListTarget);
60 }
61
62 disconnect() {
63 document.removeEventListener("visibilitychange", this.handleTabVisibilityChange);
64 }
65
66 toggleLiveRefreshing() {
67 this.isLiveValue = !this.isLiveValue;
68
69 if (!this.isLiveValue) {
70 this.stopServerPolling();
71 this.pausedAt = this.startingAtValue;
72 return;
73 }
74
75 this.pausedAt = null;
76 this.startServerPolling();
77 }
78
79 startServerPolling() {
80 this.pauseButtonTarget.classList.remove("paused");
81 this.pauseButtonTarget.setAttribute("title", this.pauseButtonTarget.dataset.pauseTitle);
82 this.refresh();
83 }
84
85 stopServerPolling() {
86 clearInterval(this.interval);
87 this.pauseButtonTarget.classList.add("paused");
88 this.pauseButtonTarget.setAttribute("title", this.pauseButtonTarget.dataset.playTitle);
89 }
90
91 async changeGroup(event) {
92 const select = event.currentTarget;
93 const table = select.closest(".most-popular-list").dataset.listId;
94 const group = select.value;
95
96 select.setAttribute("disabled", "disabled");
97
98 const data = {
99 ...iawpActions.save_real_time_preferences,
100 table,
101 group,
102 };
103
104 this.disableUI();
105
106 const response = await fetch(ajaxurl, {
107 method: "POST",
108 body: new URLSearchParams(data),
109 });
110
111 if (response.ok) {
112 await this.refresh();
113 }
114
115 select.removeAttribute("disabled");
116 }
117
118 async changeChartMinutes(event) {
119 const chartMinutes = Number(event.currentTarget.dataset.chartMinutes);
120
121 if (chartMinutes === this.chartMinutesValue) {
122 return;
123 }
124
125 this.chartMinutesButtonTargets.forEach((button) => button.setAttribute("disabled", "disabled"));
126
127 const data = {
128 ...iawpActions.save_real_time_preferences,
129 chartMinutes,
130 };
131
132 const response = await fetch(ajaxurl, {
133 method: "POST",
134 body: new URLSearchParams(data),
135 });
136
137 if (response.ok) {
138 await this.refresh({ animateChart: false });
139 }
140
141 this.chartMinutesButtonTargets.forEach((button) => button.removeAttribute("disabled"));
142 }
143
144 disableUI() {
145 this.element.querySelectorAll(".most-popular-list li button").forEach((button) => {
146 button.setAttribute("disabled", "disabled");
147 });
148
149 this.element.querySelectorAll(".most-popular-list .heading select").forEach((select) => {
150 select.setAttribute("disabled", "disabled");
151 });
152
153 // TODO Where does this safely get reenabled?
154 // this.element.querySelector('.remove-filter')?.setAttribute('disabled', 'disabled');
155 }
156
157 applyFilter(event) {
158 const buttonEl = event.currentTarget;
159 const itemEl = buttonEl.closest("li");
160 const listEl = buttonEl.closest(".most-popular-list");
161
162 if (itemEl.classList.contains("is-current-filter")) {
163 this.filter = null;
164 } else {
165 this.filter = {
166 listId: listEl.dataset.listId,
167 groupId: listEl.dataset.groupId,
168 rowId: itemEl.dataset.id,
169 group: itemEl.dataset.group,
170 name: itemEl.dataset.name,
171 };
172 }
173
174 buttonEl.closest("li").classList.add("is-applying-filter");
175 buttonEl.setAttribute("disabled", "disabled");
176
177 this.disableUI();
178 this.refresh();
179 }
180
181 async removeFilter(event) {
182 const button = event.currentTarget;
183
184 button.classList.add("is-removing");
185 this.filter = null;
186
187 this.disableUI();
188 await this.refresh();
189
190 button.classList.remove("is-removing");
191 }
192
193 async refresh({ animateChart = true } = {}) {
194 clearInterval(this.interval);
195
196 const data = {
197 ...iawpActions.fetch_real_time_data,
198 };
199
200 const filter = this.filter;
201
202 if (filter) {
203 data.listId = filter.listId;
204 data.groupId = filter.groupId;
205 data.rowId = filter.rowId;
206 }
207
208 if (Number.isInteger(this.pausedAt)) {
209 data.startingAt = this.pausedAt;
210 }
211
212 const response = await fetch(ajaxurl, {
213 method: "POST",
214 body: new URLSearchParams(data),
215 });
216
217 if (response.ok) {
218 const { data } = await response.json();
219 data.filter = filter;
220 this.rerender(data, { animateChart });
221 }
222
223 if (!this.pausedAt) {
224 this.startInterval()
225 }
226 }
227
228 startInterval() {
229 clearInterval(this.interval);
230 this.interval = setInterval(() => {
231 this.refresh();
232 }, 10000);
233 }
234
235 handleTabVisibilityChange = () => {
236 this.isTabVisibleValue = !document.hidden;
237
238 if (!this.isTabVisibleValue || !this.isLiveValue) {
239 this.stopServerPolling();
240 return;
241 }
242
243 this.startServerPolling();
244 };
245
246 rerender(data, { animateChart = true } = {}) {
247 this.visitorMessageTarget.textContent = data.visitors;
248 this.startingAtValue = data.starting_at;
249 this.chartMinutesValue = data.chart_minutes;
250 this.updateChartMinutesButtons();
251
252 if (data.filter) {
253 this.summaryWithFilterTextTarget.textContent = data.filter.group + ": " + data.filter.name;
254 this.summaryFilterTarget.classList.add("has-filter");
255 } else {
256 this.summaryFilterTarget.classList.remove("has-filter");
257 }
258
259 this.restartProgressAnimation();
260 this.updateChart(data.chart_data, { animate: animateChart });
261 this.updateMap(data.country_data);
262
263 this.updateList({
264 list: this.viewsListTarget,
265 data: data.lists.views,
266 filter: data.filter,
267 });
268
269 this.updateList({
270 list: this.referrersListTarget,
271 data: data.lists.referrers,
272 filter: data.filter,
273 });
274
275 this.updateList({
276 list: this.geoListTarget,
277 data: data.lists.geo,
278 filter: data.filter,
279 });
280
281 this.updateList({
282 list: this.campaignsListTarget,
283 data: data.lists.campaigns,
284 filter: data.filter,
285 });
286
287 this.updateList({
288 list: this.devicesListTarget,
289 data: data.lists.devices,
290 filter: data.filter,
291 });
292 }
293
294 updateList({ list, data, filter }) {
295 list.querySelector(".group-title").textContent = data.group_title;
296 list.querySelector(".group-count").textContent = "(" + data.count + ")";
297 list.dataset.groupId = data.group_id;
298
299 list.querySelectorAll("li.is-current-filter").forEach((element) => {
300 element.classList.remove("is-current-filter");
301 element.classList.remove("is-applying-filter");
302 });
303
304 const element = list.querySelector("ol");
305 const entries = data.entries;
306
307 // Remove entries that no longer exist
308 const entryIds = new Set(entries.map((entry) => String(entry.id)));
309 const existingItems = new Map(Array.from(element.querySelectorAll("li")).map((li) => [String(li.dataset.id), li]));
310
311 const isEmpty = entries.every((entry) => entry.blank);
312 element.closest(".most-popular-list").querySelector(".most-popular-empty-message").classList.toggle("show", isEmpty);
313
314 existingItems.forEach((li, id) => {
315 if (!entryIds.has(id)) {
316 li.remove();
317 existingItems.delete(id);
318 }
319 });
320
321 // Add entries that are not there
322 entries.forEach((entry) => {
323 const id = String(entry.id);
324
325 if (existingItems.has(id)) {
326 return;
327 }
328
329 const entryElement = this.elementFromEntry(entry);
330 element.appendChild(entryElement);
331 existingItems.set(id, entryElement);
332 });
333
334 // Update existing entries that were there and are still there
335 entries.forEach((entry) => {
336 const id = String(entry.id);
337 const existingItem = existingItems.get(id);
338 const entryElement = this.elementFromEntry(entry);
339
340 existingItem.dataset.id = entryElement.dataset.id;
341 existingItem.dataset.position = entryElement.dataset.position;
342 existingItem.dataset.group = entryElement.dataset.group;
343 existingItem.dataset.name = entryElement.dataset.name;
344 existingItem.replaceChildren(...entryElement.childNodes);
345 });
346
347 // Reorder final list using moveBefore
348 Array.from(element.querySelectorAll("li"))
349 .sort((a, b) => Number(a.dataset.position) - Number(b.dataset.position))
350 .forEach((li, index) => {
351 const currentItem = element.children[index];
352
353 if (li === currentItem) {
354 return;
355 }
356
357 element.moveBefore(li, currentItem);
358 });
359
360 if (filter && data.id == filter.listId && data.group_id == filter.groupId) {
361 const element = list.querySelector(`li[data-id="${this.filter.rowId}"]`);
362
363 if (element) {
364 element.classList.remove("is-applying-filter");
365 element.classList.add("is-current-filter");
366 element.removeAttribute("disabled");
367 }
368 }
369
370 const hasFilter = list.querySelector("li.is-current-filter");
371
372 list.querySelectorAll("select").forEach((element) => {
373 element.toggleAttribute("disabled", hasFilter);
374 });
375
376 if (filter) {
377 element.scrollTo(0, 0);
378 }
379 }
380
381 getLocale() {
382 // Validate the locale
383 try {
384 new Intl.NumberFormat(this.localeValue);
385
386 return this.localeValue;
387 } catch (e) {
388 return "en-US";
389 }
390 }
391
392 elementFromEntry(entry) {
393 const id = entry["id"];
394 const position = entry["position"];
395 const title = entry["title"];
396 const group = entry["group"];
397 const subtitle = entry["subtitle"];
398 const visitors = entry["visitors"];
399 const icon = entry["icon"] ? entry["icon"] : "";
400 const subtitleHTML = subtitle ? `<span class="real-time-subtitle">${subtitle}</span>` : "";
401 const className = (entry["blank"] ?? false) ? "is-blank" : "";
402
403 const li = `
404 <li data-id="${id}" data-position="${position}" data-group="${group}" data-name="${title}" class="${className}">
405 <span class="real-time-position">${position}</span>
406 <button class="list-item-filter" data-action="real-time#applyFilter">
407 <span class="list-item-filter-icons">
408 <span class="dashicons dashicons-filter"></span>
409 <span class="dashicons dashicons-remove"></span>
410 <span class="dashicons dashicons-update iawp-spin"></span>
411 </span>
412 </button>
413 ${icon}
414 <span class="real-time-resource">${title} ${subtitleHTML}</span>
415 <span class="real-time-stat">${visitors}</span>
416 </li>
417 `;
418 const el = document.createElement("div");
419 el.innerHTML = li;
420 return el.firstElementChild;
421 }
422
423 getDatasetData(id) {
424 const timestamps = this.chartDataValue["timestamps"];
425 const values = this.chartDataValue[id];
426
427 return timestamps.map((timestamp, index) => {
428 return { x: index, y: values[index], timestamp };
429 });
430 }
431
432 initializeChart() {
433 const element = this.realTimeChartTarget;
434
435 const datasetOptions = {
436 borderWidth: {
437 bottom: 1,
438 top: 1,
439 left: 1,
440 right: 1,
441 },
442 borderRadius: 2,
443 };
444
445 const data = {
446 labels: this.chartDataValue["long_labels"],
447 datasets: [
448 {
449 id: "views",
450 label: iawpText.views,
451 data: this.getDatasetData("views"),
452 backgroundColor: isDarkMode() ? "#A17FDC" : "rgba(81,35,160,0.6)",
453 borderColor: isDarkMode() ? "#A17FDC" : "rgba(81,35,160,0.6)",
454 ...datasetOptions,
455 },
456 {
457 id: "orders",
458 label: iawpText.orders,
459 data: this.getDatasetData("orders"),
460 backgroundColor: isDarkMode() ? "#68D08F" : "rgba(35, 125, 68, 0.6)",
461 borderColor: isDarkMode() ? "#68D08F" :"rgba(35, 125, 68, 0.6)",
462 ...datasetOptions,
463 },
464 {
465 id: "clicks",
466 label: iawpText.clicks,
467 data: this.getDatasetData("clicks"),
468 backgroundColor: isDarkMode() ? "#61B7F1" : "rgba(52, 152, 219, 0.6)",
469 borderColor: isDarkMode() ? "#61B7F1" : "rgba(52, 152, 219, 0.6)",
470 ...datasetOptions,
471 },
472 {
473 id: "form-submissions",
474 label: iawpText.formSubmissions,
475 data: this.getDatasetData("form_submissions"),
476 backgroundColor: isDarkMode() ? "#FFDF6F" : "hsla(46, 100%, 63%, 0.60)",
477 borderColor: isDarkMode() ? "#FFDF6F" : "rgba(255, 212, 64, 0.6)",
478 ...datasetOptions,
479 },
480 ],
481 };
482
483 const config = {
484 type: "bar",
485 data,
486 options: {
487 locale: this.getLocale(),
488 animation: {
489 duration: 0,
490 },
491 maintainAspectRatio: false,
492 interaction: {
493 intersect: false,
494 mode: "index",
495 },
496 transitions: {
497 realTimeShift: {
498 animations: {
499 numbers: {
500 type: "number",
501 properties: ["x"],
502 duration: 450,
503 easing: "easeOutQuart",
504 },
505 },
506 },
507 },
508 scales: {
509 y: {
510 stacked: true,
511 border: {
512 color: "#DEDAE6",
513 dash: [2, 4],
514 },
515 grid: {
516 tickColor: "#DEDAE6",
517 display: true,
518 drawOnChartArea: true,
519 },
520 beginAtZero: true,
521 suggestedMax: 5,
522 ticks: {
523 color: isDarkMode() ? "#ffffff" : "#6D6A73",
524 precision: 0,
525 },
526 },
527 x: {
528 type: "linear",
529 min: -0.5,
530 max: this.chartDataValue["timestamps"].length - 0.5,
531 stacked: true,
532 border: {
533 color: "#DEDAE6",
534 },
535 grid: {
536 tickColor: "#DEDAE6",
537 display: true,
538 drawOnChartArea: false,
539 },
540 ticks: {
541 color: isDarkMode() ? "#ffffff" : "#6D6A73",
542 autoSkip: false,
543 beginAtZero: true,
544 stepSize: 1,
545 callback: (value) => {
546 return this.chartDataValue["short_labels"][value] ?? "";
547 },
548 },
549 afterBuildTicks: (axis) => {
550 axis.ticks = this.chartDataValue["timestamps"].map((timestamp, index) => ({ value: index }));
551 },
552 },
553 },
554 plugins: {
555 mode: String, // 'light' or 'dark'
556 htmlLegend: {
557 container: element.closest(".chart-container").querySelector(".legend"),
558 shouldAnimate: false,
559 },
560 legend: {
561 display: false,
562 },
563 tooltip: {
564 callbacks: {
565 title: (items) => {
566 return this.chartDataValue["long_labels"][items[0].raw.x] ?? "";
567 },
568 },
569 },
570 corsair: {
571 dash: [2, 4],
572 color: "#777",
573 width: 1,
574 },
575 },
576 },
577 plugins: [corsairPlugin, htmlLegendPlugin],
578 };
579
580 new Chart(element, config);
581 }
582
583 updateChart(chartData, { animate = false } = {}) {
584 const element = this.realTimeChartTarget;
585 const chart = Chart.getChart(element);
586 const shouldAnimate = animate && this.prepareChartShiftAnimation(chart, this.chartDataValue, chartData);
587
588 this.chartDataValue = chartData;
589 chart.data.labels.splice(0, chart.data.labels.length, ...chartData["long_labels"]);
590 chart.options.scales.x.max = chartData["timestamps"].length - 0.5;
591
592 chart.data.datasets.forEach((dataset) => {
593 const dataKey = {
594 views: "views",
595 orders: "orders",
596 clicks: "clicks",
597 "form-submissions": "form_submissions",
598 }[dataset.id];
599
600 if (dataKey) {
601 const nextData = this.getDatasetData(dataKey);
602
603 if (dataset.data.length !== nextData.length) {
604 dataset.data.splice(0, dataset.data.length, ...nextData);
605 return;
606 }
607
608 nextData.forEach((dataPoint, index) => {
609 Object.assign(dataset.data[index], dataPoint);
610 });
611 }
612 });
613
614 chart.update(shouldAnimate ? "realTimeShift" : "none");
615 }
616
617 updateChartMinutesButtons() {
618 this.chartMinutesButtonTargets.forEach((button) => {
619 const isActive = Number(button.dataset.chartMinutes) === this.chartMinutesValue;
620
621 button.classList.toggle("active", isActive);
622
623 if (isActive) {
624 this.chartTitleTarget.textContent = button.dataset.chartTitle;
625 }
626 });
627 }
628
629 prepareChartShiftAnimation(chart, previousChartData, nextChartData) {
630 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
631 return false;
632 }
633
634 if (
635 !Array.isArray(previousChartData?.timestamps) ||
636 !Array.isArray(nextChartData?.timestamps) ||
637 previousChartData.timestamps.length !== nextChartData.timestamps.length
638 ) {
639 return false;
640 }
641
642 const intervalSeconds = Math.abs(nextChartData.timestamps[1] - nextChartData.timestamps[0]);
643 const timestampShift = nextChartData.timestamps[0] - previousChartData.timestamps[0];
644
645 if (timestampShift % intervalSeconds !== 0) {
646 return false;
647 }
648
649 const shiftCount = timestampShift / intervalSeconds;
650
651 if (shiftCount < 1 || shiftCount >= nextChartData.timestamps.length) {
652 return false;
653 }
654
655 chart.data.datasets.forEach((dataset, datasetIndex) => {
656 const meta = chart.getDatasetMeta(datasetIndex);
657 const previousElements = meta.data.map((element) => ({
658 x: element.x,
659 y: element.y,
660 base: element.base,
661 width: element.width,
662 height: element.height,
663 }));
664 const newestPreviousElement = previousElements[previousElements.length - 1];
665 const base = chart.scales.y.getBasePixel();
666 const enterFromX = chart.chartArea.right + (newestPreviousElement?.width ?? 0);
667
668 nextChartData.timestamps.forEach((timestamp, index) => {
669 const previousIndex = index + shiftCount;
670
671 if (previousIndex < previousElements.length) {
672 Object.assign(meta.data[index], previousElements[previousIndex]);
673 return;
674 }
675
676 Object.assign(meta.data[index], {
677 x: enterFromX,
678 y: base,
679 base,
680 width: newestPreviousElement?.width ?? meta.data[index].width,
681 height: 0,
682 });
683 });
684 });
685
686 return true;
687 }
688
689 updateMap(countryData) {
690 document.querySelector('[data-controller="map"]').dataset.mapDataValue = JSON.stringify(countryData);
691 }
692
693 restartProgressAnimation() {
694 const animation = this.element.querySelector(".real-time-progress-bar")?.getAnimations().at(0);
695
696 if (!animation) {
697 return;
698 }
699
700 animation.cancel();
701 animation.play();
702 }
703 }
704