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, type_filter=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, )