Files
planet/backend/app/api/v1/layers.py
2026-05-12 17:15:02 +08:00

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