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
1
.gitignore
vendored
|
|
@ -244,6 +244,7 @@ pyrightconfig.json
|
||||||
# Runtime-generated data (do not commit)
|
# Runtime-generated data (do not commit)
|
||||||
logs/
|
logs/
|
||||||
gnss/tiles/
|
gnss/tiles/
|
||||||
|
gnss/tilesets/
|
||||||
gnss/uploads/
|
gnss/uploads/
|
||||||
|
|
||||||
# Frontend artifacts
|
# Frontend artifacts
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,16 @@ body, #root {
|
||||||
color: #f4f7fb;
|
color: #f4f7fb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tileset-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileset-row select {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.file-row {
|
.file-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ type AppConfig = {
|
||||||
tiles_url_tms?: string;
|
tiles_url_tms?: string;
|
||||||
vector_tiles_url?: string;
|
vector_tiles_url?: string;
|
||||||
vector_metadata_url?: string;
|
vector_metadata_url?: string;
|
||||||
|
tilesets_url?: string;
|
||||||
|
tilesets_activate_url?: string;
|
||||||
ws_url: string;
|
ws_url: string;
|
||||||
gnss_source: string;
|
gnss_source: string;
|
||||||
};
|
};
|
||||||
|
|
@ -77,11 +79,14 @@ export default function App() {
|
||||||
const [track, setTrack] = useState<TrackPoint[]>([]);
|
const [track, setTrack] = useState<TrackPoint[]>([]);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const [mapMode, setMapMode] = useState<"raster" | "vector">("raster");
|
const [mapMode, setMapMode] = useState<"raster" | "vector">("raster");
|
||||||
|
const [tilesets, setTilesets] = useState<string[]>([]);
|
||||||
|
const [activeTileset, setActiveTileset] = useState<string>("");
|
||||||
const [selectedFileName, setSelectedFileName] = useState<string>("");
|
const [selectedFileName, setSelectedFileName] = useState<string>("");
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [sliceParams, setSliceParams] = useState({
|
const [sliceParams, setSliceParams] = useState({
|
||||||
source: "",
|
source: "",
|
||||||
destination: "",
|
destination: "",
|
||||||
|
tileset: "",
|
||||||
min_zoom: 10,
|
min_zoom: 10,
|
||||||
max_zoom: 16,
|
max_zoom: 16,
|
||||||
tile_format: "png",
|
tile_format: "png",
|
||||||
|
|
@ -104,6 +109,16 @@ export default function App() {
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setSliceParams((p) => ({ ...p, source: res.path }));
|
setSliceParams((p) => ({ ...p, source: res.path }));
|
||||||
setSelectedFileName(res.filename);
|
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");
|
setSliceStatus("idle");
|
||||||
sendFrontLog(`uploaded source ${res.filename}`, "info");
|
sendFrontLog(`uploaded source ${res.filename}`, "info");
|
||||||
if (res.filename?.toLowerCase?.().endsWith(".mbtiles")) {
|
if (res.filename?.toLowerCase?.().endsWith(".mbtiles")) {
|
||||||
|
|
@ -151,7 +166,10 @@ export default function App() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`${backendBase}/config`, { cache: "no-store" })
|
fetch(`${backendBase}/config`, { cache: "no-store" })
|
||||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`))))
|
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`))))
|
||||||
.then(setConfig)
|
.then((cfg) => {
|
||||||
|
setConfig(cfg);
|
||||||
|
return cfg;
|
||||||
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error("Config error", err);
|
console.error("Config error", err);
|
||||||
sendFrontLog(`config fetch failed: ${err}`, "error");
|
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(() => {
|
useEffect(() => {
|
||||||
if (!config) return;
|
if (!config) return;
|
||||||
const base = backendBase.replace(/\/+$/, "");
|
const base = backendBase.replace(/\/+$/, "");
|
||||||
|
|
@ -410,6 +468,26 @@ export default function App() {
|
||||||
<div className="card-label">GNSS source</div>
|
<div className="card-label">GNSS source</div>
|
||||||
<div className="card-value">{config?.gnss_source ?? "—"}</div>
|
<div className="card-value">{config?.gnss_source ?? "—"}</div>
|
||||||
</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">
|
||||||
<div className="card-label">Last fix</div>
|
<div className="card-label">Last fix</div>
|
||||||
<div className="card-value">{formatTime(lastPoint?.timestamp)}</div>
|
<div className="card-value">{formatTime(lastPoint?.timestamp)}</div>
|
||||||
|
|
@ -479,6 +557,7 @@ export default function App() {
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
source: sliceParams.source,
|
source: sliceParams.source,
|
||||||
destination: sliceParams.destination || undefined,
|
destination: sliceParams.destination || undefined,
|
||||||
|
tileset: sliceParams.tileset || undefined,
|
||||||
min_zoom: Number(sliceParams.min_zoom),
|
min_zoom: Number(sliceParams.min_zoom),
|
||||||
max_zoom: Number(sliceParams.max_zoom),
|
max_zoom: Number(sliceParams.max_zoom),
|
||||||
tile_format: sliceParams.tile_format,
|
tile_format: sliceParams.tile_format,
|
||||||
|
|
@ -490,6 +569,9 @@ export default function App() {
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setSliceStatus("ok");
|
setSliceStatus("ok");
|
||||||
sendFrontLog(`tiles sliced to ${res.tiles_dir}`, "info");
|
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) => {
|
.catch((err) => {
|
||||||
setSliceStatus("error");
|
setSliceStatus("error");
|
||||||
|
|
@ -552,11 +634,40 @@ export default function App() {
|
||||||
<option value="xyz">Папка XYZ</option>
|
<option value="xyz">Папка XYZ</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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">
|
<label className="field">
|
||||||
<span>Папка тайлов (опц.)</span>
|
<span>Папка тайлов (опц.)</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="пусто → gnss/tiles"
|
placeholder="лучше не трогать (по умолчанию tileset)"
|
||||||
value={sliceParams.destination}
|
value={sliceParams.destination}
|
||||||
onChange={(e) => setSliceParams((p) => ({ ...p, destination: e.target.value }))}
|
onChange={(e) => setSliceParams((p) => ({ ...p, destination: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
|
|
@ -601,6 +712,7 @@ export default function App() {
|
||||||
setSliceParams({
|
setSliceParams({
|
||||||
source: "",
|
source: "",
|
||||||
destination: "",
|
destination: "",
|
||||||
|
tileset: "",
|
||||||
min_zoom: 10,
|
min_zoom: 10,
|
||||||
max_zoom: 16,
|
max_zoom: 16,
|
||||||
tile_format: "png",
|
tile_format: "png",
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -12,6 +12,8 @@ async def get_config() -> dict:
|
||||||
"tiles_url_tms": "/tiles/tms/{z}/{x}/{y}.png",
|
"tiles_url_tms": "/tiles/tms/{z}/{x}/{y}.png",
|
||||||
"vector_tiles_url": "/tiles/vector/{z}/{x}/{y}.pbf",
|
"vector_tiles_url": "/tiles/vector/{z}/{x}/{y}.pbf",
|
||||||
"vector_metadata_url": "/tiles/vector/metadata",
|
"vector_metadata_url": "/tiles/vector/metadata",
|
||||||
|
"tilesets_url": "/tilesets",
|
||||||
|
"tilesets_activate_url": "/tilesets/activate",
|
||||||
"ws_url": "/ws/track",
|
"ws_url": "/ws/track",
|
||||||
"gnss_source": config.GNSS_SOURCE,
|
"gnss_source": config.GNSS_SOURCE,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from ...api.dependencies import get_tile_service
|
||||||
from ...models.tiles import TileSliceRequest
|
from ...models.tiles import TileSliceRequest
|
||||||
from ...services.tiles import TileCutError, TileService
|
from ...services.tiles import TileCutError, TileService
|
||||||
from ... import config
|
from ... import config
|
||||||
|
from ...services.tilesets import TilesetService, TilesetError
|
||||||
|
|
||||||
router = APIRouter(tags=["tiles"])
|
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")
|
@router.post("/tiles/slice")
|
||||||
async def slice_tiles(payload: TileSliceRequest, tile_service: TileService = Depends(get_tile_service)) -> dict:
|
async def slice_tiles(payload: TileSliceRequest, tile_service: TileService = Depends(get_tile_service)) -> dict:
|
||||||
source = payload.source_path()
|
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:
|
try:
|
||||||
if payload.source_format in {"tms", "xyz"}:
|
if payload.source_format in {"tms", "xyz"}:
|
||||||
src = tile_service.unpack_if_archive(source)
|
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:
|
except TileCutError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(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")
|
@router.post("/tiles/upload")
|
||||||
|
|
|
||||||
49
gnss/api/routes/tilesets.py
Normal file
49
gnss/api/routes/tilesets.py
Normal 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}
|
||||||
|
|
@ -2,6 +2,8 @@ from pathlib import Path
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).parent
|
BASE_DIR = Path(__file__).parent
|
||||||
TILES_DIR = BASE_DIR / "tiles"
|
TILES_DIR = BASE_DIR / "tiles"
|
||||||
|
TILESSETS_DIR = BASE_DIR / "tilesets"
|
||||||
|
DEFAULT_TILESET = "default"
|
||||||
TILE_PLACEHOLDER = BASE_DIR / "tiles" / "placeholder.png"
|
TILE_PLACEHOLDER = BASE_DIR / "tiles" / "placeholder.png"
|
||||||
UPLOAD_DIR = BASE_DIR / "uploads"
|
UPLOAD_DIR = BASE_DIR / "uploads"
|
||||||
GNSS_SOURCE = "simulated"
|
GNSS_SOURCE = "simulated"
|
||||||
|
|
@ -9,6 +11,13 @@ GNSS_PORT = "/dev/ttyAMA0"
|
||||||
GNSS_BAUD = 9600
|
GNSS_BAUD = 9600
|
||||||
GNSS_TIMEOUT = 1.0
|
GNSS_TIMEOUT = 1.0
|
||||||
SIM_START_POINT = (55.75222, 37.61556)
|
SIM_START_POINT = (55.75222, 37.61556)
|
||||||
|
SIM_PATH = "circle" # circle | rectangle
|
||||||
|
# Для симуляции движения по прямоугольнику раскомментируй строку ниже:
|
||||||
|
# SIM_PATH = "rectangle"
|
||||||
SIM_RADIUS_METERS = 250.0
|
SIM_RADIUS_METERS = 250.0
|
||||||
SIM_POINT_INTERVAL = 1.0
|
SIM_RECT_WIDTH_METERS = 400.0
|
||||||
WS_BROADCAST_INTERVAL = 1.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
|
||||||
|
|
|
||||||
47
gnss/main.py
47
gnss/main.py
|
|
@ -6,11 +6,12 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from . import config
|
from . import config
|
||||||
from .api import state
|
from .api import state
|
||||||
from .api.routes import config_route, health, logs, tiles, vector_tiles, ws
|
from .api.routes import config_route, health, logs, tiles, tilesets, vector_tiles, ws
|
||||||
from .services.gnss import GNSSService, SerialNMEASource, SimulatedPathSource
|
from .services.gnss import GNSSService, RectangularPathSource, SerialNMEASource, SimulatedPathSource
|
||||||
from .services.logs import LogService
|
from .services.logs import LogService
|
||||||
from .services.tiles import TileService
|
from .services.tiles import TileService
|
||||||
from .services.vector_mbtiles import VectorMBTilesService
|
from .services.vector_mbtiles import VectorMBTilesService
|
||||||
|
from .services.tilesets import TilesetService
|
||||||
|
|
||||||
logger = logging.getLogger("gnss")
|
logger = logging.getLogger("gnss")
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
@ -26,15 +27,28 @@ def create_gnss_service() -> GNSSService:
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
start_lat, start_lon = config.SIM_START_POINT
|
start_lat, start_lon = config.SIM_START_POINT
|
||||||
source = SimulatedPathSource(
|
path = (getattr(config, "SIM_PATH", "circle") or "circle").lower()
|
||||||
center_lat=start_lat,
|
if path == "rectangle":
|
||||||
center_lon=start_lon,
|
source = RectangularPathSource(
|
||||||
radius_m=config.SIM_RADIUS_METERS,
|
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)
|
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)
|
placeholder_tile = Path(config.TILE_PLACEHOLDER)
|
||||||
tile_service = TileService(tiles_dir=tiles_dir, placeholder=placeholder_tile, upload_dir=Path(config.UPLOAD_DIR))
|
tile_service = TileService(tiles_dir=tiles_dir, placeholder=placeholder_tile, upload_dir=Path(config.UPLOAD_DIR))
|
||||||
log_service = LogService(base_dir=Path("logs"))
|
log_service = LogService(base_dir=Path("logs"))
|
||||||
|
|
@ -63,12 +77,29 @@ app.include_router(tiles.router)
|
||||||
app.include_router(ws.router)
|
app.include_router(ws.router)
|
||||||
app.include_router(logs.router)
|
app.include_router(logs.router)
|
||||||
app.include_router(vector_tiles.router)
|
app.include_router(vector_tiles.router)
|
||||||
|
app.include_router(tilesets.router)
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def on_startup() -> None:
|
async def on_startup() -> None:
|
||||||
tile_service.ensure_tiles_dir()
|
tile_service.ensure_tiles_dir()
|
||||||
tile_service.ensure_upload_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:
|
try:
|
||||||
await gnss_service.start()
|
await gnss_service.start()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from pydantic import BaseModel, Field, field_validator
|
||||||
class TileSliceRequest(BaseModel):
|
class TileSliceRequest(BaseModel):
|
||||||
source: str
|
source: str
|
||||||
destination: str | None = None
|
destination: str | None = None
|
||||||
|
tileset: str | None = None
|
||||||
min_zoom: int = Field(ge=0)
|
min_zoom: int = Field(ge=0)
|
||||||
max_zoom: int = Field(ge=0)
|
max_zoom: int = Field(ge=0)
|
||||||
resampling: str = "bilinear"
|
resampling: str = "bilinear"
|
||||||
|
|
@ -57,3 +58,8 @@ class TileSliceRequest(BaseModel):
|
||||||
if self.destination:
|
if self.destination:
|
||||||
return Path(self.destination).expanduser().resolve()
|
return Path(self.destination).expanduser().resolve()
|
||||||
return default.resolve()
|
return default.resolve()
|
||||||
|
|
||||||
|
def tileset_name(self) -> str | None:
|
||||||
|
if not self.tileset:
|
||||||
|
return None
|
||||||
|
return self.tileset.strip()
|
||||||
|
|
|
||||||
|
|
@ -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:
|
def build_status_payload(status: str, detail: Optional[str] = None) -> dict:
|
||||||
payload = {"type": "status", "status": status, "timestamp": current_utc_time()}
|
payload = {"type": "status", "status": status, "timestamp": current_utc_time()}
|
||||||
if detail:
|
if detail:
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,10 @@ class TileService:
|
||||||
y_converted = (2**zoom - 1) - y
|
y_converted = (2**zoom - 1) - y
|
||||||
return self.get_tile_response(zoom, x, y_converted)
|
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:
|
def unpack_if_archive(self, source: Path) -> Path:
|
||||||
lower = source.suffix.lower()
|
lower = source.suffix.lower()
|
||||||
if lower == ".zip":
|
if lower == ".zip":
|
||||||
|
|
|
||||||
75
gnss/services/tilesets.py
Normal file
75
gnss/services/tilesets.py
Normal 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
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue