feat(map): filter out Null Island (0,0) positions (#3763)#3764
Conversation
Nodes with uninitialized or stale GPS emit (0,0) ("Null Island"), which
rendered false markers in the South Atlantic on the main, unified, and
Map Analysis maps. Filter these at every store-and-render chokepoint and
clean up rows already stored.
A shared helper `isNullIsland(lat, lon)` (src/utils/nullIsland.ts) treats a
coordinate within NULL_ISLAND_EPSILON (0.001°, ~111 m) of (0,0) as bogus —
a radius rather than exact equality so firmware-rounded values like 0.000001
are caught.
Store (backend):
- meshtasticManager.isValidPosition() — fronts both POSITION_APP and the
NodeInfo position exchange, so (0,0) never reaches the node row or the
position-history telemetry.
- meshcoreManager.persistContact() — stores null lat/lon for a (0,0) advert.
Render (frontend):
- nodePositionUtil.resolveNodeLatLng() — covers Map Analysis + unified map.
- DashboardMap.getNodeLatLng() — replaces the imprecise exact-(0,0) check;
covers the Dashboard main + unified maps.
- MeshCoreMap positioned filter — live in-memory contacts can still carry a
(0,0) fix that reaches the map via getAllNodes(), bypassing the DB filter.
Cleanup:
- Migration 107 nulls existing (0,0) latitude/longitude in nodes and
meshcore_nodes (latitudeOverride left untouched); naturally idempotent and
shares NULL_ISLAND_EPSILON with the runtime filter.
Tests: new nullIsland + migration 107 suites, plus Null Island cases added
to the meshtastic position and meshcore contact-persistence tests. Full
Vitest suite green (530 files, 0 failures).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
|
Claude finished @Yeraze's task —— View job PR Review: feat(map): filter out Null Island (0,0) positions (#3763)Review complete.
Overall AssessmentThis is a clean, well-scoped fix. The implementation is solid and follows the codebase conventions closely. The PR description accurately reflects the work done. A few observations below — most are minor, with one worth discussing. Code Quality
Potential Issues / Observations1. Telemetry table not cleaned up by migration 107 The PR description explicitly acknowledges this:
However, existing 2. This guard was noted in the PR as "only caught exactly 0,0" — but 3. Migration 107 SQLite uses This is intentional and explicitly marked with the raw-SQL ESLint exception note in CLAUDE.md for migrations, so no issue here. The PG/MySQL variants correctly use parameterized or interpolated-constant queries. The EPS constant is injected via template literal — since it's a compile-time 4. The PR correctly identifies that in-memory contacts bypass the DB store filter, and applies Test CoverageCoverage is thorough:
One gap: there's no test for Security / PerformanceNo concerns. The SummaryVerdict: LGTM with one point to consider. The primary question is whether existing telemetry rows with (0,0) lat/lon (position trails / heatmap) need cleanup in this PR or as a follow-up. If this is intentionally scoped to node markers only (as stated), the PR is ready to merge. |
Addresses the Claude Code Review note on PR #3764: node markers were filtered, but pre-existing (0,0) telemetry rows could still render on position trails and the coverage heatmap. - analysis.getPositions(): skip Null Island pivots. This is the single render-side chokepoint — /coverage-grid reuses getPositions, so this covers position trails, the high-zoom heatmap, AND the server-computed low-zoom heatmap cells (which a frontend-only filter cannot reach). - Migration 107: also delete the paired (0,0) latitude/longitude rows from the telemetry table, keyed on (nodeNum, timestamp) so a row is removed only when BOTH axes of the same fix are near zero. Dialect-specific: SQLite/PG use a row-value IN subquery; MySQL uses a multi-table self-join delete to avoid error 1093 (can't subquery the table being deleted). Tests: telemetry deletion cases (paired delete, unpaired-latitude preservation, Greenwich edge case, idempotency) in the migration suite, and a Null Island filter case in analysis.getPositions. Full suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
|
Thanks for the review. Triage of the points raised: Point 1 (telemetry / trails + heatmap) — addressed in ce40063. This was the substantive one. Rather than only filtering at render, I handled both sides:
New tests cover paired deletion, unpaired-latitude preservation, the Greenwich edge case, idempotency, and the Points 2, 3, 4 — acknowledged, no change needed, matching your own assessment: the Full Vitest suite green (7601 passed). Re-monitoring CI. |
|
Claude finished @Yeraze's task —— View job PR Review: feat(map): filter out Null Island (0,0) positions (#3763)Review complete.
Overall AssessmentThis is a well-executed, defense-in-depth fix. The second commit (ce40063) meaningfully addressed the only substantive gap from the first review. All three surface areas are now handled — node markers, position history/trails, and the coverage heatmap — with both a runtime filter and a one-shot data migration. The implementation is clean, idiomatic, and the test coverage is thorough. Updated Changes (ce40063)
The
One subtle behaviour to be aware of: the over-fetch strategy (
The paired-deletion approach is correct for all three dialects:
One minor observation on the SQLite variant: the outer
Good: uses Prior Points — Status
Remaining Minor Observations (no action needed)
Altitude/speed orphans ( Security / PerformanceNo concerns. Test Coverage Summary
Coverage is comprehensive. The one remaining gap (no direct test of Verdict: LGTM. The PR is ready to merge. Both the original node-marker fix and the follow-up telemetry/trail cleanup are well-implemented, tested across all three DB backends, and guarded at every relevant chokepoint. |
|
Thanks — noting the one non-blocking observation for the record: the over-fetch ( |
Closes #3763.
Nodes with uninitialized or stale GPS emit (0,0) — "Null Island", in the middle of the South Atlantic — which rendered false markers on the main map, the unified map, and the Map Analysis workspace. This filters them at every store-and-render chokepoint and cleans up rows already stored.
Approach
A shared helper
isNullIsland(lat, lon)(src/utils/nullIsland.ts) treats a coordinate withinNULL_ISLAND_EPSILON(0.001°, ~111 m) of (0,0) as bogus. A radius rather than exact equality, per the issue, so firmware-rounded values like0.000001are also caught. Lives insrc/utilsbecause it's imported by both backend and frontend.Filter before storing (backend)
meshtasticManager.isValidPosition()— this single gate fronts both thePOSITION_APPpath and the NodeInfo position exchange, so a bogus (0,0) never reaches the node row or the position-history telemetry. (The pre-existinglatitudeI && longitudeIguard only caught exactly 0,0; near-zero slipped through.)meshcoreManager.persistContact()— storesnulllat/lon for a (0,0) contact advert.Filter before rendering (frontend)
nodePositionUtil.resolveNodeLatLng()— covers the Map Analysis workspace and unified map (NodeMarkersLayerconsumes it).DashboardMap.getNodeLatLng()— replaces the imprecise(lat !== 0 || lng !== 0)exact check; covers the Dashboard main map and unified map.MeshCoreMappositionedfilter — needed because live in-memory contacts can still carry a (0,0) fix that reaches the map viagetAllNodes(), bypassing the DB-store filter.One-shot cleanup
clear_null_island_positions) nulls existing (0,0)latitude/longitudeinnodesandmeshcore_nodes. A manuallatitudeOverride/longitudeOverrideis intentionally left alone. Naturally idempotent (a nulled row no longer matches the WHERE clause) and sharesNULL_ISLAND_EPSILONwith the runtime filter so the two never drift. Implemented across all three backends (SQLite/PostgreSQL/MySQL).Tests
nullIsland.test.tsand107_clear_null_island_positions.test.tssuites, including the Greenwich-meridian edge case (lon≈0 but a real latitude → not filtered).meshtasticManager.positionChannel.test.tsandmeshcoreManager.contactPersistence.test.ts.Scope note
Scoped to node markers (the issue's stated impact). Position trails / coverage heatmap render historical telemetry; since the Meshtastic ingestion gate now also blocks (0,0) from the telemetry table, new history won't contain it either. Migration 107 covers the already-stored node rows.
🤖 Generated with Claude Code
https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5