Refactor client UI with shadcn heatmap layout

This commit is contained in:
Bernard Ngandu
2025-10-10 10:04:04 +02:00
parent 9834438ff1
commit 8f4b954af8
20 changed files with 2133 additions and 739 deletions
+239 -701
View File
@@ -1,76 +1,36 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import L, { type LayerGroup, type LeafletMouseEvent, type Map as LeafletMap } from 'leaflet'
import 'leaflet.heat'
import { useCallback, useMemo, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { cn, distanceInKm, formatCoordinate, formatRelativeTime } from '@/lib/utils'
import { AppHeader } from '@/components/layout/AppHeader'
import { ActivityPanel } from '@/components/panels/ActivityPanel'
import { HotspotStatsPanel } from '@/components/panels/HotspotStatsPanel'
import { OverviewPanel } from '@/components/panels/OverviewPanel'
import { MapViewport } from '@/components/map/MapViewport'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { useHotspotFeed } from '@/hooks/useHotspotFeed'
import { useLeafletHeatmap } from '@/hooks/useLeafletHeatmap'
import { useUserLocation } from '@/hooks/useUserLocation'
import { distanceInKm, formatCoordinate, formatRelativeTime, formatTimestamp } from '@/lib/utils'
import type { FeedStatus, LatLng } from '@/types/api'
const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api/signals'
const VISIBLE_RADIUS_KM = 5
const RADIUS_KM = 1
type Status = 'loading' | 'idle' | 'error' | 'posting' | 'refreshing'
type ApiPoint = {
id: number
lat: number
lng: number
createdAt: string
userKey: string
}
type ApiDensity = {
lat: number
lng: number
intensity: number
}
type ApiResponse = {
clientKey?: string
points?: ApiPoint[]
density?: ApiDensity[]
latestByUser?: ApiPoint[]
totals?: {
points: number
contributors: number
}
updatedAt?: string
}
type HeatPoint = [number, number, number?]
type LeafletHeatLayer = L.Layer & {
setLatLngs(points: HeatPoint[]): LeafletHeatLayer
}
interface HeatLayerOptions {
radius?: number
blur?: number
maxZoom?: number
max?: number
minOpacity?: number
gradient?: Record<number, string>
}
type LeafletWithHeat = typeof L & {
heatLayer?: (points: HeatPoint[], options?: HeatLayerOptions) => LeafletHeatLayer
}
interface LatLng {
lat: number
lng: number
}
function getStatusLabel(status: Status): string {
function getStatusLabel(status: FeedStatus): string {
switch (status) {
case 'loading':
return 'Syncing map'
case 'posting':
return 'Sending your signal'
return 'Sending signal'
case 'refreshing':
return 'Updating hotspots'
return 'Updating heat'
case 'error':
return 'Offline'
default:
@@ -78,679 +38,257 @@ function getStatusLabel(status: Status): string {
}
}
function geolocationErrorMessage(error: GeolocationPositionError): string {
switch (error.code) {
case error.PERMISSION_DENIED:
return 'Location access denied. Enable it to view nearby pings.'
case error.POSITION_UNAVAILABLE:
return 'Unable to determine your position. Try again.'
case error.TIMEOUT:
return 'Timed out while fetching your location.'
default:
return 'Failed to retrieve your location.'
}
}
export default function App() {
const mapRef = useRef<LeafletMap | null>(null)
const mapContainerRef = useRef<HTMLDivElement | null>(null)
const heatLayerRef = useRef<LeafletHeatLayer | null>(null)
const markersLayerRef = useRef<LayerGroup | null>(null)
const zonesLayerRef = useRef<LayerGroup | null>(null)
const userLayerRef = useRef<LayerGroup | null>(null)
const locationWatchIdRef = useRef<number | null>(null)
const statusRef = useRef<Status>('loading')
const initialLoadRef = useRef(true)
const hasCenteredOnUserRef = useRef(false)
const [pendingSpot, setPendingSpot] = useState<LatLng | null>(null)
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
const [isConfirming, setIsConfirming] = useState(false)
const [status, setStatus] = useState<Status>('loading')
const [rawPoints, setRawPoints] = useState<ApiPoint[]>([])
const [rawDensity, setRawDensity] = useState<ApiDensity[]>([])
const [rawLatestByUser, setRawLatestByUser] = useState<ApiPoint[]>([])
const [clientKey, setClientKey] = useState<string | null>(null)
const [lastUpdated, setLastUpdated] = useState<string | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [userLocation, setUserLocation] = useState<LatLng | null>(null)
const [locationError, setLocationError] = useState<string | null>(null)
const [isRequestingLocation, setIsRequestingLocation] = useState<boolean>(false)
const {
status,
errorMessage,
submitPoint,
fetchSnapshot,
selectVisibleDensity,
selectVisiblePoints,
selectVisibleLatestByUser,
myLatestPoint,
lastUpdated,
} = useHotspotFeed()
const setStatusSafe = useCallback((next: Status) => {
statusRef.current = next
setStatus(next)
}, [])
const { location: userLocation, error: locationError, isRequesting: isRequestingLocation } = useUserLocation()
const startLocationWatch = useCallback(() => {
if (typeof navigator === 'undefined' || !navigator.geolocation) {
setLocationError('Geolocation is not supported in this browser.')
setIsRequestingLocation(false)
return
}
setIsRequestingLocation(true)
setLocationError(null)
if (locationWatchIdRef.current !== null) {
navigator.geolocation.clearWatch(locationWatchIdRef.current)
locationWatchIdRef.current = null
}
navigator.geolocation.getCurrentPosition(
(position) => {
setUserLocation({ lat: position.coords.latitude, lng: position.coords.longitude })
setIsRequestingLocation(false)
},
(error) => {
setLocationError(geolocationErrorMessage(error))
setIsRequestingLocation(false)
},
{ enableHighAccuracy: true, timeout: 10000 },
)
const watchId = navigator.geolocation.watchPosition(
(position) => {
setUserLocation({ lat: position.coords.latitude, lng: position.coords.longitude })
setLocationError(null)
setIsRequestingLocation(false)
},
(error) => {
setLocationError(geolocationErrorMessage(error))
setIsRequestingLocation(false)
},
{ enableHighAccuracy: true, maximumAge: 15000, timeout: 10000 },
)
locationWatchIdRef.current = watchId
}, [])
const fetchSnapshot = useCallback(
async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false
const previousStatus = statusRef.current
const isInitial = initialLoadRef.current
if (previousStatus !== 'posting') {
if (isInitial) {
setStatusSafe('loading')
} else if (!silent) {
setStatusSafe('refreshing')
}
}
try {
const response = await fetch(`${API_BASE}?limit=750`, {
cache: 'no-store',
})
if (!response.ok) {
throw new Error('Unable to reach the hotspot feed.')
}
const data: ApiResponse = await response.json()
setRawPoints(data.points ?? [])
setRawDensity(data.density ?? [])
setRawLatestByUser(data.latestByUser ?? [])
setClientKey(data.clientKey ?? null)
setLastUpdated(data.updatedAt ?? new Date().toISOString())
setErrorMessage(null)
initialLoadRef.current = false
const nextStatus = previousStatus === 'posting' ? 'posting' : 'idle'
setStatusSafe(nextStatus)
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error while loading hotspots.'
setErrorMessage(message)
if (initialLoadRef.current) {
setStatusSafe('error')
} else if (previousStatus !== 'posting') {
setStatusSafe('idle')
}
}
},
[setStatusSafe],
const visibleDensity = useMemo(
() => selectVisibleDensity(userLocation ?? null),
[selectVisibleDensity, userLocation],
)
const submitPoint = useCallback(
async (lat: number, lng: number) => {
setStatusSafe('posting')
try {
const response = await fetch(API_BASE, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ lat, lng }),
})
if (!response.ok) {
const payload = await response.json().catch(() => null)
const message = payload?.message ?? 'Unable to store your signal.'
throw new Error(message)
}
await fetchSnapshot({ silent: true })
setStatusSafe('idle')
} catch (error) {
const message = error instanceof Error ? error.message : 'Something went wrong while saving your signal.'
setErrorMessage(message)
setStatusSafe('error')
throw error
}
},
[fetchSnapshot, setStatusSafe],
const visiblePoints = useMemo(
() => selectVisiblePoints(userLocation ?? null),
[selectVisiblePoints, userLocation],
)
const handleMapClick = useCallback(
({ lat, lng }: LatLng) => {
submitPoint(lat, lng).catch(() => {})
},
[submitPoint],
const visibleLatestByUser = useMemo(
() => selectVisibleLatestByUser(userLocation ?? null),
[selectVisibleLatestByUser, userLocation],
)
const initialiseMap = useCallback(() => {
if (mapRef.current || !mapContainerRef.current) {
return
}
const leaflet = L as LeafletWithHeat
const container = mapContainerRef.current
const map = leaflet
.map(container, {
worldCopyJump: true,
minZoom: 2,
zoomControl: true,
})
.setView([20, 0], 2)
leaflet
.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
crossOrigin: true,
maxZoom: 19,
})
.addTo(map)
const heatLayer =
typeof leaflet.heatLayer === 'function'
? leaflet.heatLayer([], {
radius: 32,
blur: 24,
maxZoom: 12,
gradient: {
0.2: '#38bdf8',
0.4: '#0ea5e9',
0.6: '#fbbf24',
0.8: '#f97316',
1.0: '#ef4444',
},
})
: null
const markersLayer = leaflet.layerGroup().addTo(map)
const zonesLayer = leaflet.layerGroup().addTo(map)
const userLayer = leaflet.layerGroup().addTo(map)
if (heatLayer) {
heatLayer.addTo(map)
}
const onClick = (event: LeafletMouseEvent) => {
const { lat, lng } = event.latlng
if (typeof lat === 'number' && typeof lng === 'number') {
handleMapClick({ lat, lng })
}
}
map.on('click', onClick)
map.whenReady(() => {
requestAnimationFrame(() => {
map.invalidateSize()
})
})
const onResize = () => {
requestAnimationFrame(() => {
map.invalidateSize()
})
}
window.addEventListener('resize', onResize)
mapRef.current = map
heatLayerRef.current = heatLayer
markersLayerRef.current = markersLayer
zonesLayerRef.current = zonesLayer
userLayerRef.current = userLayer
return () => {
map.off('click', onClick)
window.removeEventListener('resize', onResize)
map.remove()
mapRef.current = null
heatLayerRef.current = null
markersLayerRef.current = null
zonesLayerRef.current = null
userLayerRef.current = null
}
}, [handleMapClick])
useEffect(() => {
const cleanup = initialiseMap()
const sizeTimer = window.setTimeout(() => {
if (mapRef.current) {
mapRef.current.invalidateSize()
}
}, 150)
fetchSnapshot().catch(() => {})
const interval = window.setInterval(() => {
fetchSnapshot({ silent: true }).catch(() => {})
}, 7000)
startLocationWatch()
return () => {
if (typeof cleanup === 'function') {
cleanup()
}
window.clearTimeout(sizeTimer)
window.clearInterval(interval)
if (typeof navigator !== 'undefined' && navigator.geolocation && locationWatchIdRef.current !== null) {
navigator.geolocation.clearWatch(locationWatchIdRef.current)
}
}
}, [fetchSnapshot, initialiseMap, startLocationWatch])
const visibleDensity = useMemo(() => {
if (!userLocation) {
return []
}
return rawDensity.filter((entry) => distanceInKm(userLocation, entry) <= VISIBLE_RADIUS_KM)
}, [rawDensity, userLocation])
const visibleDangerZones = useMemo(() => {
if (!visibleDensity.length) {
return []
}
return visibleDensity.slice(0, 3)
}, [visibleDensity])
const visiblePoints = useMemo(() => {
if (!userLocation) {
return []
}
return rawPoints.filter((point) => distanceInKm(userLocation, point) <= VISIBLE_RADIUS_KM)
}, [rawPoints, userLocation])
const visibleLatestByUser = useMemo(() => {
if (!userLocation) {
return []
}
return rawLatestByUser.filter((point) => distanceInKm(userLocation, point) <= VISIBLE_RADIUS_KM)
}, [rawLatestByUser, userLocation])
const localTotals = useMemo(() => {
const uniqueUsers = new Set<string>()
visibleLatestByUser.forEach((point) => uniqueUsers.add(point.userKey))
return {
points: visiblePoints.length,
contributors: uniqueUsers.size,
}
return { points: visiblePoints.length, contributors: uniqueUsers.size }
}, [visibleLatestByUser, visiblePoints])
useEffect(() => {
const heatLayer = heatLayerRef.current
if (!heatLayer) {
return
const myVisibleSignal = useMemo(() => {
if (!myLatestPoint) {
return null
}
if (!visibleDensity.length) {
heatLayer.setLatLngs([])
return
if (userLocation && distanceInKm(userLocation, myLatestPoint) > RADIUS_KM) {
return null
}
return myLatestPoint
}, [myLatestPoint, userLocation])
const maxIntensity = Math.max(...visibleDensity.map((entry) => entry.intensity)) || 1
const heatPoints: HeatPoint[] = visibleDensity.map((entry) => [
entry.lat,
entry.lng,
Math.max(0.25, entry.intensity / maxIntensity),
])
heatLayer.setLatLngs(heatPoints)
}, [visibleDensity])
useEffect(() => {
const layer = zonesLayerRef.current
if (!layer) {
return
}
layer.clearLayers()
if (!visibleDangerZones.length) {
return
}
const maxIntensity = visibleDangerZones[0]?.intensity ?? 0
visibleDangerZones.forEach((zone, index) => {
const intensityRatio = maxIntensity ? zone.intensity / maxIntensity : 0.5
const radius = 400 + intensityRatio * 1600
const circle = L.circle([zone.lat, zone.lng], {
radius,
color: index === 0 ? '#ef4444' : '#f97316',
weight: 2,
fillColor: '#ef4444',
fillOpacity: Math.max(0.12, 0.22 - index * 0.04),
})
circle.addTo(layer).bindTooltip(`Hotspot #${index + 1}\nIntensity: ${zone.intensity}`, {
direction: 'top',
offset: [0, -12],
})
})
}, [visibleDangerZones])
useEffect(() => {
const layer = markersLayerRef.current
if (!layer) {
return
}
layer.clearLayers()
const latestMap = new Map<string, ApiPoint>()
visibleLatestByUser.forEach((point) => {
const existing = latestMap.get(point.userKey)
if (!existing || existing.createdAt < point.createdAt) {
latestMap.set(point.userKey, point)
}
})
latestMap.forEach((point) => {
const isSelf = clientKey && point.userKey === clientKey
const marker = L.circleMarker([point.lat, point.lng], {
radius: isSelf ? 10 : 6,
color: isSelf ? '#38bdf8' : '#94a3b8',
weight: isSelf ? 3 : 1.5,
opacity: 0.9,
fillOpacity: isSelf ? 0.45 : 0.28,
fillColor: isSelf ? '#38bdf8' : '#cbd5f5',
})
marker.addTo(layer).bindTooltip(
`User ${point.userKey}\n${formatCoordinate(point.lat)}, ${formatCoordinate(point.lng)}`,
{
direction: 'top',
offset: [0, -8],
},
)
})
}, [visibleLatestByUser, clientKey])
useEffect(() => {
const layer = userLayerRef.current
if (!layer) {
return
}
layer.clearLayers()
if (!userLocation) {
return
}
L.circle([userLocation.lat, userLocation.lng], {
radius: VISIBLE_RADIUS_KM * 1000,
color: '#38bdf8',
weight: 1.5,
opacity: 0.6,
dashArray: '6 6',
fillColor: '#38bdf8',
fillOpacity: 0.05,
}).addTo(layer)
L.circleMarker([userLocation.lat, userLocation.lng], {
radius: 7,
color: '#38bdf8',
weight: 2,
opacity: 0.9,
fillColor: '#38bdf8',
fillOpacity: 0.5,
}).addTo(layer)
}, [userLocation])
useEffect(() => {
if (!mapRef.current || !userLocation || hasCenteredOnUserRef.current) {
return
}
mapRef.current.setView([userLocation.lat, userLocation.lng], 12, {
animate: true,
})
hasCenteredOnUserRef.current = true
}, [userLocation])
const recentActivity = useMemo(() => visiblePoints.slice(0, 8), [visiblePoints])
const statusLabel = getStatusLabel(status)
const statusBadgeClass = cn(
'inline-flex items-center gap-2 rounded-full border border-border/60 bg-secondary/50 px-3 py-1 text-xs font-medium tracking-wide text-muted-foreground backdrop-blur',
status === 'error' && 'border-destructive/40 bg-destructive/15 text-destructive',
)
const statusAccentClass = status === 'error' ? 'text-destructive' : 'text-primary'
const lastUpdatedLabel = lastUpdated ? formatRelativeTime(lastUpdated) : 'never'
const isLoading = status === 'loading'
const isPosting = status === 'posting'
const heatMax = visibleDangerZones[0]?.intensity ?? 0
const isRefreshing = status === 'refreshing'
const hasLocation = Boolean(userLocation)
const myLatestPoint = useMemo(() => {
if (!clientKey) {
return null
}
const candidate = rawLatestByUser.find((point) => point.userKey === clientKey)
if (!candidate) {
return null
}
if (userLocation && distanceInKm(userLocation, candidate) > VISIBLE_RADIUS_KM) {
return null
}
return candidate
}, [rawLatestByUser, clientKey, userLocation])
const focusDangerZone = useCallback(() => {
if (!mapRef.current || !visibleDangerZones.length) {
return
}
const zone = visibleDangerZones[0]
mapRef.current.setView([zone.lat, zone.lng], 13, {
animate: true,
})
}, [visibleDangerZones])
const focusMySignal = useCallback(() => {
if (!mapRef.current || !myLatestPoint) {
return
}
mapRef.current.setView([myLatestPoint.lat, myLatestPoint.lng], 14, {
animate: true,
})
}, [myLatestPoint])
const refreshNow = useCallback(() => {
fetchSnapshot().catch(() => {})
}, [fetchSnapshot])
const showLocationCta = !hasLocation || Boolean(locationError)
const locationHint = locationError
? locationError
: hasLocation
? `Showing reports within ${VISIBLE_RADIUS_KM}km of you.`
? `Showing reports within ${RADIUS_KM}km of you.`
: isRequestingLocation
? 'Fetching your location...'
: 'Allow location access to view nearby danger pings.'
? 'Fetching your location'
: 'Allow location access to view nearby reports.'
const showLocationCta = !hasLocation || Boolean(locationError)
const { mapContainerRef, focusOn, fitToHeat } = useLeafletHeatmap({
heatCells: visibleDensity,
userLocation: userLocation ?? null,
onRequestSpot: (position) => {
setPendingSpot(position)
setIsConfirmOpen(true)
},
})
const handleConfirmSignal = useCallback(async () => {
if (!pendingSpot) {
return
}
setIsConfirming(true)
const result = await submitPoint(pendingSpot.lat, pendingSpot.lng)
setIsConfirming(false)
if (result.success) {
setIsConfirmOpen(false)
setPendingSpot(null)
}
}, [pendingSpot, submitPoint])
const handleRefresh = useCallback(() => {
fetchSnapshot().catch(() => undefined)
}, [fetchSnapshot])
const handleFocusHeat = useCallback(() => {
fitToHeat()
}, [fitToHeat])
const handleLocateUser = useCallback(() => {
if (userLocation) {
focusOn(userLocation, 14)
}
}, [focusOn, userLocation])
const handleFocusMySignal = useCallback(() => {
if (myVisibleSignal) {
focusOn({ lat: myVisibleSignal.lat, lng: myVisibleSignal.lng }, 15)
}
}, [focusOn, myVisibleSignal])
const handleManualReport = useCallback(() => {
if (!userLocation) {
return
}
setPendingSpot(userLocation)
setIsConfirmOpen(true)
}, [userLocation])
const dangerCells = useMemo(
() =>
[...visibleDensity]
.sort((a, b) => b.intensity - a.intensity)
.slice(0, 5)
.map((cell, index) => ({
id: `${cell.lat}-${cell.lng}-${index}`,
title: `Hotspot #${index + 1}`,
subtitle: hasLocation
? `${distanceInKm(userLocation!, cell).toFixed(2)}km away · ${formatCoordinate(cell.lat)}°, ${formatCoordinate(cell.lng)}°`
: `${formatCoordinate(cell.lat)}°, ${formatCoordinate(cell.lng)}°`,
intensity: cell.intensity,
onFocus: () => focusOn({ lat: cell.lat, lng: cell.lng }, 15),
})),
[visibleDensity, hasLocation, userLocation, focusOn],
)
const recentActivity = useMemo(
() =>
[...visiblePoints]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 8)
.map((point) => ({
id: point.id,
title: `${formatCoordinate(point.lat)}°, ${formatCoordinate(point.lng)}°`,
subtitle: `User ${point.userKey.slice(0, 4).toUpperCase()}`,
timestampLabel: formatRelativeTime(point.createdAt),
distanceLabel: hasLocation
? `${distanceInKm(userLocation!, point).toFixed(2)}km away`
: formatTimestamp(point.createdAt),
onFocus: () => focusOn({ lat: point.lat, lng: point.lng }, 15),
})),
[visiblePoints, hasLocation, userLocation, focusOn],
)
const confirmationLat = pendingSpot ? formatCoordinate(pendingSpot.lat) : '--'
const confirmationLng = pendingSpot ? formatCoordinate(pendingSpot.lng) : '--'
const isDialogDisabled = !pendingSpot || isConfirming
return (
<div className="flex min-h-screen flex-col bg-background text-foreground">
<header className="sticky top-0 z-10 border-b border-border/60 bg-background/70 backdrop-blur">
<div className="mx-auto flex w-full max-w-6xl items-center justify-between gap-4 px-6 py-4">
<div className="flex flex-col gap-1">
<h1 className="text-xl font-semibold sm:text-2xl">SignalMap</h1>
<p className="text-sm text-muted-foreground sm:text-base">
Crowd-powered danger zones layered on Leaflet + OpenStreetMap.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className={statusBadgeClass} aria-live="polite">
<span className={cn('flex items-center gap-2', statusAccentClass)}>
<span className="status-dot relative block h-2.5 w-2.5 rounded-full bg-[currentColor]" aria-hidden />
{statusLabel}
</span>
</span>
<Button variant="ghost" onClick={refreshNow} disabled={isLoading || status === 'refreshing' || isPosting}>
Refresh
</Button>
<Button onClick={focusDangerZone} disabled={!visibleDangerZones.length}>
Focus danger zone
</Button>
<Button variant="ghost" onClick={focusMySignal} disabled={!myLatestPoint}>
Locate me
</Button>
</div>
</div>
</header>
<AppHeader
status={status}
statusLabel={statusLabel}
lastUpdatedLabel={lastUpdatedLabel}
onRefresh={handleRefresh}
onFocusHeat={handleFocusHeat}
onLocateUser={handleLocateUser}
onFocusMySignal={handleFocusMySignal}
disableRefresh={isLoading || isRefreshing || isPosting}
disableHeat={visibleDensity.length === 0}
disableLocate={!hasLocation}
disableMySignal={!myVisibleSignal}
/>
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-6 px-6 py-6 lg:flex-row">
<section className="flex w-full flex-col gap-4 lg:max-w-sm">
<Card>
<CardHeader>
<CardTitle>Danger zone intel</CardTitle>
<CardDescription>Highest intensity cells near you right now.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{!hasLocation && (
<p className="text-sm text-muted-foreground">
We need your location to reveal community alerts around you.
</p>
)}
{hasLocation && visibleDangerZones.length === 0 && (
<p className="text-sm text-muted-foreground">
No hotspots within {VISIBLE_RADIUS_KM}km yet. Tap anywhere on the map to raise the first signal.
</p>
)}
{visibleDangerZones.map((zone, index) => (
<div
className="flex items-center justify-between rounded-lg border border-border/60 bg-muted/20 px-3 py-2"
key={`${zone.lat}-${zone.lng}`}
>
<div className="flex flex-col">
<span className="text-sm font-semibold text-foreground">Hotspot #{index + 1}</span>
<span className="text-xs text-muted-foreground">
{formatCoordinate(zone.lat)}, {formatCoordinate(zone.lng)}
</span>
</div>
<span className="text-sm font-semibold text-orange-400">×{zone.intensity}</span>
</div>
))}
</CardContent>
<CardFooter>
{hasLocation ? (
<span>
Heatmap max intensity: {heatMax || '—'} Last sync {lastUpdatedLabel}
</span>
) : (
<span>Totals unavailable until we can access your position.</span>
)}
</CardFooter>
</Card>
<Card>
<CardHeader>
<CardTitle>Community feed</CardTitle>
<CardDescription>
Local reports: {localTotals.points} · Unique spotters: {localTotals.contributors}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{!hasLocation && (
<p className="text-sm text-muted-foreground">
Allow location access to tune the feed to your area.
</p>
)}
{hasLocation && recentActivity.length === 0 && (
<p className="text-sm text-muted-foreground">
Waiting for the first signals nearby. Click the map to broadcast a hazard ping.
</p>
)}
{recentActivity.map((item) => (
<div
className="flex flex-col gap-1 rounded-lg border border-border/60 bg-card/40 px-3 py-2"
key={item.id}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">
{formatCoordinate(item.lat)}, {formatCoordinate(item.lng)}
</span>
<Badge variant={item.userKey === clientKey ? 'success' : 'muted'}>
{item.userKey === clientKey ? 'You' : `User ${item.userKey}`}
</Badge>
</div>
<span className="text-xs text-muted-foreground">{formatRelativeTime(item.createdAt)}</span>
</div>
))}
</CardContent>
</Card>
</section>
<section className="flex w-full flex-1 flex-col gap-4">
{status === 'error' && errorMessage && (
<div className="flex items-center justify-between rounded-xl border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive shadow-lg shadow-destructive/20">
<span>{errorMessage}</span>
<Button variant="ghost" onClick={refreshNow} className="text-destructive hover:text-destructive">
Try again
</Button>
</div>
)}
<div className="relative flex min-h-[480px] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-card/70 shadow-2xl shadow-black/40">
<div
ref={mapContainerRef}
className="h-full min-h-[480px] w-full"
aria-label="Collaborative danger zone map"
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-6 px-4 py-6 sm:px-6">
<section className="flex flex-col gap-6 lg:flex-row">
<div className="order-2 flex w-full flex-col gap-4 lg:order-1 lg:max-w-sm">
<OverviewPanel
nearbySignals={localTotals.points}
uniqueContributors={localTotals.contributors}
lastUpdatedLabel={lastUpdatedLabel}
mySignalLabel={myVisibleSignal ? formatRelativeTime(myVisibleSignal.createdAt) : null}
errorMessage={errorMessage}
onReport={handleManualReport}
onRetry={handleRefresh}
isPosting={isPosting || isConfirming}
locationHint={locationHint}
showLocationCta={showLocationCta}
disableReport={!hasLocation}
/>
<HotspotStatsPanel
hasLocation={hasLocation}
radiusKm={RADIUS_KM}
locationHint={locationHint}
cells={dangerCells}
/>
<ActivityPanel items={recentActivity} emptyMessage="No recent signals within your area yet." />
</div>
<div className="order-1 flex w-full flex-1 lg:order-2">
<MapViewport
containerRef={mapContainerRef}
isPosting={isPosting || isConfirming}
isLoading={isLoading}
confirmationHint={isConfirmOpen ? 'Confirm the new signal in the dialog to send it.' : null}
/>
<div className="pointer-events-none absolute bottom-4 left-4 right-4 mx-auto flex max-w-md flex-col gap-3 rounded-2xl border border-border/60 bg-background/90 p-4 text-xs text-muted-foreground shadow-xl shadow-black/50 backdrop-blur">
<div className="flex items-center justify-between text-[0.8rem] text-foreground">
<span className="font-semibold">Collaborative heatmap</span>
<Badge variant="destructive">LIVE</Badge>
</div>
<p className="text-[0.8rem] leading-relaxed text-muted-foreground">
Click anywhere to drop a signal. We blend every report into a shared danger zone heatmap focused on your
surroundings.
</p>
<Separator className="bg-border/40" />
<div className="flex flex-col gap-1 text-[0.75rem] text-muted-foreground">
<span>Local signals: {localTotals.points} Last sync {lastUpdatedLabel}</span>
<span>{locationHint}</span>
</div>
{showLocationCta && (
<div className="pointer-events-auto flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => startLocationWatch()}
disabled={isRequestingLocation}
>
{isRequestingLocation ? 'Requesting location…' : 'Enable location'}
</Button>
</div>
)}
</div>
</div>
</section>
</main>
<AlertDialog
open={isConfirmOpen}
onOpenChange={(nextOpen) => {
setIsConfirmOpen(nextOpen)
if (!nextOpen) {
setPendingSpot(null)
setIsConfirming(false)
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm new signal</AlertDialogTitle>
<AlertDialogDescription>
You&apos;re about to publish a community alert at these coordinates. Double-check the spot before confirming.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="rounded-2xl border border-border/60 bg-muted/40 p-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Latitude</span>
<span className="font-medium text-foreground">{confirmationLat}°</span>
</div>
<div className="mt-3 flex items-center justify-between">
<span className="text-muted-foreground">Longitude</span>
<span className="font-medium text-foreground">{confirmationLng}°</span>
</div>
</div>
<p className="text-xs text-muted-foreground">
Signals are visible to travellers within {RADIUS_KM}km and help the community stay aware of hotspots.
</p>
<AlertDialogFooter>
<AlertDialogCancel disabled={isConfirming}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={isDialogDisabled}
onClick={(event) => {
event.preventDefault()
handleConfirmSignal().catch(() => undefined)
}}
>
{isConfirming ? 'Sending…' : 'Confirm signal'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}