fix interface
This commit is contained in:
parent
042d0f8fa7
commit
f61801e855
7 changed files with 287 additions and 15 deletions
|
|
@ -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");
|
||||
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"))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
61
gnss/api/routes/gnss.py
Normal 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()}
|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue