@harshankur/viewcounter 3.1.0 → 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 +34 -10
- package/README.md +306 -136
- package/admin/css/admin.css +891 -198
- package/admin/index.html +13 -7
- package/admin/js/api.js +52 -6
- package/admin/js/appTabs.js +100 -0
- package/admin/js/charts.js +529 -189
- package/admin/js/constants.js +98 -9
- package/admin/js/dataTable.js +478 -0
- package/admin/js/format.js +58 -7
- package/admin/js/icons.js +168 -0
- package/admin/js/listbox.js +2 -1
- package/admin/js/logs.js +211 -60
- package/admin/js/main.js +201 -37
- package/admin/js/overview.js +905 -0
- package/admin/js/passwordPrompt.js +75 -0
- package/admin/js/table.js +12 -52
- package/admin/js/viewDialogs.js +30 -14
- package/admin/js/views.js +273 -207
- package/admin/locales/en.json +352 -63
- package/config/index.js +40 -5
- package/constants.js +126 -8
- package/db/AdminRepository.js +85 -159
- package/db/DatabaseManager.js +55 -7
- package/db/LogRepository.js +172 -35
- package/db/adminSchema.js +89 -4
- package/db/adminSessionStore.js +104 -0
- package/db/analysis.js +479 -0
- package/db/rejectionCounter.js +117 -0
- package/index.js +53 -19
- package/middleware/adminAuth.js +83 -43
- package/middleware/adminValidation.js +69 -3
- package/middleware/auth.js +2 -2
- package/middleware/security.js +26 -2
- package/middleware/validation.js +50 -2
- package/package.json +5 -2
- package/routes/admin.js +130 -22
- package/routes/analytics.js +197 -18
- package/tracker/tracker.js +191 -0
- package/utils/appIdUtils.js +1 -1
- package/utils/durationUtils.js +33 -0
- package/utils/errorUtils.js +4 -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
- package/admin/js/insights.js +0 -192
package/admin/js/charts.js
CHANGED
|
@@ -1,23 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Charts for the
|
|
3
|
-
*
|
|
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
|
-
* -
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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 (
|
|
11
|
-
*
|
|
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,
|
|
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
|
|
36
|
-
* a short stroke of the mark's colour, never a
|
|
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
|
|
87
|
-
*
|
|
88
|
-
* @param {Array<
|
|
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
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
el('
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
return { top: Math.ceil(max /
|
|
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
|
-
// ----
|
|
183
|
+
// ---- Over time -----------------------------------------------------------------
|
|
115
184
|
|
|
116
185
|
/**
|
|
117
|
-
*
|
|
118
|
-
* nearest period, and the same readout from the keyboard
|
|
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:
|
|
121
|
-
*
|
|
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
|
-
|
|
127
|
-
|
|
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(...
|
|
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
|
|
141
|
-
|
|
142
|
-
grid.push(svg('
|
|
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
|
|
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 ===
|
|
151
|
-
}, [
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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('
|
|
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:
|
|
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
|
-
|
|
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
|
|
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(
|
|
189
|
-
focusDot.setAttribute('visibility', 'visible');
|
|
190
|
-
const
|
|
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
|
-
|
|
194
|
-
|
|
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(
|
|
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(
|
|
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
|
-
/**
|
|
237
|
-
|
|
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('
|
|
241
|
-
el('th', { text:
|
|
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('
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
// ----
|
|
357
|
+
// ---- Ranked tables -------------------------------------------------------------
|
|
253
358
|
|
|
254
359
|
/**
|
|
255
|
-
*
|
|
256
|
-
*
|
|
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 {{
|
|
259
|
-
*
|
|
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
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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,
|
|
339
|
-
*
|
|
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
|
-
*
|
|
474
|
+
* onSelect?: (code: string) => void }} data
|
|
343
475
|
*/
|
|
344
|
-
export function worldMap({ countries,
|
|
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('
|
|
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
|
|
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,
|
|
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
|
|
371
|
-
const breaks = classBreaks([...byCountry.values()].map((record) => record.
|
|
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?.
|
|
376
|
-
const rows = [{ value: formatNumber(views), label:
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
402
|
-
|
|
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)?.
|
|
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('
|
|
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('
|
|
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
|
|
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('
|
|
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('
|
|
466
|
-
: t('
|
|
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
|
|
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' }, [
|
|
481
|
-
el('span', { className: 'bar-value', text: formatNumber(record.
|
|
482
|
-
]
|
|
483
|
-
item
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
}
|