@harshankur/viewcounter 3.0.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/.env.example +50 -6
  2. package/README.md +444 -104
  3. package/admin/apple-touch-icon.png +0 -0
  4. package/admin/assets/world-map.json +1 -0
  5. package/admin/css/admin.css +2568 -0
  6. package/admin/favicon.ico +0 -0
  7. package/admin/favicon.svg +9 -0
  8. package/admin/icon-192.png +0 -0
  9. package/admin/icon-512.png +0 -0
  10. package/admin/index.html +95 -0
  11. package/admin/js/api.js +146 -0
  12. package/admin/js/appTabs.js +100 -0
  13. package/admin/js/charts.js +842 -0
  14. package/admin/js/clamp.js +41 -0
  15. package/admin/js/constants.js +239 -0
  16. package/admin/js/dataTable.js +478 -0
  17. package/admin/js/dom.js +83 -0
  18. package/admin/js/format.js +130 -0
  19. package/admin/js/i18n.js +80 -0
  20. package/admin/js/icons.js +168 -0
  21. package/admin/js/listbox.js +145 -0
  22. package/admin/js/logs.js +318 -0
  23. package/admin/js/main.js +399 -0
  24. package/admin/js/modal.js +171 -0
  25. package/admin/js/overview.js +905 -0
  26. package/admin/js/passwordPrompt.js +75 -0
  27. package/admin/js/table.js +94 -0
  28. package/admin/js/theme.js +72 -0
  29. package/admin/js/toast.js +47 -0
  30. package/admin/js/viewDialogs.js +224 -0
  31. package/admin/js/views.js +751 -0
  32. package/admin/locales/en.json +683 -0
  33. package/admin/site.webmanifest +20 -0
  34. package/config/index.js +122 -4
  35. package/constants.js +334 -3
  36. package/db/AdminRepository.js +488 -0
  37. package/db/DatabaseManager.js +148 -26
  38. package/db/LogRepository.js +354 -0
  39. package/db/adminSchema.js +329 -0
  40. package/db/adminSessionStore.js +104 -0
  41. package/db/analysis.js +479 -0
  42. package/db/rejectionCounter.js +117 -0
  43. package/db/retention.js +97 -0
  44. package/index.js +91 -24
  45. package/middleware/adminAuth.js +244 -0
  46. package/middleware/adminValidation.js +319 -0
  47. package/middleware/auth.js +2 -2
  48. package/middleware/security.js +26 -2
  49. package/middleware/validation.js +50 -2
  50. package/package.json +20 -10
  51. package/routes/admin.js +546 -0
  52. package/routes/analytics.js +207 -19
  53. package/tracker/tracker.js +191 -0
  54. package/utils/appIdUtils.js +1 -1
  55. package/utils/cookieUtils.js +47 -0
  56. package/utils/durationUtils.js +33 -0
  57. package/utils/errorUtils.js +39 -1
  58. package/utils/geoCity.js +87 -0
  59. package/utils/ipUtils.js +1 -1
  60. package/utils/privacyUtils.js +2 -2
  61. package/utils/referrerParser.js +23 -5
  62. package/utils/secretStore.js +1 -1
  63. package/utils/userAgentParser.js +52 -3
  64. package/utils/visitorContext.js +70 -0
@@ -0,0 +1,842 @@
1
+ /**
2
+ * Charts for the Overview: stat tiles with sparklines, a trend line for any
3
+ * headline number, ranked tables with bars, a world map, a weekday-by-hour
4
+ * heatmap, and a per-minute column chart. Hand-drawn SVG, no library, no
5
+ * HTML parsing.
6
+ *
7
+ * Built to the data-viz method the project follows:
8
+ * - one series per chart, in one hue (--chart-series), so no legend box: the
9
+ * card title names the series;
10
+ * - magnitude on the map and the heatmap is a one-hue sequential ramp
11
+ * (--map-1..--map-5), validated for monotone lightness in both themes,
12
+ * with a separate "none" neutral (--map-0);
13
+ * - thin marks, 2px lines, >= 8px markers with a surface ring, hairline grid;
14
+ * - every value is readable without hovering (ranked tables, a table view
15
+ * for the trend and the heatmap, a list beside the map), and hover and
16
+ * keyboard focus show the same tooltip.
17
+ */
18
+
19
+ import { clampText } from './clamp.js';
20
+ import { el, replaceChildren } from './dom.js';
21
+ import { formatNumber, formatPercent, formatPeriod } from './format.js';
22
+ import { icon } from './icons.js';
23
+ import { t, tOr, currentLocale } from './i18n.js';
24
+ import { CHART, KEY } from './constants.js';
25
+
26
+ const SVG_NS = 'http://www.w3.org/2000/svg';
27
+ const MINUTE_MS = 60 * 1000;
28
+ const HOUR_MS = 60 * MINUTE_MS;
29
+
30
+ /** An SVG element with attributes and children. */
31
+ function svg(tag, attrs = {}, children = []) {
32
+ const node = document.createElementNS(SVG_NS, tag);
33
+ for (const [name, value] of Object.entries(attrs)) {
34
+ if (value !== null && value !== undefined && value !== false) node.setAttribute(name, String(value));
35
+ }
36
+ for (const child of children) if (child) node.append(child);
37
+ return node;
38
+ }
39
+
40
+ const text = (value) => document.createTextNode(value);
41
+ const present = (value) => value !== null && value !== undefined && !Number.isNaN(value);
42
+
43
+ // ---- Tooltip -----------------------------------------------------------------
44
+
45
+ /**
46
+ * One tooltip per chart. Values lead, labels follow; the row for the charted
47
+ * series is keyed with a short stroke of the mark's colour, never a box.
48
+ */
49
+ export function createTooltip(host) {
50
+ const node = el('div', { className: 'chart-tooltip', attrs: { role: 'status', 'aria-live': 'polite' } });
51
+ node.hidden = true;
52
+ host.append(node);
53
+
54
+ return {
55
+ /**
56
+ * @param {{ title: string, rows: Array<{ value: string, label: string, key?: boolean }> }} content
57
+ * @param {{ x: number, y: number }} at position within the host
58
+ */
59
+ show({ title, rows }, at) {
60
+ replaceChildren(node, [
61
+ el('div', { className: 'chart-tooltip-title', text: title }),
62
+ ...rows.map((row) => el('div', { className: 'chart-tooltip-row' }, [
63
+ row.key ? el('span', { className: 'chart-tooltip-key', attrs: { 'aria-hidden': 'true' } }) : null,
64
+ el('strong', { text: row.value }),
65
+ el('span', { text: row.label }),
66
+ ])),
67
+ ]);
68
+ node.hidden = false;
69
+ const hostWidth = host.clientWidth;
70
+ const width = node.offsetWidth;
71
+ const left = Math.min(Math.max(at.x + CHART.TOOLTIP_OFFSET, 0), Math.max(hostWidth - width, 0));
72
+ node.style.left = `${left}px`;
73
+ node.style.top = `${Math.max(at.y - node.offsetHeight - CHART.TOOLTIP_OFFSET, 0)}px`;
74
+ },
75
+ hide() {
76
+ node.hidden = true;
77
+ },
78
+ };
79
+ }
80
+
81
+ /** Pointer position within an element, from a pointer event. */
82
+ function pointIn(host, event) {
83
+ const box = host.getBoundingClientRect();
84
+ return { x: event.clientX - box.left, y: event.clientY - box.top };
85
+ }
86
+
87
+ /** Centre-top of an element, within a host, for keyboard-driven tooltips. */
88
+ function anchorOf(host, target) {
89
+ const box = host.getBoundingClientRect();
90
+ const mark = target.getBoundingClientRect();
91
+ return { x: mark.left - box.left + mark.width / 2, y: mark.top - box.top };
92
+ }
93
+
94
+ // ---- Stat tiles --------------------------------------------------------------
95
+
96
+ /**
97
+ * A tiny line of a series, for a stat tile. Decoration: the tile's number and
98
+ * the trend chart carry the values. Gaps (no data) break the line.
99
+ * @param {Array<number|null>} values
100
+ */
101
+ export function sparkline(values) {
102
+ const { WIDTH, HEIGHT, PAD } = CHART.SPARK;
103
+ const root = svg('svg', {
104
+ class: 'sparkline', viewBox: `0 0 ${WIDTH} ${HEIGHT}`, preserveAspectRatio: 'none',
105
+ 'aria-hidden': 'true', focusable: 'false',
106
+ });
107
+ const known = values.filter(present);
108
+ if (known.length < 2) return root;
109
+ const max = Math.max(...known);
110
+ const min = Math.min(0, ...known);
111
+ const x = (index) => (index / (values.length - 1)) * WIDTH;
112
+ const y = (value) => PAD + (HEIGHT - 2 * PAD) * (1 - (max === min ? 0.5 : (value - min) / (max - min)));
113
+ let d = '';
114
+ let drawing = false;
115
+ values.forEach((value, index) => {
116
+ if (!present(value)) {
117
+ drawing = false;
118
+ return;
119
+ }
120
+ d += `${drawing ? 'L' : 'M'}${x(index).toFixed(1)},${y(value).toFixed(1)}`;
121
+ drawing = true;
122
+ });
123
+ root.append(svg('path', { class: 'sparkline-line', d, 'vector-effect': 'non-scaling-stroke' }));
124
+ return root;
125
+ }
126
+
127
+ /**
128
+ * The headline numbers: label, value, change against the period before, and
129
+ * a sparkline of the period. With `onSelect`, each tile is a toggle button
130
+ * that picks the metric the trend chart shows.
131
+ *
132
+ * @param {Array<{ key: string, label: string, value: string, exact?: string,
133
+ * delta?: { text: string, direction: 'up'|'down'|'flat', tone: 'good'|'bad'|'neutral', period: string } | null,
134
+ * note?: string, spark: Array<number|null> }>} tiles
135
+ * @param {{ selected?: string, onSelect?: (key: string) => void, label?: string }} [options]
136
+ */
137
+ export function statTiles(tiles, { selected, onSelect, label } = {}) {
138
+ return el('div', { className: 'stat-tiles', attrs: { role: 'group', 'aria-label': label ?? null } }, tiles.map((tile) => {
139
+ const direction = { up: 'arrowUp', down: 'arrowDown' }[tile.delta?.direction];
140
+ const content = [
141
+ el('span', { className: 'stat-label', text: tile.label }),
142
+ el('span', {
143
+ className: 'stat-value',
144
+ text: tile.value,
145
+ attrs: tile.exact && tile.exact !== tile.value ? { title: tile.exact } : {},
146
+ }),
147
+ tile.delta
148
+ ? el('span', { className: `stat-delta tone-${tile.delta.tone}` }, [
149
+ direction ? icon(direction) : null,
150
+ el('span', { className: 'stat-delta-value', text: tile.delta.text }),
151
+ el('span', { className: 'stat-delta-period', text: tile.delta.period }),
152
+ ])
153
+ : el('span', { className: 'stat-delta stat-note', text: tile.note ?? '' }),
154
+ sparkline(tile.spark),
155
+ ];
156
+ if (!onSelect) return el('div', { className: 'stat-tile', dataset: { metric: tile.key } }, content);
157
+ const active = tile.key === selected;
158
+ return el('button', {
159
+ className: `stat-tile selectable${active ? ' active' : ''}`,
160
+ attrs: { type: 'button', 'aria-pressed': String(active) },
161
+ dataset: { metric: tile.key },
162
+ on: { click: () => onSelect(tile.key) },
163
+ }, content);
164
+ }));
165
+ }
166
+
167
+ // ---- Nice axis ---------------------------------------------------------------
168
+
169
+ /**
170
+ * A clean upper bound and tick step for an axis from zero.
171
+ * @param {number} max
172
+ * @param {{ integer?: boolean }} [options] integer keeps steps whole (counts, milliseconds)
173
+ */
174
+ export function niceScale(max, { integer = true } = {}) {
175
+ if (!(max > 0)) return integer ? { top: CHART.Y_TICKS, step: 1 } : { top: 1, step: 1 / CHART.Y_TICKS };
176
+ const rough = max / CHART.Y_TICKS;
177
+ const magnitude = 10 ** Math.floor(Math.log10(rough));
178
+ let step = [1, 2, 2.5, 5, 10].map((m) => m * magnitude).find((candidate) => candidate >= rough);
179
+ if (integer) step = Math.max(1, Math.round(step));
180
+ return { top: Math.ceil(max / step - 1e-9) * step, step };
181
+ }
182
+
183
+ // ---- Over time -----------------------------------------------------------------
184
+
185
+ /**
186
+ * One metric over time: a line (with a soft wash under counts), a crosshair
187
+ * that snaps to the nearest period, and the same readout from the keyboard
188
+ * (arrow keys, Home, End). A period with nothing to average is a gap, not a
189
+ * zero.
190
+ *
191
+ * @param {{ points: object[], bucket: string, lastInProgress?: boolean,
192
+ * metric: { label: string, value: (point: object) => number|null, format: (value: number) => string,
193
+ * tick?: (value: number) => string, integer?: boolean, area?: boolean } }} data
194
+ */
195
+ export function trendChart({ points, bucket, metric, lastInProgress = false }) {
196
+ const card = el('div', { className: 'chart-body' });
197
+ const tooltip = createTooltip(card);
198
+ const values = points.map((point) => {
199
+ const value = metric.value(point);
200
+ return present(value) ? value : null;
201
+ });
202
+ const known = values.filter(present);
203
+ if (known.length === 0) {
204
+ card.append(el('p', { className: 'chart-empty', text: t('charts.noData') }));
205
+ return card;
206
+ }
207
+
208
+ const { WIDTH, HEIGHT, MARGIN } = CHART.TREND;
209
+ const plotW = WIDTH - MARGIN.left - MARGIN.right;
210
+ const plotH = HEIGHT - MARGIN.top - MARGIN.bottom;
211
+ const max = Math.max(...known);
212
+ const { top, step } = niceScale(max, { integer: metric.integer !== false });
213
+ const tick = metric.tick ?? metric.format;
214
+ const x = (index) => MARGIN.left + (points.length === 1 ? plotW / 2 : (index / (points.length - 1)) * plotW);
215
+ const y = (value) => MARGIN.top + plotH - (value / top) * plotH;
216
+
217
+ const grid = [];
218
+ for (let index = 0; index * step <= top + step / 2; index++) {
219
+ const value = index * step;
220
+ grid.push(svg('line', { class: index === 0 ? 'chart-baseline' : 'chart-grid', x1: MARGIN.left, x2: WIDTH - MARGIN.right, y1: y(value), y2: y(value) }));
221
+ grid.push(svg('text', {
222
+ class: 'chart-tick', x: MARGIN.left - CHART.TICK_GAP, y: y(value), 'text-anchor': 'end', 'dominant-baseline': 'middle',
223
+ }, [text(tick(value))]));
224
+ }
225
+
226
+ const last = points.length - 1;
227
+ const labelIndexes = [...new Set([0, Math.floor(last / 2), last])];
228
+ const xLabels = labelIndexes.map((index) => svg('text', {
229
+ class: 'chart-tick',
230
+ x: x(index),
231
+ y: HEIGHT - MARGIN.bottom + CHART.X_LABEL_GAP,
232
+ 'text-anchor': index === 0 ? 'start' : index === last ? 'end' : 'middle',
233
+ }, [text(formatPeriod(points[index].period, bucket))]));
234
+
235
+ // Segments between gaps; a lone value between two gaps is a dot.
236
+ let line = '';
237
+ let drawing = false;
238
+ const lonely = [];
239
+ values.forEach((value, index) => {
240
+ if (value === null) {
241
+ drawing = false;
242
+ return;
243
+ }
244
+ line += `${drawing ? 'L' : 'M'}${x(index).toFixed(1)},${y(value).toFixed(1)}`;
245
+ if (!drawing && values[index + 1] === null) lonely.push(index);
246
+ if (!drawing && index === last) lonely.push(index);
247
+ drawing = true;
248
+ });
249
+ const gapless = known.length === values.length;
250
+ const area = gapless && metric.area !== false
251
+ ? `${line}L${x(last).toFixed(1)},${y(0)}L${x(0).toFixed(1)},${y(0)}Z`
252
+ : null;
253
+ const lastKnown = values.findLastIndex(present);
254
+
255
+ const crosshair = svg('line', { class: 'chart-crosshair', y1: MARGIN.top, y2: MARGIN.top + plotH, visibility: 'hidden' });
256
+ const focusDot = svg('circle', { class: 'chart-dot', r: CHART.DOT_RADIUS, visibility: 'hidden' });
257
+ const peak = values.indexOf(max);
258
+
259
+ const plot = svg('svg', {
260
+ class: 'trend-svg',
261
+ viewBox: `0 0 ${WIDTH} ${HEIGHT}`,
262
+ role: 'img',
263
+ tabindex: 0,
264
+ 'aria-label': t('charts.trendLabel', {
265
+ metric: metric.label,
266
+ from: formatPeriod(points[0].period, bucket),
267
+ to: formatPeriod(points[last].period, bucket),
268
+ peak: metric.format(max),
269
+ peakAt: formatPeriod(points[peak].period, bucket, { long: true }),
270
+ }),
271
+ }, [
272
+ ...grid,
273
+ area ? svg('path', { class: 'chart-area', d: area }) : null,
274
+ svg('path', { class: 'chart-line', d: line }),
275
+ ...lonely.filter((index) => index !== lastKnown).map((index) => svg('circle', {
276
+ class: 'chart-dot', cx: x(index), cy: y(values[index]), r: CHART.DOT_RADIUS,
277
+ })),
278
+ svg('circle', { class: 'chart-dot', cx: x(lastKnown), cy: y(values[lastKnown]), r: CHART.DOT_RADIUS }),
279
+ ...xLabels,
280
+ crosshair,
281
+ focusDot,
282
+ ]);
283
+
284
+ let activeIndex = -1;
285
+ function highlight(index, at) {
286
+ activeIndex = index;
287
+ const value = values[index];
288
+ crosshair.setAttribute('x1', x(index));
289
+ crosshair.setAttribute('x2', x(index));
290
+ crosshair.setAttribute('visibility', 'visible');
291
+ focusDot.setAttribute('cx', x(index));
292
+ focusDot.setAttribute('cy', y(value ?? 0));
293
+ focusDot.setAttribute('visibility', value === null ? 'hidden' : 'visible');
294
+ const period = formatPeriod(points[index].period, bucket, { long: true });
295
+ tooltip.show({
296
+ title: lastInProgress && index === last ? t('charts.inProgress', { period }) : period,
297
+ rows: [{ value: value === null ? t('common.none') : metric.format(value), label: metric.label, key: true }],
298
+ }, at ?? anchorOf(card, crosshair));
299
+ }
300
+ function reset() {
301
+ activeIndex = -1;
302
+ crosshair.setAttribute('visibility', 'hidden');
303
+ focusDot.setAttribute('visibility', 'hidden');
304
+ tooltip.hide();
305
+ }
306
+
307
+ plot.addEventListener('pointermove', (event) => {
308
+ const box = plot.getBoundingClientRect();
309
+ const svgX = ((event.clientX - box.left) / box.width) * WIDTH;
310
+ const ratio = (svgX - MARGIN.left) / plotW;
311
+ const index = Math.min(last, Math.max(0, Math.round(ratio * last)));
312
+ highlight(index, pointIn(card, event));
313
+ });
314
+ plot.addEventListener('pointerleave', reset);
315
+ plot.addEventListener('blur', reset);
316
+ plot.addEventListener('keydown', (event) => {
317
+ const moves = {
318
+ [KEY.ARROW_RIGHT]: Math.min(last, activeIndex + 1),
319
+ [KEY.ARROW_LEFT]: Math.max(0, activeIndex < 0 ? last : activeIndex - 1),
320
+ [KEY.HOME]: 0,
321
+ [KEY.END]: last,
322
+ };
323
+ if (event.key in moves) {
324
+ event.preventDefault();
325
+ highlight(moves[event.key]);
326
+ } else if (event.key === KEY.ESCAPE) {
327
+ reset();
328
+ }
329
+ });
330
+
331
+ card.append(plot);
332
+ return card;
333
+ }
334
+
335
+ /**
336
+ * Every metric per period, as a table: the accessible twin of the trend
337
+ * chart, and the place to read all of them side by side.
338
+ * @param {{ points: object[], bucket: string, metrics: Array<{ label: string,
339
+ * value: (point: object) => number|null, format: (value: number) => string }> }} data
340
+ */
341
+ export function trendTable({ points, bucket, metrics }) {
342
+ return el('table', { className: 'chart-table' }, [
343
+ el('thead', {}, [el('tr', {}, [
344
+ el('th', { text: t('charts.period'), attrs: { scope: 'col' } }),
345
+ ...metrics.map((metric) => el('th', { text: metric.label, attrs: { scope: 'col' } })),
346
+ ])]),
347
+ el('tbody', {}, [...points].reverse().map((point) => el('tr', {}, [
348
+ el('th', { text: formatPeriod(point.period, bucket, { long: true }), attrs: { scope: 'row' } }),
349
+ ...metrics.map((metric) => {
350
+ const value = metric.value(point);
351
+ return el('td', { text: present(value) ? metric.format(value) : t('common.none') });
352
+ }),
353
+ ]))),
354
+ ]);
355
+ }
356
+
357
+ // ---- Ranked tables -------------------------------------------------------------
358
+
359
+ /**
360
+ * A ranked breakdown: each label with a bar behind it scaled to the leading
361
+ * row, then one column per measure, all visible. A row with `onSelect` is a
362
+ * button that narrows everything to that value.
363
+ *
364
+ * @param {{ labelHeader: string, columns: Array<{ label: string, value: (row: object) => number|null,
365
+ * format: (value: number) => string, className?: string }>,
366
+ * rows: Array<{ label: string, detail?: string, bar: number, muted?: boolean,
367
+ * onSelect?: () => void, selectLabel?: string }>, empty?: string }} data
368
+ */
369
+ export function rankTable({ labelHeader, columns, rows, empty }) {
370
+ if (rows.length === 0) return el('p', { className: 'chart-empty', text: empty ?? t('charts.noData') });
371
+ return el('table', { className: 'rank-table' }, [
372
+ el('thead', {}, [el('tr', {}, [
373
+ el('th', { className: 'rank-label-head', text: labelHeader, attrs: { scope: 'col' } }),
374
+ ...columns.map((column) => el('th', {
375
+ className: `rank-num ${column.className ?? ''}`.trim(), text: column.label, attrs: { scope: 'col' },
376
+ })),
377
+ ])]),
378
+ el('tbody', {}, rows.map((row) => {
379
+ const bar = el('span', { className: 'rank-bar', attrs: { 'aria-hidden': 'true' } });
380
+ bar.style.width = `${Math.max(0, Math.min(1, row.bar)) * 100}%`;
381
+ const labelText = [
382
+ clampText(row.label, { className: 'rank-text' }),
383
+ row.detail ? clampText(row.detail, { className: 'rank-detail' }) : null,
384
+ ];
385
+ const label = row.onSelect
386
+ ? el('button', {
387
+ className: 'rank-select',
388
+ attrs: { type: 'button', title: row.selectLabel ?? null, 'aria-label': row.selectLabel ?? null },
389
+ on: { click: row.onSelect },
390
+ }, [bar, ...labelText, icon('filter', { className: 'rank-filter-icon' })])
391
+ : el('span', { className: 'rank-static' }, [bar, ...labelText]);
392
+ return el('tr', { className: row.muted ? 'rank-muted' : '' }, [
393
+ el('th', { className: 'rank-label', attrs: { scope: 'row' } }, [label]),
394
+ ...columns.map((column) => {
395
+ const value = column.value(row);
396
+ return el('td', {
397
+ className: `rank-num ${column.className ?? ''}`.trim(),
398
+ text: present(value) ? column.format(value) : t('common.none'),
399
+ });
400
+ }),
401
+ ]);
402
+ })),
403
+ ]);
404
+ }
405
+
406
+ // ---- World map ---------------------------------------------------------------
407
+
408
+ let mapAssetPromise = null;
409
+
410
+ /** The projected country shapes, fetched once and cached. */
411
+ function loadMapAsset() {
412
+ if (!mapAssetPromise) {
413
+ mapAssetPromise = fetch('assets/world-map.json', { credentials: 'same-origin' }).then((response) => response.json());
414
+ }
415
+ return mapAssetPromise;
416
+ }
417
+
418
+ /**
419
+ * Class breaks for a sequential scale: up to CHART.MAP_CLASSES bins over the
420
+ * non-zero counts, by quantile, with duplicate breaks merged.
421
+ * @param {number[]} values
422
+ * @returns {number[]} ascending upper bounds, one per class
423
+ */
424
+ export function classBreaks(values) {
425
+ const sorted = values.filter((value) => value > 0).sort((a, b) => a - b);
426
+ if (sorted.length === 0) return [];
427
+ const breaks = [];
428
+ for (let index = 1; index <= CHART.MAP_CLASSES; index++) {
429
+ const bound = sorted[Math.min(sorted.length - 1, Math.ceil((index / CHART.MAP_CLASSES) * sorted.length) - 1)];
430
+ if (!breaks.includes(bound)) breaks.push(bound);
431
+ }
432
+ return breaks;
433
+ }
434
+
435
+ function classOf(value, breaks) {
436
+ if (!value) return 0;
437
+ const index = breaks.findIndex((bound) => value <= bound);
438
+ const position = index === -1 ? breaks.length - 1 : index;
439
+ // Spread fewer classes across the whole ramp so the top class is always the darkest.
440
+ return 1 + Math.round((position / Math.max(1, breaks.length - 1)) * (CHART.MAP_CLASSES - 1));
441
+ }
442
+
443
+ /** The scale's legend: "none", then each class with its range. */
444
+ function scaleLegend(breaks, noneLabel) {
445
+ const items = [el('span', { className: 'legend-item' }, [
446
+ el('span', { className: 'legend-swatch map-0', attrs: { 'aria-hidden': 'true' } }),
447
+ el('span', { text: noneLabel }),
448
+ ])];
449
+ breaks.forEach((bound, index) => {
450
+ const from = index === 0 ? 1 : breaks[index - 1] + 1;
451
+ items.push(el('span', { className: 'legend-item' }, [
452
+ el('span', { className: `legend-swatch map-${classOf(bound, breaks)}`, attrs: { 'aria-hidden': 'true' } }),
453
+ el('span', { text: from === bound ? formatNumber(bound) : `${formatNumber(from)}–${formatNumber(bound)}` }),
454
+ ]));
455
+ });
456
+ return el('div', { className: 'map-legend' }, items);
457
+ }
458
+
459
+ /** A region's name in the UI's language, or its code. */
460
+ export function regionName(code) {
461
+ try {
462
+ return new Intl.DisplayNames([currentLocale()], { type: 'region' }).of(code) ?? code;
463
+ } catch {
464
+ return code;
465
+ }
466
+ }
467
+
468
+ /**
469
+ * A choropleth of views by country, with a ranked list beside it that is both
470
+ * the legend's companion and its table view. Clicking a country in the list
471
+ * (with `onSelect`) narrows everything to it.
472
+ *
473
+ * @param {{ countries: Array<{ country: string, eventType: string|null, views: number }>,
474
+ * onSelect?: (code: string) => void }} data
475
+ */
476
+ export function worldMap({ countries, onSelect }) {
477
+ const body = el('div', { className: 'chart-body map-layout' });
478
+ const mapHost = el('div', { className: 'map-host' });
479
+ const tooltip = createTooltip(mapHost);
480
+ const list = el('ol', { className: 'country-list', attrs: { 'aria-label': t('charts.topCountries') } });
481
+
482
+ // Totals per country, and the per-type split for tooltips.
483
+ const byCountry = new Map();
484
+ for (const entry of countries) {
485
+ const record = byCountry.get(entry.country) || { total: 0, types: [] };
486
+ record.total += entry.views;
487
+ record.types.push(entry);
488
+ byCountry.set(entry.country, record);
489
+ }
490
+ const total = [...byCountry.values()].reduce((sum, record) => sum + record.total, 0);
491
+ const breaks = classBreaks([...byCountry.values()].map((record) => record.total));
492
+
493
+ function tooltipFor(code) {
494
+ const record = byCountry.get(code);
495
+ const views = record?.total ?? 0;
496
+ const rows = [{ value: formatNumber(views), label: t('charts.views'), key: true }];
497
+ for (const entry of [...(record?.types ?? [])].sort((a, b) => b.views - a.views).slice(0, CHART.TOOLTIP_TYPES)) {
498
+ rows.push({
499
+ value: formatNumber(entry.views),
500
+ label: entry.eventType === null ? t('charts.otherTypes') : tOr(`eventTypes.${entry.eventType}`, entry.eventType),
501
+ });
502
+ }
503
+ if (total > 0 && views > 0) rows.push({ value: formatPercent(views / total), label: t('charts.share') });
504
+ return { title: regionName(code), rows };
505
+ }
506
+
507
+ const ranked = [...byCountry.entries()].filter(([, record]) => record.total > 0).sort((a, b) => b[1].total - a[1].total);
508
+ const listMax = ranked[0]?.[1].total ?? 0;
509
+
510
+ const marks = new Map();
511
+ function setActive(code, on) {
512
+ for (const mark of marks.get(code) || []) mark.classList.toggle('active', on);
513
+ }
514
+
515
+ loadMapAsset().then((asset) => {
516
+ const paths = asset.shapes.map((shape) => svg('path', {
517
+ d: shape.d, class: `country map-${classOf(byCountry.get(shape.code)?.total ?? 0, breaks)}`, 'data-code': shape.code,
518
+ }));
519
+ const dots = asset.points.map((point) => {
520
+ const value = byCountry.get(point.code)?.total ?? 0;
521
+ if (!value) return null;
522
+ return svg('circle', {
523
+ cx: point.x, cy: point.y, r: CHART.DOT_RADIUS,
524
+ class: `country country-point map-${classOf(value, breaks)}`, 'data-code': point.code,
525
+ });
526
+ });
527
+ const map = svg('svg', {
528
+ class: 'map-svg',
529
+ viewBox: `0 0 ${asset.width} ${asset.height}`,
530
+ role: 'img',
531
+ 'aria-label': t('charts.mapLabel', { countries: formatNumber(ranked.length) }),
532
+ }, [...paths, ...dots]);
533
+
534
+ for (const mark of map.querySelectorAll('[data-code]')) {
535
+ const code = mark.getAttribute('data-code');
536
+ marks.set(code, [...(marks.get(code) || []), mark]);
537
+ mark.addEventListener('pointermove', (event) => {
538
+ setActive(code, true);
539
+ tooltip.show(tooltipFor(code), pointIn(mapHost, event));
540
+ });
541
+ mark.addEventListener('pointerleave', () => {
542
+ setActive(code, false);
543
+ tooltip.hide();
544
+ });
545
+ if (onSelect && byCountry.has(code)) {
546
+ mark.classList.add('selectable');
547
+ mark.addEventListener('click', () => onSelect(code));
548
+ }
549
+ }
550
+ replaceChildren(mapHost, [map, mapHost.querySelector('.chart-tooltip')]);
551
+ }).catch(() => {
552
+ replaceChildren(mapHost, [el('p', { className: 'chart-empty', text: t('charts.mapUnavailable') })]);
553
+ });
554
+
555
+ // The ranked list: every country with views, as a table would be. The
556
+ // top ten show by default; the rest are one button away.
557
+ let expanded = false;
558
+ const renderList = () => {
559
+ replaceChildren(list, []);
560
+ if (ranked.length === 0) {
561
+ list.append(el('li', { className: 'chart-empty', text: t('charts.noData') }));
562
+ return;
563
+ }
564
+ const visible = expanded ? ranked : ranked.slice(0, CHART.COUNTRY_LIST_LIMIT);
565
+ for (const [code, record] of visible) list.append(countryRow(code, record));
566
+ if (ranked.length > CHART.COUNTRY_LIST_LIMIT) {
567
+ list.append(el('li', { className: 'country-more' }, [el('button', {
568
+ className: 'btn btn-ghost btn-small',
569
+ text: expanded
570
+ ? t('charts.fewerCountries')
571
+ : t('charts.moreCountries', { count: ranked.length - CHART.COUNTRY_LIST_LIMIT }),
572
+ attrs: { type: 'button', 'aria-expanded': String(expanded) },
573
+ on: { click: () => {
574
+ expanded = !expanded;
575
+ renderList();
576
+ list.querySelector('.country-more button')?.focus();
577
+ } },
578
+ })]));
579
+ }
580
+ };
581
+
582
+ function countryRow(code, record) {
583
+ const fill = el('span', { className: 'bar-fill' });
584
+ fill.style.width = `${(record.total / listMax) * 100}%`;
585
+ const content = [
586
+ el('span', { className: 'country-name clamp clamp-1', text: regionName(code) }),
587
+ el('span', { className: 'bar-track' }, [fill]),
588
+ el('span', { className: 'bar-value', text: formatNumber(record.total) }),
589
+ ];
590
+ const item = onSelect
591
+ ? el('li', {}, [el('button', {
592
+ className: 'country-row',
593
+ attrs: { type: 'button', 'aria-label': t('overview.filterBy', { dimension: t('overview.dims.country'), value: regionName(code) }) },
594
+ dataset: { code },
595
+ on: { click: () => onSelect(code) },
596
+ }, content)])
597
+ : el('li', { className: 'country-row', attrs: { tabindex: 0 }, dataset: { code } }, content);
598
+ const target = item.querySelector('.country-row') ?? item;
599
+ const on = () => {
600
+ setActive(code, true);
601
+ const mark = marks.get(code)?.[0];
602
+ if (mark) tooltip.show(tooltipFor(code), anchorOf(mapHost, mark));
603
+ };
604
+ const off = () => {
605
+ setActive(code, false);
606
+ tooltip.hide();
607
+ };
608
+ target.addEventListener('pointerenter', on);
609
+ target.addEventListener('pointerleave', off);
610
+ target.addEventListener('focus', on);
611
+ target.addEventListener('blur', off);
612
+ return item;
613
+ }
614
+ renderList();
615
+
616
+ body.append(el('div', { className: 'map-column' }, [mapHost, scaleLegend(breaks, t('charts.noViews'))]), list);
617
+ return body;
618
+ }
619
+
620
+ // ---- Weekday by hour -----------------------------------------------------------
621
+
622
+ /** Monday first, in the UI's language. */
623
+ function weekdayNames(style) {
624
+ const format = new Intl.DateTimeFormat(currentLocale(), { weekday: style, timeZone: 'UTC' });
625
+ // 2024-01-01 was a Monday.
626
+ return Array.from({ length: 7 }, (_, day) => format.format(new Date(Date.UTC(2024, 0, 1 + day))));
627
+ }
628
+
629
+ function hourName(hour) {
630
+ return new Intl.DateTimeFormat(currentLocale(), { hour: 'numeric', timeZone: 'UTC' }).format(new Date(Date.UTC(2024, 0, 1, hour)));
631
+ }
632
+
633
+ /**
634
+ * Views per weekday and hour of day, in the viewer's time zone, from the
635
+ * server's hourly counts (hours since the epoch, UTC).
636
+ * @param {Array<{ hour: number, views: number }>} hours
637
+ * @returns {number[][]} [weekday Monday..Sunday][hour 0..23]
638
+ */
639
+ export function weekHours(hours) {
640
+ const grid = Array.from({ length: 7 }, () => new Array(24).fill(0));
641
+ for (const { hour, views } of hours) {
642
+ const date = new Date(hour * HOUR_MS);
643
+ grid[(date.getDay() + 6) % 7][date.getHours()] += views;
644
+ }
645
+ return grid;
646
+ }
647
+
648
+ /**
649
+ * A weekday-by-hour heatmap on the sequential ramp. The grid is one focus
650
+ * stop; arrow keys move a highlighted cell and read it out.
651
+ * @param {{ hours: Array<{ hour: number, views: number }> }} data
652
+ */
653
+ export function heatmap({ hours }) {
654
+ const grid = weekHours(hours);
655
+ const body = el('div', { className: 'chart-body heatmap-body' });
656
+ const tooltip = createTooltip(body);
657
+ const values = grid.flat();
658
+ const breaks = classBreaks(values);
659
+ const days = weekdayNames('short');
660
+ const longDays = weekdayNames('long');
661
+ const peak = Math.max(...values);
662
+ const peakIndex = values.indexOf(peak);
663
+
664
+ const cells = [];
665
+ const matrix = el('div', {
666
+ className: 'heatmap',
667
+ attrs: {
668
+ role: 'img',
669
+ tabindex: 0,
670
+ 'aria-label': t('charts.heatmapLabel', {
671
+ day: longDays[Math.floor(peakIndex / 24)],
672
+ hour: hourName(peakIndex % 24),
673
+ views: formatNumber(peak),
674
+ }),
675
+ },
676
+ });
677
+ matrix.append(el('span', { className: 'heatmap-corner', attrs: { 'aria-hidden': 'true' } }));
678
+ for (let hour = 0; hour < 24; hour++) {
679
+ matrix.append(el('span', {
680
+ className: 'heatmap-hour',
681
+ text: hour % CHART.HEATMAP_HOUR_LABEL_EVERY === 0 ? hourName(hour) : '',
682
+ attrs: { 'aria-hidden': 'true' },
683
+ }));
684
+ }
685
+ grid.forEach((row, day) => {
686
+ matrix.append(el('span', { className: 'heatmap-day', text: days[day], attrs: { 'aria-hidden': 'true' } }));
687
+ row.forEach((views, hour) => {
688
+ const cell = el('span', {
689
+ className: `heatmap-cell map-${classOf(views, breaks)}`,
690
+ attrs: { 'aria-hidden': 'true' },
691
+ dataset: { day: String(day), hour: String(hour) },
692
+ });
693
+ cells.push(cell);
694
+ matrix.append(cell);
695
+ });
696
+ });
697
+
698
+ let active = -1;
699
+ function show(index, at) {
700
+ if (active >= 0) cells[active].classList.remove('active');
701
+ active = index;
702
+ cells[index].classList.add('active');
703
+ const day = Math.floor(index / 24);
704
+ const hour = index % 24;
705
+ tooltip.show({
706
+ title: t('charts.heatmapCell', { day: longDays[day], from: hourName(hour), to: hourName((hour + 1) % 24) }),
707
+ rows: [{ value: formatNumber(grid[day][hour]), label: t('charts.views'), key: true }],
708
+ }, at ?? anchorOf(body, cells[index]));
709
+ }
710
+ function reset() {
711
+ if (active >= 0) cells[active].classList.remove('active');
712
+ active = -1;
713
+ tooltip.hide();
714
+ }
715
+
716
+ matrix.addEventListener('pointermove', (event) => {
717
+ const cell = event.target.closest?.('.heatmap-cell');
718
+ if (!cell) return;
719
+ show(Number(cell.dataset.day) * 24 + Number(cell.dataset.hour), pointIn(body, event));
720
+ });
721
+ matrix.addEventListener('pointerleave', reset);
722
+ matrix.addEventListener('blur', reset);
723
+ matrix.addEventListener('keydown', (event) => {
724
+ const from = active < 0 ? peakIndex : active;
725
+ const moves = {
726
+ [KEY.ARROW_RIGHT]: Math.min(167, from + 1),
727
+ [KEY.ARROW_LEFT]: Math.max(0, from - 1),
728
+ [KEY.ARROW_DOWN]: Math.min(167, from + 24),
729
+ [KEY.ARROW_UP]: Math.max(0, from - 24),
730
+ [KEY.HOME]: Math.floor(from / 24) * 24,
731
+ [KEY.END]: Math.floor(from / 24) * 24 + 23,
732
+ };
733
+ if (event.key in moves) {
734
+ event.preventDefault();
735
+ show(active < 0 ? peakIndex : moves[event.key]);
736
+ } else if (event.key === KEY.ESCAPE) {
737
+ reset();
738
+ }
739
+ });
740
+
741
+ body.append(matrix, scaleLegend(breaks, t('charts.noViews')));
742
+ return body;
743
+ }
744
+
745
+ /** The heatmap as a table: hours down, weekdays across. */
746
+ export function heatmapTable({ hours }) {
747
+ const grid = weekHours(hours);
748
+ const days = weekdayNames('short');
749
+ return el('table', { className: 'chart-table' }, [
750
+ el('thead', {}, [el('tr', {}, [
751
+ el('th', { text: t('charts.hour'), attrs: { scope: 'col' } }),
752
+ ...days.map((day) => el('th', { text: day, attrs: { scope: 'col' } })),
753
+ ])]),
754
+ el('tbody', {}, Array.from({ length: 24 }, (_, hour) => el('tr', {}, [
755
+ el('th', { text: hourName(hour), attrs: { scope: 'row' } }),
756
+ ...grid.map((row) => el('td', { text: formatNumber(row[hour]) })),
757
+ ]))),
758
+ ]);
759
+ }
760
+
761
+ // ---- Right now -----------------------------------------------------------------
762
+
763
+ /**
764
+ * Views per minute over the last half hour, oldest on the left. One focus
765
+ * stop; arrow keys read each minute.
766
+ * @param {{ minutes: Array<{ minute: number, views: number }>, nowMinute: number }} data
767
+ * minutes since the epoch
768
+ */
769
+ export function minuteChart({ minutes, nowMinute }) {
770
+ const body = el('div', { className: 'chart-body minute-body' });
771
+ const tooltip = createTooltip(body);
772
+ const { WIDTH, HEIGHT, GAP, SPAN } = CHART.MINUTES;
773
+ const counts = new Map(minutes.map((entry) => [entry.minute, entry.views]));
774
+ const series = Array.from({ length: SPAN }, (_, index) => {
775
+ const minute = nowMinute - (SPAN - 1) + index;
776
+ return { minute, views: counts.get(minute) ?? 0 };
777
+ });
778
+ const max = Math.max(1, ...series.map((entry) => entry.views));
779
+ const barW = WIDTH / SPAN - GAP;
780
+ const time = new Intl.DateTimeFormat(currentLocale(), { hour: 'numeric', minute: '2-digit' });
781
+ const total = series.reduce((sum, entry) => sum + entry.views, 0);
782
+
783
+ const bars = series.map((entry, index) => {
784
+ const h = entry.views ? Math.max(2, (entry.views / max) * (HEIGHT - 2)) : 0;
785
+ return svg('rect', {
786
+ class: 'minute-bar', x: index * (barW + GAP), y: HEIGHT - h, width: barW, height: h, rx: 1.5,
787
+ });
788
+ });
789
+ const hit = svg('rect', { class: 'minute-hit', x: 0, y: 0, width: WIDTH, height: HEIGHT, fill: 'transparent' });
790
+ const plot = svg('svg', {
791
+ class: 'minute-svg',
792
+ viewBox: `0 0 ${WIDTH} ${HEIGHT}`,
793
+ preserveAspectRatio: 'none',
794
+ role: 'img',
795
+ tabindex: 0,
796
+ 'aria-label': t('charts.minutesLabel', { count: SPAN, views: formatNumber(total) }),
797
+ }, [svg('line', { class: 'chart-baseline', x1: 0, x2: WIDTH, y1: HEIGHT, y2: HEIGHT }), ...bars, hit]);
798
+
799
+ let active = -1;
800
+ function show(index, at) {
801
+ if (active >= 0) bars[active].classList.remove('active');
802
+ active = index;
803
+ bars[index].classList.add('active');
804
+ const entry = series[index];
805
+ tooltip.show({
806
+ title: time.format(new Date(entry.minute * MINUTE_MS)),
807
+ rows: [{ value: formatNumber(entry.views), label: t('charts.views'), key: true }],
808
+ }, at ?? { x: ((index + 0.5) / SPAN) * body.clientWidth, y: 0 });
809
+ }
810
+ function reset() {
811
+ if (active >= 0) bars[active].classList.remove('active');
812
+ active = -1;
813
+ tooltip.hide();
814
+ }
815
+ plot.addEventListener('pointermove', (event) => {
816
+ const box = plot.getBoundingClientRect();
817
+ const index = Math.min(SPAN - 1, Math.max(0, Math.floor(((event.clientX - box.left) / box.width) * SPAN)));
818
+ show(index, pointIn(body, event));
819
+ });
820
+ plot.addEventListener('pointerleave', reset);
821
+ plot.addEventListener('blur', reset);
822
+ plot.addEventListener('keydown', (event) => {
823
+ const moves = {
824
+ [KEY.ARROW_RIGHT]: Math.min(SPAN - 1, active + 1),
825
+ [KEY.ARROW_LEFT]: Math.max(0, active < 0 ? SPAN - 1 : active - 1),
826
+ [KEY.HOME]: 0,
827
+ [KEY.END]: SPAN - 1,
828
+ };
829
+ if (event.key in moves) {
830
+ event.preventDefault();
831
+ show(moves[event.key]);
832
+ } else if (event.key === KEY.ESCAPE) {
833
+ reset();
834
+ }
835
+ });
836
+
837
+ body.append(plot, el('div', { className: 'minute-axis', attrs: { 'aria-hidden': 'true' } }, [
838
+ el('span', { text: t('charts.minutesAgo', { count: SPAN }) }),
839
+ el('span', { text: t('charts.now') }),
840
+ ]));
841
+ return body;
842
+ }