diff --git a/frontend/src/App.css b/frontend/src/App.css index 697fb92..30b485a 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -218,6 +218,14 @@ body, #root { color: var(--muted); } +.empty code { + color: #e2e8f0; + background: rgba(255, 255, 255, 0.06); + padding: 2px 6px; + border-radius: 8px; + word-break: break-word; +} + .leaflet-control-container { filter: none; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0da23fe..4a3dae2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,321 +1,149 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { MapContainer, Marker, Polyline, TileLayer, useMap } from "react-leaflet"; +import L, { LatLngExpression } from "leaflet"; import "./App.css"; import "leaflet/dist/leaflet.css"; import "maplibre-gl/dist/maplibre-gl.css"; -import { MapPanel } from "./components/MapPanel"; -import { SettingsPanel } from "./components/SettingsPanel"; -import { TrackSummary } from "./components/TrackSummary"; -import { useVectorMap } from "./hooks/useVectorMap"; -import { getInitialTheme } from "./utils/theme"; -import type { AppConfig, IncomingMessage, MapMode, SliceParams, TrackPoint, TrackPositions } from "./types"; +type FixMessage = { + type: "fix"; + lat: number; + lon: number; + alt: number | null; + timestamp: string; + source: string; + fresh: boolean; + detail?: string; +}; +type StatusMessage = { + type: "status"; + status: string; + timestamp: string; + detail?: string; +}; + +type IncomingMessage = FixMessage | StatusMessage; + +type AppConfig = { + tiles_url: string; + tiles_url_tms?: string; + vector_tiles_url?: string; + vector_metadata_url?: string; + vector_tileset_url?: string; + vector_tileset_metadata_url?: string; + tilesets_url?: string; + tilesets_activate_url?: string; + ws_url: string; + gnss_source: string; +}; + +type TrackPoint = { + lat: number; + lon: number; + timestamp: string; + fresh: boolean; +}; + +const defaultCenter: LatLngExpression = [55.751244, 37.618423]; const backendBase = (import.meta.env.VITE_BACKEND_URL as string | undefined) ?? "http://localhost:8000"; -const initialSliceParams: SliceParams = { - source: "", - destination: "", - tileset: "", - min_zoom: 10, - max_zoom: 16, - tile_format: "png", - scheme: "xyz", - source_format: "geotiff", -}; +const markerIcon = new L.Icon({ + iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", + shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", + iconSize: [25, 41], + iconAnchor: [12, 41], +}); + +function FlyTo({ point }: { point: TrackPoint | null }) { + const map = useMap(); + useEffect(() => { + if (point) { + map.flyTo([point.lat, point.lon], Math.max(map.getZoom(), 14), { duration: 0.6 }); + } + }, [point, map]); + return null; +} + +function formatTime(ts?: string) { + if (!ts) return "—"; + try { + return new Date(ts).toLocaleTimeString(); + } catch { + return ts; + } +} export default function App() { - const [theme, setTheme] = useState<"dark" | "light">(getInitialTheme); - const [settingsOpen, setSettingsOpen] = useState(false); const [config, setConfig] = useState(null); const [status, setStatus] = useState("connecting"); const [track, setTrack] = useState([]); const wsRef = useRef(null); - const [mapMode, setMapMode] = useState("raster"); - const [gnssMode, setGnssMode] = useState<"simulated" | "serial" | "unknown">("unknown"); - const [simCenterLat, setSimCenterLat] = useState(""); - const [simCenterLon, setSimCenterLon] = useState(""); + const [mapMode, setMapMode] = useState<"raster" | "vector">("raster"); const [vectorSource, setVectorSource] = useState<"mbtiles" | "tileset">("mbtiles"); + const [rasterMbtilesMeta, setRasterMbtilesMeta] = useState(null); const [tilesets, setTilesets] = useState([]); const [activeTileset, setActiveTileset] = useState(""); + const [rasterTileset, setRasterTileset] = useState(""); + const [vectorTileset, setVectorTileset] = useState(""); + const [tilesetKinds, setTilesetKinds] = useState>({}); + const [tilesetExtents, setTilesetExtents] = useState>({}); + const [backendOnline, setBackendOnline] = useState(true); const [selectedFileName, setSelectedFileName] = useState(""); const fileInputRef = useRef(null); - const [sliceParams, setSliceParams] = useState(initialSliceParams); + const [sliceParams, setSliceParams] = useState({ + source: "", + destination: "", + tileset: "", + min_zoom: 10, + max_zoom: 16, + tile_format: "png", + scheme: "xyz" as "xyz" | "tms", + source_format: "geotiff" as "geotiff" | "mbtiles" | "tms" | "xyz", + }); const [sliceStatus, setSliceStatus] = useState("idle"); + const maplibreContainerRef = useRef(null); + const maplibreRef = useRef(null); + const leafletMapRef = useRef(null); - const sendFrontLog = useCallback((message: string, level: "info" | "warn" | "error" = "info") => { - fetch(`${backendBase}/logs`, { + const uploadFileBlob = async (file: File) => { + setSliceStatus("uploading"); + const form = new FormData(); + form.append("file", file); + return fetch(`${backendBase}/tiles/upload`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message, level, source: "frontend" }), - }).catch(() => {}); - }, []); - - useEffect(() => { - if (typeof document === "undefined") return; - document.documentElement.setAttribute("data-theme", theme); - try { - localStorage.setItem("theme", theme); - } catch { - // ignore storage errors (e.g. private mode) - } - }, [theme]); - - useEffect(() => { - fetch(`${backendBase}/config`, { cache: "no-store" }) - .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`)))) - .then((cfg) => { - setConfig(cfg); - return cfg; + body: form, + }) + .then((r) => (r.ok ? r.json() : r.json().then((j) => Promise.reject(j.detail || r.status)))) + .then((res) => { + setSliceParams((p) => ({ ...p, source: res.path })); + setSelectedFileName(res.filename); + setSliceParams((p) => { + if (p.tileset) return p; + const proposed = String(res.filename || "") + .replace(/\.[^.]+$/, "") + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 64); + return proposed ? { ...p, tileset: proposed } : p; + }); + setSliceStatus("idle"); + sendFrontLog(`uploaded source ${res.filename}`, "info"); + if (res.filename?.toLowerCase?.().endsWith(".mbtiles")) { + ingestMap(res.path).catch((err) => { + sendFrontLog(`ingest failed: ${err}`, "warn"); + }); + } }) .catch((err) => { - sendFrontLog(`config fetch failed: ${err}`, "error"); - setStatus("config-error"); + setSliceStatus("error"); + sendFrontLog(`upload error: ${err}`, "error"); }); - }, [sendFrontLog]); + }; - const refreshGnssState = useCallback(async () => { - const url = config?.gnss_state_url ? `${backendBase}${config.gnss_state_url}` : `${backendBase}/gnss/state`; - const res = await fetch(url, { cache: "no-store" }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.detail || `status ${res.status}`); - const mode = (data?.gnss?.mode as string | undefined) || "unknown"; - if (mode === "simulated" || mode === "serial") { - setGnssMode(mode); - } else { - setGnssMode("unknown"); - } - if (typeof data?.gnss?.lat === "number") setSimCenterLat(String(data.gnss.lat)); - if (typeof data?.gnss?.lon === "number") setSimCenterLon(String(data.gnss.lon)); - }, [config]); - - const refreshTilesets = useCallback(async () => { - const url = config?.tilesets_url ? `${backendBase}${config.tilesets_url}` : `${backendBase}/tilesets`; - const res = await fetch(url, { cache: "no-store" }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.detail || `status ${res.status}`); - setTilesets(data.tilesets || []); - setActiveTileset(data.active || ""); - }, [config]); - - useEffect(() => { - if (!config) return; - refreshTilesets().catch((err) => sendFrontLog(`tilesets refresh failed: ${err}`, "warn")); - refreshGnssState().catch((err) => sendFrontLog(`gnss refresh failed: ${err}`, "warn")); - }, [config, refreshGnssState, refreshTilesets, sendFrontLog]); - - useEffect(() => { - if (!config?.gnss_source) return; - if (config.gnss_source === "simulated" || config.gnss_source === "serial") setGnssMode(config.gnss_source); - }, [config]); - - const setGnssModeRequest = useCallback( - async (mode: "simulated" | "serial") => { - const url = config?.gnss_mode_url ? `${backendBase}${config.gnss_mode_url}` : `${backendBase}/gnss/mode`; - const res = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ mode }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.detail || `status ${res.status}`); - sendFrontLog(`gnss mode set: ${mode}`, "info"); - setGnssMode(mode); - setConfig((prev) => (prev ? { ...prev, gnss_source: mode } : prev)); - if (mode === "simulated") { - if (typeof data?.gnss?.lat === "number") setSimCenterLat(String(data.gnss.lat)); - if (typeof data?.gnss?.lon === "number") setSimCenterLon(String(data.gnss.lon)); - } - }, - [config, sendFrontLog], - ); - - const applySimCenter = useCallback(async () => { - const lat = Number(simCenterLat); - const lon = Number(simCenterLon); - if (!Number.isFinite(lat) || !Number.isFinite(lon)) { - sendFrontLog("invalid simulated center coordinates", "warn"); - return; - } - const url = config?.gnss_sim_center_url - ? `${backendBase}${config.gnss_sim_center_url}` - : `${backendBase}/gnss/sim/center`; - const res = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ lat, lon }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.detail || `status ${res.status}`); - sendFrontLog(`sim center updated: ${lat}, ${lon}`, "info"); - }, [config, sendFrontLog, simCenterLat, simCenterLon]); - - const activateTileset = useCallback( - async (name: string) => { - const url = config?.tilesets_activate_url - ? `${backendBase}${config.tilesets_activate_url}` - : `${backendBase}/tilesets/activate`; - const res = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.detail || `status ${res.status}`); - setActiveTileset(data.active || name); - sendFrontLog(`tileset activated: ${data.active || name}`, "info"); - }, - [config, sendFrontLog], - ); - - const createTileset = useCallback( - async (name: string) => { - const res = await fetch(`${backendBase}/tilesets`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.detail || `status ${res.status}`); - await refreshTilesets(); - setSliceParams((p) => ({ ...p, tileset: data.name || name })); - sendFrontLog(`tileset created: ${data.name || name}`, "info"); - }, - [refreshTilesets, sendFrontLog], - ); - - useEffect(() => { - if (!config) return; - const base = backendBase.replace(/\/+$/, ""); - const wsUrl = `${base}${config.ws_url}`.replace(/^http/, "ws"); - setStatus("connecting"); - const socket = new WebSocket(wsUrl); - wsRef.current = socket; - - socket.onmessage = (event) => { - try { - const msg: IncomingMessage = JSON.parse(event.data); - if (msg.type === "fix") { - const fix = msg; - setTrack((prev) => { - const next = [...prev, { lat: fix.lat, lon: fix.lon, timestamp: fix.timestamp, fresh: fix.fresh }]; - return next.slice(-500); - }); - setStatus(fix.fresh ? "ok" : "stale"); - } else { - setStatus(msg.status); - } - } catch (err) { - sendFrontLog(`ws parse error: ${err}`, "error"); - } - }; - - socket.onclose = () => { - setStatus("disconnected"); - sendFrontLog("ws disconnected", "warn"); - }; - socket.onerror = () => { - setStatus("ws-error"); - sendFrontLog("ws error", "error"); - }; - - return () => socket.close(); - }, [config, sendFrontLog]); - - const lastPoint = useMemo(() => (track.length ? track[track.length - 1] : null), [track]); - const positions: TrackPositions = useMemo( - () => track.map((p) => [p.lat, p.lon]) as TrackPositions, - [track], - ); - - const tileUrl = - config && - `${backendBase.replace(/\/+$/, "")}${ - sliceParams.scheme === "tms" - ? config.tiles_url_tms || "/tiles/tms/{z}/{x}/{y}.png" - : config.tiles_url || "/tiles/{z}/{x}/{y}.png" - }`; - - const vectorTileUrl = - config && - `${backendBase.replace(/\/+$/, "")}${ - vectorSource === "tileset" - ? config.vector_tileset_url || "/tilesets/active/vector/{z}/{x}/{y}.pbf" - : config.vector_tiles_url || "/tiles/vector/{z}/{x}/{y}.pbf" - }`; - - const vectorMetaUrl = - config && - `${backendBase.replace(/\/+$/, "")}${ - vectorSource === "tileset" - ? config.vector_tileset_metadata_url || "/tilesets/active/vector/metadata" - : config.vector_metadata_url || "/tiles/vector/metadata" - }`; - - const ingestMap = useCallback( - async (path: string) => { - const res = await fetch(`${backendBase}/maps/ingest`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - path, - tileset: sliceParams.tileset || undefined, - min_zoom: Number(sliceParams.min_zoom), - max_zoom: Number(sliceParams.max_zoom), - }), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data?.detail || `status ${res.status}`); - if (data.kind === "vector") { - setVectorSource("mbtiles"); - setMapMode("vector"); - } else if (data.kind === "raster") { - setMapMode("raster"); - } - sendFrontLog(`map ingested: kind=${data.kind} driver=${data.driver}`, "info"); - return data; - }, - [sendFrontLog, sliceParams.max_zoom, sliceParams.min_zoom, sliceParams.tileset], - ); - - const uploadFileBlob = useCallback( - async (file: File) => { - setSliceStatus("uploading"); - const form = new FormData(); - form.append("file", file); - return fetch(`${backendBase}/tiles/upload`, { - method: "POST", - body: form, - }) - .then((r) => (r.ok ? r.json() : r.json().then((j) => Promise.reject(j.detail || r.status)))) - .then((res) => { - setSliceParams((p) => ({ ...p, source: res.path })); - setSelectedFileName(res.filename); - setSliceParams((p) => { - if (p.tileset) return p; - const proposed = String(res.filename || "") - .replace(/\.[^.]+$/, "") - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 64); - return proposed ? { ...p, tileset: proposed } : p; - }); - setSliceStatus("idle"); - sendFrontLog(`uploaded source ${res.filename}`, "info"); - if (res.filename?.toLowerCase?.().endsWith(".mbtiles")) { - ingestMap(res.path).catch((err) => sendFrontLog(`ingest failed: ${err}`, "warn")); - } - }) - .catch((err) => { - setSliceStatus("error"); - sendFrontLog(`upload error: ${err}`, "error"); - }); - }, - [ingestMap, sendFrontLog], - ); - - const triggerFileSelect = useCallback(async () => { + const triggerFileSelect = async () => { try { if ((window as any).__TAURI__) { const { open } = await import("@tauri-apps/plugin-dialog"); @@ -333,127 +161,820 @@ export default function App() { } fileInputRef.current?.click(); } catch (err) { - sendFrontLog(`file dialog error: ${err}`, "warn"); + console.error("file dialog error", err); } - }, [sendFrontLog]); + }; - const handleFileSelected = useCallback( - async (file: File | null | undefined) => { - if (!file) return; - setSelectedFileName(file.name); - await uploadFileBlob(file); - }, - [uploadFileBlob], - ); - - const handleSliceSubmit = useCallback(() => { - setSliceStatus("running"); - fetch(`${backendBase}/tiles/slice`, { + const sendFrontLog = (message: string, level: string = "info") => { + fetch(`${backendBase}/logs`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - source: sliceParams.source, - destination: sliceParams.destination || undefined, - tileset: sliceParams.tileset || undefined, - min_zoom: Number(sliceParams.min_zoom), - max_zoom: Number(sliceParams.max_zoom), - tile_format: sliceParams.tile_format, - scheme: sliceParams.scheme, - source_format: sliceParams.source_format, - }), - }) - .then((r) => (r.ok ? r.json() : r.json().then((j) => Promise.reject(j.detail || r.status)))) - .then((res) => { - setSliceStatus("ok"); - sendFrontLog(`tiles sliced to ${res.tiles_dir}`, "info"); - refreshTilesets().catch(() => {}); - if (res.tileset) setActiveTileset(res.tileset); - if (res.tileset) activateTileset(res.tileset).catch(() => {}); - }) - .catch((err) => { - setSliceStatus("error"); - sendFrontLog(`tile slice error: ${err}`, "error"); - }); - }, [activateTileset, refreshTilesets, sendFrontLog, sliceParams]); + body: JSON.stringify({ message, level, source: "frontend" }), + }).catch((err) => console.error("log push failed", err)); + }; - const handleSliceReset = useCallback(() => { - setSliceParams(initialSliceParams); - setSelectedFileName(""); + useEffect(() => { + let alive = true; + const ping = async () => { + try { + const res = await fetch(`${backendBase}/health`, { cache: "no-store" }); + if (!alive) return; + setBackendOnline(res.ok); + } catch { + if (!alive) return; + setBackendOnline(false); + } + }; + ping(); + const id = window.setInterval(ping, 2000); + return () => { + alive = false; + window.clearInterval(id); + }; }, []); - const { mapContainerRef } = useVectorMap({ - mapMode, - vectorMetaUrl, - vectorTileUrl, - positions, - lastPoint, - onLog: sendFrontLog, - }); + useEffect(() => { + fetch(`${backendBase}/config`, { cache: "no-store" }) + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`)))) + .then((cfg) => { + setConfig(cfg); + return cfg; + }) + .catch((err) => { + console.error("Config error", err); + sendFrontLog(`config fetch failed: ${err}`, "error"); + setStatus("config-error"); + }); + }, []); + + const refreshTilesets = async () => { + const url = config?.tilesets_url ? `${backendBase}${config.tilesets_url}` : `${backendBase}/tilesets`; + const res = await fetch(url, { cache: "no-store" }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.detail || `status ${res.status}`); + const list: string[] = data.tilesets || []; + const kinds: Record = data.kinds || {}; + setTilesets(list); + setActiveTileset(data.active || ""); + setTilesetKinds(kinds); + setTilesetExtents(data.extents || {}); + const kind = kinds?.[data.active] || ""; + if (kind === "vector-dir") { + setVectorSource("tileset"); + if (!vectorTileset) setVectorTileset(data.active || ""); + } else if (kind === "raster") { + if (!rasterTileset) setRasterTileset(data.active || ""); + setSliceParams((p) => ({ ...p, scheme: "xyz" })); + } + + if (!rasterTileset) { + const firstRaster = list.find((t) => (kinds[t] || "") === "raster") || ""; + if (firstRaster) setRasterTileset(firstRaster); + } + if (!vectorTileset) { + const firstVector = list.find((t) => (kinds[t] || "") === "vector-dir") || ""; + if (firstVector) setVectorTileset(firstVector); + } + + const active = data.active || ""; + const extentKnown = !!data.extents?.[active]; + if (!extentKnown && kind === "raster" && active) { + try { + const r = await fetch(`${backendBase}/tilesets/active/extent`, { cache: "no-store" }); + if (r.ok) { + const e = await r.json(); + setTilesetExtents((prev) => ({ ...prev, [active]: e })); + } + } catch {} + } + }; + + useEffect(() => { + if (!config) return; + refreshTilesets().catch((err) => sendFrontLog(`tilesets refresh failed: ${err}`, "warn")); + }, [config]); + + const activateTileset = async (name: string, targetMode?: "raster" | "vector") => { + const url = config?.tilesets_activate_url ? `${backendBase}${config.tilesets_activate_url}` : `${backendBase}/tilesets/activate`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.detail || `status ${res.status}`); + setActiveTileset(data.active || name); + sendFrontLog(`tileset activated: ${data.active || name}`, "info"); + await refreshTilesets(); + if (targetMode) setMapMode(targetMode); + }; + + const switchMode = async (next: "raster" | "vector") => { + if (next === "vector") { + setMapMode("vector"); + if (vectorSource === "tileset") { + const name = vectorTileset || activeTileset; + if (name) await activateTileset(name, "vector"); + } + return; + } + setMapMode("raster"); + if (rasterMbtilesMeta) return; + const name = rasterTileset || activeTileset; + if (name) await activateTileset(name, "raster"); + }; + + const createTileset = async (name: string) => { + const res = await fetch(`${backendBase}/tilesets`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.detail || `status ${res.status}`); + await refreshTilesets(); + setSliceParams((p) => ({ ...p, tileset: data.name || name })); + sendFrontLog(`tileset created: ${data.name || name}`, "info"); + }; + + useEffect(() => { + if (!config) return; + const base = backendBase.replace(/\/+$/, ""); + const wsUrl = `${base}${config.ws_url}`.replace(/^http/, "ws"); + setStatus("connecting"); + const socket = new WebSocket(wsUrl); + wsRef.current = socket; + + socket.onmessage = (event) => { + try { + const msg: IncomingMessage = JSON.parse(event.data); + if (msg.type === "fix") { + const fix = msg as FixMessage; + setTrack((prev) => { + const next = [...prev, { lat: fix.lat, lon: fix.lon, timestamp: fix.timestamp, fresh: fix.fresh }]; + return next.slice(-500); + }); + setStatus(fix.fresh ? "ok" : "stale"); + } else { + setStatus((msg as StatusMessage).status); + } + } catch (err) { + console.error("WS parse error", err); + sendFrontLog(`ws parse error: ${err}`, "error"); + } + }; + + socket.onclose = () => { + setStatus("disconnected"); + sendFrontLog("ws disconnected", "warn"); + }; + socket.onerror = () => { + setStatus("ws-error"); + sendFrontLog("ws error", "error"); + }; + + return () => socket.close(); + }, [config]); + + const lastPoint = track.length ? track[track.length - 1] : null; + const positions = useMemo( + () => track.map((p) => [p.lat, p.lon]) as LatLngExpression[], + [track], + ); + + const tileUrl = + config && `${backendBase.replace(/\/+$/, "")}${config.tiles_url || "/tiles/{z}/{x}/{y}.png"}`; + + const activeRasterExtent = tilesetExtents[activeTileset] || null; + const boundsFromMbtiles = + typeof rasterMbtilesMeta?.bounds === "string" + ? rasterMbtilesMeta.bounds.split(",").map((p: string) => Number(p.trim())) + : Array.isArray(rasterMbtilesMeta?.bounds) + ? rasterMbtilesMeta.bounds.map((n: any) => Number(n)) + : null; + const activeRasterBounds = + tilesetExtents[activeTileset]?.bounds || + (Array.isArray(boundsFromMbtiles) && boundsFromMbtiles.length === 4 && boundsFromMbtiles.every((n) => Number.isFinite(n)) + ? boundsFromMbtiles + : undefined); + const activeRasterScheme = (activeRasterExtent?.scheme as string | undefined) || "xyz"; + const shouldFlyTo = + !!lastPoint && + (!Array.isArray(activeRasterBounds) || + activeRasterBounds.length !== 4 || + (lastPoint.lon >= activeRasterBounds[0] && + lastPoint.lon <= activeRasterBounds[2] && + lastPoint.lat >= activeRasterBounds[1] && + lastPoint.lat <= activeRasterBounds[3])); + + const fitRasterToTileset = () => { + const map = leafletMapRef.current; + if (!map) return; + const extent = tilesetExtents[activeTileset]; + if (!extent) { + const meta = rasterMbtilesMeta; + if (!meta) return; + const center = typeof meta.center === "string" ? meta.center.split(",").map((p: string) => Number(p.trim())) : []; + if (center.length >= 2 && Number.isFinite(center[0]) && Number.isFinite(center[1])) { + const z = center.length >= 3 && Number.isFinite(center[2]) ? Number(center[2]) : 13; + try { + map.setView([center[1], center[0]], z, { animate: false }); + } catch {} + } + return; + } + + const minzoom = Number.isFinite(Number(extent.minzoom)) ? Number(extent.minzoom) : null; + const maxzoom = Number.isFinite(Number(extent.maxzoom)) ? Number(extent.maxzoom) : null; + if (minzoom != null) { + try { + map.setMinZoom(minzoom); + } catch {} + } + if (maxzoom != null) { + try { + map.setMaxZoom(maxzoom); + } catch {} + } + + const zValue = Number.isFinite(Number(extent.z_value)) ? Number(extent.z_value) : null; + const center = Array.isArray(extent.center) && extent.center.length === 2 ? extent.center : null; + if (center && zValue != null) { + const [lon, lat] = center; + if (Number.isFinite(Number(lat)) && Number.isFinite(Number(lon))) { + try { + map.setView([Number(lat), Number(lon)], zValue, { animate: false }); + return; + } catch {} + } + } + + const b = extent?.bounds; + if (Array.isArray(b) && b.length === 4) { + try { + map.fitBounds( + [ + [b[1], b[0]], + [b[3], b[2]], + ], + { padding: [20, 20], duration: 0 }, + ); + } catch {} + } + }; + + useEffect(() => { + if (mapMode !== "raster") return; + fitRasterToTileset(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mapMode, activeTileset, tilesetExtents, rasterMbtilesMeta]); + + const vectorTileUrl = + config && `${backendBase.replace(/\/+$/, "")}${config.vector_tiles_url || "/tiles/vector/{z}/{x}/{y}.pbf"}`; + + const vectorMetaUrl = + config && `${backendBase.replace(/\/+$/, "")}${config.vector_metadata_url || "/tiles/vector/metadata"}`; + + const vectorTilesetUrl = + config && `${backendBase.replace(/\/+$/, "")}${config.vector_tileset_url || "/tilesets/active/vector/{z}/{x}/{y}.pbf"}`; + + const vectorTilesetMetaUrl = + config && + `${backendBase.replace(/\/+$/, "")}${ + config.vector_tileset_metadata_url || "/tilesets/active/vector/metadata" + }`; + + const activateRasterMbtiles = async (path: string) => { + const res = await fetch(`${backendBase}/tiles/raster/activate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.detail || `status ${res.status}`); + sendFrontLog(`raster mbtiles activated: ${path}`, "info"); + setMapMode("raster"); + + try { + const metaRes = await fetch(`${backendBase}/tiles/raster/metadata`, { cache: "no-store" }); + if (metaRes.ok) { + const meta = await metaRes.json(); + setRasterMbtilesMeta(meta); + const map = leafletMapRef.current; + const center = typeof meta.center === "string" ? meta.center.split(",").map((p: string) => Number(p.trim())) : []; + if (map && center.length >= 2 && Number.isFinite(center[0]) && Number.isFinite(center[1])) { + const z = center.length >= 3 && Number.isFinite(center[2]) ? Number(center[2]) : 13; + map.setView([center[1], center[0]], z, { animate: false }); + } + } + } catch {} + }; + + const ingestMap = async (path: string) => { + const res = await fetch(`${backendBase}/maps/ingest`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.detail || `status ${res.status}`); + if (data.kind === "vector") { + setVectorSource("mbtiles"); + setMapMode("vector"); + sendFrontLog(`ingest ok: vector (${data.driver})`, "info"); + return; + } + if (data.kind === "raster") { + await activateRasterMbtiles(path); + sendFrontLog(`ingest ok: raster (${data.driver})`, "info"); + } + }; + + useEffect(() => { + if (mapMode !== "vector") return; + if (!maplibreContainerRef.current) return; + const metaUrl = vectorSource === "tileset" ? vectorTilesetMetaUrl : vectorMetaUrl; + const tilesUrl = vectorSource === "tileset" ? vectorTilesetUrl : vectorTileUrl; + if (!metaUrl || !tilesUrl) return; + + let canceled = false; + (async () => { + const metaRes = await fetch(metaUrl, { cache: "no-store" }); + if (!metaRes.ok) { + sendFrontLog(`vector metadata fetch failed: ${metaRes.status} ${metaRes.statusText}`, "warn"); + return; + } + const meta = await metaRes.json(); + const vectorLayersRaw = meta.vector_layers || meta["json"] || null; + let sourceLayers: string[] = []; + try { + const parsed = typeof vectorLayersRaw === "string" ? JSON.parse(vectorLayersRaw) : vectorLayersRaw; + const layers = parsed?.vector_layers ?? parsed?.layers ?? parsed ?? []; + if (Array.isArray(layers)) { + sourceLayers = layers.map((l: any) => l.id).filter(Boolean); + } + } catch { + sourceLayers = []; + } + + const { default: maplibregl } = await import("maplibre-gl"); + if (canceled) return; + + if (maplibreRef.current) { + try { + maplibreRef.current.remove(); + } catch {} + maplibreRef.current = null; + } + + const style: any = { + version: 8, + sources: { + osm: { + type: "vector", + tiles: [tilesUrl], + minzoom: Number(meta.minzoom ?? 0), + maxzoom: Number(meta.maxzoom ?? 14), + }, + track: { + type: "geojson", + data: { type: "FeatureCollection", features: [] }, + }, + }, + layers: [ + { id: "bg", type: "background", paint: { "background-color": "#050816" } }, + ], + glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf", + }; + + for (const id of sourceLayers) { + style.layers.push({ + id: `fill-${id}`, + type: "fill", + source: "osm", + "source-layer": id, + filter: ["==", ["geometry-type"], "Polygon"], + paint: { "fill-color": "#1f2937", "fill-opacity": 0.35 }, + }); + style.layers.push({ + id: `linepoly-${id}`, + type: "line", + source: "osm", + "source-layer": id, + filter: ["==", ["geometry-type"], "Polygon"], + paint: { "line-color": "#334155", "line-width": 1 }, + }); + style.layers.push({ + id: `line-${id}`, + type: "line", + source: "osm", + "source-layer": id, + filter: ["==", ["geometry-type"], "LineString"], + paint: { "line-color": "#475569", "line-width": 1.2 }, + }); + style.layers.push({ + id: `point-${id}`, + type: "circle", + source: "osm", + "source-layer": id, + filter: ["==", ["geometry-type"], "Point"], + paint: { "circle-radius": 2, "circle-color": "#94a3b8", "circle-opacity": 0.8 }, + }); + } + + style.layers.push( + { + id: "track-line", + type: "line", + source: "track", + paint: { "line-color": "#7dd3fc", "line-width": 4, "line-opacity": 0.9 }, + }, + { + id: "track-point", + type: "circle", + source: "track", + paint: { "circle-radius": 6, "circle-color": "#a78bfa", "circle-stroke-color": "#ffffff", "circle-stroke-width": 1 }, + }, + ); + + const map = new maplibregl.Map({ + container: maplibreContainerRef.current!, + style, + center: [37.618423, 55.751244], + zoom: 11, + }); + map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), "top-right"); + + map.on("load", () => { + const bounds = meta.bounds; + const parts = + Array.isArray(bounds) && bounds.length === 4 + ? bounds.map((n: any) => Number(n)) + : typeof bounds === "string" + ? bounds.split(",").map((p: string) => Number(p.trim())) + : []; + if (parts.length === 4 && parts.every((n: number) => Number.isFinite(n))) { + map.fitBounds( + [ + [parts[0], parts[1]], + [parts[2], parts[3]], + ], + { padding: 30, duration: 0 }, + ); + } + }); + + maplibreRef.current = map; + })().catch((err) => { + sendFrontLog(`maplibre init error: ${err}`, "error"); + }); + + return () => { + canceled = true; + }; + }, [mapMode, vectorSource, vectorMetaUrl, vectorTileUrl, vectorTilesetMetaUrl, vectorTilesetUrl]); + + useEffect(() => { + if (mapMode !== "vector") return; + const map = maplibreRef.current; + if (!map) return; + const geojson = { + type: "FeatureCollection", + features: [ + positions.length + ? { type: "Feature", geometry: { type: "LineString", coordinates: positions.map((p: any) => [p[1], p[0]]) } } + : null, + lastPoint ? { type: "Feature", geometry: { type: "Point", coordinates: [lastPoint.lon, lastPoint.lat] } } : null, + ].filter(Boolean), + }; + try { + const src = map.getSource("track"); + if (src && src.setData) { + src.setData(geojson); + } + } catch {} + }, [mapMode, positions, lastPoint]); return (
-
- -
+
+
+
+
+
GNSS Offline Map
+
FastAPI · WebSocket · Tauri
+
+
+
+ Status + {status} +
+
-
-
- +
+ {!backendOnline && ( +
+
+ Backend недоступен по {backendBase}. Запусти его:{" "} + poetry run uvicorn gnss.main:app --host 0.0.0.0 --port 8000 +
+
+ )} +
+
+
+
GNSS source
+
{config?.gnss_source ?? "—"}
+
+
+
Tileset
+
+
+ + +
+
+
+
+
Last fix
+
{formatTime(lastPoint?.timestamp)}
+
+
+
Track points
+
{track.length}
+
+
+
+ +
+
+ + +
+ {mapMode === "raster" ? ( + tileUrl ? ( + { + leafletMapRef.current = e.target; + fitRasterToTileset(); + }} + > + + {positions.length > 0 && ( + <> + + + + )} + + + ) : ( +
Нет конфигурации тайлов
+ ) + ) : ( +
+ )} +
+ +
+
+
+
Нарезка тайлов
+
Запускает gdal2tiles на бэкенде
+
+
+ {sliceStatus} +
+
+
{ + e.preventDefault(); + setSliceStatus("running"); + fetch(`${backendBase}/tiles/slice`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + source: sliceParams.source, + destination: sliceParams.destination || undefined, + tileset: sliceParams.tileset || undefined, + min_zoom: Number(sliceParams.min_zoom), + max_zoom: Number(sliceParams.max_zoom), + tile_format: sliceParams.tile_format, + scheme: sliceParams.scheme, + source_format: sliceParams.source_format, + }), + }) + .then((r) => (r.ok ? r.json() : r.json().then((j) => Promise.reject(j.detail || r.status)))) + .then((res) => { + setSliceStatus("ok"); + sendFrontLog(`tiles sliced to ${res.tiles_dir}`, "info"); + refreshTilesets().catch(() => {}); + if (res.tileset) setActiveTileset(res.tileset); + if (res.tileset) activateTileset(res.tileset).catch(() => {}); + }) + .catch((err) => { + setSliceStatus("error"); + sendFrontLog(`tile slice error: ${err}`, "error"); + }); + }} + > + + + + + + + + +
+ + +
+
- -
- - -
- - setSettingsOpen(false)} - config={config} - status={status} - theme={theme} - onToggleTheme={() => setTheme((t) => (t === "dark" ? "light" : "dark"))} - gnssMode={gnssMode} - simCenterLat={simCenterLat} - simCenterLon={simCenterLon} - onGnssRefresh={() => refreshGnssState().catch((err) => sendFrontLog(`${err}`, "warn"))} - onGnssModeChange={(mode) => setGnssModeRequest(mode).catch((err) => sendFrontLog(`${err}`, "error"))} - onSimCenterLatChange={setSimCenterLat} - onSimCenterLonChange={setSimCenterLon} - onSimCenterApply={() => applySimCenter().catch((err) => sendFrontLog(`${err}`, "error"))} - vectorSource={vectorSource} - onVectorSourceChange={setVectorSource} - tilesets={tilesets} - activeTileset={activeTileset} - onActivateTileset={(name) => activateTileset(name).catch((err) => sendFrontLog(`${err}`, "error"))} - onRefreshTilesets={() => refreshTilesets().catch((err) => sendFrontLog(`${err}`, "warn"))} - onCreateTileset={(name) => createTileset(name).catch((err) => sendFrontLog(`tileset create failed: ${err}`, "error"))} - sliceParams={sliceParams} - onSliceParamsChange={setSliceParams} - sliceStatus={sliceStatus} - onSliceSubmit={handleSliceSubmit} - onSliceReset={handleSliceReset} - selectedFileName={selectedFileName} - onTriggerFileSelect={triggerFileSelect} - onFileSelected={handleFileSelected} - fileInputRef={fileInputRef} - />
); } diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index d8ab148..24852df 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -9,16 +9,6 @@ type SettingsPanelProps = { status: string; theme: "dark" | "light"; onToggleTheme: () => void; - gnssMode: "simulated" | "serial" | "unknown"; - simCenterLat: string; - simCenterLon: string; - onGnssRefresh: () => void; - onGnssModeChange: (mode: "simulated" | "serial") => void; - onSimCenterLatChange: (value: string) => void; - onSimCenterLonChange: (value: string) => void; - onSimCenterApply: () => void; - vectorSource: "mbtiles" | "tileset"; - onVectorSourceChange: (source: "mbtiles" | "tileset") => void; tilesets: string[]; activeTileset: string; onActivateTileset: (name: string) => void; @@ -42,16 +32,6 @@ export const SettingsPanel = ({ status, theme, onToggleTheme, - gnssMode, - simCenterLat, - simCenterLon, - onGnssRefresh, - onGnssModeChange, - onSimCenterLatChange, - onSimCenterLonChange, - onSimCenterApply, - vectorSource, - onVectorSourceChange, tilesets, activeTileset, onActivateTileset, @@ -94,44 +74,6 @@ export const SettingsPanel = ({ -
-
-
GNSS
-
Режим и точка симуляции
-
- -
- - - - {gnssMode === "simulated" && ( -
- - - -
- )} -
Тема
@@ -161,14 +103,6 @@ export const SettingsPanel = ({
- -
Нарезка тайлов
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a9582e8..edc1fca 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,46 +1,4 @@ import ReactDOM from "react-dom/client"; import App from "./App"; -const backendBase = - (import.meta.env.VITE_BACKEND_URL as string | undefined) ?? "http://localhost:8000"; - -const sendFrontLog = (message: string, level: "info" | "warn" | "error" = "info") => { - fetch(`${backendBase}/logs`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message, level, source: "frontend" }), - }).catch(() => {}); -}; - -window.addEventListener("error", (event) => { - const err = event.error instanceof Error ? event.error : null; - const details = err?.stack || err?.message || String(event.message || "unknown error"); - sendFrontLog(`window.error: ${details}`, "error"); -}); - -window.addEventListener("unhandledrejection", (event) => { - const reason = event.reason instanceof Error ? event.reason.stack || event.reason.message : String(event.reason); - sendFrontLog(`unhandledrejection: ${reason}`, "error"); -}); - -const _consoleError = console.error.bind(console); -console.error = (...args: unknown[]) => { - try { - sendFrontLog(`console.error: ${args.map(String).join(" ")}`, "error"); - } catch { - // ignore - } - _consoleError(...args); -}; - -const _consoleWarn = console.warn.bind(console); -console.warn = (...args: unknown[]) => { - try { - sendFrontLog(`console.warn: ${args.map(String).join(" ")}`, "warn"); - } catch { - // ignore - } - _consoleWarn(...args); -}; - ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 587aa5f..9d1bcc2 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -27,15 +27,10 @@ export type AppConfig = { tiles_url_tms?: string; vector_tiles_url?: string; vector_metadata_url?: string; - vector_tileset_url?: string; - vector_tileset_metadata_url?: string; tilesets_url?: string; tilesets_activate_url?: string; ws_url: string; gnss_source: string; - gnss_state_url?: string; - gnss_mode_url?: string; - gnss_sim_center_url?: string; }; export type TrackPoint = { diff --git a/gnss/__main__.py b/gnss/__main__.py index bcd7e52..125ad7c 100644 --- a/gnss/__main__.py +++ b/gnss/__main__.py @@ -1,7 +1,5 @@ import argparse import os -import sys -from pathlib import Path from typing import Optional import uvicorn @@ -14,80 +12,19 @@ def _env(name: str, default: str) -> str: return v if v is not None and v != "" else default -class _TeeStream: - def __init__(self, primary, secondary): - self._primary = primary - self._secondary = secondary - - def write(self, data): - try: - self._primary.write(data) - except Exception: - pass - try: - self._secondary.write(data) - except Exception: - pass - return len(data) - - def flush(self): - try: - self._primary.flush() - except Exception: - pass - try: - self._secondary.flush() - except Exception: - pass - - def isatty(self): - try: - return self._primary.isatty() - except Exception: - return False - - -def _ensure_uvicorn_logs_to_file(log_path: Path) -> None: - import logging - - log_path.parent.mkdir(parents=True, exist_ok=True) - file_handler = logging.FileHandler(log_path, encoding="utf-8") - file_handler.setLevel(logging.INFO) - file_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")) - - for name in ("uvicorn", "uvicorn.error", "uvicorn.access"): - logger = logging.getLogger(name) - logger.setLevel(logging.INFO) - if not any( - isinstance(h, logging.FileHandler) and getattr(h, "baseFilename", None) == str(log_path) - for h in logger.handlers - ): - logger.addHandler(file_handler) - logger.propagate = True - - def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser(prog="gnss", description="GNSS Backend (FastAPI)") parser.add_argument("--host", default=_env("GNSS_HOST", "0.0.0.0")) parser.add_argument("--port", type=int, default=int(_env("GNSS_PORT", "8000"))) parser.add_argument("--log-level", default=_env("GNSS_LOG_LEVEL", "info")) - parser.add_argument("--log-file", default=_env("GNSS_BACKEND_LOG_FILE", "logs/backend.log")) args = parser.parse_args(argv) - log_path = Path(args.log_file).expanduser() - log_path.parent.mkdir(parents=True, exist_ok=True) - backend_log_file = log_path.open("a", encoding="utf-8", buffering=1) - sys.stdout = _TeeStream(sys.stdout, backend_log_file) # type: ignore[assignment] - sys.stderr = _TeeStream(sys.stderr, backend_log_file) # type: ignore[assignment] - _ensure_uvicorn_logs_to_file(log_path) - uvicorn.run( app, host=args.host, port=args.port, log_level=args.log_level, access_log=True, - log_config=None, workers=1, ) return 0 diff --git a/gnss/api/routes/config_route.py b/gnss/api/routes/config_route.py index ee1c653..37cfcb6 100644 --- a/gnss/api/routes/config_route.py +++ b/gnss/api/routes/config_route.py @@ -1,17 +1,12 @@ from fastapi import APIRouter from ... import config -from .. import state router = APIRouter(tags=["config"]) @router.get("/config") async def get_config() -> dict: - try: - gnss_source = state.get_gnss_service().source_kind() - except Exception: - gnss_source = config.GNSS_SOURCE return { "tiles_url": "/tiles/{z}/{x}/{y}.png", "tiles_url_tms": "/tiles/tms/{z}/{x}/{y}.png", @@ -22,8 +17,5 @@ async def get_config() -> dict: "tilesets_url": "/tilesets", "tilesets_activate_url": "/tilesets/activate", "ws_url": "/ws/track", - "gnss_source": gnss_source, - "gnss_state_url": "/gnss/state", - "gnss_mode_url": "/gnss/mode", - "gnss_sim_center_url": "/gnss/sim/center", + "gnss_source": config.GNSS_SOURCE, } diff --git a/gnss/api/routes/gnss.py b/gnss/api/routes/gnss.py deleted file mode 100644 index 0517c8c..0000000 --- a/gnss/api/routes/gnss.py +++ /dev/null @@ -1,61 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field - -from ..dependencies import get_gnss_service -from ... import config -from ...services.gnss import GNSSService, RectangularPathSource, SerialNMEASource, SimulatedPathSource - - -router = APIRouter(tags=["gnss"]) - - -@router.get("/gnss/state") -async def gnss_state(gnss: GNSSService = Depends(get_gnss_service)) -> dict: - return {"status": "ok", "gnss": gnss.describe()} - - -class SetGNSSModeRequest(BaseModel): - mode: str = Field(pattern="^(simulated|serial)$") - - -@router.post("/gnss/mode") -async def set_gnss_mode(payload: SetGNSSModeRequest, gnss: GNSSService = Depends(get_gnss_service)) -> dict: - mode = payload.mode.lower() - if mode == "serial": - source = SerialNMEASource(port=config.GNSS_PORT, baudrate=config.GNSS_BAUD, timeout=config.GNSS_TIMEOUT) - await gnss.set_source(source) - return {"status": "ok", "gnss": gnss.describe()} - - start_lat, start_lon = config.SIM_START_POINT - path = (getattr(config, "SIM_PATH", "circle") or "circle").lower() - if path == "rectangle": - source = RectangularPathSource( - center_lat=start_lat, - center_lon=start_lon, - width_m=config.SIM_RECT_WIDTH_METERS, - height_m=config.SIM_RECT_HEIGHT_METERS, - step_m=config.SIM_STEP_METERS, - alt_m=config.SIM_ALT_METERS, - ) - else: - source = SimulatedPathSource(center_lat=start_lat, center_lon=start_lon, radius_m=config.SIM_RADIUS_METERS) - await gnss.set_source(source) - return {"status": "ok", "gnss": gnss.describe()} - - -class UpdateSimulatedCenterRequest(BaseModel): - lat: float = Field(ge=-90, le=90) - lon: float = Field(ge=-180, le=180) - - -@router.post("/gnss/sim/center") -async def update_simulated_center( - payload: UpdateSimulatedCenterRequest, - gnss: GNSSService = Depends(get_gnss_service), -) -> dict: - try: - await gnss.update_simulated_center(lat=payload.lat, lon=payload.lon) - except TypeError as exc: - raise HTTPException(status_code=400, detail=str(exc)) - return {"status": "ok", "gnss": gnss.describe()} - diff --git a/gnss/main.py b/gnss/main.py index 8db31e8..da70d61 100644 --- a/gnss/main.py +++ b/gnss/main.py @@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware from . import config from .api import state -from .api.routes import config_route, gnss, health, logs, maps, raster_mbtiles, tiles, tilesets, vector_tiles, ws +from .api.routes import config_route, health, logs, maps, raster_mbtiles, tiles, tilesets, vector_tiles, ws from .services.gnss import GNSSService, RectangularPathSource, SerialNMEASource, SimulatedPathSource from .services.logs import LogService from .services.tiles import TileService @@ -84,7 +84,6 @@ app.include_router(config_route.router) app.include_router(tiles.router) app.include_router(ws.router) app.include_router(logs.router) -app.include_router(gnss.router) app.include_router(maps.router) app.include_router(raster_mbtiles.router) app.include_router(vector_tiles.router) diff --git a/gnss/services/gnss.py b/gnss/services/gnss.py index 2e056d9..824c8e7 100644 --- a/gnss/services/gnss.py +++ b/gnss/services/gnss.py @@ -180,43 +180,6 @@ class GNSSService: async def stop(self) -> None: await self.source.stop() - def source_kind(self) -> str: - if isinstance(self.source, SerialNMEASource): - return "serial" - if isinstance(self.source, (SimulatedPathSource, RectangularPathSource)): - return "simulated" - return "unknown" - - def describe(self) -> dict: - kind = self.source_kind() - payload: dict = {"mode": kind} - if kind == "simulated": - payload["lat"] = getattr(self.source, "center_lat", None) - payload["lon"] = getattr(self.source, "center_lon", None) - payload["path"] = "rectangle" if isinstance(self.source, RectangularPathSource) else "circle" - if kind == "serial": - payload["port"] = getattr(self.source, "port", None) - payload["baudrate"] = getattr(self.source, "baudrate", None) - return payload - - async def set_source(self, source: BaseGNSSSource) -> None: - async with self._lock: - try: - await self.source.stop() - except Exception: - pass - self.source = source - self._last_fix = None - await self.source.start() - - async def update_simulated_center(self, lat: float, lon: float) -> None: - async with self._lock: - if not isinstance(self.source, (SimulatedPathSource, RectangularPathSource)): - raise TypeError("current GNSS source is not simulated") - self.source.center_lat = lat - self.source.center_lon = lon - self._last_fix = None - async def _fetch_fix(self) -> Optional[GNSSPoint]: async with self._lock: fix = await self.source.next_point()