Skip to content

fix(analysis-map): render cross-source neighbor links (union, not intersection) (#3792)#3801

Merged
Yeraze merged 1 commit into
mainfrom
fix/analysis-neighbor-links-cross-source
Jun 26, 2026
Merged

fix(analysis-map): render cross-source neighbor links (union, not intersection) (#3792)#3801
Yeraze merged 1 commit into
mainfrom
fix/analysis-neighbor-links-cross-source

Conversation

@Yeraze

@Yeraze Yeraze commented Jun 26, 2026

Copy link
Copy Markdown
Owner

Summary

On the Analysis Map, selecting multiple sources (e.g. LoRa + MQTT) showed fewer neighbor links than viewing either source alone — an intersection where a union was intended. The API already returns the union; the bug is entirely in frontend rendering. (Verified the reporter's root-cause analysis against the code — it's correct.)

Root cause

NeighborLinksLayer builds a position lookup keyed "${sourceId}:${nodeNum}", then resolves both endpoints of each edge using only the edge's own sourceId:

const aKey = `${e.sourceId}:${Number(e.nodeNum)}`;
const bKey = `${e.sourceId}:${Number(e.neighborNum)}`;
const a = positionByKey.get(aKey);
const b = positionByKey.get(bKey);
if (!a || !b) continue;   // silently drops the edge

A neighbor edge carries the sourceId of the reporting node, but the other endpoint's position may only be recorded under a different selected source. That lookup misses → the edge is dropped. Only edges whose both endpoints are positioned under the same source as the reporting node survive ⇒ effective intersection.

The API is correct: analysis.ts getNeighbors() uses inArray(neighbors.sourceId, args.sourceIds) and returns the full union.

Changes

  • NeighborLinksLayer.tsx — added nodeNum-keyed fallback maps (positionByNode, transportByNode). Endpoint position and transport class now prefer the source-scoped key, then fall back to the node's value on any selected source. Updated the edges memo deps and the component doc comment.
  • NeighborLinksLayer.test.tsx — regression tests: a cross-source edge (neighbor positioned only under a different source) now renders; an edge whose endpoint has no position on any source is still dropped.
  • CHANGELOG.md — Fixed entry.

Issues Resolved

Fixes #3792.

Documentation Updates

  • CHANGELOG.md — Fixed entry under [Unreleased]. No other docs affected (internal rendering fix).

Testing

  • Full unit suite: 7668 passed, 0 failed, 0 suite failures
  • TypeScript clean for the changed file
  • New cross-source regression test fails without the fix, passes with it
  • Reviewer: on the Analysis Map with two sources, select each individually then both — the combined view should now show the union (≥ either alone)

🤖 Generated with Claude Code

…ersection) (#3792)

NeighborLinksLayer keyed its node-position lookup by `${sourceId}:${nodeNum}`
and resolved BOTH endpoints of each edge with the edge's own sourceId. A
neighbor edge carries the sourceId of the reporting node, but the other
endpoint's position may only be recorded under a different selected source — so
that lookup missed and the edge was silently dropped (line `if (!a || !b)
continue`). With multiple sources selected this collapsed the intended union of
links into an intersection (fewer links than either source alone).

The API (getNeighbors, inArray over sourceIds) already returns the union; this
is a frontend-only fix. Endpoint positions (and transport class) now prefer the
source-scoped key but fall back to the node's position on any selected source.
Added regression tests: a cross-source edge now renders, while an edge with an
endpoint that has no position anywhere is still dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011JEaCGwY9Wz8jeV4e22GW4
@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown

Claude finished @Yeraze's task —— View job


PR Review: fix(analysis-map): render cross-source neighbor links (union, not intersection)

  • Gather context and understand the request
  • Read changed files
  • Review code quality and correctness
  • Review test coverage
  • Post final review

Summary

The fix is correct, well-scoped, and the root cause diagnosis in the PR description is accurate. The implementation uses a sensible fallback pattern and the tests cover the critical scenarios.


Code Review

NeighborLinksLayer.tsx

Correctness ✅

The fallback pattern (positionByKey.get(aKey) ?? positionByNode.get(Number(e.nodeNum))) correctly implements "prefer source-scoped, fall back to any source." The fix directly addresses the bug: previously both endpoints were looked up strictly under edge.sourceId, so any edge where the neighbor node's position was recorded under a different source was silently dropped.

Minor observations:

  1. positionByNode / transportByNode use first-wins ordering (if (!map.has(...)) guard at lines 86, 94). This means the first occurrence in the nodes array wins the fallback. If nodes are sorted by source in a consistent order, this is fine — but it may produce visually inconsistent transport coloring when the same nodeNum appears under multiple sources with different transport classes. Low severity for a fallback, but worth noting.

  2. eslint-disable-next-line comment at line 144 — the inWindow function is defined inside the component but not in the useMemo deps array. This is the existing pattern (the ts.* values are already in the deps), so the suppression is justified. However, it's worth noting inWindow could be moved into the useMemo body or memoized separately to eliminate the disable comment entirely. This is a pre-existing issue, not introduced by this PR.

  3. Transport class resolution logic at line 130 is functional but slightly hard to read:

    const tc: NodeTransportClass = aTx === 'mqtt' || bTx === 'mqtt' ? 'mqtt' : aTx === 'udp' || bTx === 'udp' ? 'udp' : 'rf';

    This is pre-existing code, just moved — no action needed.

  4. Four useMemo calls vs. two — the fix introduces positionByNode and transportByNode as separate memos. These iterate nodes a second time. A single memo pass building all four maps would be more efficient, but for the typical node counts seen in Meshtastic deployments this is negligible. Not a blocking concern.


NeighborLinksLayer.test.tsx

Test coverage ✅

The two new test cases directly cover the fix:

  • renders a cross-source edge when the neighbor is positioned under a different source (#3792) — the regression test that would have caught the bug.
  • still drops an edge when an endpoint has no position on any source — verifies the "no position anywhere" edge case still filters correctly.

The beforeEach reset pattern cleanly isolates test state, and the mutable mockState approach keeps setup readable.

One gap to consider: There's no test asserting that when a node appears under both sources, the source-scoped position takes precedence over the cross-source fallback. This would confirm the "prefer own source" behavior. It's not a blocker — the implementation is clearly correct — but it would harden the intent.


Verdict

Approve. The fix is minimal, targeted, and correct. The root cause is clearly explained, the fallback is sound, and the regression test will catch this class of bug going forward. The minor observations above are all low-severity or pre-existing and don't warrant blocking the merge.

@Yeraze Yeraze merged commit b7b2f60 into main Jun 26, 2026
18 checks passed
@Yeraze Yeraze deleted the fix/analysis-neighbor-links-cross-source branch June 26, 2026 21:32
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.

Analysis Map: multi-source selection shows fewer neighbor links than individual sources (intersection instead of union)

1 participant