From 8456913ccb790a23fe9e69af9f424c9a66b6818a Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Thu, 4 Jun 2026 11:57:10 +0100 Subject: [PATCH 1/4] docs(agents): require sibling component docs when creating or changing UI Document the convention for ComponentName.md alongside tsx files so agents and contributors keep behaviour notes accurate without a full backfill. --- AGENTS.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index b0a9b11..8be937b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,22 @@ npm test # Vitest - Pages under `src/pages/`; shared components under `src/components/`. - API config: `config.ts` + remote `config.json`; `MESHFLOW_API_URL` for backend base URL. +### Component and screen documentation + +When you **create** a non-trivial component or page screen, or **meaningfully change** one you touch, add or update a sibling markdown file next to the main `.tsx` (same basename, e.g. `HeardPathMap.tsx` → `HeardPathMap.md`). + +Do **not** backfill docs for the whole codebase in one go; only document what you are working on so behaviour stays accurate. + +Each doc should explain, in plain language: + +- **Purpose** — what the UI is for and when to use it vs similar components (e.g. `HeardPathMap` vs `HeardPathGeoMap`). +- **Props / inputs** — main types and what drives rendering. +- **Behaviour** — what is drawn or shown, empty states, warnings, and non-obvious rules (maps, dialogs, protocol splits). +- **Wiring** — where it is used in the app and which adapters/hooks feed it. +- **Related** — links to paired components, feature docs under `docs/`, or API contracts when relevant. + +Examples: [`src/components/messages/HeardPathMap.md`](src/components/messages/HeardPathMap.md), [`src/components/messages/HeardPathGeoMap.md`](src/components/messages/HeardPathGeoMap.md). + ## Configuration - Default config in `config.ts` From 2e28a332d31a367a9f980c33f14f84205f69d6d1 Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Thu, 4 Jun 2026 11:58:47 +0100 Subject: [PATCH 2/4] docs(messages): document HeardPathMap vs HeardPathGeoMap heard dialog maps Add sibling component docs and link them from the MeshCore heard-path-dialog feature doc so map behaviour and when to use each component stay explicit. --- docs/meshcore/heard-path-dialog.md | 2 +- src/components/messages/HeardPathGeoMap.md | 49 ++++++++++++++++ src/components/messages/HeardPathMap.md | 65 ++++++++++++++++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 src/components/messages/HeardPathGeoMap.md create mode 100644 src/components/messages/HeardPathMap.md diff --git a/docs/meshcore/heard-path-dialog.md b/docs/meshcore/heard-path-dialog.md index a07b7ed..0b04aa1 100644 --- a/docs/meshcore/heard-path-dialog.md +++ b/docs/meshcore/heard-path-dialog.md @@ -4,7 +4,7 @@ Opened from **N heard** on a MeshCore text message in message history. ### Layout -1. **Geo map** — Leaflet markers for sender (when position known) and feeders with coordinates. No hop polylines on the map. +1. **Geo map** — [`HeardPathGeoMap`](../../src/components/messages/HeardPathGeoMap.tsx) ([docs](../../src/components/messages/HeardPathGeoMap.md)): Leaflet markers for sender (when position known) and feeders with coordinates. No hop polylines. Not the same as [`HeardPathMap`](../../src/components/messages/HeardPathMap.tsx) ([docs](../../src/components/messages/HeardPathMap.md)), which Meshtastic uses for sender→feeder path lines. 2. **Paths by feeder** — One schematic row per observation: sender → hash hops → feeder. Hops use dashed monospace badges (`unknown` per API v1); they are **not** placed at geographic coordinates. 3. **Feeder list** — Each observer with RSSI/SNR and a **Path (this feeder)** column showing that observation’s hop chain. diff --git a/src/components/messages/HeardPathGeoMap.md b/src/components/messages/HeardPathGeoMap.md new file mode 100644 index 0000000..852ab96 --- /dev/null +++ b/src/components/messages/HeardPathGeoMap.md @@ -0,0 +1,49 @@ +# HeardPathGeoMap + +Leaflet map for the **message heard** dialog that shows **only geographic anchors** (sender and feeders). It does **not** draw hop polylines, path segments, or hash labels on the map. + +Use this when hop evidence is hash-based or schematic and should not be interpreted as real-world RF geometry on the map—typical for **MeshCore** heard dialogs today. + +## When to use + +| Use `HeardPathGeoMap` | Use `HeardPathMap` instead | +| ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| MeshCore heard dialog top map (markers for “who” heard the message and where they sit) | Meshtastic heard dialog, or any case where you want sender → hop waypoints → feeder **lines** on the map | +| You will show the full hop chain elsewhere (e.g. `MeshCoreHeardPathsPanel`, `PathHopChain`) | `path_known` and/or positioned intermediate hops should appear as polylines | + +These components are **not** drop-in replacements: props and rendering models differ (see below). + +## Props + +| Prop | Type | Role | +| ------------ | ---------------------------- | ----------------------------------------------------------------------------------- | +| `sender` | `HeardPathGeoAnchor \| null` | Sender marker when `position` is known | +| `feeders` | `HeardPathGeoAnchor[]` | One marker per feeder/feeder with a known position | +| `senderName` | `string \| null` | Label for the amber banner when sender position is missing (e.g. `mc_sender_label`) | + +`HeardPathGeoAnchor`: `{ label, position, color? }`. Feeder `color` defaults from `HEARD_PATH_LEG_COLORS` by index. + +## What it draws + +1. **Sender** (green, `HEARD_PATH_SENDER_COLOR`) — if `sender` is non-null. +2. **Feeder markers** — one per entry in `feeders`, with per-leg colour when `color` is set. +3. **No polylines** — no use of `buildSegments` or `path_hashes` / `resolved_path`. + +Map bounds fit all markers (padding, max zoom 15). Default centre before fit: Glasgow area (`55.8642, -4.2518`). + +## Empty and warning states + +- **Empty** (`!sender && feeders.length === 0`): placeholder text — “No map — sender and feeder positions unknown” (`data-testid="heard-path-geo-map-empty"`). +- **Sender missing, feeders present**: amber overlay — sender position unknown; feeders shown; **hop paths are schematic below** (paths belong in `MeshCoreHeardPathsPanel` / list, not on this map). + +## Wiring in the app + +[`MessageItem.tsx`](./MessageItem.tsx) → `MeshCoreHeardDialogBody` builds `geoSender` and `geoFeeders` from `meshCoreHeardLegs(message)` (feeders with `receiverPosition` only) and renders `HeardPathGeoMap`. + +Data still comes from message `heard[]` / API; this component only consumes **positions**, not hop hashes. + +## Related + +- [`HeardPathMap.md`](./HeardPathMap.md) — path polylines and hop geometry +- [`heard-path-map-adapters.ts`](./heard-path-map-adapters.ts) — `meshCoreHeardLegs`, Meshtastic vs MC adapters +- [`docs/meshcore/heard-path-dialog.md`](../../../docs/meshcore/heard-path-dialog.md) — full dialog layout diff --git a/src/components/messages/HeardPathMap.md b/src/components/messages/HeardPathMap.md new file mode 100644 index 0000000..95e521f --- /dev/null +++ b/src/components/messages/HeardPathMap.md @@ -0,0 +1,65 @@ +# HeardPathMap + +Leaflet map for the **message heard** dialog that shows **sender, feeders (receivers), and path geometry**: solid polylines where hop positions are known, dashed lines with hash tooltips where they are not. + +Use this when each observation leg can be drawn as a geographic path from sender through waypoints to the hearing feeder—primarily **Meshtastic** heard UI today. + +## When to use + +| Use `HeardPathMap` | Use `HeardPathGeoMap` instead | +| ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| Meshtastic `HeardDialog` branch in [`MessageItem.tsx`](./MessageItem.tsx) | MeshCore heard dialog top map (markers only; hops shown schematically below) | +| Legs include `waypoints` + `pathKnown` from `messageToHeardPathLegs` / `meshtasticHeardToLegs` | You only need sender + feeder pins with no on-map hop geometry | + +These components are **not** drop-in replacements: `HeardPathMap` expects per-leg `HeardPathLeg` objects with waypoints; `HeardPathGeoMap` expects a flat `feeders[]` list. + +## Props + +| Prop | Type | Role | +| ------------ | ----------------------------- | ----------------------------------------------------------------- | +| `sender` | `{ label, position } \| null` | Path start marker (green) | +| `legs` | `HeardPathLeg[]` | One entry per feeder observation that has a **receiver position** | +| `senderName` | `string \| null` | Banner label when sender position is missing | + +### `HeardPathLeg` + +| Field | Role | +| ----------- | ------------------------------------------------------------------------- | +| `receiver` | Feeder marker (`label`, `position`) | +| `waypoints` | Intermediate hops as `TracerouteRouteNode[]` (`position` optional) | +| `pathKnown` | From API `path_known`: all hops resolved with positions | +| `lineColor` | Polyline and feeder marker colour (defaults from `HEARD_PATH_LEG_COLORS`) | + +Legs without a receiver position are omitted upstream (`meshtasticHeardToLegs` / `meshCoreHeardToLegs`). + +## What it draws + +For each leg (feeder colour by index): + +1. **Receiver marker** at `receiver.position`. +2. **Path lines** only if **sender position is known** (`senderPos`). If sender is missing, feeder markers still render but **no polylines** (banner: path lines omitted). +3. **Segment mode** (when `senderPos` exists): + - **`pathKnown` and at least one waypoint with `position`**: [`buildSegments`](../../../lib/map-path-segments.ts) — solid runs through known coordinates; dashed segments between known points with permanent tooltips showing `node_id_str` (hash) for unknown hops in that span. + - **Otherwise**: single **dashed** line sender → feeder; tooltips list all waypoint `node_id_str` values at the segment midpoint. + +Sender marker uses `HEARD_PATH_SENDER_COLOR`. Map height **280px** (vs 240px on geo map). + +## Empty and warning states + +- **`legs.length === 0`**: placeholder — either “No feeder positions available for map” (sender known) or “No map — sender and feeder positions unknown”. +- **Sender missing, legs present**: amber overlay — sender position unknown; feeders shown; **path lines omitted**. + +## Data adapters + +| Adapter | Protocol | Output | +| ----------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `meshtasticHeardToLegs` | Meshtastic | Sender from `sender_position`; legs from `heard[]` with `observer_position`; empty `waypoints` | +| `meshCoreHeardToLegs` | MeshCore | Waypoints from `resolved_path` / `path_hashes`; used by tests and available for future MC map work—not wired in `MeshCoreHeardDialogBody` today | + +Entry point: `messageToHeardPathLegs(message)` in [`heard-path-map-adapters.ts`](./heard-path-map-adapters.ts). + +## Related + +- [`HeardPathGeoMap.md`](./HeardPathGeoMap.md) — anchor-only map for MeshCore +- [`MeshCoreHeardPathsPanel.tsx`](./MeshCoreHeardPathsPanel.tsx) — schematic per-feeder hop chains (MC) +- [`docs/meshcore/heard-path-dialog.md`](../../../docs/meshcore/heard-path-dialog.md) — full dialog layout From f71f3d610a7ec72268e7d801862e4a2576883879 Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Thu, 4 Jun 2026 12:40:34 +0100 Subject: [PATCH 3/4] feat(messages): partial MC heard path overlay on HeardPathGeoMap Share Leaflet path drawing via heard-path-map-layers, add buildPartialSegments for sender-unknown and truncated trails, and pass meshCore path legs to the geo map. --- docs/meshcore/heard-path-dialog.md | 2 +- src/components/messages/HeardPathGeoMap.md | 3 +- src/components/messages/HeardPathGeoMap.tsx | 24 ++- src/components/messages/HeardPathMap.tsx | 81 ++-------- src/components/messages/HopPositionIcon.tsx | 31 ++++ .../messages/MeshCoreHeardPathFlow.tsx | 25 +-- .../messages/MeshCoreHeardPathsPanel.tsx | 16 +- src/components/messages/MessageItem.tsx | 16 +- src/components/messages/PathHopBadge.tsx | 36 ++++- .../messages/heard-path-map-adapters.test.ts | 65 ++++++++ .../messages/heard-path-map-adapters.ts | 21 ++- .../messages/heard-path-map-layers.ts | 145 ++++++++++++++++++ src/lib/map-path-segments.test.ts | 51 +++++- src/lib/map-path-segments.ts | 82 ++++++++++ src/lib/models.ts | 2 + 15 files changed, 497 insertions(+), 103 deletions(-) create mode 100644 src/components/messages/HopPositionIcon.tsx create mode 100644 src/components/messages/heard-path-map-layers.ts diff --git a/docs/meshcore/heard-path-dialog.md b/docs/meshcore/heard-path-dialog.md index 0b04aa1..238cdd1 100644 --- a/docs/meshcore/heard-path-dialog.md +++ b/docs/meshcore/heard-path-dialog.md @@ -4,7 +4,7 @@ Opened from **N heard** on a MeshCore text message in message history. ### Layout -1. **Geo map** — [`HeardPathGeoMap`](../../src/components/messages/HeardPathGeoMap.tsx) ([docs](../../src/components/messages/HeardPathGeoMap.md)): Leaflet markers for sender (when position known) and feeders with coordinates. No hop polylines. Not the same as [`HeardPathMap`](../../src/components/messages/HeardPathMap.tsx) ([docs](../../src/components/messages/HeardPathMap.md)), which Meshtastic uses for sender→feeder path lines. +1. **Geo map** — [`HeardPathGeoMap`](../../src/components/messages/HeardPathGeoMap.tsx) ([docs](../../src/components/messages/HeardPathGeoMap.md)): Leaflet markers for sender (when position known) and feeders with coordinates, plus optional **partial hop polylines** when API resolves hop positions. Ambiguous hops are list-only. Meshtastic uses [`HeardPathMap`](../../src/components/messages/HeardPathMap.tsx) ([docs](../../src/components/messages/HeardPathMap.md)) for full sender→feeder paths. 2. **Paths by feeder** — One schematic row per observation: sender → hash hops → feeder. Hops use dashed monospace badges (`unknown` per API v1); they are **not** placed at geographic coordinates. 3. **Feeder list** — Each observer with RSSI/SNR and a **Path (this feeder)** column showing that observation’s hop chain. diff --git a/src/components/messages/HeardPathGeoMap.md b/src/components/messages/HeardPathGeoMap.md index 852ab96..0ec2574 100644 --- a/src/components/messages/HeardPathGeoMap.md +++ b/src/components/messages/HeardPathGeoMap.md @@ -19,6 +19,7 @@ These components are **not** drop-in replacements: props and rendering models di | ------------ | ---------------------------- | ----------------------------------------------------------------------------------- | | `sender` | `HeardPathGeoAnchor \| null` | Sender marker when `position` is known | | `feeders` | `HeardPathGeoAnchor[]` | One marker per feeder/feeder with a known position | +| `pathLegs` | `HeardPathLeg[]` (optional) | Per-feeder polylines and intermediate hop markers when positions are known | | `senderName` | `string \| null` | Label for the amber banner when sender position is missing (e.g. `mc_sender_label`) | `HeardPathGeoAnchor`: `{ label, position, color? }`. Feeder `color` defaults from `HEARD_PATH_LEG_COLORS` by index. @@ -27,7 +28,7 @@ These components are **not** drop-in replacements: props and rendering models di 1. **Sender** (green, `HEARD_PATH_SENDER_COLOR`) — if `sender` is non-null. 2. **Feeder markers** — one per entry in `feeders`, with per-leg colour when `color` is set. -3. **No polylines** — no use of `buildSegments` or `path_hashes` / `resolved_path`. +3. **Optional path overlay** — when `pathLegs` is set, draws partial polylines and positioned hop markers via [`heard-path-map-layers.ts`](./heard-path-map-layers.ts). Ambiguous hops are omitted from `pathLegs`. Without `pathLegs`, no path geometry is drawn. Map bounds fit all markers (padding, max zoom 15). Default centre before fit: Glasgow area (`55.8642, -4.2518`). diff --git a/src/components/messages/HeardPathGeoMap.tsx b/src/components/messages/HeardPathGeoMap.tsx index 096275d..1a2d921 100644 --- a/src/components/messages/HeardPathGeoMap.tsx +++ b/src/components/messages/HeardPathGeoMap.tsx @@ -3,6 +3,8 @@ import { MAP_NODE_MARKER_CSS } from '@/lib/map-marker-styles'; import { useMapTileUrl } from '@/hooks/useMapTileUrl'; import { createNodeIcon } from '@/components/nodes/map-utils'; import type { LatLng } from '@/lib/map-path-segments'; +import type { HeardPathLeg } from './HeardPathMap'; +import { drawHeardPathPolylinesOnly, hasDrawablePathOnMap, toLatLng } from './heard-path-map-layers'; import { HEARD_PATH_LEG_COLORS, HEARD_PATH_SENDER_COLOR } from './heard-path-constants'; import L from 'leaflet'; import { useEffect, useRef } from 'react'; @@ -19,20 +21,20 @@ export type HeardPathGeoAnchor = { export type HeardPathGeoMapProps = { sender: HeardPathGeoAnchor | null; feeders: HeardPathGeoAnchor[]; + /** Optional per-feeder path geometry (MeshCore); anchor markers remain on this map. */ + pathLegs?: HeardPathLeg[]; senderName?: string | null; }; -function toLatLng(pos: MapPosition): LatLng { - return [pos.latitude, pos.longitude]; -} - -export function HeardPathGeoMap({ sender, feeders, senderName }: HeardPathGeoMapProps) { +export function HeardPathGeoMap({ sender, feeders, pathLegs = [], senderName }: HeardPathGeoMapProps) { const mapRef = useRef(null); const mapInstanceRef = useRef(null); const tileLayerRef = useRef(null); const layersRef = useRef([]); const { url: tileUrl, attribution } = useMapTileUrl(); + const senderPos = sender ? toLatLng(sender.position) : null; + useEffect(() => { if (mapRef.current && !mapInstanceRef.current) { const map = L.map(mapRef.current).setView(DEFAULT_CENTER, 13); @@ -95,6 +97,10 @@ export function HeardPathGeoMap({ sender, feeders, senderName }: HeardPathGeoMap hasMarker = true; }); + if (pathLegs.length > 0) { + drawHeardPathPolylinesOnly(map, layersRef.current, bounds, senderPos, pathLegs); + } + if (!hasMarker) return; map.invalidateSize(); @@ -109,7 +115,7 @@ export function HeardPathGeoMap({ sender, feeders, senderName }: HeardPathGeoMap } }, 150); return () => clearTimeout(t); - }, [sender, feeders]); + }, [sender, feeders, pathLegs, senderPos]); if (!sender && feeders.length === 0) { return ( @@ -123,6 +129,7 @@ export function HeardPathGeoMap({ sender, feeders, senderName }: HeardPathGeoMap } const showSenderWarning = !sender; + const hasPartialPaths = !sender && hasDrawablePathOnMap(null, pathLegs); const warningLabel = senderName?.trim() || sender?.label; return ( @@ -134,7 +141,10 @@ export function HeardPathGeoMap({ sender, feeders, senderName }: HeardPathGeoMap role="status" > Sender position unknown - {warningLabel ? ` (${warningLabel})` : ''} — feeders shown; hop paths are schematic below. + {warningLabel ? ` (${warningLabel})` : ''} — + {hasPartialPaths + ? ' paths use known hop and feeder positions only; full chain below.' + : ' feeders shown; hop paths are schematic below.'} )} diff --git a/src/components/messages/HeardPathMap.tsx b/src/components/messages/HeardPathMap.tsx index 448417e..48490a6 100644 --- a/src/components/messages/HeardPathMap.tsx +++ b/src/components/messages/HeardPathMap.tsx @@ -1,14 +1,12 @@ -import { buildSegments, midpoint, type LatLng } from '@/lib/map-path-segments'; +import type { LatLng } from '@/lib/map-path-segments'; import { MAP_NODE_MARKER_CSS } from '@/lib/map-marker-styles'; import type { TracerouteRouteNode } from '@/lib/models'; import { useMapTileUrl } from '@/hooks/useMapTileUrl'; -import { createNodeIcon } from '@/components/nodes/map-utils'; +import { drawHeardPathLayers, hasDrawablePathOnMap, toLatLng } from './heard-path-map-layers'; import L from 'leaflet'; import { useEffect, useRef } from 'react'; import 'leaflet/dist/leaflet.css'; -import { HEARD_PATH_LEG_COLORS, HEARD_PATH_SENDER_COLOR } from './heard-path-constants'; - const DEFAULT_CENTER: LatLng = [55.8642, -4.2518]; export type MapPosition = { latitude: number; longitude: number }; @@ -27,14 +25,6 @@ export type HeardPathMapProps = { senderName?: string | null; }; -function toLatLng(pos: MapPosition): LatLng { - return [pos.latitude, pos.longitude]; -} - -function hasPositionedWaypoints(waypoints: TracerouteRouteNode[]): boolean { - return waypoints.some((node) => node.position != null); -} - export function HeardPathMap({ sender, legs, senderName }: HeardPathMapProps) { const mapRef = useRef(null); const mapInstanceRef = useRef(null); @@ -83,63 +73,12 @@ export function HeardPathMap({ sender, legs, senderName }: HeardPathMapProps) { layersRef.current = []; const bounds = L.latLngBounds([]); - - if (senderPos) { - const senderMarker = L.marker(senderPos, { - icon: createNodeIcon(sender?.label ?? 'S', HEARD_PATH_SENDER_COLOR, false), - }).addTo(map); - layersRef.current.push(senderMarker); - bounds.extend(senderPos); - } - - legs.forEach((leg, index) => { - const receiverPos = toLatLng(leg.receiver.position); - const color = leg.lineColor ?? HEARD_PATH_LEG_COLORS[index % HEARD_PATH_LEG_COLORS.length]; - const receiverMarker = L.marker(receiverPos, { - icon: createNodeIcon(leg.receiver.label, color, false), - }).addTo(map); - layersRef.current.push(receiverMarker); - bounds.extend(receiverPos); - - if (!senderPos) { - return; - } - - const segments = - leg.pathKnown && hasPositionedWaypoints(leg.waypoints) - ? buildSegments(senderPos, leg.waypoints, receiverPos) - : [ - { - latlngs: [senderPos, receiverPos] as LatLng[], - dashed: true, - unknownLabels: leg.waypoints.map((node) => ({ node_id_str: node.node_id_str })), - }, - ]; - - segments.forEach((seg) => { - const poly = L.polyline(seg.latlngs, { - color, - weight: 4, - dashArray: seg.dashed ? '10, 10' : undefined, - }).addTo(map); - layersRef.current.push(poly); - seg.latlngs.forEach((p) => bounds.extend(p)); - - if (seg.dashed && seg.unknownLabels.length > 0 && seg.latlngs.length >= 2) { - const mid = midpoint(seg.latlngs[0], seg.latlngs[seg.latlngs.length - 1]); - seg.unknownLabels.forEach((label) => { - const tooltip = L.tooltip({ - permanent: true, - direction: 'center', - className: 'traceroute-unknown-label', - }) - .setContent(label.node_id_str) - .setLatLng(mid); - tooltip.addTo(map); - layersRef.current.push(tooltip); - }); - } - }); + drawHeardPathLayers({ + map, + layers: layersRef.current, + bounds, + sender, + legs, }); if (bounds.isValid()) { @@ -168,6 +107,7 @@ export function HeardPathMap({ sender, legs, senderName }: HeardPathMapProps) { } const showSenderWarning = !senderPos; + const hasPartialPaths = !senderPos && hasDrawablePathOnMap(null, legs); const warningLabel = senderName?.trim() || sender?.label; return ( @@ -183,7 +123,8 @@ export function HeardPathMap({ sender, legs, senderName }: HeardPathMapProps) { role="status" > Sender position unknown - {warningLabel ? ` (${warningLabel})` : ''} — feeders shown; path lines omitted. + {warningLabel ? ` (${warningLabel})` : ''} — + {hasPartialPaths ? ' paths use known hop and feeder positions only.' : ' feeders shown; path lines omitted.'} )} diff --git a/src/components/messages/HopPositionIcon.tsx b/src/components/messages/HopPositionIcon.tsx new file mode 100644 index 0000000..7a3b3e6 --- /dev/null +++ b/src/components/messages/HopPositionIcon.tsx @@ -0,0 +1,31 @@ +import type { ResolvedHop } from '@/lib/models'; +import { MapPin, MapPinOff } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export type HopPositionIconProps = { + hop?: ResolvedHop | null; + positioned?: boolean; + className?: string; +}; + +export function HopPositionIcon({ hop, positioned, className }: HopPositionIconProps) { + const hasPosition = positioned ?? (hop?.status === 'resolved' && hop.position != null); + const ambiguous = hop?.status === 'ambiguous' || (hop?.candidates?.length ?? 0) > 1; + + if (hasPosition) { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/components/messages/MeshCoreHeardPathFlow.tsx b/src/components/messages/MeshCoreHeardPathFlow.tsx index 2fe6e49..14eba5d 100644 --- a/src/components/messages/MeshCoreHeardPathFlow.tsx +++ b/src/components/messages/MeshCoreHeardPathFlow.tsx @@ -1,4 +1,5 @@ import { Badge } from '@/components/ui/badge'; +import { HopPositionIcon } from './HopPositionIcon'; import { PathHopChain } from './PathHopChain'; import type { MeshCoreHeardLeg } from './heard-path-map-adapters'; import { ArrowRight } from 'lucide-react'; @@ -12,18 +13,24 @@ export type MeshCoreHeardPathFlowProps = { export function MeshCoreHeardPathFlow({ leg, senderDisplayLabel, senderKnown }: MeshCoreHeardPathFlowProps) { const startBadge = ( - - {senderKnown ? senderDisplayLabel : `Sender unknown${senderDisplayLabel ? ` (${senderDisplayLabel})` : ''}`} - + + + + {senderKnown ? senderDisplayLabel : `Sender unknown${senderDisplayLabel ? ` (${senderDisplayLabel})` : ''}`} + + ); const endBadge = ( - - {leg.receiverLabel} - + + + + {leg.receiverLabel} + + ); if (leg.hops.length === 0) { diff --git a/src/components/messages/MeshCoreHeardPathsPanel.tsx b/src/components/messages/MeshCoreHeardPathsPanel.tsx index b9f6fb0..e5c5ee6 100644 --- a/src/components/messages/MeshCoreHeardPathsPanel.tsx +++ b/src/components/messages/MeshCoreHeardPathsPanel.tsx @@ -5,9 +5,15 @@ export type MeshCoreHeardPathsPanelProps = { legs: MeshCoreHeardLeg[]; senderDisplayLabel: string; senderKnown: boolean; + hasAmbiguousHops?: boolean; }; -export function MeshCoreHeardPathsPanel({ legs, senderDisplayLabel, senderKnown }: MeshCoreHeardPathsPanelProps) { +export function MeshCoreHeardPathsPanel({ + legs, + senderDisplayLabel, + senderKnown, + hasAmbiguousHops = false, +}: MeshCoreHeardPathsPanelProps) { if (legs.length === 0) { return (

@@ -18,6 +24,14 @@ export function MeshCoreHeardPathsPanel({ legs, senderDisplayLabel, senderKnown return (

+ {hasAmbiguousHops && ( +

+ Some path hops match multiple nodes. Those hops are listed below but omitted from the map. +

+ )}

Paths are per feeder and may differ for the same message. Hop hashes are list-order evidence, not map coordinates. diff --git a/src/components/messages/MessageItem.tsx b/src/components/messages/MessageItem.tsx index 5adbfca..1b69874 100644 --- a/src/components/messages/MessageItem.tsx +++ b/src/components/messages/MessageItem.tsx @@ -8,9 +8,12 @@ import { isMeshCoreHeardMessage, isMeshCoreHeardObservation, meshCoreHeardLegs, + meshCoreHeardToLegs, + messageHasAmbiguousPathHops, messageToHeardPathLegs, resolvedHopsFromObservation, } from '@/components/messages/heard-path-map-adapters'; +import { HopPositionIcon } from '@/components/messages/HopPositionIcon'; import { Avatar } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -40,12 +43,15 @@ function MeshCoreHeardDialogBody({ message }: { message: TextMessage }) { ); const geoSender = sender ? { label: sender.label, position: sender.position } : null; + const { legs: pathLegs } = useMemo(() => meshCoreHeardToLegs(message), [message]); + const hasAmbiguousHops = useMemo(() => messageHasAmbiguousPathHops(message), [message]); return ( <> {message.mc_sender_candidates && message.mc_sender_candidates.length > 1 && ( @@ -53,7 +59,12 @@ function MeshCoreHeardDialogBody({ message }: { message: TextMessage }) { Multiple nodes match sender "{message.mc_sender_label}" — map uses feeder positions only.

)} - +
{legs.map((leg, index) => { const mc = leg.observation; @@ -70,7 +81,8 @@ function MeshCoreHeardDialogBody({ message }: { message: TextMessage }) { style={{ borderLeftWidth: 4, borderLeftColor: leg.lineColor }} >
-
+
+ {observerLink ? ( {leg.receiverLabel} diff --git a/src/components/messages/PathHopBadge.tsx b/src/components/messages/PathHopBadge.tsx index 6028301..939ce40 100644 --- a/src/components/messages/PathHopBadge.tsx +++ b/src/components/messages/PathHopBadge.tsx @@ -4,13 +4,25 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import type { ResolvedHop } from '@/lib/models'; import { nodeDetailPath } from '@/lib/node-detail-routes'; import { cn } from '@/lib/utils'; +import { HopPositionIcon } from './HopPositionIcon'; function hopIsLinkable(hop: ResolvedHop): boolean { return hop.status === 'resolved' && Boolean(hop.node_id_str?.trim()); } function hopLabel(hop: ResolvedHop): string { - return hop.long_name?.trim() || hop.hash; + return hop.short_name?.trim() || hop.long_name?.trim() || hop.hash; +} + +function hopTooltip(hop: ResolvedHop): string | null { + if (hop.candidates && hop.candidates.length > 0) { + const names = hop.candidates.map((c) => c.short_name || c.long_name || c.node_id_str).join(', '); + return `${hop.candidates.length} possible nodes: ${names}`; + } + if (hop.ambiguous) { + return 'Multiple matches possible'; + } + return null; } export function PathHopBadge({ hop }: { hop: ResolvedHop }) { @@ -28,22 +40,30 @@ export function PathHopBadge({ hop }: { hop: ResolvedHop }) { ); - const wrapped = - hop.ambiguous && unknown ? ( + const tooltipText = hopTooltip(hop); + const wrappedBadge = + tooltipText != null ? ( {badge} - Multiple matches possible + {tooltipText} ) : ( badge ); + const content = ( + + + {wrappedBadge} + + ); + if (!hopIsLinkable(hop)) { - return wrapped; + return content; } const path = nodeDetailPath({ @@ -53,16 +73,16 @@ export function PathHopBadge({ hop }: { hop: ResolvedHop }) { }); if (!path) { - return wrapped; + return content; } return ( e.stopPropagation()} > - {wrapped} + {content} ); } diff --git a/src/components/messages/heard-path-map-adapters.test.ts b/src/components/messages/heard-path-map-adapters.test.ts index adbe819..fd4f390 100644 --- a/src/components/messages/heard-path-map-adapters.test.ts +++ b/src/components/messages/heard-path-map-adapters.test.ts @@ -347,6 +347,71 @@ describe('heard path adapters', () => { expect(legs[0].waypoints[0].short_name).toBe('Hop1'); }); + it('meshCoreHeardToLegs omits ambiguous hops from map waypoints', () => { + const message: TextMessage = { + id: '8', + packet_id: 8, + protocol: 'meshcore', + sender: { node_id_str: 'mc:abc', long_name: 'X', short_name: 'X' }, + sender_position: { latitude: 55.0, longitude: -4.0 }, + recipient_meshtastic_node_id: null, + channel: 1, + sent_at: new Date().toISOString(), + message_text: 'mc', + is_emoji: false, + reply_to_meshtastic_packet_id: null, + heard: [ + { + observer: { + node_id_str: 'mc:feed', + internal_id: null, + long_name: 'Feeder', + short_name: 'F', + position: { latitude: 55.2, longitude: -4.2 }, + }, + rx_time: new Date().toISOString(), + rx_rssi: -90, + rx_snr: 2, + path_hashes: ['aa', 'bb'], + resolved_path: [ + { + hash: 'aa', + status: 'resolved', + node_id_str: 'mc:hop', + internal_id: '1', + long_name: 'Hop', + short_name: 'H', + ambiguous: false, + position: { latitude: 55.1, longitude: -4.1 }, + }, + { + hash: 'bb', + status: 'ambiguous', + node_id_str: null, + internal_id: null, + long_name: null, + short_name: null, + ambiguous: true, + candidates: [ + { + internal_id: '2', + node_id_str: 'mc:x', + long_name: 'X1', + short_name: 'X1', + position: null, + }, + ], + }, + ], + path_known: false, + }, + ], + }; + const { legs } = meshCoreHeardToLegs(message); + expect(legs[0].waypoints).toHaveLength(1); + expect(legs[0].waypoints[0].node_id_str).toBe('mc:hop'); + }); + it('resolvedHopsFromObservation falls back to path_hashes', () => { const hops = resolvedHopsFromObservation({ observer: { diff --git a/src/components/messages/heard-path-map-adapters.ts b/src/components/messages/heard-path-map-adapters.ts index cf4e9c5..c1fdc3c 100644 --- a/src/components/messages/heard-path-map-adapters.ts +++ b/src/components/messages/heard-path-map-adapters.ts @@ -81,8 +81,11 @@ export function heardPathSenderDisplayLabel( return message.mc_sender_label?.trim() || message.sender?.short_name || message.sender?.node_id_str || 'Sender'; } -function hopToWaypoint(hop: ResolvedHop): TracerouteRouteNode { - const label = hop.long_name || hop.node_id_str || hop.hash; +function hopToWaypoint(hop: ResolvedHop): TracerouteRouteNode | null { + if (hop.status === 'ambiguous') { + return null; + } + const label = hop.short_name || hop.long_name || hop.node_id_str || hop.hash; return { meshtastic_node_id: UNKNOWN_NODE_ID, node_id_str: hop.node_id_str || hop.hash, @@ -149,9 +152,10 @@ export function meshCoreHeardToLegs(message: TextMessage): { const mapLegs: HeardPathLeg[] = []; for (const leg of legs) { if (!leg.receiverPosition) continue; + const waypoints = leg.hops.map(hopToWaypoint).filter((node): node is TracerouteRouteNode => node != null); mapLegs.push({ receiver: { label: leg.receiverLabel, position: leg.receiverPosition }, - waypoints: leg.hops.map(hopToWaypoint), + waypoints, pathKnown: leg.pathKnown, lineColor: leg.lineColor, }); @@ -170,6 +174,17 @@ export function messageToHeardPathLegs(message: TextMessage): { return meshtasticHeardToLegs(message); } +export function messageHasAmbiguousPathHops(message: TextMessage): boolean { + for (const obs of message.heard ?? []) { + if (!isMeshCoreHeardObservation(obs)) continue; + const hops = resolvedHopsFromObservation(obs); + if (hops.some((hop) => hop.status === 'ambiguous' || (hop.candidates?.length ?? 0) > 1)) { + return true; + } + } + return false; +} + export function isMeshCoreHeardMessage(message: TextMessage): boolean { const proto = message.protocol?.toString().toLowerCase(); if (proto === 'meshcore' || proto === '2') return true; diff --git a/src/components/messages/heard-path-map-layers.ts b/src/components/messages/heard-path-map-layers.ts new file mode 100644 index 0000000..7961b1f --- /dev/null +++ b/src/components/messages/heard-path-map-layers.ts @@ -0,0 +1,145 @@ +import { buildPartialSegments, midpoint, type LatLng, type PathSegment } from '@/lib/map-path-segments'; +import { createNodeIcon } from '@/components/nodes/map-utils'; +import type { MapPosition } from './HeardPathMap'; +import type { HeardPathLeg } from './HeardPathMap'; +import { HEARD_PATH_LEG_COLORS, HEARD_PATH_SENDER_COLOR } from './heard-path-constants'; +import L from 'leaflet'; + +export function toLatLng(pos: MapPosition): LatLng { + return [pos.latitude, pos.longitude]; +} + +export function planLegPathSegments(senderPos: LatLng | null, leg: HeardPathLeg): PathSegment[] { + const receiverPos = toLatLng(leg.receiver.position); + const hasPositionedHop = leg.waypoints.some((node) => node.position != null); + + if (!senderPos && !hasPositionedHop) { + return []; + } + + if (leg.pathKnown && hasPositionedHop && senderPos) { + return buildPartialSegments({ + startPos: senderPos, + waypoints: leg.waypoints, + endPos: receiverPos, + truncateAfterLastKnown: false, + }); + } + + return buildPartialSegments({ + startPos: senderPos, + waypoints: leg.waypoints, + endPos: receiverPos, + truncateAfterLastKnown: true, + }); +} + +function unknownLabelContent(labels: { node_id_str: string }[]): string { + return labels.map((l) => l.node_id_str).join(' · '); +} + +export function addPathSegmentsToMap(map: L.Map, layers: L.Layer[], segments: PathSegment[], color: string): void { + segments.forEach((seg) => { + const poly = L.polyline(seg.latlngs, { + color, + weight: 4, + dashArray: seg.dashed ? '10, 10' : undefined, + }).addTo(map); + layers.push(poly); + + if (seg.dashed && seg.unknownLabels.length > 0 && seg.latlngs.length >= 2) { + const mid = midpoint(seg.latlngs[0], seg.latlngs[seg.latlngs.length - 1]); + const tooltip = L.tooltip({ + permanent: true, + direction: 'center', + className: 'traceroute-unknown-label', + }) + .setContent(unknownLabelContent(seg.unknownLabels)) + .setLatLng(mid); + tooltip.addTo(map); + layers.push(tooltip); + } + }); +} + +export type DrawHeardPathLayersOptions = { + map: L.Map; + layers: L.Layer[]; + bounds: L.LatLngBounds; + sender: { label: string; position: MapPosition } | null; + legs: HeardPathLeg[]; +}; + +function drawLegPolylinesAndHopMarkers( + map: L.Map, + layers: L.Layer[], + bounds: L.LatLngBounds, + senderPos: LatLng | null, + legs: HeardPathLeg[], + options: { includeReceiverMarkers: boolean } +): boolean { + let drewPath = false; + + legs.forEach((leg, index) => { + const receiverPos = toLatLng(leg.receiver.position); + const color = leg.lineColor ?? HEARD_PATH_LEG_COLORS[index % HEARD_PATH_LEG_COLORS.length]; + + if (options.includeReceiverMarkers) { + const receiverMarker = L.marker(receiverPos, { + icon: createNodeIcon(leg.receiver.label, color, false), + }).addTo(map); + layers.push(receiverMarker); + bounds.extend(receiverPos); + } + + leg.waypoints.forEach((node) => { + if (!node.position) return; + const pos = toLatLng(node.position); + const label = node.short_name || node.node_id_str; + const hopMarker = L.marker(pos, { + icon: createNodeIcon(label, color, false), + }).addTo(map); + layers.push(hopMarker); + bounds.extend(pos); + }); + + const segments = planLegPathSegments(senderPos, leg); + if (segments.length > 0) { + drewPath = true; + addPathSegmentsToMap(map, layers, segments, color); + segments.forEach((seg) => { + seg.latlngs.forEach((p) => bounds.extend(p)); + }); + } + }); + + return drewPath; +} + +export function drawHeardPathLayers({ map, layers, bounds, sender, legs }: DrawHeardPathLayersOptions): boolean { + const senderPos = sender ? toLatLng(sender.position) : null; + + if (senderPos) { + const senderMarker = L.marker(senderPos, { + icon: createNodeIcon(sender?.label ?? 'S', HEARD_PATH_SENDER_COLOR, false), + }).addTo(map); + layers.push(senderMarker); + bounds.extend(senderPos); + } + + return drawLegPolylinesAndHopMarkers(map, layers, bounds, senderPos, legs, { includeReceiverMarkers: true }); +} + +export function drawHeardPathPolylinesOnly( + map: L.Map, + layers: L.Layer[], + bounds: L.LatLngBounds, + senderPos: LatLng | null, + legs: HeardPathLeg[] +): boolean { + return drawLegPolylinesAndHopMarkers(map, layers, bounds, senderPos, legs, { includeReceiverMarkers: false }); +} + +export function hasDrawablePathOnMap(senderPos: LatLng | null, legs: HeardPathLeg[]): boolean { + return legs.some((leg) => planLegPathSegments(senderPos, leg).length > 0); +} diff --git a/src/lib/map-path-segments.test.ts b/src/lib/map-path-segments.test.ts index 77f8f59..9f2c419 100644 --- a/src/lib/map-path-segments.test.ts +++ b/src/lib/map-path-segments.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildSegments } from './map-path-segments'; +import { buildPartialSegments, buildSegments } from './map-path-segments'; import type { TracerouteRouteNode } from '@/lib/models'; const start: [number, number] = [55.0, -4.0]; @@ -34,3 +34,52 @@ describe('buildSegments', () => { expect(segments.some((s) => !s.dashed)).toBe(true); }); }); + +describe('buildPartialSegments', () => { + it('starts at first positioned hop when sender is missing', () => { + const nodes: TracerouteRouteNode[] = [ + { + meshtastic_node_id: 1, + node_id_str: 'hop', + short_name: 'H', + position: { latitude: 55.05, longitude: -4.05 }, + }, + ]; + const segments = buildPartialSegments({ + startPos: null, + waypoints: nodes, + endPos: end, + }); + expect(segments.length).toBeGreaterThan(0); + }); + + it('omits line to feeder when unknown hops trail last positioned hop', () => { + const nodes: TracerouteRouteNode[] = [ + { + meshtastic_node_id: 1, + node_id_str: 'known', + short_name: 'K', + position: { latitude: 55.05, longitude: -4.05 }, + }, + { + meshtastic_node_id: 2, + node_id_str: 'unknown', + short_name: 'unknown', + position: null, + }, + ]; + const segments = buildPartialSegments({ + startPos: start, + waypoints: nodes, + endPos: end, + truncateAfterLastKnown: true, + }); + const reachesFeeder = segments.some( + (s) => + s.latlngs.length > 0 && + s.latlngs[s.latlngs.length - 1][0] === end[0] && + s.latlngs[s.latlngs.length - 1][1] === end[1] + ); + expect(reachesFeeder).toBe(false); + }); +}); diff --git a/src/lib/map-path-segments.ts b/src/lib/map-path-segments.ts index d2382d5..c5eba13 100644 --- a/src/lib/map-path-segments.ts +++ b/src/lib/map-path-segments.ts @@ -56,3 +56,85 @@ export function buildSegments( export function midpoint(a: LatLng, b: LatLng): LatLng { return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; } + +function toLatLngFromNode(node: TracerouteRouteNode): LatLng { + return [node.position!.latitude, node.position!.longitude]; +} + +export type PartialSegmentParams = { + startPos: LatLng | null; + waypoints: TracerouteRouteNode[]; + endPos: LatLng | null; + truncateAfterLastKnown?: boolean; +}; + +/** + * Build path segments when start and/or end may be missing and trailing hops may lack positions. + * Omits lines past the last positioned intermediate when truncateAfterLastKnown is true. + */ +export function buildPartialSegments({ + startPos, + waypoints, + endPos, + truncateAfterLastKnown = true, +}: PartialSegmentParams): PathSegment[] { + let nodes = waypoints; + let end = endPos; + + if (truncateAfterLastKnown && endPos) { + let lastPositionedIdx = -1; + for (let i = waypoints.length - 1; i >= 0; i -= 1) { + if (waypoints[i].position) { + lastPositionedIdx = i; + break; + } + } + if (lastPositionedIdx >= 0) { + const trailingUnknown = waypoints.slice(lastPositionedIdx + 1).some((w) => !w.position); + if (trailingUnknown) { + end = null; + nodes = waypoints.slice(0, lastPositionedIdx + 1); + } + } else if (!startPos) { + return []; + } + } + + let start = startPos; + if (!start) { + const firstIdx = nodes.findIndex((w) => w.position); + if (firstIdx < 0) { + return []; + } + start = toLatLngFromNode(nodes[firstIdx]); + nodes = nodes.slice(firstIdx + 1); + } + + if (!start) { + return []; + } + + if (!end) { + const lastPositioned = [...nodes].reverse().find((w) => w.position); + if (!lastPositioned?.position) { + return []; + } + const endAnchor = toLatLngFromNode(lastPositioned); + const lastIdx = nodes.indexOf(lastPositioned); + const chain = nodes.slice(0, lastIdx + 1); + return buildSegments(start, chain, endAnchor); + } + + const hasPositionedHop = nodes.some((w) => w.position); + if (!hasPositionedHop) { + return [ + { + latlngs: [start, end], + dashed: true, + unknownLabels: waypoints.map((node) => ({ node_id_str: node.node_id_str })), + }, + ]; + } + + return buildSegments(start, nodes, end); +} diff --git a/src/lib/models.ts b/src/lib/models.ts index 239d0d2..0846b79 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -416,8 +416,10 @@ export interface ResolvedHop { node_id_str: string | null; internal_id: string | null; long_name: string | null; + short_name?: string | null; ambiguous: boolean; position?: MapPosition | null; + candidates?: McSenderCandidate[]; } export interface HeardObserver { From 5ec249cccff906c3da244c774703ea819b8ea040 Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Thu, 4 Jun 2026 13:17:29 +0100 Subject: [PATCH 4/4] fix(messages): show MC heard sender without requiring map position Single mc_sender_candidate is identified in the heard dialog with a node link; HopPositionIcon and geo map use coordinates only when present. --- docs/meshcore/heard-path-dialog.md | 2 +- .../messages/MeshCoreHeardPathFlow.test.tsx | 25 ++++- .../messages/MeshCoreHeardPathFlow.tsx | 39 +++++-- .../messages/MeshCoreHeardPathsPanel.tsx | 12 +- src/components/messages/MessageItem.tsx | 8 +- .../messages/heard-path-map-adapters.test.ts | 36 +++++- .../messages/heard-path-map-adapters.ts | 103 +++++++++++++----- 7 files changed, 183 insertions(+), 42 deletions(-) diff --git a/docs/meshcore/heard-path-dialog.md b/docs/meshcore/heard-path-dialog.md index 238cdd1..21c5cc9 100644 --- a/docs/meshcore/heard-path-dialog.md +++ b/docs/meshcore/heard-path-dialog.md @@ -5,7 +5,7 @@ Opened from **N heard** on a MeshCore text message in message history. ### Layout 1. **Geo map** — [`HeardPathGeoMap`](../../src/components/messages/HeardPathGeoMap.tsx) ([docs](../../src/components/messages/HeardPathGeoMap.md)): Leaflet markers for sender (when position known) and feeders with coordinates, plus optional **partial hop polylines** when API resolves hop positions. Ambiguous hops are list-only. Meshtastic uses [`HeardPathMap`](../../src/components/messages/HeardPathMap.tsx) ([docs](../../src/components/messages/HeardPathMap.md)) for full sender→feeder paths. -2. **Paths by feeder** — One schematic row per observation: sender → hash hops → feeder. Hops use dashed monospace badges (`unknown` per API v1); they are **not** placed at geographic coordinates. +2. **Paths by feeder** — One schematic row per observation: sender → hash hops → feeder. A single `mc_sender_candidate` is shown as the sender (with node link) even when the node has no map position; `HopPositionIcon` reflects position only. Hops use dashed monospace badges when unresolved; they are **not** placed at geographic coordinates unless the API supplies hop positions. 3. **Feeder list** — Each observer with RSSI/SNR and a **Path (this feeder)** column showing that observation’s hop chain. Each feeder can report a **different** `path_hashes` / `resolved_path` for the same message. diff --git a/src/components/messages/MeshCoreHeardPathFlow.test.tsx b/src/components/messages/MeshCoreHeardPathFlow.test.tsx index fab0cfa..ca6ed4f 100644 --- a/src/components/messages/MeshCoreHeardPathFlow.test.tsx +++ b/src/components/messages/MeshCoreHeardPathFlow.test.tsx @@ -61,10 +61,20 @@ const baseLeg: MeshCoreHeardLeg = { lineColor: '#2563eb', }; -function renderFlow(leg: MeshCoreHeardLeg, senderKnown: boolean) { +function renderFlow( + leg: MeshCoreHeardLeg, + senderKnown: boolean, + opts?: { senderHasPosition?: boolean; senderDetailPath?: string | null } +) { return render( - + ); } @@ -84,7 +94,16 @@ describe('MeshCoreHeardPathFlow', () => { }); it('shows sender unknown label when sender not known', () => { - renderFlow(baseLeg, false); + renderFlow(baseLeg, false, { senderHasPosition: false }); expect(screen.getByText(/Sender unknown/i)).toBeInTheDocument(); }); + + it('links identified sender without position', () => { + renderFlow(baseLeg, true, { + senderHasPosition: false, + senderDetailPath: '/nodes/mc%3A9cce73b9b3ee', + }); + expect(screen.getByRole('link', { name: '☘️GI7' })).toHaveAttribute('href', '/nodes/mc%3A9cce73b9b3ee'); + expect(screen.queryByText(/Sender unknown/i)).not.toBeInTheDocument(); + }); }); diff --git a/src/components/messages/MeshCoreHeardPathFlow.tsx b/src/components/messages/MeshCoreHeardPathFlow.tsx index 14eba5d..e573fe9 100644 --- a/src/components/messages/MeshCoreHeardPathFlow.tsx +++ b/src/components/messages/MeshCoreHeardPathFlow.tsx @@ -4,23 +4,46 @@ import { PathHopChain } from './PathHopChain'; import type { MeshCoreHeardLeg } from './heard-path-map-adapters'; import { ArrowRight } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { Link } from 'react-router-dom'; export type MeshCoreHeardPathFlowProps = { leg: MeshCoreHeardLeg; senderDisplayLabel: string; senderKnown: boolean; + senderHasPosition?: boolean; + senderDetailPath?: string | null; }; -export function MeshCoreHeardPathFlow({ leg, senderDisplayLabel, senderKnown }: MeshCoreHeardPathFlowProps) { +export function MeshCoreHeardPathFlow({ + leg, + senderDisplayLabel, + senderKnown, + senderHasPosition = false, + senderDetailPath = null, +}: MeshCoreHeardPathFlowProps) { + const senderLabel = senderKnown + ? senderDisplayLabel + : `Sender unknown${senderDisplayLabel ? ` (${senderDisplayLabel})` : ''}`; + + const senderBadge = ( + + {senderLabel} + + ); + const startBadge = ( - - - {senderKnown ? senderDisplayLabel : `Sender unknown${senderDisplayLabel ? ` (${senderDisplayLabel})` : ''}`} - + + {senderKnown && senderDetailPath ? ( + + {senderBadge} + + ) : ( + senderBadge + )} ); diff --git a/src/components/messages/MeshCoreHeardPathsPanel.tsx b/src/components/messages/MeshCoreHeardPathsPanel.tsx index e5c5ee6..2d2ef8e 100644 --- a/src/components/messages/MeshCoreHeardPathsPanel.tsx +++ b/src/components/messages/MeshCoreHeardPathsPanel.tsx @@ -5,6 +5,8 @@ export type MeshCoreHeardPathsPanelProps = { legs: MeshCoreHeardLeg[]; senderDisplayLabel: string; senderKnown: boolean; + senderHasPosition?: boolean; + senderDetailPath?: string | null; hasAmbiguousHops?: boolean; }; @@ -12,6 +14,8 @@ export function MeshCoreHeardPathsPanel({ legs, senderDisplayLabel, senderKnown, + senderHasPosition = false, + senderDetailPath = null, hasAmbiguousHops = false, }: MeshCoreHeardPathsPanelProps) { if (legs.length === 0) { @@ -43,7 +47,13 @@ export function MeshCoreHeardPathsPanel({ style={{ borderLeftWidth: 4, borderLeftColor: leg.lineColor }} >
Heard by {leg.receiverLabel}
- +
))}
diff --git a/src/components/messages/MessageItem.tsx b/src/components/messages/MessageItem.tsx index 1b69874..e5874dd 100644 --- a/src/components/messages/MessageItem.tsx +++ b/src/components/messages/MessageItem.tsx @@ -7,6 +7,7 @@ import { PathHopChain } from '@/components/messages/PathHopChain'; import { isMeshCoreHeardMessage, isMeshCoreHeardObservation, + heardPathSenderForGeoMap, meshCoreHeardLegs, meshCoreHeardToLegs, messageHasAmbiguousPathHops, @@ -28,7 +29,8 @@ import { nodeDetailPath } from '@/lib/node-detail-routes'; function MeshCoreHeardDialogBody({ message }: { message: TextMessage }) { const { sender, senderDisplayLabel, legs } = useMemo(() => meshCoreHeardLegs(message), [message]); - const senderKnown = sender != null; + const senderKnown = sender?.identified ?? false; + const senderHasPosition = sender?.position != null; const geoFeeders = useMemo( () => @@ -42,7 +44,7 @@ function MeshCoreHeardDialogBody({ message }: { message: TextMessage }) { [legs] ); - const geoSender = sender ? { label: sender.label, position: sender.position } : null; + const geoSender = heardPathSenderForGeoMap(sender); const { legs: pathLegs } = useMemo(() => meshCoreHeardToLegs(message), [message]); const hasAmbiguousHops = useMemo(() => messageHasAmbiguousPathHops(message), [message]); @@ -63,6 +65,8 @@ function MeshCoreHeardDialogBody({ message }: { message: TextMessage }) { legs={legs} senderDisplayLabel={senderDisplayLabel} senderKnown={senderKnown} + senderHasPosition={senderHasPosition} + senderDetailPath={sender?.detailPath ?? null} hasAmbiguousHops={hasAmbiguousHops} />
diff --git a/src/components/messages/heard-path-map-adapters.test.ts b/src/components/messages/heard-path-map-adapters.test.ts index fd4f390..bc2c338 100644 --- a/src/components/messages/heard-path-map-adapters.test.ts +++ b/src/components/messages/heard-path-map-adapters.test.ts @@ -3,6 +3,7 @@ import { meshCoreHeardLegs, meshCoreHeardToLegs, meshtasticHeardToLegs, + resolveHeardPathSender, resolvedHopsFromObservation, } from './heard-path-map-adapters'; import type { TextMessage } from '@/lib/models'; @@ -46,6 +47,39 @@ describe('heard path adapters', () => { expect(legs[0].waypoints).toHaveLength(0); }); + it('resolveHeardPathSender identifies single mc_sender_candidate without position', () => { + const message: TextMessage = { + id: 'gi7', + packet_id: 1, + protocol: 'meshcore', + sender: null, + mc_sender_label: '☘️GI7ULG☘️', + mc_sender_candidates: [ + { + internal_id: '00000000-0000-4000-8000-000000000099', + node_id_str: 'mc:9cce73b9b3ee', + long_name: '☘️GI7ULG☘️', + short_name: '☘️GI7', + position: null, + }, + ], + recipient_meshtastic_node_id: null, + channel: 1, + sent_at: new Date().toISOString(), + message_text: '☘️GI7ULG☘️: Test', + is_emoji: false, + reply_to_meshtastic_packet_id: null, + heard: [], + }; + const sender = resolveHeardPathSender(message); + expect(sender?.identified).toBe(true); + expect(sender?.label).toBe('☘️GI7'); + expect(sender?.position).toBeNull(); + expect(sender?.detailPath).toBe('/nodes/mc%3A9cce73b9b3ee'); + const { sender: geoSender } = meshCoreHeardToLegs(message); + expect(geoSender).toBeNull(); + }); + it('meshCoreHeardToLegs uses single mc_sender_candidate with position as sender', () => { const message: TextMessage = { id: '3', @@ -76,7 +110,7 @@ describe('heard path adapters', () => { expect(sender?.position.latitude).toBe(55.0); }); - it('meshCoreHeardToLegs omits sender when multiple positioned candidates', () => { + it('meshCoreHeardToLegs omits geo sender when multiple mc_sender_candidates', () => { const pos = { latitude: 55.0, longitude: -4.0 }; const message: TextMessage = { id: '4', diff --git a/src/components/messages/heard-path-map-adapters.ts b/src/components/messages/heard-path-map-adapters.ts index c1fdc3c..b1f1833 100644 --- a/src/components/messages/heard-path-map-adapters.ts +++ b/src/components/messages/heard-path-map-adapters.ts @@ -7,6 +7,7 @@ import type { TextMessage, } from '@/lib/models'; import type { TracerouteRouteNode } from '@/lib/models'; +import { nodeDetailPath } from '@/lib/node-detail-routes'; import { HEARD_PATH_LEG_COLORS } from './heard-path-constants'; import type { HeardPathLeg } from './HeardPathMap'; @@ -41,42 +42,91 @@ export function resolvedHopsFromObservation(obs: MeshCoreHeardObservation): Reso })); } -function senderFromMcCandidates(candidates: McSenderCandidate[] | undefined): { +/** Sender for heard dialog: identity vs map position are separate. */ +export type HeardPathSender = { label: string; - position: MapPosition; -} | null { - if (!candidates?.length) return null; - const positioned = candidates.filter((c) => c.position != null) as Array< - McSenderCandidate & { position: MapPosition } - >; - if (positioned.length !== 1) return null; - const node = positioned[0]; - return { - label: node.short_name || node.long_name || node.node_id_str, - position: node.position, - }; + position: MapPosition | null; + /** True when the sender node is identified (not ambiguous). */ + identified: boolean; + detailPath: string | null; +}; + +function mcCandidateDetailPath(candidate: McSenderCandidate): string | null { + return nodeDetailPath({ + internal_id: candidate.internal_id ?? undefined, + node_id_str: candidate.node_id_str, + protocol: 2, + }); } -export function mapHeardPathSender(message: TextMessage): { label: string; position: MapPosition } | null { - if (message.sender && message.sender_position) { +function senderFromMcCandidates( + candidates: McSenderCandidate[] | undefined, + fallbackLabel?: string +): HeardPathSender | null { + if (!candidates?.length) { + if (fallbackLabel) { + return { label: fallbackLabel, position: null, identified: false, detailPath: null }; + } + return null; + } + if (candidates.length === 1) { + const node = candidates[0]; return { - label: message.sender.short_name || message.sender.node_id_str, - position: message.sender_position, + label: node.short_name || node.long_name || node.node_id_str, + position: node.position ?? null, + identified: true, + detailPath: mcCandidateDetailPath(node), }; } + const label = + fallbackLabel || candidates[0].short_name || candidates[0].long_name || candidates[0].node_id_str || 'Sender'; + return { label, position: null, identified: false, detailPath: null }; +} + +export function resolveHeardPathSender(message: TextMessage): HeardPathSender | null { + const fallbackLabel = message.mc_sender_label?.trim(); + const proto = message.protocol?.toString().toLowerCase(); + const isMeshCore = proto === 'meshcore' || proto === '2'; + + if (message.sender?.node_id_str) { + return { + label: message.sender.short_name || message.sender.long_name || message.sender.node_id_str, + position: message.sender_position ?? null, + identified: true, + detailPath: nodeDetailPath({ + node_id_str: message.sender.node_id_str, + protocol: isMeshCore ? 2 : 1, + }), + }; + } + if (message.sender_position) { return { - label: message.sender?.short_name || message.mc_sender_label || message.sender?.node_id_str || 'Sender', + label: message.sender?.short_name || fallbackLabel || message.sender?.node_id_str || 'Sender', position: message.sender_position, + identified: true, + detailPath: message.sender?.node_id_str + ? nodeDetailPath({ node_id_str: message.sender.node_id_str, protocol: isMeshCore ? 2 : 1 }) + : null, }; } - return senderFromMcCandidates(message.mc_sender_candidates); + + return senderFromMcCandidates(message.mc_sender_candidates, fallbackLabel); +} + +/** Geo map anchor when sender has coordinates only. */ +export function heardPathSenderForGeoMap( + sender: HeardPathSender | null +): { label: string; position: MapPosition } | null { + if (!sender?.position) return null; + return { label: sender.label, position: sender.position }; +} + +export function mapHeardPathSender(message: TextMessage): { label: string; position: MapPosition } | null { + return heardPathSenderForGeoMap(resolveHeardPathSender(message)); } -export function heardPathSenderDisplayLabel( - message: TextMessage, - sender: { label: string; position: MapPosition } | null -): string { +export function heardPathSenderDisplayLabel(message: TextMessage, sender: HeardPathSender | null): string { if (sender?.label) return sender.label; return message.mc_sender_label?.trim() || message.sender?.short_name || message.sender?.node_id_str || 'Sender'; } @@ -95,11 +145,11 @@ function hopToWaypoint(hop: ResolvedHop): TracerouteRouteNode | null { } export function meshCoreHeardLegs(message: TextMessage): { - sender: { label: string; position: MapPosition } | null; + sender: HeardPathSender | null; senderDisplayLabel: string; legs: MeshCoreHeardLeg[]; } { - const sender = mapHeardPathSender(message); + const sender = resolveHeardPathSender(message); const senderDisplayLabel = heardPathSenderDisplayLabel(message, sender); const legs: MeshCoreHeardLeg[] = []; let colorIndex = 0; @@ -148,7 +198,8 @@ export function meshCoreHeardToLegs(message: TextMessage): { sender: { label: string; position: MapPosition } | null; legs: HeardPathLeg[]; } { - const { sender, legs } = meshCoreHeardLegs(message); + const { legs } = meshCoreHeardLegs(message); + const sender = heardPathSenderForGeoMap(resolveHeardPathSender(message)); const mapLegs: HeardPathLeg[] = []; for (const leg of legs) { if (!leg.receiverPosition) continue;