| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815 |
- /**
- * Copyright (c) UNA, Inc - https://una.io
- * MIT License - https://opensource.org/licenses/MIT
- *
- * @defgroup Analytics Analytics
- * @ingroup UnaModules
- *
- * @{
- */
- /**
- * Analytics blocks on a page share one set of filters kept in the page URL: range, tz,
- * platform, f[dimension] and the chart metric. Changing a filter reloads every block
- * from the server, which renders it from the same templates as the first page load.
- * A block's own state (its tab, its page) is kept in the URL too, as tab_<name> and
- * page_<name>, so a reload or a shared link shows the same thing.
- */
- function BxAnalyticsDashboard(oOptions) {
- this._sActionUrl = oOptions.sActionUrl;
- this._aStrings = oOptions.aStrings || {};
- var $this = this;
- if (document.readyState === 'loading')
- document.addEventListener('DOMContentLoaded', function () { $this.init(); });
- else
- this.init();
- }
- BxAnalyticsDashboard.prototype.init = function () {
- var $this = this;
- document.addEventListener('change', function (oEvent) {
- var oSelect = oEvent.target.closest('[data-bx-analytics-param]');
- if (oSelect)
- $this.setParam(oSelect.getAttribute('data-bx-analytics-param'), oSelect.value);
- });
- document.addEventListener('click', function (oEvent) {
- var oTarget, oBlock, sName;
- if ((oTarget = oEvent.target.closest('[data-bx-analytics-metric]'))) {
- $this.selectMetric(oTarget);
- return;
- }
- if ((oTarget = oEvent.target.closest('[data-bx-analytics-tab]'))) {
- oBlock = oTarget.closest('[data-bx-analytics-block]');
- sName = $this.getBlockName(oBlock);
- $this.setUrlParam('tab_' + sName, oTarget.getAttribute('data-bx-analytics-tab'));
- $this.setUrlParam('page_' + sName, null);
- $this.reload(oBlock, true).catch(function () {}); // reload() shows its own failure
- return;
- }
- if ((oTarget = oEvent.target.closest('[data-bx-analytics-pager]'))) {
- oBlock = oTarget.closest('[data-bx-analytics-block]');
- $this.setUrlParam('page_' + oTarget.getAttribute('data-bx-analytics-pager'), oTarget.getAttribute('data-bx-analytics-page'));
- $this.reload(oBlock, false).catch(function () {}); // reload() shows its own failure
- return;
- }
- if ((oTarget = oEvent.target.closest('[data-bx-analytics-open]'))) {
- oBlock = oTarget.closest('[data-bx-analytics-block]');
- sName = oTarget.getAttribute('data-bx-analytics-open');
- var sValue = oTarget.getAttribute('data-bx-analytics-value');
- $this.setUrlParam(sName, sValue);
- $this.reload(oBlock, false).then(function (oNew) {
- $this.focusAfterOpen(oNew, sName, sValue);
- }, function () {}); // reload() shows its own failure
- return;
- }
- if ((oTarget = oEvent.target.closest('[data-bx-analytics-action]'))) {
- $this.runAction(oTarget);
- return;
- }
- if ((oTarget = oEvent.target.closest('[data-bx-analytics-filter]'))) {
- // the platform has a parameter of its own (the filter bar's select), not f[platform]
- sName = oTarget.getAttribute('data-bx-analytics-filter');
- $this.setParam(sName === 'platform' ? 'platform' : 'f[' + sName + ']', oTarget.getAttribute('data-bx-analytics-value'));
- return;
- }
- if ((oTarget = oEvent.target.closest('[data-bx-analytics-unfilter]')))
- $this.setParam('f[' + oTarget.getAttribute('data-bx-analytics-unfilter') + ']', null);
- });
- document.addEventListener('submit', function (oEvent) {
- var oForm = oEvent.target.closest('[data-bx-analytics-form]');
- if (!oForm)
- return;
- oEvent.preventDefault();
- $this.submitForm(oForm);
- });
- // arrow keys move between tabs, as in any tab list
- document.addEventListener('keydown', function (oEvent) {
- var oTab = oEvent.target.closest('[data-bx-analytics-tab]');
- if (!oTab || (oEvent.key !== 'ArrowRight' && oEvent.key !== 'ArrowLeft'))
- return;
- var aTabs = Array.prototype.slice.call(oTab.parentNode.querySelectorAll('[data-bx-analytics-tab]'));
- var iNext = (aTabs.indexOf(oTab) + (oEvent.key === 'ArrowRight' ? 1 : -1) + aTabs.length) % aTabs.length;
- aTabs[iNext].focus();
- aTabs[iNext].click();
- oEvent.preventDefault();
- });
- // a focused tab in a row that scrolls is moved clear of the row's faded ends; focus()
- // alone scrolls only a tab that is out of sight, not one that is cut off
- document.addEventListener('focusin', function (oEvent) {
- var oTab = oEvent.target.closest('[data-bx-analytics-tab]');
- if (oTab)
- $this.showTab(oTab);
- });
- this.drawCharts(document);
- this.drawMaps(document);
- this.showSelectedTabs(document);
- // blocks that follow live data reload themselves while the page is visible
- setInterval(function () {
- if (document.visibilityState !== 'visible')
- return;
- document.querySelectorAll('[data-bx-analytics-refresh]').forEach(function (oRefresh) {
- var oBlock = oRefresh.closest('[data-bx-analytics-block]');
- var iEvery = 1000 * parseInt(oRefresh.getAttribute('data-bx-analytics-refresh'), 10);
- if (oBlock && !oBlock._bxAnalyticsBusy && Date.now() - (oBlock._bxAnalyticsLoaded || $this._iStarted) >= iEvery)
- $this.reload(oBlock, false, true).catch(function () {}); // a quiet refresh tries again later
- });
- }, 5000);
- this._iStarted = Date.now();
- // without a timezone in the page URL the server takes the viewer's from a cookie, kept
- // here; only a page drawn before the cookie was set (or in another zone) loads again
- var sTz = this.getTimezone();
- var oUrl = new URL(window.location.href);
- if (!oUrl.searchParams.get('tz') && sTz) {
- var sRendered = this.getTimezoneCookie() || 'UTC';
- this.setTimezoneCookie(sTz);
- if (sTz !== sRendered) {
- this.setUrlParam('tz', sTz);
- this.reloadAll();
- }
- }
- };
- /**
- * The timezone cookie the server reads when a request has no `tz` (BxAnalyticsFilter).
- */
- BxAnalyticsDashboard.prototype.getTimezoneCookie = function () {
- var aMatch = document.cookie.match(/(?:^|;\s*)bx_analytics_tz=([^;]*)/);
- try {
- return aMatch ? decodeURIComponent(aMatch[1]) : '';
- } catch (e) {
- return '';
- }
- };
- BxAnalyticsDashboard.prototype.setTimezoneCookie = function (sTz) {
- document.cookie = 'bx_analytics_tz=' + encodeURIComponent(sTz) + '; path=/; max-age=31536000; SameSite=Lax' + (window.location.protocol === 'https:' ? '; Secure' : '');
- };
- BxAnalyticsDashboard.prototype.getTimezone = function () {
- try {
- return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
- } catch (e) {
- return 'UTC';
- }
- };
- /**
- * A block's own parameters (group, table...), set by the server.
- */
- BxAnalyticsDashboard.prototype.getBlockParams = function (oBlock) {
- try {
- return JSON.parse(oBlock.getAttribute('data-bx-analytics-params') || '{}') || {};
- } catch (e) {
- return {};
- }
- };
- /**
- * The name a block keeps its tab and page under in the URL.
- */
- BxAnalyticsDashboard.prototype.getBlockName = function (oBlock) {
- var oParams = this.getBlockParams(oBlock);
- return oParams.group || oParams.table || oBlock.getAttribute('data-bx-analytics-block');
- };
- /**
- * Set a filter parameter (null removes it) and reload every block; lists start again
- * from their first page.
- */
- BxAnalyticsDashboard.prototype.setParam = function (sName, sValue) {
- var oUrl = new URL(window.location.href);
- Array.from(oUrl.searchParams.keys()).forEach(function (sKey) {
- if (sKey.indexOf('page_') === 0)
- oUrl.searchParams.delete(sKey);
- });
- window.history.replaceState(window.history.state, '', oUrl.toString());
- this.setUrlParam(sName, sValue);
- if (!new URL(window.location.href).searchParams.get('tz'))
- this.setUrlParam('tz', this.getTimezone());
- this.reloadAll();
- };
- BxAnalyticsDashboard.prototype.setUrlParam = function (sName, sValue) {
- var oUrl = new URL(window.location.href);
- if (sValue === null || sValue === '')
- oUrl.searchParams.delete(sName);
- else
- oUrl.searchParams.set(sName, sValue);
- window.history.replaceState(window.history.state, '', oUrl.toString());
- };
- BxAnalyticsDashboard.prototype.reloadAll = function () {
- var $this = this;
- document.querySelectorAll('[data-bx-analytics-block]').forEach(function (oBlock) {
- $this.reload(oBlock, false).catch(function () {}); // reload() shows its own failure
- });
- };
- /**
- * Replace a block with a fresh copy for the current URL parameters. A quiet reload (live
- * blocks) keeps the block as it is until the new copy arrives.
- */
- BxAnalyticsDashboard.prototype.reload = function (oBlock, bKeepFocus, bQuiet) {
- var $this = this;
- var sReport = oBlock.getAttribute('data-bx-analytics-block');
- var oParams = new URL(window.location.href).searchParams;
- oParams.set('bp', oBlock.getAttribute('data-bx-analytics-params') || '{}');
- var sFocusTab = bKeepFocus && document.activeElement ? document.activeElement.getAttribute('data-bx-analytics-tab') : null;
- oBlock._bxAnalyticsBusy = true;
- if (!bQuiet) {
- oBlock.setAttribute('aria-busy', 'true');
- oBlock.classList.add('bx-analytics-loading');
- }
- // the module URL is m/analytics/ with permalinks and modules/?r=analytics/ without
- var sUrl = this._sActionUrl + 'report/' + sReport + '/';
- sUrl += (sUrl.indexOf('?') === -1 ? '?' : '&') + oParams.toString();
- return fetch(sUrl, {credentials: 'same-origin'})
- .then(function (oResponse) {
- if (!oResponse.ok)
- throw new Error(oResponse.status);
- return oResponse.json();
- })
- .then(function (oData) {
- var oWrapper = document.createElement('div');
- oWrapper.innerHTML = oData.html;
- var oNew = oWrapper.firstElementChild;
- if (!oNew) {
- oBlock._bxAnalyticsBusy = false;
- return null;
- }
- $this.destroyChart(oBlock);
- oBlock.replaceWith(oNew);
- oNew._bxAnalyticsLoaded = Date.now();
- $this.drawCharts(oNew);
- $this.drawMaps(oNew);
- $this.showSelectedTabs(oNew);
- if (typeof bx_time === 'function')
- bx_time(undefined, false, oNew);
- if (sFocusTab) {
- var oTab = oNew.querySelector('[data-bx-analytics-tab="' + sFocusTab + '"]');
- if (oTab)
- oTab.focus();
- }
- return oNew;
- })
- .catch(function () {
- oBlock._bxAnalyticsBusy = false;
- oBlock.removeAttribute('aria-busy');
- oBlock.classList.remove('bx-analytics-loading');
- if (!bQuiet) {
- oBlock.classList.add('bx-analytics-failed');
- oBlock.setAttribute('data-bx-analytics-error', $this._aStrings.error || '');
- }
- return null;
- });
- };
- /**
- * Scroll each tab row that is wider than its card (touch screens) to its selected tab, as
- * a fresh row starts at its first tab.
- */
- BxAnalyticsDashboard.prototype.showSelectedTabs = function (oRoot) {
- var $this = this;
- oRoot.querySelectorAll('[data-bx-analytics-tab][aria-selected="true"]').forEach(function (oTab) {
- $this.showTab(oTab);
- });
- };
- /**
- * Scroll a tab's row, not the page, so that the whole tab shows clear of the row's faded
- * ends (its scroll-padding).
- */
- BxAnalyticsDashboard.prototype.showTab = function (oTab) {
- var oRow = oTab.parentNode;
- if (!oRow || oRow.scrollWidth <= oRow.clientWidth)
- return;
- var oStyle = window.getComputedStyle(oRow);
- var fPadStart = parseFloat(oStyle.scrollPaddingInlineStart) || 0;
- var fPadEnd = parseFloat(oStyle.scrollPaddingInlineEnd) || 0;
- var bRtl = oStyle.direction === 'rtl';
- var fPadLeft = bRtl ? fPadEnd : fPadStart;
- var fPadRight = bRtl ? fPadStart : fPadEnd;
- var oRowRect = oRow.getBoundingClientRect();
- var oTabRect = oTab.getBoundingClientRect();
- if (oTabRect.left < oRowRect.left + fPadLeft)
- oRow.scrollLeft -= oRowRect.left + fPadLeft - oTabRect.left;
- else if (oTabRect.right > oRowRect.right - fPadRight)
- oRow.scrollLeft += oTabRect.right - oRowRect.right + fPadRight;
- };
- /**
- * Keyboard focus after a block opened something: the first field of a form it shows, or the
- * control for the same thing (a back or cancel leads to the control that opened it).
- */
- BxAnalyticsDashboard.prototype.focusAfterOpen = function (oBlock, sName, sValue) {
- if (!oBlock)
- return;
- var oFocus = oBlock.querySelector('[data-bx-analytics-form] input:not([type="hidden"]), [data-bx-analytics-form] select');
- if (!oFocus && sValue)
- oFocus = oBlock.querySelector('[data-bx-analytics-open="' + sName + '"][data-bx-analytics-value="' + CSS.escape(sValue) + '"]');
- if (!oFocus)
- oFocus = oBlock.querySelector('[data-bx-analytics-open="' + sName + '"]');
- if (oFocus)
- oFocus.focus();
- };
- /**
- * Send a goal or funnel form; on success close the form and show the saved item.
- */
- BxAnalyticsDashboard.prototype.submitForm = function (oForm) {
- var $this = this;
- var sKind = oForm.getAttribute('data-bx-analytics-form');
- var oBlock = oForm.closest('[data-bx-analytics-block]');
- var oError = oForm.querySelector('.bx-analytics-form-error');
- var oSubmit = oForm.querySelector('[type="submit"]');
- var sSaved = '';
- var oData = new FormData(oForm);
- oData.set('op', 'save');
- oSubmit.disabled = true;
- this.post(sKind, oData)
- .then(function (oResult) {
- if (!oResult.ok)
- throw new Error(oResult.error || $this._aStrings.error || '');
- sSaved = oResult.id ? String(oResult.id) : '';
- $this.setUrlParam('edit_' + sKind, null);
- if (sKind === 'funnel' && oResult.id)
- $this.setUrlParam('funnel', String(oResult.id));
- return $this.reload(oBlock, false);
- })
- .then(function (oNew) {
- $this.focusAfterOpen(oNew, 'edit_' + sKind, sSaved);
- })
- .catch(function (oException) {
- oSubmit.disabled = false;
- oError.textContent = oException.message || $this._aStrings.error || '';
- oError.hidden = false;
- });
- };
- /**
- * A button that changes data: delete a goal or a funnel after the viewer confirms it.
- */
- BxAnalyticsDashboard.prototype.runAction = function (oButton) {
- var $this = this;
- var sKind = oButton.getAttribute('data-bx-analytics-action');
- var sConfirm = oButton.getAttribute('data-bx-analytics-confirm');
- if (sConfirm && !window.confirm(sConfirm))
- return;
- var oBlock = oButton.closest('[data-bx-analytics-block]');
- var oData = new FormData();
- oData.set('op', oButton.getAttribute('data-bx-analytics-op'));
- oData.set('id', oButton.getAttribute('data-bx-analytics-id'));
- oData.set('csrf_token', oButton.getAttribute('data-bx-analytics-csrf') || '');
- oButton.disabled = true;
- this.post(sKind, oData)
- .then(function (oResult) {
- if (!oResult.ok)
- throw new Error(oResult.error || '');
- if (sKind === 'funnel')
- $this.setUrlParam('funnel', null);
- return $this.reload(oBlock, false);
- })
- .then(function (oNew) {
- // a status change keeps the view: focus goes to the first action left
- var oAction = oNew && sKind === 'error_status' ? oNew.querySelector('[data-bx-analytics-action="error_status"]') : null;
- if (oAction)
- oAction.focus();
- else
- $this.focusAfterOpen(oNew, 'edit_' + sKind, 'new');
- })
- .catch(function (oException) {
- oButton.disabled = false;
- window.alert(oException.message || $this._aStrings.error || '');
- });
- };
- BxAnalyticsDashboard.prototype.post = function (sAction, oData) {
- var sUrl = this._sActionUrl + sAction + '/';
- return fetch(sUrl, {method: 'POST', body: oData, credentials: 'same-origin'})
- .then(function (oResponse) {
- return oResponse.json().catch(function () {
- return {error: ''};
- });
- });
- };
- BxAnalyticsDashboard.prototype.selectMetric = function (oButton) {
- var oBlock = oButton.closest('[data-bx-analytics-block]');
- oBlock.querySelectorAll('[data-bx-analytics-metric]').forEach(function (o) {
- o.setAttribute('aria-pressed', o === oButton ? 'true' : 'false');
- });
- this.setUrlParam('metric', oButton.getAttribute('data-bx-analytics-metric'));
- var oJson = oBlock.querySelector('.bx-analytics-chart-data');
- var oData = oJson ? this.getChartData(oJson) : null;
- if (oData) {
- oData.metric = oButton.getAttribute('data-bx-analytics-metric');
- this.drawChart(oJson, oData);
- }
- };
- /**
- * The data of a chart: JSON in a script element right after the chart's container.
- */
- BxAnalyticsDashboard.prototype.getChartData = function (oJson) {
- if (!oJson._bxAnalyticsData) {
- try {
- oJson._bxAnalyticsData = JSON.parse(oJson.textContent);
- } catch (e) {
- return null;
- }
- }
- return oJson._bxAnalyticsData;
- };
- BxAnalyticsDashboard.prototype.drawCharts = function (oRoot) {
- var $this = this;
- var aJson = oRoot.querySelectorAll ? oRoot.querySelectorAll('.bx-analytics-chart-data') : [];
- Array.prototype.forEach.call(aJson, function (oJson) {
- var oData = $this.getChartData(oJson);
- if (oData)
- $this.drawChart(oJson, oData);
- });
- };
- /**
- * Visitors on a Leaflet map: a circle per country (or place), its area by visitors.
- */
- BxAnalyticsDashboard.prototype.drawMaps = function (oRoot) {
- if (typeof L === 'undefined')
- return;
- var aMaps = oRoot.querySelectorAll ? oRoot.querySelectorAll('.bx-analytics-map') : [];
- Array.prototype.forEach.call(aMaps, function (oElement) {
- if (oElement._bxAnalyticsMap)
- return;
- var oJson = oElement.parentNode.querySelector('.bx-analytics-map-data');
- var oData;
- try {
- oData = JSON.parse(oJson.textContent);
- } catch (e) {
- return;
- }
- var oMap = L.map(oElement, {worldCopyJump: true, minZoom: 1, scrollWheelZoom: false}).setView([25, 10], 2);
- oElement._bxAnalyticsMap = oMap;
- try {
- L.tileLayer.provider(oData.provider || 'OpenStreetMap.Mapnik').addTo(oMap);
- } catch (e) {
- L.tileLayer.provider('OpenStreetMap.Mapnik').addTo(oMap);
- }
- var sAccent = window.getComputedStyle(oElement).getPropertyValue('--bx-analytics-accent').trim() || '#4f46e5';
- var aItems = oData.points && oData.points.length ? oData.points : oData.countries;
- var iMax = 1;
- aItems.forEach(function (o) { iMax = Math.max(iMax, o.visitors); });
- // the whole world when there is little to show, otherwise just the places with visitors
- if (aItems.length > 1)
- oMap.fitBounds(L.latLngBounds(aItems.map(function (o) { return [o.lat, o.lng]; })), {padding: [30, 30], maxZoom: 5});
- else if (aItems.length === 1)
- oMap.setView([aItems[0].lat, aItems[0].lng], 4);
- aItems.forEach(function (o) {
- L.circleMarker([o.lat, o.lng], {
- radius: 4 + 26 * Math.sqrt(o.visitors / iMax),
- color: sAccent,
- weight: 1,
- fillColor: sAccent,
- fillOpacity: 0.35
- }).bindTooltip(function () {
- // place names come from request headers: text, never HTML
- var oTip = document.createElement('span');
- oTip.textContent = o.label + ': ' + o.visitors + ' ' + oData.visitors.toLowerCase();
- return oTip;
- }).addTo(oMap);
- });
- });
- };
- BxAnalyticsDashboard.prototype.destroyChart = function (oBlock) {
- var oMap = oBlock.querySelector('.bx-analytics-map');
- if (oMap && oMap._bxAnalyticsMap) {
- oMap._bxAnalyticsMap.remove();
- oMap._bxAnalyticsMap = null;
- }
- oBlock.querySelectorAll('canvas').forEach(function (oCanvas) {
- if (oCanvas._bxAnalyticsChart) {
- oCanvas._bxAnalyticsChart.destroy();
- oCanvas._bxAnalyticsChart = null;
- }
- });
- };
- /**
- * Colours for the chart: the accent and a categorical palette, from the block's CSS.
- */
- BxAnalyticsDashboard.prototype.getColors = function (oElement) {
- var oStyle = window.getComputedStyle(oElement);
- var get = function (sName, sDefault) {
- return oStyle.getPropertyValue(sName).trim() || sDefault;
- };
- var aPalette = [];
- for (var i = 1; i <= 8; i++)
- aPalette.push(get('--bx-analytics-c' + i, '#4f46e5'));
- return {
- accent: get('--bx-analytics-accent', '#4f46e5'),
- fill: get('--bx-analytics-accent-fill', 'rgba(79, 70, 229, 0.15)'),
- grid: get('--bx-analytics-grid', 'rgba(0, 0, 0, 0.08)'),
- muted: get('--bx-analytics-muted', '#6b7280'),
- good: get('--bx-analytics-good', '#047857'),
- bad: get('--bx-analytics-bad', '#b91c1c'),
- palette: aPalette
- };
- };
- /**
- * Several values over time (kind `series`: `series` of {label, points of {t, v, partial}})
- * as lines per category, the bucket in progress dashed.
- */
- BxAnalyticsDashboard.prototype.getSeriesLines = function (oData) {
- var aSeries = oData.series || [];
- return {
- kind: 'lines',
- metric: oData.metric,
- bucket: oData.bucket,
- tz: oData.tz,
- legend: true,
- labels: aSeries.length ? aSeries[0].points.map(function (oPoint) { return oPoint.t; }) : [],
- datasets: aSeries.map(function (oSeries) {
- return {
- label: oSeries.label,
- values: oSeries.points.map(function (oPoint) { return oPoint.v; }),
- partial: oSeries.points.map(function (oPoint) { return !!oPoint.partial; })
- };
- })
- };
- };
- /**
- * Draw the chart a JSON element describes, in the canvas of the container before it.
- * Kinds: a metric over time (the overview, `series` with every metric), stacked bars per
- * category (`labels` and `datasets`), lines per category (`datasets`, kind `lines`), and
- * several values of one metric over time (kind `series`, the query).
- */
- BxAnalyticsDashboard.prototype.drawChart = function (oJson, oData) {
- var oContainer = oJson.previousElementSibling;
- var oCanvas = oContainer ? oContainer.querySelector('canvas') : null;
- if (!oCanvas || typeof Chart === 'undefined')
- return;
- if (oCanvas._bxAnalyticsChart) {
- oCanvas._bxAnalyticsChart.destroy();
- oCanvas._bxAnalyticsChart = null;
- }
- var $this = this;
- var oColors = this.getColors(oContainer);
- var bReducedMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
- if (oData.kind === 'series')
- oData = this.getSeriesLines(oData);
- var sKind = oData.kind || 'metric';
- var sMetric = oData.metric || 'number';
- var oChart;
- var oScales = {
- x: {
- stacked: sKind === 'stacked',
- grid: {display: false},
- ticks: {color: oColors.muted, autoSkip: true, maxRotation: 0, maxTicksLimit: 8}
- },
- y: {
- stacked: sKind === 'stacked',
- beginAtZero: true,
- grid: {color: oColors.grid},
- border: {display: false},
- ticks: {
- color: oColors.muted,
- precision: sMetric === 'pages_per_session' ? 1 : (sMetric === 'cls' ? undefined : 0),
- callback: function (fValue) {
- return $this.formatMetric(sMetric, fValue, oData);
- }
- }
- }
- };
- if (sKind === 'metric') {
- var aLabels = [], aValues = [], aPartial = [];
- oData.series.forEach(function (oPoint) {
- aLabels.push($this.formatBucket(oPoint.t, oData.bucket, oData.tz));
- aValues.push(oPoint.future ? null : oPoint[sMetric]);
- aPartial.push(!!oPoint.partial);
- });
- oChart = {
- type: 'line',
- data: {
- labels: aLabels,
- datasets: [{
- label: oData.titles[sMetric],
- data: aValues,
- borderColor: oColors.accent,
- backgroundColor: oColors.fill,
- borderWidth: 2,
- fill: true,
- tension: 0.3,
- pointRadius: 0,
- pointHoverRadius: 4,
- spanGaps: false,
- // the period still in progress is drawn dashed, so its low value doesn't read as a drop
- segment: {
- borderDash: function (oContext) {
- return aPartial[oContext.p1DataIndex] ? [4, 4] : undefined;
- }
- }
- }]
- },
- legend: false,
- label: function (oContext) {
- return oData.titles[sMetric] + ': ' + $this.formatMetric(sMetric, oContext.parsed.y, oData);
- }
- };
- }
- else {
- oChart = {
- type: sKind === 'stacked' ? 'bar' : 'line',
- data: {
- labels: oData.labels.map(function (iTime) { return $this.formatBucket(iTime, oData.bucket || 'day', oData.tz || 'UTC'); }),
- datasets: oData.datasets.map(function (oSet, iIndex) {
- // a set may ask for a meaning colour (good, bad) instead of the next in the palette
- var sColor = oSet.tone && oColors[oSet.tone] ? oColors[oSet.tone] : oColors.palette[iIndex % oColors.palette.length];
- return {
- label: oSet.label,
- data: oSet.values,
- backgroundColor: sColor,
- borderColor: sColor,
- borderWidth: sKind === 'stacked' ? 0 : (oSet.dashed ? 1.5 : 2),
- // reference lines, such as a metric's thresholds
- borderDash: oSet.dashed ? [6, 4] : undefined,
- spanGaps: false,
- borderRadius: sKind === 'stacked' ? 2 : 0,
- pointRadius: 0,
- pointHoverRadius: sKind === 'stacked' ? 0 : 4,
- tension: 0.3,
- // the bucket still in progress is drawn dashed
- segment: oSet.partial ? {
- borderDash: function (oContext) {
- return oSet.partial[oContext.p1DataIndex] ? [4, 4] : undefined;
- }
- } : undefined
- };
- })
- },
- legend: oData.legend || oData.datasets.length > 1,
- label: function (oContext) {
- return oContext.dataset.label + ': ' + $this.formatMetric(sMetric, oContext.parsed.y, oData);
- }
- };
- }
- oCanvas._bxAnalyticsChart = new Chart(oCanvas, {
- type: oChart.type,
- data: oChart.data,
- options: {
- responsive: true,
- maintainAspectRatio: false,
- animation: bReducedMotion ? false : {duration: 300},
- interaction: {mode: 'index', intersect: false},
- plugins: {
- legend: {
- display: oChart.legend,
- position: 'bottom',
- labels: {color: oColors.muted, boxWidth: 12, boxHeight: 12}
- },
- tooltip: {
- callbacks: {label: oChart.label}
- }
- },
- scales: oScales
- }
- });
- };
- BxAnalyticsDashboard.prototype.formatBucket = function (iTime, sBucket, sTz) {
- var oDate = new Date(iTime * 1000);
- var oOptions = {timeZone: sTz || 'UTC'};
- if (sBucket === 'hour') {
- oOptions.hour = 'numeric';
- oOptions.minute = '2-digit';
- }
- else if (sBucket === 'month') {
- oOptions.month = 'short';
- oOptions.year = 'numeric';
- }
- else {
- oOptions.month = 'short';
- oOptions.day = 'numeric';
- }
- try {
- return new Intl.DateTimeFormat(document.documentElement.lang || undefined, oOptions).format(oDate);
- } catch (e) {
- return oDate.toISOString().slice(0, sBucket === 'hour' ? 16 : 10);
- }
- };
- BxAnalyticsDashboard.prototype.formatMetric = function (sMetric, fValue, oData) {
- if (fValue === null || fValue === undefined)
- return '';
- switch (sMetric) {
- // amounts in the site's default currency, which the chart data names
- case 'money':
- try {
- if (oData && oData.currency)
- 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);
- } catch (e) {
- // an unknown currency code: the number alone
- }
- break;
- case 'bounce_rate':
- return Math.round(fValue) + '%';
- // timings: 850ms, 2.40s
- case 'ms':
- return fValue >= 1000 ? (fValue / 1000).toFixed(2) + (this._aStrings.duration_s || 's') : Math.round(fValue) + (this._aStrings.ms || 'ms');
- // layout shift: a score
- case 'cls':
- return Number(fValue).toFixed(fValue && fValue < 0.1 ? 3 : 2);
- case 'pages_per_session':
- return Number(fValue).toFixed(1);
- case 'duration':
- case 'engaged':
- var iSeconds = Math.round(fValue);
- if (iSeconds < 60)
- return iSeconds + (this._aStrings.duration_s || 's');
- if (iSeconds < 3600)
- return Math.floor(iSeconds / 60) + (this._aStrings.duration_m || 'm') + ' ' + (iSeconds % 60) + (this._aStrings.duration_s || 's');
- return Math.floor(iSeconds / 3600) + (this._aStrings.duration_h || 'h') + ' ' + Math.floor((iSeconds % 3600) / 60) + (this._aStrings.duration_m || 'm');
- }
- try {
- return new Intl.NumberFormat(document.documentElement.lang || undefined, {notation: 'compact', maximumFractionDigits: 1}).format(fValue);
- } catch (e) {
- return String(fValue);
- }
- };
- /** @} */
|