fix: Restore all files lost during destructive rebase

A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-02-09 09:51:32 +01:00
parent f7487ee240
commit bfdaf63ba9
2009 changed files with 749983 additions and 1731 deletions

View File

@@ -0,0 +1,9 @@
"""
GeoEdu Service API Endpoints
"""
from .tiles import router as tiles_router
from .terrain import router as terrain_router
from .aoi import router as aoi_router
from .learning import router as learning_router
__all__ = ["tiles_router", "terrain_router", "aoi_router", "learning_router"]

304
geo-service/api/aoi.py Normal file
View File

@@ -0,0 +1,304 @@
"""
AOI (Area of Interest) API Endpoints
Handles polygon selection, validation, and Unity bundle generation
"""
from fastapi import APIRouter, HTTPException, Path, Query, BackgroundTasks
from fastapi.responses import JSONResponse, FileResponse
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
import uuid
import structlog
from config import settings
from models.aoi import AOIRequest, AOIResponse, AOIStatus, AOIManifest
from services.aoi_packager import AOIPackagerService
from services.osm_extractor import OSMExtractorService
logger = structlog.get_logger(__name__)
router = APIRouter()
# Initialize services
aoi_packager = AOIPackagerService()
osm_extractor = OSMExtractorService()
@router.post("", response_model=AOIResponse)
async def create_aoi(
request: AOIRequest,
background_tasks: BackgroundTasks,
):
"""
Create a new AOI (Area of Interest) for Unity 3D export.
Validates the polygon, checks size limits (max 4 km²), and queues
bundle generation. Returns immediately with a status URL.
The bundle will contain:
- Terrain heightmap
- OSM features (buildings, roads, water, etc.)
- Learning node positions
- Attribution information
"""
# Validate polygon
try:
area_km2 = aoi_packager.calculate_area_km2(request.polygon)
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Invalid polygon: {str(e)}")
# Check size limit
if area_km2 > settings.max_aoi_size_km2:
raise HTTPException(
status_code=400,
detail=f"AOI too large: {area_km2:.2f} km² exceeds maximum of {settings.max_aoi_size_km2} km²",
)
# Check if polygon is within Germany bounds
if not aoi_packager.is_within_germany(request.polygon):
raise HTTPException(
status_code=400,
detail="AOI must be within Germany. Bounds: [5.87°E, 47.27°N] to [15.04°E, 55.06°N]",
)
# Generate AOI ID
aoi_id = str(uuid.uuid4())
# Create AOI record
aoi_data = {
"id": aoi_id,
"polygon": request.polygon,
"theme": request.theme,
"quality": request.quality,
"area_km2": area_km2,
"status": AOIStatus.QUEUED,
"created_at": datetime.utcnow().isoformat(),
}
# Start background processing
background_tasks.add_task(
aoi_packager.process_aoi,
aoi_id=aoi_id,
polygon=request.polygon,
theme=request.theme,
quality=request.quality,
)
logger.info(
"AOI created",
aoi_id=aoi_id,
area_km2=area_km2,
theme=request.theme,
)
return AOIResponse(
aoi_id=aoi_id,
status=AOIStatus.QUEUED,
area_km2=area_km2,
estimated_size_mb=aoi_packager.estimate_bundle_size_mb(area_km2, request.quality),
message="AOI queued for processing",
)
@router.get("/{aoi_id}", response_model=AOIResponse)
async def get_aoi_status(
aoi_id: str = Path(..., description="AOI UUID"),
):
"""
Get the status of an AOI processing job.
Returns current status (queued, processing, completed, failed)
and download URLs when ready.
"""
aoi_data = await aoi_packager.get_aoi_status(aoi_id)
if aoi_data is None:
raise HTTPException(status_code=404, detail="AOI not found")
response = AOIResponse(
aoi_id=aoi_id,
status=aoi_data["status"],
area_km2=aoi_data.get("area_km2", 0),
estimated_size_mb=aoi_data.get("estimated_size_mb", 0),
message=aoi_data.get("message", ""),
)
# Add download URLs if completed
if aoi_data["status"] == AOIStatus.COMPLETED:
response.download_url = f"/api/v1/aoi/{aoi_id}/bundle.zip"
response.manifest_url = f"/api/v1/aoi/{aoi_id}/manifest.json"
response.completed_at = aoi_data.get("completed_at")
return response
@router.get("/{aoi_id}/manifest.json")
async def get_aoi_manifest(
aoi_id: str = Path(..., description="AOI UUID"),
):
"""
Get the Unity bundle manifest for an AOI.
The manifest contains:
- Terrain configuration
- Asset list and paths
- Learning node positions
- Attribution requirements
"""
manifest = await aoi_packager.get_manifest(aoi_id)
if manifest is None:
raise HTTPException(status_code=404, detail="Manifest not found or AOI not ready")
return JSONResponse(content=manifest)
@router.get("/{aoi_id}/bundle.zip")
async def download_aoi_bundle(
aoi_id: str = Path(..., description="AOI UUID"),
):
"""
Download the complete AOI bundle as a ZIP file.
Contains all assets needed for Unity 3D rendering:
- terrain.heightmap (16-bit PNG)
- osm_features.json (buildings, roads, etc.)
- learning_nodes.json (educational content positions)
- attribution.json (required license notices)
"""
bundle_path = await aoi_packager.get_bundle_path(aoi_id)
if bundle_path is None:
raise HTTPException(status_code=404, detail="Bundle not found or AOI not ready")
return FileResponse(
path=bundle_path,
filename=f"geo-lernwelt-{aoi_id[:8]}.zip",
media_type="application/zip",
)
@router.delete("/{aoi_id}")
async def delete_aoi(
aoi_id: str = Path(..., description="AOI UUID"),
):
"""
Delete an AOI and its bundle.
Implements DSGVO data minimization - users can delete their data.
"""
success = await aoi_packager.delete_aoi(aoi_id)
if not success:
raise HTTPException(status_code=404, detail="AOI not found")
logger.info("AOI deleted", aoi_id=aoi_id)
return {"message": "AOI deleted successfully", "aoi_id": aoi_id}
@router.get("/{aoi_id}/preview")
async def get_aoi_preview(
aoi_id: str = Path(..., description="AOI UUID"),
width: int = Query(512, ge=64, le=2048, description="Preview width"),
height: int = Query(512, ge=64, le=2048, description="Preview height"),
):
"""
Get a preview image of the AOI.
Returns a rendered preview showing terrain, OSM features,
and learning node positions.
"""
preview_data = await aoi_packager.generate_preview(aoi_id, width, height)
if preview_data is None:
raise HTTPException(status_code=404, detail="Preview not available")
from fastapi.responses import Response
return Response(
content=preview_data,
media_type="image/png",
)
@router.post("/validate")
async def validate_aoi_polygon(
polygon: dict,
):
"""
Validate an AOI polygon without creating it.
Checks:
- Valid GeoJSON format
- Within Germany bounds
- Within size limits
- Not self-intersecting
"""
try:
# Validate geometry
is_valid, message = aoi_packager.validate_polygon(polygon)
if not is_valid:
return {
"valid": False,
"error": message,
}
# Calculate area
area_km2 = aoi_packager.calculate_area_km2(polygon)
# Check bounds
within_germany = aoi_packager.is_within_germany(polygon)
# Check size
within_size_limit = area_km2 <= settings.max_aoi_size_km2
return {
"valid": is_valid and within_germany and within_size_limit,
"area_km2": round(area_km2, 3),
"within_germany": within_germany,
"within_size_limit": within_size_limit,
"max_size_km2": settings.max_aoi_size_km2,
"estimated_bundle_size_mb": aoi_packager.estimate_bundle_size_mb(area_km2, "medium"),
}
except Exception as e:
return {
"valid": False,
"error": str(e),
}
@router.get("/templates/mainau")
async def get_mainau_template():
"""
Get pre-configured AOI template for Mainau island (demo location).
Mainau is a small island in Lake Constance (Bodensee) -
perfect for educational geography lessons.
"""
return {
"name": "Insel Mainau",
"description": "Blumeninsel im Bodensee - ideal fuer Erdkunde-Unterricht",
"polygon": {
"type": "Polygon",
"coordinates": [
[
[9.1875, 47.7055],
[9.1975, 47.7055],
[9.1975, 47.7115],
[9.1875, 47.7115],
[9.1875, 47.7055],
]
],
},
"center": [9.1925, 47.7085],
"area_km2": 0.45,
"suggested_themes": ["topographie", "vegetation", "landnutzung"],
"features": [
"Schloss und Schlosskirche",
"Botanischer Garten",
"Bodensee-Ufer",
"Waldgebiete",
],
}

289
geo-service/api/learning.py Normal file
View File

@@ -0,0 +1,289 @@
"""
Learning Nodes API Endpoints
Generates and manages educational content for AOI regions
"""
from fastapi import APIRouter, HTTPException, Path, Query, BackgroundTasks
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from typing import Optional
import structlog
from config import settings
from models.learning_node import LearningNode, LearningNodeRequest, LearningTheme
from services.learning_generator import LearningGeneratorService
logger = structlog.get_logger(__name__)
router = APIRouter()
# Initialize learning generator service
learning_service = LearningGeneratorService()
class GenerateNodesRequest(BaseModel):
"""Request model for generating learning nodes."""
aoi_id: str = Field(..., description="AOI UUID to generate nodes for")
theme: LearningTheme = Field(LearningTheme.TOPOGRAPHIE, description="Learning theme")
difficulty: str = Field("mittel", pattern="^(leicht|mittel|schwer)$", description="Difficulty level")
node_count: int = Field(5, ge=1, le=20, description="Number of nodes to generate")
grade_level: Optional[str] = Field(None, description="Target grade level (e.g., '5-6', '7-8')")
language: str = Field("de", description="Content language")
class GenerateNodesResponse(BaseModel):
"""Response model for generated learning nodes."""
aoi_id: str
theme: str
nodes: list[LearningNode]
total_count: int
generation_model: str
@router.post("/generate", response_model=GenerateNodesResponse)
async def generate_learning_nodes(
request: GenerateNodesRequest,
):
"""
Generate learning nodes for an AOI using LLM.
Uses Ollama with the configured model to generate educational content
based on the AOI's geographic features and selected theme.
Themes:
- topographie: Landforms, elevation, terrain features
- landnutzung: Land use, settlement patterns, agriculture
- orientierung: Navigation, compass, map reading
- geologie: Rock types, geological formations
- hydrologie: Water features, drainage, watersheds
- vegetation: Plant communities, climate zones
"""
try:
nodes = await learning_service.generate_nodes(
aoi_id=request.aoi_id,
theme=request.theme,
difficulty=request.difficulty,
node_count=request.node_count,
grade_level=request.grade_level,
language=request.language,
)
logger.info(
"Learning nodes generated",
aoi_id=request.aoi_id,
theme=request.theme.value,
count=len(nodes),
)
return GenerateNodesResponse(
aoi_id=request.aoi_id,
theme=request.theme.value,
nodes=nodes,
total_count=len(nodes),
generation_model=settings.ollama_model,
)
except FileNotFoundError:
raise HTTPException(
status_code=404,
detail="AOI not found. Please create an AOI first.",
)
except ConnectionError:
raise HTTPException(
status_code=503,
detail="LLM service (Ollama) not available. Please check if Ollama is running.",
)
except Exception as e:
logger.error("Error generating learning nodes", error=str(e))
raise HTTPException(status_code=500, detail="Error generating learning nodes")
@router.get("/templates")
async def get_learning_templates():
"""
Get available learning theme templates.
Returns theme definitions with descriptions, suitable grade levels,
and example questions.
"""
return {
"themes": [
{
"id": "topographie",
"name": "Topographie",
"description": "Landschaftsformen, Höhen und Geländemerkmale",
"icon": "mountain",
"grade_levels": ["5-6", "7-8", "9-10"],
"example_questions": [
"Welche Höhe hat der höchste Punkt in diesem Gebiet?",
"Beschreibe die Hangneigung im nördlichen Bereich.",
"Wo befinden sich Täler und wo Bergrücken?",
],
"keywords": ["Höhe", "Hang", "Tal", "Berg", "Hügel", "Ebene"],
},
{
"id": "landnutzung",
"name": "Landnutzung",
"description": "Siedlungen, Landwirtschaft und Flächennutzung",
"icon": "home",
"grade_levels": ["5-6", "7-8", "9-10", "11-12"],
"example_questions": [
"Welche Arten von Gebäuden sind in diesem Bereich zu finden?",
"Wie viel Prozent der Fläche wird landwirtschaftlich genutzt?",
"Wo verläuft die Grenze zwischen Siedlung und Naturraum?",
],
"keywords": ["Siedlung", "Acker", "Wald", "Industrie", "Straße"],
},
{
"id": "orientierung",
"name": "Orientierung",
"description": "Kartenlesen, Kompass und Navigation",
"icon": "compass",
"grade_levels": ["5-6", "7-8"],
"example_questions": [
"In welcher Himmelsrichtung liegt der See?",
"Wie lang ist der Weg von A nach B?",
"Beschreibe den Weg vom Parkplatz zum Aussichtspunkt.",
],
"keywords": ["Norden", "Süden", "Entfernung", "Maßstab", "Legende"],
},
{
"id": "geologie",
"name": "Geologie",
"description": "Gesteinsarten und geologische Formationen",
"icon": "layers",
"grade_levels": ["7-8", "9-10", "11-12"],
"example_questions": [
"Welches Gestein dominiert in diesem Gebiet?",
"Wie sind die Felsformationen entstanden?",
"Welche Rolle spielt die Geologie für die Landschaft?",
],
"keywords": ["Gestein", "Formation", "Erosion", "Schicht", "Mineral"],
},
{
"id": "hydrologie",
"name": "Hydrologie",
"description": "Gewässer, Einzugsgebiete und Wasserkreislauf",
"icon": "droplet",
"grade_levels": ["5-6", "7-8", "9-10"],
"example_questions": [
"Wohin fließt das Wasser in diesem Gebiet?",
"Welche Gewässerarten sind vorhanden?",
"Wo könnte sich bei Starkregen Wasser sammeln?",
],
"keywords": ["Fluss", "See", "Bach", "Einzugsgebiet", "Quelle"],
},
{
"id": "vegetation",
"name": "Vegetation",
"description": "Pflanzengemeinschaften und Klimazonen",
"icon": "tree",
"grade_levels": ["5-6", "7-8", "9-10", "11-12"],
"example_questions": [
"Welche Waldtypen sind in diesem Gebiet zu finden?",
"Wie beeinflusst die Höhenlage die Vegetation?",
"Welche Pflanzen würdest du hier erwarten?",
],
"keywords": ["Wald", "Wiese", "Laubbaum", "Nadelbaum", "Höhenstufe"],
},
],
"difficulties": [
{"id": "leicht", "name": "Leicht", "description": "Grundlegende Beobachtungen"},
{"id": "mittel", "name": "Mittel", "description": "Verknüpfung von Zusammenhängen"},
{"id": "schwer", "name": "Schwer", "description": "Analyse und Transfer"},
],
"supported_languages": ["de", "en"],
}
@router.get("/{aoi_id}/nodes")
async def get_aoi_learning_nodes(
aoi_id: str = Path(..., description="AOI UUID"),
theme: Optional[LearningTheme] = Query(None, description="Filter by theme"),
):
"""
Get all learning nodes for an AOI.
Returns previously generated nodes, optionally filtered by theme.
"""
nodes = await learning_service.get_nodes_for_aoi(aoi_id, theme)
if nodes is None:
raise HTTPException(status_code=404, detail="AOI not found")
return {
"aoi_id": aoi_id,
"nodes": nodes,
"total_count": len(nodes),
}
@router.put("/{aoi_id}/nodes/{node_id}")
async def update_learning_node(
aoi_id: str = Path(..., description="AOI UUID"),
node_id: str = Path(..., description="Node UUID"),
node_update: LearningNode,
):
"""
Update a learning node (teacher review/edit).
Allows teachers to modify AI-generated content before use.
"""
success = await learning_service.update_node(aoi_id, node_id, node_update)
if not success:
raise HTTPException(status_code=404, detail="Node not found")
logger.info("Learning node updated", aoi_id=aoi_id, node_id=node_id)
return {"message": "Node updated successfully", "node_id": node_id}
@router.delete("/{aoi_id}/nodes/{node_id}")
async def delete_learning_node(
aoi_id: str = Path(..., description="AOI UUID"),
node_id: str = Path(..., description="Node UUID"),
):
"""
Delete a learning node.
"""
success = await learning_service.delete_node(aoi_id, node_id)
if not success:
raise HTTPException(status_code=404, detail="Node not found")
return {"message": "Node deleted successfully", "node_id": node_id}
@router.post("/{aoi_id}/nodes/{node_id}/approve")
async def approve_learning_node(
aoi_id: str = Path(..., description="AOI UUID"),
node_id: str = Path(..., description="Node UUID"),
):
"""
Approve a learning node for student use.
Only approved nodes will be visible to students.
"""
success = await learning_service.approve_node(aoi_id, node_id)
if not success:
raise HTTPException(status_code=404, detail="Node not found")
return {"message": "Node approved", "node_id": node_id}
@router.get("/statistics")
async def get_learning_statistics():
"""
Get statistics about learning node usage.
"""
stats = await learning_service.get_statistics()
return {
"total_nodes_generated": stats.get("total_nodes", 0),
"nodes_by_theme": stats.get("by_theme", {}),
"nodes_by_difficulty": stats.get("by_difficulty", {}),
"average_nodes_per_aoi": stats.get("avg_per_aoi", 0),
"most_popular_theme": stats.get("popular_theme", "topographie"),
}

230
geo-service/api/terrain.py Normal file
View File

@@ -0,0 +1,230 @@
"""
Terrain/DEM API Endpoints
Serves heightmap tiles from Copernicus DEM data
"""
from fastapi import APIRouter, HTTPException, Path, Query, Response
from fastapi.responses import JSONResponse
import structlog
from config import settings
from services.dem_service import DEMService
logger = structlog.get_logger(__name__)
router = APIRouter()
# Initialize DEM service
dem_service = DEMService()
@router.get("/{z}/{x}/{y}.png", response_class=Response)
async def get_heightmap_tile(
z: int = Path(..., ge=0, le=14, description="Zoom level"),
x: int = Path(..., ge=0, description="Tile X coordinate"),
y: int = Path(..., ge=0, description="Tile Y coordinate"),
):
"""
Get a heightmap tile as 16-bit PNG (Mapbox Terrain-RGB encoding).
Heightmaps are generated from Copernicus DEM GLO-30 (30m resolution).
The encoding allows for ~0.1m precision: height = -10000 + ((R * 256 * 256 + G * 256 + B) * 0.1)
"""
try:
tile_data = await dem_service.get_heightmap_tile(z, x, y)
if tile_data is None:
return Response(status_code=204) # No terrain data for this tile
return Response(
content=tile_data,
media_type="image/png",
headers={
"Cache-Control": "public, max-age=604800", # 7 days cache
"X-Tile-Source": "copernicus-dem",
},
)
except FileNotFoundError:
logger.warning("DEM data not found", z=z, x=x, y=y)
raise HTTPException(
status_code=503,
detail="DEM data not available. Please download Copernicus DEM first.",
)
except Exception as e:
logger.error("Error serving heightmap tile", z=z, x=x, y=y, error=str(e))
raise HTTPException(status_code=500, detail="Error serving heightmap tile")
@router.get("/hillshade/{z}/{x}/{y}.png", response_class=Response)
async def get_hillshade_tile(
z: int = Path(..., ge=0, le=14, description="Zoom level"),
x: int = Path(..., ge=0, description="Tile X coordinate"),
y: int = Path(..., ge=0, description="Tile Y coordinate"),
azimuth: float = Query(315, ge=0, le=360, description="Light azimuth in degrees"),
altitude: float = Query(45, ge=0, le=90, description="Light altitude in degrees"),
):
"""
Get a hillshade tile for terrain visualization.
Hillshade is rendered from DEM with configurable light direction.
Default light comes from northwest (azimuth=315) at 45° altitude.
"""
try:
tile_data = await dem_service.get_hillshade_tile(z, x, y, azimuth, altitude)
if tile_data is None:
return Response(status_code=204)
return Response(
content=tile_data,
media_type="image/png",
headers={
"Cache-Control": "public, max-age=604800",
"X-Hillshade-Azimuth": str(azimuth),
"X-Hillshade-Altitude": str(altitude),
},
)
except FileNotFoundError:
raise HTTPException(status_code=503, detail="DEM data not available")
except Exception as e:
logger.error("Error serving hillshade tile", z=z, x=x, y=y, error=str(e))
raise HTTPException(status_code=500, detail="Error serving hillshade tile")
@router.get("/contours/{z}/{x}/{y}.pbf", response_class=Response)
async def get_contour_tile(
z: int = Path(..., ge=0, le=14, description="Zoom level"),
x: int = Path(..., ge=0, description="Tile X coordinate"),
y: int = Path(..., ge=0, description="Tile Y coordinate"),
interval: int = Query(20, ge=5, le=100, description="Contour interval in meters"),
):
"""
Get contour lines as vector tile.
Contours are generated from DEM at the specified interval.
Useful for topographic map overlays.
"""
try:
tile_data = await dem_service.get_contour_tile(z, x, y, interval)
if tile_data is None:
return Response(status_code=204)
return Response(
content=tile_data,
media_type="application/x-protobuf",
headers={
"Content-Encoding": "gzip",
"Cache-Control": "public, max-age=604800",
"X-Contour-Interval": str(interval),
},
)
except FileNotFoundError:
raise HTTPException(status_code=503, detail="DEM data not available")
except Exception as e:
logger.error("Error serving contour tile", z=z, x=x, y=y, error=str(e))
raise HTTPException(status_code=500, detail="Error serving contour tile")
@router.get("/elevation")
async def get_elevation_at_point(
lat: float = Query(..., ge=47.27, le=55.06, description="Latitude"),
lon: float = Query(..., ge=5.87, le=15.04, description="Longitude"),
):
"""
Get elevation at a specific point.
Returns elevation in meters from Copernicus DEM.
Only works within Germany bounds.
"""
try:
elevation = await dem_service.get_elevation(lat, lon)
if elevation is None:
raise HTTPException(
status_code=404,
detail="No elevation data available for this location",
)
return {
"latitude": lat,
"longitude": lon,
"elevation_m": round(elevation, 1),
"source": "Copernicus DEM GLO-30",
"resolution_m": 30,
}
except FileNotFoundError:
raise HTTPException(status_code=503, detail="DEM data not available")
except Exception as e:
logger.error("Error getting elevation", lat=lat, lon=lon, error=str(e))
raise HTTPException(status_code=500, detail="Error getting elevation")
@router.post("/elevation/profile")
async def get_elevation_profile(
coordinates: list[list[float]],
samples: int = Query(100, ge=10, le=1000, description="Number of sample points"),
):
"""
Get elevation profile along a path.
Takes a list of [lon, lat] coordinates and returns elevations sampled along the path.
Useful for hiking/cycling route profiles.
"""
if len(coordinates) < 2:
raise HTTPException(status_code=400, detail="At least 2 coordinates required")
if len(coordinates) > 100:
raise HTTPException(status_code=400, detail="Maximum 100 coordinates allowed")
try:
profile = await dem_service.get_elevation_profile(coordinates, samples)
return {
"profile": profile,
"statistics": {
"min_elevation_m": min(p["elevation_m"] for p in profile if p["elevation_m"]),
"max_elevation_m": max(p["elevation_m"] for p in profile if p["elevation_m"]),
"total_ascent_m": sum(
max(0, profile[i + 1]["elevation_m"] - profile[i]["elevation_m"])
for i in range(len(profile) - 1)
if profile[i]["elevation_m"] and profile[i + 1]["elevation_m"]
),
"total_descent_m": sum(
max(0, profile[i]["elevation_m"] - profile[i + 1]["elevation_m"])
for i in range(len(profile) - 1)
if profile[i]["elevation_m"] and profile[i + 1]["elevation_m"]
),
},
"source": "Copernicus DEM GLO-30",
}
except FileNotFoundError:
raise HTTPException(status_code=503, detail="DEM data not available")
except Exception as e:
logger.error("Error getting elevation profile", error=str(e))
raise HTTPException(status_code=500, detail="Error getting elevation profile")
@router.get("/metadata")
async def get_dem_metadata():
"""
Get metadata about available DEM data.
"""
metadata = await dem_service.get_metadata()
return {
"name": "Copernicus DEM GLO-30",
"description": "Global 30m Digital Elevation Model",
"resolution_m": 30,
"coverage": "Germany (Deutschland)",
"bounds": [5.87, 47.27, 15.04, 55.06],
"vertical_datum": "EGM2008",
"horizontal_datum": "WGS84",
"license": "Copernicus Data (free, attribution required)",
"attribution": "© Copernicus Service Information 2024",
"data_available": metadata.get("data_available", False),
"tiles_generated": metadata.get("tiles_generated", 0),
}

221
geo-service/api/tiles.py Normal file
View File

@@ -0,0 +1,221 @@
"""
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)",
}