feat(frontend): replace dashboard with realtime device map
All checks were successful
Deploy to Production / deploy (push) Successful in 1m54s
All checks were successful
Deploy to Production / deploy (push) Successful in 1m54s
This commit is contained in:
22
bun.lock
22
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
76
frontend/src/lib/coord-transform.ts
Normal file
76
frontend/src/lib/coord-transform.ts
Normal 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]
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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: "© 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: "© 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: "© 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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user