444 lines
13 KiB
Python
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),
|
|
}
|