Add hop display map in pathing modal

This commit is contained in:
Jack Kingsman
2026-03-04 13:03:43 -08:00
parent 9629a35fe1
commit c2931a266e
2 changed files with 231 additions and 12 deletions

View File

@@ -1,3 +1,4 @@
import { useState, lazy, Suspense } from 'react';
import type { Contact, RadioConfig, MessagePath } from '../types';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
import { Button } from './ui/button';
@@ -14,6 +15,10 @@ import {
import { formatTime } from '../utils/messageParser';
import { getMapFocusHash } from '../utils/urlHash';
const PathRouteMap = lazy(() =>
import('./PathRouteMap').then((m) => ({ default: m.PathRouteMap }))
);
interface PathModalProps {
open: boolean;
onClose: () => void;
@@ -39,6 +44,7 @@ export function PathModal({
isResendable,
onResend,
}: PathModalProps) {
const [expandedMaps, setExpandedMaps] = useState<Set<number>>(new Set());
const hasResendActions = isOutgoingChan && messageId !== undefined && onResend;
const hasPaths = paths.length > 0;
@@ -120,19 +126,54 @@ export function PathModal({
</div>
)}
{resolvedPaths.map((pathData, index) => (
<div key={index}>
{!hasSinglePath && (
<div className="text-sm text-foreground/70 font-semibold mb-2 pb-1 border-b border-border">
Path {index + 1}{' '}
<span className="font-normal text-muted-foreground">
received {formatTime(pathData.received_at)}
</span>
{resolvedPaths.map((pathData, index) => {
const mapExpanded = expandedMaps.has(index);
const toggleMap = () =>
setExpandedMaps((prev) => {
const next = new Set(prev);
if (next.has(index)) next.delete(index);
else next.add(index);
return next;
});
return (
<div key={index}>
<div className="flex items-center justify-between mb-2 pb-1 border-b border-border">
{!hasSinglePath ? (
<div className="text-sm text-foreground/70 font-semibold">
Path {index + 1}{' '}
<span className="font-normal text-muted-foreground">
received {formatTime(pathData.received_at)}
</span>
</div>
) : (
<div />
)}
<button
onClick={toggleMap}
className="text-xs text-primary hover:underline cursor-pointer shrink-0 ml-2"
>
{mapExpanded ? 'Hide map' : 'Map route'}
</button>
</div>
)}
<PathVisualization resolved={pathData.resolved} senderInfo={senderInfo} />
</div>
))}
{mapExpanded && (
<div className="mb-2">
<Suspense
fallback={
<div
className="rounded border border-border bg-muted/30 animate-pulse"
style={{ height: 220 }}
/>
}
>
<PathRouteMap resolved={pathData.resolved} senderInfo={senderInfo} />
</Suspense>
</div>
)}
<PathVisualization resolved={pathData.resolved} senderInfo={senderInfo} />
</div>
);
})}
</div>
)}

View File

@@ -0,0 +1,178 @@
import { useEffect, useRef } from 'react';
import { MapContainer, TileLayer, Marker, Tooltip, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { isValidLocation } from '../utils/pathUtils';
import type { ResolvedPath, SenderInfo } from '../utils/pathUtils';
interface PathRouteMapProps {
resolved: ResolvedPath;
senderInfo: SenderInfo;
}
// Colors for hop markers (indexed by hop number - 1)
const HOP_COLORS = [
'#f97316', // Hop 1: orange
'#eab308', // Hop 2: yellow
'#22c55e', // Hop 3: green
'#06b6d4', // Hop 4: cyan
'#ec4899', // Hop 5: pink
'#f43f5e', // Hop 6: rose
'#a855f7', // Hop 7: purple
'#64748b', // Hop 8: slate
];
const SENDER_COLOR = '#3b82f6'; // blue
const RECEIVER_COLOR = '#8b5cf6'; // violet
function makeIcon(label: string, color: string): L.DivIcon {
return L.divIcon({
className: '',
iconSize: [24, 24],
iconAnchor: [12, 12],
html: `<div style="
width:24px;height:24px;border-radius:50%;
background:${color};color:#fff;
display:flex;align-items:center;justify-content:center;
font-size:11px;font-weight:700;
border:2px solid rgba(255,255,255,0.8);
box-shadow:0 1px 4px rgba(0,0,0,0.4);
">${label}</div>`,
});
}
function getHopColor(hopIndex: number): string {
return HOP_COLORS[hopIndex % HOP_COLORS.length];
}
/** Collect all valid [lat, lon] points for bounds fitting */
function collectPoints(resolved: ResolvedPath): [number, number][] {
const pts: [number, number][] = [];
if (isValidLocation(resolved.sender.lat, resolved.sender.lon)) {
pts.push([resolved.sender.lat!, resolved.sender.lon!]);
}
for (const hop of resolved.hops) {
for (const m of hop.matches) {
if (isValidLocation(m.lat, m.lon)) {
pts.push([m.lat!, m.lon!]);
}
}
}
if (isValidLocation(resolved.receiver.lat, resolved.receiver.lon)) {
pts.push([resolved.receiver.lat!, resolved.receiver.lon!]);
}
return pts;
}
/** Fit map bounds once on mount, then let the user pan/zoom freely */
function RouteMapBounds({ points }: { points: [number, number][] }) {
const map = useMap();
const fitted = useRef(false);
useEffect(() => {
if (fitted.current || points.length === 0) return;
fitted.current = true;
if (points.length === 1) {
map.setView(points[0], 12);
} else {
map.fitBounds(points as L.LatLngBoundsExpression, { padding: [30, 30], maxZoom: 14 });
}
}, [map, points]);
return null;
}
export function PathRouteMap({ resolved, senderInfo }: PathRouteMapProps) {
const points = collectPoints(resolved);
const hasAnyGps = points.length > 0;
// Check if some nodes are missing GPS
let totalNodes = 2; // sender + receiver
let nodesWithGps = 0;
if (isValidLocation(resolved.sender.lat, resolved.sender.lon)) nodesWithGps++;
if (isValidLocation(resolved.receiver.lat, resolved.receiver.lon)) nodesWithGps++;
for (const hop of resolved.hops) {
if (hop.matches.length === 0) {
totalNodes++;
} else {
totalNodes += hop.matches.length;
nodesWithGps += hop.matches.filter((m) => isValidLocation(m.lat, m.lon)).length;
}
}
const someMissingGps = hasAnyGps && nodesWithGps < totalNodes;
if (!hasAnyGps) {
return (
<div className="h-14 rounded border border-border bg-muted/30 flex items-center justify-center text-sm text-muted-foreground">
No nodes in this route have GPS coordinates
</div>
);
}
const center: [number, number] = points[0];
return (
<div>
<div className="rounded border border-border overflow-hidden" style={{ height: 220 }}>
<MapContainer
center={center}
zoom={10}
className="h-full w-full"
style={{ background: '#1a1a2e' }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<RouteMapBounds points={points} />
{/* Sender marker */}
{isValidLocation(resolved.sender.lat, resolved.sender.lon) && (
<Marker
position={[resolved.sender.lat!, resolved.sender.lon!]}
icon={makeIcon('S', SENDER_COLOR)}
>
<Tooltip direction="top" offset={[0, -14]}>
{senderInfo.name || 'Sender'}
</Tooltip>
</Marker>
)}
{/* Hop markers */}
{resolved.hops.map((hop, hopIdx) =>
hop.matches
.filter((m) => isValidLocation(m.lat, m.lon))
.map((m, mIdx) => (
<Marker
key={`hop-${hopIdx}-${mIdx}`}
position={[m.lat!, m.lon!]}
icon={makeIcon(String(hopIdx + 1), getHopColor(hopIdx))}
>
<Tooltip direction="top" offset={[0, -14]}>
{m.name || m.public_key.slice(0, 12)}
</Tooltip>
</Marker>
))
)}
{/* Receiver marker */}
{isValidLocation(resolved.receiver.lat, resolved.receiver.lon) && (
<Marker
position={[resolved.receiver.lat!, resolved.receiver.lon!]}
icon={makeIcon('R', RECEIVER_COLOR)}
>
<Tooltip direction="top" offset={[0, -14]}>
{resolved.receiver.name || 'Receiver'}
</Tooltip>
</Marker>
)}
</MapContainer>
</div>
{someMissingGps && (
<p className="text-xs text-muted-foreground mt-1">
Some nodes in this route have no GPS and are not shown
</p>
)}
</div>
);
}