Skip to content

feat(map): filter out Null Island (0,0) positions (#3763)#3764

Merged
Yeraze merged 2 commits into
mainfrom
feat/3763-filter-null-island
Jun 25, 2026
Merged

feat(map): filter out Null Island (0,0) positions (#3763)#3764
Yeraze merged 2 commits into
mainfrom
feat/3763-filter-null-island

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 25, 2026

Copy link
Copy Markdown
Owner

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 within NULL_ISLAND_EPSILON (0.001°, ~111 m) of (0,0) as bogus. A radius rather than exact equality, per the issue, so firmware-rounded values like 0.000001 are also caught. Lives in src/utils because it's imported by both backend and frontend.

Filter before storing (backend)

  • meshtasticManager.isValidPosition() — this single gate fronts both the POSITION_APP path and the NodeInfo position exchange, so a bogus (0,0) never reaches the node row or the position-history telemetry. (The pre-existing latitudeI && longitudeI guard only caught exactly 0,0; near-zero slipped through.)
  • meshcoreManager.persistContact() — stores null lat/lon for a (0,0) contact advert.

Filter before rendering (frontend)

  • nodePositionUtil.resolveNodeLatLng() — covers the Map Analysis workspace and unified map (NodeMarkersLayer consumes it).
  • DashboardMap.getNodeLatLng() — replaces the imprecise (lat !== 0 || lng !== 0) exact check; covers the Dashboard main map and unified map.
  • MeshCoreMap positioned filter — needed because live in-memory contacts can still carry a (0,0) fix that reaches the map via getAllNodes(), bypassing the DB-store filter.

One-shot cleanup

  • Migration 107 (clear_null_island_positions) nulls existing (0,0) latitude/longitude in nodes and meshcore_nodes. A manual latitudeOverride/longitudeOverride is intentionally left alone. Naturally idempotent (a nulled row no longer matches the WHERE clause) and shares NULL_ISLAND_EPSILON with the runtime filter so the two never drift. Implemented across all three backends (SQLite/PostgreSQL/MySQL).

Tests

  • New nullIsland.test.ts and 107_clear_null_island_positions.test.ts suites, including the Greenwich-meridian edge case (lon≈0 but a real latitude → not filtered).
  • Null Island cases added to meshtasticManager.positionChannel.test.ts and meshcoreManager.contactPersistence.test.ts.
  • Migration count test bumped 106 → 107.
  • Full Vitest suite green: 530 files, 0 failures.

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

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
@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: feat(map): filter out Null Island (0,0) positions (#3763)

Review complete.

  • Gather context and understand the request
  • Read changed files
  • Analyze code quality, bugs, performance, security, test coverage
  • Post review feedback

Overall Assessment

This 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

src/utils/nullIsland.ts — The helper is well-named, well-documented, and handles null/undefined/NaN/Infinity edge cases. Using a radius rather than exact equality is the right call for firmware rounding.

src/components/Dashboard/DashboardMap.tsx (getNodeLatLng) and src/components/MapAnalysis/nodePositionUtil.ts (resolveNodeLatLng) — Both do the same thing: extract lat/lng from either the flat API shape or a nested position object, then reject Null Island. There is minor duplication between the two functions (same lat = node.latitude ?? node.position?.latitude logic). This is acceptable given the slight shape difference ({ lat, lng } vs [number, number]), and the PR description notes the intentional parallel. Not a blocker.

src/server/meshcoreManager.ts (persistContact) — The implementation is correct. The atNullIsland boolean gate before the upsertNode call is clear and avoids null-coalescing confusion.


Potential Issues / Observations

1. Telemetry table not cleaned up by migration 107

The PR description explicitly acknowledges this:

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.

However, existing telemetry rows with telemetryType = 'latitude' / 'longitude' carrying (0,0) values will still render in position trails and the coverage heatmap for existing deployments until those rows age out or are deleted. If the issue only concerns node markers (as stated in "Scope note"), this is fine. But if a user has been receiving many Null Island positions, trails and heatmaps may still show South Atlantic points for some time. This may be worth a follow-up issue, or at least a note in the scope section.

2. processPositionMessageProtobuf still has the pre-existing if (position.latitudeI && position.longitudeI) guard at line ~6130

This guard was noted in the PR as "only caught exactly 0,0" — but isValidPosition now catches near-zero too. The old guard is still there as the outer condition. Since latitudeI and longitudeI are integers (1e-7 degree scale), a near-zero value like 0.0005° would be 5000 (integer) and is non-zero, so it passes the outer if and then gets caught by isValidPosition. The guard combination is correct. However, it's slightly misleading to have the old comment if (position.latitudeI && position.longitudeI) still as a filter — it reads as "skip if missing" but is really "skip if exactly zero integer" in addition to isValidPosition. Not a bug, just a note.

3. Migration 107 SQLite uses db.exec() (raw SQL)

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 const number (not user input), there's no injection risk.

4. MeshCoreMappositioned computed only from in-memory contacts

The PR correctly identifies that in-memory contacts bypass the DB store filter, and applies isNullIsland in the useMemo at line ~232-234. This is correct.


Test Coverage

Coverage is thorough:

  • nullIsland.test.ts: unit tests covering exact (0,0), near-zero within epsilon, boundary at epsilon, real-world positions (including Greenwich meridian edge case), and null/undefined/NaN/Infinity.
  • 107_clear_null_island_positions.test.ts: SQLite migration covering exact/near-zero nulling, legitimate position preservation, manual override preservation, and idempotency.
  • meshtasticManager.positionChannel.test.ts: near-(0,0) position dropped without telemetry insertion; legitimate position stores correctly.
  • meshcoreManager.contactPersistence.test.ts: (0,0) contact stores null lat/lon.

One gap: there's no test for resolveNodeLatLng or getNodeLatLng directly (they're utility/component-level functions). Given that isNullIsland itself is well-tested and these functions delegate to it, this is acceptable.


Security / Performance

No concerns. The isNullIsland check is O(1) and runs only on path boundaries (ingestion, rendering). No user input is used in the migration SQL template.


Summary

Verdict: 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
@Yeraze

Yeraze commented Jun 25, 2026

Copy link
Copy Markdown
Owner Author

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:

  • analysis.getPositions() now skips Null Island pivots. That's the single render-side chokepoint — /coverage-grid reuses getPositions, so it covers position trails, the high-zoom heatmap, and the server-computed low-zoom heatmap cells (which a frontend-only filter can't reach).
  • Migration 107 now also deletes the paired (0,0) latitude/longitude rows from telemetry, 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 sidestep error 1093.

New tests cover paired deletion, unpaired-latitude preservation, the Greenwich edge case, idempotency, and the getPositions filter.

Points 2, 3, 4 — acknowledged, no change needed, matching your own assessment: the latitudeI && longitudeI guard + isValidPosition combination is correct (the old comment is slightly imprecise but not wrong); migration raw SQL is the sanctioned src/server/migrations/** exception and EPS is a compile-time const (no injection); and the MeshCoreMap in-memory filter is intentional.

Full Vitest suite green (7601 passed). Re-monitoring CI.

@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: feat(map): filter out Null Island (0,0) positions (#3763)

Review complete.

  • Gather context and understand the request
  • Read changed files
  • Analyze code quality, bugs, performance, security, test coverage
  • Post review feedback

Overall Assessment

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

src/db/repositories/analysis.tsgetPositions() pivot filter (analysis.ts:388)

The isNullIsland check is correctly placed inside the pivot loop, after lat and lon are matched but before the row is pushed. This is the right location because:

  • It receives the actual floating-point values (not the integer latitudeI/longitudeI), matching isNullIsland's contract.
  • It covers getCoverageGrid for free since that method pages through getPositions.
  • The server-computed low-zoom heatmap (/coverage-grid) reuses this same path, so a frontend-only filter would have missed it.

One subtle behaviour to be aware of: the over-fetch strategy (pageSize + 1 rows per stream) combined with the post-pivot Null Island skip means a page heavily populated with (0,0) fixes could yield fewer than pageSize pivots even when hasMore was expected to be true. The loop breaks as soon as pivots.length > pageSize, so genuinely near-zero databases would cause under-filled pages. In practice, migration 107 removes those rows, and the ingestion gate prevents new ones, so the edge case won't affect real deployments. Worth noting in case the pagination behavior is ever revisited.

src/server/migrations/107_clear_null_island_positions.ts — telemetry cleanup

The paired-deletion approach is correct for all three dialects:

  • SQLite/PostgreSQL: Row-value (nodeNum, timestamp) IN (subquery) correctly ensures only rows where both lat and lon of the same fix are near-zero are deleted. The outer WHERE ABS(value) < EPS is a useful early filter before the join.
  • MySQL: Multi-table DELETE lat, lon FROM ... JOIN ... is the right workaround for MySQL error 1093. The join condition and predicate are equivalent to the SQLite/PG path. ✓

One minor observation on the SQLite variant: the outer WHERE telemetryType IN ('latitude','longitude') AND ABS(value) < EPS is evaluated before the subquery, which means any longitude row whose value is outside EPS will not be targeted by the outer DELETE even if its paired latitude is near-zero (the subquery finds paired near-zero lat+lon, and the outer WHERE restricts which rows actually get deleted). For a (0.0004, -35.0) pair: latitude is near-zero but longitude is not, so the outer WHERE ABS(value) < EPS filter on the longitude row would exclude it — but the subquery wouldn't have matched that pair either (longitude ABS(-35.0) < EPS is false in the subquery). The semantics are consistent; this is purely an observation, not a bug.

src/db/repositories/analysis.test.ts — new Null Island test (analysis.test.ts:76–93)

Good: uses 0.0004 and -0.0002 (within epsilon), confirms the fix survives and the Null Island fix is absent from results. The test is straightforward and doesn't over-specify internal pagination behaviour.


Prior Points — Status

# Point Status
1 Telemetry / trails + heatmap ✅ Addressed — getPositions() pivot filter + migration 107 telemetry cleanup
2 latitudeI && longitudeI guard + isValidPosition combination ✅ No change needed, correct as-is
3 Migration raw SQL exception ✅ Sanctioned use under src/server/migrations/**
4 MeshCoreMap in-memory filter ✅ Correct, intentional

Remaining Minor Observations (no action needed)

getNodeLatLng (DashboardMap) vs resolveNodeLatLng (nodePositionUtil) — Minor duplication of the node.latitude ?? node.position?.latitude extraction pattern noted in the first review is still present and remains acceptable given the different return shapes ({lat,lng} vs [number,number]).

isValidPosition redundancy (meshtasticManager.ts:6130) — The outer if (position.latitudeI && position.longitudeI) guard and the inner isValidPosition check are both still present. The outer guard catches the integer 0 exactly, while isValidPosition catches near-zero float values after conversion. The combination is correct; the slightly imprecise comment (// Check for valid numbers) is a pre-existing issue and doesn't affect correctness.

Altitude/speed orphans (migration 107) — The migration comment correctly notes that altitude/speed rows of a cleaned fix become harmless orphans (the render layer requires both lat and lon to plot). This is acceptable since the getPositions pivot already skips unpaired lat rows, so orphaned altitude rows are harmlessly ignored.


Security / Performance

No concerns. EPS is a compile-time constant — no injection risk in migration SQL. isNullIsland is O(1). The migration telemetry subquery does a self-join on (nodeNum, timestamp); with an index on those columns this is efficient; without one it's a full-table scan that runs exactly once on upgrade. No security issues.


Test Coverage Summary

Suite Coverage
nullIsland.test.ts Exact zero, near-zero, boundary, real-world, Greenwich edge case, null/undefined/NaN/Infinity
107_clear_null_island_positions.test.ts Node table nulling (exact + near), preservation (real + override), idempotency; telemetry paired deletion, unpaired preservation, Greenwich edge case, idempotency
meshtasticManager.positionChannel.test.ts Near-(0,0) dropped, legitimate position stored
meshcoreManager.contactPersistence.test.ts (0,0) contact stores null lat/lon
analysis.test.ts Null Island pivot skipped, legitimate pivots preserved

Coverage is comprehensive. The one remaining gap (no direct test of getNodeLatLng / resolveNodeLatLng) is acceptable since isNullIsland is independently well-tested and these are thin wrappers.


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.


View job

@Yeraze

Yeraze commented Jun 25, 2026

Copy link
Copy Markdown
Owner Author

Thanks — noting the one non-blocking observation for the record: the over-fetch (pageSize + 1) combined with the post-pivot Null Island skip can under-fill a page when a window is dense with (0,0) fixes. Leaving pagination as-is deliberately: migration 107 purges the historical rows and the ingestion gate blocks new ones, so a near-zero-dense window can't occur in a migrated deployment — and adding a re-fetch loop to top up pages would add real complexity for a case that can't arise. Flagging it here in case the pagination path is ever revisited independently. The SQLite outer-WHERE semantics and the noted duplication/redundancy are all consistent/intentional as you called out — no changes there.

@Yeraze Yeraze merged commit b82e87b into main Jun 25, 2026
20 checks passed
@Yeraze Yeraze deleted the feat/3763-filter-null-island branch June 25, 2026 23:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Filter out Null Island (0,0) position updates

1 participant