""" Tile Server API Endpoints Serves Vector Tiles from PMTiles or generates on-demand from PostGIS """ from fastapi import APIRouter, HTTPException, Path, Query, Response from fastapi.responses import JSONResponse import structlog from config import settings from services.tile_server import TileServerService logger = structlog.get_logger(__name__) router = APIRouter() # Initialize tile server service tile_service = TileServerService() @router.get("/{z}/{x}/{y}.pbf", response_class=Response) async def get_vector_tile( z: int = Path(..., ge=0, le=22, description="Zoom level"), x: int = Path(..., ge=0, description="Tile X coordinate"), y: int = Path(..., ge=0, description="Tile Y coordinate"), ): """ Get a vector tile in Protocol Buffers format. Returns OSM data as vector tiles suitable for MapLibre GL JS. Tiles are served from pre-generated PMTiles or cached on-demand. """ try: tile_data = await tile_service.get_tile(z, x, y) if tile_data is None: # Return empty tile (204 No Content is standard for empty tiles) return Response(status_code=204) return Response( content=tile_data, media_type="application/x-protobuf", headers={ "Content-Encoding": "gzip", "Cache-Control": "public, max-age=86400", # 24h cache "X-Tile-Source": "pmtiles", }, ) except FileNotFoundError: logger.warning("PMTiles file not found", z=z, x=x, y=y) raise HTTPException( status_code=503, detail="Tile data not available. Please run the data download script first.", ) except Exception as e: logger.error("Error serving tile", z=z, x=x, y=y, error=str(e)) raise HTTPException(status_code=500, detail="Error serving tile") @router.get("/style.json") async def get_maplibre_style( base_url: str = Query(None, description="Base URL for tile server"), ): """ Get MapLibre GL JS style specification. Returns a style document configured for the self-hosted tile server. """ # Use provided base_url or construct from settings if base_url is None: base_url = f"http://localhost:{settings.port}" style = { "version": 8, "name": "GeoEdu Germany", "metadata": { "description": "Self-hosted OSM tiles for DSGVO-compliant education", "attribution": "© OpenStreetMap contributors", }, "sources": { "osm": { "type": "vector", "tiles": [f"{base_url}/api/v1/tiles/{{z}}/{{x}}/{{y}}.pbf"], "minzoom": 0, "maxzoom": 14, "attribution": "© OpenStreetMap contributors (ODbL)", }, "terrain": { "type": "raster-dem", "tiles": [f"{base_url}/api/v1/terrain/{{z}}/{{x}}/{{y}}.png"], "tileSize": 256, "maxzoom": 14, "attribution": "© Copernicus DEM GLO-30", }, }, "sprite": "", "glyphs": "https://fonts.openmaptiles.org/{fontstack}/{range}.pbf", "layers": [ # Background { "id": "background", "type": "background", "paint": {"background-color": "#f8f4f0"}, }, # Water { "id": "water", "type": "fill", "source": "osm", "source-layer": "water", "paint": {"fill-color": "#a0c8f0"}, }, # Landuse - Parks { "id": "landuse-park", "type": "fill", "source": "osm", "source-layer": "landuse", "filter": ["==", "class", "park"], "paint": {"fill-color": "#c8e6c8", "fill-opacity": 0.5}, }, # Landuse - Forest { "id": "landuse-forest", "type": "fill", "source": "osm", "source-layer": "landuse", "filter": ["==", "class", "wood"], "paint": {"fill-color": "#94d294", "fill-opacity": 0.5}, }, # Buildings { "id": "building", "type": "fill", "source": "osm", "source-layer": "building", "minzoom": 13, "paint": {"fill-color": "#d9d0c9", "fill-opacity": 0.8}, }, # Roads - Minor { "id": "road-minor", "type": "line", "source": "osm", "source-layer": "transportation", "filter": ["all", ["==", "$type", "LineString"], ["in", "class", "minor", "service"]], "paint": {"line-color": "#ffffff", "line-width": 1}, }, # Roads - Major { "id": "road-major", "type": "line", "source": "osm", "source-layer": "transportation", "filter": ["all", ["==", "$type", "LineString"], ["in", "class", "primary", "secondary", "tertiary"]], "paint": {"line-color": "#ffc107", "line-width": 2}, }, # Roads - Highway { "id": "road-highway", "type": "line", "source": "osm", "source-layer": "transportation", "filter": ["all", ["==", "$type", "LineString"], ["==", "class", "motorway"]], "paint": {"line-color": "#ff6f00", "line-width": 3}, }, # Place labels { "id": "place-label", "type": "symbol", "source": "osm", "source-layer": "place", "layout": { "text-field": "{name}", "text-font": ["Open Sans Regular"], "text-size": 12, }, "paint": {"text-color": "#333333", "text-halo-color": "#ffffff", "text-halo-width": 1}, }, ], "terrain": {"source": "terrain", "exaggeration": 1.5}, } return JSONResponse(content=style) @router.get("/metadata") async def get_tile_metadata(): """ Get metadata about available tiles. Returns information about data coverage, zoom levels, and update status. """ metadata = await tile_service.get_metadata() return { "name": "GeoEdu Germany Tiles", "description": "Self-hosted OSM vector tiles for Germany", "format": "pbf", "scheme": "xyz", "minzoom": metadata.get("minzoom", 0), "maxzoom": metadata.get("maxzoom", 14), "bounds": metadata.get("bounds", [5.87, 47.27, 15.04, 55.06]), # Germany bbox "center": metadata.get("center", [10.45, 51.16, 6]), # Center of Germany "attribution": "© OpenStreetMap contributors (ODbL)", "data_available": metadata.get("data_available", False), "last_updated": metadata.get("last_updated"), } @router.get("/bounds") async def get_tile_bounds(): """ Get the geographic bounds of available tile data. Returns bounding box for Germany in [west, south, east, north] format. """ return { "bounds": [5.87, 47.27, 15.04, 55.06], # Germany bounding box "center": [10.45, 51.16], "description": "Germany (Deutschland)", }