diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0da23fe..320f20d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -38,6 +38,7 @@ export default function App() { const [vectorSource, setVectorSource] = useState<"mbtiles" | "tileset">("mbtiles"); const [tilesets, setTilesets] = useState([]); const [activeTileset, setActiveTileset] = useState(""); + const [tilesetKinds, setTilesetKinds] = useState>({}); const [selectedFileName, setSelectedFileName] = useState(""); const fileInputRef = useRef(null); const [sliceParams, setSliceParams] = useState(initialSliceParams); @@ -96,6 +97,7 @@ export default function App() { if (!res.ok) throw new Error(data?.detail || `status ${res.status}`); setTilesets(data.tilesets || []); setActiveTileset(data.active || ""); + setTilesetKinds(data.kinds || {}); }, [config]); useEffect(() => { @@ -164,8 +166,15 @@ export default function App() { if (!res.ok) throw new Error(data?.detail || `status ${res.status}`); setActiveTileset(data.active || name); sendFrontLog(`tileset activated: ${data.active || name}`, "info"); + const kind = (data.kind as string | undefined) || tilesetKinds[name] || ""; + if (kind.startsWith("vector")) { + setMapMode("vector"); + setVectorSource(kind === "vector-dir" ? "tileset" : "mbtiles"); + } else if (kind.startsWith("raster")) { + setMapMode("raster"); + } }, - [config, sendFrontLog], + [config, sendFrontLog, tilesetKinds], ); const createTileset = useCallback( @@ -273,9 +282,13 @@ export default function App() { setMapMode("raster"); } sendFrontLog(`map ingested: kind=${data.kind} driver=${data.driver}`, "info"); + if (data.tileset) { + refreshTilesets().catch(() => {}); + activateTileset(String(data.tileset)).catch(() => {}); + } return data; }, - [sendFrontLog, sliceParams.max_zoom, sliceParams.min_zoom, sliceParams.tileset], + [activateTileset, refreshTilesets, sendFrontLog, sliceParams.max_zoom, sliceParams.min_zoom, sliceParams.tileset], ); const uploadFileBlob = useCallback( @@ -291,6 +304,10 @@ export default function App() { .then((res) => { setSliceParams((p) => ({ ...p, source: res.path })); setSelectedFileName(res.filename); + const lower = String(res.filename || "").toLowerCase(); + if (lower.endsWith(".mbtiles")) setSliceParams((p) => ({ ...p, source_format: "mbtiles" })); + if (lower.endsWith(".tif") || lower.endsWith(".tiff") || lower.endsWith(".vrt")) + setSliceParams((p) => ({ ...p, source_format: "geotiff" })); setSliceParams((p) => { if (p.tileset) return p; const proposed = String(res.filename || "") @@ -326,7 +343,11 @@ export default function App() { ], }); if (typeof selected === "string") { + const lower = selected.toLowerCase(); setSliceParams((p) => ({ ...p, source: selected })); + if (lower.endsWith(".mbtiles")) setSliceParams((p) => ({ ...p, source_format: "mbtiles" })); + if (lower.endsWith(".tif") || lower.endsWith(".tiff") || lower.endsWith(".vrt")) + setSliceParams((p) => ({ ...p, source_format: "geotiff" })); setSelectedFileName(selected.split("/").pop() || selected); return; } diff --git a/gnss/api/routes/maps.py b/gnss/api/routes/maps.py index 9512658..17eaf77 100644 --- a/gnss/api/routes/maps.py +++ b/gnss/api/routes/maps.py @@ -1,3 +1,4 @@ +import json from pathlib import Path from fastapi import APIRouter, Depends, HTTPException @@ -7,6 +8,8 @@ from ..dependencies import get_raster_mbtiles_service, get_tile_service, get_vec from ...services.raster_mbtiles import RasterMBTilesError, RasterMBTilesService from ...services.tiles import TileCutError, TileService from ...services.vector_mbtiles import VectorMBTilesError, VectorMBTilesService +from ...services.tilesets import TilesetError, TilesetService +from ... import config router = APIRouter(tags=["maps"]) @@ -30,21 +33,61 @@ async def ingest_map( if not p.exists(): raise HTTPException(status_code=400, detail=f"path not found: {p}") + tileset_svc = TilesetService(tilesets_dir=config.TILESSETS_DIR, default_tileset=config.DEFAULT_TILESET) + tileset_svc.ensure() + suggested_tileset = (payload.tileset or p.stem or "").strip() + tileset_name: str | None = None + if suggested_tileset: + try: + tileset_name = tileset_svc.create(suggested_tileset) + tileset_svc.set_active(tileset_name) + tile_service.set_tiles_dir(tileset_svc.resolve(tileset_name)) + except TilesetError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + if p.suffix.lower() == ".mbtiles": try: info = vector_mbtiles.activate(p) - return {"status": "ok", "kind": "vector", "driver": "mbtiles", "path": str(info.path), "format": info.metadata.get("format")} + if tileset_name: + manifest = {"kind": "vector-mbtiles", "source": str(info.path), "format": info.metadata.get("format")} + (tileset_svc.resolve(tileset_name) / "gnss_tileset.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return { + "status": "ok", + "kind": "vector", + "driver": "mbtiles", + "path": str(info.path), + "format": info.metadata.get("format"), + "tileset": tileset_name, + } except VectorMBTilesError: pass try: info = raster_mbtiles.activate(p) except RasterMBTilesError as exc: raise HTTPException(status_code=400, detail=str(exc)) - return {"status": "ok", "kind": "raster", "driver": "mbtiles", "path": str(info.path), "format": info.metadata.get("format")} + if tileset_name: + manifest = {"kind": "raster-mbtiles", "source": str(info.path), "format": info.metadata.get("format")} + (tileset_svc.resolve(tileset_name) / "gnss_tileset.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return { + "status": "ok", + "kind": "raster", + "driver": "mbtiles", + "path": str(info.path), + "format": info.metadata.get("format"), + "tileset": tileset_name, + } if p.suffix.lower() in {".tif", ".tiff", ".vrt"}: if payload.max_zoom < payload.min_zoom: raise HTTPException(status_code=400, detail="max_zoom must be >= min_zoom") + if tileset_name: + tile_service.set_tiles_dir(tileset_svc.resolve(tileset_name)) try: tile_service.slice_to_tiles( source=p, @@ -55,7 +98,13 @@ async def ingest_map( ) except TileCutError as exc: raise HTTPException(status_code=400, detail=str(exc)) - return {"status": "ok", "kind": "raster", "driver": "gdal2tiles", "path": str(p), "tiles_dir": str(tile_service.tiles_dir)} + return { + "status": "ok", + "kind": "raster", + "driver": "gdal2tiles", + "path": str(p), + "tiles_dir": str(tile_service.tiles_dir), + "tileset": tileset_name, + } raise HTTPException(status_code=400, detail="unsupported input; expected .mbtiles or GeoTIFF/VRT") - diff --git a/gnss/api/routes/tiles.py b/gnss/api/routes/tiles.py index 8542d64..1a42c6d 100644 --- a/gnss/api/routes/tiles.py +++ b/gnss/api/routes/tiles.py @@ -5,12 +5,13 @@ from fastapi.responses import JSONResponse import json -from ...api.dependencies import get_raster_mbtiles_service, get_tile_service +from ...api.dependencies import get_raster_mbtiles_service, get_tile_service, get_vector_mbtiles_service from ...models.tiles import TileSliceRequest +from ...services.raster_mbtiles import RasterMBTilesError, RasterMBTilesService from ...services.tiles import TileCutError, TileService +from ...services.vector_mbtiles import VectorMBTilesError, VectorMBTilesService from ... import config from ...services.tilesets import TilesetService, TilesetError -from ...services.raster_mbtiles import RasterMBTilesService router = APIRouter(tags=["tiles"]) @@ -53,7 +54,12 @@ async def serve_tile_tms( @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), + raster_mbtiles: RasterMBTilesService = Depends(get_raster_mbtiles_service), + vector_mbtiles: VectorMBTilesService = Depends(get_vector_mbtiles_service), +) -> dict: source = payload.source_path() tileset_svc = TilesetService(tilesets_dir=config.TILESSETS_DIR, default_tileset=config.DEFAULT_TILESET) tileset_svc.ensure() @@ -66,6 +72,28 @@ async def slice_tiles(payload: TileSliceRequest, tile_service: TileService = Dep destination_default = tileset_svc.resolve(tileset_name) destination = payload.destination_path(destination_default) try: + if payload.source_format == "mbtiles": + try: + info = vector_mbtiles.activate(source) + kind = "vector" + manifest = {"kind": "vector-mbtiles", "source": str(info.path), "format": info.metadata.get("format")} + except VectorMBTilesError: + info = raster_mbtiles.activate(source) + kind = "raster" + manifest = {"kind": "raster-mbtiles", "source": str(info.path), "format": info.metadata.get("format")} + active_tileset = tileset_name or tileset_svc.get_active() + (tileset_svc.resolve(active_tileset) / "gnss_tileset.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return { + "status": "ok", + "kind": kind, + "driver": "mbtiles", + "path": str(source), + "scheme": payload.scheme, + "tileset": active_tileset, + } if payload.source_format in {"tms", "xyz"}: src = tile_service.unpack_if_archive(source) tile_service.copy_tile_tree(src, destination) @@ -83,6 +111,8 @@ async def slice_tiles(payload: TileSliceRequest, tile_service: TileService = Dep ) except TileCutError as exc: raise HTTPException(status_code=400, detail=str(exc)) + except (RasterMBTilesError, VectorMBTilesError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) active_tileset = tileset_name or tileset_svc.get_active() try: diff --git a/gnss/api/routes/tilesets.py b/gnss/api/routes/tilesets.py index 1984c35..4f2c42c 100644 --- a/gnss/api/routes/tilesets.py +++ b/gnss/api/routes/tilesets.py @@ -5,10 +5,12 @@ from pathlib import Path from fastapi import APIRouter, Depends, HTTPException, Response from pydantic import BaseModel -from ..dependencies import get_tile_service +from ..dependencies import get_raster_mbtiles_service, get_tile_service, get_vector_mbtiles_service from ... import config +from ...services.raster_mbtiles import RasterMBTilesError, RasterMBTilesService from ...services.tiles import TileService from ...services.tilesets import TilesetError, TilesetService +from ...services.vector_mbtiles import VectorMBTilesError, VectorMBTilesService router = APIRouter(tags=["tilesets"]) @@ -31,6 +33,15 @@ def _is_gzipped(data: bytes) -> bool: def _detect_kind(path: Path) -> str: + manifest = path / "gnss_tileset.json" + if manifest.is_file(): + try: + parsed = json.loads(manifest.read_text(encoding="utf-8")) + kind = str(parsed.get("kind") or "").lower() + if kind in {"vector-mbtiles", "raster-mbtiles"}: + return kind + except Exception: + pass meta = path / "metadata.json" if meta.is_file(): try: @@ -218,14 +229,40 @@ async def create_tileset(payload: CreateTileset) -> dict: @router.post("/tilesets/activate") -async def activate_tileset(payload: ActivateTileset, tile_service: TileService = Depends(get_tile_service)) -> dict: +async def activate_tileset( + payload: ActivateTileset, + tile_service: TileService = Depends(get_tile_service), + raster_mbtiles: RasterMBTilesService = Depends(get_raster_mbtiles_service), + vector_mbtiles: VectorMBTilesService = Depends(get_vector_mbtiles_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} + tileset_dir = svc.resolve(active) + kind = _detect_kind(tileset_dir) + activated: dict | None = None + manifest_path = tileset_dir / "gnss_tileset.json" + if manifest_path.is_file() and kind in {"vector-mbtiles", "raster-mbtiles"}: + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + src = manifest.get("source") or manifest.get("path") or manifest.get("mbtiles") + if not src: + raise ValueError("gnss_tileset.json missing 'source'") + mbtiles_path = Path(str(src)).expanduser().resolve() + if kind == "vector-mbtiles": + info = vector_mbtiles.activate(mbtiles_path) + activated = {"kind": "vector", "driver": "mbtiles", "path": str(info.path)} + else: + info = raster_mbtiles.activate(mbtiles_path) + activated = {"kind": "raster", "driver": "mbtiles", "path": str(info.path)} + except (VectorMBTilesError, RasterMBTilesError, ValueError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"failed to activate mbtiles tileset: {exc}") + return {"status": "ok", "active": active, "kind": kind, "activated": activated} @router.get("/tilesets/active/vector/metadata") @@ -255,4 +292,3 @@ async def active_vector_tile(z: int, x: int, y: int) -> Response: if _is_gzipped(data): headers["Content-Encoding"] = "gzip" return Response(content=data, media_type="application/x-protobuf", headers=headers) - diff --git a/gnss/main.py b/gnss/main.py index 8db31e8..b8d5636 100644 --- a/gnss/main.py +++ b/gnss/main.py @@ -53,8 +53,8 @@ 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")) -raster_mbtiles_service = RasterMBTilesService() -vector_mbtiles_service = VectorMBTilesService() +raster_mbtiles_service = RasterMBTilesService(state_file=Path(config.DATA_DIR) / "raster_mbtiles.active") +vector_mbtiles_service = VectorMBTilesService(state_file=Path(config.DATA_DIR) / "vector_mbtiles.active") gnss_service = create_gnss_service() state.set_services( gnss=gnss_service, diff --git a/gnss/services/raster_mbtiles.py b/gnss/services/raster_mbtiles.py index 0fd6c13..f024bf4 100644 --- a/gnss/services/raster_mbtiles.py +++ b/gnss/services/raster_mbtiles.py @@ -40,9 +40,36 @@ def _format_to_media_type(fmt: str) -> str: class RasterMBTilesService: - def __init__(self): + def __init__(self, state_file: Optional[Path] = None): self._lock = threading.Lock() self._active: Optional[RasterMBTilesInfo] = None + self._state_file = Path(state_file).expanduser().resolve() if state_file else None + self._restore() + + def _restore(self) -> None: + if not self._state_file: + return + try: + raw = self._state_file.read_text(encoding="utf-8").strip() + except FileNotFoundError: + return + except Exception: + return + if not raw: + return + try: + self.activate(Path(raw)) + except Exception: + return + + def _persist(self, info: RasterMBTilesInfo) -> None: + if not self._state_file: + return + try: + self._state_file.parent.mkdir(parents=True, exist_ok=True) + self._state_file.write_text(str(info.path), encoding="utf-8") + except Exception: + return def activate(self, path: Path) -> RasterMBTilesInfo: p = Path(path).expanduser().resolve() @@ -66,6 +93,7 @@ class RasterMBTilesService: info = RasterMBTilesInfo(path=p, metadata=metadata) with self._lock: self._active = info + self._persist(info) return info def get_active(self) -> RasterMBTilesInfo: @@ -118,4 +146,3 @@ class RasterMBTilesService: fmt = str(info.metadata.get("format") or "").lower() return data, _format_to_media_type(fmt) - diff --git a/gnss/services/vector_mbtiles.py b/gnss/services/vector_mbtiles.py index 496979b..c9031dc 100644 --- a/gnss/services/vector_mbtiles.py +++ b/gnss/services/vector_mbtiles.py @@ -29,9 +29,36 @@ def _has_table(conn: sqlite3.Connection, table: str) -> bool: class VectorMBTilesService: - def __init__(self): + def __init__(self, state_file: Optional[Path] = None): self._lock = threading.Lock() self._active: Optional[VectorMBTilesInfo] = None + self._state_file = Path(state_file).expanduser().resolve() if state_file else None + self._restore() + + def _restore(self) -> None: + if not self._state_file: + return + try: + raw = self._state_file.read_text(encoding="utf-8").strip() + except FileNotFoundError: + return + except Exception: + return + if not raw: + return + try: + self.activate(Path(raw)) + except Exception: + return + + def _persist(self, info: VectorMBTilesInfo) -> None: + if not self._state_file: + return + try: + self._state_file.parent.mkdir(parents=True, exist_ok=True) + self._state_file.write_text(str(info.path), encoding="utf-8") + except Exception: + return def activate(self, path: Path) -> VectorMBTilesInfo: p = Path(path).expanduser().resolve() @@ -55,6 +82,7 @@ class VectorMBTilesService: info = VectorMBTilesInfo(path=p, metadata=metadata) with self._lock: self._active = info + self._persist(info) return info def get_active(self) -> VectorMBTilesInfo: