feat(frontend): replace dashboard with realtime device map
All checks were successful
Deploy to Production / deploy (push) Successful in 1m54s

This commit is contained in:
魏风
2026-03-24 00:18:43 +08:00
parent ff971e2cea
commit 20355b0804
5 changed files with 818 additions and 18 deletions

View File

@@ -29,10 +29,13 @@
"@tanstack/react-router": "^1.163.3",
"@tanstack/react-router-devtools": "^1.163.3",
"@tanstack/react-table": "^8.21.3",
"@types/leaflet.markercluster": "^1.5.6",
"axios": "1.13.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"form-data": "4.0.5",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"lucide-react": "^0.563.0",
"next-themes": "^0.4.6",
"react": "^19.1.1",
@@ -40,6 +43,8 @@
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.68.0",
"react-icons": "^5.5.0",
"react-leaflet": "^5.0.0",
"react-leaflet-cluster": "^4.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.2.0",
@@ -51,6 +56,7 @@
"@playwright/test": "1.58.2",
"@tanstack/router-devtools": "^1.159.10",
"@tanstack/router-plugin": "^1.140.0",
"@types/leaflet": "^1.9.21",
"@types/node": "^25.3.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
@@ -281,6 +287,8 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@react-leaflet/core": ["@react-leaflet/core@3.0.0", "", { "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.2", "", { "os": "android", "cpu": "arm" }, "sha512-21J6xzayjy3O6NdnlO6aXi/urvSRjm6nCI6+nF6ra2YofKruGixN9kfT+dt55HVNwfDmpDHJcaS3JuP/boNnlA=="],
@@ -429,8 +437,14 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/leaflet": ["@types/leaflet@1.9.21", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w=="],
"@types/leaflet.markercluster": ["@types/leaflet.markercluster@1.5.6", "", { "dependencies": { "@types/leaflet": "^1.9" } }, "sha512-I7hZjO2+isVXGYWzKxBp8PsCzAYCJBc29qBdFpquOCkS7zFDqUsUvkEOyQHedsk/Cy5tocQzf+Ndorm5W9YKTQ=="],
"@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="],
"@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="],
@@ -613,6 +627,10 @@
"json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="],
"leaflet.markercluster": ["leaflet.markercluster@1.5.3", "", { "peerDependencies": { "leaflet": "^1.3.1" } }, "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA=="],
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
@@ -713,6 +731,10 @@
"react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="],
"react-leaflet": ["react-leaflet@5.0.0", "", { "dependencies": { "@react-leaflet/core": "^3.0.0" }, "peerDependencies": { "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw=="],
"react-leaflet-cluster": ["react-leaflet-cluster@4.0.0", "", { "dependencies": { "leaflet.markercluster": "^1.5.3" }, "peerDependencies": { "@react-leaflet/core": "^3.0.0", "leaflet": "^1.9.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-leaflet": "^5.0.0" } }, "sha512-Lu75+KOu2ruGyAx8LoCQvlHuw+3CLLJQGEoSk01ymsDN/YnCiRV6ChkpsvaruVyYBPzUHwiskFw4Jo7WHj5qNw=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],

View File

@@ -33,10 +33,13 @@
"@tanstack/react-router": "^1.163.3",
"@tanstack/react-router-devtools": "^1.163.3",
"@tanstack/react-table": "^8.21.3",
"@types/leaflet.markercluster": "^1.5.6",
"axios": "1.13.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"form-data": "4.0.5",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"lucide-react": "^0.563.0",
"next-themes": "^0.4.6",
"react": "^19.1.1",
@@ -44,6 +47,8 @@
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.68.0",
"react-icons": "^5.5.0",
"react-leaflet": "^5.0.0",
"react-leaflet-cluster": "^4.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.2.0",
@@ -55,6 +60,7 @@
"@playwright/test": "1.58.2",
"@tanstack/router-devtools": "^1.159.10",
"@tanstack/router-plugin": "^1.140.0",
"@types/leaflet": "^1.9.21",
"@types/node": "^25.3.2",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",

View File

@@ -0,0 +1,76 @@
/**
* WGS-84 ↔ GCJ-02 coordinate transformation.
*
* GCJ-02 (国测局坐标系 / "Mars coordinates") is the mandatory coordinate system
* used by Chinese mapping services including Gaode (AutoNavi / 高德地图).
*
* GPS devices output WGS-84 coordinates. When overlaying markers on a GCJ-02
* base map the coordinates must be transformed, otherwise markers will appear
* shifted by tens to hundreds of metres in mainland China.
*/
const PI = Math.PI
const SEMI_MAJOR = 6378245.0 // Semi-major axis of Krasovsky ellipsoid
const EE = 0.006693421622965943 // Eccentricity squared
function outOfChina(lat: number, lon: number): boolean {
return lon < 72.004 || lon > 137.8347 || lat < 0.8293 || lat > 55.8271
}
function transformLat(x: number, y: number): number {
let ret =
-100.0 +
2.0 * x +
3.0 * y +
0.2 * y * y +
0.1 * x * y +
0.2 * Math.sqrt(Math.abs(x))
ret +=
((20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0) /
3.0
ret +=
((20.0 * Math.sin(y * PI) + 40.0 * Math.sin((y / 3.0) * PI)) * 2.0) / 3.0
ret +=
((160.0 * Math.sin((y / 12.0) * PI) + 320 * Math.sin((y * PI) / 30.0)) *
2.0) /
3.0
return ret
}
function transformLon(x: number, y: number): number {
let ret =
300.0 +
x +
2.0 * y +
0.1 * x * x +
0.1 * x * y +
0.1 * Math.sqrt(Math.abs(x))
ret +=
((20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0) /
3.0
ret +=
((20.0 * Math.sin(x * PI) + 40.0 * Math.sin((x / 3.0) * PI)) * 2.0) / 3.0
ret +=
((150.0 * Math.sin((x / 12.0) * PI) + 300.0 * Math.sin((x / 30.0) * PI)) *
2.0) /
3.0
return ret
}
/** Convert WGS-84 → GCJ-02 */
export function wgs84ToGcj02(wgsLat: number, wgsLon: number): [number, number] {
if (outOfChina(wgsLat, wgsLon)) {
return [wgsLat, wgsLon]
}
let dLat = transformLat(wgsLon - 105.0, wgsLat - 35.0)
let dLon = transformLon(wgsLon - 105.0, wgsLat - 35.0)
const radLat = (wgsLat / 180.0) * PI
let magic = Math.sin(radLat)
magic = 1 - EE * magic * magic
const sqrtMagic = Math.sqrt(magic)
dLat = (dLat * 180.0) / (((SEMI_MAJOR * (1 - EE)) / (magic * sqrtMagic)) * PI)
dLon = (dLon * 180.0) / ((SEMI_MAJOR / sqrtMagic) * Math.cos(radLat) * PI)
return [wgsLat + dLat, wgsLon + dLon]
}

View File

@@ -29,23 +29,25 @@ function Layout() {
const pathname = useRouterState({
select: (state) => state.location.pathname,
})
const isGnssMonitorPage = pathname === "/gnss-monitor"
const isFullScreenPage = pathname === "/gnss-monitor" || pathname === "/"
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b px-4">
<SidebarInset className="flex flex-col">
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b px-4 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
<SidebarTrigger className="-ml-1 text-muted-foreground" />
</header>
<main className="flex-1 p-6 md:p-8">
<main
className={`flex-1 flex flex-col ${isFullScreenPage ? "p-0" : "p-6 md:p-8"}`}
>
<div
className={`mx-auto ${isGnssMonitorPage ? "max-w-none" : "max-w-7xl"}`}
className={`flex-1 flex flex-col mx-auto w-full ${isFullScreenPage ? "max-w-none" : "max-w-7xl"}`}
>
<Outlet />
</div>
</main>
<Footer />
{!isFullScreenPage && <Footer />}
</SidebarInset>
</SidebarProvider>
)

View File

@@ -1,31 +1,725 @@
import { createFileRoute } from "@tanstack/react-router"
import L from "leaflet"
import {
Clock,
Layers,
Maximize,
Mountain,
Radio,
Satellite,
Signal,
} from "lucide-react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { MapContainer, Marker, Popup, TileLayer, useMap } from "react-leaflet"
import "leaflet/dist/leaflet.css"
import useAuth from "@/hooks/useAuth"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { wgs84ToGcj02 } from "@/lib/coord-transform"
export const Route = createFileRoute("/_layout/")({
component: Dashboard,
head: () => ({
meta: [
{
title: "Dashboard - FastAPI Template",
title: "Device Map - SpatialHub",
},
],
}),
})
function Dashboard() {
const { user: currentUser } = useAuth()
/* ---------- types ---------- */
type SignalHealth = {
level: string
score: number
strong_signal_count: number
average_snr_dbhz: number | null
best_snr_dbhz: number | null
message: string
}
type TelemetryDevice = {
device_id: string
last_event_time: string | null
updated_at: string
fix_status: string | null
lat: number | null
lon: number | null
alt_m: number | null
satellites_used: number | null
signal_health: SignalHealth | null
}
type TelemetrySnapshot = {
generated_at: string
devices: TelemetryDevice[]
}
type ConnectionState = "connecting" | "live" | "retrying"
type BaseMapType = "osm" | "amap" | "amap-satellite"
/* ---------- constants ---------- */
const BASE_MAPS: Record<
BaseMapType,
{
label: string
url: string
attribution: string
subdomains: string
gcj02: boolean
}
> = {
osm: {
label: "OpenStreetMap",
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution: "&copy; OpenStreetMap contributors",
subdomains: "abc",
gcj02: false,
},
amap: {
label: "高德矢量",
url: "https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}",
attribution: "&copy; AutoNavi",
subdomains: "1234",
gcj02: true,
},
"amap-satellite": {
label: "高德卫星",
url: "https://webst0{s}.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}",
attribution: "&copy; AutoNavi",
subdomains: "1234",
gcj02: true,
},
}
const DEFAULT_CENTER: [number, number] = [31.2304, 121.4737]
const DEFAULT_ZOOM = 5
const FIX_STATUS_LABEL: Record<string, string> = {
"0": "No Fix",
"1": "Single",
"2": "DGPS",
"4": "RTK Fixed",
"5": "RTK Float",
A: "Valid",
V: "Invalid",
}
/* ---------- custom marker icons ---------- */
/**
* Build an SVG DivIcon for a device marker.
* - Online devices: pin shape with colored gradient + subtle pulse ring
* - Offline devices: muted grey, no pulse
* - Selected device: larger + bright border ring
*/
function buildDeviceIcon(
fillColor: string,
isSelected: boolean,
isOffline: boolean,
): L.DivIcon {
const size = isSelected ? 36 : 28
const half = size / 2
const dotR = isSelected ? 7 : 5.5
const ringR = isSelected ? 12 : 9
// Pulse ring only for online + selected-or-not
const pulseRing = isOffline
? ""
: `<circle cx="${half}" cy="${half}" r="${ringR}"
fill="none" stroke="${fillColor}" stroke-width="2" opacity="0.5">
<animate attributeName="r" from="${ringR}" to="${half - 1}" dur="1.8s" repeatCount="indefinite"/>
<animate attributeName="opacity" from="0.5" to="0" dur="1.8s" repeatCount="indefinite"/>
</circle>`
const borderStroke = isSelected
? `stroke="${fillColor}" stroke-width="2.5"`
: `stroke="rgba(255,255,255,0.85)" stroke-width="1.5"`
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
${pulseRing}
<circle cx="${half}" cy="${half}" r="${dotR}" fill="${fillColor}" ${borderStroke}
style="filter: drop-shadow(0 1px 3px rgba(0,0,0,0.35));" />
</svg>`
return L.divIcon({
html: svg,
className: "", // remove default leaflet-div-icon styling
iconSize: [size, size],
iconAnchor: [half, half],
popupAnchor: [0, -half],
})
}
/* ---------- helpers ---------- */
function buildTelemetryWsUrl(token: string): string {
const apiBase = (
import.meta.env.VITE_API_URL || window.location.origin
).replace(/\/$/, "")
const wsBase = apiBase.startsWith("https://")
? `wss://${apiBase.slice("https://".length)}`
: apiBase.startsWith("http://")
? `ws://${apiBase.slice("http://".length)}`
: apiBase
return `${wsBase}/api/v1/telemetry/ws?token=${encodeURIComponent(token)}`
}
function formatCoord(value: number | null): string {
if (value === null) return "-"
return value.toFixed(6)
}
function getFixStatusLabel(value: string | null): string {
if (!value) return "Unknown"
return FIX_STATUS_LABEL[value] ?? `Fix ${value}`
}
function getFixStatusColor(value: string | null): string {
if (value === "4") return "text-emerald-500"
if (value === "5") return "text-orange-400"
if (value === "2" || value === "A") return "text-sky-500"
if (value === "1") return "text-amber-500"
if (value === "0" || value === "V") return "text-red-500"
return "text-muted-foreground"
}
function deviceMarkerColor(
fixStatus: string | null,
isOffline: boolean,
): string {
if (isOffline) return "#94a3b8"
if (fixStatus === "4") return "#10b981"
if (fixStatus === "5") return "#fb923c"
if (fixStatus === "2" || fixStatus === "A") return "#0ea5e9"
if (fixStatus === "1") return "#f59e0b"
if (fixStatus === "0" || fixStatus === "V") return "#ef4444"
return "#64748b"
}
const OFFLINE_THRESHOLD_MS = 5 * 60 * 1000
function checkIsOffline(lastEventTime: string | null): boolean {
if (!lastEventTime) return true
return Date.now() - new Date(lastEventTime).getTime() > OFFLINE_THRESHOLD_MS
}
function timeAgo(isoString: string | null): string {
if (!isoString) return "Unknown"
const diffMs = Date.now() - new Date(isoString).getTime()
if (diffMs < 0) return "just now"
const seconds = Math.floor(diffMs / 1000)
if (seconds < 60) return `${seconds}s ago`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ${minutes % 60}m ago`
return new Date(isoString).toLocaleString()
}
function signalHealthBadge(health: SignalHealth | null) {
if (!health) return null
const colors: Record<string, string> = {
good: "text-emerald-500 bg-emerald-500/10 border-emerald-500/20",
fair: "text-amber-500 bg-amber-500/10 border-amber-500/20",
poor: "text-red-500 bg-red-500/10 border-red-500/20",
}
return (
<div>
<div>
<h1 className="text-2xl truncate max-w-sm">
Hi, {currentUser?.full_name || currentUser?.email} 👋
</h1>
<p className="text-muted-foreground">
Welcome back, nice to see you again!!!
</p>
<Badge variant="outline" className={colors[health.level] ?? ""}>
<Signal className="size-3" />
{health.level === "good"
? "Good"
: health.level === "fair"
? "Fair"
: "Poor"}{" "}
({health.score}%)
</Badge>
)
}
function projectCoord(
lat: number,
lon: number,
useGcj02: boolean,
): [number, number] {
if (!useGcj02) return [lat, lon]
return wgs84ToGcj02(lat, lon)
}
/* ---------- map sub-components ---------- */
function FitBoundsToDevices({
coordinates,
}: {
coordinates: Array<[number, number]>
}) {
const map = useMap()
const hasFitted = useRef(false)
useEffect(() => {
if (coordinates.length === 0 || hasFitted.current) return
hasFitted.current = true
if (coordinates.length === 1) {
map.setView(coordinates[0], 14, { animate: true })
return
}
map.fitBounds(coordinates, { padding: [40, 40] })
}, [coordinates, map])
return null
}
/** Badges-only overlay — lives inside MapContainer, no dropdown needed */
function MapStatusBadges({
isLive,
status,
deviceCount,
disconnectedDuration,
}: {
isLive: boolean
status: ConnectionState
deviceCount: number
disconnectedDuration: number
}) {
return (
<div
className="leaflet-bottom leaflet-left ml-3 mb-5"
style={{ pointerEvents: "auto", zIndex: 800 }}
>
<div className="leaflet-control flex items-center gap-2">
<Badge
variant="outline"
className={`backdrop-blur-md bg-background/80 shadow-sm ${
isLive
? "text-emerald-500 border-emerald-500/30"
: "text-amber-500 border-amber-500/30"
}`}
>
{isLive && (
<span className="relative mr-1.5 flex size-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-emerald-500" />
</span>
)}
{!isLive && <Radio className="size-3" />}
{isLive
? "Live"
: status === "retrying"
? `Reconnecting${disconnectedDuration > 0 ? ` (${disconnectedDuration}s)` : ""}`
: "Connecting"}
</Badge>
<Badge
variant="outline"
className="backdrop-blur-md bg-background/80 shadow-sm"
>
Devices: {deviceCount}
</Badge>
</div>
</div>
)
}
/** FitBounds helper — must live inside MapContainer to call useMap() */
function FitBoundsControl({
coordinates,
}: {
coordinates: Array<[number, number]>
}) {
const map = useMap()
const fitAll = () => {
if (!coordinates.length) return
if (coordinates.length === 1) {
map.setView(coordinates[0], 14, { animate: true })
} else {
map.fitBounds(coordinates, { padding: [40, 40] })
}
}
// expose via a ref so the outside button can call it
const ref = window as unknown as Record<string, () => void>
ref.__mapFitAll = fitAll
return null
}
/* ---------- WebSocket hook ---------- */
function useTelemetryWS() {
const [snapshot, setSnapshot] = useState<TelemetrySnapshot | null>(null)
const [status, setStatus] = useState<ConnectionState>("connecting")
const [disconnectedAt, setDisconnectedAt] = useState<number | null>(null)
const retryCount = useRef(0)
const lastMessageAt = useRef<number>(0)
useEffect(() => {
const token = localStorage.getItem("access_token")
if (!token) {
setStatus("retrying")
return
}
let reconnectTimer: number | undefined
let staleTimer: number | undefined
let socket: WebSocket | undefined
let shouldReconnect = true
const STALE_TIMEOUT_MS = 10_000
const resetStaleTimer = () => {
if (staleTimer) window.clearTimeout(staleTimer)
staleTimer = window.setTimeout(() => {
socket?.close()
}, STALE_TIMEOUT_MS)
}
const connect = () => {
setStatus((prev) => (prev === "live" ? prev : "connecting"))
socket = new WebSocket(buildTelemetryWsUrl(token))
socket.onopen = () => {
setStatus("live")
setDisconnectedAt(null)
retryCount.current = 0
lastMessageAt.current = Date.now()
resetStaleTimer()
}
socket.onmessage = (event) => {
try {
const payload = JSON.parse(event.data) as TelemetrySnapshot
setSnapshot(payload)
setStatus("live")
setDisconnectedAt(null)
lastMessageAt.current = Date.now()
resetStaleTimer()
} catch {
// ignore malformed messages
}
}
socket.onclose = () => {
if (staleTimer) window.clearTimeout(staleTimer)
if (!shouldReconnect) return
setStatus("retrying")
setDisconnectedAt((prev) => prev ?? Date.now())
const backoffMs = Math.min(2000 * 2 ** retryCount.current, 30000)
retryCount.current++
reconnectTimer = window.setTimeout(connect, backoffMs)
}
socket.onerror = () => {
socket?.close()
}
}
connect()
return () => {
shouldReconnect = false
if (reconnectTimer) window.clearTimeout(reconnectTimer)
if (staleTimer) window.clearTimeout(staleTimer)
if (socket) socket.close()
}
}, [])
return { snapshot, status, disconnectedAt }
}
/* ---------- Dashboard ---------- */
function Dashboard() {
const { snapshot, status, disconnectedAt } = useTelemetryWS()
const [baseMap, setBaseMap] = useState<BaseMapType>("amap")
const [activeDeviceId, setActiveDeviceId] = useState<string>("")
const activeDeviceIdRef = useRef(activeDeviceId)
activeDeviceIdRef.current = activeDeviceId
const isGcj02 = BASE_MAPS[baseMap].gcj02
const devicesWithCoords = useMemo(
() =>
(snapshot?.devices ?? []).filter(
(device) =>
device.lat !== null &&
device.lon !== null &&
device.lat >= -90 &&
device.lat <= 90 &&
device.lon >= -180 &&
device.lon <= 180,
),
[snapshot],
)
useEffect(() => {
if (!devicesWithCoords.length) {
if (activeDeviceIdRef.current) setActiveDeviceId("")
return
}
const exists = devicesWithCoords.some(
(d) => d.device_id === activeDeviceIdRef.current,
)
if (!exists && !activeDeviceIdRef.current) {
setActiveDeviceId(devicesWithCoords[0].device_id)
}
}, [devicesWithCoords])
const activeDevice = useMemo(
() =>
devicesWithCoords.find((d) => d.device_id === activeDeviceId) ??
devicesWithCoords[0] ??
null,
[devicesWithCoords, activeDeviceId],
)
const mapPoints = useMemo(
() =>
devicesWithCoords.map((d) =>
projectCoord(d.lat as number, d.lon as number, isGcj02),
),
[devicesWithCoords, isGcj02],
)
const handleDeviceClick = useCallback((deviceId: string) => {
setActiveDeviceId(deviceId)
}, [])
const isLive = status === "live"
// Disconnected time ticker
const [, forceUpdate] = useState(0)
useEffect(() => {
if (!disconnectedAt) return
const interval = window.setInterval(() => forceUpdate((n) => n + 1), 1000)
return () => window.clearInterval(interval)
}, [disconnectedAt])
const disconnectedDuration = disconnectedAt
? Math.floor((Date.now() - disconnectedAt) / 1000)
: 0
return (
<div className="flex flex-col h-full bg-background overflow-hidden">
{/* Map area — fills available space */}
<div className="relative flex-1 min-h-0">
{/* Controls live OUTSIDE MapContainer to avoid Leaflet z-index stacking issues */}
<div className="absolute top-3 right-3 z-9999 flex flex-col gap-2 pointer-events-auto">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="secondary"
size="icon"
className="h-8 w-8 shadow-md backdrop-blur-md bg-background/80"
>
<Layers className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-10001">
{(
Object.entries(BASE_MAPS) as Array<
[BaseMapType, (typeof BASE_MAPS)[BaseMapType]]
>
).map(([key, provider]) => (
<DropdownMenuItem key={key} onClick={() => setBaseMap(key)}>
{provider.label} {baseMap === key && "✓"}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="secondary"
size="icon"
className="h-8 w-8 shadow-md backdrop-blur-md bg-background/80"
title="Fit all devices"
onClick={() =>
(window as unknown as Record<string, () => void>).__mapFitAll?.()
}
>
<Maximize className="size-4" />
</Button>
</div>
<div className="h-full w-full">
<MapContainer
center={DEFAULT_CENTER}
className="h-full w-full"
scrollWheelZoom
zoom={DEFAULT_ZOOM}
>
<TileLayer
key={baseMap}
attribution={BASE_MAPS[baseMap].attribution}
subdomains={BASE_MAPS[baseMap].subdomains}
url={BASE_MAPS[baseMap].url}
/>
<FitBoundsToDevices coordinates={mapPoints} />
<FitBoundsControl coordinates={mapPoints} />
<MapStatusBadges
isLive={isLive}
status={status}
deviceCount={devicesWithCoords.length}
disconnectedDuration={disconnectedDuration}
/>
{devicesWithCoords.map((device) => {
const isOffline = checkIsOffline(device.last_event_time)
const isSelected = activeDeviceId === device.device_id
const [projLat, projLon] = projectCoord(
device.lat as number,
device.lon as number,
isGcj02,
)
const color = deviceMarkerColor(device.fix_status, isOffline)
const icon = buildDeviceIcon(color, isSelected, isOffline)
return (
<Marker
position={[projLat, projLon]}
icon={icon}
eventHandlers={{
click: () => handleDeviceClick(device.device_id),
}}
key={device.device_id}
>
<Popup>
<div className="space-y-1 text-sm font-sans min-w-[200px]">
<div className="font-semibold text-base mb-2 border-b pb-1">
{device.device_id}
{isOffline && (
<span className="ml-2 text-xs text-rose-500 font-medium tracking-wide">
(Offline)
</span>
)}
</div>
<div className="grid grid-cols-[80px_1fr] gap-x-2 gap-y-0.5">
<span className="text-muted-foreground">Lat:</span>
<span>{formatCoord(device.lat)}</span>
<span className="text-muted-foreground">Lon:</span>
<span>{formatCoord(device.lon)}</span>
<span className="text-muted-foreground">Alt:</span>
<span>
{device.alt_m !== null
? `${device.alt_m.toFixed(1)} m`
: "-"}
</span>
<span className="text-muted-foreground">Fix:</span>
<span>{getFixStatusLabel(device.fix_status)}</span>
<span className="text-muted-foreground">Sats:</span>
<span>{device.satellites_used ?? "-"}</span>
</div>
<div className="text-xs text-muted-foreground pt-2 mt-2 border-t">
Last seen:{" "}
{device.last_event_time
? new Date(device.last_event_time).toLocaleString()
: "Unknown"}
</div>
</div>
</Popup>
</Marker>
)
})}
</MapContainer>
</div>
</div>
{/* Selected Device Panel — placed at bottom, compact */}
<Card className="rounded-none border-x-0 border-b-0 shadow-none bg-background/50 backdrop-blur-sm shrink-0">
<CardHeader>
<CardTitle className="flex items-center gap-2">
Selected Device
{activeDevice?.signal_health &&
signalHealthBadge(activeDevice.signal_health)}
</CardTitle>
<CardDescription>
Click a marker on the map to view detailed device info
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 text-sm sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-8">
<div>
<p className="text-muted-foreground mb-1">Device ID</p>
<p className="font-medium break-all">
{activeDevice?.device_id ?? "-"}
</p>
</div>
<div>
<p className="text-muted-foreground mb-1">Latitude</p>
<p className="font-medium font-mono">
{formatCoord(activeDevice?.lat ?? null)}
</p>
</div>
<div>
<p className="text-muted-foreground mb-1">Longitude</p>
<p className="font-medium font-mono">
{formatCoord(activeDevice?.lon ?? null)}
</p>
</div>
<div>
<p className="text-muted-foreground mb-1 flex items-center gap-1">
<Mountain className="size-3" />
Altitude
</p>
<p className="font-medium font-mono">
{activeDevice?.alt_m !== null && activeDevice?.alt_m !== undefined
? `${activeDevice.alt_m.toFixed(1)} m`
: "-"}
</p>
</div>
<div>
<p className="text-muted-foreground mb-1">Fix Status</p>
<p
className={`font-medium ${getFixStatusColor(activeDevice?.fix_status ?? null)}`}
>
{getFixStatusLabel(activeDevice?.fix_status ?? null)}
</p>
</div>
<div>
<p className="text-muted-foreground mb-1 flex items-center gap-1">
<Satellite className="size-3" />
Satellites
</p>
<p className="font-medium font-mono">
{activeDevice?.satellites_used ?? "-"}
</p>
</div>
<div>
<p className="text-muted-foreground mb-1 flex items-center gap-1">
<Signal className="size-3" />
Signal Score
</p>
<p className="font-medium font-mono">
{activeDevice?.signal_health
? `${activeDevice.signal_health.score}%`
: "-"}
</p>
</div>
<div>
<p className="text-muted-foreground mb-1 flex items-center gap-1">
<Clock className="size-3" />
Last Seen
</p>
<p className="font-medium">
{timeAgo(activeDevice?.last_event_time ?? null)}
</p>
</div>
</CardContent>
</Card>
</div>
)
}