dashboard.js 29 KB

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