update viewers page
This commit is contained in:
Generated
+7
@@ -11,6 +11,7 @@
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
"@vitejs/plugin-react": "^4.5.0",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"vite": "^6.3.5"
|
||||
@@ -2715,6 +2716,12 @@
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
"@vitejs/plugin-react": "^4.5.0",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"vite": "^6.3.5"
|
||||
|
||||
+74
-23
@@ -500,15 +500,18 @@ function readJsonBody(req) {
|
||||
}
|
||||
|
||||
function purgeOldAnalytics(db) {
|
||||
const eventCutoff = new Date(Date.now() - ANALYTICS_RETENTION_DAYS * 24 * 60 * 60 * 1000).toISOString()
|
||||
const activeCutoff = new Date(Date.now() - ANALYTICS_ACTIVE_WINDOW_SECONDS * 3 * 1000).toISOString()
|
||||
|
||||
db.prepare(`
|
||||
delete from viewer_events
|
||||
where occurred_at < datetime('now', ?)
|
||||
`).run(`-${ANALYTICS_RETENTION_DAYS} days`)
|
||||
where occurred_at < ?
|
||||
`).run(eventCutoff)
|
||||
|
||||
db.prepare(`
|
||||
delete from active_viewers
|
||||
where last_seen_at < datetime('now', ?)
|
||||
`).run(`-${ANALYTICS_ACTIVE_WINDOW_SECONDS * 3} seconds`)
|
||||
where last_seen_at < ?
|
||||
`).run(activeCutoff)
|
||||
}
|
||||
|
||||
function recordViewerEvent(req, payload) {
|
||||
@@ -612,12 +615,14 @@ function viewerDashboard() {
|
||||
const db = ensureAnalyticsDb()
|
||||
purgeOldAnalytics(db)
|
||||
|
||||
const activeSince = `-${ANALYTICS_ACTIVE_WINDOW_SECONDS} seconds`
|
||||
const activeSince = new Date(Date.now() - ANALYTICS_ACTIVE_WINDOW_SECONDS * 1000).toISOString()
|
||||
const daySince = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
|
||||
const thirtyDaysSince = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
|
||||
const active = db.prepare(`
|
||||
select session_id, visitor_id, first_seen_at, last_seen_at, page_path, page_title,
|
||||
referrer, browser, os, device, screen, language, timezone, country
|
||||
from active_viewers
|
||||
where last_seen_at >= datetime('now', ?)
|
||||
where last_seen_at >= ?
|
||||
order by last_seen_at desc
|
||||
limit 100
|
||||
`).all(activeSince).map((row) => ({
|
||||
@@ -637,65 +642,110 @@ function viewerDashboard() {
|
||||
country: row.country,
|
||||
}))
|
||||
|
||||
const activePageMap = new Map()
|
||||
for (const viewer of active) {
|
||||
const key = `${viewer.page_path}|${viewer.page_title}`
|
||||
const existing = activePageMap.get(key) || {
|
||||
page_path: viewer.page_path,
|
||||
page_title: viewer.page_title,
|
||||
viewers: 0,
|
||||
visitors: new Set(),
|
||||
clients: new Map(),
|
||||
countries: new Set(),
|
||||
last_seen_at: viewer.last_seen_at,
|
||||
}
|
||||
|
||||
existing.viewers += 1
|
||||
existing.visitors.add(viewer.visitor)
|
||||
if (viewer.country) existing.countries.add(viewer.country)
|
||||
const clientKey = `${viewer.browser} on ${viewer.os}`
|
||||
existing.clients.set(clientKey, (existing.clients.get(clientKey) || 0) + 1)
|
||||
if (new Date(viewer.last_seen_at).getTime() > new Date(existing.last_seen_at).getTime()) {
|
||||
existing.last_seen_at = viewer.last_seen_at
|
||||
}
|
||||
activePageMap.set(key, existing)
|
||||
}
|
||||
|
||||
const activePages = Array.from(activePageMap.values())
|
||||
.map((page) => ({
|
||||
page_path: page.page_path,
|
||||
page_title: page.page_title,
|
||||
viewers: page.viewers,
|
||||
visitors: page.visitors.size,
|
||||
countries: Array.from(page.countries).sort(),
|
||||
clients: Array.from(page.clients.entries())
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.slice(0, 4)
|
||||
.map(([label, count]) => ({ label, count })),
|
||||
last_seen_at: page.last_seen_at,
|
||||
}))
|
||||
.sort((a, b) => b.viewers - a.viewers || new Date(b.last_seen_at) - new Date(a.last_seen_at))
|
||||
|
||||
const topPages = db.prepare(`
|
||||
select page_path, page_title, count(*) as views
|
||||
from viewer_events
|
||||
where event_type = 'page_view'
|
||||
and occurred_at >= datetime('now', '-24 hours')
|
||||
and occurred_at >= ?
|
||||
group by page_path, page_title
|
||||
order by views desc, page_path asc
|
||||
limit 12
|
||||
`).all()
|
||||
`).all(daySince)
|
||||
|
||||
const clients = db.prepare(`
|
||||
select browser, os, device, count(*) as events
|
||||
from viewer_events
|
||||
where occurred_at >= datetime('now', '-24 hours')
|
||||
where occurred_at >= ?
|
||||
group by browser, os, device
|
||||
order by events desc
|
||||
limit 12
|
||||
`).all()
|
||||
`).all(daySince)
|
||||
|
||||
const clients30d = db.prepare(`
|
||||
select browser, os, device, count(*) as events, count(distinct visitor_id) as visitors
|
||||
from viewer_events
|
||||
where occurred_at >= datetime('now', '-30 days')
|
||||
where occurred_at >= ?
|
||||
group by browser, os, device
|
||||
order by events desc
|
||||
limit 12
|
||||
`).all()
|
||||
`).all(thirtyDaysSince)
|
||||
|
||||
const activity30d = db.prepare(`
|
||||
select
|
||||
date(occurred_at) as date,
|
||||
count(*) as events,
|
||||
count(distinct visitor_id) as visitors,
|
||||
sum(case when event_type = 'page_view' then 1 else 0 end) as page_views
|
||||
sum(case when event_type = 'page_view' then 1 else 0 end) as page_views,
|
||||
count(distinct browser || '|' || os || '|' || device) as clients,
|
||||
count(distinct case
|
||||
when country != '' then country
|
||||
when timezone != '' and timezone != 'Not shared' then timezone
|
||||
else null
|
||||
end) as locations
|
||||
from viewer_events
|
||||
where occurred_at >= datetime('now', '-30 days')
|
||||
where occurred_at >= ?
|
||||
group by date(occurred_at)
|
||||
order by date asc
|
||||
`).all()
|
||||
`).all(thirtyDaysSince)
|
||||
|
||||
const countries = db.prepare(`
|
||||
select country, count(*) as events, count(distinct visitor_id) as visitors
|
||||
from viewer_events
|
||||
where occurred_at >= datetime('now', '-30 days')
|
||||
where occurred_at >= ?
|
||||
and country != ''
|
||||
group by country
|
||||
order by visitors desc, events desc
|
||||
limit 80
|
||||
`).all()
|
||||
`).all(thirtyDaysSince)
|
||||
|
||||
const locations = db.prepare(`
|
||||
select country, timezone, language, count(*) as events, count(distinct visitor_id) as visitors
|
||||
from viewer_events
|
||||
where occurred_at >= datetime('now', '-30 days')
|
||||
where occurred_at >= ?
|
||||
and (country != '' or (timezone != '' and timezone != 'Not shared'))
|
||||
group by country, timezone, language
|
||||
order by visitors desc, events desc
|
||||
limit 32
|
||||
`).all()
|
||||
`).all(thirtyDaysSince)
|
||||
|
||||
const totals = db.prepare(`
|
||||
select
|
||||
@@ -703,8 +753,8 @@ function viewerDashboard() {
|
||||
count(distinct visitor_id) as visitors_24h,
|
||||
sum(case when event_type = 'page_view' then 1 else 0 end) as page_views_24h
|
||||
from viewer_events
|
||||
where occurred_at >= datetime('now', '-24 hours')
|
||||
`).get()
|
||||
where occurred_at >= ?
|
||||
`).get(daySince)
|
||||
|
||||
const totals30d = db.prepare(`
|
||||
select
|
||||
@@ -713,13 +763,14 @@ function viewerDashboard() {
|
||||
count(distinct session_id) as sessions_30d,
|
||||
sum(case when event_type = 'page_view' then 1 else 0 end) as page_views_30d
|
||||
from viewer_events
|
||||
where occurred_at >= datetime('now', '-30 days')
|
||||
`).get()
|
||||
where occurred_at >= ?
|
||||
`).get(thirtyDaysSince)
|
||||
|
||||
return {
|
||||
active_window_seconds: ANALYTICS_ACTIVE_WINDOW_SECONDS,
|
||||
generated_at: new Date().toISOString(),
|
||||
active,
|
||||
active_pages: activePages,
|
||||
top_pages: topPages,
|
||||
clients,
|
||||
clients_30d: clients30d,
|
||||
|
||||
+223
-77
@@ -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: '© 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">
|
||||
|
||||
Reference in New Issue
Block a user