@harshankur/viewcounter 3.0.0 → 3.1.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 +20 -0
- package/README.md +189 -14
- package/admin/apple-touch-icon.png +0 -0
- package/admin/assets/world-map.json +1 -0
- package/admin/css/admin.css +1875 -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 +89 -0
- package/admin/js/api.js +100 -0
- package/admin/js/charts.js +502 -0
- package/admin/js/clamp.js +41 -0
- package/admin/js/constants.js +150 -0
- package/admin/js/dom.js +83 -0
- package/admin/js/format.js +79 -0
- package/admin/js/i18n.js +80 -0
- package/admin/js/insights.js +192 -0
- package/admin/js/listbox.js +144 -0
- package/admin/js/logs.js +167 -0
- package/admin/js/main.js +235 -0
- package/admin/js/modal.js +171 -0
- package/admin/js/table.js +134 -0
- package/admin/js/theme.js +72 -0
- package/admin/js/toast.js +47 -0
- package/admin/js/viewDialogs.js +208 -0
- package/admin/js/views.js +685 -0
- package/admin/locales/en.json +394 -0
- package/admin/site.webmanifest +20 -0
- package/config/index.js +83 -0
- package/constants.js +215 -2
- package/db/AdminRepository.js +562 -0
- package/db/DatabaseManager.js +94 -20
- package/db/LogRepository.js +217 -0
- package/db/adminSchema.js +244 -0
- package/db/retention.js +97 -0
- package/index.js +39 -6
- package/middleware/adminAuth.js +204 -0
- package/middleware/adminValidation.js +253 -0
- package/package.json +17 -10
- package/routes/admin.js +438 -0
- package/routes/analytics.js +11 -2
- package/utils/cookieUtils.js +47 -0
- package/utils/errorUtils.js +35 -0
|
@@ -0,0 +1,502 @@
|
|
|
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.
|
|
4
|
+
*
|
|
5
|
+
* 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;
|
|
9
|
+
* - 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.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { el, replaceChildren } from './dom.js';
|
|
16
|
+
import { formatNumber, formatPercent, formatCompact, formatPeriod } from './format.js';
|
|
17
|
+
import { t, tOr, currentLocale } from './i18n.js';
|
|
18
|
+
import { CHART, KEY } from './constants.js';
|
|
19
|
+
|
|
20
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
21
|
+
|
|
22
|
+
/** An SVG element with attributes and children. */
|
|
23
|
+
function svg(tag, attrs = {}, children = []) {
|
|
24
|
+
const node = document.createElementNS(SVG_NS, tag);
|
|
25
|
+
for (const [name, value] of Object.entries(attrs)) {
|
|
26
|
+
if (value !== null && value !== undefined && value !== false) node.setAttribute(name, String(value));
|
|
27
|
+
}
|
|
28
|
+
for (const child of children) if (child) node.append(child);
|
|
29
|
+
return node;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---- Tooltip -----------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
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.
|
|
37
|
+
*/
|
|
38
|
+
export function createTooltip(host) {
|
|
39
|
+
const node = el('div', { className: 'chart-tooltip', attrs: { role: 'status', 'aria-live': 'polite' } });
|
|
40
|
+
node.hidden = true;
|
|
41
|
+
host.append(node);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
/**
|
|
45
|
+
* @param {{ title: string, rows: Array<{ value: string, label: string, key?: boolean }> }} content
|
|
46
|
+
* @param {{ x: number, y: number }} at position within the host
|
|
47
|
+
*/
|
|
48
|
+
show({ title, rows }, at) {
|
|
49
|
+
replaceChildren(node, [
|
|
50
|
+
el('div', { className: 'chart-tooltip-title', text: title }),
|
|
51
|
+
...rows.map((row) => el('div', { className: 'chart-tooltip-row' }, [
|
|
52
|
+
row.key ? el('span', { className: 'chart-tooltip-key', attrs: { 'aria-hidden': 'true' } }) : null,
|
|
53
|
+
el('strong', { text: row.value }),
|
|
54
|
+
el('span', { text: row.label }),
|
|
55
|
+
])),
|
|
56
|
+
]);
|
|
57
|
+
node.hidden = false;
|
|
58
|
+
const hostWidth = host.clientWidth;
|
|
59
|
+
const width = node.offsetWidth;
|
|
60
|
+
const left = Math.min(Math.max(at.x + CHART.TOOLTIP_OFFSET, 0), Math.max(hostWidth - width, 0));
|
|
61
|
+
node.style.left = `${left}px`;
|
|
62
|
+
node.style.top = `${Math.max(at.y - node.offsetHeight - CHART.TOOLTIP_OFFSET, 0)}px`;
|
|
63
|
+
},
|
|
64
|
+
hide() {
|
|
65
|
+
node.hidden = true;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Pointer position within an element, from a pointer event. */
|
|
71
|
+
function pointIn(host, event) {
|
|
72
|
+
const box = host.getBoundingClientRect();
|
|
73
|
+
return { x: event.clientX - box.left, y: event.clientY - box.top };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Centre-top of an element, within a host, for keyboard-driven tooltips. */
|
|
77
|
+
function anchorOf(host, target) {
|
|
78
|
+
const box = host.getBoundingClientRect();
|
|
79
|
+
const mark = target.getBoundingClientRect();
|
|
80
|
+
return { x: mark.left - box.left + mark.width / 2, y: mark.top - box.top };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---- Stat tiles --------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
/**
|
|
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
|
|
89
|
+
*/
|
|
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
|
+
]);
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---- Nice axis ---------------------------------------------------------------
|
|
103
|
+
|
|
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;
|
|
108
|
+
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 };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---- Views over time ---------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/**
|
|
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).
|
|
119
|
+
*
|
|
120
|
+
* @param {{ points: Array<{ period: string, views: number, uniqueViews: number }>, bucket: string }} data
|
|
121
|
+
* @returns {HTMLElement}
|
|
122
|
+
*/
|
|
123
|
+
export function trendChart({ points, bucket, lastInProgress = false }) {
|
|
124
|
+
const card = el('div', { className: 'chart-body' });
|
|
125
|
+
const tooltip = createTooltip(card);
|
|
126
|
+
if (points.length === 0) {
|
|
127
|
+
card.append(el('p', { className: 'chart-empty', text: t('insights.noData') }));
|
|
128
|
+
return card;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const { WIDTH, HEIGHT, MARGIN } = CHART.TREND;
|
|
132
|
+
const plotW = WIDTH - MARGIN.left - MARGIN.right;
|
|
133
|
+
const plotH = HEIGHT - MARGIN.top - MARGIN.bottom;
|
|
134
|
+
const max = Math.max(...points.map((point) => point.views));
|
|
135
|
+
const { top, step } = niceScale(max);
|
|
136
|
+
const x = (index) => MARGIN.left + (points.length === 1 ? plotW / 2 : (index / (points.length - 1)) * plotW);
|
|
137
|
+
const y = (value) => MARGIN.top + plotH - (value / top) * plotH;
|
|
138
|
+
|
|
139
|
+
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))]));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const labelIndexes = [...new Set([0, Math.floor((points.length - 1) / 2), points.length - 1])];
|
|
146
|
+
const xLabels = labelIndexes.map((index) => svg('text', {
|
|
147
|
+
class: 'chart-tick',
|
|
148
|
+
x: x(index),
|
|
149
|
+
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;
|
|
156
|
+
|
|
157
|
+
const crosshair = svg('line', { class: 'chart-crosshair', y1: MARGIN.top, y2: MARGIN.top + plotH, visibility: 'hidden' });
|
|
158
|
+
const focusDot = svg('circle', { class: 'chart-dot', r: CHART.DOT_RADIUS, visibility: 'hidden' });
|
|
159
|
+
|
|
160
|
+
const plot = svg('svg', {
|
|
161
|
+
class: 'trend-svg',
|
|
162
|
+
viewBox: `0 0 ${WIDTH} ${HEIGHT}`,
|
|
163
|
+
role: 'img',
|
|
164
|
+
tabindex: 0,
|
|
165
|
+
'aria-label': t('insights.trendLabel', {
|
|
166
|
+
from: formatPeriod(points[0].period, bucket),
|
|
167
|
+
to: formatPeriod(points[last].period, bucket),
|
|
168
|
+
peak: formatNumber(max),
|
|
169
|
+
}),
|
|
170
|
+
}, [
|
|
171
|
+
...grid,
|
|
172
|
+
svg('path', { class: 'chart-area', d: area }),
|
|
173
|
+
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 }),
|
|
175
|
+
...xLabels,
|
|
176
|
+
crosshair,
|
|
177
|
+
focusDot,
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
let activeIndex = -1;
|
|
181
|
+
function highlight(index, at) {
|
|
182
|
+
activeIndex = index;
|
|
183
|
+
const point = points[index];
|
|
184
|
+
crosshair.setAttribute('x1', x(index));
|
|
185
|
+
crosshair.setAttribute('x2', x(index));
|
|
186
|
+
crosshair.setAttribute('visibility', 'visible');
|
|
187
|
+
focusDot.setAttribute('cx', x(index));
|
|
188
|
+
focusDot.setAttribute('cy', y(point.views));
|
|
189
|
+
focusDot.setAttribute('visibility', 'visible');
|
|
190
|
+
const inProgress = lastInProgress && index === last;
|
|
191
|
+
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));
|
|
200
|
+
}
|
|
201
|
+
function reset() {
|
|
202
|
+
activeIndex = -1;
|
|
203
|
+
crosshair.setAttribute('visibility', 'hidden');
|
|
204
|
+
focusDot.setAttribute('visibility', 'hidden');
|
|
205
|
+
tooltip.hide();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
plot.addEventListener('pointermove', (event) => {
|
|
209
|
+
const box = plot.getBoundingClientRect();
|
|
210
|
+
const svgX = ((event.clientX - box.left) / box.width) * WIDTH;
|
|
211
|
+
const ratio = (svgX - MARGIN.left) / plotW;
|
|
212
|
+
const index = Math.min(points.length - 1, Math.max(0, Math.round(ratio * (points.length - 1))));
|
|
213
|
+
highlight(index, pointIn(card, event));
|
|
214
|
+
});
|
|
215
|
+
plot.addEventListener('pointerleave', reset);
|
|
216
|
+
plot.addEventListener('blur', reset);
|
|
217
|
+
plot.addEventListener('keydown', (event) => {
|
|
218
|
+
const moves = {
|
|
219
|
+
[KEY.ARROW_RIGHT]: Math.min(points.length - 1, activeIndex + 1),
|
|
220
|
+
[KEY.ARROW_LEFT]: Math.max(0, activeIndex < 0 ? last : activeIndex - 1),
|
|
221
|
+
[KEY.HOME]: 0,
|
|
222
|
+
[KEY.END]: last,
|
|
223
|
+
};
|
|
224
|
+
if (event.key in moves) {
|
|
225
|
+
event.preventDefault();
|
|
226
|
+
highlight(moves[event.key]);
|
|
227
|
+
} else if (event.key === KEY.ESCAPE) {
|
|
228
|
+
reset();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
card.append(plot);
|
|
233
|
+
return card;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The trend as a table: the accessible twin of the chart. */
|
|
237
|
+
export function trendTable({ points, bucket }) {
|
|
238
|
+
return el('table', { className: 'chart-table' }, [
|
|
239
|
+
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' } }),
|
|
243
|
+
])]),
|
|
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) }),
|
|
248
|
+
]))),
|
|
249
|
+
]);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---- Breakdown bars ----------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Ranked horizontal bars for one nominal dimension: one hue, value at the
|
|
256
|
+
* tip, the tail folded into "Other".
|
|
257
|
+
*
|
|
258
|
+
* @param {{ entries: Array<{ value: string|null, views: number }>, total: number,
|
|
259
|
+
* labelFor: (value: string|null) => string }} data
|
|
260
|
+
*/
|
|
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;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ---- World map ---------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
let mapAssetPromise = null;
|
|
303
|
+
|
|
304
|
+
/** The projected country shapes, fetched once and cached. */
|
|
305
|
+
function loadMapAsset() {
|
|
306
|
+
if (!mapAssetPromise) {
|
|
307
|
+
mapAssetPromise = fetch('assets/world-map.json', { credentials: 'same-origin' }).then((response) => response.json());
|
|
308
|
+
}
|
|
309
|
+
return mapAssetPromise;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Class breaks for a sequential scale: up to CHART.MAP_CLASSES bins over the
|
|
314
|
+
* non-zero counts, by quantile, with duplicate breaks merged.
|
|
315
|
+
* @param {number[]} values
|
|
316
|
+
* @returns {number[]} ascending upper bounds, one per class
|
|
317
|
+
*/
|
|
318
|
+
export function classBreaks(values) {
|
|
319
|
+
const sorted = values.filter((value) => value > 0).sort((a, b) => a - b);
|
|
320
|
+
if (sorted.length === 0) return [];
|
|
321
|
+
const breaks = [];
|
|
322
|
+
for (let index = 1; index <= CHART.MAP_CLASSES; index++) {
|
|
323
|
+
const bound = sorted[Math.min(sorted.length - 1, Math.ceil((index / CHART.MAP_CLASSES) * sorted.length) - 1)];
|
|
324
|
+
if (!breaks.includes(bound)) breaks.push(bound);
|
|
325
|
+
}
|
|
326
|
+
return breaks;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function classOf(value, breaks) {
|
|
330
|
+
if (!value) return 0;
|
|
331
|
+
const index = breaks.findIndex((bound) => value <= bound);
|
|
332
|
+
const position = index === -1 ? breaks.length - 1 : index;
|
|
333
|
+
// Spread fewer classes across the whole ramp so the top class is always the darkest.
|
|
334
|
+
return 1 + Math.round((position / Math.max(1, breaks.length - 1)) * (CHART.MAP_CLASSES - 1));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
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.
|
|
340
|
+
*
|
|
341
|
+
* @param {{ countries: Array<{ country: string, eventType: string|null, views: number }>,
|
|
342
|
+
* type: string|null, total: number }} data type null means every type
|
|
343
|
+
*/
|
|
344
|
+
export function worldMap({ countries, type }) {
|
|
345
|
+
const body = el('div', { className: 'chart-body map-layout' });
|
|
346
|
+
const mapHost = el('div', { className: 'map-host' });
|
|
347
|
+
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
|
+
})();
|
|
360
|
+
|
|
361
|
+
// Totals per country for the selected type, and the per-type split for tooltips.
|
|
362
|
+
const byCountry = new Map();
|
|
363
|
+
for (const entry of countries) {
|
|
364
|
+
const record = byCountry.get(entry.country) || { total: 0, selected: 0, types: [] };
|
|
365
|
+
record.total += entry.views;
|
|
366
|
+
if (type === null || entry.eventType === type) record.selected += entry.views;
|
|
367
|
+
record.types.push(entry);
|
|
368
|
+
byCountry.set(entry.country, record);
|
|
369
|
+
}
|
|
370
|
+
const selectedTotal = [...byCountry.values()].reduce((sum, record) => sum + record.selected, 0);
|
|
371
|
+
const breaks = classBreaks([...byCountry.values()].map((record) => record.selected));
|
|
372
|
+
|
|
373
|
+
function tooltipFor(code) {
|
|
374
|
+
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
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (selectedTotal > 0 && views > 0) rows.push({ value: formatPercent(views / selectedTotal), label: t('insights.share') });
|
|
386
|
+
return { title: regionName(code), rows };
|
|
387
|
+
}
|
|
388
|
+
|
|
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;
|
|
393
|
+
|
|
394
|
+
const marks = new Map();
|
|
395
|
+
function setActive(code, on) {
|
|
396
|
+
for (const mark of marks.get(code) || []) mark.classList.toggle('active', on);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
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
|
+
});
|
|
404
|
+
const dots = asset.points.map((point) => {
|
|
405
|
+
const value = byCountry.get(point.code)?.selected ?? 0;
|
|
406
|
+
if (!value) return null;
|
|
407
|
+
return svg('circle', {
|
|
408
|
+
cx: point.x, cy: point.y, r: CHART.DOT_RADIUS,
|
|
409
|
+
class: `country country-point map-${classOf(value, breaks)}`, 'data-code': point.code,
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
const map = svg('svg', {
|
|
413
|
+
class: 'map-svg',
|
|
414
|
+
viewBox: `0 0 ${asset.width} ${asset.height}`,
|
|
415
|
+
role: 'img',
|
|
416
|
+
'aria-label': t('insights.mapLabel', { countries: formatNumber(ranked.length) }),
|
|
417
|
+
}, [...paths, ...dots]);
|
|
418
|
+
|
|
419
|
+
for (const mark of map.querySelectorAll('[data-code]')) {
|
|
420
|
+
const code = mark.getAttribute('data-code');
|
|
421
|
+
marks.set(code, [...(marks.get(code) || []), mark]);
|
|
422
|
+
mark.addEventListener('pointermove', (event) => {
|
|
423
|
+
setActive(code, true);
|
|
424
|
+
tooltip.show(tooltipFor(code), pointIn(mapHost, event));
|
|
425
|
+
});
|
|
426
|
+
mark.addEventListener('pointerleave', () => {
|
|
427
|
+
setActive(code, false);
|
|
428
|
+
tooltip.hide();
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
replaceChildren(mapHost, [map, mapHost.querySelector('.chart-tooltip')]);
|
|
432
|
+
}).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
|
+
]));
|
|
447
|
+
});
|
|
448
|
+
replaceChildren(legend, legendItems);
|
|
449
|
+
|
|
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.
|
|
452
|
+
let expanded = false;
|
|
453
|
+
const renderList = () => {
|
|
454
|
+
replaceChildren(list, []);
|
|
455
|
+
if (ranked.length === 0) {
|
|
456
|
+
list.append(el('li', { className: 'chart-empty', text: t('insights.noData') }));
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const visible = expanded ? ranked : ranked.slice(0, CHART.COUNTRY_LIST_LIMIT);
|
|
460
|
+
for (const [code, record] of visible) list.append(countryRow(code, record));
|
|
461
|
+
if (ranked.length > CHART.COUNTRY_LIST_LIMIT) {
|
|
462
|
+
list.append(el('li', { className: 'country-more' }, [el('button', {
|
|
463
|
+
className: 'btn btn-ghost btn-small',
|
|
464
|
+
text: expanded
|
|
465
|
+
? t('insights.fewerCountries')
|
|
466
|
+
: t('insights.moreCountries', { count: ranked.length - CHART.COUNTRY_LIST_LIMIT }),
|
|
467
|
+
attrs: { type: 'button', 'aria-expanded': String(expanded) },
|
|
468
|
+
on: { click: () => {
|
|
469
|
+
expanded = !expanded;
|
|
470
|
+
renderList();
|
|
471
|
+
list.querySelector('.country-more button')?.focus();
|
|
472
|
+
} },
|
|
473
|
+
})]));
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
function countryRow(code, record) {
|
|
478
|
+
const item = el('li', { className: 'country-row', attrs: { tabindex: 0 } }, [
|
|
479
|
+
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}%`;
|
|
484
|
+
const on = () => {
|
|
485
|
+
setActive(code, true);
|
|
486
|
+
const mark = marks.get(code)?.[0];
|
|
487
|
+
if (mark) tooltip.show(tooltipFor(code), anchorOf(mapHost, mark));
|
|
488
|
+
};
|
|
489
|
+
const off = () => {
|
|
490
|
+
setActive(code, false);
|
|
491
|
+
tooltip.hide();
|
|
492
|
+
};
|
|
493
|
+
item.addEventListener('pointerenter', on);
|
|
494
|
+
item.addEventListener('pointerleave', off);
|
|
495
|
+
item.addEventListener('focus', on);
|
|
496
|
+
item.addEventListener('blur', off);
|
|
497
|
+
return item;
|
|
498
|
+
}
|
|
499
|
+
renderList();
|
|
500
|
+
|
|
501
|
+
return body;
|
|
502
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clamped text with its full value on hover (FRONTEND.md §14).
|
|
3
|
+
*
|
|
4
|
+
* Clamp and tooltip ship as one unit so they cannot drift apart: call sites
|
|
5
|
+
* pass text and a line count, and the tooltip appears only when the rendered
|
|
6
|
+
* box is actually truncated, re-measured whenever the box resizes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { el } from './dom.js';
|
|
10
|
+
import { CLAMP_LINES } from './constants.js';
|
|
11
|
+
|
|
12
|
+
const observed = new WeakSet();
|
|
13
|
+
const resizeObserver = new ResizeObserver((entries) => {
|
|
14
|
+
for (const entry of entries) updateTitle(entry.target);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function isTruncated(node) {
|
|
18
|
+
return node.scrollHeight > node.clientHeight + 1 || node.scrollWidth > node.clientWidth + 1;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function updateTitle(node) {
|
|
22
|
+
if (isTruncated(node)) node.setAttribute('title', node.textContent);
|
|
23
|
+
else node.removeAttribute('title');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} text
|
|
28
|
+
* @param {{ lines?: number, className?: string }} [options]
|
|
29
|
+
* @returns {HTMLElement}
|
|
30
|
+
*/
|
|
31
|
+
export function clampText(text, { lines = CLAMP_LINES.SINGLE, className = '' } = {}) {
|
|
32
|
+
const node = el('span', {
|
|
33
|
+
className: `clamp clamp-${lines} ${className}`.trim(),
|
|
34
|
+
text,
|
|
35
|
+
});
|
|
36
|
+
if (!observed.has(node)) {
|
|
37
|
+
observed.add(node);
|
|
38
|
+
resizeObserver.observe(node);
|
|
39
|
+
}
|
|
40
|
+
return node;
|
|
41
|
+
}
|