PageSourceSearch

https://mtg.studio/js/ui/analytics/panels-tech.js

js mtg.studio collected 2026-10-09 22:15:58 UTC 10,189 bytes, 226 lines download raw bytes

1/**
2 * Environment panels: geography (+ the world map), platform/device breakdown, and the
3 * live realtime panel.
4 *
5 * Mixin: Object.assign'd onto the `AnalyticsDashboard` singleton in ./index.js, so `this`
6 * is that singleton exactly as before the split.
7 */
8import { api } from '../../data/api.js';
9import { logger } from '../../core/logger.js';
10import { t } from '../../locales.js';
11import { REALTIME_MS } from './constants.js';
12import {
13    el,
14    esc,
15    fmtNum,
16    fmtDuration,
17    featureLabel,
18    osLabel,
19    countryFlag,
20    countryName,
21    isAuthFailure,
22} from './format.js';
23import {
24    loadingHtml,
25    emptyHtml,
26    adminOnlyHtml,
27    errorHtml,
28    kpiCard,
29    card,
30    barList,
31    worldMapSvg,
32    donutChart,
33} from './charts.js';
34
35const log = logger.child('analytics-dashboard');
36
37export const analyticsPanelsTech = {
38    // ---- Geography ------------------------------------------------------------------------
39
40    /** @param {HTMLElement} panel @param {number} token */
41    async _renderGeo(panel, token) {
42        panel.innerHTML = loadingHtml();
43        const r = await api.analytics.geo(this._range);
44        if (!this._fresh(token)) return;
45        if (isAuthFailure(r)) return void (panel.innerHTML = adminOnlyHtml());
46        if (!r || r.success === false) return void (panel.innerHTML = errorHtml(r));
47
48        const countries = /** @type {any[]} */ (Array.isArray(r.countries) ? r.countries : []);
49        if (!countries.length) {
50            panel.innerHTML = emptyHtml('fa-earth-americas', t('dashNoGeoData'));
51            return;
52        }
53        const cities = /** @type {any[]} */ (Array.isArray(r.cities) ? r.cities : []);
54        const maxSessions = Math.max(1, ...countries.map((c) => c.sessions || 0));
55        const rows = countries
56            .map(
57                (c) =>
58                    `<tr><td class="dash-td-country">${countryFlag(c.country)} ${countryName(c.country)}</td>` +
59                    `<td class="dash-td-bar"><span class="dash-bar-track"><span class="dash-bar-fill" style="width:${Math.max(2, Math.round(((c.sessions || 0) / maxSessions) * 100))}%"></span></span></td>` +
60                    `<td class="dash-td-num">${fmtNum(c.sessions)}</td>` +
61                    `<td class="dash-td-num">${fmtDuration(c.avgSessionLengthSec)}</td></tr>`
62            )
63            .join('');
64        const countryTable = card(
65            'dashPanelSessionsByCountry',
66            'fa-earth-americas',
67            `<div class="dash-table-wrap"><table class="dash-table"><thead><tr><th>${esc(t('dashThCountry'))}</th><th></th><th class="dash-td-num">${esc(t('dashThSessions'))}</th><th class="dash-td-num">${esc(t('dashThAvgLength'))}</th></tr></thead><tbody>${rows}</tbody></table></div>`
68        );
69
70        // City drill-down (each already tagged with its country by the backend).
71        let cityTable = '';
72        if (cities.length) {
73            const maxCity = Math.max(1, ...cities.map((c) => c.sessions || 0));
74            const cityRows = cities
75                .map(
76                    (c) =>
77                        `<tr><td>${esc(c.city)}</td>` +
78                        `<td class="dash-td-country">${countryFlag(c.country)} ${countryName(c.country)}</td>` +
79                        `<td class="dash-td-bar"><span class="dash-bar-track"><span class="dash-bar-fill" style="width:${Math.max(2, Math.round(((c.sessions || 0) / maxCity) * 100))}%"></span></span></td>` +
80                        `<td class="dash-td-num">${fmtNum(c.sessions)}</td>` +
81                        `<td class="dash-td-num">${fmtDuration(c.avgSessionLengthSec)}</td></tr>`
82                )
83                .join('');
84            cityTable = card(
85                'dashPanelTopCities',
86                'fa-city',
87                `<div class="dash-table-wrap"><table class="dash-table"><thead><tr><th>${esc(t('dashThCity'))}</th><th>${esc(t('dashThCountry'))}</th><th></th><th class="dash-td-num">${esc(t('dashThSessions'))}</th><th class="dash-td-num">${esc(t('dashThAvgLength'))}</th></tr></thead><tbody>${cityRows}</tbody></table></div>`
88            );
89        }
90
91        panel.innerHTML =
92            card(
93                'dashPanelWorldMap',
94                'fa-earth-americas',
95                `<div class="dash-worldmap" id="dash-worldmap">${loadingHtml(t('dashLoadingMap'))}</div>`
96            ) +
97            countryTable +
98            cityTable +
99            `<p class="dash-note"><i class="fas fa-shield-halved" aria-hidden="true"></i> ${esc(t('dashGeoPrivacyNote', { k: 20 }))}</p>`;
100        // The map geometry (~120KB) is lazy-loaded only when the Geography tab is viewed.
101        this._renderWorldMap(countries, token);
102    },
103    /**
104     * Fill the world-map mount with a choropleth shaded by sessions. Loads the generated
105     * geometry asset on demand. @param {any[]} countries @param {number} token
106     */
107    async _renderWorldMap(countries, token) {
108        const mount = el('dash-worldmap');
109        if (!mount) return;
110        let paths;
111        let viewBox;
112        try {
113            const mod = await import('../../data/world-geo.js');
114            paths = mod.WORLD_PATHS;
115            viewBox = mod.WORLD_VIEWBOX;
116        } catch (err) {
117            log.warn('world-map asset failed to load', err);
118            mount.innerHTML = emptyHtml('fa-earth-americas', t('dashMapUnavailable'));
119            return;
120        }
121        // Bail if the user navigated away while the asset was loading.
122        if (token !== this._token || this._tab !== 'geo') return;
123        mount.innerHTML = worldMapSvg(countries, paths, viewBox);
124    },
125    // ---- Platform & tech ------------------------------------------------------------------
126
127    /** @param {HTMLElement} panel @param {number} token */
128    async _renderPlatform(panel, token) {
129        panel.innerHTML = loadingHtml();
130        const r = await api.analytics.platform(this._range);
131        if (!this._fresh(token)) return;
132        if (isAuthFailure(r)) return void (panel.innerHTML = adminOnlyHtml());
133        if (!r || r.success === false) return void (panel.innerHTML = errorHtml(r));
134
135        const platforms = /** @type {any[]} */ (Array.isArray(r.platforms) ? r.platforms : []);
136        const devices = /** @type {any[]} */ (Array.isArray(r.devices) ? r.devices : []);
137        const browsers = /** @type {any[]} */ (Array.isArray(r.browsers) ? r.browsers : []);
138        const operatingSystems = /** @type {any[]} */ (
139            Array.isArray(r.operatingSystems) ? r.operatingSystems : []
140        );
141        const languages = /** @type {any[]} */ (Array.isArray(r.languages) ? r.languages : []);
142
143        const donut = card(
144            'dashPanelPlatform',
145            'fa-chart-pie',
146            donutChart(
147                platforms.map((x) => ({ label: esc(x.platform), value: x.sessions || 0 })),
148                t('dashDonutSessions')
149            )
150        );
151        /** Avg-session-length sub-label per slice (present on all Platform breakdowns now). */
152        const withLen = (/** @type {string} */ label, /** @type {any} */ x) => ({
153            label,
154            value: x.sessions || 0,
155            sub:
156                typeof x.avgSessionLengthSec === 'number' ? fmtDuration(x.avgSessionLengthSec) : '',
157        });
158        const osCard = card(
159            'dashPanelOperatingSystem',
160            'fa-computer',
161            barList(operatingSystems.map((x) => withLen(osLabel(x.os), x)))
162        );
163        const deviceCard = card(
164            'dashPanelDeviceCategory',
165            'fa-mobile-screen',
166            barList(devices.map((x) => withLen(esc(x.device), x)))
167        );
168        const browserCard = card(
169            'dashPanelBrowser',
170            'fa-globe',
171            barList(browsers.map((x) => withLen(esc(x.browser), x)))
172        );
173        const langCard = card(
174            'dashPanelLanguage',
175            'fa-language',
176            barList(languages.map((x) => withLen(esc(x.language), x)))
177        );
178        panel.innerHTML =
179            '<div class="dash-grid-2">' +
180            donut +
181            osCard +
182            deviceCard +
183            browserCard +
184            langCard +
185            '</div>' +
186            `<p class="dash-note"><i class="fas fa-circle-info" aria-hidden="true"></i> ${esc(t('dashPlatformOsNote'))}</p>`;
187    },
188    // ---- Real-time ------------------------------------------------------------------------
189
190    /** @param {HTMLElement} panel @param {number} token @param {boolean} showLoading */
191    async _renderRealtime(panel, token, showLoading) {
192        if (showLoading) panel.innerHTML = loadingHtml(t('dashLoadingLivePulse'));
193        const r = await api.analytics.realtime();
194        // Realtime polls, so only bail on a truly stale token (tab switched away).
195        if (token !== this._token || this._tab !== 'realtime') return;
196        if (isAuthFailure(r)) return void (panel.innerHTML = adminOnlyHtml());
197        if (!r || r.success === false) return void (panel.innerHTML = errorHtml(r));
198        const topFeatures = /** @type {any[]} */ (
199            Array.isArray(r.topFeatures) ? r.topFeatures : []
200        );
201        const topCard = topFeatures.length
202            ? card(
203                  'dashPanelTopFeaturesNow',
204                  'fa-fire',
205                  barList(
206                      topFeatures.map((f) => ({
207                          label: esc(featureLabel(f.feature)),
208                          value: f.events || 0,
209                          valueText: t('dashEvents', { n: fmtNum(f.events) }),
210                      }))
211                  )
212              )
213            : '';
214        panel.innerHTML =
215            '<div class="kpi-grid kpi-grid-live">' +
216            kpiCard(
217                'dashKpiActiveNow',
218                `${fmtNum(r.activeNow)}
218 <i class="fas fa-circle dash-live-dot" aria-hidden="true"></i>`,
219                t('dashFootLast5min')
220            ) +
221            kpiCard('dashKpiEventsPerMin', fmtNum(r.eventsPerMin), t('dashFootLast5min')) +
222            '</div>' +
223            topCard +
224            `<p class="dash-note"><i class="fas fa-bolt" aria-hidden="true"></i> ${esc(t('dashRealtimeNote', { n: REALTIME_MS / 1000 }))}</p>`;
225    },
226};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.