fix(map): omit reset and invalid node locations (#61)

This commit is contained in:
n30nex authored and GitHub committed 2026-09-23 20:16:26 -04:00
1 parent 929ba83cf1
commit 0f0a6ca51c
7 files changed
+64 -13

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
// Beacon preserves explicit 0/0 advert resets in the data. They are unlocated
// radio nodes, not markers at Null Island. Either axis alone may validly be zero.
// This is a node-location rule, not a restriction on map camera coordinates.
export function hasMapLocation(
node: { lat?: number | null; lng?: number | null } | null | undefined,
): node is { lat: number; lng: number } {
return node?.lat != null && node.lng != null
&& Number.isFinite(node.lat) && Number.isFinite(node.lng)
&& Math.abs(node.lat) <= 90 && Math.abs(node.lng) <= 180
&& (node.lat !== 0 || node.lng !== 0);
}
+7 -7
View File
@@ -1,5 +1,6 @@
import type { Feature, FeatureCollection, LineString, Point } from "geojson";
import type { NodeSummary, NodeNeighbor } from "../nodes/types";
import { hasMapLocation } from "./location";
// Build the maplibre GeoJSON source from the nodes API response. Properties stay primitive because
// clustering serializes them, and there's no maplibre import, so this stays unit-testable.
@@ -16,8 +17,7 @@ export function nodesToFeatureCollection(
): FeatureCollection<Point, NodeFeatureProps> {
const features: Feature<Point, NodeFeatureProps>[] = [];
for (const n of nodes) {
// != null keeps 0 (a valid coordinate) while dropping null/undefined
if (n.lat == null || n.lng == null) continue;
if (!hasMapLocation(n)) continue;
features.push({
type: "Feature",
// GeoJSON/maplibre order is [lng, lat]; the API sends decimal degrees as-is
@@ -52,13 +52,13 @@ export function buildNeighborEdges(
): FeatureCollection<LineString, NeighborEdgeProps> {
const located = new Map<string, NodeSummary>();
for (const n of nodes) {
if (n.lat != null && n.lng != null) located.set(n.id, n);
if (hasMapLocation(n)) located.set(n.id, n);
}
const seen = new Set<string>();
const features: Feature<LineString, NeighborEdgeProps>[] = [];
for (const n of nodes) {
if (n.lat == null || n.lng == null || !n.neighborIds) continue;
if (!hasMapLocation(n) || !n.neighborIds) continue;
for (const otherId of n.neighborIds) {
if (otherId === n.id) continue; // a node listing itself would draw a zero-length edge
const other = located.get(otherId);
@@ -89,12 +89,12 @@ export function buildFocusedNeighborEdges(
now: number = Date.now(),
): FeatureCollection<LineString, NeighborEdgeProps> {
const empty: FeatureCollection<LineString, NeighborEdgeProps> = { type: "FeatureCollection", features: [] };
if (!selected || selected.lat == null || selected.lng == null) return empty;
if (!hasMapLocation(selected)) return empty;
const from: [number, number] = [selected.lng, selected.lat];
const byId = new Map<string, { lng: number; lat: number; obs: number; lastSeen: number }>();
for (const nb of neighbors) {
if (nb.id === selected.id || nb.lat == null || nb.lng == null) continue;
if (nb.id === selected.id || !hasMapLocation(nb)) continue;
const prev = byId.get(nb.id);
if (prev) {
prev.obs += nb.observationCount;
@@ -123,7 +123,7 @@ export function neighborFocusIds(nodes: NodeSummary[], selectedId: string | null
if (!selectedId) return null;
const located = new Map<string, NodeSummary>();
for (const n of nodes) {
if (n.lat != null && n.lng != null) located.set(n.id, n);
if (hasMapLocation(n)) located.set(n.id, n);
}
const selected = located.get(selectedId);
if (!selected) return null;
+2 -1
View File
@@ -1,4 +1,5 @@
import type { ResolvedHop } from "../../types/api";
import { hasMapLocation } from "./location";
// Pure helpers for drawing a packet's path — the live flow animation (modelled on MeshMapper's
// LiveViz) and the path map share them. No maplibre import, so they stay unit-testable; the hook
@@ -24,7 +25,7 @@ export function resolvedPathNodes(resolvedPath: ResolvedHop[]): { id: string; ln
const seen = new Set<string>();
const out: { id: string; lng: number; lat: number }[] = [];
for (const hop of resolvedPath) {
const node = hop.nodes.find((n) => n.latitude != null && n.longitude != null);
const node = hop.nodes.find((n) => hasMapLocation({ lat: n.latitude, lng: n.longitude }));
if (node && !seen.has(node.id)) {
seen.add(node.id);
out.push({ id: node.id, lng: node.longitude!, lat: node.latitude! });
+2 -1
View File
@@ -2,6 +2,7 @@ import type { Feature, FeatureCollection, LineString, Point } from "geojson";
import type { PacketDetail, Observation, ResolvedHop } from "../../types/api";
import { PayloadType } from "../../types/enums";
import { packetChain } from "./packet-flow";
import { hasMapLocation } from "./location";
export interface PathPoint {
id: string;
@@ -37,7 +38,7 @@ function pathPoints(hops: ResolvedHop[]): PathPoint[] {
const seen = new Set<string>();
const out: PathPoint[] = [];
for (const hop of hops) {
const node = hop.nodes.find((n) => n.latitude != null && n.longitude != null);
const node = hop.nodes.find((n) => hasMapLocation({ lat: n.latitude, lng: n.longitude }));
if (node && !seen.has(node.id)) {
seen.add(node.id);
out.push({ id: node.id, name: node.name, lng: node.longitude!, lat: node.latitude! });
+26 -4
View File
@@ -64,10 +64,15 @@ describe("nodesToFeatureCollection", () => {
expect(fc.features.map((f) => f.properties.id)).toEqual(["c"]);
});
it("keeps nodes at the 0/0 coordinate (0 is a valid coordinate)", () => {
const fc = nodesToFeatureCollection([node({ id: "z", lat: 0, lng: 0 })]);
expect(fc.features).toHaveLength(1);
expect(fc.features[0]!.geometry.coordinates).toEqual([0, 0]);
it("omits location resets and unusable coordinates", () => {
const positions = [[0, 0], [-0, 0], [NaN, 10], [10, Infinity], [91, 20], [-91, 20], [10, 181], [10, -181]];
expect(nodesToFeatureCollection(positions.map(([lat, lng], i) => node({ id: String(i), lat, lng }))).features).toEqual([]);
});
it("keeps valid equator, prime-meridian and boundary locations", () => {
const positions = [[0, -75], [45, 0], [90, 180], [-90, -180]];
const fc = nodesToFeatureCollection(positions.map(([lat, lng], i) => node({ id: String(i), lat, lng })));
expect(fc.features.map((f) => f.geometry.coordinates)).toEqual([[-75, 0], [0, 45], [180, 90], [-180, -90]]);
});
it("passes decimal coordinates through untouched (api/nodes.go sends *float64 degrees)", () => {
@@ -114,6 +119,17 @@ describe("buildNeighborEdges", () => {
const fc = buildNeighborEdges([selfRef, b], "on", null);
expect(fc.features).toHaveLength(1); // a<->b only, not a<->a
});
it("omits both directions of neighbour edges to reset or invalid locations", () => {
const valid = node({ id: "a", neighborIds: ["reset", "invalid"] });
const reset = node({ id: "reset", lat: 0, lng: 0, neighborIds: ["a"] });
const invalid = node({ id: "invalid", lat: 91, lng: 10, neighborIds: ["a"] });
for (const mode of ["on", "selected"] as const) {
expect(buildNeighborEdges([valid, reset, invalid], mode, "a").features).toEqual([]);
}
expect(neighborFocusIds([valid, reset, invalid], "a")).toBeNull();
expect(neighborFocusIds([valid, reset, invalid], "reset")).toBeNull();
});
});
describe("neighborFocusIds", () => {
@@ -200,6 +216,12 @@ describe("buildFocusedNeighborEdges", () => {
], NOW);
expect(fc.features.map((f) => f.properties.obs)).toEqual([5]);
});
it("omits reset and invalid locations from focused edges", () => {
const candidates = [nb({ id: "reset", lat: 0, lng: 0 }), nb({ id: "invalid", lat: 10, lng: Infinity }), nb({ id: "valid", lat: 0, lng: 15 })];
expect(buildFocusedNeighborEdges(sel, candidates, NOW).features.map((f) => f.geometry.coordinates)).toEqual([[[-75, 45], [15, 0]]]);
expect(buildFocusedNeighborEdges(node({ lat: 0, lng: 0 }), [nb({})], NOW).features).toEqual([]);
});
});
describe("filterByNodeType", () => {
+5
View File
@@ -35,6 +35,11 @@ describe("resolvedPathNodes", () => {
it("skips hops with no located candidate", () => {
expect(resolvedPathNodes([{ confidence: "ambiguous", nodes: [{ id: "x", publicKey: "pk" }] }])).toEqual([]);
});
it("skips reset/invalid candidates and still finds a usable candidate", () => {
const candidates: ResolvedHop = { confidence: "ambiguous", nodes: [...hop("reset", 0, 0).nodes, ...hop("invalid", 10, 91).nodes, ...hop("valid", 0, 45).nodes] };
expect(resolvedPathNodes([hop("unknown", 0, 0), candidates, hop("bad", Infinity, 10)])).toEqual([{ id: "valid", lng: 0, lat: 45 }]);
});
});
describe("posAtHop", () => {
+11
View File
@@ -21,6 +21,17 @@ function detail(observations: Observation[], over: Partial<PacketDetail> = {}):
}
describe("buildPacketPaths", () => {
it("excludes location resets and invalid candidates from observation and trace paths", () => {
const candidates: ResolvedHop = { confidence: "ambiguous", nodes: [...hop("reset", 0, 0).nodes, ...hop("invalid", 181, 10).nodes, ...hop("valid", 0, 45).nodes] };
const hops = [hop("unknown", 0, 0), candidates, hop("end", -75, 0)];
const ordinary = buildPacketPaths(detail([obs(1, hops)]));
const trace = buildPacketPaths(detail([], { header: { payloadType: PayloadType.TRACE, routeType: 1 }, resolvedRoute: hops } as Partial<PacketDetail>));
for (const paths of [ordinary, trace]) {
expect(paths[0]!.points.map((p) => [p.lng, p.lat])).toEqual([[0, 45], [-75, 0]]);
}
expect(buildPacketPaths(detail([obs(1, [hop("unknown", 0, 0), hop("one", -75, 45)])]))).toEqual([]);
});
it("keys each path by observerId and carries propagation, fastest first", () => {
const d = detail([
obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-slow", observerName: "Slow", propagationTimeMs: 900 }),