From 1546358450049c1498b10fb14e3f43676270d2f5 Mon Sep 17 00:00:00 2001 From: Jehan Azad Date: Sun, 6 Sep 2026 04:18:33 +0000 Subject: [PATCH 1/9] coverage: publish the evidence-only detection area (retina-analytics bump) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps retina-analytics to the evidence-only polygon: under FOV_MODE=off the published empirical_coverage.polygon is what the node has been SEEN to detect, with the declared beam wedge no longer clipping it away. test_public_location's fixture spread 30 calibration points along a single line, which opened one bin — enough for the old beam-clipped sector, not an area under the new rule. Six adjacent bearings give it a lobe with both a measured arc and the RX apex its displacement assertions read. Co-Authored-By: Claude Fable 5.1 --- backend/tests/test_public_location.py | 17 ++++++++++++++--- libs/retina-analytics | 2 +- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/backend/tests/test_public_location.py b/backend/tests/test_public_location.py index 7bf3002f..bc98081f 100644 --- a/backend/tests/test_public_location.py +++ b/backend/tests/test_public_location.py @@ -428,9 +428,20 @@ def registered_node(): state.node_analytics.register_node(_NODE_ID, dict(_NODE_CFG)) area = state.node_analytics.detection_areas[_NODE_ID] coverage = state.node_analytics.empirical_coverages[_NODE_ID] - # MIN_POINTS (20) calibration points before to_polygon() emits anything. - for i in range(30): - coverage.add_point(_TRUE_RX_LAT + 0.05 + i * 0.002, _TRUE_RX_LON + 0.06 + i * 0.002) + # MIN_POINTS (20) calibration points before to_polygon() emits anything, + # and the published polygon is evidence-only (FOV off), so a bin is drawn + # only once it holds FOV_OPEN_MIN_POINTS detections of its OWN. Thirty + # points spread over six adjacent bearings is a six-bin lobe with the rest + # of the compass closed — a polygon that has both a measured arc and the + # RX apex these assertions read. A single line of points, which this was, + # opens one bin and no longer forms an area at all. + for step in range(6): + rad = math.radians(30.0 + 5.0 * step) + for i in range(5): + range_km = 10.0 + i + lat = _TRUE_RX_LAT + range_km * math.cos(rad) / 111.19 + lon = _TRUE_RX_LON + range_km * math.sin(rad) / (111.19 * math.cos(math.radians(_TRUE_RX_LAT))) + coverage.add_point(lat, lon) for i in range(5): area.record_verified_detection(_TRUE_RX_LAT + 0.1 + i * 0.01, _TRUE_RX_LON + 0.1, f"abc{i:03d}") yield _NODE_ID diff --git a/libs/retina-analytics b/libs/retina-analytics index c58b662d..af59891f 160000 --- a/libs/retina-analytics +++ b/libs/retina-analytics @@ -1 +1 @@ -Subproject commit c58b662d764c8ef51cd003f98b21c3b69640d346 +Subproject commit af59891f5b78741b5625c5906dbf016b4a05f266 From a5193fdda69ff56d6417107c0091ccc5aef4dc9f Mon Sep 17 00:00:00 2001 From: Jehan Azad Date: Sun, 6 Sep 2026 04:22:00 +0000 Subject: [PATCH 2/9] nodes: publish a node_ref for every node, registered or not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every public payload that names a node named it by node_id: the map's node popups, the aircraft detail panel, the illuminator popup. A node_id comes off the board and is the name its owner chose — a run of them is a naming convention — so printing it hands a stranger a correlation key nobody agreed to publish. core.nodes.Node already carries the intended public handle, node_ref, but only nodes that registered through /v1/nodes have a row: the blah2 bridge's receivers and everything on the plain TCP protocol have none, which on the test deployment is all seven live nodes. services/node_ref.public_node_ref returns the stored ref when there is one and otherwise derives HMAC-SHA256(fuzz salt, "node_ref|" + node_id) rendered in mint_node_ref's own base36 alphabet, to the same 15-char shape — so registered and legacy nodes are not tellable apart by form. The salt is already this deployment's anonymity key, secret and stable across restarts, and the domain prefix keeps this frame clear of the location HMAC's. There is no fallback to the raw id: a lookup that fails derives, because "publish the id when the database is down" makes the leak conditional on exactly the moment nobody is watching. Wired into both /api/radar/analytics variants, /api/radar/nodes and the fresh per-node analytics route. No node API route or model changes, so contracts/nodes-v1.openapi.yaml does not move. Co-Authored-By: Claude Fable 5.1 --- backend/routes/analytics.py | 5 + backend/services/node_ref.py | 176 ++++++++++++++++++++ backend/services/tasks/analytics_refresh.py | 11 +- backend/tests/test_analytics_refresh.py | 52 ++++++ backend/tests/test_node_ref.py | 139 ++++++++++++++++ 5 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 backend/services/node_ref.py create mode 100644 backend/tests/test_node_ref.py diff --git a/backend/routes/analytics.py b/backend/routes/analytics.py index 93af0243..fd3e5bfe 100644 --- a/backend/routes/analytics.py +++ b/backend/routes/analytics.py @@ -11,6 +11,7 @@ from core import state from services import node_bias +from services.node_ref import public_node_ref from services.public_location import public_node_summary from services.publication import is_private @@ -41,6 +42,10 @@ async def radar_node_analytics(node_id: str): # the same receiver-geometry rewrite has to happen here too, or this route # is the hole the cached one closed. See services/public_location.py. summary = public_node_summary(node_id, summary) + # The same public handle the cached listing carries, for the same reason: + # this route is built fresh, so anything the listing adds has to be added + # here too or the two surfaces disagree. See services/node_ref.py. + summary = {**summary, "node_ref": public_node_ref(node_id)} # Backend-computed bias estimate from claim residuals — same conditional # shape as the manager's own blocks: present only once the node has # residual history. The trust block above already blends backend-fed diff --git a/backend/services/node_ref.py b/backend/services/node_ref.py new file mode 100644 index 00000000..00c76514 --- /dev/null +++ b/backend/services/node_ref.py @@ -0,0 +1,176 @@ +"""The public handle for a node: `node_ref`, never the raw `node_id`. + +A `node_id` comes off the board and is what Mender, the TCP handler and every +config file know the node as. It is also, on this deployment, a name its owner +chose — `radar3-retnode` names a machine, and a run of them names a fleet's +naming convention — so printing it on a public map hands a stranger a +correlation key the node's owner never agreed to publish. `core.nodes.Node` +already carries the answer: `node_ref`, minted at registration +(`services.node_auth.mint_node_ref`) as `"nde" + 12` base36 characters, +deliberately rotatable without reflashing the board. + +The gap this module closes is that most live nodes have no row. The `nodes` +table only holds nodes that registered through `/v1/nodes`; the blah2 bridge's +receivers and anything speaking the plain TCP protocol never do, and on the +test deployment (2026-09-06) that is all seven of them. So a ref has to exist +for an unregistered node too, and it has to be indistinguishable from a minted +one — a map where some nodes show `ndeXXXXXXXXXXXX` and the rest show +`radar3-retnode` publishes exactly the ids it was trying not to. + +For those, the ref is derived: `HMAC-SHA256(fuzz salt, "node_ref|" + node_id)` +rendered in the same base36 alphabet, truncated to the same 12 characters. + +- The salt is `services.public_location._salt()` — the configured + `NODE_FUZZ_SALT`, else the persisted runtime salt. It is already this + deployment's anonymity key, it is secret, and it survives restarts, so a + derived ref is stable for the life of the deployment without a table to + store it in. A deployment that rotates the salt re-anonymises its nodes, + which is the same thing rotating it already does to their positions. +- The `node_ref|` domain prefix separates this HMAC frame from the location + one (`public_location._frame_message`, which hashes a bare identity or an + identity plus a bounds label). Two frames sharing a key must not be able to + spell each other's messages, or a published ref would be a published sample + of the offset key. +- There is no fallback to the raw id. A node whose ref cannot be looked up is + derived; derivation needs only the salt, and the salt always resolves (see + `_persisted_salt`). "Publish the id when something goes wrong" would make + the leak conditional on a database being down, which is precisely when + nobody is watching. + +Registered nodes still win: their stored `node_ref` is the handle the rest of +the node API and the dashboard already use, and deriving a second one for them +would publish two names for one node. +""" + +from __future__ import annotations + +import hashlib +import hmac +import logging +import threading +import time + +logger = logging.getLogger(__name__) + +# How long a snapshot of the nodes table is reused. Refs change only when a +# node registers or is rotated, both human-speed events, and the alternative is +# a database round trip per published node per refresh cycle. Mirrors +# services/node_sites.py, which caches the same kind of thing for the same +# reason. +_TTL_S = 30.0 + +# After a failed refresh, retry sooner than the full TTL — the snapshot is +# older than it should be, not wrong. +_ERROR_RETRY_S = 5.0 + +_PREFIX = "nde" +_REF_CHARS = 12 # same shape as mint_node_ref, so the two are not tellable apart + +# Domain separator for the derivation HMAC. "|" cannot start a node id, and +# the location frame's messages never begin with this literal, so no node id +# can spell a message in the other frame. +_DOMAIN = "node_ref|" + +_lock = threading.Lock() +# node_id -> stored node_ref, from the last successful read of the nodes table. +_db_refs: dict[str, str] = {} +_expires_at: float = 0.0 +# (salt, node_id) -> derived ref. Keyed on the salt so a re-salted deployment +# (or a test that monkeypatches it) cannot read a stale ref back out — the same +# reason public_location keys its offset cache on the salt. Bounded by the +# number of nodes this process has ever published. +_derived: dict[tuple[str, str], str] = {} +# Whether the "no node table" case has been logged. It is the normal state of +# a deployment whose nodes all predate /v1/nodes, so it is worth saying once +# and worth never saying again. +_db_unavailable_logged = False + + +def _reset_for_tests() -> None: + """Drop the snapshot and the derived refs. Tests only.""" + global _expires_at, _db_unavailable_logged + with _lock: + _db_refs.clear() + _derived.clear() + _expires_at = 0.0 + _db_unavailable_logged = False + + +def _refs_from_db() -> dict[str, str]: + """{node_id: node_ref} for every registered node. + + Imported inside the function and driven by the synchronous-engine pattern + services/publication.py documents, for the reasons services/node_sites.py + gives: the callers are executor threads and route handlers, neither of + which can await, and this module must import cleanly in a process with no + database at all. + """ + from sqlalchemy import select + + from core.nodes import Node + from services.publication import _sync_engine + + with _sync_engine().connect() as conn: + rows = conn.execute(select(Node.node_id, Node.node_ref)) + return {node_id: ref for node_id, ref in rows if node_id and ref} + + +def _snapshot() -> dict[str, str]: + global _expires_at, _db_unavailable_logged + now = time.monotonic() + if now < _expires_at: + return _db_refs + with _lock: + if time.monotonic() < _expires_at: + return _db_refs + try: + _db_refs.clear() + _db_refs.update(_refs_from_db()) + _expires_at = time.monotonic() + _TTL_S + except Exception: + # No database, no table, or a transient failure. Every node then + # derives its ref, which is the answer for an unregistered node + # anyway — so this degrades to "nothing is registered", never to + # publishing an id. + if not _db_unavailable_logged: + _db_unavailable_logged = True + logger.exception("node_ref: no node registry available, deriving every public ref") + _expires_at = time.monotonic() + _ERROR_RETRY_S + return _db_refs + + +def _derived_ref(node_id: str) -> str: + """The HMAC-derived ref for a node with no registry row.""" + from services.node_auth import _ALPHABET + from services.public_location import _salt + + salt = _salt() + key = (salt, node_id) + cached = _derived.get(key) + if cached is not None: + return cached + + digest = hmac.new(salt.encode("utf-8"), (_DOMAIN + node_id).encode("utf-8"), hashlib.sha256).digest() + # Base36 over the digest as one big integer, least-significant digit first. + # Twelve characters is ~62 bits of the 256 available, the same width — and + # the same alphabet — mint_node_ref draws at random. + value = int.from_bytes(digest, "big") + base = len(_ALPHABET) + chars = [] + for _ in range(_REF_CHARS): + value, remainder = divmod(value, base) + chars.append(_ALPHABET[remainder]) + ref = _PREFIX + "".join(chars) + _derived[key] = ref + return ref + + +def public_node_ref(node_id: str) -> str: + """The public handle for a node: its registered ref, else a derived one. + + Never the node id. A missing id derives from the empty string rather than + passing through, for the reason public_offset_km hashes it: a missing id is + a config fault, and the safe reading of a config fault is not to publish + whatever was there. + """ + return _snapshot().get(node_id) or _derived_ref(node_id or "") diff --git a/backend/services/tasks/analytics_refresh.py b/backend/services/tasks/analytics_refresh.py index 3ba824f9..7c1967f1 100644 --- a/backend/services/tasks/analytics_refresh.py +++ b/backend/services/tasks/analytics_refresh.py @@ -24,6 +24,7 @@ from services.geo import bearing_deg, bistatic_delay_us, haversine_km, node_beam_params, point_in_beam from services.geo import valid_latlon as _valid_latlon from services.id_utils import multinode_hex_from_key +from services.node_ref import public_node_ref from services.node_sites import log_colocation_audit from services.public_location import ( fuzz_enabled, @@ -331,8 +332,15 @@ def _refresh_analytics_and_nodes(): # public_summaries drops it before public_node_summaries rewrites what is # left. Two separate promises, applied in the order they compose — there # is nothing to translate for a node that is not being published. + public_nodes = public_node_summaries(public_summaries(state.node_analytics.get_all_summaries())) + # The handle the map is allowed to print. Added once, before the real-only + # split, so both variants carry it: a node id names a machine its owner + # chose the name of, and every surface that shows a node has to have + # something else to show. See services/node_ref.py. + for nid, summary in public_nodes.items(): + summary["node_ref"] = public_node_ref(nid) analytics_data = { - "nodes": public_node_summaries(public_summaries(state.node_analytics.get_all_summaries())), + "nodes": public_nodes, "cross_node": public_cross_node(state.node_analytics.get_cross_node_analysis()), } state.latest_analytics_bytes = orjson.dumps(analytics_data, option=orjson.OPT_SERIALIZE_NUMPY) @@ -369,6 +377,7 @@ def _refresh_analytics_and_nodes(): "nodes": { nid: { "status": info.get("status"), + "node_ref": public_node_ref(nid), "name": info.get("config", {}).get("name", nid), "config_hash": info.get("config_hash"), "last_heartbeat": info.get("last_heartbeat"), diff --git a/backend/tests/test_analytics_refresh.py b/backend/tests/test_analytics_refresh.py index ac168e1e..8c84a56e 100644 --- a/backend/tests/test_analytics_refresh.py +++ b/backend/tests/test_analytics_refresh.py @@ -413,3 +413,55 @@ def test_a_stale_claim_still_reads_as_missed(self): assert entry["detected"] == 0 assert entry["missed"] == 1 assert entry["miss_rate"] == 1.0 + + +# ── Public node identifiers ────────────────────────────────────────────────── + + +class TestNodeRefInPublicPayloads: + """Every payload that names a node carries its public handle. + + The map has to print something for a node, and the node id is the name its + owner gave the machine — see services/node_ref.py. Added before the + real-only split, so both analytics variants and /api/radar/nodes agree. + """ + + NODE = "test-noderef-1" + + @pytest.fixture(autouse=True) + def _a_connected_node(self): + from core import state + + state.node_analytics.register_node(self.NODE, {"rx_lat": 33.45, "rx_lon": -112.07, "max_range_km": 50}) + state.connected_nodes[self.NODE] = { + "status": "connected", + "config": {"name": "noderef-test", "rx_lat": 33.45, "rx_lon": -112.07}, + "is_synthetic": False, + } + yield + state.connected_nodes.pop(self.NODE, None) + state.node_analytics.retire_node(self.NODE) + + def _refresh(self): + from services.tasks.analytics_refresh import _refresh_analytics_and_nodes + + _refresh_analytics_and_nodes() + + def test_both_analytics_variants_carry_it(self): + from core import state + from services.node_ref import public_node_ref + + self._refresh() + expected = public_node_ref(self.NODE) + for raw in (state.latest_analytics_bytes, state.latest_analytics_real_bytes): + node = orjson.loads(raw)["nodes"][self.NODE] + assert node["node_ref"] == expected + assert node["node_ref"] != self.NODE + + def test_the_nodes_payload_carries_it(self): + from core import state + from services.node_ref import public_node_ref + + self._refresh() + node = orjson.loads(state.latest_nodes_bytes)["nodes"][self.NODE] + assert node["node_ref"] == public_node_ref(self.NODE) diff --git a/backend/tests/test_node_ref.py b/backend/tests/test_node_ref.py new file mode 100644 index 00000000..a096d0d4 --- /dev/null +++ b/backend/tests/test_node_ref.py @@ -0,0 +1,139 @@ +"""Tests for services/node_ref.py — the public handle for every node. + +The property under test is that a public payload never carries a node id, for +registered and unregistered nodes alike. A ref that is derived for one node +and looked up for another must be indistinguishable in form, or the shape of +the string says which nodes are on the registry. +""" + +import asyncio +import os +import re + +import pytest + +os.environ.setdefault("RETINA_ENV", "test") + +from core.nodes import Node # noqa: E402 +from core.users import async_session_maker # noqa: E402 +from services import node_ref, public_location # noqa: E402 +from services.node_ref import public_node_ref # noqa: E402 + +_SALT = "test-salt-for-node-ref" +_ID = "radar3-retnode" + +# What mint_node_ref produces: "nde" and twelve base36 characters. +_REF_RE = re.compile(r"^nde[0-9a-z]{12}$") + + +@pytest.fixture(autouse=True) +def _fixed_salt(monkeypatch): + """A salt fixed here, so a failure never depends on the runtime salt file.""" + monkeypatch.setenv("NODE_FUZZ_SALT", _SALT) + public_location._reset_for_tests() + node_ref._reset_for_tests() + yield + public_location._reset_for_tests() + node_ref._reset_for_tests() + + +@pytest.fixture() +def seed_node(): + """seed_node(node_id, node_ref) — write a registry row and drop the cache.""" + + def _seed(node_id: str, ref: str) -> None: + async def _go(): + async with async_session_maker() as session: + session.add(Node(node_id=node_id, node_ref=ref)) + await session.commit() + + asyncio.run(_go()) + # asyncio.run() clears the loop on exit (3.12); conftest's _clean_db + # restores one for the same reason. + asyncio.set_event_loop(asyncio.new_event_loop()) + node_ref._reset_for_tests() + + return _seed + + +class TestDerivedRef: + """The unregistered case, which on this deployment is every live node.""" + + def test_it_has_the_shape_of_a_minted_ref(self): + assert _REF_RE.match(public_node_ref(_ID)) + + def test_it_is_stable_across_calls(self): + first = public_node_ref(_ID) + node_ref._reset_for_tests() # not merely reading the memo back + assert public_node_ref(_ID) == first + + def test_different_nodes_get_different_refs(self): + assert public_node_ref(_ID) != public_node_ref("radar3a-retnode") + + def test_the_salt_moves_every_ref(self, monkeypatch): + """Rotating the fuzz salt re-anonymises nodes, as it does positions.""" + before = public_node_ref(_ID) + monkeypatch.setattr(public_location, "_salt", lambda: "a-different-salt") + node_ref._reset_for_tests() + assert public_node_ref(_ID) != before + + def test_it_is_never_the_node_id(self): + for node_id in (_ID, "ret7e2ca6f6", "", "nde000000000000"): + assert public_node_ref(node_id) != node_id + + def test_a_missing_id_does_not_pass_through(self): + """A config fault must not publish whatever was in the field.""" + assert _REF_RE.match(public_node_ref("")) + + def test_the_domain_prefix_separates_it_from_the_location_hmac(self): + """Both frames hash a node id under the fuzz salt. + + Without the "node_ref|" domain the two messages would be the same + string, and a published ref would be a published sample of the digest + the location offset is drawn from. + """ + import hashlib + import hmac + + undomained = hmac.new(_SALT.encode(), _ID.encode(), hashlib.sha256).digest() + assert hmac.new(_SALT.encode(), f"node_ref|{_ID}".encode(), hashlib.sha256).digest() != undomained + # And no node id can spell another frame's message: the derivation is + # keyed on a prefix a node id cannot start with. + assert public_node_ref(_ID) != public_node_ref(f"node_ref|{_ID}") + + +class TestRegisteredRef: + def test_the_stored_ref_wins_over_derivation(self, seed_node): + derived = public_node_ref(_ID) + seed_node(_ID, "nde0123456789ab") + assert public_node_ref(_ID) == "nde0123456789ab" + assert public_node_ref(_ID) != derived + + def test_an_unregistered_node_still_derives(self, seed_node): + seed_node(_ID, "nde0123456789ab") + other = public_node_ref("ret7e2ca6f6") + assert _REF_RE.match(other) + assert other != "nde0123456789ab" + + +class TestPerNodeAnalyticsRoute: + """GET /api/radar/analytics/{node_id} is built fresh, not from the cache. + + The cached listing's coverage is in test_analytics_refresh.py; this is the + other surface, which has to be wired separately or the two disagree. + """ + + def test_the_route_carries_the_ref(self): + from fastapi.testclient import TestClient + + from core import state + from main import app + + state.node_analytics.register_node(_ID, {"rx_lat": 34.85, "rx_lon": -82.40, "max_range_km": 50}) + try: + with TestClient(app, raise_server_exceptions=False) as client: + body = client.get(f"/api/radar/analytics/{_ID}").json() + assert body["node_ref"] == public_node_ref(_ID) + assert body["node_ref"] != _ID + finally: + state.node_analytics.retire_node(_ID) From 667ab72a9c601751943774453678ffc93eb51be7 Mon Sep 17 00:00:00 2001 From: Jehan Azad Date: Sun, 6 Sep 2026 04:30:05 +0000 Subject: [PATCH 3/9] map: one marker per site, node_ref labels, measured coverage only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three display bugs, one theme: the map was showing configuration as if it were measurement, and machine names as if they were public handles. One marker per receive SITE. Co-located receivers are published at exactly equal coordinates so the pair leaks one sample of its position instead of two; the map drew a marker and an uncertainty disc per node, so the two glyphs stacked (only the top one could be clicked) and the doubled fill made a shared site read as better located than a lone node. A site now carries one disc, a count badge on the glyph, and a popup block per node with its own Show/Hide control — a marker click at a shared site has no single node to mean. Single-node sites behave as before. Nodes are named by node_ref. Node popups, the detail panel's claiming node and detecting-node list, and the illuminator popup all printed the raw node_id, which is the name the operator gave the machine. They print nodeLabel() now; node_id stays the join key throughout. No theoretical beam is drawn anywhere. The dashed Yagi sector was a declared azimuth and width — configuration nobody surveyed — drawn as a detection area, and the node with the LEAST evidence got the boldest wedge (full-strength cone when it had no measured polygon at all). The coverage layer, the selected-node overlay and the contributing-node highlights now draw the empirical polygon or nothing, node popups quote measured reach instead of declared range and beam, and the gap diagnostic tests membership with point-in-polygon against the measured coverage rather than isInBeam against the declared wedge. geo.ts keeps yagiSectorPositions and its tests; nothing draws with it. Co-Authored-By: Claude Fable 5.1 --- frontend/src/components/LiveAircraftMap.css | 40 +++ frontend/src/components/LiveAircraftMap.tsx | 267 +++++++++--------- .../components/map/AircraftDetailPanel.tsx | 12 +- .../src/components/map/InBeamDiagnostic.tsx | 76 ++--- frontend/src/components/map/Toolbar.tsx | 4 +- frontend/src/components/map/geo.test.ts | 33 +++ frontend/src/components/map/geo.ts | 35 +++ frontend/src/components/map/hooks.ts | 4 + frontend/src/components/map/icons.ts | 33 ++- frontend/src/components/map/index.ts | 4 +- frontend/src/components/map/nodeSites.test.ts | 90 ++++++ frontend/src/components/map/nodeSites.ts | 110 ++++++++ frontend/src/types.ts | 21 ++ 13 files changed, 550 insertions(+), 179 deletions(-) create mode 100644 frontend/src/components/map/nodeSites.test.ts create mode 100644 frontend/src/components/map/nodeSites.ts diff --git a/frontend/src/components/LiveAircraftMap.css b/frontend/src/components/LiveAircraftMap.css index f013dfff..181a1439 100644 --- a/frontend/src/components/LiveAircraftMap.css +++ b/frontend/src/components/LiveAircraftMap.css @@ -604,6 +604,46 @@ border: none !important; } +/* Count badge on a receive site shared by more than one node. Sits on the + glyph's shoulder rather than beside it so the marker's anchor — and + therefore the point it claims to be at — does not move when a site gains a + second receiver. */ +.node-badge { + position: absolute; + top: -3px; + right: -5px; + min-width: 12px; + height: 12px; + padding: 0 2px; + border-radius: 6px; + background: #facc15; + color: #0a162a; + font-size: 9px; + font-weight: 700; + line-height: 12px; + text-align: center; + box-shadow: 0 0 4px rgba(250, 204, 21, 0.75); +} + +/* Per-node "Show"/"Hide" control in a shared site's popup. A site with two + receivers has no single node for a marker click to select, so the choice + moves into the popup. */ +.node-select { + margin-top: 3px; + padding: 1px 7px; + border: 1px solid rgba(250, 204, 21, 0.5); + border-radius: 3px; + background: rgba(250, 204, 21, 0.12); + color: #facc15; + font-size: 0.7rem; + font-weight: 600; + cursor: pointer; +} + +.node-select:hover { + background: rgba(250, 204, 21, 0.24); +} + /* ── Bottom playback bar ─────────────────────────────────────── */ .playback-bar { diff --git a/frontend/src/components/LiveAircraftMap.tsx b/frontend/src/components/LiveAircraftMap.tsx index e9f9a51f..d8edcc26 100644 --- a/frontend/src/components/LiveAircraftMap.tsx +++ b/frontend/src/components/LiveAircraftMap.tsx @@ -36,9 +36,11 @@ import { drIconState, getAircraftColor, solveUncertaintyRadiusM, - nodeIcon, - yagiSectorPositions, + nodeSiteIcon, uncertaintyDiscRadiusM, + nodeLabel, + groupNodesBySite, + polygonMaxReachKm, FitBounds, ViewportTracker, MapClickClear, @@ -781,21 +783,33 @@ const BasemapLayer = memo(function BasemapLayer({ url }) { ); }); -/* ── NodeMarkersLayer: SVG CircleMarkers for synthetic nodes + divIcon for the - real radar node. - Background reason: 914 DOM divs with drop-shadow filters caused severe - pan/zoom jank, so the bulk synthetic fleet stays on cheap SVG circles in - a single overlay. But the real node is the one the user is actually - tracking, and a 5 px disc was getting lost under nearby aircraft icons — - so it gets the larger glowing divIcon (a handful of DOM nodes is fine). ── */ -const NodeMarkersLayer = memo(function NodeMarkersLayer({ visibleNodes, onSelectNode }) { - return visibleNodes.map((n) => { - const isSynth = n.node_id?.startsWith("synth-"); +/* ── NodeMarkersLayer: one marker per receive SITE, not per node. + Co-located receivers are published at exactly equal coordinates on + purpose — they share one fuzz offset so the pair leaks one sample of its + position instead of two (backend/services/node_sites.py). A marker per + node therefore stacked two identical glyphs and two identical + uncertainty discs on one point: the lower node could not be clicked at + all, and the doubled fill made a shared site look MORE precisely located + than a lone one. Sites carry a count badge instead, and the popup lists + each node at the site. + + Background reason for the two glyph kinds: 914 DOM divs with drop-shadow + filters caused severe pan/zoom jank, so the bulk synthetic fleet stays on + cheap SVG circles in a single overlay. But a real node is the one the + user is actually tracking, and a 5 px disc was getting lost under nearby + aircraft icons — so it gets the larger glowing divIcon (a handful of DOM + nodes is fine). A site counts as synthetic only when every node at it + is: one real receiver there means the site is real. ── */ +const NodeMarkersLayer = memo(function NodeMarkersLayer({ visibleNodes, onSelectNode, selectedNodeId }) { + return groupNodesBySite(visibleNodes).map((site) => { + const multi = site.nodes.length > 1; // Every published rx coordinate is displaced by the backend; the disc is - // how the map admits it, at the radius the feed itself declares. Not - // special-cased by node kind — a synthetic node that ever carries the + // how the map admits it, at the radius the feed itself declares. One per + // site — the members share the coordinate and therefore the disc, and two + // stacked fills would read as a tighter answer than either node gave. + // Not special-cased by node kind — a synthetic node that ever carries the // field gets one too, because the disclosure follows the data. - const discRadiusM = uncertaintyDiscRadiusM(n.location_uncertainty_km); + const discRadiusM = uncertaintyDiscRadiusM(site.location_uncertainty_km); // A soft blob, not a ring: a crisp edge would read as a surveyed // boundary, and the true receiver is no likelier just inside the rim than // at the centre. The blur is a CSS filter (screen-space, so the edge @@ -804,7 +818,7 @@ const NodeMarkersLayer = memo(function NodeMarkersLayer({ visibleNodes, onSelect // which is also what keeps the node reachable at far zoom. const disc = discRadiusM > 0 ? ( ) : null; - const uncertaintyLine = discRadiusM > 0 - ? <>Location approximate: ±{n.location_uncertainty_km} km
- : null; - if (isSynth) { + const popup = ( + + {multi && <>{site.nodes.length} nodes at this site
} + {discRadiusM > 0 && <>Location approximate: ±{site.location_uncertainty_km} km
} + {site.nodes.map((n, i) => ( + + {i > 0 &&
} + {nodeLabel(n)}
+ {/* Only measured coverage is quoted. The declared beam azimuth, + width and range used to be printed here; they are + configuration, most nodes' aim was never surveyed, and beside + a calibration-point count they read as measurements. */} + {n.empirical_polygon && n.empirical_polygon.length >= 3 + ? <>Coverage: measured from {n.empirical_n_points} calibration pts, + reach ≤ {polygonMaxReachKm(n.rx_lat, n.rx_lon, n.empirical_polygon)} km + : <>Coverage: not yet measured ({n.empirical_n_points || 0} calibration pts)} + {multi && ( + <> +
+ {/* At a shared site the marker click can no longer stand for + "select this node" — there are two — so each block carries + its own control and the click just opens the popup. */} + + + )} +
+ ))} +
+ ); + // A single-node site keeps the old behaviour: clicking the marker toggles + // that node's overlay and opens the popup. + const clickHandlers = multi ? undefined : { click: () => onSelectNode(site.nodes[0].node_id) }; + if (site.isSynth) { return ( - + {disc} onSelectNode(n.node_id) }} + eventHandlers={clickHandlers} > - - {n.node_id}
- {uncertaintyLine} - Beam: {n.beam_azimuth_deg}° / {n.beam_width_deg}°
- {n.max_bistatic_range_km != null - ? <>Bistatic range: {n.max_bistatic_range_km} km
- : <>Range: {n.max_range_km} km
} - {n.empirical_polygon && n.empirical_polygon.length >= 3 - ? <>Coverage: empirical, {n.empirical_n_points} calibration pts - : <>Coverage: theoretical ({n.empirical_n_points || 0} calibration pts)} -
+ {popup}
); } return ( - + {disc} onSelectNode(n.node_id) }} + eventHandlers={clickHandlers} > - - {n.node_id}
- {uncertaintyLine} - Beam: {n.beam_azimuth_deg}° / {n.beam_width_deg}°
- {n.max_bistatic_range_km != null - ? <>Bistatic range: {n.max_bistatic_range_km} km
- : <>Range: {n.max_range_km} km
} - {n.empirical_polygon && n.empirical_polygon.length >= 3 - ? <>Coverage: empirical, {n.empirical_n_points} calibration pts - : <>Coverage: theoretical ({n.empirical_n_points || 0} calibration pts)} -
+ {popup}
); @@ -875,49 +900,35 @@ const NodeMarkersLayer = memo(function NodeMarkersLayer({ visibleNodes, onSelect const CoverageLayer = memo(function CoverageLayer({ visibleNodes, showCoverage }) { if (!showCoverage) return null; return visibleNodes.map((n) => { - if (n.empirical_polygon && n.empirical_polygon.length >= 3) { - // Blurred edge as presentational honesty, not as the privacy mechanism: - // the coordinates underneath are already fuzzed server-side, and the - // polygon is approximate by construction anyway (a rigid translation of - // the calibrated shape onto a fuzzed anchor). A 1.5 px stroke under the - // blur reads as a glow, i.e. as a boundary the data never established — - // so the outline goes and the fill alone carries the region. - return ( - - ); - } - // The theoretical fallback stays sharp on purpose: it is a declared model - // sector, already dashed to say so, and blurring it would conflate "we - // measured this, roughly" with "we never measured this at all". + // Measured coverage or nothing. A node with no polygon used to get a + // dashed theoretical Yagi sector drawn from its declared azimuth and + // width — numbers nobody surveyed — which put a confident wedge on the map + // for a node that had never been shown to detect anything. "We have not + // measured this yet" is drawn as empty space, which is what it is. + if (!n.empirical_polygon || n.empirical_polygon.length < 3) return null; + // Blurred edge as presentational honesty, not as the privacy mechanism: + // the coordinates underneath are already fuzzed server-side, and the + // polygon is approximate by construction anyway (a rigid translation of + // the calibrated shape onto a fuzzed anchor). A 1.5 px stroke under the + // blur reads as a glow, i.e. as a boundary the data never established — + // so the outline goes and the fill alone carries the region. return ( ); @@ -937,7 +948,8 @@ const IlluminatorsLayer = memo(function IlluminatorsLayer({ visibleNodes, showIl if (Math.abs(n.tx_lat) < 1e-6 && Math.abs(n.tx_lon) < 1e-6) continue; const key = `${n.tx_lat.toFixed(4)},${n.tx_lon.toFixed(4)}`; if (!byTx.has(key)) byTx.set(key, { lat: n.tx_lat, lon: n.tx_lon, nodes: [] }); - byTx.get(key).nodes.push(n.node_id); + // Labels, not ids: this popup is as public as the node popups are. + byTx.get(key).nodes.push(nodeLabel(n)); } return [...byTx.entries()].map(([key, tx]) => ( { + const m = {}; + for (const n of nodes) m[n.node_id] = n; + return m; + }, [nodes]); + const nodeLabelFor = useCallback((nodeId) => nodeLabel(nodesById[nodeId]), [nodesById]); /* ── Local UI state ─────────────────────────────────────────── */ // URL-hash deep-link state — parsed once at mount. Anything we find here @@ -1109,10 +1130,10 @@ export default function LiveAircraftMap() { const [showFilters, setShowFilters] = useState(false); const [showStats, setShowStats] = usePersistedState("tf.layer.stats", initialLayers?.stats ?? true); const [showRangeRings, setShowRangeRings] = usePersistedState("tf.layer.rangeRings", initialLayers?.rangeRings ?? false); - // Beam-gap diagnostic defaults OFF: it draws one line per (aircraft, node) - // pair, so a metro-scoped fleet whose nodes all cover the same airspace turns - // the map into a thicket. Still available from the "Beam gaps" toolbar button - // and the `b` URL-hash layer. + // Coverage-gap diagnostic defaults OFF: it draws one line per (aircraft, + // node) pair, so a metro-scoped fleet whose nodes all cover the same airspace + // turns the map into a thicket. Still available from the "Coverage gaps" + // toolbar button and the `b` URL-hash layer. // Storage key is versioned (.v2) so the new default reaches anyone who // already has the old `tf.layer.inBeamDiag: true` persisted in localStorage — // without it, every existing user keeps seeing the lines. @@ -1957,7 +1978,7 @@ export default function LiveAircraftMap() { {/* Node markers — uses full `nodes` list (not viewport-culled) so it only re-renders every 30s when node data refreshes, not on every pan/zoom. SVG circles all share one composited layer — no per-element pan cost. */} - + {/* Illuminators (TX towers our nodes use) — off by default, deduped per transmitter */} @@ -1967,14 +1988,6 @@ export default function LiveAircraftMap() { const sn = visibleNodes.find((n) => n.node_id === selectedNodeId) || nodes.find((n) => n.node_id === selectedNodeId); if (!sn) return null; const hasEmpirical = Array.isArray(sn.empirical_polygon) && sn.empirical_polygon.length >= 3; - const conePositions = yagiSectorPositions( - sn.rx_lat, sn.rx_lon, - sn.tx_lat, sn.tx_lon, - sn.beam_azimuth_deg, - sn.beam_width_deg ?? 42, - sn.max_range_km ?? 50, - sn.max_bistatic_range_km, - ); // Find aircraft detected by this node (those whose node_id matches) const nodeAircraft = radarAircraft.filter((ac) => ac.node_id === selectedNodeId); return ( @@ -2001,21 +2014,12 @@ export default function LiveAircraftMap() { interactive={false} /> )} - {/* Theoretical Yagi cone — stays sharp and dashed like the - always-on fallback: a declared model sector must not borrow - the blurred edge that means "measured, roughly". Faint - reference behind empirical; full highlight when no empirical data */} - + {/* No theoretical cone here either: selecting a node used to + draw its declared Yagi sector, faint behind the measured + area and at full strength when there was none — so the + node with the LEAST evidence got the boldest wedge. A + node with nothing measured now shows its marker, its + transmitter and its detections, and no area at all. */} {/* TX tower marker */} {sn.tx_lat && sn.tx_lon && ( = 3; return ( - {/* Coverage area — the empirical polygon carries the same soft, + {/* Coverage area — the measured polygon, with the same soft, strokeless edge as the always-on CoverageLayer (see the note - there); the Yagi fallback below stays sharp and dashed because - it is a declared model, not a measurement. Fill raised to 0.18 - to replace the prominence of the dropped 1.5 px stroke. */} - {hasEmpirical ? ( + there). Fill raised to 0.18 to replace the prominence of the + dropped 1.5 px stroke. A contributing node with nothing + measured contributes no area: it keeps the ring and the line + to the aircraft, which is what says it contributed. */} + {hasEmpirical && ( - ) : ( - )} {/* Prominent node marker ring */} )} diff --git a/frontend/src/components/map/AircraftDetailPanel.tsx b/frontend/src/components/map/AircraftDetailPanel.tsx index 44ed4b5b..eebb5500 100644 --- a/frontend/src/components/map/AircraftDetailPanel.tsx +++ b/frontend/src/components/map/AircraftDetailPanel.tsx @@ -12,7 +12,13 @@ import { solveUncertaintyRadiusM, } from "./uncertainty"; -export default function AircraftDetailPanel({ ac, onClose, groundTruth, trails, computeError, detectingNodes = [], solveHistory = null }) { +/** + * `nodeLabelFor` maps a node id to the handle the map is allowed to print + * (map/nodeSites.ts). Node ids stay the join key everywhere — `ac.node_id`, + * `detectingNodes` — and only the text changes; the default keeps the panel + * usable on its own (in a test, say) without ever falling back to the id. + */ +export default function AircraftDetailPanel({ ac, onClose, groundTruth, trails, computeError, detectingNodes = [], solveHistory = null, nodeLabelFor = (_nodeId) => "unlisted node" }) { if (!ac) return null; const err = computeError(ac.hex, ac); @@ -217,7 +223,7 @@ export default function AircraftDetailPanel({ ac, onClose, groundTruth, trails, {isAdsbSingleNode && (
Claimed detection
- + - {detectingNodes.join(", ")} + {detectingNodes.map(nodeLabelFor).join(", ")} ({detectingNodes.length}) ) diff --git a/frontend/src/components/map/InBeamDiagnostic.tsx b/frontend/src/components/map/InBeamDiagnostic.tsx index c2724493..c0307ead 100644 --- a/frontend/src/components/map/InBeamDiagnostic.tsx +++ b/frontend/src/components/map/InBeamDiagnostic.tsx @@ -2,22 +2,27 @@ import { memo, useEffect, useRef } from "react"; import { useMap } from "react-leaflet"; import L from "leaflet"; -import { isInBeam } from "./geo"; +import { pointInPolygon, haversineDistanceKm } from "./geo"; import { groundTruthKey } from "./constants"; -/* ── InBeamDiagnostic: flags ADS-B aircraft inside a node's beam that - have no recent confirmed detection from that node. Renders a - dashed red polyline from the node's RX position to the aircraft — - one per (aircraft, node) pair — so the "missing link" is +/* ── InBeamDiagnostic: flags ADS-B aircraft inside a node's MEASURED + coverage that have no recent confirmed detection from that node. + Renders a dashed red polyline from the node's RX position to the + aircraft — one per (aircraft, node) pair — so the "missing link" is geometrically visible. Reads detectionsRef as the recently-detected oracle — a "hex|node_id" → ts map covering every detection shape (single-node and multinode), TTL-pruned so its grace period matches - the spec (don't flag a detection that only just expired). Thresholds - are tightened to 0.9 × beam width and 0.95 × max range to avoid - flagging aircraft that are only momentarily clipping the edges. ── */ - -const BEAM_WIDTH_FACTOR = 0.9; -const MAX_RANGE_FACTOR = 0.95; + the spec (don't flag a detection that only just expired). + + Membership used to be a declared-beam test (azimuth, width, range, + tightened by fudge factors to stop edge-clipping aircraft flagging). + Those numbers are configuration, so the layer was reporting gaps + against a wedge the node had never been shown to cover: a mis-declared + azimuth painted the map red, and a node whose real lobe sat outside its + declared one had its true gaps hidden. The test is now the node's own + empirical polygon, which is what "this node can see here" means, and a + node with no measured coverage yet is skipped entirely — there is + nothing to be missing from. ── */ const InBeamDiagnostic = memo(function InBeamDiagnostic({ detectionsRef, groundTruthRef, nodesByIdRef, smoothRef }) { const map = useMap(); @@ -40,23 +45,30 @@ const InBeamDiagnostic = memo(function InBeamDiagnostic({ detectionsRef, groundT // Pre-resolve the node list once per tick, so the O(truth × nodes) loop // below rejects distant pairs with two comparisons instead of a - // haversine + bearing — enabling this layer on a dense testmap used to - // hang the tab. + // point-in-polygon walk — enabling this layer on a dense testmap used + // to hang the tab. The bounding box comes from the polygon itself (its + // furthest vertex), so the prefilter can never exclude a point the + // polygon test would have accepted. // // rx_lat/rx_lon are the server-published coordinates, displaced from the // operator's true position by the backend; no true receiver position - // reaches the browser. The backend derives its own published beam - // geometry from the same anchor, so in-beam rays drawn from it agree - // with the served arcs by construction. + // reaches the browser. The polygon is translated rigidly onto that same + // anchor, so a ray drawn from it agrees with the served coverage by + // construction. const nodeList = []; for (const [nodeId, node] of Object.entries(nodes)) { const rxLat = node.rx_lat; const rxLon = node.rx_lon; - const { beam_azimuth_deg: azimuth, beam_width_deg: beamWidth, max_range_km: maxRange } = node; - if (rxLat == null || rxLon == null || azimuth == null || beamWidth == null || maxRange == null) continue; - const reachKm = maxRange * MAX_RANGE_FACTOR; + const polygon = node.empirical_polygon; + if (rxLat == null || rxLon == null) continue; + if (!Array.isArray(polygon) || polygon.length < 3) continue; + let reachKm = 0; + for (const [vLat, vLon] of polygon) { + const d = haversineDistanceKm(rxLat, rxLon, vLat, vLon); + if (d > reachKm) reachKm = d; + } nodeList.push({ - nodeId, rxLat, rxLon, azimuth, beamWidth, reachKm, + nodeId, rxLat, rxLon, polygon, reachKm, latPadDeg: reachKm / 111 + 0.01, }); } @@ -65,29 +77,29 @@ const InBeamDiagnostic = memo(function InBeamDiagnostic({ detectionsRef, groundT if (!Array.isArray(trail) || trail.length === 0) continue; const last = trail[trail.length - 1]; if (!last) continue; - // Beam membership is tested against the raw ground-truth sample — + // Coverage membership is tested against the raw ground-truth sample — // the freshest real fix. A dead-reckoned position extrapolates the // last velocity and, for a stalled/lost track, can glide across a - // beam edge and fabricate (or hide) a gap, so it must NOT drive the - // in/out decision. - const beamLat = last[0]; - const beamLon = last[1]; - if (beamLat == null || beamLon == null) continue; + // coverage edge and fabricate (or hide) a gap, so it must NOT drive + // the in/out decision. + const fixLat = last[0]; + const fixLon = last[1]; + if (fixLat == null || fixLon == null) continue; // The drawn endpoint, by contrast, prefers the dead-reckoned position // (same source that draws the aircraft dot) so the line's far end // lands on the icon rather than lagging behind it by one update. const s = smooth[groundTruthKey(hex)]; - const acLat = s ? s.lat : beamLat; - const acLon = s ? s.lon : beamLon; - const kmPerDegLon = 111 * Math.cos(beamLat * (Math.PI / 180)); + const acLat = s ? s.lat : fixLat; + const acLon = s ? s.lon : fixLon; + const kmPerDegLon = 111 * Math.cos(fixLat * (Math.PI / 180)); for (const n of nodeList) { // Cheap box reject before any trig. - if (Math.abs(beamLat - n.rxLat) > n.latPadDeg) continue; - if (Math.abs(beamLon - n.rxLon) * kmPerDegLon > n.reachKm + 1) continue; + if (Math.abs(fixLat - n.rxLat) > n.latPadDeg) continue; + if (Math.abs(fixLon - n.rxLon) * kmPerDegLon > n.reachKm + 1) continue; if (recentDetections[`${hex}|${n.nodeId}`] != null) continue; - if (!isInBeam(n.rxLat, n.rxLon, n.azimuth, n.beamWidth * BEAM_WIDTH_FACTOR, n.reachKm, beamLat, beamLon)) continue; + if (!pointInPolygon(fixLat, fixLon, n.polygon)) continue; const pairKey = `${hex}|${n.nodeId}`; seen.add(pairKey); diff --git a/frontend/src/components/map/Toolbar.tsx b/frontend/src/components/map/Toolbar.tsx index c241b1d9..44f247e2 100644 --- a/frontend/src/components/map/Toolbar.tsx +++ b/frontend/src/components/map/Toolbar.tsx @@ -97,8 +97,8 @@ export default function Toolbar({ -