230 lines
6.9 KiB
Python
230 lines
6.9 KiB
Python
from typing import Any, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.v1.visualization import (
|
|
_parse_bbox,
|
|
get_bgp_anomalies_geojson,
|
|
get_bgp_collectors_geojson,
|
|
get_bgp_incidents_geojson,
|
|
get_cables_geojson,
|
|
get_landing_points_geojson,
|
|
get_satellites_geojson,
|
|
)
|
|
from app.api.v1.vessels import build_vessel_snapshot_response
|
|
from app.db.session import get_db
|
|
|
|
router = APIRouter()
|
|
|
|
DEFAULT_LAYER_LIMIT = 1000
|
|
MAX_LAYER_LIMIT = 5000
|
|
LOW_ZOOM_FEATURE_LIMIT = 500
|
|
|
|
|
|
def _clamp_limit(limit: int, zoom: int) -> tuple[int, bool]:
|
|
clamped = min(max(limit, 1), MAX_LAYER_LIMIT)
|
|
if zoom <= 3:
|
|
return min(clamped, LOW_ZOOM_FEATURE_LIMIT), clamped != limit or clamped > LOW_ZOOM_FEATURE_LIMIT
|
|
return clamped, clamped != limit
|
|
|
|
|
|
def _coordinate_in_bbox(coord: Any, bbox: tuple[float, float, float, float]) -> bool:
|
|
if not isinstance(coord, (list, tuple)) or len(coord) < 2:
|
|
return False
|
|
try:
|
|
lon = float(coord[0])
|
|
lat = float(coord[1])
|
|
except (TypeError, ValueError):
|
|
return False
|
|
lon_min, lat_min, lon_max, lat_max = bbox
|
|
return lon_min <= lon <= lon_max and lat_min <= lat <= lat_max
|
|
|
|
|
|
def _geometry_intersects_bbox(geometry: dict, bbox: tuple[float, float, float, float]) -> bool:
|
|
coordinates = geometry.get("coordinates")
|
|
geometry_type = geometry.get("type")
|
|
if geometry_type == "Point":
|
|
return _coordinate_in_bbox(coordinates, bbox)
|
|
if geometry_type in {"LineString", "MultiPoint"}:
|
|
return any(_coordinate_in_bbox(coord, bbox) for coord in coordinates or [])
|
|
if geometry_type in {"Polygon", "MultiLineString"}:
|
|
return any(
|
|
_coordinate_in_bbox(coord, bbox)
|
|
for line in coordinates or []
|
|
for coord in line
|
|
)
|
|
if geometry_type == "MultiPolygon":
|
|
return any(
|
|
_coordinate_in_bbox(coord, bbox)
|
|
for polygon in coordinates or []
|
|
for line in polygon
|
|
for coord in line
|
|
)
|
|
return False
|
|
|
|
|
|
def _guard_geojson_layer(
|
|
geojson: dict,
|
|
*,
|
|
bbox: tuple[float, float, float, float],
|
|
zoom: int,
|
|
limit: int,
|
|
) -> dict:
|
|
bounded_limit, limit_clamped = _clamp_limit(limit, zoom)
|
|
features = [
|
|
feature
|
|
for feature in geojson.get("features", [])
|
|
if _geometry_intersects_bbox(feature.get("geometry") or {}, bbox)
|
|
]
|
|
visible_count = len(features)
|
|
returned_features = features[:bounded_limit]
|
|
return {
|
|
**geojson,
|
|
"features": returned_features,
|
|
"visible_count": visible_count,
|
|
"returned_count": len(returned_features),
|
|
"diagnostics": {
|
|
"bbox_limited": True,
|
|
"limit": bounded_limit,
|
|
"limit_clamped": limit_clamped,
|
|
"truncated": visible_count > len(returned_features),
|
|
"degraded": zoom <= 3 or visible_count > len(returned_features),
|
|
"stats_scope": "viewport",
|
|
},
|
|
}
|
|
|
|
|
|
def _parse_layer_bbox(bbox: str) -> tuple[float, float, float, float]:
|
|
parsed = _parse_bbox(bbox)
|
|
if parsed is None:
|
|
raise HTTPException(status_code=400, detail="bbox is required")
|
|
return parsed
|
|
|
|
|
|
@router.get("/vessels/snapshot")
|
|
async def get_vessel_layer_snapshot(
|
|
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
|
zoom: int = Query(..., ge=1, le=20),
|
|
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
|
vessel_type: Optional[str] = Query(None, alias="type"),
|
|
since_minutes: int = Query(60, ge=1, le=1440),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
parsed_bbox = _parse_layer_bbox(bbox)
|
|
return await build_vessel_snapshot_response(
|
|
db,
|
|
bbox=parsed_bbox,
|
|
zoom=zoom,
|
|
limit=limit,
|
|
vessel_type=vessel_type,
|
|
since_minutes=since_minutes,
|
|
)
|
|
|
|
|
|
@router.get("/cables")
|
|
async def get_cable_layer(
|
|
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
|
zoom: int = Query(..., ge=1, le=20),
|
|
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
return _guard_geojson_layer(
|
|
await get_cables_geojson(db),
|
|
bbox=_parse_layer_bbox(bbox),
|
|
zoom=zoom,
|
|
limit=limit,
|
|
)
|
|
|
|
|
|
@router.get("/landing-points")
|
|
async def get_landing_point_layer(
|
|
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
|
zoom: int = Query(..., ge=1, le=20),
|
|
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
return _guard_geojson_layer(
|
|
await get_landing_points_geojson(db),
|
|
bbox=_parse_layer_bbox(bbox),
|
|
zoom=zoom,
|
|
limit=limit,
|
|
)
|
|
|
|
|
|
@router.get("/satellites")
|
|
async def get_satellite_layer(
|
|
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
|
zoom: int = Query(..., ge=1, le=20),
|
|
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
bounded_limit, _ = _clamp_limit(limit, zoom)
|
|
return _guard_geojson_layer(
|
|
await get_satellites_geojson(limit=bounded_limit, db=db),
|
|
bbox=_parse_layer_bbox(bbox),
|
|
zoom=zoom,
|
|
limit=limit,
|
|
)
|
|
|
|
|
|
@router.get("/bgp/anomalies")
|
|
async def get_bgp_anomaly_layer(
|
|
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
|
zoom: int = Query(..., ge=1, le=20),
|
|
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
|
severity: Optional[str] = Query(None),
|
|
status: Optional[str] = Query("active"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
bounded_limit, _ = _clamp_limit(limit, zoom)
|
|
return _guard_geojson_layer(
|
|
await get_bgp_anomalies_geojson(
|
|
severity=severity,
|
|
status=status,
|
|
limit=bounded_limit,
|
|
db=db,
|
|
),
|
|
bbox=_parse_layer_bbox(bbox),
|
|
zoom=zoom,
|
|
limit=limit,
|
|
)
|
|
|
|
|
|
@router.get("/bgp/incidents")
|
|
async def get_bgp_incident_layer(
|
|
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
|
zoom: int = Query(..., ge=1, le=20),
|
|
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
|
severity: Optional[str] = Query(None),
|
|
status: Optional[str] = Query("active"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
bounded_limit, _ = _clamp_limit(limit, zoom)
|
|
return _guard_geojson_layer(
|
|
await get_bgp_incidents_geojson(
|
|
severity=severity,
|
|
status=status,
|
|
limit=min(bounded_limit, 500),
|
|
db=db,
|
|
),
|
|
bbox=_parse_layer_bbox(bbox),
|
|
zoom=zoom,
|
|
limit=limit,
|
|
)
|
|
|
|
|
|
@router.get("/bgp/collectors")
|
|
async def get_bgp_collector_layer(
|
|
bbox: str = Query(..., description="lon_min,lat_min,lon_max,lat_max"),
|
|
zoom: int = Query(..., ge=1, le=20),
|
|
limit: int = Query(DEFAULT_LAYER_LIMIT, ge=1),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
return _guard_geojson_layer(
|
|
await get_bgp_collectors_geojson(db),
|
|
bbox=_parse_layer_bbox(bbox),
|
|
zoom=zoom,
|
|
limit=limit,
|
|
)
|