diff --git a/Docs/nuitka.md b/Docs/nuitka.md new file mode 100644 index 0000000..ec19f9a --- /dev/null +++ b/Docs/nuitka.md @@ -0,0 +1,46 @@ +## Nuitka build (backend) + +### Важно + +- `gdal2tiles.py` и GDAL — это системные бинарники/библиотеки, Nuitka их не “впаяет” в один файл. На целевой машине GDAL всё равно должен быть установлен, если ты используешь `/tiles/slice` (нарезку). +- Для onefile-режима бинарник распаковывается во временную папку, поэтому проектные папки внутри пакета не должны использоваться для записи. В коде это решено через `GNSS_DATA_DIR`. + +### Где лежат данные + +По умолчанию: +- в обычном режиме (запуск из исходников) данные лежат рядом с пакетом (`gnss/`). +- в “замороженном” режиме (`nuitka --onefile`) данные идут в `~/.local/share/gnss`. + +Можно переопределить: +- `GNSS_DATA_DIR=/path/to/data` + +Структура: +- `$GNSS_DATA_DIR/tilesets//...` +- `$GNSS_DATA_DIR/uploads/...` +- `$GNSS_DATA_DIR/tiles/...` (опционально) + +### Запуск без Nuitka (для проверки) + +`poetry run gnss-backend --host 0.0.0.0 --port 8000` + +### Установка Nuitka (в Poetry) + +Рекомендовано держать Nuitka как dev-зависимость: + +`poetry add --group dev nuitka` + +### Сборка (standalone + onefile) + +Пример: + +`poetry run python -m nuitka --onefile --standalone --follow-imports --assume-yes-for-downloads --output-dir=dist --output-filename=gnss-backend gnss/__main__.py` + +После сборки: +- бинарник будет в `dist/` (например `dist/gnss-backend.bin`) + +### Запуск бинарника + +`GNSS_DATA_DIR=./data ./dist/gnss-backend.bin --host 0.0.0.0 --port 8000` + +Положи tileset’ы сюда: +- `./data/tilesets//...` diff --git a/frontend/index.html b/frontend/index.html index ff93803..dff4f82 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,9 @@ - + - Tauri + React + Typescript + GoAgro diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg new file mode 100644 index 0000000..a06c1e2 --- /dev/null +++ b/frontend/public/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/public/menu.svg b/frontend/public/menu.svg new file mode 100644 index 0000000..9c02950 --- /dev/null +++ b/frontend/public/menu.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/tauri.svg b/frontend/public/tauri.svg deleted file mode 100644 index 31b62c9..0000000 --- a/frontend/public/tauri.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/frontend/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css index dd7d863..697fb92 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,78 +1,121 @@ :root { font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; - background: #050816; - color: #e8ecf4; + color-scheme: dark; + --bg: #050816; + --gradient-a: none; + --gradient-b: none; + --text: #f4f7fb; + --muted: #94a3b8; + --surface: rgba(255, 255, 255, 0.03); + --surface-strong: rgba(255, 255, 255, 0.04); + --border: rgba(255, 255, 255, 0.08); + --border-strong: rgba(255, 255, 255, 0.1); + --pill-bg: rgba(255, 255, 255, 0.06); + --pill-border: rgba(255, 255, 255, 0.1); + --panel-shadow: none; + --input-bg: rgba(255, 255, 255, 0.05); + --input-border: rgba(255, 255, 255, 0.08); + --btn-ghost-bg: rgba(255, 255, 255, 0.05); + --btn-ghost-border: rgba(255, 255, 255, 0.08); + --primary: #2563eb; + --glass: rgba(255, 255, 255, 0.04); + --glass-border: rgba(255, 255, 255, 0.08); +} + +:root[data-theme="light"] { + color-scheme: light; + --bg: #f6f8ff; + --gradient-a: none; + --gradient-b: none; + --text: #0f172a; + --muted: #64748b; + --surface: rgba(255, 255, 255, 0.92); + --surface-strong: #ffffff; + --border: rgba(15, 23, 42, 0.08); + --border-strong: rgba(15, 23, 42, 0.12); + --pill-bg: rgba(15, 23, 42, 0.05); + --pill-border: rgba(15, 23, 42, 0.1); + --panel-shadow: none; + --input-bg: rgba(255, 255, 255, 0.98); + --input-border: rgba(15, 23, 42, 0.14); + --btn-ghost-bg: rgba(255, 255, 255, 0.94); + --btn-ghost-border: rgba(15, 23, 42, 0.12); + --glass: rgba(255, 255, 255, 0.9); + --glass-border: rgba(15, 23, 42, 0.08); + --primary: #3B82F6; } * { box-sizing: border-box; } +.maplibre .maplibregl-ctrl-attrib { + display: none !important; +} body, #root { margin: 0; min-height: 100vh; + background: var(--bg); + color: var(--text); } .layout { min-height: 100vh; - background: radial-gradient(circle at 20% 20%, rgba(126, 221, 252, 0.08), transparent 25%), - radial-gradient(circle at 80% 0%, rgba(255, 135, 252, 0.08), transparent 22%), - #050816; - color: #f4f7fb; + height: 100vh; + background: var(--bg); + color: var(--text); display: flex; flex-direction: column; + transition: background 0.2s ease, color 0.2s ease; } -.topbar { +.content { + width: 100%; + max-width: none; + margin: 0; display: flex; - justify-content: space-between; - align-items: center; - padding: 18px 22px; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); - backdrop-filter: blur(10px); - background: rgba(255, 255, 255, 0.04); + flex-direction: column; + overflow: hidden; + flex: 1 1 auto; + min-height: 0; } -.brand { +.status-strip { + flex: 0 0 auto; + padding: 6px 10px; + background: var(--surface); + border-bottom: 1px solid var(--border); + font-size: 12px; +} + +.mini-info { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; + padding: 0; +} + +.mini-info.compact { + align-items: stretch; +} + +.chip { + padding: 4px 10px; + border-radius: 10px; + background: var(--surface-strong); display: flex; - gap: 12px; - align-items: center; + flex-direction: column; + gap: 2px; +} +.leaflet-container{ + height: 100% !important; +} +.chip.small span { + font-size: 12px; } -.pulse { - width: 12px; - height: 12px; - border-radius: 50%; - background: #f59e0b; - box-shadow: 0 0 0 8px rgba(245, 158, 11, 0.2); - animation: pulse 2.4s infinite; -} - -.pulse.ok { - background: #34d399; - box-shadow: 0 0 0 8px rgba(52, 211, 153, 0.18); -} - -@keyframes pulse { - 0% { - transform: scale(1); - } - 50% { - transform: scale(1.2); - } - 100% { - transform: scale(1); - } -} - -.title { - font-weight: 700; - letter-spacing: 0.4px; -} - -.subtitle { - color: #94a3b8; - font-size: 13px; +.chip.small strong { + font-size: 16px; } .pill { @@ -81,14 +124,13 @@ body, #root { align-items: center; padding: 8px 12px; border-radius: 999px; - background: linear-gradient(135deg, rgba(125, 211, 252, 0.18), rgba(252, 165, 255, 0.18)); - border: 1px solid rgba(255, 255, 255, 0.1); + background: var(--pill-bg); + border: 1px solid var(--pill-border); } .pill-label { - color: #cbd5e1; + color: var(--muted); font-size: 13px; - text-transform: uppercase; letter-spacing: 0.6px; } @@ -96,37 +138,35 @@ body, #root { font-weight: 700; } -.content { - display: grid; - grid-template-rows: auto 1fr auto; - gap: 14px; - padding: 14px 18px 18px; - flex: 1; -} - .panel { - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 14px; + background: var(--surface); + border: 1px solid var(--border); padding: 14px; - box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.35); + box-shadow: var(--panel-shadow); + transition: background 0.2s ease, border-color 0.2s ease; } .info-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + grid-template-columns: repeat(3, 1fr); gap: 12px; + padding-bottom: 32px; } .card { - padding: 12px; + width: 141px; + height: 141px; border-radius: 12px; - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.06); + background: var(--surface-strong); + border: 1px solid var(--border); + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; } .card-label { - color: #94a3b8; + color: var(--muted); font-size: 13px; margin-bottom: 4px; } @@ -134,38 +174,52 @@ body, #root { .card-value { font-size: 20px; font-weight: 700; + color: var(--text); +} + +.map-shell { + flex: 1 1 auto; + min-height: 0; + padding: 0; } .map-panel { padding: 0; + display: flex; + flex-direction: column; + gap: 0; + flex: 1 1 auto; + min-height: 0; + background: transparent; + border: none; } .map { - height: 70vh; - border-radius: 12px; + height: 100%; + min-height: 0; + border-radius: 0; overflow: hidden; + border: none; + width: 100%; } .maplibre { - height: 70vh; - border-radius: 12px; + height: 100%; + min-height: 0; + border-radius: 0; overflow: hidden; -} - -.map-switch { - display: flex; - gap: 10px; - padding: 12px; + border: none; + width: 100%; } .empty { padding: 24px; text-align: center; - color: #94a3b8; + color: var(--muted); } .leaflet-control-container { - filter: drop-shadow(0 10px 20px rgba(0, 0, 0, 0.25)); + filter: none; } .slice-panel { @@ -178,6 +232,8 @@ body, #root { display: flex; justify-content: space-between; align-items: center; + padding: 32px 0; + border-top: 1px solid var(--border); } .panel-title { @@ -186,15 +242,15 @@ body, #root { } .panel-subtitle { - color: #94a3b8; + color: var(--muted); font-size: 13px; } .badge { padding: 6px 10px; border-radius: 999px; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.1); + background: var(--btn-ghost-bg); + border: 1px solid var(--border-strong); text-transform: uppercase; font-size: 12px; letter-spacing: 0.6px; @@ -211,29 +267,44 @@ body, #root { } .slice-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + display: flex; + flex-direction: column; gap: 12px; + padding-bottom: 80px; } .field { - display: flex; - flex-direction: column; - gap: 6px; + display: grid; + grid-template-columns: 180px 1fr; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-radius: 12px; + background: var(--surface-strong); + border: 1px solid var(--border); +} + +.field.compact { + grid-template-columns: 160px 1fr; + padding: 8px 10px; + background: var(--surface); } .field span { - color: #94a3b8; + color: var(--muted); font-size: 13px; + font-weight: 600; } .field input, -.field select { +.field select, +.tileset-row select { padding: 10px 12px; border-radius: 10px; - border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(255, 255, 255, 0.05); - color: #f4f7fb; + border: 1px solid var(--input-border); + background: var(--input-bg); + color: var(--text); + width: 100%; } .tileset-row { @@ -246,9 +317,16 @@ body, #root { flex: 1; } +.tileset-row.stacked { + flex-direction: row; + align-items: center; +} + .file-row { display: flex; gap: 8px; + align-items: center; + width: 100%; } .file-row input { @@ -256,14 +334,51 @@ body, #root { } .muted { - color: #94a3b8; + color: var(--muted); font-size: 12px; } -.actions { +.field .muted { + grid-column: 2; +} + +.actions-row { display: flex; gap: 10px; - align-items: flex-end; + flex-wrap: wrap; + align-items: center; + justify-content: end; +} + +.field.actions { + grid-template-columns: 1fr 1fr; + background: var(--surface); +} + +.field.actions span { + color: var(--muted); +} + +@media (max-width: 720px) { + .field, + .field.compact, + .field.actions { + grid-template-columns: 1fr; + align-items: flex-start; + } + + .field span { + margin-bottom: 6px; + } + + .field .muted { + grid-column: 1; + } + + .tileset-row.stacked { + flex-direction: column; + align-items: stretch; + } } .btn { @@ -272,29 +387,119 @@ body, #root { border: 1px solid transparent; cursor: pointer; font-weight: 700; - transition: transform 0.12s ease, border 0.12s ease, box-shadow 0.12s ease; + transition: transform 0.12s ease, border 0.12s ease, background 0.2s ease; + color: var(--text); + background: transparent; +} + +.btn.small { + padding: 8px 12px; + font-size: 14px; } .btn.primary { - background: linear-gradient(135deg, #38bdf8, #8b5cf6); - color: #0b1020; - box-shadow: 0 10px 30px -12px rgba(56, 189, 248, 0.8); + background: var(--primary); + color: var(--bg); } .btn.ghost { - background: rgba(255, 255, 255, 0.05); - color: #e8ecf4; - border-color: rgba(255, 255, 255, 0.08); + background: var(--btn-ghost-bg); + color: var(--text); + border-color: var(--btn-ghost-border); } -.btn:hover { - transform: translateY(-1px); +.bottom-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; + width: 100%; + height: 10vh; + min-height: 64px; + max-height: 120px; +} +.bottom-settings{ + display: grid; + grid-template-columns: 1fr; + gap: 0; + width: 100%; + height: 10vh; + min-height: 64px; + max-height: 120px; + position: absolute; + bottom: 0; + left: 0; +} +.bottom-settings .btn.block { + width: 100%; + height: 100%; + border-radius: 0; + font-size: 16px; +} +.bottom-actions .btn.block { + width: 100%; + height: 100%; + border-radius: 0; + font-size: 16px; +} + +.bottom-actions .btn.ghost { + border-right: 1px solid var(--border); +} + +.settings-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.3); + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + z-index: 30; +} + +.settings-backdrop.open { + opacity: 1; + pointer-events: auto; + z-index: 999; +} + +.settings-panel { + position: fixed; + top: 0; + right: 0; + width: 100vw; + height: 100vh; + background: var(--bg); + border-left: 1px solid var(--border); + transform: translateX(100%); + transition: transform 0.25s ease; + z-index: 40; + display: flex; + flex-direction: column; + padding: 18px 0; + gap: 12px; +} + +.settings-panel.open { + transform: translateX(0); + z-index: 1000; +} + +.settings-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 18px; +} + +.settings-content { + overflow: auto; + padding: 0; + display: flex; + flex-direction: column; + gap: 12px; } @media (max-width: 900px) { - .content { - grid-template-rows: auto 1fr auto; - } .map { height: 60vh; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8cd777a..320f20d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,167 +1,66 @@ -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; - 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 [mapMode, setMapMode] = useState("raster"); + const [gnssMode, setGnssMode] = useState<"simulated" | "serial" | "unknown">("unknown"); + const [simCenterLat, setSimCenterLat] = useState(""); + const [simCenterLon, setSimCenterLon] = useState(""); + const [vectorSource, setVectorSource] = useState<"mbtiles" | "tileset">("mbtiles"); const [tilesets, setTilesets] = useState([]); const [activeTileset, setActiveTileset] = useState(""); + const [tilesetKinds, setTilesetKinds] = useState>({}); 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 uploadFileBlob = 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"); - }); - }; - - const triggerFileSelect = async () => { - try { - if ((window as any).__TAURI__) { - const { open } = await import("@tauri-apps/plugin-dialog"); - const selected = await open({ - multiple: false, - filters: [ - { name: "Raster/MBTiles", extensions: ["tif", "tiff", "mbtiles", "jpg", "jpeg", "png"] }, - ], - }); - if (typeof selected === "string") { - setSliceParams((p) => ({ ...p, source: selected })); - setSelectedFileName(selected.split("/").pop() || selected); - return; - } - } - fileInputRef.current?.click(); - } catch (err) { - console.error("file dialog error", err); - } - }; - - const sendFrontLog = (message: string, level: string = "info") => { + const sendFrontLog = useCallback((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((err) => console.error("log push failed", err)); - }; + }).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" }) @@ -171,51 +70,128 @@ export default function App() { return cfg; }) .catch((err) => { - console.error("Config error", err); sendFrontLog(`config fetch failed: ${err}`, "error"); setStatus("config-error"); }); - }, []); + }, [sendFrontLog]); - const refreshTilesets = async () => { + 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 || ""); - }; + setTilesetKinds(data.kinds || {}); + }, [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 activateTileset = async (name: string) => { - const url = config?.tilesets_activate_url ? `${backendBase}${config.tilesets_activate_url}` : `${backendBase}/tilesets/activate`; + 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({ name }), + body: JSON.stringify({ lat, lon }), }); 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"); - }; + sendFrontLog(`sim center updated: ${lat}, ${lon}`, "info"); + }, [config, sendFrontLog, simCenterLat, simCenterLon]); - 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"); - }; + 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"); + const kind = (data.kind as string | undefined) || tilesetKinds[name] || ""; + if (kind.startsWith("vector")) { + setMapMode("vector"); + setVectorSource(kind === "vector-dir" ? "tileset" : "mbtiles"); + } else if (kind.startsWith("raster")) { + setMapMode("raster"); + } + }, + [config, sendFrontLog, tilesetKinds], + ); + + 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; @@ -229,17 +205,16 @@ export default function App() { try { const msg: IncomingMessage = JSON.parse(event.data); if (msg.type === "fix") { - const fix = msg as FixMessage; + 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 as StatusMessage).status); + setStatus(msg.status); } } catch (err) { - console.error("WS parse error", err); sendFrontLog(`ws parse error: ${err}`, "error"); } }; @@ -254,11 +229,11 @@ export default function App() { }; return () => socket.close(); - }, [config]); + }, [config, sendFrontLog]); - const lastPoint = track.length ? track[track.length - 1] : null; - const positions = useMemo( - () => track.map((p) => [p.lat, p.lon]) as LatLngExpression[], + 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], ); @@ -271,462 +246,235 @@ export default function App() { }`; const vectorTileUrl = - config && `${backendBase.replace(/\/+$/, "")}${config.vector_tiles_url || "/tiles/vector/{z}/{x}/{y}.pbf"}`; + 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(/\/+$/, "")}${config.vector_metadata_url || "/tiles/vector/metadata"}`; + config && + `${backendBase.replace(/\/+$/, "")}${ + vectorSource === "tileset" + ? config.vector_tileset_metadata_url || "/tilesets/active/vector/metadata" + : config.vector_metadata_url || "/tiles/vector/metadata" + }`; - const activateVectorMbtiles = async (path: string) => { - const res = await fetch(`${backendBase}/tiles/vector/activate`, { + 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"); + if (data.tileset) { + refreshTilesets().catch(() => {}); + activateTileset(String(data.tileset)).catch(() => {}); + } + return data; + }, + [activateTileset, refreshTilesets, 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); + const lower = String(res.filename || "").toLowerCase(); + if (lower.endsWith(".mbtiles")) setSliceParams((p) => ({ ...p, source_format: "mbtiles" })); + if (lower.endsWith(".tif") || lower.endsWith(".tiff") || lower.endsWith(".vrt")) + setSliceParams((p) => ({ ...p, source_format: "geotiff" })); + 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 () => { + try { + if ((window as any).__TAURI__) { + const { open } = await import("@tauri-apps/plugin-dialog"); + const selected = await open({ + multiple: false, + filters: [ + { name: "Raster/MBTiles", extensions: ["tif", "tiff", "mbtiles", "jpg", "jpeg", "png"] }, + ], + }); + if (typeof selected === "string") { + const lower = selected.toLowerCase(); + setSliceParams((p) => ({ ...p, source: selected })); + if (lower.endsWith(".mbtiles")) setSliceParams((p) => ({ ...p, source_format: "mbtiles" })); + if (lower.endsWith(".tif") || lower.endsWith(".tiff") || lower.endsWith(".vrt")) + setSliceParams((p) => ({ ...p, source_format: "geotiff" })); + setSelectedFileName(selected.split("/").pop() || selected); + return; + } + } + fileInputRef.current?.click(); + } catch (err) { + sendFrontLog(`file dialog error: ${err}`, "warn"); + } + }, [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`, { 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"); - }; - - useEffect(() => { - if (mapMode !== "vector") return; - if (!maplibreContainerRef.current) return; - if (!vectorMetaUrl || !vectorTileUrl) return; - - let canceled = false; - (async () => { - const metaRes = await fetch(vectorMetaUrl, { cache: "no-store" }); - if (!metaRes.ok) 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: [vectorTileUrl], - 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; - if (typeof bounds === "string") { - const parts = 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, vectorMetaUrl, vectorTileUrl]); - - 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} -
-
+
+ +
-
-
-
-
-
GNSS source
-
{config?.gnss_source ?? "—"}
-
-
-
Tileset
-
-
- - -
-
-
-
-
Last fix
-
{formatTime(lastPoint?.timestamp)}
-
-
-
Track points
-
{track.length}
-
-
-
- -
-
- - -
- {mapMode === "raster" ? ( - tileUrl ? ( - - - {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/MapPanel.tsx b/frontend/src/components/MapPanel.tsx new file mode 100644 index 0000000..d813f09 --- /dev/null +++ b/frontend/src/components/MapPanel.tsx @@ -0,0 +1,60 @@ +import { MapContainer, Marker, Polyline, TileLayer, useMap } from "react-leaflet"; +import L from "leaflet"; +import type { MapMode, TrackPoint, TrackPositions } from "../types"; +import { useEffect } from "react"; +import type { MutableRefObject, RefObject } from "react"; + +const defaultCenter: [number, number] = [55.751244, 37.618423]; + +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], +}); + +type MapPanelProps = { + mapMode: MapMode; + tileUrl?: string | null; + positions: TrackPositions; + lastPoint: TrackPoint | null; + sliceScheme: "xyz" | "tms"; + vectorContainerRef: RefObject | MutableRefObject; +}; + +const 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; +}; + +export const MapPanel = ({ mapMode, tileUrl, positions, lastPoint, sliceScheme, vectorContainerRef }: MapPanelProps) => { + if (mapMode === "raster") { + return tileUrl ? ( + + + {positions.length > 0 && ( + <> + + + + )} + + + ) : ( +
Нет конфигурации тайлов
+ ); + } + + return
; +}; diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx new file mode 100644 index 0000000..d8ab148 --- /dev/null +++ b/frontend/src/components/SettingsPanel.tsx @@ -0,0 +1,344 @@ +import type { MutableRefObject, RefObject } from "react"; +import { FormEvent } from "react"; +import type { AppConfig, SliceParams } from "../types"; + +type SettingsPanelProps = { + open: boolean; + onClose: () => void; + config: AppConfig | null; + 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; + onRefreshTilesets: () => void; + onCreateTileset: (name: string) => void; + sliceParams: SliceParams; + onSliceParamsChange: (params: SliceParams) => void; + sliceStatus: string; + onSliceSubmit: (event?: FormEvent) => void; + onSliceReset: () => void; + selectedFileName: string; + onTriggerFileSelect: () => void; + onFileSelected: (file: File | null | undefined) => void; + fileInputRef: RefObject | MutableRefObject; +}; + +export const SettingsPanel = ({ + open, + onClose, + config, + status, + theme, + onToggleTheme, + gnssMode, + simCenterLat, + simCenterLon, + onGnssRefresh, + onGnssModeChange, + onSimCenterLatChange, + onSimCenterLonChange, + onSimCenterApply, + vectorSource, + onVectorSourceChange, + tilesets, + activeTileset, + onActivateTileset, + onRefreshTilesets, + onCreateTileset, + sliceParams, + onSliceParamsChange, + sliceStatus, + onSliceSubmit, + onSliceReset, + selectedFileName, + onTriggerFileSelect, + onFileSelected, + fileInputRef, +}: SettingsPanelProps) => ( + <> +
+ + +); diff --git a/frontend/src/components/TrackSummary.tsx b/frontend/src/components/TrackSummary.tsx new file mode 100644 index 0000000..a2f1e85 --- /dev/null +++ b/frontend/src/components/TrackSummary.tsx @@ -0,0 +1,25 @@ +import { formatTime } from "../utils/time"; +import type { TrackPoint } from "../types"; + +type TrackSummaryProps = { + lastPoint: TrackPoint | null; + pointsCount: number; + compact?: boolean; +}; + +export const TrackSummary = ({ lastPoint, pointsCount, compact }: TrackSummaryProps) => ( +
+
+ Последняя фиксация + {formatTime(lastPoint?.timestamp)} +
+
+ Точек трека + {pointsCount} +
+
+ Статус + ок +
+
+); diff --git a/frontend/src/hooks/useVectorMap.ts b/frontend/src/hooks/useVectorMap.ts new file mode 100644 index 0000000..45df673 --- /dev/null +++ b/frontend/src/hooks/useVectorMap.ts @@ -0,0 +1,202 @@ +import { useEffect, useRef } from "react"; +import type { MapMode, TrackPoint, TrackPositions } from "../types"; + +type UseVectorMapOptions = { + mapMode: MapMode; + vectorTileUrl?: string | null; + vectorMetaUrl?: string | null; + positions: TrackPositions; + lastPoint: TrackPoint | null; + onLog?: (message: string, level?: "info" | "warn" | "error") => void; +}; + +export const useVectorMap = ({ + mapMode, + vectorMetaUrl, + vectorTileUrl, + positions, + lastPoint, + onLog, +}: UseVectorMapOptions) => { + const mapContainerRef = useRef(null); + const mapRef = useRef(null); + + useEffect(() => { + if (mapMode !== "vector" && mapRef.current) { + try { + mapRef.current.remove(); + } catch { + // ignore + } finally { + mapRef.current = null; + } + } + }, [mapMode]); + + useEffect(() => { + if (mapMode !== "vector") return; + if (!mapContainerRef.current) return; + if (!vectorMetaUrl || !vectorTileUrl) return; + + let canceled = false; + (async () => { + const metaRes = await fetch(vectorMetaUrl, { cache: "no-store" }); + if (!metaRes.ok) 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 (mapRef.current) { + try { + mapRef.current.remove(); + } catch { + // ignore + } + mapRef.current = null; + } + + const style: any = { + version: 8, + sources: { + osm: { + type: "vector", + tiles: [vectorTileUrl], + 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 }, + }, + { + id: `linepoly-${id}`, + type: "line", + source: "osm", + "source-layer": id, + filter: ["==", ["geometry-type"], "Polygon"], + paint: { "line-color": "#334155", "line-width": 1 }, + }, + { + id: `line-${id}`, + type: "line", + source: "osm", + "source-layer": id, + filter: ["==", ["geometry-type"], "LineString"], + paint: { "line-color": "#475569", "line-width": 1.2 }, + }, + { + 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: mapContainerRef.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; + if (typeof bounds === "string") { + const parts = 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 }, + ); + } + } + }); + + mapRef.current = map; + })().catch((err) => onLog?.(`maplibre init error: ${err}`, "error")); + + return () => { + canceled = true; + }; + }, [mapMode, vectorMetaUrl, vectorTileUrl, onLog]); + + useEffect(() => { + if (mapMode !== "vector") return; + const map = mapRef.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 (err) { + onLog?.(`maplibre track update error: ${err}`, "warn"); + } + }, [mapMode, positions, lastPoint, onLog]); + + return { mapContainerRef }; +}; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index edc1fca..a9582e8 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,4 +1,46 @@ 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 new file mode 100644 index 0000000..587aa5f --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,59 @@ +import type { LatLngExpression } from "leaflet"; + +export type MapMode = "raster" | "vector"; + +export type FixMessage = { + type: "fix"; + lat: number; + lon: number; + alt: number | null; + timestamp: string; + source: string; + fresh: boolean; + detail?: string; +}; + +export type StatusMessage = { + type: "status"; + status: string; + timestamp: string; + detail?: string; +}; + +export type IncomingMessage = FixMessage | StatusMessage; + +export 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; + gnss_state_url?: string; + gnss_mode_url?: string; + gnss_sim_center_url?: string; +}; + +export type TrackPoint = { + lat: number; + lon: number; + timestamp: string; + fresh: boolean; +}; + +export type SliceParams = { + source: string; + destination: string; + tileset: string; + min_zoom: number; + max_zoom: number; + tile_format: string; + scheme: "xyz" | "tms"; + source_format: "geotiff" | "mbtiles" | "tms" | "xyz"; +}; + +export type TrackPositions = LatLngExpression[]; diff --git a/frontend/src/utils/theme.ts b/frontend/src/utils/theme.ts new file mode 100644 index 0000000..4b56e1e --- /dev/null +++ b/frontend/src/utils/theme.ts @@ -0,0 +1,10 @@ +export const getInitialTheme = (): "dark" | "light" => { + if (typeof window === "undefined") return "dark"; + try { + const stored = localStorage.getItem("theme"); + if (stored === "light" || stored === "dark") return stored; + } catch { + // ignore storage errors + } + return window.matchMedia?.("(prefers-color-scheme: light)")?.matches ? "light" : "dark"; +}; diff --git a/frontend/src/utils/time.ts b/frontend/src/utils/time.ts new file mode 100644 index 0000000..59799ac --- /dev/null +++ b/frontend/src/utils/time.ts @@ -0,0 +1,8 @@ +export function formatTime(ts?: string) { + if (!ts) return "—"; + try { + return new Date(ts).toLocaleTimeString(); + } catch { + return ts; + } +} diff --git a/gnss/__main__.py b/gnss/__main__.py new file mode 100644 index 0000000..bcd7e52 --- /dev/null +++ b/gnss/__main__.py @@ -0,0 +1,97 @@ +import argparse +import os +import sys +from pathlib import Path +from typing import Optional + +import uvicorn + +from gnss.main import app + + +def _env(name: str, default: str) -> str: + v = os.getenv(name) + 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 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/gnss/__pycache__/__init__.cpython-313.pyc b/gnss/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 6fbb8b4..0000000 Binary files a/gnss/__pycache__/__init__.cpython-313.pyc and /dev/null differ diff --git a/gnss/__pycache__/app.cpython-313.pyc b/gnss/__pycache__/app.cpython-313.pyc deleted file mode 100644 index b42401d..0000000 Binary files a/gnss/__pycache__/app.cpython-313.pyc and /dev/null differ diff --git a/gnss/__pycache__/config.cpython-313.pyc b/gnss/__pycache__/config.cpython-313.pyc deleted file mode 100644 index a73c46b..0000000 Binary files a/gnss/__pycache__/config.cpython-313.pyc and /dev/null differ diff --git a/gnss/__pycache__/gnss_core.cpython-313.pyc b/gnss/__pycache__/gnss_core.cpython-313.pyc deleted file mode 100644 index fefd378..0000000 Binary files a/gnss/__pycache__/gnss_core.cpython-313.pyc and /dev/null differ diff --git a/gnss/__pycache__/gnss_utils.cpython-313.pyc b/gnss/__pycache__/gnss_utils.cpython-313.pyc deleted file mode 100644 index 6c41cd0..0000000 Binary files a/gnss/__pycache__/gnss_utils.cpython-313.pyc and /dev/null differ diff --git a/gnss/api/dependencies.py b/gnss/api/dependencies.py index d42379b..160242c 100644 --- a/gnss/api/dependencies.py +++ b/gnss/api/dependencies.py @@ -1,3 +1,15 @@ -from .state import get_gnss_service, get_log_service, get_tile_service, get_vector_mbtiles_service +from .state import ( + get_gnss_service, + get_log_service, + get_raster_mbtiles_service, + get_tile_service, + get_vector_mbtiles_service, +) -__all__ = ["get_gnss_service", "get_tile_service", "get_log_service", "get_vector_mbtiles_service"] +__all__ = [ + "get_gnss_service", + "get_tile_service", + "get_log_service", + "get_raster_mbtiles_service", + "get_vector_mbtiles_service", +] diff --git a/gnss/api/routes/config_route.py b/gnss/api/routes/config_route.py index 50fcbef..ee1c653 100644 --- a/gnss/api/routes/config_route.py +++ b/gnss/api/routes/config_route.py @@ -1,19 +1,29 @@ 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", "vector_tiles_url": "/tiles/vector/{z}/{x}/{y}.pbf", "vector_metadata_url": "/tiles/vector/metadata", + "vector_tileset_url": "/tilesets/active/vector/{z}/{x}/{y}.pbf", + "vector_tileset_metadata_url": "/tilesets/active/vector/metadata", "tilesets_url": "/tilesets", "tilesets_activate_url": "/tilesets/activate", "ws_url": "/ws/track", - "gnss_source": config.GNSS_SOURCE, + "gnss_source": gnss_source, + "gnss_state_url": "/gnss/state", + "gnss_mode_url": "/gnss/mode", + "gnss_sim_center_url": "/gnss/sim/center", } diff --git a/gnss/api/routes/gnss.py b/gnss/api/routes/gnss.py new file mode 100644 index 0000000..0517c8c --- /dev/null +++ b/gnss/api/routes/gnss.py @@ -0,0 +1,61 @@ +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/api/routes/maps.py b/gnss/api/routes/maps.py new file mode 100644 index 0000000..17eaf77 --- /dev/null +++ b/gnss/api/routes/maps.py @@ -0,0 +1,110 @@ +import json +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from ..dependencies import get_raster_mbtiles_service, get_tile_service, get_vector_mbtiles_service +from ...services.raster_mbtiles import RasterMBTilesError, RasterMBTilesService +from ...services.tiles import TileCutError, TileService +from ...services.vector_mbtiles import VectorMBTilesError, VectorMBTilesService +from ...services.tilesets import TilesetError, TilesetService +from ... import config + + +router = APIRouter(tags=["maps"]) + + +class MapIngestRequest(BaseModel): + path: str + tileset: str | None = None + min_zoom: int = Field(default=10, ge=0) + max_zoom: int = Field(default=16, ge=0) + + +@router.post("/maps/ingest") +async def ingest_map( + payload: MapIngestRequest, + tile_service: TileService = Depends(get_tile_service), + raster_mbtiles: RasterMBTilesService = Depends(get_raster_mbtiles_service), + vector_mbtiles: VectorMBTilesService = Depends(get_vector_mbtiles_service), +) -> dict: + p = Path(payload.path).expanduser().resolve() + if not p.exists(): + raise HTTPException(status_code=400, detail=f"path not found: {p}") + + tileset_svc = TilesetService(tilesets_dir=config.TILESSETS_DIR, default_tileset=config.DEFAULT_TILESET) + tileset_svc.ensure() + suggested_tileset = (payload.tileset or p.stem or "").strip() + tileset_name: str | None = None + if suggested_tileset: + try: + tileset_name = tileset_svc.create(suggested_tileset) + tileset_svc.set_active(tileset_name) + tile_service.set_tiles_dir(tileset_svc.resolve(tileset_name)) + except TilesetError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + if p.suffix.lower() == ".mbtiles": + try: + info = vector_mbtiles.activate(p) + if tileset_name: + manifest = {"kind": "vector-mbtiles", "source": str(info.path), "format": info.metadata.get("format")} + (tileset_svc.resolve(tileset_name) / "gnss_tileset.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return { + "status": "ok", + "kind": "vector", + "driver": "mbtiles", + "path": str(info.path), + "format": info.metadata.get("format"), + "tileset": tileset_name, + } + except VectorMBTilesError: + pass + try: + info = raster_mbtiles.activate(p) + except RasterMBTilesError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + if tileset_name: + manifest = {"kind": "raster-mbtiles", "source": str(info.path), "format": info.metadata.get("format")} + (tileset_svc.resolve(tileset_name) / "gnss_tileset.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return { + "status": "ok", + "kind": "raster", + "driver": "mbtiles", + "path": str(info.path), + "format": info.metadata.get("format"), + "tileset": tileset_name, + } + + if p.suffix.lower() in {".tif", ".tiff", ".vrt"}: + if payload.max_zoom < payload.min_zoom: + raise HTTPException(status_code=400, detail="max_zoom must be >= min_zoom") + if tileset_name: + tile_service.set_tiles_dir(tileset_svc.resolve(tileset_name)) + try: + tile_service.slice_to_tiles( + source=p, + destination=tile_service.tiles_dir, + min_zoom=payload.min_zoom, + max_zoom=payload.max_zoom, + scheme="xyz", + ) + except TileCutError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return { + "status": "ok", + "kind": "raster", + "driver": "gdal2tiles", + "path": str(p), + "tiles_dir": str(tile_service.tiles_dir), + "tileset": tileset_name, + } + + raise HTTPException(status_code=400, detail="unsupported input; expected .mbtiles or GeoTIFF/VRT") diff --git a/gnss/api/routes/raster_mbtiles.py b/gnss/api/routes/raster_mbtiles.py new file mode 100644 index 0000000..a508241 --- /dev/null +++ b/gnss/api/routes/raster_mbtiles.py @@ -0,0 +1,51 @@ +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException, Response +from pydantic import BaseModel + +from ..dependencies import get_raster_mbtiles_service +from ...services.raster_mbtiles import RasterMBTilesError, RasterMBTilesService + + +router = APIRouter(tags=["raster-mbtiles"]) + + +class ActivateRasterMBTiles(BaseModel): + path: str + + +@router.post("/tiles/raster/activate") +async def activate_raster_mbtiles( + payload: ActivateRasterMBTiles, + service: RasterMBTilesService = Depends(get_raster_mbtiles_service), +): + try: + info = service.activate(Path(payload.path)) + except RasterMBTilesError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return {"status": "ok", "path": str(info.path), "format": info.metadata.get("format")} + + +@router.get("/tiles/raster/metadata") +async def raster_metadata(service: RasterMBTilesService = Depends(get_raster_mbtiles_service)): + try: + return service.get_metadata() + except RasterMBTilesError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +@router.get("/tiles/raster/{z}/{x}/{y}") +async def raster_tile( + z: int, + x: int, + y: int, + service: RasterMBTilesService = Depends(get_raster_mbtiles_service), +): + try: + data, media_type = service.get_tile(z=z, x=x, y_xyz=y) + except KeyError: + raise HTTPException(status_code=404, detail="tile not found") + except RasterMBTilesError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return Response(content=data, media_type=media_type) + diff --git a/gnss/api/routes/tiles.py b/gnss/api/routes/tiles.py index ce356b9..1a42c6d 100644 --- a/gnss/api/routes/tiles.py +++ b/gnss/api/routes/tiles.py @@ -3,9 +3,13 @@ from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, UploadFile from fastapi.responses import JSONResponse -from ...api.dependencies import get_tile_service +import json + +from ...api.dependencies import get_raster_mbtiles_service, get_tile_service, get_vector_mbtiles_service from ...models.tiles import TileSliceRequest +from ...services.raster_mbtiles import RasterMBTilesError, RasterMBTilesService from ...services.tiles import TileCutError, TileService +from ...services.vector_mbtiles import VectorMBTilesError, VectorMBTilesService from ... import config from ...services.tilesets import TilesetService, TilesetError @@ -13,17 +17,49 @@ router = APIRouter(tags=["tiles"]) @router.get("/tiles/{zoom}/{x}/{y}.png") -async def serve_tile(zoom: int, x: int, y: int, tile_service: TileService = Depends(get_tile_service)): - return tile_service.get_tile_response(zoom, x, y) +async def serve_tile( + zoom: int, + x: int, + y: int, + tile_service: TileService = Depends(get_tile_service), + raster_mbtiles: RasterMBTilesService = Depends(get_raster_mbtiles_service), +): + hit = tile_service._tiles_dir_tile_path(zoom, x, y) + if hit: + return tile_service.get_tile_response(zoom, x, y) + try: + data, media_type = raster_mbtiles.get_tile(z=zoom, x=x, y_xyz=y) + except Exception: + return tile_service.get_tile_response(zoom, x, y) + return tile_service.bytes_response(data=data, media_type=media_type) @router.get("/tiles/tms/{zoom}/{x}/{y}.png") -async def serve_tile_tms(zoom: int, x: int, y: int, tile_service: TileService = Depends(get_tile_service)): - return tile_service.get_tile_response_tms(zoom, x, y) +async def serve_tile_tms( + zoom: int, + x: int, + y: int, + tile_service: TileService = Depends(get_tile_service), + raster_mbtiles: RasterMBTilesService = Depends(get_raster_mbtiles_service), +): + y_converted = (2**zoom - 1) - y + hit = tile_service._tiles_dir_tile_path(zoom, x, y_converted) + if hit: + return tile_service.get_tile_response(zoom, x, y_converted) + try: + data, media_type = raster_mbtiles.get_tile(z=zoom, x=x, y_xyz=y_converted) + except Exception: + return tile_service.get_tile_response_tms(zoom, x, y) + return tile_service.bytes_response(data=data, media_type=media_type) @router.post("/tiles/slice") -async def slice_tiles(payload: TileSliceRequest, tile_service: TileService = Depends(get_tile_service)) -> dict: +async def slice_tiles( + payload: TileSliceRequest, + tile_service: TileService = Depends(get_tile_service), + raster_mbtiles: RasterMBTilesService = Depends(get_raster_mbtiles_service), + vector_mbtiles: VectorMBTilesService = Depends(get_vector_mbtiles_service), +) -> dict: source = payload.source_path() tileset_svc = TilesetService(tilesets_dir=config.TILESSETS_DIR, default_tileset=config.DEFAULT_TILESET) tileset_svc.ensure() @@ -36,6 +72,28 @@ async def slice_tiles(payload: TileSliceRequest, tile_service: TileService = Dep destination_default = tileset_svc.resolve(tileset_name) destination = payload.destination_path(destination_default) try: + if payload.source_format == "mbtiles": + try: + info = vector_mbtiles.activate(source) + kind = "vector" + manifest = {"kind": "vector-mbtiles", "source": str(info.path), "format": info.metadata.get("format")} + except VectorMBTilesError: + info = raster_mbtiles.activate(source) + kind = "raster" + manifest = {"kind": "raster-mbtiles", "source": str(info.path), "format": info.metadata.get("format")} + active_tileset = tileset_name or tileset_svc.get_active() + (tileset_svc.resolve(active_tileset) / "gnss_tileset.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return { + "status": "ok", + "kind": kind, + "driver": "mbtiles", + "path": str(source), + "scheme": payload.scheme, + "tileset": active_tileset, + } if payload.source_format in {"tms", "xyz"}: src = tile_service.unpack_if_archive(source) tile_service.copy_tile_tree(src, destination) @@ -53,12 +111,37 @@ async def slice_tiles(payload: TileSliceRequest, tile_service: TileService = Dep ) except TileCutError as exc: raise HTTPException(status_code=400, detail=str(exc)) + except (RasterMBTilesError, VectorMBTilesError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + active_tileset = tileset_name or tileset_svc.get_active() + try: + tileset_root = tileset_svc.resolve(active_tileset) + (tileset_root / "gnss_tileset.json").write_text( + json.dumps( + { + "tileset": active_tileset, + "scheme": payload.scheme, + "source_format": payload.source_format, + "tile_format": payload.tile_format, + "min_zoom": payload.min_zoom, + "max_zoom": payload.max_zoom, + "source": str(source), + "destination": str(destination), + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + except Exception: + pass return { "status": "ok", "tiles_dir": str(destination), "source": str(source), "scheme": payload.scheme, - "tileset": tileset_name or tileset_svc.get_active(), + "tileset": active_tileset, } diff --git a/gnss/api/routes/tilesets.py b/gnss/api/routes/tilesets.py index c8007ba..4f2c42c 100644 --- a/gnss/api/routes/tilesets.py +++ b/gnss/api/routes/tilesets.py @@ -1,10 +1,16 @@ -from fastapi import APIRouter, Depends, HTTPException +import json +import math +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException, Response from pydantic import BaseModel -from ..dependencies import get_tile_service +from ..dependencies import get_raster_mbtiles_service, get_tile_service, get_vector_mbtiles_service from ... import config +from ...services.raster_mbtiles import RasterMBTilesError, RasterMBTilesService from ...services.tiles import TileService from ...services.tilesets import TilesetError, TilesetService +from ...services.vector_mbtiles import VectorMBTilesError, VectorMBTilesService router = APIRouter(tags=["tilesets"]) @@ -22,10 +28,194 @@ class ActivateTileset(BaseModel): name: str +def _is_gzipped(data: bytes) -> bool: + return len(data) >= 2 and data[0] == 0x1F and data[1] == 0x8B + + +def _detect_kind(path: Path) -> str: + manifest = path / "gnss_tileset.json" + if manifest.is_file(): + try: + parsed = json.loads(manifest.read_text(encoding="utf-8")) + kind = str(parsed.get("kind") or "").lower() + if kind in {"vector-mbtiles", "raster-mbtiles"}: + return kind + except Exception: + pass + meta = path / "metadata.json" + if meta.is_file(): + try: + parsed = json.loads(meta.read_text(encoding="utf-8")) + fmt = str(parsed.get("format") or "").lower() + if fmt in {"pbf", "mvt"}: + return "vector-dir" + except Exception: + pass + if any(path.rglob("*.png")) or any(path.rglob("*.jpg")) or any(path.rglob("*.jpeg")) or any(path.rglob("*.webp")): + return "raster" + if any(path.rglob("*.pbf")): + return "vector-dir" + return "unknown" + + +def _tile_y_to_lat(y: float, z: int) -> float: + n = math.pi - (2.0 * math.pi * y) / (2**z) + return math.degrees(math.atan(math.sinh(n))) + + +def _tile_x_to_lon(x: float, z: int) -> float: + return (x / (2**z)) * 360.0 - 180.0 + + +def _scan_xyz_tile_matrix_extent(path: Path, z_value: int) -> tuple[int, int, int, int] | None: + z_dir = path / str(z_value) + if not z_dir.is_dir(): + return None + min_x = None + max_x = None + min_y = None + max_y = None + for xdir in z_dir.iterdir(): + if not xdir.is_dir(): + continue + try: + x = int(xdir.name) + except Exception: + continue + min_x = x if min_x is None else min(min_x, x) + max_x = x if max_x is None else max(max_x, x) + for f in xdir.iterdir(): + if not f.is_file(): + continue + if f.suffix.lower() not in {".png", ".jpg", ".jpeg", ".webp"}: + continue + try: + y = int(f.stem) + except Exception: + continue + min_y = y if min_y is None else min(min_y, y) + max_y = y if max_y is None else max(max_y, y) + if min_x is None or max_x is None or min_y is None or max_y is None: + return None + return min_x, min_y, max_x, max_y + + +def _tile_matrix_to_bounds(x_min: int, y_min: int, x_max: int, y_max: int, z: int) -> tuple[float, float, float, float]: + west = _tile_x_to_lon(x_min, z) + east = _tile_x_to_lon(x_max + 1, z) + north = _tile_y_to_lat(y_min, z) + south = _tile_y_to_lat(y_max + 1, z) + return west, south, east, north + + +def _parse_mapml_extent(path: Path) -> dict | None: + mapml = path / "mapml.mapml" + if not mapml.is_file(): + return None + text = mapml.read_text(encoding="utf-8", errors="ignore") + import re + + z_tag = re.search(r']*name="z"[^>]*>', text) + x_tag = re.search(r']*name="x"[^>]*>', text) + y_tag = re.search(r']*name="y"[^>]*>', text) + if not z_tag or not x_tag or not y_tag: + return None + + def _attrs(tag: str) -> dict[str, str]: + return {m.group(1): m.group(2) for m in re.finditer(r'(\w+)="([^"]+)"', tag)} + + z_attrs = _attrs(z_tag.group(0)) + x_attrs = _attrs(x_tag.group(0)) + y_attrs = _attrs(y_tag.group(0)) + try: + z_value = int(z_attrs.get("value", z_attrs.get("max", "0"))) + z_min = int(z_attrs.get("min", "0")) + z_max = int(z_attrs.get("max", str(z_value))) + x_min = int(x_attrs["min"]) + x_max = int(x_attrs["max"]) + y_min = int(y_attrs["min"]) + y_max = int(y_attrs["max"]) + except Exception: + return None + + scanned = _scan_xyz_tile_matrix_extent(path, z_value) + if scanned: + x_min, y_min, x_max, y_max = scanned + + west, south, east, north = _tile_matrix_to_bounds(x_min, y_min, x_max, y_max, z_value) + return { + "scheme": "xyz", + "z_value": z_value, + "minzoom": z_min, + "maxzoom": z_max, + "bounds": [west, south, east, north], + "center": [(west + east) / 2.0, (south + north) / 2.0], + "tile_matrix": {"z": z_value, "x_min": x_min, "x_max": x_max, "y_min": y_min, "y_max": y_max}, + } + + +def _parse_leaflet_bounds(path: Path) -> dict | None: + leaflet = path / "leaflet.html" + if not leaflet.is_file(): + return None + text = leaflet.read_text(encoding="utf-8", errors="ignore") + import re + + m = re.search( + r"map\\.fitBounds\\(\\[\\[([0-9.+-]+),\\s*([0-9.+-]+)\\],\\s*\\[([0-9.+-]+),\\s*([0-9.+-]+)\\]\\]\\)", + text, + ) + if not m: + return None + lat1, lon1, lat2, lon2 = map(float, m.groups()) + south = min(lat1, lat2) + north = max(lat1, lat2) + west = min(lon1, lon2) + east = max(lon1, lon2) + + mzoom = re.search(r"minZoom:\\s*(\\d+)", text) + mxzoom = re.search(r"maxZoom:\\s*(\\d+)", text) + mcenter = re.search(r"center:\\s*\\[([0-9.+-]+),\\s*([0-9.+-]+)\\]", text) + + minzoom = int(mzoom.group(1)) if mzoom else None + maxzoom = int(mxzoom.group(1)) if mxzoom else None + center = ( + [float(mcenter.group(2)), float(mcenter.group(1))] + if mcenter + else [(west + east) / 2.0, (south + north) / 2.0] + ) + return { + "scheme": "xyz", + "bounds": [west, south, east, north], + "center": center, + "minzoom": minzoom, + "maxzoom": maxzoom, + } + + @router.get("/tilesets") async def list_tilesets() -> dict: svc = _tileset_service() - return {"tilesets": svc.list_tilesets(), "active": svc.get_active()} + tilesets = svc.list_tilesets() + kinds = {name: _detect_kind(svc.resolve(name)) for name in tilesets} + extents = {} + for name in tilesets: + p = svc.resolve(name) + e = _parse_mapml_extent(p) or _parse_leaflet_bounds(p) + if e: + extents[name] = e + return {"tilesets": tilesets, "active": svc.get_active(), "kinds": kinds, "extents": extents} + + +@router.get("/tilesets/active/extent") +async def active_tileset_extent() -> dict: + svc = _tileset_service() + tileset_dir = svc.resolve(None) + extent = _parse_mapml_extent(tileset_dir) or _parse_leaflet_bounds(tileset_dir) + if not extent: + raise HTTPException(status_code=404, detail="no extent info for active tileset") + extent["tileset"] = svc.get_active() + return extent @router.post("/tilesets") @@ -39,11 +229,66 @@ async def create_tileset(payload: CreateTileset) -> dict: @router.post("/tilesets/activate") -async def activate_tileset(payload: ActivateTileset, tile_service: TileService = Depends(get_tile_service)) -> dict: +async def activate_tileset( + payload: ActivateTileset, + tile_service: TileService = Depends(get_tile_service), + raster_mbtiles: RasterMBTilesService = Depends(get_raster_mbtiles_service), + vector_mbtiles: VectorMBTilesService = Depends(get_vector_mbtiles_service), +) -> dict: svc = _tileset_service() try: active = svc.set_active(payload.name) except TilesetError as exc: raise HTTPException(status_code=400, detail=str(exc)) tile_service.set_tiles_dir(svc.resolve(active)) - return {"status": "ok", "active": active} + tileset_dir = svc.resolve(active) + kind = _detect_kind(tileset_dir) + activated: dict | None = None + manifest_path = tileset_dir / "gnss_tileset.json" + if manifest_path.is_file() and kind in {"vector-mbtiles", "raster-mbtiles"}: + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + src = manifest.get("source") or manifest.get("path") or manifest.get("mbtiles") + if not src: + raise ValueError("gnss_tileset.json missing 'source'") + mbtiles_path = Path(str(src)).expanduser().resolve() + if kind == "vector-mbtiles": + info = vector_mbtiles.activate(mbtiles_path) + activated = {"kind": "vector", "driver": "mbtiles", "path": str(info.path)} + else: + info = raster_mbtiles.activate(mbtiles_path) + activated = {"kind": "raster", "driver": "mbtiles", "path": str(info.path)} + except (VectorMBTilesError, RasterMBTilesError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"failed to activate mbtiles tileset: {exc}") + return {"status": "ok", "active": active, "kind": kind, "activated": activated} + + +@router.get("/tilesets/active/vector/metadata") +async def active_vector_metadata() -> dict: + svc = _tileset_service() + tileset_dir = svc.resolve(None) + meta_path = tileset_dir / "metadata.json" + if not meta_path.is_file(): + raise HTTPException(status_code=400, detail="active tileset has no metadata.json (not a MapTiler vector folder)") + try: + parsed = json.loads(meta_path.read_text(encoding="utf-8")) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"failed to parse metadata.json: {exc}") + parsed["tileset"] = svc.get_active() + return parsed + + +@router.get("/tilesets/active/vector/{z}/{x}/{y}.pbf") +async def active_vector_tile(z: int, x: int, y: int) -> Response: + svc = _tileset_service() + tileset_dir = svc.resolve(None) + tile_path = tileset_dir / str(z) / str(x) / f"{y}.pbf" + if not tile_path.is_file(): + raise HTTPException(status_code=404, detail="tile not found") + data = tile_path.read_bytes() + headers = {} + if _is_gzipped(data): + headers["Content-Encoding"] = "gzip" + return Response(content=data, media_type="application/x-protobuf", headers=headers) diff --git a/gnss/api/state.py b/gnss/api/state.py index 007fc1d..536af58 100644 --- a/gnss/api/state.py +++ b/gnss/api/state.py @@ -3,21 +3,28 @@ from typing import Optional from ..services.gnss import GNSSService from ..services.logs import LogService from ..services.tiles import TileService +from ..services.raster_mbtiles import RasterMBTilesService from ..services.vector_mbtiles import VectorMBTilesService _gnss_service: Optional[GNSSService] = None _tile_service: Optional[TileService] = None _log_service: Optional[LogService] = None +_raster_mbtiles_service: Optional[RasterMBTilesService] = None _vector_mbtiles_service: Optional[VectorMBTilesService] = None def set_services( - gnss: GNSSService, tiles: TileService, logger: LogService, vector_mbtiles: VectorMBTilesService + gnss: GNSSService, + tiles: TileService, + logger: LogService, + raster_mbtiles: RasterMBTilesService, + vector_mbtiles: VectorMBTilesService, ) -> None: - global _gnss_service, _tile_service, _log_service, _vector_mbtiles_service + global _gnss_service, _tile_service, _log_service, _raster_mbtiles_service, _vector_mbtiles_service _gnss_service = gnss _tile_service = tiles _log_service = logger + _raster_mbtiles_service = raster_mbtiles _vector_mbtiles_service = vector_mbtiles @@ -43,3 +50,9 @@ def get_vector_mbtiles_service() -> VectorMBTilesService: if _vector_mbtiles_service is None: raise RuntimeError("Vector MBTiles service is not initialized") return _vector_mbtiles_service + + +def get_raster_mbtiles_service() -> RasterMBTilesService: + if _raster_mbtiles_service is None: + raise RuntimeError("Raster MBTiles service is not initialized") + return _raster_mbtiles_service diff --git a/gnss/config.py b/gnss/config.py index f6ed51c..0784e2c 100644 --- a/gnss/config.py +++ b/gnss/config.py @@ -1,16 +1,28 @@ +import os +import sys from pathlib import Path BASE_DIR = Path(__file__).parent -TILES_DIR = BASE_DIR / "tiles" -TILESSETS_DIR = BASE_DIR / "tilesets" + + +def _default_data_dir() -> Path: + if getattr(sys, "frozen", False): + return Path.home() / ".local" / "share" / "gnss" + return BASE_DIR + + +DATA_DIR = Path(os.getenv("GNSS_DATA_DIR", str(_default_data_dir()))).expanduser() + +TILES_DIR = DATA_DIR / "tiles" +TILESSETS_DIR = DATA_DIR / "tilesets" DEFAULT_TILESET = "default" -TILE_PLACEHOLDER = BASE_DIR / "tiles" / "placeholder.png" -UPLOAD_DIR = BASE_DIR / "uploads" +TILE_PLACEHOLDER = TILES_DIR / "placeholder.png" +UPLOAD_DIR = DATA_DIR / "uploads" GNSS_SOURCE = "simulated" GNSS_PORT = "/dev/ttyAMA0" GNSS_BAUD = 9600 GNSS_TIMEOUT = 1.0 -SIM_START_POINT = (55.75222, 37.61556) +SIM_START_POINT = (55.1644, 61.4368) SIM_PATH = "circle" # circle | rectangle # Для симуляции движения по прямоугольнику раскомментируй строку ниже: # SIM_PATH = "rectangle" diff --git a/gnss/main.py b/gnss/main.py index 2cbf561..b8d5636 100644 --- a/gnss/main.py +++ b/gnss/main.py @@ -6,10 +6,11 @@ from fastapi.middleware.cors import CORSMiddleware from . import config from .api import state -from .api.routes import config_route, health, logs, tiles, tilesets, vector_tiles, ws +from .api.routes import config_route, gnss, 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 +from .services.raster_mbtiles import RasterMBTilesService from .services.vector_mbtiles import VectorMBTilesService from .services.tilesets import TilesetService @@ -52,9 +53,16 @@ tiles_dir = tileset_service.resolve(None) placeholder_tile = Path(config.TILE_PLACEHOLDER) tile_service = TileService(tiles_dir=tiles_dir, placeholder=placeholder_tile, upload_dir=Path(config.UPLOAD_DIR)) log_service = LogService(base_dir=Path("logs")) -vector_mbtiles_service = VectorMBTilesService() +raster_mbtiles_service = RasterMBTilesService(state_file=Path(config.DATA_DIR) / "raster_mbtiles.active") +vector_mbtiles_service = VectorMBTilesService(state_file=Path(config.DATA_DIR) / "vector_mbtiles.active") gnss_service = create_gnss_service() -state.set_services(gnss=gnss_service, tiles=tile_service, logger=log_service, vector_mbtiles=vector_mbtiles_service) +state.set_services( + gnss=gnss_service, + tiles=tile_service, + logger=log_service, + raster_mbtiles=raster_mbtiles_service, + vector_mbtiles=vector_mbtiles_service, +) app = FastAPI(title="GNSS Backend", version="0.2.0") @@ -76,6 +84,9 @@ 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) app.include_router(tilesets.router) diff --git a/gnss/services/gnss.py b/gnss/services/gnss.py index 824c8e7..2e056d9 100644 --- a/gnss/services/gnss.py +++ b/gnss/services/gnss.py @@ -180,6 +180,43 @@ 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() diff --git a/gnss/services/raster_mbtiles.py b/gnss/services/raster_mbtiles.py new file mode 100644 index 0000000..f024bf4 --- /dev/null +++ b/gnss/services/raster_mbtiles.py @@ -0,0 +1,148 @@ +import sqlite3 +import threading +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional + + +class RasterMBTilesError(RuntimeError): + pass + + +@dataclass(frozen=True) +class RasterMBTilesInfo: + path: Path + metadata: dict[str, Any] + + +def _read_metadata(conn: sqlite3.Connection) -> dict[str, str]: + cur = conn.execute("SELECT name, value FROM metadata") + return {name: value for name, value in cur.fetchall()} + + +def _has_table(conn: sqlite3.Connection, table: str) -> bool: + cur = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1", + (table,), + ) + return cur.fetchone() is not None + + +def _format_to_media_type(fmt: str) -> str: + f = (fmt or "").lower() + if f in {"jpg", "jpeg"}: + return "image/jpeg" + if f == "png": + return "image/png" + if f == "webp": + return "image/webp" + return "application/octet-stream" + + +class RasterMBTilesService: + def __init__(self, state_file: Optional[Path] = None): + self._lock = threading.Lock() + self._active: Optional[RasterMBTilesInfo] = None + self._state_file = Path(state_file).expanduser().resolve() if state_file else None + self._restore() + + def _restore(self) -> None: + if not self._state_file: + return + try: + raw = self._state_file.read_text(encoding="utf-8").strip() + except FileNotFoundError: + return + except Exception: + return + if not raw: + return + try: + self.activate(Path(raw)) + except Exception: + return + + def _persist(self, info: RasterMBTilesInfo) -> None: + if not self._state_file: + return + try: + self._state_file.parent.mkdir(parents=True, exist_ok=True) + self._state_file.write_text(str(info.path), encoding="utf-8") + except Exception: + return + + def activate(self, path: Path) -> RasterMBTilesInfo: + p = Path(path).expanduser().resolve() + if not p.exists(): + raise RasterMBTilesError(f"mbtiles not found: {p}") + if p.suffix.lower() != ".mbtiles": + raise RasterMBTilesError("expected .mbtiles file") + + conn = sqlite3.connect(f"file:{p}?mode=ro", uri=True) + try: + metadata = _read_metadata(conn) + finally: + conn.close() + + fmt = (metadata.get("format") or "").lower() + if fmt in {"pbf", "mvt"}: + raise RasterMBTilesError("this is a vector mbtiles; use /tiles/vector/activate") + if fmt not in {"png", "jpg", "jpeg", "webp"}: + raise RasterMBTilesError(f"mbtiles format is '{fmt or 'unknown'}'; expected raster png/jpg/webp") + + info = RasterMBTilesInfo(path=p, metadata=metadata) + with self._lock: + self._active = info + self._persist(info) + return info + + def get_active(self) -> RasterMBTilesInfo: + with self._lock: + if self._active is None: + raise RasterMBTilesError("no active raster mbtiles; upload/activate first") + return self._active + + def get_metadata(self) -> dict[str, Any]: + info = self.get_active() + meta = dict(info.metadata) + meta["path"] = str(info.path) + meta["media_type"] = _format_to_media_type(str(info.metadata.get("format") or "")) + return meta + + def get_tile(self, z: int, x: int, y_xyz: int) -> tuple[bytes, str]: + info = self.get_active() + if z < 0: + raise RasterMBTilesError("invalid z") + max_index = (2**z) - 1 + if x < 0 or x > max_index or y_xyz < 0 or y_xyz > max_index: + raise RasterMBTilesError("invalid x/y for z") + + y_tms = max_index - y_xyz + conn = sqlite3.connect(f"file:{info.path}?mode=ro", uri=True) + try: + if _has_table(conn, "tiles"): + cur = conn.execute( + "SELECT tile_data FROM tiles WHERE zoom_level=? AND tile_column=? AND tile_row=? LIMIT 1", + (z, x, y_tms), + ) + row = cur.fetchone() + if row is None: + raise KeyError("tile not found") + data = row[0] + elif _has_table(conn, "map") and _has_table(conn, "images"): + cur = conn.execute( + "SELECT images.tile_data FROM map JOIN images ON map.tile_id = images.tile_id " + "WHERE map.zoom_level=? AND map.tile_column=? AND map.tile_row=? LIMIT 1", + (z, x, y_tms), + ) + row = cur.fetchone() + if row is None: + raise KeyError("tile not found") + data = row[0] + else: + raise RasterMBTilesError("unsupported mbtiles schema (no tiles table and no map/images tables)") + finally: + conn.close() + + fmt = str(info.metadata.get("format") or "").lower() + return data, _format_to_media_type(fmt) diff --git a/gnss/services/tiles.py b/gnss/services/tiles.py index 177aea3..51d0d6d 100644 --- a/gnss/services/tiles.py +++ b/gnss/services/tiles.py @@ -79,14 +79,31 @@ class TileService: raise TileCutError(f"gdal2tiles failed: {result.stderr or result.stdout}") return destination + def _tiles_dir_tile_path(self, zoom: int, x: int, y: int) -> tuple[Path, str] | None: + base = self.tiles_dir / str(zoom) / str(x) / f"{y}" + for ext, media_type in ( + (".png", "image/png"), + (".jpg", "image/jpeg"), + (".jpeg", "image/jpeg"), + (".webp", "image/webp"), + ): + candidate = base.with_suffix(ext) + if candidate.is_file(): + return candidate, media_type + return None + def get_tile_response(self, zoom: int, x: int, y: int): - tile_path = self.tiles_dir / str(zoom) / str(x) / f"{y}.png" - if tile_path.is_file(): - return FileResponse(tile_path, media_type="image/png") + hit = self._tiles_dir_tile_path(zoom, x, y) + if hit: + tile_path, media_type = hit + return FileResponse(tile_path, media_type=media_type) if self.placeholder.is_file(): return FileResponse(self.placeholder, media_type="image/png") return Response(content=DEFAULT_PLACEHOLDER_PNG, media_type="image/png") + def bytes_response(self, data: bytes, media_type: str): + return Response(content=data, media_type=media_type) + def get_tile_response_tms(self, zoom: int, x: int, y: int): y_converted = (2**zoom - 1) - y return self.get_tile_response(zoom, x, y_converted) diff --git a/gnss/services/vector_mbtiles.py b/gnss/services/vector_mbtiles.py index 496979b..c9031dc 100644 --- a/gnss/services/vector_mbtiles.py +++ b/gnss/services/vector_mbtiles.py @@ -29,9 +29,36 @@ def _has_table(conn: sqlite3.Connection, table: str) -> bool: class VectorMBTilesService: - def __init__(self): + def __init__(self, state_file: Optional[Path] = None): self._lock = threading.Lock() self._active: Optional[VectorMBTilesInfo] = None + self._state_file = Path(state_file).expanduser().resolve() if state_file else None + self._restore() + + def _restore(self) -> None: + if not self._state_file: + return + try: + raw = self._state_file.read_text(encoding="utf-8").strip() + except FileNotFoundError: + return + except Exception: + return + if not raw: + return + try: + self.activate(Path(raw)) + except Exception: + return + + def _persist(self, info: VectorMBTilesInfo) -> None: + if not self._state_file: + return + try: + self._state_file.parent.mkdir(parents=True, exist_ok=True) + self._state_file.write_text(str(info.path), encoding="utf-8") + except Exception: + return def activate(self, path: Path) -> VectorMBTilesInfo: p = Path(path).expanduser().resolve() @@ -55,6 +82,7 @@ class VectorMBTilesService: info = VectorMBTilesInfo(path=p, metadata=metadata) with self._lock: self._active = info + self._persist(info) return info def get_active(self) -> VectorMBTilesInfo: diff --git a/poetry.lock b/poetry.lock index f4df004..89d2213 100644 --- a/poetry.lock +++ b/poetry.lock @@ -194,6 +194,43 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "nuitka" +version = "2.8.9" +description = "Python compiler with full language support and CPython compatibility" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "nuitka-2.8.9.tar.gz", hash = "sha256:b178cd437f2110c46943b368db51d20d57d586a13f8f6323ab1be4e51e2fabf8"}, +] + +[package.dependencies] +ordered-set = ">=4.1.0" +zstandard = ">=0.15" + +[package.extras] +all = ["imageio", "setuptools (>=42)", "toml", "zstandard (>=0.15)"] +app = ["zstandard (>=0.15)"] +build-wheel = ["setuptools (>=42)", "toml", "wheel"] +icon-conversion = ["imageio"] +onefile = ["zstandard (>=0.15)"] + +[[package]] +name = "ordered-set" +version = "4.1.0" +description = "An OrderedSet is a custom MutableSet that remembers its order, so that every" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8"}, + {file = "ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562"}, +] + +[package.extras] +dev = ["black", "mypy", "pytest"] + [[package]] name = "pydantic" version = "2.12.5" @@ -852,6 +889,118 @@ files = [ {file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"}, ] +[[package]] +name = "zstandard" +version = "0.25.0" +description = "Zstandard bindings for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd"}, + {file = "zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7"}, + {file = "zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550"}, + {file = "zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d"}, + {file = "zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b"}, + {file = "zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0"}, + {file = "zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0"}, + {file = "zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd"}, + {file = "zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701"}, + {file = "zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1"}, + {file = "zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150"}, + {file = "zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab"}, + {file = "zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e"}, + {file = "zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74"}, + {file = "zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa"}, + {file = "zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e"}, + {file = "zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c"}, + {file = "zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f"}, + {file = "zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431"}, + {file = "zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a"}, + {file = "zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc"}, + {file = "zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6"}, + {file = "zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072"}, + {file = "zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277"}, + {file = "zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313"}, + {file = "zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097"}, + {file = "zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778"}, + {file = "zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065"}, + {file = "zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa"}, + {file = "zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7"}, + {file = "zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4"}, + {file = "zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2"}, + {file = "zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137"}, + {file = "zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b"}, + {file = "zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00"}, + {file = "zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64"}, + {file = "zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea"}, + {file = "zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb"}, + {file = "zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a"}, + {file = "zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902"}, + {file = "zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f"}, + {file = "zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b"}, + {file = "zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6"}, + {file = "zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91"}, + {file = "zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708"}, + {file = "zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512"}, + {file = "zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa"}, + {file = "zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd"}, + {file = "zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01"}, + {file = "zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9"}, + {file = "zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94"}, + {file = "zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1"}, + {file = "zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f"}, + {file = "zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea"}, + {file = "zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e"}, + {file = "zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551"}, + {file = "zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a"}, + {file = "zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611"}, + {file = "zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3"}, + {file = "zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b"}, + {file = "zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851"}, + {file = "zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250"}, + {file = "zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98"}, + {file = "zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf"}, + {file = "zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09"}, + {file = "zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5"}, + {file = "zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049"}, + {file = "zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3"}, + {file = "zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f"}, + {file = "zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c"}, + {file = "zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439"}, + {file = "zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043"}, + {file = "zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859"}, + {file = "zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0"}, + {file = "zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7"}, + {file = "zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2"}, + {file = "zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344"}, + {file = "zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c"}, + {file = "zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088"}, + {file = "zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12"}, + {file = "zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2"}, + {file = "zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d"}, + {file = "zstandard-0.25.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b9af1fe743828123e12b41dd8091eca1074d0c1569cc42e6e1eee98027f2bbd0"}, + {file = "zstandard-0.25.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4b14abacf83dfb5c25eb4e4a79520de9e7e205f72c9ee7702f91233ae57d33a2"}, + {file = "zstandard-0.25.0-cp39-cp39-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:a51ff14f8017338e2f2e5dab738ce1ec3b5a851f23b18c1ae1359b1eecbee6df"}, + {file = "zstandard-0.25.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3b870ce5a02d4b22286cf4944c628e0f0881b11b3f14667c1d62185a99e04f53"}, + {file = "zstandard-0.25.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:05353cef599a7b0b98baca9b068dd36810c3ef0f42bf282583f438caf6ddcee3"}, + {file = "zstandard-0.25.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19796b39075201d51d5f5f790bf849221e58b48a39a5fc74837675d8bafc7362"}, + {file = "zstandard-0.25.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:53e08b2445a6bc241261fea89d065536f00a581f02535f8122eba42db9375530"}, + {file = "zstandard-0.25.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1f3689581a72eaba9131b1d9bdbfe520ccd169999219b41000ede2fca5c1bfdb"}, + {file = "zstandard-0.25.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d8c56bb4e6c795fc77d74d8e8b80846e1fb8292fc0b5060cd8131d522974b751"}, + {file = "zstandard-0.25.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:53f94448fe5b10ee75d246497168e5825135d54325458c4bfffbaafabcc0a577"}, + {file = "zstandard-0.25.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c2ba942c94e0691467ab901fc51b6f2085ff48f2eea77b1a48240f011e8247c7"}, + {file = "zstandard-0.25.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:07b527a69c1e1c8b5ab1ab14e2afe0675614a09182213f21a0717b62027b5936"}, + {file = "zstandard-0.25.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:51526324f1b23229001eb3735bc8c94f9c578b1bd9e867a0a646a3b17109f388"}, + {file = "zstandard-0.25.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89c4b48479a43f820b749df49cd7ba2dbc2b1b78560ecb5ab52985574fd40b27"}, + {file = "zstandard-0.25.0-cp39-cp39-win32.whl", hash = "sha256:1cd5da4d8e8ee0e88be976c294db744773459d51bb32f707a0f166e5ad5c8649"}, + {file = "zstandard-0.25.0-cp39-cp39-win_amd64.whl", hash = "sha256:37daddd452c0ffb65da00620afb8e17abd4adaae6ce6310702841760c2c26860"}, + {file = "zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b"}, +] + +[package.extras] +cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""] + [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0" diff --git a/pyproject.toml b/pyproject.toml index 8853679..c3e167c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,14 @@ uvicorn = {extras = ["standard"], version = ">=0.30.0,<0.31.0"} python-multipart = ">=0.0.21,<0.0.22" gdal2tiles = "^0.1.9" +[tool.poetry.scripts] +gnss-backend = "gnss.__main__:main" + [build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" + +[dependency-groups] +dev = [ + "nuitka (>=2.8.9,<3.0.0)" +]