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)
|
||||
logs/
|
||||
gnss/tiles/
|
||||
gnss/tilesets/
|
||||
gnss/uploads/
|
||||
|
||||
# Frontend artifacts
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
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
|
||||
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
|
||||
|
|
|
|||
47
gnss/main.py
47
gnss/main.py
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
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