diff --git a/app/(dashboard)/analytics/AnalyticsClient.tsx b/app/(dashboard)/analytics/AnalyticsClient.tsx index 1ad7b23f..6352baf3 100644 --- a/app/(dashboard)/analytics/AnalyticsClient.tsx +++ b/app/(dashboard)/analytics/AnalyticsClient.tsx @@ -43,7 +43,7 @@ const WorldMap = dynamic(() => import('./WorldMapInner'), { // ── Types (mirrored from analytics-db — can't import server-only code) ──────── -type Interval = '24h' | '7d' | '30d'; +type Interval = '1h' | '12h' | '24h' | '7d' | '30d'; interface AnalyticsSummary { totalRequests: number; @@ -99,6 +99,8 @@ function formatBytes(bytes: number): string { function formatTs(ts: number, interval: Interval): string { const d = new Date(ts * 1000); + if (interval === '1h') return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + if (interval === '12h') return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); if (interval === '24h') return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); if (interval === '7d') return d.toLocaleDateString([], { weekday: 'short' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); @@ -239,6 +241,8 @@ export default function AnalyticsClient() { size="small" onChange={(_e, v) => { if (v) setIntervalVal(v); }} > + 1h + 12h 24h 7d 30d diff --git a/app/(dashboard)/analytics/WorldMapInner.tsx b/app/(dashboard)/analytics/WorldMapInner.tsx index 67afff66..1c877c27 100644 --- a/app/(dashboard)/analytics/WorldMapInner.tsx +++ b/app/(dashboard)/analytics/WorldMapInner.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import MapGL, { Layer, Source, type MapRef, type MapLayerMouseEvent } from 'react-map-gl/maplibre'; +import { useState, useMemo, useCallback, useEffect } from 'react'; +import MapGL, { Layer, Popup, Source, type MapLayerMouseEvent } from 'react-map-gl/maplibre'; import { feature } from 'topojson-client'; import type { Topology, GeometryCollection } from 'topojson-specification'; +import type { ExpressionSpecification, FillLayerSpecification, LineLayerSpecification } from 'maplibre-gl'; import { Box, CircularProgress, Typography } from '@mui/material'; import 'maplibre-gl/dist/maplibre-gl.css'; @@ -80,33 +81,68 @@ function flag(code: string): string { return String.fromCodePoint(...[...code.toUpperCase()].map(c => 0x1F1E6 + c.charCodeAt(0) - 65)); } -const OCEAN = '#0b1628'; +const OCEAN = '#0a1628'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const MAP_STYLE: any = { version: 8, name: 'blank', sources: {}, - layers: [ - { id: 'bg', type: 'background', paint: { 'background-color': OCEAN } }, - ], + layers: [{ id: 'bg', type: 'background', paint: { 'background-color': OCEAN } }], +}; + +const FILL_LAYER: Omit = { + id: 'countries-fill', + type: 'fill', + paint: { + 'fill-color': [ + 'interpolate', ['linear'], + ['coalesce', ['get', 'norm'], 0], + 0, '#1e293b', // no traffic — slate-800, clearly distinct from ocean + 0.001, '#1e3a8a', // any traffic + 0.4, '#3b82f6', + 1, '#93c5fd', + ] as ExpressionSpecification, + 'fill-opacity': 1, + }, +}; + +const HIGHLIGHT_LAYER: Omit = { + id: 'countries-highlight', + type: 'fill', + paint: { + 'fill-color': '#7dd3fc', + 'fill-opacity': 0.45, + }, +}; + +const OUTLINE_LAYER: Omit = { + id: 'countries-outline', + type: 'line', + paint: { + 'line-color': 'rgba(148,163,184,0.18)', + 'line-width': 0.6, + }, }; export interface CountryStats { countryCode: string; total: number; blocked: number; } -interface TooltipState { x: number; y: number; alpha2: string; total: number; blocked: number; } +interface HoverInfo { + longitude: number; + latitude: number; + alpha2: string; + total: number; + blocked: number; +} export default function WorldMapInner({ data }: { data: CountryStats[] }) { - const mapRef = useRef(null); const [baseGeojson, setBaseGeojson] = useState(null); - const [tooltip, setTooltip] = useState(null); - const hoveredIdRef = useRef(null); + const [hoverInfo, setHoverInfo] = useState(null); const countMap = useMemo(() => new Map(data.map(d => [d.countryCode, d.total])), [data]); const blockedMap = useMemo(() => new Map(data.map(d => [d.countryCode, d.blocked])), [data]); const max = useMemo(() => data.reduce((m, d) => Math.max(m, d.total), 0), [data]); - // Load topology once useEffect(() => { fetch('/geo/countries-110m.json') .then(r => r.json()) @@ -116,70 +152,38 @@ export default function WorldMapInner({ data }: { data: CountryStats[] }) { .catch(() => setBaseGeojson({ type: 'FeatureCollection', features: [] })); }, []); - // Enrich GeoJSON with traffic properties whenever data or topology changes const geojson = useMemo(() => { if (!baseGeojson) return null; const safeMax = Math.max(max, 1); return { ...baseGeojson, features: baseGeojson.features.map(f => { - const numId = String(f.id ?? ''); - const alpha2 = N2A[numId] ?? null; + const alpha2 = N2A[String(f.id ?? '')] ?? null; const total = alpha2 ? (countMap.get(alpha2) ?? 0) : 0; const blocked = alpha2 ? (blockedMap.get(alpha2) ?? 0) : 0; - return { - ...f, - properties: { ...f.properties, alpha2, total, blocked, norm: total / safeMax }, - }; + return { ...f, properties: { ...f.properties, alpha2, total, blocked, norm: total / safeMax } }; }), }; }, [baseGeojson, countMap, blockedMap, max]); - const onMouseMove = useCallback((e: MapLayerMouseEvent) => { - const map = mapRef.current?.getMap(); - if (!map) return; - - const [f] = e.features ?? []; - if (!f) { - if (hoveredIdRef.current != null) { - map.setFeatureState({ source: 'countries', id: hoveredIdRef.current }, { hover: false }); - hoveredIdRef.current = null; - } - setTooltip(null); - return; - } - - const fId = f.id ?? null; - if (fId !== hoveredIdRef.current) { - if (hoveredIdRef.current != null) { - map.setFeatureState({ source: 'countries', id: hoveredIdRef.current }, { hover: false }); - } - hoveredIdRef.current = fId; - if (fId != null) map.setFeatureState({ source: 'countries', id: fId }, { hover: true }); - } - - const alpha2 = (f.properties?.alpha2 as string | null) ?? null; - if (alpha2) { - setTooltip({ - x: e.originalEvent.clientX, - y: e.originalEvent.clientY, - alpha2, - total: (f.properties?.total as number) ?? 0, - blocked: (f.properties?.blocked as number) ?? 0, - }); - } else { - setTooltip(null); - } + const onHover = useCallback((event: MapLayerMouseEvent) => { + const f = event.features?.[0]; + if (!f) { setHoverInfo(null); return; } + const alpha2 = f.properties?.alpha2 as string | null; + if (!alpha2) { setHoverInfo(null); return; } + setHoverInfo({ + longitude: event.lngLat.lng, + latitude: event.lngLat.lat, + alpha2, + total: (f.properties?.total as number) ?? 0, + blocked: (f.properties?.blocked as number) ?? 0, + }); }, []); - const onMouseLeave = useCallback(() => { - const map = mapRef.current?.getMap(); - if (map && hoveredIdRef.current != null) { - map.setFeatureState({ source: 'countries', id: hoveredIdRef.current }, { hover: false }); - hoveredIdRef.current = null; - } - setTooltip(null); - }, []); + const highlightFilter = useMemo( + () => ['==', ['get', 'alpha2'], hoverInfo?.alpha2 ?? ''], + [hoverInfo?.alpha2], + ); if (!geojson) { return ( @@ -191,6 +195,20 @@ export default function WorldMapInner({ data }: { data: CountryStats[] }) { return ( + {/* Override MapLibre popup chrome to match dark theme */} + + setHoverInfo(null)} style={{ width: '100%', height: '100%' }} attributionControl={false} - scrollZoom={false} dragRotate={false} pitchWithRotate={false} - cursor={tooltip ? 'crosshair' : 'default'} + cursor={hoverInfo ? 'crosshair' : 'grab'} > - - - + + + + + {hoverInfo && ( + +
+
+ {flag(hoverInfo.alpha2)} + {NAMES[hoverInfo.alpha2] ?? hoverInfo.alpha2} +
+
+ Requests + + {hoverInfo.total.toLocaleString()} + +
+ {hoverInfo.blocked > 0 && ( +
+ Blocked + + {hoverInfo.blocked.toLocaleString()} + +
+ )} + {hoverInfo.total === 0 && ( +
No traffic recorded
+ )} +
+
+ )}
@@ -255,52 +285,6 @@ export default function WorldMapInner({ data }: { data: CountryStats[] }) { High
)} - - {tooltip && ( - - - {flag(tooltip.alpha2)} - - {NAMES[tooltip.alpha2] ?? tooltip.alpha2} - - - - - Requests - - {tooltip.total.toLocaleString()} - - - {tooltip.blocked > 0 && ( - - Blocked - - {tooltip.blocked.toLocaleString()} - - - )} - {tooltip.total === 0 && ( - No traffic recorded - )} - - - )} ); }