Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/meshcore/heard-path-dialog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## Message heard dialog (MeshCore paths)

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.
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.

Each feeder can report a **different** `path_hashes` / `resolved_path` for the same message.

### Related

- Tracking: [meshflow-ui#311](https://github.com/pskillen/meshflow-ui/issues/311)
- Passive path diagnostic tables: [passive-path-preview.md](./passive-path-preview.md) (`/meshcore/path-tracing`)
- Geographic hop placement on maps: deferred — see [meshflow-api packet-path-tracing outstanding](https://github.com/pskillen/meshflow-api/blob/main/docs/features/meshcore/packet-path-tracing/packet-path-tracing-outstanding.md)
2 changes: 2 additions & 0 deletions docs/meshcore/passive-path-preview.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ This page is a **diagnostic MVP** for MeshCore passive packet path tracing (API
Staff users can manually annotate a segment (link hash to an observed node) via `PATCH /api/meshcore/path-tracing/segments/{id}/`.

This preview supports **M2 decision-making** (mode/size distribution, chain sanity). The full map, realtime buffer, and centrality UI are tracked separately as [meshflow-ui#309](https://github.com/pskillen/meshflow-ui/issues/309) (M7), building on API work in [meshflow-api#372](https://github.com/pskillen/meshflow-api/issues/372).

For **per-message** passive paths in the heard dialog (logical hop chains per feeder), see [heard-path-dialog.md](./heard-path-dialog.md) ([#311](https://github.com/pskillen/meshflow-ui/issues/311)).
142 changes: 142 additions & 0 deletions src/components/messages/HeardPathGeoMap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import type { MapPosition } from '@/lib/models';
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 { HEARD_PATH_LEG_COLORS, HEARD_PATH_SENDER_COLOR } from './heard-path-constants';
import L from 'leaflet';
import { useEffect, useRef } from 'react';
import 'leaflet/dist/leaflet.css';

const DEFAULT_CENTER: LatLng = [55.8642, -4.2518];

export type HeardPathGeoAnchor = {
label: string;
position: MapPosition;
color?: string;
};

export type HeardPathGeoMapProps = {
sender: HeardPathGeoAnchor | null;
feeders: HeardPathGeoAnchor[];
senderName?: string | null;
};

function toLatLng(pos: MapPosition): LatLng {
return [pos.latitude, pos.longitude];
}

export function HeardPathGeoMap({ sender, feeders, senderName }: HeardPathGeoMapProps) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
const tileLayerRef = useRef<L.TileLayer | null>(null);
const layersRef = useRef<L.Layer[]>([]);
const { url: tileUrl, attribution } = useMapTileUrl();

useEffect(() => {
if (mapRef.current && !mapInstanceRef.current) {
const map = L.map(mapRef.current).setView(DEFAULT_CENTER, 13);
const tileLayer = L.tileLayer(tileUrl, { attribution }).addTo(map);
tileLayerRef.current = tileLayer;
mapInstanceRef.current = map;

const style = document.createElement('style');
style.id = 'heard-path-geo-map-styles';
style.textContent = MAP_NODE_MARKER_CSS;
document.head.appendChild(style);

return () => {
map.remove();
mapInstanceRef.current = null;
tileLayerRef.current = null;
style.remove();
};
}
}, []);

Check warning on line 55 in src/components/messages/HeardPathGeoMap.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

React Hook useEffect has missing dependencies: 'attribution' and 'tileUrl'. Either include them or remove the dependency array

useEffect(() => {
const map = mapInstanceRef.current;
const oldLayer = tileLayerRef.current;
if (map && oldLayer) {
map.removeLayer(oldLayer);
const newLayer = L.tileLayer(tileUrl, { attribution }).addTo(map);
tileLayerRef.current = newLayer;
}
}, [tileUrl, attribution]);

useEffect(() => {
const map = mapInstanceRef.current;
if (!map) return;

layersRef.current.forEach((layer) => layer.remove());
layersRef.current = [];

const bounds = L.latLngBounds([]);
let hasMarker = false;

if (sender) {
const pos = toLatLng(sender.position);
const marker = L.marker(pos, {
icon: createNodeIcon(sender.label, HEARD_PATH_SENDER_COLOR, false),
}).addTo(map);
layersRef.current.push(marker);
bounds.extend(pos);
hasMarker = true;
}

feeders.forEach((feeder, index) => {
const pos = toLatLng(feeder.position);
const color = feeder.color ?? HEARD_PATH_LEG_COLORS[index % HEARD_PATH_LEG_COLORS.length];
const marker = L.marker(pos, {
icon: createNodeIcon(feeder.label, color, false),
}).addTo(map);
layersRef.current.push(marker);
bounds.extend(pos);
hasMarker = true;
});

if (!hasMarker) return;

map.invalidateSize();
const t = setTimeout(() => {
if (mapInstanceRef.current !== map) return;
map.invalidateSize();
const singlePoint = bounds.getNorthEast().equals(bounds.getSouthWest());
if (singlePoint) {
map.setView(bounds.getCenter(), 13);
} else {
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 15 });
}
}, 150);
return () => clearTimeout(t);
}, [sender, feeders]);

if (!sender && feeders.length === 0) {
return (
<div
className="flex min-h-[200px] items-center justify-center rounded-md border bg-muted/30 text-sm text-muted-foreground"
data-testid="heard-path-geo-map-empty"
>
No map — sender and feeder positions unknown
</div>
);
}

const showSenderWarning = !sender;
const warningLabel = senderName?.trim() || sender?.label;

return (
<div className="relative rounded-md border" style={{ height: '240px' }} data-testid="heard-path-geo-map">
<div ref={mapRef} style={{ height: '100%' }} className="map-container rounded-md" />
{showSenderWarning && feeders.length > 0 && (
<div
className="pointer-events-none absolute left-2 right-2 top-2 z-[1000] rounded-md border border-amber-500/60 bg-amber-50/95 px-3 py-2 text-xs text-amber-950 shadow-sm dark:border-amber-600/50 dark:bg-amber-950/90 dark:text-amber-50"
role="status"
>
Sender position unknown
{warningLabel ? ` (${warningLabel})` : ''} — feeders shown; hop paths are schematic below.
</div>
)}
</div>
);
}
8 changes: 4 additions & 4 deletions src/components/messages/HeardPathMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
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];
const SENDER_COLOR = '#16a34a';
const LEG_COLORS = ['#2563eb', '#0891b2', '#7c3aed', '#db2777', '#ea580c'];

export type MapPosition = { latitude: number; longitude: number };

Expand Down Expand Up @@ -63,7 +63,7 @@
style.remove();
};
}
}, []);

Check warning on line 66 in src/components/messages/HeardPathMap.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

React Hook useEffect has missing dependencies: 'attribution' and 'tileUrl'. Either include them or remove the dependency array

useEffect(() => {
const map = mapInstanceRef.current;
Expand All @@ -86,15 +86,15 @@

if (senderPos) {
const senderMarker = L.marker(senderPos, {
icon: createNodeIcon(sender?.label ?? 'S', SENDER_COLOR, false),
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 ?? LEG_COLORS[index % LEG_COLORS.length];
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);
Expand Down
90 changes: 90 additions & 0 deletions src/components/messages/MeshCoreHeardPathFlow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { MeshCoreHeardPathFlow } from './MeshCoreHeardPathFlow';
import type { MeshCoreHeardLeg } from './heard-path-map-adapters';

const baseLeg: MeshCoreHeardLeg = {
observation: {
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: 'unknown',
node_id_str: null,
internal_id: null,
long_name: null,
ambiguous: false,
},
{
hash: 'bb',
status: 'unknown',
node_id_str: null,
internal_id: null,
long_name: null,
ambiguous: false,
},
],
path_known: false,
},
receiverLabel: 'F',
receiverPosition: { latitude: 55.2, longitude: -4.2 },
hops: [
{
hash: 'aa',
status: 'unknown',
node_id_str: null,
internal_id: null,
long_name: null,
ambiguous: false,
},
{
hash: 'bb',
status: 'unknown',
node_id_str: null,
internal_id: null,
long_name: null,
ambiguous: false,
},
],
pathKnown: false,
lineColor: '#2563eb',
};

function renderFlow(leg: MeshCoreHeardLeg, senderKnown: boolean) {
return render(
<MemoryRouter>
<MeshCoreHeardPathFlow leg={leg} senderDisplayLabel="Sender" senderKnown={senderKnown} />
</MemoryRouter>
);
}

describe('MeshCoreHeardPathFlow', () => {
it('renders one hop badge per segment', () => {
renderFlow(baseLeg, true);
expect(screen.getByText('aa')).toBeInTheDocument();
expect(screen.getByText('bb')).toBeInTheDocument();
expect(screen.queryAllByRole('link')).toHaveLength(0);
});

it('shows empty path message when no hops', () => {
const leg = { ...baseLeg, hops: [] };
renderFlow(leg, true);
expect(screen.getByText(/No path recorded for this observation/i)).toBeInTheDocument();
});

it('shows sender unknown label when sender not known', () => {
renderFlow(baseLeg, false);
expect(screen.getByText(/Sender unknown/i)).toBeInTheDocument();
});
});
49 changes: 49 additions & 0 deletions src/components/messages/MeshCoreHeardPathFlow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Badge } from '@/components/ui/badge';
import { PathHopChain } from './PathHopChain';
import type { MeshCoreHeardLeg } from './heard-path-map-adapters';
import { ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';

export type MeshCoreHeardPathFlowProps = {
leg: MeshCoreHeardLeg;
senderDisplayLabel: string;
senderKnown: boolean;
};

export function MeshCoreHeardPathFlow({ leg, senderDisplayLabel, senderKnown }: MeshCoreHeardPathFlowProps) {
const startBadge = (
<Badge
variant={senderKnown ? 'secondary' : 'outline'}
className={cn(!senderKnown && 'border-dashed text-muted-foreground')}
>
{senderKnown ? senderDisplayLabel : `Sender unknown${senderDisplayLabel ? ` (${senderDisplayLabel})` : ''}`}
</Badge>
);

const endBadge = (
<Badge variant="secondary" className="max-w-full" style={{ borderColor: leg.lineColor }}>
{leg.receiverLabel}
</Badge>
);

if (leg.hops.length === 0) {
return (
<div className="flex flex-wrap items-center gap-1 text-sm" data-testid={`heard-path-flow-${leg.receiverLabel}`}>
{startBadge}
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
{endBadge}
<span className="w-full text-xs text-muted-foreground italic mt-1">No path recorded for this observation</span>
</div>
);
}

return (
<div className="flex flex-wrap items-center gap-1 text-sm" data-testid={`heard-path-flow-${leg.receiverLabel}`}>
{startBadge}
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<PathHopChain hops={leg.hops} />
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
{endBadge}
</div>
);
}
37 changes: 37 additions & 0 deletions src/components/messages/MeshCoreHeardPathsPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { MeshCoreHeardPathFlow } from './MeshCoreHeardPathFlow';
import type { MeshCoreHeardLeg } from './heard-path-map-adapters';

export type MeshCoreHeardPathsPanelProps = {
legs: MeshCoreHeardLeg[];
senderDisplayLabel: string;
senderKnown: boolean;
};

export function MeshCoreHeardPathsPanel({ legs, senderDisplayLabel, senderKnown }: MeshCoreHeardPathsPanelProps) {
if (legs.length === 0) {
return (
<p className="text-sm text-muted-foreground rounded-md border bg-muted/30 px-3 py-4">
No feeder observations for this message.
</p>
);
}

return (
<div className="space-y-3" data-testid="meshcore-heard-paths-panel">
<p className="text-xs text-muted-foreground">
Paths are per feeder and may differ for the same message. Hop hashes are list-order evidence, not map
coordinates.
</p>
{legs.map((leg) => (
<div
key={`${leg.observation.observer.node_id_str}-${leg.observation.rx_time}`}
className="rounded-md border px-3 py-3 space-y-2"
style={{ borderLeftWidth: 4, borderLeftColor: leg.lineColor }}
>
<div className="text-xs font-medium text-muted-foreground">Heard by {leg.receiverLabel}</div>
<MeshCoreHeardPathFlow leg={leg} senderDisplayLabel={senderDisplayLabel} senderKnown={senderKnown} />
</div>
))}
</div>
);
}
Loading
Loading