Add Symfony payload mapping, fixtures, and QA tooling
This commit is contained in:
+737
-31
@@ -1,35 +1,741 @@
|
||||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from '/vite.svg'
|
||||
import './App.css'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Badge } from '@ui/badge'
|
||||
import { Button } from '@ui/button'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@ui/card'
|
||||
import { Separator } from '@ui/separator'
|
||||
import { cn, distanceInKm, formatCoordinate, formatRelativeTime } from '@lib/utils'
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api/signals'
|
||||
const VISIBLE_RADIUS_KM = 5
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<a href="https://vite.dev" target="_blank">
|
||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
||||
</a>
|
||||
<a href="https://react.dev" target="_blank">
|
||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
||||
</a>
|
||||
</div>
|
||||
<h1>Vite + React</h1>
|
||||
<div className="card">
|
||||
<button onClick={() => setCount((count) => count + 1)}>
|
||||
count is {count}
|
||||
</button>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test HMR
|
||||
</p>
|
||||
</div>
|
||||
<p className="read-the-docs">
|
||||
Click on the Vite and React logos to learn more
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
type Status = 'loading' | 'idle' | 'error' | 'posting' | 'refreshing'
|
||||
|
||||
type ApiPoint = {
|
||||
id: number
|
||||
lat: number
|
||||
lng: number
|
||||
createdAt: string
|
||||
userKey: string
|
||||
}
|
||||
|
||||
export default App
|
||||
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
|
||||
}
|
||||
|
||||
interface LatLng {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
|
||||
function getStatusLabel(status: Status): string {
|
||||
switch (status) {
|
||||
case 'loading':
|
||||
return 'Syncing map'
|
||||
case 'posting':
|
||||
return 'Sending your signal'
|
||||
case 'refreshing':
|
||||
return 'Updating hotspots'
|
||||
case 'error':
|
||||
return 'Offline'
|
||||
default:
|
||||
return 'Live feed'
|
||||
}
|
||||
}
|
||||
|
||||
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<any>(null)
|
||||
const mapContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const heatLayerRef = useRef<any>(null)
|
||||
const markersLayerRef = useRef<any>(null)
|
||||
const zonesLayerRef = useRef<any>(null)
|
||||
const userLayerRef = useRef<any>(null)
|
||||
const locationWatchIdRef = useRef<number | null>(null)
|
||||
const statusRef = useRef<Status>('loading')
|
||||
const initialLoadRef = useRef(true)
|
||||
const hasCenteredOnUserRef = useRef(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 setStatusSafe = useCallback((next: Status) => {
|
||||
statusRef.current = next
|
||||
setStatus(next)
|
||||
}, [])
|
||||
|
||||
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 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 handleMapClick = useCallback(
|
||||
({ lat, lng }: LatLng) => {
|
||||
submitPoint(lat, lng).catch(() => {})
|
||||
},
|
||||
[submitPoint],
|
||||
)
|
||||
|
||||
const initialiseMap = useCallback(() => {
|
||||
if (mapRef.current || !mapContainerRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const leaflet = (window as any).L
|
||||
if (!leaflet) {
|
||||
setErrorMessage('Leaflet failed to load. Refresh the page to try again.')
|
||||
setStatusSafe('error')
|
||||
return
|
||||
}
|
||||
|
||||
const map = leaflet
|
||||
.map(mapContainerRef.current, {
|
||||
worldCopyJump: true,
|
||||
minZoom: 2,
|
||||
zoomControl: true,
|
||||
})
|
||||
.setView([20, 0], 2)
|
||||
|
||||
leaflet
|
||||
.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© 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: any) => {
|
||||
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, setStatusSafe])
|
||||
|
||||
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,
|
||||
}
|
||||
}, [visibleLatestByUser, visiblePoints])
|
||||
|
||||
useEffect(() => {
|
||||
const heatLayer = heatLayerRef.current
|
||||
const leaflet = (window as any).L
|
||||
if (!heatLayer || !leaflet) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!visibleDensity.length) {
|
||||
heatLayer.setLatLngs([])
|
||||
return
|
||||
}
|
||||
|
||||
const maxIntensity = Math.max(...visibleDensity.map((entry) => entry.intensity)) || 1
|
||||
const heatPoints = visibleDensity.map((entry) => [entry.lat, entry.lng, Math.max(0.25, entry.intensity / maxIntensity)])
|
||||
heatLayer.setLatLngs(heatPoints)
|
||||
}, [visibleDensity])
|
||||
|
||||
useEffect(() => {
|
||||
const layer = zonesLayerRef.current
|
||||
const leaflet = (window as any).L
|
||||
if (!layer || !leaflet) {
|
||||
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 = leaflet.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
|
||||
const leaflet = (window as any).L
|
||||
if (!layer || !leaflet) {
|
||||
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 = leaflet.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
|
||||
const leaflet = (window as any).L
|
||||
if (!layer || !leaflet) {
|
||||
return
|
||||
}
|
||||
|
||||
layer.clearLayers()
|
||||
|
||||
if (!userLocation) {
|
||||
return
|
||||
}
|
||||
|
||||
leaflet
|
||||
.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)
|
||||
|
||||
leaflet
|
||||
.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 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 locationHint = locationError
|
||||
? locationError
|
||||
: hasLocation
|
||||
? `Showing reports within ${VISIBLE_RADIUS_KM}km of you.`
|
||||
: isRequestingLocation
|
||||
? 'Fetching your location...'
|
||||
: 'Allow location access to view nearby danger pings.'
|
||||
|
||||
const showLocationCta = !hasLocation || Boolean(locationError)
|
||||
|
||||
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>
|
||||
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user