mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-22 10:42:25 +00:00
fix(map): omit reset and invalid node locations (#61)
This commit is contained in:
1 parent
929ba83cf1
commit
0f0a6ca51c
7 files changed
+64
-13
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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! });
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
Reference in new issue
Block a user