Skip to content

Commit 0766c4b

Browse files
committed
Analytics dashboard: Rich download timelines
- Daily-bucketed CF query (`toDate(timestamp) AS day`) replaces aggregate-only query — same query count, finer granularity - Per-version mini timelines with synced x-axis zoom (scroll to zoom) and cursor crosshair sync via uPlot - Versions sorted by semver descending, date labels on x-axis - Country names via `Intl.DisplayNames`: "Sweden (SE)" instead of raw codes - Country hover tooltip with per-architecture and per-version mini timelines - Data-point hover tooltip with architecture and country pie charts, inline legends with colored dots, values, and percentages - New `MiniTimeline.svelte` (synced uPlot chart) and `PieChart.svelte` (SVG pie with legend) components
1 parent 98586d5 commit 0766c4b

File tree

3 files changed

+170
-9
lines changed

3 files changed

+170
-9
lines changed

apps/analytics-dashboard/src/lib/components/MiniTimeline.svelte

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,21 @@
1313
xMin?: number | null
1414
/** Shared zoom X-axis max (unix seconds). null = auto. */
1515
xMax?: number | null
16+
/** Cursor sync key — charts with the same key sync crosshairs. */
17+
syncKey?: string
18+
/** Fires when the cursor hovers a data point (index into data[0]) or leaves (null). */
19+
onhover?: (idx: number | null) => void
1620
}
1721
18-
let { data, height = 100, maxY, xMin = null, xMax = null }: Props = $props()
22+
let { data, height = 48, maxY, xMin = null, xMax = null, syncKey, onhover }: Props = $props()
1923
2024
let container: HTMLDivElement
2125
let chart: uPlot | null = null
2226
2327
function buildOpts(width: number): uPlot.Options {
2428
const yMax = maxY ?? Math.max(...(data[1] as number[]), 1)
25-
return {
29+
30+
const opts: uPlot.Options = {
2631
width,
2732
height,
2833
series: [
@@ -34,16 +39,43 @@
3439
},
3540
],
3641
axes: [
37-
{ show: false },
42+
{
43+
stroke: '#71717a',
44+
font: '9px -apple-system, system-ui, sans-serif',
45+
ticks: { show: false },
46+
grid: { show: false },
47+
gap: 2,
48+
size: 14,
49+
},
3850
{ show: false },
3951
],
4052
scales: {
4153
y: { range: () => [0, yMax] },
4254
},
43-
cursor: { show: false },
55+
cursor: syncKey
56+
? {
57+
show: true,
58+
x: true,
59+
y: false,
60+
points: { show: false },
61+
sync: { key: syncKey, setSeries: false },
62+
}
63+
: { show: false },
4464
legend: { show: false },
45-
padding: [4, 0, 0, 0],
65+
padding: [2, 0, 0, 0],
4666
}
67+
68+
if (onhover) {
69+
opts.hooks = {
70+
setCursor: [
71+
(u: uPlot) => {
72+
onhover(u.cursor.idx ?? null)
73+
},
74+
],
75+
}
76+
}
77+
78+
return opts
4779
}
4880
4981
function createChart() {
@@ -79,7 +111,6 @@
79111
if (chart && xMin != null && xMax != null) {
80112
chart.setScale('x', { min: xMin, max: xMax })
81113
} else if (chart && xMin == null && xMax == null && data[0].length > 0) {
82-
// Reset to full range
83114
chart.setScale('x', {
84115
min: data[0][0] as number,
85116
max: data[0][data[0].length - 1] as number,
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<script lang="ts">
2+
interface Props {
3+
slices: Array<{ label: string; value: number }>
4+
size?: number
5+
}
6+
7+
let { slices, size = 72 }: Props = $props()
8+
9+
const colors = [
10+
'#ffc206', '#22c55e', '#3b82f6', '#ef4444',
11+
'#a855f7', '#f97316', '#06b6d4', '#ec4899',
12+
'#84cc16', '#6366f1',
13+
]
14+
15+
const total = $derived(slices.reduce((sum, s) => sum + s.value, 0))
16+
17+
const arcs = $derived.by(() => {
18+
let angle = -Math.PI / 2
19+
return slices.map((slice, i) => {
20+
const frac = total > 0 ? slice.value / total : 0
21+
const sweep = frac * 2 * Math.PI
22+
const end = angle + sweep
23+
const r = 40
24+
const cx = 50
25+
const cy = 50
26+
27+
let path: string
28+
if (frac >= 0.9999) {
29+
path = `M ${cx},${cy - r} A ${r},${r} 0 1,1 ${cx - 0.01},${cy - r} Z`
30+
} else if (frac <= 0.0001) {
31+
path = ''
32+
} else {
33+
const x1 = cx + r * Math.cos(angle)
34+
const y1 = cy + r * Math.sin(angle)
35+
const x2 = cx + r * Math.cos(end)
36+
const y2 = cy + r * Math.sin(end)
37+
const large = sweep > Math.PI ? 1 : 0
38+
path = `M ${cx},${cy} L ${x1},${y1} A ${r},${r} 0 ${large},1 ${x2},${y2} Z`
39+
}
40+
41+
const result = {
42+
label: slice.label,
43+
value: slice.value,
44+
color: colors[i % colors.length],
45+
path,
46+
frac,
47+
}
48+
angle = end
49+
return result
50+
})
51+
})
52+
</script>
53+
54+
<div>
55+
<svg viewBox="0 0 100 100" width={size} height={size} role="img" class="pointer-events-none">
56+
{#each arcs as arc}
57+
{#if arc.path}
58+
<path
59+
d={arc.path}
60+
fill={arc.color}
61+
stroke="var(--color-surface-elevated)"
62+
stroke-width="1.5"
63+
/>
64+
{/if}
65+
{/each}
66+
</svg>
67+
<div class="mt-1 space-y-px">
68+
{#each arcs as arc}
69+
<div class="flex items-center gap-1.5 text-xs leading-tight">
70+
<span style="color: {arc.color}" class="text-[10px]">●</span>
71+
<span class="text-text-secondary">{arc.label}</span>
72+
<span class="ml-auto tabular-nums text-text-tertiary">{arc.value}</span>
73+
<span class="w-9 tabular-nums text-text-tertiary text-right">{(arc.frac * 100).toFixed(0)}%</span>
74+
</div>
75+
{/each}
76+
</div>
77+
</div>

apps/analytics-dashboard/src/routes/+page.svelte

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,17 @@
3737
return [allTimestamps, allDays.map((d) => byDay.get(d) ?? 0)]
3838
}
3939
40+
/** Compares two semver strings, descending (higher version first). */
41+
function compareSemverDesc(a: string, b: string): number {
42+
const pa = a.split('.').map(Number)
43+
const pb = b.split('.').map(Number)
44+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
45+
const diff = (pb[i] ?? 0) - (pa[i] ?? 0)
46+
if (diff !== 0) return diff
47+
}
48+
return 0
49+
}
50+
4051
/** Finds the max daily download value across a set of groups. */
4152
function maxDailyAcrossGroups(
4253
rows: DownloadRow[],
@@ -63,10 +74,12 @@
6374
<script lang="ts">
6475
import Chart from '$lib/components/Chart.svelte'
6576
import MiniTimeline from '$lib/components/MiniTimeline.svelte'
77+
import PieChart from '$lib/components/PieChart.svelte'
6678
6779
let { data } = $props()
6880
6981
const ranges = ['24h', '7d', '30d'] as const
82+
const downloadSyncKey = 'dl-timelines'
7083
7184
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' })
7285
function formatCountry(code: string): string {
@@ -80,6 +93,17 @@
8093
let tooltipX = $state(0)
8194
let tooltipY = $state(0)
8295
96+
// Data-point hover state
97+
let hoveredDayIdx: number | null = $state(null)
98+
let dayTooltipVisible = $state(false)
99+
let dayTooltipX = $state(0)
100+
let dayTooltipY = $state(0)
101+
102+
function handleDayHover(idx: number | null) {
103+
hoveredDayIdx = idx
104+
dayTooltipVisible = idx != null
105+
}
106+
83107
function handleDownloadWheel(e: WheelEvent, dataXMin: number, dataXMax: number) {
84108
e.preventDefault()
85109
const zoomFactor = e.deltaY > 0 ? 1.3 : 1 / 1.3
@@ -263,7 +287,7 @@
263287
{@const cf = data.cloudflare.data}
264288
{@const totalDownloads = cf.downloads.reduce((sum, r) => sum + r.downloads, 0)}
265289
{@const { days: allDays, timestamps: allTimestamps } = getDayAxis(cf.downloads)}
266-
{@const versions = aggregateBy(cf.downloads, 'version', 'downloads').slice(0, 8)}
290+
{@const versions = aggregateBy(cf.downloads, 'version', 'downloads').sort((a, b) => compareSemverDesc(a.x, b.x)).slice(0, 8)}
267291
{@const versionMaxY = maxDailyAcrossGroups(cf.downloads, 'version', versions.map((v) => v.x), allDays)}
268292

269293
{@render metricRow([
@@ -278,6 +302,7 @@
278302
<div
279303
class="grid gap-4 md:grid-cols-3"
280304
onwheel={(e) => handleDownloadWheel(e, allTimestamps[0], allTimestamps[allTimestamps.length - 1])}
305+
onmousemove={(e: MouseEvent) => { dayTooltipX = e.clientX; dayTooltipY = e.clientY }}
281306
>
282307
<div>
283308
<h3 class="mb-2 text-sm font-medium text-text-secondary">By version</h3>
@@ -292,7 +317,7 @@
292317
<span class="text-text-primary">{version.x}</span>
293318
<span class="tabular-nums text-text-secondary">{formatNumber(version.y)}</span>
294319
</div>
295-
<MiniTimeline data={timelineData} height={48} maxY={versionMaxY} xMin={zoomXMin} xMax={zoomXMax} />
320+
<MiniTimeline data={timelineData} height={48} maxY={versionMaxY} xMin={zoomXMin} xMax={zoomXMax} syncKey={downloadSyncKey} onhover={handleDayHover} />
296321
</div>
297322
{/each}
298323
<p class="text-xs text-text-tertiary">Scroll to zoom timeline</p>
@@ -306,12 +331,40 @@
306331
{@render countryTable(cf.downloads, allDays, allTimestamps)}
307332
</div>
308333
</div>
334+
335+
<!-- Data-point hover tooltip with pie charts -->
336+
{#if dayTooltipVisible && hoveredDayIdx != null && hoveredDayIdx < allDays.length}
337+
{@const day = allDays[hoveredDayIdx]}
338+
{@const dayRows = cf.downloads.filter((r) => r.day === day)}
339+
{@const dayTotal = dayRows.reduce((sum, r) => sum + r.downloads, 0)}
340+
{#if dayTotal > 0}
341+
<div
342+
class="pointer-events-none fixed z-50 rounded-lg border border-border bg-surface-elevated p-3 shadow-lg"
343+
style="left: {dayTooltipX + 16}px; top: {Math.max(16, dayTooltipY - 100)}px;"
344+
>
345+
<p class="mb-2 text-sm font-medium text-text-primary">
346+
{day}
347+
<span class="ml-2 tabular-nums text-text-secondary">{formatNumber(dayTotal)} downloads</span>
348+
</p>
349+
<div class="flex items-start gap-5">
350+
<div>
351+
<p class="mb-1 text-xs font-medium text-text-tertiary">Architecture</p>
352+
<PieChart slices={aggregateBy(dayRows, 'arch', 'downloads').map((a) => ({ label: a.x, value: a.y }))} />
353+
</div>
354+
<div>
355+
<p class="mb-1 text-xs font-medium text-text-tertiary">Country</p>
356+
<PieChart slices={aggregateBy(dayRows, 'country', 'downloads').slice(0, 8).map((c) => ({ label: c.x.toUpperCase(), value: c.y }))} />
357+
</div>
358+
</div>
359+
</div>
360+
{/if}
361+
{/if}
309362
{:else if cf.downloads.length > 0}
310363
<!-- Fewer than 2 days of data — show tables only -->
311364
<div class="grid gap-4 md:grid-cols-3">
312365
<div>
313366
<h3 class="mb-2 text-sm font-medium text-text-secondary">By version</h3>
314-
{@render metricTable(aggregateBy(cf.downloads, 'version', 'downloads').slice(0, 8), 'Version', 'Downloads')}
367+
{@render metricTable(aggregateBy(cf.downloads, 'version', 'downloads').sort((a, b) => compareSemverDesc(a.x, b.x)).slice(0, 8), 'Version', 'Downloads')}
315368
</div>
316369
<div>
317370
<h3 class="mb-2 text-sm font-medium text-text-secondary">By architecture</h3>

0 commit comments

Comments
 (0)