@harshankur/viewcounter 3.1.0 → 3.3.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 (50) hide show
  1. package/.env.example +37 -11
  2. package/README.md +330 -138
  3. package/admin/css/admin.css +891 -198
  4. package/admin/index.html +13 -7
  5. package/admin/js/api.js +52 -6
  6. package/admin/js/appTabs.js +100 -0
  7. package/admin/js/charts.js +529 -189
  8. package/admin/js/constants.js +98 -9
  9. package/admin/js/dataTable.js +478 -0
  10. package/admin/js/format.js +58 -7
  11. package/admin/js/icons.js +168 -0
  12. package/admin/js/listbox.js +2 -1
  13. package/admin/js/logs.js +211 -60
  14. package/admin/js/main.js +201 -37
  15. package/admin/js/overview.js +905 -0
  16. package/admin/js/passwordPrompt.js +75 -0
  17. package/admin/js/table.js +12 -52
  18. package/admin/js/viewDialogs.js +30 -14
  19. package/admin/js/views.js +273 -207
  20. package/admin/locales/en.json +352 -63
  21. package/config/index.js +40 -5
  22. package/constants.js +126 -8
  23. package/db/AdminRepository.js +85 -159
  24. package/db/DatabaseManager.js +57 -7
  25. package/db/LogRepository.js +172 -35
  26. package/db/adminSchema.js +93 -4
  27. package/db/adminSessionStore.js +104 -0
  28. package/db/analysis.js +484 -0
  29. package/db/rejectionCounter.js +117 -0
  30. package/index.js +49 -26
  31. package/middleware/adminAuth.js +83 -43
  32. package/middleware/adminValidation.js +69 -3
  33. package/middleware/auth.js +2 -2
  34. package/middleware/security.js +26 -2
  35. package/middleware/validation.js +50 -2
  36. package/package.json +5 -2
  37. package/routes/admin.js +130 -22
  38. package/routes/analytics.js +236 -18
  39. package/tracker/tracker.js +240 -0
  40. package/utils/appIdUtils.js +1 -1
  41. package/utils/durationUtils.js +33 -0
  42. package/utils/errorUtils.js +4 -1
  43. package/utils/geoCity.js +87 -0
  44. package/utils/ipUtils.js +1 -1
  45. package/utils/privacyUtils.js +2 -2
  46. package/utils/referrerParser.js +23 -5
  47. package/utils/secretStore.js +1 -1
  48. package/utils/userAgentParser.js +52 -3
  49. package/utils/visitorContext.js +70 -0
  50. package/admin/js/insights.js +0 -192
@@ -1,23 +1,31 @@
1
1
  /**
2
- * Charts for the insights panel: stat tiles, a views-over-time line, a world
3
- * map, and breakdown bars. Hand-drawn SVG, no library, no HTML parsing.
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.
4
6
  *
5
7
  * Built to the data-viz method the project follows:
6
- * - single-series marks in one hue (--chart-series), so no legend box;
7
- * - the map is a one-hue sequential scale (--map-1..--map-5) with a separate
8
- * "no views" neutral, validated for monotone lightness in both themes;
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);
9
13
  * - thin marks, 2px lines, >= 8px markers with a surface ring, hairline grid;
10
- * - every value is readable without hovering (direct labels, a ranked list
11
- * beside the map, a table view of the trend), and hover and keyboard focus
12
- * show the same tooltip.
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.
13
17
  */
14
18
 
19
+ import { clampText } from './clamp.js';
15
20
  import { el, replaceChildren } from './dom.js';
16
- import { formatNumber, formatPercent, formatCompact, formatPeriod } from './format.js';
21
+ import { formatNumber, formatPercent, formatPeriod } from './format.js';
22
+ import { icon } from './icons.js';
17
23
  import { t, tOr, currentLocale } from './i18n.js';
18
24
  import { CHART, KEY } from './constants.js';
19
25
 
20
26
  const SVG_NS = 'http://www.w3.org/2000/svg';
27
+ const MINUTE_MS = 60 * 1000;
28
+ const HOUR_MS = 60 * MINUTE_MS;
21
29
 
22
30
  /** An SVG element with attributes and children. */
23
31
  function svg(tag, attrs = {}, children = []) {
@@ -29,11 +37,14 @@ function svg(tag, attrs = {}, children = []) {
29
37
  return node;
30
38
  }
31
39
 
40
+ const text = (value) => document.createTextNode(value);
41
+ const present = (value) => value !== null && value !== undefined && !Number.isNaN(value);
42
+
32
43
  // ---- Tooltip -----------------------------------------------------------------
33
44
 
34
45
  /**
35
- * One tooltip per chart card. Values lead, labels follow; rows are keyed with
36
- * a short stroke of the mark's colour, never a filled box.
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.
37
48
  */
38
49
  export function createTooltip(host) {
39
50
  const node = el('div', { className: 'chart-tooltip', attrs: { role: 'status', 'aria-live': 'polite' } });
@@ -83,95 +94,188 @@ function anchorOf(host, target) {
83
94
  // ---- Stat tiles --------------------------------------------------------------
84
95
 
85
96
  /**
86
- * A KPI row: label, then value. Proportional figures, compact above a
87
- * thousand, with the exact number in the title.
88
- * @param {Array<{ label: string, value: number, format?: 'number'|'percent', hint?: string }>} tiles
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]
89
136
  */
90
- export function statTiles(tiles) {
91
- return el('div', { className: 'stat-tiles' }, tiles.map((tile) => {
92
- const exact = tile.format === 'percent' ? formatPercent(tile.value) : formatNumber(tile.value);
93
- const shown = tile.format === 'percent' ? exact : formatCompact(tile.value);
94
- return el('div', { className: 'stat-tile' }, [
95
- el('div', { className: 'stat-label', text: tile.label }),
96
- el('div', { className: 'stat-value', text: shown, attrs: shown === exact ? {} : { title: exact } }),
97
- tile.hint ? el('div', { className: 'stat-hint', text: tile.hint }) : null,
98
- ]);
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);
99
164
  }));
100
165
  }
101
166
 
102
167
  // ---- Nice axis ---------------------------------------------------------------
103
168
 
104
- /** A clean upper bound and tick step for a count axis. */
105
- function niceScale(max, ticks = CHART.Y_TICKS) {
106
- if (max <= 0) return { top: ticks, step: 1 };
107
- const rough = max / ticks;
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;
108
177
  const magnitude = 10 ** Math.floor(Math.log10(rough));
109
- const step = [1, 2, 2.5, 5, 10].map((m) => m * magnitude).find((candidate) => candidate >= rough);
110
- const whole = Math.max(1, Math.round(step));
111
- return { top: Math.ceil(max / whole) * whole, step: whole };
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 };
112
181
  }
113
182
 
114
- // ---- Views over time ---------------------------------------------------------
183
+ // ---- Over time -----------------------------------------------------------------
115
184
 
116
185
  /**
117
- * A single-series line with a soft area wash, a crosshair that snaps to the
118
- * nearest period, and the same readout from the keyboard (arrow keys).
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.
119
190
  *
120
- * @param {{ points: Array<{ period: string, views: number, uniqueViews: number }>, bucket: string }} data
121
- * @returns {HTMLElement}
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
122
194
  */
123
- export function trendChart({ points, bucket, lastInProgress = false }) {
195
+ export function trendChart({ points, bucket, metric, lastInProgress = false }) {
124
196
  const card = el('div', { className: 'chart-body' });
125
197
  const tooltip = createTooltip(card);
126
- if (points.length === 0) {
127
- card.append(el('p', { className: 'chart-empty', text: t('insights.noData') }));
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') }));
128
205
  return card;
129
206
  }
130
207
 
131
208
  const { WIDTH, HEIGHT, MARGIN } = CHART.TREND;
132
209
  const plotW = WIDTH - MARGIN.left - MARGIN.right;
133
210
  const plotH = HEIGHT - MARGIN.top - MARGIN.bottom;
134
- const max = Math.max(...points.map((point) => point.views));
135
- const { top, step } = niceScale(max);
211
+ const max = Math.max(...known);
212
+ const { top, step } = niceScale(max, { integer: metric.integer !== false });
213
+ const tick = metric.tick ?? metric.format;
136
214
  const x = (index) => MARGIN.left + (points.length === 1 ? plotW / 2 : (index / (points.length - 1)) * plotW);
137
215
  const y = (value) => MARGIN.top + plotH - (value / top) * plotH;
138
216
 
139
217
  const grid = [];
140
- for (let value = 0; value <= top; value += step) {
141
- grid.push(svg('line', { class: value === 0 ? 'chart-baseline' : 'chart-grid', x1: MARGIN.left, x2: WIDTH - MARGIN.right, y1: y(value), y2: y(value) }));
142
- grid.push(svg('text', { class: 'chart-tick', x: MARGIN.left - CHART.TICK_GAP, y: y(value), 'text-anchor': 'end', 'dominant-baseline': 'middle' }, [document.createTextNode(formatCompact(value))]));
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))]));
143
224
  }
144
225
 
145
- const labelIndexes = [...new Set([0, Math.floor((points.length - 1) / 2), points.length - 1])];
226
+ const last = points.length - 1;
227
+ const labelIndexes = [...new Set([0, Math.floor(last / 2), last])];
146
228
  const xLabels = labelIndexes.map((index) => svg('text', {
147
229
  class: 'chart-tick',
148
230
  x: x(index),
149
231
  y: HEIGHT - MARGIN.bottom + CHART.X_LABEL_GAP,
150
- 'text-anchor': index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'middle',
151
- }, [document.createTextNode(formatPeriod(points[index].period, bucket))]));
152
-
153
- const line = points.map((point, index) => `${index === 0 ? 'M' : 'L'}${x(index).toFixed(1)},${y(point.views).toFixed(1)}`).join('');
154
- const area = `${line}L${x(points.length - 1).toFixed(1)},${y(0)}L${x(0).toFixed(1)},${y(0)}Z`;
155
- const last = points.length - 1;
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);
156
254
 
157
255
  const crosshair = svg('line', { class: 'chart-crosshair', y1: MARGIN.top, y2: MARGIN.top + plotH, visibility: 'hidden' });
158
256
  const focusDot = svg('circle', { class: 'chart-dot', r: CHART.DOT_RADIUS, visibility: 'hidden' });
257
+ const peak = values.indexOf(max);
159
258
 
160
259
  const plot = svg('svg', {
161
260
  class: 'trend-svg',
162
261
  viewBox: `0 0 ${WIDTH} ${HEIGHT}`,
163
262
  role: 'img',
164
263
  tabindex: 0,
165
- 'aria-label': t('insights.trendLabel', {
264
+ 'aria-label': t('charts.trendLabel', {
265
+ metric: metric.label,
166
266
  from: formatPeriod(points[0].period, bucket),
167
267
  to: formatPeriod(points[last].period, bucket),
168
- peak: formatNumber(max),
268
+ peak: metric.format(max),
269
+ peakAt: formatPeriod(points[peak].period, bucket, { long: true }),
169
270
  }),
170
271
  }, [
171
272
  ...grid,
172
- svg('path', { class: 'chart-area', d: area }),
273
+ area ? svg('path', { class: 'chart-area', d: area }) : null,
173
274
  svg('path', { class: 'chart-line', d: line }),
174
- svg('circle', { class: 'chart-dot', cx: x(last), cy: y(points[last].views), r: CHART.DOT_RADIUS }),
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 }),
175
279
  ...xLabels,
176
280
  crosshair,
177
281
  focusDot,
@@ -180,23 +284,18 @@ export function trendChart({ points, bucket, lastInProgress = false }) {
180
284
  let activeIndex = -1;
181
285
  function highlight(index, at) {
182
286
  activeIndex = index;
183
- const point = points[index];
287
+ const value = values[index];
184
288
  crosshair.setAttribute('x1', x(index));
185
289
  crosshair.setAttribute('x2', x(index));
186
290
  crosshair.setAttribute('visibility', 'visible');
187
291
  focusDot.setAttribute('cx', x(index));
188
- focusDot.setAttribute('cy', y(point.views));
189
- focusDot.setAttribute('visibility', 'visible');
190
- const inProgress = lastInProgress && index === last;
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 });
191
295
  tooltip.show({
192
- title: inProgress
193
- ? t('insights.inProgress', { period: formatPeriod(point.period, bucket, { long: true }) })
194
- : formatPeriod(point.period, bucket, { long: true }),
195
- rows: [
196
- { value: formatNumber(point.views), label: t('insights.views'), key: true },
197
- { value: formatNumber(point.uniqueViews), label: t('insights.uniqueViews') },
198
- ],
199
- }, at ?? anchorOf(card, focusDot));
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));
200
299
  }
201
300
  function reset() {
202
301
  activeIndex = -1;
@@ -209,14 +308,14 @@ export function trendChart({ points, bucket, lastInProgress = false }) {
209
308
  const box = plot.getBoundingClientRect();
210
309
  const svgX = ((event.clientX - box.left) / box.width) * WIDTH;
211
310
  const ratio = (svgX - MARGIN.left) / plotW;
212
- const index = Math.min(points.length - 1, Math.max(0, Math.round(ratio * (points.length - 1))));
311
+ const index = Math.min(last, Math.max(0, Math.round(ratio * last)));
213
312
  highlight(index, pointIn(card, event));
214
313
  });
215
314
  plot.addEventListener('pointerleave', reset);
216
315
  plot.addEventListener('blur', reset);
217
316
  plot.addEventListener('keydown', (event) => {
218
317
  const moves = {
219
- [KEY.ARROW_RIGHT]: Math.min(points.length - 1, activeIndex + 1),
318
+ [KEY.ARROW_RIGHT]: Math.min(last, activeIndex + 1),
220
319
  [KEY.ARROW_LEFT]: Math.max(0, activeIndex < 0 ? last : activeIndex - 1),
221
320
  [KEY.HOME]: 0,
222
321
  [KEY.END]: last,
@@ -233,68 +332,75 @@ export function trendChart({ points, bucket, lastInProgress = false }) {
233
332
  return card;
234
333
  }
235
334
 
236
- /** The trend as a table: the accessible twin of the chart. */
237
- export function trendTable({ points, bucket }) {
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 }) {
238
342
  return el('table', { className: 'chart-table' }, [
239
343
  el('thead', {}, [el('tr', {}, [
240
- el('th', { text: t('insights.period'), attrs: { scope: 'col' } }),
241
- el('th', { text: t('insights.views'), attrs: { scope: 'col' } }),
242
- el('th', { text: t('insights.uniqueViews'), attrs: { scope: 'col' } }),
344
+ el('th', { text: t('charts.period'), attrs: { scope: 'col' } }),
345
+ ...metrics.map((metric) => el('th', { text: metric.label, attrs: { scope: 'col' } })),
243
346
  ])]),
244
- el('tbody', {}, points.map((point) => el('tr', {}, [
245
- el('td', { text: formatPeriod(point.period, bucket, { long: true }) }),
246
- el('td', { text: formatNumber(point.views) }),
247
- el('td', { text: formatNumber(point.uniqueViews) }),
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
+ }),
248
353
  ]))),
249
354
  ]);
250
355
  }
251
356
 
252
- // ---- Breakdown bars ----------------------------------------------------------
357
+ // ---- Ranked tables -------------------------------------------------------------
253
358
 
254
359
  /**
255
- * Ranked horizontal bars for one nominal dimension: one hue, value at the
256
- * tip, the tail folded into "Other".
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.
257
363
  *
258
- * @param {{ entries: Array<{ value: string|null, views: number }>, total: number,
259
- * labelFor: (value: string|null) => string }} data
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
260
368
  */
261
- export function barList({ entries, total, labelFor }) {
262
- const body = el('div', { className: 'chart-body bar-list' });
263
- const tooltip = createTooltip(body);
264
- const shown = entries.reduce((sum, entry) => sum + entry.views, 0);
265
- const rows = [...entries.map((entry) => ({ label: labelFor(entry.value), views: entry.views }))];
266
- if (total > shown) rows.push({ label: t('insights.other'), views: total - shown, other: true });
267
- if (rows.length === 0) {
268
- body.append(el('p', { className: 'chart-empty', text: t('insights.noData') }));
269
- return body;
270
- }
271
- const max = Math.max(...rows.map((row) => row.views));
272
-
273
- for (const row of rows) {
274
- const share = total > 0 ? row.views / total : 0;
275
- const bar = el('div', {
276
- className: `bar-row${row.other ? ' bar-other' : ''}`,
277
- attrs: { tabindex: 0 },
278
- }, [
279
- el('span', { className: 'bar-label clamp clamp-1', text: row.label, attrs: { title: row.label } }),
280
- el('span', { className: 'bar-track' }, [el('span', { className: 'bar-fill' })]),
281
- el('span', { className: 'bar-value', text: formatNumber(row.views) }),
282
- ]);
283
- bar.querySelector('.bar-fill').style.width = `${(row.views / max) * 100}%`;
284
- const show = (at) => tooltip.show({
285
- title: row.label,
286
- rows: [
287
- { value: formatNumber(row.views), label: t('insights.views'), key: true },
288
- { value: formatPercent(share), label: t('insights.share') },
289
- ],
290
- }, at);
291
- bar.addEventListener('pointermove', (event) => show(pointIn(body, event)));
292
- bar.addEventListener('pointerleave', () => tooltip.hide());
293
- bar.addEventListener('focus', () => show(anchorOf(body, bar)));
294
- bar.addEventListener('blur', () => tooltip.hide());
295
- body.append(bar);
296
- }
297
- return body;
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
+ ]);
298
404
  }
299
405
 
300
406
  // ---- World map ---------------------------------------------------------------
@@ -334,62 +440,72 @@ function classOf(value, breaks) {
334
440
  return 1 + Math.round((position / Math.max(1, breaks.length - 1)) * (CHART.MAP_CLASSES - 1));
335
441
  }
336
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
+
337
468
  /**
338
- * A choropleth of views by country, for one event type or all of them, with a
339
- * ranked list beside it that is both the legend's companion and its table view.
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.
340
472
  *
341
473
  * @param {{ countries: Array<{ country: string, eventType: string|null, views: number }>,
342
- * type: string|null, total: number }} data type null means every type
474
+ * onSelect?: (code: string) => void }} data
343
475
  */
344
- export function worldMap({ countries, type }) {
476
+ export function worldMap({ countries, onSelect }) {
345
477
  const body = el('div', { className: 'chart-body map-layout' });
346
478
  const mapHost = el('div', { className: 'map-host' });
347
479
  const tooltip = createTooltip(mapHost);
348
- const list = el('ol', { className: 'country-list', attrs: { 'aria-label': t('insights.topCountries') } });
349
- const legend = el('div', { className: 'map-legend' });
350
- body.append(el('div', { className: 'map-column' }, [mapHost, legend]), list);
351
-
352
- const regionName = (() => {
353
- try {
354
- const names = new Intl.DisplayNames([currentLocale()], { type: 'region' });
355
- return (code) => names.of(code) ?? code;
356
- } catch {
357
- return (code) => code;
358
- }
359
- })();
480
+ const list = el('ol', { className: 'country-list', attrs: { 'aria-label': t('charts.topCountries') } });
360
481
 
361
- // Totals per country for the selected type, and the per-type split for tooltips.
482
+ // Totals per country, and the per-type split for tooltips.
362
483
  const byCountry = new Map();
363
484
  for (const entry of countries) {
364
- const record = byCountry.get(entry.country) || { total: 0, selected: 0, types: [] };
485
+ const record = byCountry.get(entry.country) || { total: 0, types: [] };
365
486
  record.total += entry.views;
366
- if (type === null || entry.eventType === type) record.selected += entry.views;
367
487
  record.types.push(entry);
368
488
  byCountry.set(entry.country, record);
369
489
  }
370
- const selectedTotal = [...byCountry.values()].reduce((sum, record) => sum + record.selected, 0);
371
- const breaks = classBreaks([...byCountry.values()].map((record) => record.selected));
490
+ const total = [...byCountry.values()].reduce((sum, record) => sum + record.total, 0);
491
+ const breaks = classBreaks([...byCountry.values()].map((record) => record.total));
372
492
 
373
493
  function tooltipFor(code) {
374
494
  const record = byCountry.get(code);
375
- const views = record?.selected ?? 0;
376
- const rows = [{ value: formatNumber(views), label: type === null ? t('insights.views') : tOr(`eventTypes.${type}`, type), key: true }];
377
- if (record && type === null) {
378
- for (const entry of [...record.types].sort((a, b) => b.views - a.views).slice(0, CHART.TOOLTIP_TYPES)) {
379
- rows.push({
380
- value: formatNumber(entry.views),
381
- label: entry.eventType === null ? t('insights.otherTypes') : tOr(`eventTypes.${entry.eventType}`, entry.eventType),
382
- });
383
- }
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
+ });
384
502
  }
385
- if (selectedTotal > 0 && views > 0) rows.push({ value: formatPercent(views / selectedTotal), label: t('insights.share') });
503
+ if (total > 0 && views > 0) rows.push({ value: formatPercent(views / total), label: t('charts.share') });
386
504
  return { title: regionName(code), rows };
387
505
  }
388
506
 
389
- const ranked = [...byCountry.entries()]
390
- .filter(([, record]) => record.selected > 0)
391
- .sort((a, b) => b[1].selected - a[1].selected);
392
- const listMax = ranked[0]?.[1].selected ?? 0;
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;
393
509
 
394
510
  const marks = new Map();
395
511
  function setActive(code, on) {
@@ -397,12 +513,11 @@ export function worldMap({ countries, type }) {
397
513
  }
398
514
 
399
515
  loadMapAsset().then((asset) => {
400
- const paths = asset.shapes.map((shape) => {
401
- const value = byCountry.get(shape.code)?.selected ?? 0;
402
- return svg('path', { d: shape.d, class: `country map-${classOf(value, breaks)}`, 'data-code': shape.code });
403
- });
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
+ }));
404
519
  const dots = asset.points.map((point) => {
405
- const value = byCountry.get(point.code)?.selected ?? 0;
520
+ const value = byCountry.get(point.code)?.total ?? 0;
406
521
  if (!value) return null;
407
522
  return svg('circle', {
408
523
  cx: point.x, cy: point.y, r: CHART.DOT_RADIUS,
@@ -413,7 +528,7 @@ export function worldMap({ countries, type }) {
413
528
  class: 'map-svg',
414
529
  viewBox: `0 0 ${asset.width} ${asset.height}`,
415
530
  role: 'img',
416
- 'aria-label': t('insights.mapLabel', { countries: formatNumber(ranked.length) }),
531
+ 'aria-label': t('charts.mapLabel', { countries: formatNumber(ranked.length) }),
417
532
  }, [...paths, ...dots]);
418
533
 
419
534
  for (const mark of map.querySelectorAll('[data-code]')) {
@@ -427,33 +542,23 @@ export function worldMap({ countries, type }) {
427
542
  setActive(code, false);
428
543
  tooltip.hide();
429
544
  });
545
+ if (onSelect && byCountry.has(code)) {
546
+ mark.classList.add('selectable');
547
+ mark.addEventListener('click', () => onSelect(code));
548
+ }
430
549
  }
431
550
  replaceChildren(mapHost, [map, mapHost.querySelector('.chart-tooltip')]);
432
551
  }).catch(() => {
433
- replaceChildren(mapHost, [el('p', { className: 'chart-empty', text: t('insights.mapUnavailable') })]);
434
- });
435
-
436
- // Scale legend: "no views", then each class with its range.
437
- const legendItems = [el('span', { className: 'legend-item' }, [
438
- el('span', { className: 'legend-swatch map-0', attrs: { 'aria-hidden': 'true' } }),
439
- el('span', { text: t('insights.noViews') }),
440
- ])];
441
- breaks.forEach((bound, index) => {
442
- const from = index === 0 ? 1 : breaks[index - 1] + 1;
443
- legendItems.push(el('span', { className: 'legend-item' }, [
444
- el('span', { className: `legend-swatch map-${classOf(bound, breaks)}`, attrs: { 'aria-hidden': 'true' } }),
445
- el('span', { text: from === bound ? formatNumber(bound) : `${formatNumber(from)}–${formatNumber(bound)}` }),
446
- ]));
552
+ replaceChildren(mapHost, [el('p', { className: 'chart-empty', text: t('charts.mapUnavailable') })]);
447
553
  });
448
- replaceChildren(legend, legendItems);
449
554
 
450
- // The ranked list: every country with views, as a table would be. The top
451
- // ten show by default; the rest are one button away.
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.
452
557
  let expanded = false;
453
558
  const renderList = () => {
454
559
  replaceChildren(list, []);
455
560
  if (ranked.length === 0) {
456
- list.append(el('li', { className: 'chart-empty', text: t('insights.noData') }));
561
+ list.append(el('li', { className: 'chart-empty', text: t('charts.noData') }));
457
562
  return;
458
563
  }
459
564
  const visible = expanded ? ranked : ranked.slice(0, CHART.COUNTRY_LIST_LIMIT);
@@ -462,8 +567,8 @@ export function worldMap({ countries, type }) {
462
567
  list.append(el('li', { className: 'country-more' }, [el('button', {
463
568
  className: 'btn btn-ghost btn-small',
464
569
  text: expanded
465
- ? t('insights.fewerCountries')
466
- : t('insights.moreCountries', { count: ranked.length - CHART.COUNTRY_LIST_LIMIT }),
570
+ ? t('charts.fewerCountries')
571
+ : t('charts.moreCountries', { count: ranked.length - CHART.COUNTRY_LIST_LIMIT }),
467
572
  attrs: { type: 'button', 'aria-expanded': String(expanded) },
468
573
  on: { click: () => {
469
574
  expanded = !expanded;
@@ -475,12 +580,22 @@ export function worldMap({ countries, type }) {
475
580
  };
476
581
 
477
582
  function countryRow(code, record) {
478
- const item = el('li', { className: 'country-row', attrs: { tabindex: 0 } }, [
583
+ const fill = el('span', { className: 'bar-fill' });
584
+ fill.style.width = `${(record.total / listMax) * 100}%`;
585
+ const content = [
479
586
  el('span', { className: 'country-name clamp clamp-1', text: regionName(code) }),
480
- el('span', { className: 'bar-track' }, [el('span', { className: 'bar-fill' })]),
481
- el('span', { className: 'bar-value', text: formatNumber(record.selected) }),
482
- ]);
483
- item.querySelector('.bar-fill').style.width = `${(record.selected / listMax) * 100}%`;
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;
484
599
  const on = () => {
485
600
  setActive(code, true);
486
601
  const mark = marks.get(code)?.[0];
@@ -490,13 +605,238 @@ export function worldMap({ countries, type }) {
490
605
  setActive(code, false);
491
606
  tooltip.hide();
492
607
  };
493
- item.addEventListener('pointerenter', on);
494
- item.addEventListener('pointerleave', off);
495
- item.addEventListener('focus', on);
496
- item.addEventListener('blur', off);
608
+ target.addEventListener('pointerenter', on);
609
+ target.addEventListener('pointerleave', off);
610
+ target.addEventListener('focus', on);
611
+ target.addEventListener('blur', off);
497
612
  return item;
498
613
  }
499
614
  renderList();
500
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
+ ]));
501
841
  return body;
502
842
  }