dashboard.js 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815
  1. /**
  2. * Copyright (c) UNA, Inc - https://una.io
  3. * MIT License - https://opensource.org/licenses/MIT
  4. *
  5. * @defgroup Analytics Analytics
  6. * @ingroup UnaModules
  7. *
  8. * @{
  9. */
  10. /**
  11. * Analytics blocks on a page share one set of filters kept in the page URL: range, tz,
  12. * platform, f[dimension] and the chart metric. Changing a filter reloads every block
  13. * from the server, which renders it from the same templates as the first page load.
  14. * A block's own state (its tab, its page) is kept in the URL too, as tab_<name> and
  15. * page_<name>, so a reload or a shared link shows the same thing.
  16. */
  17. function BxAnalyticsDashboard(oOptions) {
  18. this._sActionUrl = oOptions.sActionUrl;
  19. this._aStrings = oOptions.aStrings || {};
  20. var $this = this;
  21. if (document.readyState === 'loading')
  22. document.addEventListener('DOMContentLoaded', function () { $this.init(); });
  23. else
  24. this.init();
  25. }
  26. BxAnalyticsDashboard.prototype.init = function () {
  27. var $this = this;
  28. document.addEventListener('change', function (oEvent) {
  29. var oSelect = oEvent.target.closest('[data-bx-analytics-param]');
  30. if (oSelect)
  31. $this.setParam(oSelect.getAttribute('data-bx-analytics-param'), oSelect.value);
  32. });
  33. document.addEventListener('click', function (oEvent) {
  34. var oTarget, oBlock, sName;
  35. if ((oTarget = oEvent.target.closest('[data-bx-analytics-metric]'))) {
  36. $this.selectMetric(oTarget);
  37. return;
  38. }
  39. if ((oTarget = oEvent.target.closest('[data-bx-analytics-tab]'))) {
  40. oBlock = oTarget.closest('[data-bx-analytics-block]');
  41. sName = $this.getBlockName(oBlock);
  42. $this.setUrlParam('tab_' + sName, oTarget.getAttribute('data-bx-analytics-tab'));
  43. $this.setUrlParam('page_' + sName, null);
  44. $this.reload(oBlock, true).catch(function () {}); // reload() shows its own failure
  45. return;
  46. }
  47. if ((oTarget = oEvent.target.closest('[data-bx-analytics-pager]'))) {
  48. oBlock = oTarget.closest('[data-bx-analytics-block]');
  49. $this.setUrlParam('page_' + oTarget.getAttribute('data-bx-analytics-pager'), oTarget.getAttribute('data-bx-analytics-page'));
  50. $this.reload(oBlock, false).catch(function () {}); // reload() shows its own failure
  51. return;
  52. }
  53. if ((oTarget = oEvent.target.closest('[data-bx-analytics-open]'))) {
  54. oBlock = oTarget.closest('[data-bx-analytics-block]');
  55. sName = oTarget.getAttribute('data-bx-analytics-open');
  56. var sValue = oTarget.getAttribute('data-bx-analytics-value');
  57. $this.setUrlParam(sName, sValue);
  58. $this.reload(oBlock, false).then(function (oNew) {
  59. $this.focusAfterOpen(oNew, sName, sValue);
  60. }, function () {}); // reload() shows its own failure
  61. return;
  62. }
  63. if ((oTarget = oEvent.target.closest('[data-bx-analytics-action]'))) {
  64. $this.runAction(oTarget);
  65. return;
  66. }
  67. if ((oTarget = oEvent.target.closest('[data-bx-analytics-filter]'))) {
  68. // the platform has a parameter of its own (the filter bar's select), not f[platform]
  69. sName = oTarget.getAttribute('data-bx-analytics-filter');
  70. $this.setParam(sName === 'platform' ? 'platform' : 'f[' + sName + ']', oTarget.getAttribute('data-bx-analytics-value'));
  71. return;
  72. }
  73. if ((oTarget = oEvent.target.closest('[data-bx-analytics-unfilter]')))
  74. $this.setParam('f[' + oTarget.getAttribute('data-bx-analytics-unfilter') + ']', null);
  75. });
  76. document.addEventListener('submit', function (oEvent) {
  77. var oForm = oEvent.target.closest('[data-bx-analytics-form]');
  78. if (!oForm)
  79. return;
  80. oEvent.preventDefault();
  81. $this.submitForm(oForm);
  82. });
  83. // arrow keys move between tabs, as in any tab list
  84. document.addEventListener('keydown', function (oEvent) {
  85. var oTab = oEvent.target.closest('[data-bx-analytics-tab]');
  86. if (!oTab || (oEvent.key !== 'ArrowRight' && oEvent.key !== 'ArrowLeft'))
  87. return;
  88. var aTabs = Array.prototype.slice.call(oTab.parentNode.querySelectorAll('[data-bx-analytics-tab]'));
  89. var iNext = (aTabs.indexOf(oTab) + (oEvent.key === 'ArrowRight' ? 1 : -1) + aTabs.length) % aTabs.length;
  90. aTabs[iNext].focus();
  91. aTabs[iNext].click();
  92. oEvent.preventDefault();
  93. });
  94. // a focused tab in a row that scrolls is moved clear of the row's faded ends; focus()
  95. // alone scrolls only a tab that is out of sight, not one that is cut off
  96. document.addEventListener('focusin', function (oEvent) {
  97. var oTab = oEvent.target.closest('[data-bx-analytics-tab]');
  98. if (oTab)
  99. $this.showTab(oTab);
  100. });
  101. this.drawCharts(document);
  102. this.drawMaps(document);
  103. this.showSelectedTabs(document);
  104. // blocks that follow live data reload themselves while the page is visible
  105. setInterval(function () {
  106. if (document.visibilityState !== 'visible')
  107. return;
  108. document.querySelectorAll('[data-bx-analytics-refresh]').forEach(function (oRefresh) {
  109. var oBlock = oRefresh.closest('[data-bx-analytics-block]');
  110. var iEvery = 1000 * parseInt(oRefresh.getAttribute('data-bx-analytics-refresh'), 10);
  111. if (oBlock && !oBlock._bxAnalyticsBusy && Date.now() - (oBlock._bxAnalyticsLoaded || $this._iStarted) >= iEvery)
  112. $this.reload(oBlock, false, true).catch(function () {}); // a quiet refresh tries again later
  113. });
  114. }, 5000);
  115. this._iStarted = Date.now();
  116. // without a timezone in the page URL the server takes the viewer's from a cookie, kept
  117. // here; only a page drawn before the cookie was set (or in another zone) loads again
  118. var sTz = this.getTimezone();
  119. var oUrl = new URL(window.location.href);
  120. if (!oUrl.searchParams.get('tz') && sTz) {
  121. var sRendered = this.getTimezoneCookie() || 'UTC';
  122. this.setTimezoneCookie(sTz);
  123. if (sTz !== sRendered) {
  124. this.setUrlParam('tz', sTz);
  125. this.reloadAll();
  126. }
  127. }
  128. };
  129. /**
  130. * The timezone cookie the server reads when a request has no `tz` (BxAnalyticsFilter).
  131. */
  132. BxAnalyticsDashboard.prototype.getTimezoneCookie = function () {
  133. var aMatch = document.cookie.match(/(?:^|;\s*)bx_analytics_tz=([^;]*)/);
  134. try {
  135. return aMatch ? decodeURIComponent(aMatch[1]) : '';
  136. } catch (e) {
  137. return '';
  138. }
  139. };
  140. BxAnalyticsDashboard.prototype.setTimezoneCookie = function (sTz) {
  141. document.cookie = 'bx_analytics_tz=' + encodeURIComponent(sTz) + '; path=/; max-age=31536000; SameSite=Lax' + (window.location.protocol === 'https:' ? '; Secure' : '');
  142. };
  143. BxAnalyticsDashboard.prototype.getTimezone = function () {
  144. try {
  145. return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
  146. } catch (e) {
  147. return 'UTC';
  148. }
  149. };
  150. /**
  151. * A block's own parameters (group, table...), set by the server.
  152. */
  153. BxAnalyticsDashboard.prototype.getBlockParams = function (oBlock) {
  154. try {
  155. return JSON.parse(oBlock.getAttribute('data-bx-analytics-params') || '{}') || {};
  156. } catch (e) {
  157. return {};
  158. }
  159. };
  160. /**
  161. * The name a block keeps its tab and page under in the URL.
  162. */
  163. BxAnalyticsDashboard.prototype.getBlockName = function (oBlock) {
  164. var oParams = this.getBlockParams(oBlock);
  165. return oParams.group || oParams.table || oBlock.getAttribute('data-bx-analytics-block');
  166. };
  167. /**
  168. * Set a filter parameter (null removes it) and reload every block; lists start again
  169. * from their first page.
  170. */
  171. BxAnalyticsDashboard.prototype.setParam = function (sName, sValue) {
  172. var oUrl = new URL(window.location.href);
  173. Array.from(oUrl.searchParams.keys()).forEach(function (sKey) {
  174. if (sKey.indexOf('page_') === 0)
  175. oUrl.searchParams.delete(sKey);
  176. });
  177. window.history.replaceState(window.history.state, '', oUrl.toString());
  178. this.setUrlParam(sName, sValue);
  179. if (!new URL(window.location.href).searchParams.get('tz'))
  180. this.setUrlParam('tz', this.getTimezone());
  181. this.reloadAll();
  182. };
  183. BxAnalyticsDashboard.prototype.setUrlParam = function (sName, sValue) {
  184. var oUrl = new URL(window.location.href);
  185. if (sValue === null || sValue === '')
  186. oUrl.searchParams.delete(sName);
  187. else
  188. oUrl.searchParams.set(sName, sValue);
  189. window.history.replaceState(window.history.state, '', oUrl.toString());
  190. };
  191. BxAnalyticsDashboard.prototype.reloadAll = function () {
  192. var $this = this;
  193. document.querySelectorAll('[data-bx-analytics-block]').forEach(function (oBlock) {
  194. $this.reload(oBlock, false).catch(function () {}); // reload() shows its own failure
  195. });
  196. };
  197. /**
  198. * Replace a block with a fresh copy for the current URL parameters. A quiet reload (live
  199. * blocks) keeps the block as it is until the new copy arrives.
  200. */
  201. BxAnalyticsDashboard.prototype.reload = function (oBlock, bKeepFocus, bQuiet) {
  202. var $this = this;
  203. var sReport = oBlock.getAttribute('data-bx-analytics-block');
  204. var oParams = new URL(window.location.href).searchParams;
  205. oParams.set('bp', oBlock.getAttribute('data-bx-analytics-params') || '{}');
  206. var sFocusTab = bKeepFocus && document.activeElement ? document.activeElement.getAttribute('data-bx-analytics-tab') : null;
  207. oBlock._bxAnalyticsBusy = true;
  208. if (!bQuiet) {
  209. oBlock.setAttribute('aria-busy', 'true');
  210. oBlock.classList.add('bx-analytics-loading');
  211. }
  212. // the module URL is m/analytics/ with permalinks and modules/?r=analytics/ without
  213. var sUrl = this._sActionUrl + 'report/' + sReport + '/';
  214. sUrl += (sUrl.indexOf('?') === -1 ? '?' : '&') + oParams.toString();
  215. return fetch(sUrl, {credentials: 'same-origin'})
  216. .then(function (oResponse) {
  217. if (!oResponse.ok)
  218. throw new Error(oResponse.status);
  219. return oResponse.json();
  220. })
  221. .then(function (oData) {
  222. var oWrapper = document.createElement('div');
  223. oWrapper.innerHTML = oData.html;
  224. var oNew = oWrapper.firstElementChild;
  225. if (!oNew) {
  226. oBlock._bxAnalyticsBusy = false;
  227. return null;
  228. }
  229. $this.destroyChart(oBlock);
  230. oBlock.replaceWith(oNew);
  231. oNew._bxAnalyticsLoaded = Date.now();
  232. $this.drawCharts(oNew);
  233. $this.drawMaps(oNew);
  234. $this.showSelectedTabs(oNew);
  235. if (typeof bx_time === 'function')
  236. bx_time(undefined, false, oNew);
  237. if (sFocusTab) {
  238. var oTab = oNew.querySelector('[data-bx-analytics-tab="' + sFocusTab + '"]');
  239. if (oTab)
  240. oTab.focus();
  241. }
  242. return oNew;
  243. })
  244. .catch(function () {
  245. oBlock._bxAnalyticsBusy = false;
  246. oBlock.removeAttribute('aria-busy');
  247. oBlock.classList.remove('bx-analytics-loading');
  248. if (!bQuiet) {
  249. oBlock.classList.add('bx-analytics-failed');
  250. oBlock.setAttribute('data-bx-analytics-error', $this._aStrings.error || '');
  251. }
  252. return null;
  253. });
  254. };
  255. /**
  256. * Scroll each tab row that is wider than its card (touch screens) to its selected tab, as
  257. * a fresh row starts at its first tab.
  258. */
  259. BxAnalyticsDashboard.prototype.showSelectedTabs = function (oRoot) {
  260. var $this = this;
  261. oRoot.querySelectorAll('[data-bx-analytics-tab][aria-selected="true"]').forEach(function (oTab) {
  262. $this.showTab(oTab);
  263. });
  264. };
  265. /**
  266. * Scroll a tab's row, not the page, so that the whole tab shows clear of the row's faded
  267. * ends (its scroll-padding).
  268. */
  269. BxAnalyticsDashboard.prototype.showTab = function (oTab) {
  270. var oRow = oTab.parentNode;
  271. if (!oRow || oRow.scrollWidth <= oRow.clientWidth)
  272. return;
  273. var oStyle = window.getComputedStyle(oRow);
  274. var fPadStart = parseFloat(oStyle.scrollPaddingInlineStart) || 0;
  275. var fPadEnd = parseFloat(oStyle.scrollPaddingInlineEnd) || 0;
  276. var bRtl = oStyle.direction === 'rtl';
  277. var fPadLeft = bRtl ? fPadEnd : fPadStart;
  278. var fPadRight = bRtl ? fPadStart : fPadEnd;
  279. var oRowRect = oRow.getBoundingClientRect();
  280. var oTabRect = oTab.getBoundingClientRect();
  281. if (oTabRect.left < oRowRect.left + fPadLeft)
  282. oRow.scrollLeft -= oRowRect.left + fPadLeft - oTabRect.left;
  283. else if (oTabRect.right > oRowRect.right - fPadRight)
  284. oRow.scrollLeft += oTabRect.right - oRowRect.right + fPadRight;
  285. };
  286. /**
  287. * Keyboard focus after a block opened something: the first field of a form it shows, or the
  288. * control for the same thing (a back or cancel leads to the control that opened it).
  289. */
  290. BxAnalyticsDashboard.prototype.focusAfterOpen = function (oBlock, sName, sValue) {
  291. if (!oBlock)
  292. return;
  293. var oFocus = oBlock.querySelector('[data-bx-analytics-form] input:not([type="hidden"]), [data-bx-analytics-form] select');
  294. if (!oFocus && sValue)
  295. oFocus = oBlock.querySelector('[data-bx-analytics-open="' + sName + '"][data-bx-analytics-value="' + CSS.escape(sValue) + '"]');
  296. if (!oFocus)
  297. oFocus = oBlock.querySelector('[data-bx-analytics-open="' + sName + '"]');
  298. if (oFocus)
  299. oFocus.focus();
  300. };
  301. /**
  302. * Send a goal or funnel form; on success close the form and show the saved item.
  303. */
  304. BxAnalyticsDashboard.prototype.submitForm = function (oForm) {
  305. var $this = this;
  306. var sKind = oForm.getAttribute('data-bx-analytics-form');
  307. var oBlock = oForm.closest('[data-bx-analytics-block]');
  308. var oError = oForm.querySelector('.bx-analytics-form-error');
  309. var oSubmit = oForm.querySelector('[type="submit"]');
  310. var sSaved = '';
  311. var oData = new FormData(oForm);
  312. oData.set('op', 'save');
  313. oSubmit.disabled = true;
  314. this.post(sKind, oData)
  315. .then(function (oResult) {
  316. if (!oResult.ok)
  317. throw new Error(oResult.error || $this._aStrings.error || '');
  318. sSaved = oResult.id ? String(oResult.id) : '';
  319. $this.setUrlParam('edit_' + sKind, null);
  320. if (sKind === 'funnel' && oResult.id)
  321. $this.setUrlParam('funnel', String(oResult.id));
  322. return $this.reload(oBlock, false);
  323. })
  324. .then(function (oNew) {
  325. $this.focusAfterOpen(oNew, 'edit_' + sKind, sSaved);
  326. })
  327. .catch(function (oException) {
  328. oSubmit.disabled = false;
  329. oError.textContent = oException.message || $this._aStrings.error || '';
  330. oError.hidden = false;
  331. });
  332. };
  333. /**
  334. * A button that changes data: delete a goal or a funnel after the viewer confirms it.
  335. */
  336. BxAnalyticsDashboard.prototype.runAction = function (oButton) {
  337. var $this = this;
  338. var sKind = oButton.getAttribute('data-bx-analytics-action');
  339. var sConfirm = oButton.getAttribute('data-bx-analytics-confirm');
  340. if (sConfirm && !window.confirm(sConfirm))
  341. return;
  342. var oBlock = oButton.closest('[data-bx-analytics-block]');
  343. var oData = new FormData();
  344. oData.set('op', oButton.getAttribute('data-bx-analytics-op'));
  345. oData.set('id', oButton.getAttribute('data-bx-analytics-id'));
  346. oData.set('csrf_token', oButton.getAttribute('data-bx-analytics-csrf') || '');
  347. oButton.disabled = true;
  348. this.post(sKind, oData)
  349. .then(function (oResult) {
  350. if (!oResult.ok)
  351. throw new Error(oResult.error || '');
  352. if (sKind === 'funnel')
  353. $this.setUrlParam('funnel', null);
  354. return $this.reload(oBlock, false);
  355. })
  356. .then(function (oNew) {
  357. // a status change keeps the view: focus goes to the first action left
  358. var oAction = oNew && sKind === 'error_status' ? oNew.querySelector('[data-bx-analytics-action="error_status"]') : null;
  359. if (oAction)
  360. oAction.focus();
  361. else
  362. $this.focusAfterOpen(oNew, 'edit_' + sKind, 'new');
  363. })
  364. .catch(function (oException) {
  365. oButton.disabled = false;
  366. window.alert(oException.message || $this._aStrings.error || '');
  367. });
  368. };
  369. BxAnalyticsDashboard.prototype.post = function (sAction, oData) {
  370. var sUrl = this._sActionUrl + sAction + '/';
  371. return fetch(sUrl, {method: 'POST', body: oData, credentials: 'same-origin'})
  372. .then(function (oResponse) {
  373. return oResponse.json().catch(function () {
  374. return {error: ''};
  375. });
  376. });
  377. };
  378. BxAnalyticsDashboard.prototype.selectMetric = function (oButton) {
  379. var oBlock = oButton.closest('[data-bx-analytics-block]');
  380. oBlock.querySelectorAll('[data-bx-analytics-metric]').forEach(function (o) {
  381. o.setAttribute('aria-pressed', o === oButton ? 'true' : 'false');
  382. });
  383. this.setUrlParam('metric', oButton.getAttribute('data-bx-analytics-metric'));
  384. var oJson = oBlock.querySelector('.bx-analytics-chart-data');
  385. var oData = oJson ? this.getChartData(oJson) : null;
  386. if (oData) {
  387. oData.metric = oButton.getAttribute('data-bx-analytics-metric');
  388. this.drawChart(oJson, oData);
  389. }
  390. };
  391. /**
  392. * The data of a chart: JSON in a script element right after the chart's container.
  393. */
  394. BxAnalyticsDashboard.prototype.getChartData = function (oJson) {
  395. if (!oJson._bxAnalyticsData) {
  396. try {
  397. oJson._bxAnalyticsData = JSON.parse(oJson.textContent);
  398. } catch (e) {
  399. return null;
  400. }
  401. }
  402. return oJson._bxAnalyticsData;
  403. };
  404. BxAnalyticsDashboard.prototype.drawCharts = function (oRoot) {
  405. var $this = this;
  406. var aJson = oRoot.querySelectorAll ? oRoot.querySelectorAll('.bx-analytics-chart-data') : [];
  407. Array.prototype.forEach.call(aJson, function (oJson) {
  408. var oData = $this.getChartData(oJson);
  409. if (oData)
  410. $this.drawChart(oJson, oData);
  411. });
  412. };
  413. /**
  414. * Visitors on a Leaflet map: a circle per country (or place), its area by visitors.
  415. */
  416. BxAnalyticsDashboard.prototype.drawMaps = function (oRoot) {
  417. if (typeof L === 'undefined')
  418. return;
  419. var aMaps = oRoot.querySelectorAll ? oRoot.querySelectorAll('.bx-analytics-map') : [];
  420. Array.prototype.forEach.call(aMaps, function (oElement) {
  421. if (oElement._bxAnalyticsMap)
  422. return;
  423. var oJson = oElement.parentNode.querySelector('.bx-analytics-map-data');
  424. var oData;
  425. try {
  426. oData = JSON.parse(oJson.textContent);
  427. } catch (e) {
  428. return;
  429. }
  430. var oMap = L.map(oElement, {worldCopyJump: true, minZoom: 1, scrollWheelZoom: false}).setView([25, 10], 2);
  431. oElement._bxAnalyticsMap = oMap;
  432. try {
  433. L.tileLayer.provider(oData.provider || 'OpenStreetMap.Mapnik').addTo(oMap);
  434. } catch (e) {
  435. L.tileLayer.provider('OpenStreetMap.Mapnik').addTo(oMap);
  436. }
  437. var sAccent = window.getComputedStyle(oElement).getPropertyValue('--bx-analytics-accent').trim() || '#4f46e5';
  438. var aItems = oData.points && oData.points.length ? oData.points : oData.countries;
  439. var iMax = 1;
  440. aItems.forEach(function (o) { iMax = Math.max(iMax, o.visitors); });
  441. // the whole world when there is little to show, otherwise just the places with visitors
  442. if (aItems.length > 1)
  443. oMap.fitBounds(L.latLngBounds(aItems.map(function (o) { return [o.lat, o.lng]; })), {padding: [30, 30], maxZoom: 5});
  444. else if (aItems.length === 1)
  445. oMap.setView([aItems[0].lat, aItems[0].lng], 4);
  446. aItems.forEach(function (o) {
  447. L.circleMarker([o.lat, o.lng], {
  448. radius: 4 + 26 * Math.sqrt(o.visitors / iMax),
  449. color: sAccent,
  450. weight: 1,
  451. fillColor: sAccent,
  452. fillOpacity: 0.35
  453. }).bindTooltip(function () {
  454. // place names come from request headers: text, never HTML
  455. var oTip = document.createElement('span');
  456. oTip.textContent = o.label + ': ' + o.visitors + ' ' + oData.visitors.toLowerCase();
  457. return oTip;
  458. }).addTo(oMap);
  459. });
  460. });
  461. };
  462. BxAnalyticsDashboard.prototype.destroyChart = function (oBlock) {
  463. var oMap = oBlock.querySelector('.bx-analytics-map');
  464. if (oMap && oMap._bxAnalyticsMap) {
  465. oMap._bxAnalyticsMap.remove();
  466. oMap._bxAnalyticsMap = null;
  467. }
  468. oBlock.querySelectorAll('canvas').forEach(function (oCanvas) {
  469. if (oCanvas._bxAnalyticsChart) {
  470. oCanvas._bxAnalyticsChart.destroy();
  471. oCanvas._bxAnalyticsChart = null;
  472. }
  473. });
  474. };
  475. /**
  476. * Colours for the chart: the accent and a categorical palette, from the block's CSS.
  477. */
  478. BxAnalyticsDashboard.prototype.getColors = function (oElement) {
  479. var oStyle = window.getComputedStyle(oElement);
  480. var get = function (sName, sDefault) {
  481. return oStyle.getPropertyValue(sName).trim() || sDefault;
  482. };
  483. var aPalette = [];
  484. for (var i = 1; i <= 8; i++)
  485. aPalette.push(get('--bx-analytics-c' + i, '#4f46e5'));
  486. return {
  487. accent: get('--bx-analytics-accent', '#4f46e5'),
  488. fill: get('--bx-analytics-accent-fill', 'rgba(79, 70, 229, 0.15)'),
  489. grid: get('--bx-analytics-grid', 'rgba(0, 0, 0, 0.08)'),
  490. muted: get('--bx-analytics-muted', '#6b7280'),
  491. good: get('--bx-analytics-good', '#047857'),
  492. bad: get('--bx-analytics-bad', '#b91c1c'),
  493. palette: aPalette
  494. };
  495. };
  496. /**
  497. * Several values over time (kind `series`: `series` of {label, points of {t, v, partial}})
  498. * as lines per category, the bucket in progress dashed.
  499. */
  500. BxAnalyticsDashboard.prototype.getSeriesLines = function (oData) {
  501. var aSeries = oData.series || [];
  502. return {
  503. kind: 'lines',
  504. metric: oData.metric,
  505. bucket: oData.bucket,
  506. tz: oData.tz,
  507. legend: true,
  508. labels: aSeries.length ? aSeries[0].points.map(function (oPoint) { return oPoint.t; }) : [],
  509. datasets: aSeries.map(function (oSeries) {
  510. return {
  511. label: oSeries.label,
  512. values: oSeries.points.map(function (oPoint) { return oPoint.v; }),
  513. partial: oSeries.points.map(function (oPoint) { return !!oPoint.partial; })
  514. };
  515. })
  516. };
  517. };
  518. /**
  519. * Draw the chart a JSON element describes, in the canvas of the container before it.
  520. * Kinds: a metric over time (the overview, `series` with every metric), stacked bars per
  521. * category (`labels` and `datasets`), lines per category (`datasets`, kind `lines`), and
  522. * several values of one metric over time (kind `series`, the query).
  523. */
  524. BxAnalyticsDashboard.prototype.drawChart = function (oJson, oData) {
  525. var oContainer = oJson.previousElementSibling;
  526. var oCanvas = oContainer ? oContainer.querySelector('canvas') : null;
  527. if (!oCanvas || typeof Chart === 'undefined')
  528. return;
  529. if (oCanvas._bxAnalyticsChart) {
  530. oCanvas._bxAnalyticsChart.destroy();
  531. oCanvas._bxAnalyticsChart = null;
  532. }
  533. var $this = this;
  534. var oColors = this.getColors(oContainer);
  535. var bReducedMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  536. if (oData.kind === 'series')
  537. oData = this.getSeriesLines(oData);
  538. var sKind = oData.kind || 'metric';
  539. var sMetric = oData.metric || 'number';
  540. var oChart;
  541. var oScales = {
  542. x: {
  543. stacked: sKind === 'stacked',
  544. grid: {display: false},
  545. ticks: {color: oColors.muted, autoSkip: true, maxRotation: 0, maxTicksLimit: 8}
  546. },
  547. y: {
  548. stacked: sKind === 'stacked',
  549. beginAtZero: true,
  550. grid: {color: oColors.grid},
  551. border: {display: false},
  552. ticks: {
  553. color: oColors.muted,
  554. precision: sMetric === 'pages_per_session' ? 1 : (sMetric === 'cls' ? undefined : 0),
  555. callback: function (fValue) {
  556. return $this.formatMetric(sMetric, fValue, oData);
  557. }
  558. }
  559. }
  560. };
  561. if (sKind === 'metric') {
  562. var aLabels = [], aValues = [], aPartial = [];
  563. oData.series.forEach(function (oPoint) {
  564. aLabels.push($this.formatBucket(oPoint.t, oData.bucket, oData.tz));
  565. aValues.push(oPoint.future ? null : oPoint[sMetric]);
  566. aPartial.push(!!oPoint.partial);
  567. });
  568. oChart = {
  569. type: 'line',
  570. data: {
  571. labels: aLabels,
  572. datasets: [{
  573. label: oData.titles[sMetric],
  574. data: aValues,
  575. borderColor: oColors.accent,
  576. backgroundColor: oColors.fill,
  577. borderWidth: 2,
  578. fill: true,
  579. tension: 0.3,
  580. pointRadius: 0,
  581. pointHoverRadius: 4,
  582. spanGaps: false,
  583. // the period still in progress is drawn dashed, so its low value doesn't read as a drop
  584. segment: {
  585. borderDash: function (oContext) {
  586. return aPartial[oContext.p1DataIndex] ? [4, 4] : undefined;
  587. }
  588. }
  589. }]
  590. },
  591. legend: false,
  592. label: function (oContext) {
  593. return oData.titles[sMetric] + ': ' + $this.formatMetric(sMetric, oContext.parsed.y, oData);
  594. }
  595. };
  596. }
  597. else {
  598. oChart = {
  599. type: sKind === 'stacked' ? 'bar' : 'line',
  600. data: {
  601. labels: oData.labels.map(function (iTime) { return $this.formatBucket(iTime, oData.bucket || 'day', oData.tz || 'UTC'); }),
  602. datasets: oData.datasets.map(function (oSet, iIndex) {
  603. // a set may ask for a meaning colour (good, bad) instead of the next in the palette
  604. var sColor = oSet.tone && oColors[oSet.tone] ? oColors[oSet.tone] : oColors.palette[iIndex % oColors.palette.length];
  605. return {
  606. label: oSet.label,
  607. data: oSet.values,
  608. backgroundColor: sColor,
  609. borderColor: sColor,
  610. borderWidth: sKind === 'stacked' ? 0 : (oSet.dashed ? 1.5 : 2),
  611. // reference lines, such as a metric's thresholds
  612. borderDash: oSet.dashed ? [6, 4] : undefined,
  613. spanGaps: false,
  614. borderRadius: sKind === 'stacked' ? 2 : 0,
  615. pointRadius: 0,
  616. pointHoverRadius: sKind === 'stacked' ? 0 : 4,
  617. tension: 0.3,
  618. // the bucket still in progress is drawn dashed
  619. segment: oSet.partial ? {
  620. borderDash: function (oContext) {
  621. return oSet.partial[oContext.p1DataIndex] ? [4, 4] : undefined;
  622. }
  623. } : undefined
  624. };
  625. })
  626. },
  627. legend: oData.legend || oData.datasets.length > 1,
  628. label: function (oContext) {
  629. return oContext.dataset.label + ': ' + $this.formatMetric(sMetric, oContext.parsed.y, oData);
  630. }
  631. };
  632. }
  633. oCanvas._bxAnalyticsChart = new Chart(oCanvas, {
  634. type: oChart.type,
  635. data: oChart.data,
  636. options: {
  637. responsive: true,
  638. maintainAspectRatio: false,
  639. animation: bReducedMotion ? false : {duration: 300},
  640. interaction: {mode: 'index', intersect: false},
  641. plugins: {
  642. legend: {
  643. display: oChart.legend,
  644. position: 'bottom',
  645. labels: {color: oColors.muted, boxWidth: 12, boxHeight: 12}
  646. },
  647. tooltip: {
  648. callbacks: {label: oChart.label}
  649. }
  650. },
  651. scales: oScales
  652. }
  653. });
  654. };
  655. BxAnalyticsDashboard.prototype.formatBucket = function (iTime, sBucket, sTz) {
  656. var oDate = new Date(iTime * 1000);
  657. var oOptions = {timeZone: sTz || 'UTC'};
  658. if (sBucket === 'hour') {
  659. oOptions.hour = 'numeric';
  660. oOptions.minute = '2-digit';
  661. }
  662. else if (sBucket === 'month') {
  663. oOptions.month = 'short';
  664. oOptions.year = 'numeric';
  665. }
  666. else {
  667. oOptions.month = 'short';
  668. oOptions.day = 'numeric';
  669. }
  670. try {
  671. return new Intl.DateTimeFormat(document.documentElement.lang || undefined, oOptions).format(oDate);
  672. } catch (e) {
  673. return oDate.toISOString().slice(0, sBucket === 'hour' ? 16 : 10);
  674. }
  675. };
  676. BxAnalyticsDashboard.prototype.formatMetric = function (sMetric, fValue, oData) {
  677. if (fValue === null || fValue === undefined)
  678. return '';
  679. switch (sMetric) {
  680. // amounts in the site's default currency, which the chart data names
  681. case 'money':
  682. try {
  683. if (oData && oData.currency)
  684. return new Intl.NumberFormat(document.documentElement.lang || undefined, {style: 'currency', currency: oData.currency, notation: Math.abs(fValue) >= 10000 ? 'compact' : 'standard', minimumFractionDigits: 0, maximumFractionDigits: Math.abs(fValue) >= 100 ? 0 : 2}).format(fValue);
  685. } catch (e) {
  686. // an unknown currency code: the number alone
  687. }
  688. break;
  689. case 'bounce_rate':
  690. return Math.round(fValue) + '%';
  691. // timings: 850ms, 2.40s
  692. case 'ms':
  693. return fValue >= 1000 ? (fValue / 1000).toFixed(2) + (this._aStrings.duration_s || 's') : Math.round(fValue) + (this._aStrings.ms || 'ms');
  694. // layout shift: a score
  695. case 'cls':
  696. return Number(fValue).toFixed(fValue && fValue < 0.1 ? 3 : 2);
  697. case 'pages_per_session':
  698. return Number(fValue).toFixed(1);
  699. case 'duration':
  700. case 'engaged':
  701. var iSeconds = Math.round(fValue);
  702. if (iSeconds < 60)
  703. return iSeconds + (this._aStrings.duration_s || 's');
  704. if (iSeconds < 3600)
  705. return Math.floor(iSeconds / 60) + (this._aStrings.duration_m || 'm') + ' ' + (iSeconds % 60) + (this._aStrings.duration_s || 's');
  706. return Math.floor(iSeconds / 3600) + (this._aStrings.duration_h || 'h') + ' ' + Math.floor((iSeconds % 3600) / 60) + (this._aStrings.duration_m || 'm');
  707. }
  708. try {
  709. return new Intl.NumberFormat(document.documentElement.lang || undefined, {notation: 'compact', maximumFractionDigits: 1}).format(fValue);
  710. } catch (e) {
  711. return String(fValue);
  712. }
  713. };
  714. /** @} */