update viewers page

This commit is contained in:
Heidi
2026-05-16 07:42:53 +01:00
parent eec70b39aa
commit a586282b3f
4 changed files with 305 additions and 100 deletions
+223 -77
View File
@@ -1,4 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import Tree from '../Tree/Tree'
import FallingLeaves from '../Tree/FallingLeaves'
@@ -2090,10 +2092,10 @@ const countryMapPoints = {
ZA: [24, -29],
}
function geoPoint(lon, lat) {
function mapPointFromPercent(x, y) {
return [
((lon + 180) / 360) * 100,
((85 - lat) / 145) * 100,
85 - (y / 100) * 145,
(x / 100) * 360 - 180,
]
}
@@ -2114,13 +2116,68 @@ function filledLast30Days(rows) {
const date = new Date()
date.setDate(date.getDate() - (29 - index))
const key = date.toISOString().slice(0, 10)
return byDate.get(key) || { date: key, events: 0, visitors: 0, page_views: 0 }
return byDate.get(key) || {
date: key,
events: 0,
visitors: 0,
page_views: 0,
clients: 0,
locations: 0,
}
})
}
function MiniLineChart({ accent = 'text-fury-cyan', data, label, metric, stroke = '#e82517' }) {
const width = 320
const height = 112
const padding = 12
const maxValue = Math.max(1, ...data.map((item) => Number(item[metric] || 0)))
const points = data.map((item, index) => {
const x = padding + (index / Math.max(1, data.length - 1)) * (width - padding * 2)
const y = height - padding - (Number(item[metric] || 0) / maxValue) * (height - padding * 2)
return { ...item, x, y, value: Number(item[metric] || 0) }
})
const pathData = points
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`)
.join(' ')
const latest = points.at(-1)?.value || 0
return (
<div className="rounded-md border border-border bg-bg p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold">{label}</p>
<p className="mt-1 text-xs text-text-soft">Daily count</p>
</div>
<p className={`text-sm font-semibold ${accent}`}>{formatNumber(latest)}</p>
</div>
<svg className="mt-3 h-28 w-full overflow-visible" viewBox={`0 0 ${width} ${height}`} role="img" aria-label={`${label} line chart`}>
<path d={`M ${padding} ${height - padding} H ${width - padding}`} fill="none" stroke="#fee5cd" strokeWidth="1" />
<path d={pathData} fill="none" stroke={stroke} strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" />
{points.map((point) => (
<circle
cx={point.x}
cy={point.y}
fill={stroke}
key={`${metric}-${point.date}`}
r="4"
tabIndex="0"
>
<title>
{`${shortDateFormat.format(new Date(point.date))}: ${formatNumber(point.value)} ${label.toLowerCase()}, ${formatNumber(point.visitors || 0)} visitors`}
</title>
</circle>
))}
</svg>
</div>
)
}
function ViewersPage({ viewers }) {
const data = viewers.data || {}
const active = data.active || []
const activePages = data.active_pages || []
const topPages = data.top_pages || []
const clients = data.clients || []
const clients30d = data.clients_30d || []
@@ -2129,7 +2186,6 @@ function ViewersPage({ viewers }) {
const locations = data.locations || []
const dataTypes = data.data_types || []
const totals = data.totals || {}
const maxDailyEvents = Math.max(1, ...activity30d.map((day) => Number(day.events || 0)))
const generatedAt = data.generated_at ? dateFormat.format(new Date(data.generated_at)) : 'Waiting for data'
return (
@@ -2182,28 +2238,98 @@ function ViewersPage({ viewers }) {
</div>
</div>
<div className="mt-5 flex h-36 items-end gap-1 rounded-md border border-border bg-bg p-3">
{activity30d.map((day) => {
const height = `${Math.max(8, (Number(day.events || 0) / maxDailyEvents) * 100)}%`
return (
<div
aria-label={`${formatNumber(day.events)} events on ${day.date}`}
className="min-w-1 flex-1 rounded-sm bg-fury-cyan transition hover:bg-fury-aqua"
key={day.date}
style={{ height }}
title={`${shortDateFormat.format(new Date(day.date))}: ${formatNumber(day.events)} events, ${formatNumber(day.visitors)} visitors`}
/>
)
})}
<div className="mt-5 grid gap-4 lg:grid-cols-2 xl:grid-cols-5">
<MiniLineChart
data={activity30d}
label="Events"
metric="events"
stroke="#e82517"
/>
<MiniLineChart
accent="text-fury-violet"
data={activity30d}
label="Visitors"
metric="visitors"
stroke="#fb7b04"
/>
<MiniLineChart
accent="text-fury-cyan"
data={activity30d}
label="Page views"
metric="page_views"
stroke="#ed5145"
/>
<MiniLineChart
accent="text-text"
data={activity30d}
label="Clients"
metric="clients"
stroke="#000000"
/>
<MiniLineChart
accent="text-fury-cyan"
data={activity30d}
label="Locations"
metric="locations"
stroke="#009ccc"
/>
</div>
</div>
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
<div className="border-b border-surface px-5 py-4">
<h2 className="text-lg font-semibold">Currently viewing</h2>
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-lg font-semibold">Currently viewing</h2>
<p className="mt-1 text-sm text-text-soft">
Pages with active heartbeats within {formatNumber(data.active_window_seconds || 75)} seconds
</p>
</div>
<p className="text-sm font-semibold text-fury-cyan">
{formatNumber(active.length)} active sessions
</p>
</div>
</div>
{activePages.map((page) => (
<div
className="grid gap-4 border-b border-surface px-5 py-4 text-sm lg:grid-cols-[1fr_auto_auto]"
key={`${page.page_path}-${page.page_title}`}
>
<div className="min-w-0">
<p className="truncate text-lg font-semibold">{page.page_title || page.page_path}</p>
<p className="truncate text-xs text-text-soft">{page.page_path}</p>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-text-soft">
{(page.clients || []).map((client) => (
<span className="rounded-md bg-surface px-2 py-1" key={client.label}>
{client.label} ({formatNumber(client.count)})
</span>
))}
{(page.countries || []).map((country) => (
<span className="rounded-md bg-surface px-2 py-1" key={country}>
{country}
</span>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-right sm:grid-cols-1">
<p className="font-semibold text-fury-cyan">{formatNumber(page.viewers)} viewing</p>
<p className="text-text-soft">{formatNumber(page.visitors)} visitors</p>
</div>
<p className="text-right text-text-soft">Seen {relativeSeconds(page.last_seen_at)}</p>
</div>
))}
{!activePages.length ? (
<p className="px-5 py-10 text-sm text-text-soft">
{viewers.error || 'No pages are actively being viewed right now'}
</p>
) : null}
</div>
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
<div className="border-b border-surface px-5 py-4">
<h2 className="text-lg font-semibold">Active sessions</h2>
<p className="mt-1 text-sm text-text-soft">
Heartbeats within {formatNumber(data.active_window_seconds || 75)} seconds
Individual sessions currently contributing to the active page counts
</p>
</div>
{active.map((viewer) => (
@@ -2332,6 +2458,8 @@ function ViewersPage({ viewers }) {
}
function LocationSignalMap({ countries, locations }) {
const mapRef = useRef(null)
const markersRef = useRef(null)
const maxCountryVisitors = Math.max(1, ...countries.map((country) => Number(country.visitors || 0)))
const maxLocationVisitors = Math.max(1, ...locations.map((location) => Number(location.visitors || 0)))
const fallbackLocations = locations.filter((location) => !location.country)
@@ -2339,11 +2467,10 @@ function LocationSignalMap({ countries, locations }) {
.map((country) => {
const coordinates = countryMapPoints[country.country]
if (!coordinates) return null
const [left, top] = geoPoint(coordinates[0], coordinates[1])
return {
...country,
left,
top,
lat: coordinates[1],
lon: coordinates[0],
label: countryNames[country.country] || country.country,
}
})
@@ -2364,62 +2491,81 @@ function LocationSignalMap({ countries, locations }) {
events: location.events,
}))
useEffect(() => {
if (!mapRef.current || markersRef.current) return undefined
const map = L.map(mapRef.current, {
center: [25, 0],
maxBounds: [[-85, -220], [85, 220]],
minZoom: 1,
scrollWheelZoom: true,
worldCopyJump: true,
zoom: 1,
})
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 8,
}).addTo(map)
markersRef.current = {
layer: L.layerGroup().addTo(map),
map,
}
return () => {
markersRef.current = null
map.remove()
}
}, [])
useEffect(() => {
if (!markersRef.current) return
const { layer } = markersRef.current
layer.clearLayers()
countryMarkers.forEach((country) => {
const radius = 10 + (Number(country.visitors || 0) / maxCountryVisitors) * 22
L.circleMarker([country.lat, country.lon], {
color: '#e82517',
fillColor: '#e82517',
fillOpacity: 0.4,
opacity: 0,
radius,
stroke: false,
})
.bindTooltip(`${country.label}: ${formatNumber(country.visitors)} visitors`, {
direction: 'top',
opacity: 0.95,
})
.addTo(layer)
})
fallbackLocations.forEach((location) => {
const [x, y] = timezonePoint(location.timezone)
const [lat, lon] = mapPointFromPercent(x, y)
const radius = 7 + (Number(location.visitors || 0) / maxLocationVisitors) * 16
L.circleMarker([lat, lon], {
color: '#000000',
fillColor: '#000000',
fillOpacity: 0.4,
opacity: 0,
radius,
stroke: false,
})
.bindTooltip(`${location.timezone}: ${formatNumber(location.visitors)} visitors`, {
direction: 'top',
opacity: 0.95,
})
.addTo(layer)
})
}, [countryMarkers, fallbackLocations, maxCountryVisitors, maxLocationVisitors])
return (
<div className="p-5">
<div className="relative h-[340px] overflow-hidden rounded-md border border-border bg-surface">
<iframe
className="absolute inset-0 h-full w-full grayscale-[20%] saturate-[0.85]"
loading="lazy"
referrerPolicy="no-referrer"
src="https://www.openstreetmap.org/export/embed.html?bbox=-180%2C-58%2C180%2C84&layer=mapnik"
title="OpenStreetMap traffic overview"
/>
<div className="pointer-events-none absolute inset-0 bg-fury-white/10" />
{countryMarkers.map((country) => {
const size = 16 + (Number(country.visitors || 0) / maxCountryVisitors) * 28
return (
<div
className="group pointer-events-auto absolute -translate-x-1/2 -translate-y-1/2"
key={country.country}
style={{ left: `${country.left}%`, top: `${country.top}%` }}
>
<span
className="block rounded-full border-2 border-fury-white bg-fury-cyan shadow-[0_4px_18px_rgba(232,37,23,0.35)]"
style={{ height: size, width: size }}
/>
<span className="absolute left-1/2 top-full z-10 mt-2 hidden w-max max-w-44 -translate-x-1/2 rounded-md bg-text px-2 py-1 text-xs font-semibold text-bg shadow-lg group-hover:block">
{country.label}: {formatNumber(country.visitors)} visitors
</span>
</div>
)
})}
{fallbackLocations.map((location) => {
const [x, y] = timezonePoint(location.timezone)
const radius = 5 + (Number(location.visitors || 0) / maxLocationVisitors) * 12
return (
<div
className="group pointer-events-auto absolute -translate-x-1/2 -translate-y-1/2"
key={`${location.timezone}-${location.language}`}
style={{ left: `${x}%`, top: `${y}%` }}
>
<span
className="block rounded-full border-2 border-fury-white bg-text shadow-[0_4px_18px_rgba(0,0,0,0.2)]"
style={{ height: radius * 2, width: radius * 2 }}
/>
<span className="absolute left-1/2 top-full z-10 mt-2 hidden w-max max-w-52 -translate-x-1/2 rounded-md bg-text px-2 py-1 text-xs font-semibold text-bg shadow-lg group-hover:block">
{location.timezone}: {formatNumber(location.visitors)} visitors
</span>
</div>
)
})}
<div className="pointer-events-none absolute right-3 bottom-3 rounded-sm bg-fury-white/95 px-2 py-1 text-[10px] font-semibold text-text-soft shadow-sm">
© OpenStreetMap contributors
</div>
<div ref={mapRef} className="h-full w-full" />
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-2">