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