This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/geo-service/services/tile_server.py
BreakPilot Dev 19855efacc
Some checks failed
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
ci/woodpecker/manual/build-ci-image Pipeline was successful
ci/woodpecker/manual/main Pipeline failed
feat: BreakPilot PWA - Full codebase (clean push without large binaries)
All services: admin-v2, studio-v2, website, ai-compliance-sdk,
consent-service, klausur-service, voice-service, and infrastructure.
Large PDFs and compiled binaries excluded via .gitignore.
2026-02-11 13:25:58 +01:00

187 lines
5.7 KiB
Python

"""
Tile Server Service
Serves vector tiles from PMTiles format or generates on-demand from PostGIS
"""
import os
import gzip
from typing import Optional
from datetime import datetime
import structlog
from pmtiles.reader import Reader as PMTilesReader
from pmtiles.tile import TileType
from config import settings
logger = structlog.get_logger(__name__)
class MMapFileReader:
"""Memory-mapped file reader for PMTiles."""
def __init__(self, path: str):
self.path = path
self._file = None
self._size = 0
def __enter__(self):
self._file = open(self.path, "rb")
self._file.seek(0, 2) # Seek to end
self._size = self._file.tell()
self._file.seek(0)
return self
def __exit__(self, *args):
if self._file:
self._file.close()
def read(self, offset: int, length: int) -> bytes:
"""Read bytes from file at offset."""
self._file.seek(offset)
return self._file.read(length)
def size(self) -> int:
"""Get file size."""
return self._size
class TileServerService:
"""
Service for serving vector tiles from PMTiles format.
PMTiles is a cloud-optimized format for tile archives that allows
random access to individual tiles without extracting the entire archive.
"""
def __init__(self):
self.pmtiles_path = settings.pmtiles_path
self.cache_dir = settings.tile_cache_dir
self._reader = None
self._metadata_cache = None
def _get_reader(self) -> Optional[PMTilesReader]:
"""Get or create PMTiles reader."""
if not os.path.exists(self.pmtiles_path):
logger.warning("PMTiles file not found", path=self.pmtiles_path)
return None
if self._reader is None:
try:
file_reader = MMapFileReader(self.pmtiles_path)
file_reader.__enter__()
self._reader = PMTilesReader(file_reader)
logger.info("PMTiles reader initialized", path=self.pmtiles_path)
except Exception as e:
logger.error("Failed to initialize PMTiles reader", error=str(e))
return None
return self._reader
async def get_tile(self, z: int, x: int, y: int) -> Optional[bytes]:
"""
Get a vector tile at the specified coordinates.
Args:
z: Zoom level
x: Tile X coordinate
y: Tile Y coordinate
Returns:
Tile data as gzipped protobuf, or None if not found
"""
# Check cache first
cache_path = os.path.join(self.cache_dir, str(z), str(x), f"{y}.pbf")
if os.path.exists(cache_path):
with open(cache_path, "rb") as f:
return f.read()
# Try to get from PMTiles
reader = self._get_reader()
if reader is None:
raise FileNotFoundError("PMTiles file not available")
try:
tile_data = reader.get_tile(z, x, y)
if tile_data is None:
return None
# Cache the tile
await self._cache_tile(z, x, y, tile_data)
return tile_data
except Exception as e:
logger.error("Error reading tile", z=z, x=x, y=y, error=str(e))
return None
async def _cache_tile(self, z: int, x: int, y: int, data: bytes):
"""Cache a tile to disk."""
cache_path = os.path.join(self.cache_dir, str(z), str(x))
os.makedirs(cache_path, exist_ok=True)
tile_path = os.path.join(cache_path, f"{y}.pbf")
with open(tile_path, "wb") as f:
f.write(data)
async def get_metadata(self) -> dict:
"""
Get metadata about the tile archive.
Returns:
Dictionary with metadata including bounds, zoom levels, etc.
"""
if self._metadata_cache is not None:
return self._metadata_cache
reader = self._get_reader()
if reader is None:
return {
"data_available": False,
"minzoom": 0,
"maxzoom": 14,
"bounds": [5.87, 47.27, 15.04, 55.06],
"center": [10.45, 51.16, 6],
}
try:
header = reader.header()
metadata = reader.metadata()
self._metadata_cache = {
"data_available": True,
"minzoom": header.get("minZoom", 0),
"maxzoom": header.get("maxZoom", 14),
"bounds": header.get("bounds", [5.87, 47.27, 15.04, 55.06]),
"center": header.get("center", [10.45, 51.16, 6]),
"tile_type": "mvt", # Mapbox Vector Tiles
"last_updated": datetime.fromtimestamp(
os.path.getmtime(self.pmtiles_path)
).isoformat() if os.path.exists(self.pmtiles_path) else None,
**metadata,
}
return self._metadata_cache
except Exception as e:
logger.error("Error reading metadata", error=str(e))
return {"data_available": False}
def clear_cache(self):
"""Clear the tile cache."""
import shutil
if os.path.exists(self.cache_dir):
shutil.rmtree(self.cache_dir)
os.makedirs(self.cache_dir)
logger.info("Tile cache cleared")
def get_cache_size_mb(self) -> float:
"""Get the current cache size in MB."""
total_size = 0
for dirpath, dirnames, filenames in os.walk(self.cache_dir):
for filename in filenames:
filepath = os.path.join(dirpath, filename)
total_size += os.path.getsize(filepath)
return total_size / (1024 * 1024)