From ae1229d550939bd9ea0d828121bd897dde92f7cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A0=D0=98=D0=BB=D1=8C=D1=8F=20=D0=A8=D0=B0=D0=BB=D0=BE?= =?UTF-8?q?=D0=BD=D0=B8=D0=BD?= Date: Fri, 19 Dec 2025 13:24:56 +0500 Subject: [PATCH 1/2] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F=20=D0=B4=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D0=B9=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/index.html | 4 +- frontend/public/logo.svg | 4 + frontend/public/menu.svg | 5 + frontend/public/tauri.svg | 6 - frontend/public/vite.svg | 1 - frontend/src/App.css | 322 ++++++-- frontend/src/App.tsx | 850 ++++++---------------- frontend/src/components/MapPanel.tsx | 60 ++ frontend/src/components/SettingsPanel.tsx | 293 ++++++++ frontend/src/components/Topbar.tsx | 21 + frontend/src/components/TrackSummary.tsx | 20 + frontend/src/hooks/useVectorMap.ts | 202 +++++ frontend/src/types.ts | 54 ++ frontend/src/utils/theme.ts | 10 + frontend/src/utils/time.ts | 8 + 15 files changed, 1170 insertions(+), 690 deletions(-) create mode 100644 frontend/public/logo.svg create mode 100644 frontend/public/menu.svg delete mode 100644 frontend/public/tauri.svg delete mode 100644 frontend/public/vite.svg create mode 100644 frontend/src/components/MapPanel.tsx create mode 100644 frontend/src/components/SettingsPanel.tsx create mode 100644 frontend/src/components/Topbar.tsx create mode 100644 frontend/src/components/TrackSummary.tsx create mode 100644 frontend/src/hooks/useVectorMap.ts create mode 100644 frontend/src/types.ts create mode 100644 frontend/src/utils/theme.ts create mode 100644 frontend/src/utils/time.ts 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..68e9169 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,36 +1,91 @@ :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; + background: var(--bg); + color: var(--text); display: flex; flex-direction: column; + transition: background 0.2s ease, color 0.2s ease; +} + +.content { + width: 100%; + max-width: 1200px; + margin: 0 auto; + display: flex; + flex-direction: column; + overflow-y: hidden; } .topbar { display: flex; justify-content: space-between; + border-radius: 0 0 14px 14px; align-items: center; padding: 18px 22px; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); + border-bottom: 1px solid var(--glass-border); backdrop-filter: blur(10px); - background: rgba(255, 255, 255, 0.04); + background: var(--glass); } .brand { @@ -44,13 +99,11 @@ body, #root { 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 { @@ -71,24 +124,55 @@ body, #root { } .subtitle { - color: #94a3b8; + color: var(--muted); font-size: 13px; } +.top-actions { + display: flex; + gap: 10px; + align-items: center; +} + +.mini-info { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + padding: 0 12px; +} + +.chip { + padding: 10px 12px; + border-radius: 12px; + background: var(--surface-strong); + border: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 4px; +} + +.chip span { + font-size: 13px; + color: var(--muted); +} + +.chip strong { + font-size: 18px; +} + .pill { display: inline-flex; gap: 6px; 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,20 +180,12 @@ 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 { @@ -121,12 +197,12 @@ body, #root { .card { padding: 12px; 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); } .card-label { - color: #94a3b8; + color: var(--muted); font-size: 13px; margin-bottom: 4px; } @@ -134,38 +210,40 @@ body, #root { .card-value { font-size: 20px; font-weight: 700; + color: var(--text); } .map-panel { - padding: 0; + padding: 12px; + display: flex; + flex-direction: column; + gap: 12px; } .map { height: 70vh; border-radius: 12px; overflow: hidden; + border: 1px solid var(--border); + width: 100%; } .maplibre { height: 70vh; border-radius: 12px; overflow: hidden; -} - -.map-switch { - display: flex; - gap: 10px; - padding: 12px; + border: 1px solid var(--border); + 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 +256,7 @@ body, #root { display: flex; justify-content: space-between; align-items: center; + padding-bottom: 14px; } .panel-title { @@ -186,15 +265,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 +290,43 @@ body, #root { } .slice-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + display: flex; + flex-direction: column; gap: 12px; } .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 +339,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 +356,50 @@ 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; +} + +.field.actions { + grid-template-columns: 180px 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 +408,81 @@ 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); +.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 18px 18px; + 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..74ff06b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,167 +1,62 @@ -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 { Topbar } from "./components/Topbar"; +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 [tilesets, setTilesets] = useState([]); const [activeTileset, setActiveTileset] = 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 +66,58 @@ 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 refreshTilesets = useCallback(async () => { const url = config?.tilesets_url ? `${backendBase}${config.tilesets_url}` : `${backendBase}/tilesets`; const res = await fetch(url, { cache: "no-store" }); const data = await res.json(); if (!res.ok) throw new Error(data?.detail || `status ${res.status}`); setTilesets(data.tilesets || []); setActiveTileset(data.active || ""); - }; + }, [config]); useEffect(() => { if (!config) return; refreshTilesets().catch((err) => sendFrontLog(`tilesets refresh failed: ${err}`, "warn")); - }, [config]); + }, [config, refreshTilesets, sendFrontLog]); - const activateTileset = 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 activateTileset = useCallback( + async (name: string) => { + const url = config?.tilesets_activate_url + ? `${backendBase}${config.tilesets_activate_url}` + : `${backendBase}/tilesets/activate`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.detail || `status ${res.status}`); + setActiveTileset(data.active || name); + sendFrontLog(`tileset activated: ${data.active || name}`, "info"); + }, + [config, sendFrontLog], + ); - const createTileset = 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 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 +131,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 +155,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], ); @@ -276,457 +177,178 @@ export default function App() { const vectorMetaUrl = config && `${backendBase.replace(/\/+$/, "")}${config.vector_metadata_url || "/tiles/vector/metadata"}`; - const activateVectorMbtiles = async (path: string) => { - const res = await fetch(`${backendBase}/tiles/vector/activate`, { + const activateVectorMbtiles = useCallback( + async (path: string) => { + const res = await fetch(`${backendBase}/tiles/vector/activate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.detail || `status ${res.status}`); + sendFrontLog(`vector mbtiles activated: ${path}`, "info"); + setMapMode("vector"); + return data; + }, + [sendFrontLog], + ); + + const uploadFileBlob = useCallback( + async (file: File) => { + setSliceStatus("uploading"); + const form = new FormData(); + form.append("file", file); + return fetch(`${backendBase}/tiles/upload`, { + method: "POST", + body: form, + }) + .then((r) => (r.ok ? r.json() : r.json().then((j) => Promise.reject(j.detail || r.status)))) + .then((res) => { + setSliceParams((p) => ({ ...p, source: res.path })); + setSelectedFileName(res.filename); + setSliceParams((p) => { + if (p.tileset) return p; + const proposed = String(res.filename || "") + .replace(/\.[^.]+$/, "") + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 64); + return proposed ? { ...p, tileset: proposed } : p; + }); + setSliceStatus("idle"); + sendFrontLog(`uploaded source ${res.filename}`, "info"); + if (res.filename?.toLowerCase?.().endsWith(".mbtiles")) { + activateVectorMbtiles(res.path).catch((err) => { + sendFrontLog(`vector activate failed: ${err}`, "warn"); + }); + } + }) + .catch((err) => { + setSliceStatus("error"); + sendFrontLog(`upload error: ${err}`, "error"); + }); + }, + [activateVectorMbtiles, sendFrontLog], + ); + + const triggerFileSelect = useCallback(async () => { + try { + if ((window as any).__TAURI__) { + const { open } = await import("@tauri-apps/plugin-dialog"); + 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) { + 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} -
-
+ setSettingsOpen(true)} />
-
-
-
-
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"))} + mapMode={mapMode} + onMapModeChange={setMapMode} + 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..cecd936 --- /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..09e1890 --- /dev/null +++ b/frontend/src/components/SettingsPanel.tsx @@ -0,0 +1,293 @@ +import type { MutableRefObject, RefObject } from "react"; +import { FormEvent } from "react"; +import type { AppConfig, MapMode, SliceParams } from "../types"; + +type SettingsPanelProps = { + open: boolean; + onClose: () => void; + config: AppConfig | null; + status: string; + theme: "dark" | "light"; + onToggleTheme: () => void; + mapMode: MapMode; + onMapModeChange: (mode: MapMode) => 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, + mapMode, + onMapModeChange, + tilesets, + activeTileset, + onActivateTileset, + onRefreshTilesets, + onCreateTileset, + sliceParams, + onSliceParamsChange, + sliceStatus, + onSliceSubmit, + onSliceReset, + selectedFileName, + onTriggerFileSelect, + onFileSelected, + fileInputRef, +}: SettingsPanelProps) => ( + <> +
+ + +); diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx new file mode 100644 index 0000000..8a79920 --- /dev/null +++ b/frontend/src/components/Topbar.tsx @@ -0,0 +1,21 @@ +type TopbarProps = { + status: string; + onOpenSettings: () => void; +}; + +export const Topbar = ({ status, onOpenSettings }: TopbarProps) => ( +
+
+
+
+
GoAgro
+
Тестовая версия
+
+
+
+ +
+
+); diff --git a/frontend/src/components/TrackSummary.tsx b/frontend/src/components/TrackSummary.tsx new file mode 100644 index 0000000..24a417c --- /dev/null +++ b/frontend/src/components/TrackSummary.tsx @@ -0,0 +1,20 @@ +import { formatTime } from "../utils/time"; +import type { TrackPoint } from "../types"; + +type TrackSummaryProps = { + lastPoint: TrackPoint | null; + pointsCount: number; +}; + +export const TrackSummary = ({ lastPoint, pointsCount }: 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/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..9d1bcc2 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,54 @@ +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; + tilesets_url?: string; + tilesets_activate_url?: string; + ws_url: string; + gnss_source: 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; + } +} From 3d65d2b7e8550923f185863570039a920b4f11d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A0=D0=98=D0=BB=D1=8C=D1=8F=20=D0=A8=D0=B0=D0=BB=D0=BE?= =?UTF-8?q?=D0=BD=D0=B8=D0=BD?= Date: Fri, 19 Dec 2025 14:11:32 +0500 Subject: [PATCH 2/2] =?UTF-8?q?=D0=9A=D1=80=D1=83=D1=82=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=B4=D0=B8=D0=B7=D0=B0=D0=B9=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.css | 187 +++++----- frontend/src/App.tsx | 25 +- frontend/src/components/SettingsPanel.tsx | 409 +++++++++++----------- frontend/src/components/Topbar.tsx | 21 -- frontend/src/components/TrackSummary.tsx | 13 +- 5 files changed, 326 insertions(+), 329 deletions(-) delete mode 100644 frontend/src/components/Topbar.tsx diff --git a/frontend/src/App.css b/frontend/src/App.css index 68e9169..697fb92 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -61,6 +61,7 @@ body, #root { .layout { min-height: 100vh; + height: 100vh; background: var(--bg); color: var(--text); display: flex; @@ -70,94 +71,51 @@ body, #root { .content { width: 100%; - max-width: 1200px; - margin: 0 auto; + max-width: none; + margin: 0; display: flex; flex-direction: column; - overflow-y: hidden; + overflow: hidden; + flex: 1 1 auto; + min-height: 0; } -.topbar { - display: flex; - justify-content: space-between; - border-radius: 0 0 14px 14px; - align-items: center; - padding: 18px 22px; - border-bottom: 1px solid var(--glass-border); - backdrop-filter: blur(10px); - background: var(--glass); -} - -.brand { - display: flex; - gap: 12px; - align-items: center; -} - -.pulse { - width: 12px; - height: 12px; - border-radius: 50%; - background: #f59e0b; - animation: pulse 2.4s infinite; -} - -.pulse.ok { - background: #34d399; -} - -@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: var(--muted); - font-size: 13px; -} - -.top-actions { - display: flex; - gap: 10px; - align-items: center; +.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; - gap: 10px; - padding: 0 12px; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; + padding: 0; +} + +.mini-info.compact { + align-items: stretch; } .chip { - padding: 10px 12px; - border-radius: 12px; + padding: 4px 10px; + border-radius: 10px; background: var(--surface-strong); - border: 1px solid var(--border); display: flex; flex-direction: column; - gap: 4px; + gap: 2px; +} +.leaflet-container{ + height: 100% !important; +} +.chip.small span { + font-size: 12px; } -.chip span { - font-size: 13px; - color: var(--muted); -} - -.chip strong { - font-size: 18px; +.chip.small strong { + font-size: 16px; } .pill { @@ -190,15 +148,21 @@ body, #root { .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: var(--surface-strong); border: 1px solid var(--border); + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; } .card-label { @@ -213,26 +177,38 @@ body, #root { color: var(--text); } +.map-shell { + flex: 1 1 auto; + min-height: 0; + padding: 0; +} + .map-panel { - padding: 12px; + padding: 0; display: flex; flex-direction: column; - gap: 12px; + 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: 1px solid var(--border); + border: none; width: 100%; } .maplibre { - height: 70vh; - border-radius: 12px; + height: 100%; + min-height: 0; + border-radius: 0; overflow: hidden; - border: 1px solid var(--border); + border: none; width: 100%; } @@ -256,7 +232,8 @@ body, #root { display: flex; justify-content: space-between; align-items: center; - padding-bottom: 14px; + padding: 32px 0; + border-top: 1px solid var(--border); } .panel-title { @@ -293,6 +270,7 @@ body, #root { display: flex; flex-direction: column; gap: 12px; + padding-bottom: 80px; } .field { @@ -369,10 +347,11 @@ body, #root { gap: 10px; flex-wrap: wrap; align-items: center; + justify-content: end; } .field.actions { - grid-template-columns: 180px 1fr; + grid-template-columns: 1fr 1fr; background: var(--surface); } @@ -429,6 +408,44 @@ body, #root { border-color: var(--btn-ghost-border); } +.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; @@ -476,7 +493,7 @@ body, #root { .settings-content { overflow: auto; - padding: 0 18px 18px; + padding: 0; display: flex; flex-direction: column; gap: 12px; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 74ff06b..d389fdb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,7 +5,6 @@ import "maplibre-gl/dist/maplibre-gl.css"; import { MapPanel } from "./components/MapPanel"; import { SettingsPanel } from "./components/SettingsPanel"; -import { Topbar } from "./components/Topbar"; import { TrackSummary } from "./components/TrackSummary"; import { useVectorMap } from "./hooks/useVectorMap"; import { getInitialTheme } from "./utils/theme"; @@ -309,11 +308,12 @@ export default function App() { return (
- setSettingsOpen(true)} /> +
+ +
-
-
- +
+
+
+ + +
+ setSettingsOpen(false)} @@ -332,8 +345,6 @@ export default function App() { status={status} theme={theme} onToggleTheme={() => setTheme((t) => (t === "dark" ? "light" : "dark"))} - mapMode={mapMode} - onMapModeChange={setMapMode} tilesets={tilesets} activeTileset={activeTileset} onActivateTileset={(name) => activateTileset(name).catch((err) => sendFrontLog(`${err}`, "error"))} diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 09e1890..24852df 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -1,6 +1,6 @@ import type { MutableRefObject, RefObject } from "react"; import { FormEvent } from "react"; -import type { AppConfig, MapMode, SliceParams } from "../types"; +import type { AppConfig, SliceParams } from "../types"; type SettingsPanelProps = { open: boolean; @@ -9,8 +9,6 @@ type SettingsPanelProps = { status: string; theme: "dark" | "light"; onToggleTheme: () => void; - mapMode: MapMode; - onMapModeChange: (mode: MapMode) => void; tilesets: string[]; activeTileset: string; onActivateTileset: (name: string) => void; @@ -34,8 +32,6 @@ export const SettingsPanel = ({ status, theme, onToggleTheme, - mapMode, - onMapModeChange, tilesets, activeTileset, onActivateTileset, @@ -59,9 +55,6 @@ export const SettingsPanel = ({
Настройки
Управление источниками и нарезкой
-
@@ -80,213 +73,205 @@ export const SettingsPanel = ({
{status}
+ +
+
+
Тема
+
Светлая или тёмная
+
+ +
+ +
+
+
Наборы тайлов
+
Активация и обновление
+
+
+
+ + +
+ +
+
+
Нарезка тайлов
+
Запускает gdal2tiles на бэкенде
+
+
+ {sliceStatus} +
+
+
{ + event.preventDefault(); + onSliceSubmit(event); + }} + > + + + + + + + + + + + + + + + + + +
+ + +
+
+
-
-
-
-
Тема
-
Светлая или тёмная
-
- +
+
- -
- -
-
-
-
Наборы тайлов
-
Активация и обновление
-
-
-
- - -
-
- -
-
-
-
Нарезка тайлов
-
Запускает gdal2tiles на бэкенде
-
-
- {sliceStatus} -
-
-
{ - event.preventDefault(); - onSliceSubmit(event); - }} - > - - - - - - - - - - - - - - - - -
- Действия -
- - -
-
-
-
diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx deleted file mode 100644 index 8a79920..0000000 --- a/frontend/src/components/Topbar.tsx +++ /dev/null @@ -1,21 +0,0 @@ -type TopbarProps = { - status: string; - onOpenSettings: () => void; -}; - -export const Topbar = ({ status, onOpenSettings }: TopbarProps) => ( -
-
-
-
-
GoAgro
-
Тестовая версия
-
-
-
- -
-
-); diff --git a/frontend/src/components/TrackSummary.tsx b/frontend/src/components/TrackSummary.tsx index 24a417c..a2f1e85 100644 --- a/frontend/src/components/TrackSummary.tsx +++ b/frontend/src/components/TrackSummary.tsx @@ -4,17 +4,22 @@ import type { TrackPoint } from "../types"; type TrackSummaryProps = { lastPoint: TrackPoint | null; pointsCount: number; + compact?: boolean; }; -export const TrackSummary = ({ lastPoint, pointsCount }: TrackSummaryProps) => ( -
-
+export const TrackSummary = ({ lastPoint, pointsCount, compact }: TrackSummaryProps) => ( +
+
Последняя фиксация {formatTime(lastPoint?.timestamp)}
-
+
Точек трека {pointsCount}
+
+ Статус + ок +
);