@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.
- package/.env.example +50 -6
- package/README.md +444 -104
- package/admin/apple-touch-icon.png +0 -0
- package/admin/assets/world-map.json +1 -0
- package/admin/css/admin.css +2568 -0
- package/admin/favicon.ico +0 -0
- package/admin/favicon.svg +9 -0
- package/admin/icon-192.png +0 -0
- package/admin/icon-512.png +0 -0
- package/admin/index.html +95 -0
- package/admin/js/api.js +146 -0
- package/admin/js/appTabs.js +100 -0
- package/admin/js/charts.js +842 -0
- package/admin/js/clamp.js +41 -0
- package/admin/js/constants.js +239 -0
- package/admin/js/dataTable.js +478 -0
- package/admin/js/dom.js +83 -0
- package/admin/js/format.js +130 -0
- package/admin/js/i18n.js +80 -0
- package/admin/js/icons.js +168 -0
- package/admin/js/listbox.js +145 -0
- package/admin/js/logs.js +318 -0
- package/admin/js/main.js +399 -0
- package/admin/js/modal.js +171 -0
- package/admin/js/overview.js +905 -0
- package/admin/js/passwordPrompt.js +75 -0
- package/admin/js/table.js +94 -0
- package/admin/js/theme.js +72 -0
- package/admin/js/toast.js +47 -0
- package/admin/js/viewDialogs.js +224 -0
- package/admin/js/views.js +751 -0
- package/admin/locales/en.json +683 -0
- package/admin/site.webmanifest +20 -0
- package/config/index.js +122 -4
- package/constants.js +334 -3
- package/db/AdminRepository.js +488 -0
- package/db/DatabaseManager.js +148 -26
- package/db/LogRepository.js +354 -0
- package/db/adminSchema.js +329 -0
- package/db/adminSessionStore.js +104 -0
- package/db/analysis.js +479 -0
- package/db/rejectionCounter.js +117 -0
- package/db/retention.js +97 -0
- package/index.js +91 -24
- package/middleware/adminAuth.js +244 -0
- package/middleware/adminValidation.js +319 -0
- package/middleware/auth.js +2 -2
- package/middleware/security.js +26 -2
- package/middleware/validation.js +50 -2
- package/package.json +20 -10
- package/routes/admin.js +546 -0
- package/routes/analytics.js +207 -19
- package/tracker/tracker.js +191 -0
- package/utils/appIdUtils.js +1 -1
- package/utils/cookieUtils.js +47 -0
- package/utils/durationUtils.js +33 -0
- package/utils/errorUtils.js +39 -1
- package/utils/geoCity.js +87 -0
- package/utils/ipUtils.js +1 -1
- package/utils/privacyUtils.js +2 -2
- package/utils/referrerParser.js +23 -5
- package/utils/secretStore.js +1 -1
- package/utils/userAgentParser.js +52 -3
- 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
|
+
}
|