Update models tiles and directories for source

This commit is contained in:
parent 870b3f4e42
commit aa36dea08e
13 changed files with 396 additions and 14 deletions

1
.gitignore vendored
View file

@ -244,6 +244,7 @@ pyrightconfig.json
# Runtime-generated data (do not commit)
logs/
gnss/tiles/
gnss/tilesets/
gnss/uploads/
# Frontend artifacts

View file

@ -236,6 +236,16 @@ body, #root {
color: #f4f7fb;
}
.tileset-row {
display: flex;
gap: 10px;
align-items: center;
}
.tileset-row select {
flex: 1;
}
.file-row {
display: flex;
gap: 8px;

View file

@ -30,6 +30,8 @@ type AppConfig = {
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;
};
@ -77,11 +79,14 @@ export default function App() {
const [track, setTrack] = useState<TrackPoint[]>([]);
const wsRef = useRef<WebSocket | null>(null);
const [mapMode, setMapMode] = useState<"raster" | "vector">("raster");
const [tilesets, setTilesets] = useState<string[]>([]);
const [activeTileset, setActiveTileset] = useState<string>("");
const [selectedFileName, setSelectedFileName] = useState<string>("");
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [sliceParams, setSliceParams] = useState({
source: "",
destination: "",
tileset: "",
min_zoom: 10,
max_zoom: 16,
tile_format: "png",
@ -104,6 +109,16 @@ export default function App() {
.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")) {
@ -151,7 +166,10 @@ export default function App() {
useEffect(() => {
fetch(`${backendBase}/config`, { cache: "no-store" })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`))))
.then(setConfig)
.then((cfg) => {
setConfig(cfg);
return cfg;
})
.catch((err) => {
console.error("Config error", err);
sendFrontLog(`config fetch failed: ${err}`, "error");
@ -159,6 +177,46 @@ export default function App() {
});
}, []);
const refreshTilesets = async () => {
const url = config?.tilesets_url ? `${backendBase}${config.tilesets_url}` : `${backendBase}/tilesets`;
const res = await fetch(url, { cache: "no-store" });
const data = await res.json();
if (!res.ok) throw new Error(data?.detail || `status ${res.status}`);
setTilesets(data.tilesets || []);
setActiveTileset(data.active || "");
};
useEffect(() => {
if (!config) return;
refreshTilesets().catch((err) => sendFrontLog(`tilesets refresh failed: ${err}`, "warn"));
}, [config]);
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 createTileset = async (name: string) => {
const res = await fetch(`${backendBase}/tilesets`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.detail || `status ${res.status}`);
await refreshTilesets();
setSliceParams((p) => ({ ...p, tileset: data.name || name }));
sendFrontLog(`tileset created: ${data.name || name}`, "info");
};
useEffect(() => {
if (!config) return;
const base = backendBase.replace(/\/+$/, "");
@ -410,6 +468,26 @@ export default function App() {
<div className="card-label">GNSS source</div>
<div className="card-value">{config?.gnss_source ?? "—"}</div>
</div>
<div className="card">
<div className="card-label">Tileset</div>
<div className="card-value">
<div className="tileset-row">
<select
value={activeTileset}
onChange={(e) => activateTileset(e.target.value).catch((err) => sendFrontLog(`${err}`, "error"))}
>
{tilesets.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<button type="button" className="btn ghost" onClick={() => refreshTilesets().catch(() => {})}>
Обновить
</button>
</div>
</div>
</div>
<div className="card">
<div className="card-label">Last fix</div>
<div className="card-value">{formatTime(lastPoint?.timestamp)}</div>
@ -479,6 +557,7 @@ export default function App() {
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,
@ -490,6 +569,9 @@ export default function App() {
.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");
@ -552,11 +634,40 @@ export default function App() {
<option value="xyz">Папка XYZ</option>
</select>
</label>
<label className="field">
<span>Tileset (имя папки)</span>
<div className="file-row">
<input
type="text"
list="tilesets-list"
placeholder="например: moscow (пусто → активный)"
value={sliceParams.tileset}
onChange={(e) => setSliceParams((p) => ({ ...p, tileset: e.target.value }))}
/>
<button
type="button"
className="btn ghost"
onClick={() => {
const name = sliceParams.tileset.trim();
if (!name) return;
createTileset(name).catch((err) => sendFrontLog(`tileset create failed: ${err}`, "error"));
}}
>
Создать
</button>
</div>
<datalist id="tilesets-list">
{tilesets.map((t) => (
<option key={t} value={t} />
))}
</datalist>
<small className="muted">Активный: {activeTileset || "—"}</small>
</label>
<label className="field">
<span>Папка тайлов (опц.)</span>
<input
type="text"
placeholder="пусто → gnss/tiles"
placeholder="лучше не трогать (по умолчанию tileset)"
value={sliceParams.destination}
onChange={(e) => setSliceParams((p) => ({ ...p, destination: e.target.value }))}
/>
@ -601,6 +712,7 @@ export default function App() {
setSliceParams({
source: "",
destination: "",
tileset: "",
min_zoom: 10,
max_zoom: 16,
tile_format: "png",

View file

@ -12,6 +12,8 @@ async def get_config() -> dict:
"tiles_url_tms": "/tiles/tms/{z}/{x}/{y}.png",
"vector_tiles_url": "/tiles/vector/{z}/{x}/{y}.pbf",
"vector_metadata_url": "/tiles/vector/metadata",
"tilesets_url": "/tilesets",
"tilesets_activate_url": "/tilesets/activate",
"ws_url": "/ws/track",
"gnss_source": config.GNSS_SOURCE,
}

View file

@ -7,6 +7,7 @@ from ...api.dependencies import get_tile_service
from ...models.tiles import TileSliceRequest
from ...services.tiles import TileCutError, TileService
from ... import config
from ...services.tilesets import TilesetService, TilesetError
router = APIRouter(tags=["tiles"])
@ -24,7 +25,16 @@ async def serve_tile_tms(zoom: int, x: int, y: int, tile_service: TileService =
@router.post("/tiles/slice")
async def slice_tiles(payload: TileSliceRequest, tile_service: TileService = Depends(get_tile_service)) -> dict:
source = payload.source_path()
destination = payload.destination_path(tile_service.tiles_dir)
tileset_svc = TilesetService(tilesets_dir=config.TILESSETS_DIR, default_tileset=config.DEFAULT_TILESET)
tileset_svc.ensure()
tileset_name = payload.tileset_name()
if tileset_name:
try:
tileset_svc.create(tileset_name)
except TilesetError as exc:
raise HTTPException(status_code=400, detail=str(exc))
destination_default = tileset_svc.resolve(tileset_name)
destination = payload.destination_path(destination_default)
try:
if payload.source_format in {"tms", "xyz"}:
src = tile_service.unpack_if_archive(source)
@ -43,7 +53,13 @@ async def slice_tiles(payload: TileSliceRequest, tile_service: TileService = Dep
)
except TileCutError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"status": "ok", "tiles_dir": str(destination), "source": str(source), "scheme": payload.scheme}
return {
"status": "ok",
"tiles_dir": str(destination),
"source": str(source),
"scheme": payload.scheme,
"tileset": tileset_name or tileset_svc.get_active(),
}
@router.post("/tiles/upload")

View file

@ -0,0 +1,49 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from ..dependencies import get_tile_service
from ... import config
from ...services.tiles import TileService
from ...services.tilesets import TilesetError, TilesetService
router = APIRouter(tags=["tilesets"])
def _tileset_service() -> TilesetService:
return TilesetService(tilesets_dir=config.TILESSETS_DIR, default_tileset=config.DEFAULT_TILESET)
class CreateTileset(BaseModel):
name: str
class ActivateTileset(BaseModel):
name: str
@router.get("/tilesets")
async def list_tilesets() -> dict:
svc = _tileset_service()
return {"tilesets": svc.list_tilesets(), "active": svc.get_active()}
@router.post("/tilesets")
async def create_tileset(payload: CreateTileset) -> dict:
svc = _tileset_service()
try:
name = svc.create(payload.name)
except TilesetError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"status": "ok", "name": name}
@router.post("/tilesets/activate")
async def activate_tileset(payload: ActivateTileset, tile_service: TileService = Depends(get_tile_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}

View file

@ -2,6 +2,8 @@ from pathlib import Path
BASE_DIR = Path(__file__).parent
TILES_DIR = BASE_DIR / "tiles"
TILESSETS_DIR = BASE_DIR / "tilesets"
DEFAULT_TILESET = "default"
TILE_PLACEHOLDER = BASE_DIR / "tiles" / "placeholder.png"
UPLOAD_DIR = BASE_DIR / "uploads"
GNSS_SOURCE = "simulated"
@ -9,6 +11,13 @@ GNSS_PORT = "/dev/ttyAMA0"
GNSS_BAUD = 9600
GNSS_TIMEOUT = 1.0
SIM_START_POINT = (55.75222, 37.61556)
SIM_PATH = "circle" # circle | rectangle
# Для симуляции движения по прямоугольнику раскомментируй строку ниже:
# SIM_PATH = "rectangle"
SIM_RADIUS_METERS = 250.0
SIM_POINT_INTERVAL = 1.0
WS_BROADCAST_INTERVAL = 1.0
SIM_RECT_WIDTH_METERS = 400.0
SIM_RECT_HEIGHT_METERS = 250.0
SIM_STEP_METERS = 20.0
SIM_ALT_METERS = 50.0
SIM_POINT_INTERVAL = 1
WS_BROADCAST_INTERVAL = 1

View file

@ -6,11 +6,12 @@ from fastapi.middleware.cors import CORSMiddleware
from . import config
from .api import state
from .api.routes import config_route, health, logs, tiles, vector_tiles, ws
from .services.gnss import GNSSService, SerialNMEASource, SimulatedPathSource
from .api.routes import config_route, health, logs, 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.vector_mbtiles import VectorMBTilesService
from .services.tilesets import TilesetService
logger = logging.getLogger("gnss")
logging.basicConfig(level=logging.INFO)
@ -26,15 +27,28 @@ def create_gnss_service() -> GNSSService:
)
else:
start_lat, start_lon = config.SIM_START_POINT
source = SimulatedPathSource(
center_lat=start_lat,
center_lon=start_lon,
radius_m=config.SIM_RADIUS_METERS,
)
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,
)
return GNSSService(source=source, broadcast_interval=config.WS_BROADCAST_INTERVAL)
tiles_dir = Path(config.TILES_DIR)
tileset_service = TilesetService(tilesets_dir=Path(config.TILESSETS_DIR), default_tileset=config.DEFAULT_TILESET)
tileset_service.ensure()
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"))
@ -63,12 +77,29 @@ app.include_router(tiles.router)
app.include_router(ws.router)
app.include_router(logs.router)
app.include_router(vector_tiles.router)
app.include_router(tilesets.router)
@app.on_event("startup")
async def on_startup() -> None:
tile_service.ensure_tiles_dir()
tile_service.ensure_upload_dir()
tileset_service.ensure()
legacy_tiles_dir = Path(config.TILES_DIR)
default_tileset_dir = tileset_service.resolve(config.DEFAULT_TILESET)
if legacy_tiles_dir.is_dir() and any(legacy_tiles_dir.iterdir()):
if not default_tileset_dir.is_dir() or not any(default_tileset_dir.iterdir()):
default_tileset_dir.mkdir(parents=True, exist_ok=True)
for item in legacy_tiles_dir.iterdir():
target = default_tileset_dir / item.name
if item.is_dir():
import shutil
shutil.copytree(item, target, dirs_exist_ok=True)
else:
import shutil
shutil.copy2(item, target)
try:
await gnss_service.start()
except Exception as exc:

View file

@ -6,6 +6,7 @@ from pydantic import BaseModel, Field, field_validator
class TileSliceRequest(BaseModel):
source: str
destination: str | None = None
tileset: str | None = None
min_zoom: int = Field(ge=0)
max_zoom: int = Field(ge=0)
resampling: str = "bilinear"
@ -57,3 +58,8 @@ class TileSliceRequest(BaseModel):
if self.destination:
return Path(self.destination).expanduser().resolve()
return default.resolve()
def tileset_name(self) -> str | None:
if not self.tileset:
return None
return self.tileset.strip()

View file

@ -93,6 +93,73 @@ class SimulatedPathSource(BaseGNSSSource):
)
class RectangularPathSource(BaseGNSSSource):
def __init__(
self,
center_lat: float,
center_lon: float,
width_m: float,
height_m: float,
step_m: float = 20.0,
alt_m: float = 50.0,
):
self.center_lat = center_lat
self.center_lon = center_lon
self.width_m = width_m
self.height_m = height_m
self.step_m = step_m
self.alt_m = alt_m
self._edge_index = 0
self._edge_progress_m = 0.0
self._current_lat = center_lat
self._current_lon = center_lon
async def start(self) -> None:
self._edge_index = 0
self._edge_progress_m = 0.0
half_h = self.height_m / 2.0
half_w = self.width_m / 2.0
lat_s, lon_s = move_point(self.center_lat, self.center_lon, half_h, 180.0)
lat_sw, lon_sw = move_point(lat_s, lon_s, half_w, 270.0)
self._current_lat, self._current_lon = lat_sw, lon_sw
async def stop(self) -> None:
return None
async def next_point(self) -> Optional[GNSSPoint]:
edges = [
(90.0, self.width_m),
(0.0, self.height_m),
(270.0, self.width_m),
(180.0, self.height_m),
]
bearing, length = edges[self._edge_index]
step = self.step_m
remaining = length - self._edge_progress_m
if remaining <= 0.0:
self._edge_index = (self._edge_index + 1) % len(edges)
self._edge_progress_m = 0.0
bearing, length = edges[self._edge_index]
remaining = length
if step > remaining:
step = remaining
self._current_lat, self._current_lon = move_point(
self._current_lat, self._current_lon, step, bearing
)
self._edge_progress_m += step
return GNSSPoint(
lat=self._current_lat,
lon=self._current_lon,
alt=self.alt_m,
timestamp=current_utc_time(),
source="simulated",
)
def build_status_payload(status: str, detail: Optional[str] = None) -> dict:
payload = {"type": "status", "status": status, "timestamp": current_utc_time()}
if detail:

View file

@ -91,6 +91,10 @@ class TileService:
y_converted = (2**zoom - 1) - y
return self.get_tile_response(zoom, x, y_converted)
def set_tiles_dir(self, tiles_dir: Path) -> None:
self.tiles_dir = Path(tiles_dir)
self.ensure_tiles_dir()
def unpack_if_archive(self, source: Path) -> Path:
lower = source.suffix.lower()
if lower == ".zip":

75
gnss/services/tilesets.py Normal file
View file

@ -0,0 +1,75 @@
import re
from pathlib import Path
from typing import Optional
class TilesetError(RuntimeError):
pass
def _sanitize_tileset_name(name: str) -> str:
n = (name or "").strip().lower()
n = re.sub(r"[^a-z0-9._-]+", "-", n)
n = n.strip("-")
if not n:
raise TilesetError("tileset name is empty")
if len(n) > 64:
raise TilesetError("tileset name is too long")
return n
class TilesetService:
def __init__(self, tilesets_dir: Path, default_tileset: str = "default"):
self.tilesets_dir = Path(tilesets_dir)
self.default_tileset = _sanitize_tileset_name(default_tileset)
self._active_file = self.tilesets_dir / ".active"
def ensure(self) -> None:
self.tilesets_dir.mkdir(parents=True, exist_ok=True)
default_dir = self.tilesets_dir / self.default_tileset
default_dir.mkdir(parents=True, exist_ok=True)
if not self._active_file.exists():
self._active_file.write_text(self.default_tileset, encoding="utf-8")
def list_tilesets(self) -> list[str]:
self.ensure()
items: list[str] = []
for p in self.tilesets_dir.iterdir():
if not p.is_dir():
continue
if p.name.startswith("."):
continue
items.append(p.name)
return sorted(items)
def get_active(self) -> str:
self.ensure()
try:
name = self._active_file.read_text(encoding="utf-8").strip()
except FileNotFoundError:
name = self.default_tileset
if not name:
name = self.default_tileset
return _sanitize_tileset_name(name)
def set_active(self, name: str) -> str:
self.ensure()
tileset = _sanitize_tileset_name(name)
path = self.tilesets_dir / tileset
if not path.is_dir():
raise TilesetError(f"tileset not found: {tileset}")
self._active_file.write_text(tileset, encoding="utf-8")
return tileset
def create(self, name: str) -> str:
self.ensure()
tileset = _sanitize_tileset_name(name)
path = self.tilesets_dir / tileset
path.mkdir(parents=True, exist_ok=True)
return tileset
def resolve(self, name: Optional[str] = None) -> Path:
self.ensure()
tileset = self.get_active() if not name else _sanitize_tileset_name(name)
return self.tilesets_dir / tileset