diff --git a/frontend/src/App.css b/frontend/src/App.css index 30b485a..697fb92 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -218,14 +218,6 @@ 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 4a3dae2..d389fdb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,149 +1,237 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { MapContainer, Marker, Polyline, TileLayer, useMap } from "react-leaflet"; -import L, { LatLngExpression } from "leaflet"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import "./App.css"; import "leaflet/dist/leaflet.css"; import "maplibre-gl/dist/maplibre-gl.css"; -type FixMessage = { - type: "fix"; - lat: number; - lon: number; - alt: number | null; - timestamp: string; - source: string; - fresh: boolean; - detail?: string; -}; +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 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 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; - } -} +const initialSliceParams: SliceParams = { + source: "", + destination: "", + tileset: "", + min_zoom: 10, + max_zoom: 16, + tile_format: "png", + scheme: "xyz", + source_format: "geotiff", +}; 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" | "vector">("raster"); - const [vectorSource, setVectorSource] = useState<"mbtiles" | "tileset">("mbtiles"); - const [rasterMbtilesMeta, setRasterMbtilesMeta] = useState(null); + const [mapMode, setMapMode] = useState("raster"); 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({ - 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 [sliceParams, setSliceParams] = useState(initialSliceParams); const [sliceStatus, setSliceStatus] = useState("idle"); - const maplibreContainerRef = useRef(null); - const maplibreRef = useRef(null); - const leafletMapRef = useRef(null); - const uploadFileBlob = async (file: File) => { - setSliceStatus("uploading"); - const form = new FormData(); - form.append("file", file); - return fetch(`${backendBase}/tiles/upload`, { + const sendFrontLog = useCallback((message: string, level: "info" | "warn" | "error" = "info") => { + fetch(`${backendBase}/logs`, { 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"); - }); - } + 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; }) .catch((err) => { - setSliceStatus("error"); - sendFrontLog(`upload error: ${err}`, "error"); + sendFrontLog(`config fetch failed: ${err}`, "error"); + setStatus("config-error"); }); - }; + }, [sendFrontLog]); - const triggerFileSelect = async () => { + 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")); + }, [config, refreshTilesets, sendFrontLog]); + + 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(/\/+$/, "")}${config.vector_tiles_url || "/tiles/vector/{z}/{x}/{y}.pbf"}`; + + const vectorMetaUrl = + config && `${backendBase.replace(/\/+$/, "")}${config.vector_metadata_url || "/tiles/vector/metadata"}`; + + const activateVectorMbtiles = useCallback( + async (path: string) => { + const res = await fetch(`${backendBase}/tiles/vector/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(`vector mbtiles activated: ${path}`, "info"); + setMapMode("vector"); + return data; + }, + [sendFrontLog], + ); + + 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")) { + activateVectorMbtiles(res.path).catch((err) => { + sendFrontLog(`vector activate failed: ${err}`, "warn"); + }); + } + }) + .catch((err) => { + setSliceStatus("error"); + sendFrontLog(`upload error: ${err}`, "error"); + }); + }, + [activateVectorMbtiles, sendFrontLog], + ); + + const triggerFileSelect = useCallback(async () => { try { if ((window as any).__TAURI__) { const { open } = await import("@tauri-apps/plugin-dialog"); @@ -161,820 +249,117 @@ export default function App() { } fileInputRef.current?.click(); } catch (err) { - console.error("file dialog error", err); + sendFrontLog(`file dialog error: ${err}`, "warn"); } - }; + }, [sendFrontLog]); - const sendFrontLog = (message: string, level: string = "info") => { - fetch(`${backendBase}/logs`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message, level, source: "frontend" }), - }).catch((err) => console.error("log push failed", err)); - }; - - 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); - }; - }, []); - - 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 handleFileSelected = useCallback( + async (file: File | null | undefined) => { + if (!file) return; + setSelectedFileName(file.name); + await uploadFileBlob(file); + }, + [uploadFileBlob], ); - 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`, { + const handleSliceSubmit = useCallback(() => { + setSliceStatus("running"); + fetch(`${backendBase}/tiles/slice`, { 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, + 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"); }); - map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), "top-right"); + }, [activateTileset, refreshTilesets, sendFrontLog, sliceParams]); - 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 }, - ); - } - }); + const handleSliceReset = useCallback(() => { + setSliceParams(initialSliceParams); + setSelectedFileName(""); + }, []); - 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]); + const { mapContainerRef } = useVectorMap({ + mapMode, + vectorMetaUrl, + vectorTileUrl, + positions, + lastPoint, + onLog: sendFrontLog, + }); 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"))} + 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} + />
); }