mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-28 10:01:31 +02:00
Merge branch 'main' of https://github.com/bigbodycobain/Shadowbroker into fix/287-rate-limit-proxy-aware
This commit is contained in:
+105
-10
@@ -98,6 +98,88 @@ def _current_etag(prefix: str = "") -> str:
|
||||
return f"{prefix}v{get_data_version()}-l{get_active_layers_version()}"
|
||||
|
||||
|
||||
# ── Issue #288: viewport-aware payloads ─────────────────────────────────────
|
||||
# Heavy, density-driven, time-sensitive layers that benefit from bbox
|
||||
# filtering. Light reference layers (datacenters, military_bases,
|
||||
# power_plants, satellites, weather, news, etc.) are intentionally NOT
|
||||
# in these sets — they ship world-scale even when bounds are supplied so
|
||||
# panning never reveals an "empty world" of static infrastructure.
|
||||
#
|
||||
# When the caller does NOT pass s/w/n/e, none of this runs and the response
|
||||
# is byte-for-byte identical to the pre-#288 behavior.
|
||||
_FAST_BBOX_HEAVY_KEYS: tuple[str, ...] = (
|
||||
"commercial_flights",
|
||||
"military_flights",
|
||||
"private_flights",
|
||||
"private_jets",
|
||||
"tracked_flights",
|
||||
"ships",
|
||||
"cctv",
|
||||
"uavs",
|
||||
"liveuamap",
|
||||
"gps_jamming",
|
||||
"sigint",
|
||||
"trains",
|
||||
)
|
||||
_SLOW_BBOX_HEAVY_KEYS: tuple[str, ...] = (
|
||||
"gdelt",
|
||||
"firms_fires",
|
||||
"kiwisdr",
|
||||
"scanners",
|
||||
"psk_reporter",
|
||||
)
|
||||
|
||||
|
||||
def _has_full_bbox(s, w, n, e) -> bool:
|
||||
return None not in (s, w, n, e)
|
||||
|
||||
|
||||
def _bbox_etag_suffix(s, w, n, e) -> str:
|
||||
"""Quantize bbox to 1° before mixing into the ETag.
|
||||
|
||||
The 20% padding inside _bbox_filter already absorbs sub-degree pans;
|
||||
quantizing here means small mouse drags don't blow the ETag cache
|
||||
on the client. Full-world bounds collapse to a single suffix.
|
||||
"""
|
||||
if not _has_full_bbox(s, w, n, e):
|
||||
return ""
|
||||
try:
|
||||
ss = math.floor(float(s))
|
||||
ww = math.floor(float(w))
|
||||
nn = math.ceil(float(n))
|
||||
ee = math.ceil(float(e))
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
# If the requested window covers basically the whole world, treat it as
|
||||
# "no bbox" for caching purposes so world-zoomed clients all hit the
|
||||
# same ETag and benefit from the existing 304 path.
|
||||
lat_span, lng_span = _bbox_spans(s, w, n, e)
|
||||
if lng_span >= 300 or lat_span >= 120:
|
||||
return ""
|
||||
return f"|bbox={ss},{ww},{nn},{ee}"
|
||||
|
||||
|
||||
def _apply_bbox_to_payload(payload: dict, heavy_keys: tuple[str, ...],
|
||||
s: float, w: float, n: float, e: float) -> dict:
|
||||
"""In-place filter the heavy-key collections in *payload* to a viewport.
|
||||
|
||||
Items without lat/lng are passed through (so e.g. summary blobs aren't
|
||||
accidentally dropped). The existing _bbox_filter helper applies a 20%
|
||||
pad and handles antimeridian crossings.
|
||||
"""
|
||||
lat_span, lng_span = _bbox_spans(s, w, n, e)
|
||||
# World-scale request → skip filtering entirely. Spares the CPU and
|
||||
# guarantees the response matches the no-params shape.
|
||||
if lng_span >= 300 or lat_span >= 120:
|
||||
return payload
|
||||
for key in heavy_keys:
|
||||
items = payload.get(key)
|
||||
if not isinstance(items, list) or not items:
|
||||
continue
|
||||
payload[key] = _bbox_filter(items, s, w, n, e)
|
||||
return payload
|
||||
|
||||
|
||||
def _json_safe(value):
|
||||
if isinstance(value, float):
|
||||
return value if math.isfinite(value) else None
|
||||
@@ -479,13 +561,14 @@ async def bootstrap_critical(request: Request):
|
||||
@limiter.limit("120/minute")
|
||||
async def live_data_fast(
|
||||
request: Request,
|
||||
s: float = Query(None, description="South bound (ignored)", ge=-90, le=90),
|
||||
w: float = Query(None, description="West bound (ignored)", ge=-180, le=180),
|
||||
n: float = Query(None, description="North bound (ignored)", ge=-90, le=90),
|
||||
e: float = Query(None, description="East bound (ignored)", ge=-180, le=180),
|
||||
s: float = Query(None, description="South bound — when all four bounds are supplied, heavy/dense layers (vessels, aircraft, sigint, CCTV, …) are filtered to this viewport with 20% padding. Static reference layers (satellites, etc.) always ship world-scale.", ge=-90, le=90),
|
||||
w: float = Query(None, description="West bound (see s)", ge=-180, le=180),
|
||||
n: float = Query(None, description="North bound (see s)", ge=-90, le=90),
|
||||
e: float = Query(None, description="East bound (see s)", ge=-180, le=180),
|
||||
initial: bool = Query(False, description="Return a capped startup payload for first paint"),
|
||||
):
|
||||
etag = _current_etag(prefix="fast|initial|" if initial else "fast|full|")
|
||||
bbox_suffix = _bbox_etag_suffix(s, w, n, e)
|
||||
etag = _current_etag(prefix=("fast|initial|" if initial else "fast|full|") + bbox_suffix.lstrip("|") + ("|" if bbox_suffix else ""))
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
from services.fetchers._store import (active_layers, get_latest_data_subset_refs, get_source_timestamps_snapshot)
|
||||
@@ -525,6 +608,11 @@ async def live_data_fast(
|
||||
payload = _cap_fast_startup_payload(payload)
|
||||
else:
|
||||
payload = _cap_fast_dashboard_payload(payload)
|
||||
# Issue #288: bbox filter heavy/dense layers only when all four bounds
|
||||
# are supplied. Without bounds, behaviour is byte-for-byte identical
|
||||
# to the pre-#288 implementation.
|
||||
if _has_full_bbox(s, w, n, e):
|
||||
payload = _apply_bbox_to_payload(payload, _FAST_BBOX_HEAVY_KEYS, s, w, n, e)
|
||||
return Response(content=orjson.dumps(_sanitize_payload(payload)), media_type="application/json",
|
||||
headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
|
||||
@@ -533,12 +621,13 @@ async def live_data_fast(
|
||||
@limiter.limit("60/minute")
|
||||
async def live_data_slow(
|
||||
request: Request,
|
||||
s: float = Query(None, description="South bound (ignored)", ge=-90, le=90),
|
||||
w: float = Query(None, description="West bound (ignored)", ge=-180, le=180),
|
||||
n: float = Query(None, description="North bound (ignored)", ge=-90, le=90),
|
||||
e: float = Query(None, description="East bound (ignored)", ge=-180, le=180),
|
||||
s: float = Query(None, description="South bound — when all four bounds are supplied, heavy/dense layers (gdelt, firms_fires, kiwisdr, scanners, psk_reporter) are filtered to this viewport with 20% padding. Static reference layers (datacenters, military bases, power plants, weather, news, …) always ship world-scale.", ge=-90, le=90),
|
||||
w: float = Query(None, description="West bound (see s)", ge=-180, le=180),
|
||||
n: float = Query(None, description="North bound (see s)", ge=-90, le=90),
|
||||
e: float = Query(None, description="East bound (see s)", ge=-180, le=180),
|
||||
):
|
||||
etag = _current_etag(prefix="slow|full|")
|
||||
bbox_suffix = _bbox_etag_suffix(s, w, n, e)
|
||||
etag = _current_etag(prefix="slow|full|" + bbox_suffix.lstrip("|") + ("|" if bbox_suffix else ""))
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
from services.fetchers._store import (active_layers, get_latest_data_subset_refs, get_source_timestamps_snapshot)
|
||||
@@ -592,6 +681,12 @@ async def live_data_slow(
|
||||
"crowdthreat": (d.get("crowdthreat") or []) if active_layers.get("crowdthreat", True) else [],
|
||||
"freshness": freshness,
|
||||
}
|
||||
# Issue #288: bbox filter heavy/dense layers only when all four bounds
|
||||
# are supplied. Static reference layers (datacenters, military bases,
|
||||
# power_plants, etc.) deliberately stay world-scale so panning never
|
||||
# hides the infrastructure overlay the operator already has on screen.
|
||||
if _has_full_bbox(s, w, n, e):
|
||||
payload = _apply_bbox_to_payload(payload, _SLOW_BBOX_HEAVY_KEYS, s, w, n, e)
|
||||
return Response(
|
||||
content=orjson.dumps(_sanitize_payload(payload), default=str, option=orjson.OPT_NON_STR_KEYS),
|
||||
media_type="application/json",
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Tests for issue #288: viewport bbox filtering on /api/live-data/{fast,slow}.
|
||||
|
||||
Behaviour contract:
|
||||
* Without s/w/n/e params, the response is byte-for-byte identical to the
|
||||
pre-#288 implementation. (No filtering, no extra fields, no ETag change.)
|
||||
* With s/w/n/e supplied, heavy/dense layers are filtered to that viewport
|
||||
with a 20% padding box.
|
||||
* Light reference layers (datacenters, military_bases, power_plants,
|
||||
satellites, news, weather, …) are NEVER filtered, even when bounds are
|
||||
supplied — panning must never reveal an "empty world" of infrastructure.
|
||||
* World-scale bounds (lng_span >= 300 OR lat_span >= 120) short-circuit
|
||||
filtering and share the global ETag.
|
||||
* The ETag includes a 1°-quantized bbox so two viewports never poison each
|
||||
other's 304 cache.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ───────────────────────── /api/live-data/fast ─────────────────────────────
|
||||
|
||||
|
||||
class TestFastBboxFiltering:
|
||||
def _seed_fast(self, monkeypatch):
|
||||
"""Plant deterministic heavy + light fixtures across the globe."""
|
||||
from services.fetchers import _store
|
||||
|
||||
# Heavy collections: dense across the world.
|
||||
commercial = [
|
||||
{"lat": -60.0, "lng": -120.0, "id": "f-sw"}, # south Pacific
|
||||
{"lat": 35.0, "lng": -75.0, "id": "f-ne"}, # eastern US
|
||||
{"lat": 35.0, "lng": 100.0, "id": "f-asia"}, # Asia
|
||||
]
|
||||
ships = [
|
||||
{"lat": -60.0, "lng": -120.0, "id": "s-sw"},
|
||||
{"lat": 35.0, "lng": -75.0, "id": "s-ne"},
|
||||
]
|
||||
cctv = [{"lat": 35.0, "lng": -75.0, "id": "c-1"}]
|
||||
|
||||
# Sigint heavy collection.
|
||||
sigint = [
|
||||
{"source": "meshtastic", "lat": 35.0, "lng": -75.0, "id": "sig-east"},
|
||||
{"source": "meshtastic", "lat": 35.0, "lng": 100.0, "id": "sig-asia"},
|
||||
]
|
||||
|
||||
# Light/reference layer — must NEVER be filtered.
|
||||
satellites = [
|
||||
{"lat": -60.0, "lng": -120.0, "id": "sat-sw"},
|
||||
{"lat": 35.0, "lng": -75.0, "id": "sat-ne"},
|
||||
{"lat": 35.0, "lng": 100.0, "id": "sat-asia"},
|
||||
]
|
||||
|
||||
monkeypatch.setitem(_store.latest_data, "commercial_flights", commercial)
|
||||
monkeypatch.setitem(_store.latest_data, "ships", ships)
|
||||
monkeypatch.setitem(_store.latest_data, "cctv", cctv)
|
||||
monkeypatch.setitem(_store.latest_data, "sigint", sigint)
|
||||
monkeypatch.setitem(_store.latest_data, "satellites", satellites)
|
||||
# Ensure all layers are on so the response includes them.
|
||||
for layer in (
|
||||
"flights", "ships_military", "ships_cargo", "ships_civilian",
|
||||
"ships_passenger", "ships_tracked_yachts", "cctv",
|
||||
"sigint_meshtastic", "sigint_aprs", "satellites",
|
||||
):
|
||||
monkeypatch.setitem(_store.active_layers, layer, True)
|
||||
|
||||
def test_no_bbox_returns_world_data(self, client, monkeypatch):
|
||||
self._seed_fast(monkeypatch)
|
||||
r = client.get("/api/live-data/fast")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
# All heavy fixtures pass through unchanged.
|
||||
assert len(data["commercial_flights"]) == 3
|
||||
assert len(data["ships"]) == 2
|
||||
assert len(data["sigint"]) == 2
|
||||
# Light layer also full.
|
||||
assert len(data["satellites"]) == 3
|
||||
|
||||
def test_bbox_filters_heavy_layers(self, client, monkeypatch):
|
||||
self._seed_fast(monkeypatch)
|
||||
# Box tightly around the eastern-US fixture (lat 35, lng -75).
|
||||
# ±5° → after 20% padding inside _bbox_filter, ~±6° window.
|
||||
r = client.get("/api/live-data/fast?s=30&w=-80&n=40&e=-70")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
# Heavy layers: only the eastern-US fixture survives.
|
||||
assert {f["id"] for f in data["commercial_flights"]} == {"f-ne"}
|
||||
assert {s["id"] for s in data["ships"]} == {"s-ne"}
|
||||
assert {c["id"] for c in data["cctv"]} == {"c-1"}
|
||||
assert {s["id"] for s in data["sigint"]} == {"sig-east"}
|
||||
|
||||
def test_bbox_does_not_filter_light_layers(self, client, monkeypatch):
|
||||
self._seed_fast(monkeypatch)
|
||||
r = client.get("/api/live-data/fast?s=30&w=-80&n=40&e=-70")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
# Satellites are a reference layer — must NOT be bbox-filtered.
|
||||
assert len(data["satellites"]) == 3
|
||||
|
||||
def test_world_scale_bbox_skips_filtering(self, client, monkeypatch):
|
||||
self._seed_fast(monkeypatch)
|
||||
# lng_span = 360 → treated as world-scale; same as no bbox.
|
||||
r = client.get("/api/live-data/fast?s=-90&w=-180&n=90&e=180")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert len(data["commercial_flights"]) == 3
|
||||
assert len(data["ships"]) == 2
|
||||
|
||||
def test_partial_bbox_is_treated_as_no_bbox(self, client, monkeypatch):
|
||||
self._seed_fast(monkeypatch)
|
||||
# Only three of four bounds → filtering must NOT engage.
|
||||
r = client.get("/api/live-data/fast?s=30&w=-80&n=40")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert len(data["commercial_flights"]) == 3
|
||||
|
||||
def test_etag_changes_with_bbox(self, client, monkeypatch):
|
||||
self._seed_fast(monkeypatch)
|
||||
r_world = client.get("/api/live-data/fast")
|
||||
r_local = client.get("/api/live-data/fast?s=30&w=-80&n=40&e=-70")
|
||||
assert r_world.status_code == 200
|
||||
assert r_local.status_code == 200
|
||||
etag_world = r_world.headers.get("etag")
|
||||
etag_local = r_local.headers.get("etag")
|
||||
assert etag_world and etag_local
|
||||
assert etag_world != etag_local, (
|
||||
"ETag must differ between world and regional bbox to prevent "
|
||||
"304 cache poisoning across viewports"
|
||||
)
|
||||
|
||||
def test_etag_stable_for_subdegree_pan(self, client, monkeypatch):
|
||||
self._seed_fast(monkeypatch)
|
||||
# Sub-degree pan should land in the same 1°-quantized bucket.
|
||||
r_a = client.get("/api/live-data/fast?s=30&w=-80&n=40&e=-70")
|
||||
r_b = client.get("/api/live-data/fast?s=30.3&w=-79.8&n=39.7&e=-70.4")
|
||||
assert r_a.headers.get("etag") == r_b.headers.get("etag")
|
||||
|
||||
def test_if_none_match_returns_304_for_same_bbox(self, client, monkeypatch):
|
||||
self._seed_fast(monkeypatch)
|
||||
r1 = client.get("/api/live-data/fast?s=30&w=-80&n=40&e=-70")
|
||||
etag = r1.headers.get("etag")
|
||||
r2 = client.get(
|
||||
"/api/live-data/fast?s=30&w=-80&n=40&e=-70",
|
||||
headers={"If-None-Match": etag},
|
||||
)
|
||||
assert r2.status_code == 304
|
||||
|
||||
|
||||
# ───────────────────────── /api/live-data/slow ─────────────────────────────
|
||||
|
||||
|
||||
class TestSlowBboxFiltering:
|
||||
def _seed_slow(self, monkeypatch):
|
||||
from services.fetchers import _store
|
||||
|
||||
# Heavy collections.
|
||||
gdelt = [
|
||||
{"lat": 35.0, "lng": -75.0, "id": "g-east"},
|
||||
{"lat": 35.0, "lng": 100.0, "id": "g-asia"},
|
||||
]
|
||||
firms_fires = [
|
||||
{"lat": 35.0, "lng": -75.0, "id": "fire-east"},
|
||||
{"lat": -10.0, "lng": 120.0, "id": "fire-ido"},
|
||||
]
|
||||
# Light/reference layers — must always ship in full.
|
||||
datacenters = [
|
||||
{"lat": 35.0, "lng": -75.0, "id": "dc-east"},
|
||||
{"lat": 35.0, "lng": 100.0, "id": "dc-asia"},
|
||||
{"lat": -10.0, "lng": 120.0, "id": "dc-ido"},
|
||||
]
|
||||
military_bases = [
|
||||
{"lat": 35.0, "lng": -75.0, "id": "mb-east"},
|
||||
{"lat": -10.0, "lng": 120.0, "id": "mb-ido"},
|
||||
]
|
||||
power_plants = [
|
||||
{"lat": 35.0, "lng": -75.0, "id": "pp-east"},
|
||||
{"lat": 35.0, "lng": 100.0, "id": "pp-asia"},
|
||||
]
|
||||
|
||||
monkeypatch.setitem(_store.latest_data, "gdelt", gdelt)
|
||||
monkeypatch.setitem(_store.latest_data, "firms_fires", firms_fires)
|
||||
monkeypatch.setitem(_store.latest_data, "datacenters", datacenters)
|
||||
monkeypatch.setitem(_store.latest_data, "military_bases", military_bases)
|
||||
monkeypatch.setitem(_store.latest_data, "power_plants", power_plants)
|
||||
for layer in (
|
||||
"global_incidents", "firms", "datacenters", "military_bases", "power_plants",
|
||||
):
|
||||
monkeypatch.setitem(_store.active_layers, layer, True)
|
||||
|
||||
def test_no_bbox_returns_world_data(self, client, monkeypatch):
|
||||
self._seed_slow(monkeypatch)
|
||||
r = client.get("/api/live-data/slow")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert len(data["gdelt"]) == 2
|
||||
assert len(data["firms_fires"]) == 2
|
||||
assert len(data["datacenters"]) == 3
|
||||
|
||||
def test_bbox_filters_heavy_layers(self, client, monkeypatch):
|
||||
self._seed_slow(monkeypatch)
|
||||
r = client.get("/api/live-data/slow?s=30&w=-80&n=40&e=-70")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert {g["id"] for g in data["gdelt"]} == {"g-east"}
|
||||
assert {f["id"] for f in data["firms_fires"]} == {"fire-east"}
|
||||
|
||||
def test_bbox_leaves_reference_layers_untouched(self, client, monkeypatch):
|
||||
"""Datacenters, bases, and power plants are infrastructure overlays —
|
||||
they must remain world-scale so panning never hides them."""
|
||||
self._seed_slow(monkeypatch)
|
||||
r = client.get("/api/live-data/slow?s=30&w=-80&n=40&e=-70")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert len(data["datacenters"]) == 3
|
||||
assert len(data["military_bases"]) == 2
|
||||
assert len(data["power_plants"]) == 2
|
||||
|
||||
def test_antimeridian_bbox(self, client, monkeypatch):
|
||||
from services.fetchers import _store
|
||||
# Box that straddles the antimeridian (Pacific): w=170, e=-170.
|
||||
gdelt = [
|
||||
{"lat": 0.0, "lng": 175.0, "id": "in-west"},
|
||||
{"lat": 0.0, "lng": -175.0, "id": "in-east"},
|
||||
{"lat": 0.0, "lng": 0.0, "id": "out-mid"},
|
||||
]
|
||||
monkeypatch.setitem(_store.latest_data, "gdelt", gdelt)
|
||||
monkeypatch.setitem(_store.active_layers, "global_incidents", True)
|
||||
r = client.get("/api/live-data/slow?s=-10&w=170&n=10&e=-170")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
ids = {g["id"] for g in data["gdelt"]}
|
||||
assert "in-west" in ids
|
||||
assert "in-east" in ids
|
||||
assert "out-mid" not in ids
|
||||
|
||||
|
||||
# ─────────────────── Direct helper coverage (defensive) ─────────────────────
|
||||
|
||||
|
||||
class TestHelpers:
|
||||
def test_has_full_bbox(self):
|
||||
from routers.data import _has_full_bbox
|
||||
assert _has_full_bbox(1, 2, 3, 4)
|
||||
assert not _has_full_bbox(None, 2, 3, 4)
|
||||
assert not _has_full_bbox(1, None, 3, 4)
|
||||
assert not _has_full_bbox(1, 2, None, 4)
|
||||
assert not _has_full_bbox(1, 2, 3, None)
|
||||
|
||||
def test_bbox_etag_suffix_quantizes(self):
|
||||
from routers.data import _bbox_etag_suffix
|
||||
a = _bbox_etag_suffix(30.1, -79.6, 39.9, -70.1)
|
||||
b = _bbox_etag_suffix(30.4, -79.2, 39.4, -70.8)
|
||||
assert a == b, "Sub-degree pan must collapse to the same ETag suffix"
|
||||
assert a.startswith("|bbox=")
|
||||
|
||||
def test_bbox_etag_suffix_world_collapses(self):
|
||||
from routers.data import _bbox_etag_suffix
|
||||
# World-scale → empty suffix (shares the global ETag).
|
||||
assert _bbox_etag_suffix(-90, -180, 90, 180) == ""
|
||||
|
||||
def test_bbox_etag_suffix_partial_is_empty(self):
|
||||
from routers.data import _bbox_etag_suffix
|
||||
assert _bbox_etag_suffix(None, -180, 90, 180) == ""
|
||||
|
||||
def test_apply_bbox_preserves_non_list_values(self):
|
||||
from routers.data import _apply_bbox_to_payload, _FAST_BBOX_HEAVY_KEYS
|
||||
payload = {
|
||||
"commercial_flights": [{"lat": 35, "lng": -75, "id": "x"}],
|
||||
"satellite_source": "tle", # not a list, must pass through
|
||||
"sigint_totals": {"total": 1}, # dict — must pass through
|
||||
}
|
||||
out = _apply_bbox_to_payload(dict(payload), _FAST_BBOX_HEAVY_KEYS, 30, -80, 40, -70)
|
||||
assert out["satellite_source"] == "tle"
|
||||
assert out["sigint_totals"] == {"total": 1}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
normalizeViewBounds,
|
||||
type ViewBounds,
|
||||
} from '@/lib/viewportPrivacy';
|
||||
import { setLiveDataBounds } from '@/lib/liveDataViewport';
|
||||
|
||||
const VIEWPORT_POST_DEBOUNCE_MS = 2500;
|
||||
const VIEWPORT_POST_MIN_INTERVAL_MS = 12000;
|
||||
@@ -70,6 +71,17 @@ export function useViewportBounds(
|
||||
window.dispatchEvent(new CustomEvent(VIEWPORT_COMMITTED_EVENT));
|
||||
}
|
||||
|
||||
// Issue #288: hand the same coarsened/expanded bounds to the live-data
|
||||
// poller so heavy collections in /api/live-data/{fast,slow} can be
|
||||
// scoped to the visible region. Static reference layers are unaffected
|
||||
// — see backend _FAST_BBOX_HEAVY_KEYS / _SLOW_BBOX_HEAVY_KEYS.
|
||||
setLiveDataBounds({
|
||||
south: preloadBounds.south,
|
||||
west: preloadBounds.west,
|
||||
north: preloadBounds.north,
|
||||
east: preloadBounds.east,
|
||||
});
|
||||
|
||||
// Debounce POSTing viewport bounds to backend for dynamic AIS stream filtering
|
||||
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { API_BASE } from "@/lib/api";
|
||||
import { mergeData, setBackendStatus as setStoreBackendStatus } from "./useDataStore";
|
||||
import { appendLiveDataBoundsParams } from "@/lib/liveDataViewport";
|
||||
|
||||
export type BackendStatus = 'connecting' | 'connected' | 'disconnected';
|
||||
|
||||
@@ -32,8 +33,8 @@ export async function forceRefreshLiveData(): Promise<void> {
|
||||
|
||||
try {
|
||||
const [fastRes, slowRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/api/live-data/fast`),
|
||||
fetch(`${API_BASE}/api/live-data/slow`),
|
||||
fetch(appendLiveDataBoundsParams(`${API_BASE}/api/live-data/fast`)),
|
||||
fetch(appendLiveDataBoundsParams(`${API_BASE}/api/live-data/slow`)),
|
||||
]);
|
||||
|
||||
if (fastRes.ok) {
|
||||
@@ -85,9 +86,13 @@ export const LAYER_TOGGLE_EVENT = 'sb:layer-toggle';
|
||||
/**
|
||||
* Polls the backend for fast and slow data tiers.
|
||||
*
|
||||
* All data is fetched globally (no bbox filtering) — the backend returns its
|
||||
* full in-memory cache and MapLibre culls off-screen entities on the GPU.
|
||||
* This eliminates the "empty map when zooming out" lag.
|
||||
* Issue #288: heavy, density-driven layers (vessels, aircraft, gdelt
|
||||
* events, fires, sigint, …) are bbox-scoped to the visible map area via
|
||||
* `appendLiveDataBoundsParams`. Static reference layers (datacenters,
|
||||
* military bases, power plants, satellites, weather, news, …) are NOT
|
||||
* filtered backend-side, so panning never reveals an "empty world" of
|
||||
* infrastructure. World-zoomed views skip bbox params entirely and hit
|
||||
* the shared ETag cache exactly like the pre-#288 behaviour.
|
||||
*
|
||||
* The AIS stream viewport POST (/api/viewport) is still handled separately
|
||||
* by useViewportBounds to limit upstream AIS ingestion.
|
||||
@@ -147,7 +152,9 @@ export function useDataPolling() {
|
||||
const useStartupPayload = !fetchedStartupFastPayload && !fastEtag.current;
|
||||
const headers: Record<string, string> = {};
|
||||
if (!useStartupPayload && fastEtag.current) headers['If-None-Match'] = fastEtag.current;
|
||||
const url = `${API_BASE}/api/live-data/fast${useStartupPayload ? '?initial=1' : ''}`;
|
||||
const url = appendLiveDataBoundsParams(
|
||||
`${API_BASE}/api/live-data/fast${useStartupPayload ? '?initial=1' : ''}`,
|
||||
);
|
||||
const res = await fetch(url, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
@@ -193,10 +200,13 @@ export function useDataPolling() {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (slowEtag.current) headers['If-None-Match'] = slowEtag.current;
|
||||
const res = await fetch(`${API_BASE}/api/live-data/slow`, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const res = await fetch(
|
||||
appendLiveDataBoundsParams(`${API_BASE}/api/live-data/slow`),
|
||||
{
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
if (res.status === 304) { scheduleNext('slow'); return; }
|
||||
if (res.ok) {
|
||||
slowEtag.current = res.headers.get('etag') || null;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Shared module-level state for the current map viewport bounds, used by
|
||||
* `useDataPolling` to scope `/api/live-data/{fast,slow}` to the visible
|
||||
* area when the user has zoomed in.
|
||||
*
|
||||
* Issue #288: the backend now bbox-filters dense layers (vessels, aircraft,
|
||||
* gdelt events, fires, sigint, …) when all four bounds are supplied. Light
|
||||
* reference layers stay world-scale. Heavy collections aren't sent over the
|
||||
* wire for parts of the planet the operator isn't looking at, which cuts
|
||||
* the steady-state poll from ~27 MB to ~5 MB for a typical regional view.
|
||||
*
|
||||
* No bounds set → callers omit the params entirely → backend ships full
|
||||
* world data (byte-identical to pre-#288 behaviour). This keeps the cold
|
||||
* boot path (where no map is mounted yet) and the world-zoomed view
|
||||
* unchanged.
|
||||
*/
|
||||
|
||||
export interface LiveDataBounds {
|
||||
south: number;
|
||||
west: number;
|
||||
north: number;
|
||||
east: number;
|
||||
}
|
||||
|
||||
let _current: LiveDataBounds | null = null;
|
||||
|
||||
/** True when lng_span ≥ 300 OR lat_span ≥ 120. Backend treats these as
|
||||
* world-scale and skips filtering — so the frontend doesn't bother sending
|
||||
* bounds at all, which keeps the ETag cache shared across operators in the
|
||||
* zoomed-out case. */
|
||||
function isEffectivelyWorld(bounds: LiveDataBounds): boolean {
|
||||
const latSpan = Math.max(0, bounds.north - bounds.south);
|
||||
let lngSpan = bounds.east - bounds.west;
|
||||
if (lngSpan < 0) lngSpan += 360;
|
||||
return lngSpan >= 300 || latSpan >= 120;
|
||||
}
|
||||
|
||||
/** Push the latest committed bounds. Called from `useViewportBounds`
|
||||
* whenever the map's bounds change enough to matter. Pass `null` to
|
||||
* fall back to world-scale fetching (e.g. on unmount). */
|
||||
export function setLiveDataBounds(bounds: LiveDataBounds | null): void {
|
||||
if (bounds === null) {
|
||||
_current = null;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(bounds.south) ||
|
||||
!Number.isFinite(bounds.west) ||
|
||||
!Number.isFinite(bounds.north) ||
|
||||
!Number.isFinite(bounds.east)
|
||||
) {
|
||||
_current = null;
|
||||
return;
|
||||
}
|
||||
if (isEffectivelyWorld(bounds)) {
|
||||
// World-zoomed → fetch globally, share the ETag cache across operators.
|
||||
_current = null;
|
||||
return;
|
||||
}
|
||||
_current = bounds;
|
||||
}
|
||||
|
||||
/** Read the current bounds, or `null` if the caller should fetch the full
|
||||
* world payload. Reader contract: must tolerate `null` and call without
|
||||
* bbox params in that case. */
|
||||
export function getLiveDataBounds(): LiveDataBounds | null {
|
||||
return _current;
|
||||
}
|
||||
|
||||
/** Append `s/w/n/e` query params to a URL when bounds are set, otherwise
|
||||
* return the URL unchanged. Centralised so all live-data callers stay in
|
||||
* sync about quantization and the world-scale skip rule. */
|
||||
export function appendLiveDataBoundsParams(url: string): string {
|
||||
const b = _current;
|
||||
if (!b) return url;
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
// Match backend ETag quantization (1° floor/ceil) so the client and
|
||||
// server agree on which bounds round to the same cache key.
|
||||
const s = Math.floor(b.south);
|
||||
const w = Math.floor(b.west);
|
||||
const n = Math.ceil(b.north);
|
||||
const e = Math.ceil(b.east);
|
||||
return `${url}${sep}s=${s}&w=${w}&n=${n}&e=${e}`;
|
||||
}
|
||||
Reference in New Issue
Block a user