Files
smart-city-digital-twin-mar…/smart-app-city/backend/app/routes/gis.py

444 lines
13 KiB
Python

"""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),
}