"""GIS routes — layers, features, geocoding search, and reverse geocoding. All endpoints require JWT authentication (Bearer token). """ from __future__ import annotations from typing import Any, Optional from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.auth.jwt import get_current_user from app.database import get_session # --------------------------------------------------------------------------- # Router # --------------------------------------------------------------------------- router = APIRouter(prefix="/gis", tags=["GIS"]) # --------------------------------------------------------------------------- # GIS Layer model (in-memory store for now, can be replaced by SQLModel) # --------------------------------------------------------------------------- # Sample GIS layers for Martinique digital twin SAMPLE_LAYERS: list[dict[str, Any]] = [ { "id": "layer-zones", "name": "Zones", "description": "Administrative and operational zones of Martinique", "type": "vector", "feature_count": 0, }, { "id": "layer-sensors", "name": "Capteurs IoT", "description": "All IoT sensor locations", "type": "vector", "feature_count": 0, }, { "id": "layer-road-network", "name": "Réseau routier", "description": "Martinique road network", "type": "vector", "feature_count": 0, }, { "id": "layer-buildings", "name": "Bâtiments", "description": "Building footprints", "type": "vector", "feature_count": 0, }, { "id": "layer-environment", "name": "Environnement", "description": "Environmental data layers (air quality, temperature, humidity)", "type": "raster", "feature_count": 0, }, ] # Sample GeoJSON features per layer SAMPLE_FEATURES: dict[str, dict[str, Any]] = { "layer-zones": { "type": "FeatureCollection", "features": [ { "type": "Feature", "id": "zone-fort-france", "properties": { "name": "Fort-de-France", "description": "Capital city zone", "color": "#E53935", }, "geometry": { "type": "Polygon", "coordinates": [ [ [-61.083, 14.595], [-61.055, 14.595], [-61.055, 14.635], [-61.083, 14.635], [-61.083, 14.595], ] ], }, }, { "type": "Feature", "id": "zone-le-lamentin", "properties": { "name": "Le Lamentin", "description": "Industrial and commercial zone", "color": "#1E88E5", }, "geometry": { "type": "Polygon", "coordinates": [ [ [-61.015, 14.598], [-60.985, 14.598], [-60.985, 14.625], [-61.015, 14.625], [-61.015, 14.598], ] ], }, }, ], }, "layer-sensors": { "type": "FeatureCollection", "features": [ { "type": "Feature", "id": "sensor-temp-01", "properties": { "name": "Température Centre-Ville", "type": "temperature", "status": "active", "last_value": 28.5, "unit": "°C", }, "geometry": { "type": "Point", "coordinates": [-61.07, 14.61], }, }, { "type": "Feature", "id": "sensor-aq-01", "properties": { "name": "Qualité de l'air Schoelcher", "type": "air_quality", "status": "active", "last_value": 42.0, "unit": "AQI", }, "geometry": { "type": "Point", "coordinates": [-61.095, 14.615], }, }, ], }, "layer-road-network": { "type": "FeatureCollection", "features": [ { "type": "Feature", "id": "road-n1", "properties": { "name": "Route Nationale 1", "type": "primary", "surface": "asphalt", }, "geometry": { "type": "LineString", "coordinates": [ [-61.055, 14.61], [-61.02, 14.605], [-60.99, 14.61], ], }, }, ], }, "layer-buildings": { "type": "FeatureCollection", "features": [ { "type": "Feature", "id": "building-mairie", "properties": { "name": "Hôtel de Ville", "type": "public", "address": "1 Rue de l'Hôtel de Ville, Fort-de-France", }, "geometry": { "type": "Polygon", "coordinates": [ [ [-61.0715, 14.6055], [-61.0705, 14.6055], [-61.0705, 14.6065], [-61.0715, 14.6065], [-61.0715, 14.6055], ] ], }, }, ], }, "layer-environment": { "type": "FeatureCollection", "features": [], }, } # Sample geocoding results for Martinique addresses SAMPLE_ADDRESSES: list[dict[str, Any]] = [ { "id": "addr-1", "label": "Fort-de-France, Martinique", "street": "", "city": "Fort-de-France", "postal_code": "97200", "region": "Martinique", "country": "France", "latitude": 14.6161, "longitude": -61.0588, }, { "id": "addr-2", "label": "Le Lamentin, Martinique", "street": "", "city": "Le Lamentin", "postal_code": "97232", "region": "Martinique", "country": "France", "latitude": 14.6167, "longitude": -61.0000, }, { "id": "addr-3", "label": "Schoelcher, Martinique", "street": "", "city": "Schoelcher", "postal_code": "97233", "region": "Martinique", "country": "France", "latitude": 14.6167, "longitude": -61.0833, }, { "id": "addr-4", "label": "Le Robert, Martinique", "street": "", "city": "Le Robert", "postal_code": "97222", "region": "Martinique", "country": "France", "latitude": 14.6833, "longitude": -60.9500, }, { "id": "addr-5", "label": "Ducos, Martinique", "street": "", "city": "Ducos", "postal_code": "97224", "region": "Martinique", "country": "France", "latitude": 14.5833, "longitude": -60.9833, }, { "id": "addr-6", "label": "1 Rue de l'Hôtel de Ville, Fort-de-France", "street": "1 Rue de l'Hôtel de Ville", "city": "Fort-de-France", "postal_code": "97200", "region": "Martinique", "country": "France", "latitude": 14.6060, "longitude": -61.0710, }, { "id": "addr-7", "label": "Pointe du Bout, Trois-Îlets, Martinique", "street": "Pointe du Bout", "city": "Trois-Îlets", "postal_code": "97229", "region": "Martinique", "country": "France", "latitude": 14.5333, "longitude": -61.0333, }, { "id": "addr-8", "label": "Saint-Pierre, Martinique", "street": "", "city": "Saint-Pierre", "postal_code": "97250", "region": "Martinique", "country": "France", "latitude": 14.7500, "longitude": -61.1833, }, ] # =========================================================================== # HELPERS # =========================================================================== def _find_layer(layer_id: str) -> Optional[dict[str, Any]]: """Return the layer dict matching *layer_id*, or None.""" for layer in SAMPLE_LAYERS: if layer["id"] == layer_id: return layer return None # =========================================================================== # ENDPOINTS # =========================================================================== @router.get( "/layers", summary="List all available GIS layers", response_model=dict, ) async def list_layers( current_user: dict = Depends(get_current_user), ) -> dict: """Return the list of all available GIS layers.""" return { "layers": SAMPLE_LAYERS, "total": len(SAMPLE_LAYERS), } @router.get( "/layers/{layer_id}/features", summary="Get GeoJSON features for a specific layer", response_model=dict, ) async def get_layer_features( layer_id: str, current_user: dict = Depends(get_current_user), ) -> dict: """Return the GeoJSON FeatureCollection for the given *layer_id*. Raises 404 if the layer does not exist. """ layer = _find_layer(layer_id) if layer is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Layer '{layer_id}' not found", ) features = SAMPLE_FEATURES.get(layer_id, {"type": "FeatureCollection", "features": []}) return { "layer": layer, "geojson": features, } @router.get( "/search", summary="Search for an address (forward geocoding)", response_model=dict, ) async def search_address( q: str = Query(..., min_length=2, max_length=200, description="Search query (address, city, place name)"), limit: int = Query(10, ge=1, le=50, description="Maximum number of results"), current_user: dict = Depends(get_current_user), ) -> dict: """Search for an address or place name in Martinique. Returns a list of matching addresses with their coordinates. The search is case-insensitive and matches against city, street, postal code, and region fields. """ query_lower = q.lower().strip() results: list[dict[str, Any]] = [] for addr in SAMPLE_ADDRESSES: searchable = " ".join( filter( None, [ addr.get("street", ""), addr.get("city", ""), addr.get("postal_code", ""), addr.get("region", ""), addr.get("label", ""), ], ) ).lower() if query_lower in searchable: results.append(addr) if len(results) >= limit: break return { "query": q, "results": results, "total": len(results), } @router.get( "/reverse", summary="Reverse geocoding — find address from coordinates", response_model=dict, ) async def reverse_geocode( lat: float = Query(..., ge=-90.0, le=90.0, description="Latitude"), lng: float = Query(..., ge=-180.0, le=180.0, description="Longitude"), current_user: dict = Depends(get_current_user), ) -> dict: """Find the nearest known address for the given coordinates. Uses a simplified Euclidean distance calculation over the Martinique sample addresses. In production this would call an external geocoding service (Nominatim, Photon, etc.). """ # Find nearest sample address by approximate distance best: Optional[dict[str, Any]] = None best_dist = float("inf") deg_to_km = 111.0 # approximate km per degree at equator for addr in SAMPLE_ADDRESSES: dlat = (addr["latitude"] - lat) * deg_to_km dlng = (addr["longitude"] - lng) * deg_to_km dist = (dlat**2 + dlng**2) ** 0.5 if dist < best_dist: best_dist = dist best = addr if best is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="No known address near the given coordinates", ) return { "query": {"latitude": lat, "longitude": lng}, "result": best, "distance_km": round(best_dist, 3), }