fix interface

This commit is contained in:
parent 042d0f8fa7
commit f61801e855
7 changed files with 287 additions and 15 deletions

View file

@ -32,6 +32,10 @@ export default function App() {
const [track, setTrack] = useState<TrackPoint[]>([]);
const wsRef = useRef<WebSocket | null>(null);
const [mapMode, setMapMode] = useState<MapMode>("raster");
const [gnssMode, setGnssMode] = useState<"simulated" | "serial" | "unknown">("unknown");
const [simCenterLat, setSimCenterLat] = useState<string>("");
const [simCenterLon, setSimCenterLon] = useState<string>("");
const [vectorSource, setVectorSource] = useState<"mbtiles" | "tileset">("mbtiles");
const [tilesets, setTilesets] = useState<string[]>([]);
const [activeTileset, setActiveTileset] = useState<string>("");
const [selectedFileName, setSelectedFileName] = useState<string>("");
@ -70,6 +74,21 @@ export default function App() {
});
}, [sendFrontLog]);
const refreshGnssState = useCallback(async () => {
const url = config?.gnss_state_url ? `${backendBase}${config.gnss_state_url}` : `${backendBase}/gnss/state`;
const res = await fetch(url, { cache: "no-store" });
const data = await res.json();
if (!res.ok) throw new Error(data?.detail || `status ${res.status}`);
const mode = (data?.gnss?.mode as string | undefined) || "unknown";
if (mode === "simulated" || mode === "serial") {
setGnssMode(mode);
} else {
setGnssMode("unknown");
}
if (typeof data?.gnss?.lat === "number") setSimCenterLat(String(data.gnss.lat));
if (typeof data?.gnss?.lon === "number") setSimCenterLon(String(data.gnss.lon));
}, [config]);
const refreshTilesets = useCallback(async () => {
const url = config?.tilesets_url ? `${backendBase}${config.tilesets_url}` : `${backendBase}/tilesets`;
const res = await fetch(url, { cache: "no-store" });
@ -82,7 +101,54 @@ export default function App() {
useEffect(() => {
if (!config) return;
refreshTilesets().catch((err) => sendFrontLog(`tilesets refresh failed: ${err}`, "warn"));
}, [config, refreshTilesets, sendFrontLog]);
refreshGnssState().catch((err) => sendFrontLog(`gnss refresh failed: ${err}`, "warn"));
}, [config, refreshGnssState, refreshTilesets, sendFrontLog]);
useEffect(() => {
if (!config?.gnss_source) return;
if (config.gnss_source === "simulated" || config.gnss_source === "serial") setGnssMode(config.gnss_source);
}, [config]);
const setGnssModeRequest = useCallback(
async (mode: "simulated" | "serial") => {
const url = config?.gnss_mode_url ? `${backendBase}${config.gnss_mode_url}` : `${backendBase}/gnss/mode`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.detail || `status ${res.status}`);
sendFrontLog(`gnss mode set: ${mode}`, "info");
setGnssMode(mode);
setConfig((prev) => (prev ? { ...prev, gnss_source: mode } : prev));
if (mode === "simulated") {
if (typeof data?.gnss?.lat === "number") setSimCenterLat(String(data.gnss.lat));
if (typeof data?.gnss?.lon === "number") setSimCenterLon(String(data.gnss.lon));
}
},
[config, sendFrontLog],
);
const applySimCenter = useCallback(async () => {
const lat = Number(simCenterLat);
const lon = Number(simCenterLon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
sendFrontLog("invalid simulated center coordinates", "warn");
return;
}
const url = config?.gnss_sim_center_url
? `${backendBase}${config.gnss_sim_center_url}`
: `${backendBase}/gnss/sim/center`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lat, lon }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.detail || `status ${res.status}`);
sendFrontLog(`sim center updated: ${lat}, ${lon}`, "info");
}, [config, sendFrontLog, simCenterLat, simCenterLon]);
const activateTileset = useCallback(
async (name: string) => {
@ -171,25 +237,45 @@ 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 = useCallback(
const ingestMap = useCallback(
async (path: string) => {
const res = await fetch(`${backendBase}/tiles/vector/activate`, {
const res = await fetch(`${backendBase}/maps/ingest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
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}`);
sendFrontLog(`vector mbtiles activated: ${path}`, "info");
setMapMode("vector");
if (data.kind === "vector") {
setVectorSource("mbtiles");
setMapMode("vector");
} else if (data.kind === "raster") {
setMapMode("raster");
}
sendFrontLog(`map ingested: kind=${data.kind} driver=${data.driver}`, "info");
return data;
},
[sendFrontLog],
[sendFrontLog, sliceParams.max_zoom, sliceParams.min_zoom, sliceParams.tileset],
);
const uploadFileBlob = useCallback(
@ -218,9 +304,7 @@ export default function App() {
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");
});
ingestMap(res.path).catch((err) => sendFrontLog(`ingest failed: ${err}`, "warn"));
}
})
.catch((err) => {
@ -228,7 +312,7 @@ export default function App() {
sendFrontLog(`upload error: ${err}`, "error");
});
},
[activateVectorMbtiles, sendFrontLog],
[ingestMap, sendFrontLog],
);
const triggerFileSelect = useCallback(async () => {
@ -345,6 +429,16 @@ export default function App() {
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"))}

View file

@ -9,6 +9,16 @@ type SettingsPanelProps = {
status: string;
theme: "dark" | "light";
onToggleTheme: () => void;
gnssMode: "simulated" | "serial" | "unknown";
simCenterLat: string;
simCenterLon: string;
onGnssRefresh: () => void;
onGnssModeChange: (mode: "simulated" | "serial") => void;
onSimCenterLatChange: (value: string) => void;
onSimCenterLonChange: (value: string) => void;
onSimCenterApply: () => void;
vectorSource: "mbtiles" | "tileset";
onVectorSourceChange: (source: "mbtiles" | "tileset") => void;
tilesets: string[];
activeTileset: string;
onActivateTileset: (name: string) => void;
@ -32,6 +42,16 @@ export const SettingsPanel = ({
status,
theme,
onToggleTheme,
gnssMode,
simCenterLat,
simCenterLon,
onGnssRefresh,
onGnssModeChange,
onSimCenterLatChange,
onSimCenterLonChange,
onSimCenterApply,
vectorSource,
onVectorSourceChange,
tilesets,
activeTileset,
onActivateTileset,
@ -74,6 +94,44 @@ export const SettingsPanel = ({
</div>
</div>
<div className="panel-header">
<div>
<div className="panel-title">GNSS</div>
<div className="panel-subtitle">Режим и точка симуляции</div>
</div>
<button type="button" className="btn ghost small" onClick={onGnssRefresh}>
Обновить
</button>
</div>
<label className="field">
<span>Режим</span>
<select
value={gnssMode}
onChange={(e) => onGnssModeChange(e.target.value as "simulated" | "serial")}
disabled={gnssMode === "unknown"}
>
<option value="simulated">Симуляция</option>
<option value="serial">Реальный (Serial NMEA)</option>
</select>
</label>
{gnssMode === "simulated" && (
<div className="slice-grid">
<label className="field">
<span>Lat</span>
<input value={simCenterLat} onChange={(e) => onSimCenterLatChange(e.target.value)} />
</label>
<label className="field">
<span>Lon</span>
<input value={simCenterLon} onChange={(e) => onSimCenterLonChange(e.target.value)} />
</label>
<button type="button" className="btn ghost" onClick={onSimCenterApply}>
Применить
</button>
</div>
)}
<div className="panel-header">
<div>
<div className="panel-title">Тема</div>
@ -103,6 +161,14 @@ export const SettingsPanel = ({
</button>
</div>
<label className="field">
<span>Векторный источник</span>
<select value={vectorSource} onChange={(e) => onVectorSourceChange(e.target.value as "mbtiles" | "tileset")}>
<option value="mbtiles">MBTiles (/tiles/vector)</option>
<option value="tileset">Tileset папка (/tilesets/active/vector)</option>
</select>
</label>
<div className="panel-header">
<div>
<div className="panel-title">Нарезка тайлов</div>

View file

@ -27,10 +27,15 @@ export type AppConfig = {
tiles_url_tms?: string;
vector_tiles_url?: string;
vector_metadata_url?: string;
vector_tileset_url?: string;
vector_tileset_metadata_url?: string;
tilesets_url?: string;
tilesets_activate_url?: string;
ws_url: string;
gnss_source: string;
gnss_state_url?: string;
gnss_mode_url?: string;
gnss_sim_center_url?: string;
};
export type TrackPoint = {

View file

@ -1,12 +1,17 @@
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",
@ -17,5 +22,8 @@ async def get_config() -> dict:
"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",
}

61
gnss/api/routes/gnss.py Normal file
View file

@ -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()}

View file

@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
from . import config
from .api import state
from .api.routes import config_route, health, logs, maps, raster_mbtiles, 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
@ -84,6 +84,7 @@ 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)

View file

@ -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()