diff --git a/frontend/src/components/PathModal.tsx b/frontend/src/components/PathModal.tsx index 12e5b39..eaf6495 100644 --- a/frontend/src/components/PathModal.tsx +++ b/frontend/src/components/PathModal.tsx @@ -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>(new Set()); const hasResendActions = isOutgoingChan && messageId !== undefined && onResend; const hasPaths = paths.length > 0; @@ -120,19 +126,54 @@ export function PathModal({ )} - {resolvedPaths.map((pathData, index) => ( -
- {!hasSinglePath && ( -
- Path {index + 1}{' '} - - — received {formatTime(pathData.received_at)} - + {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 ( +
+
+ {!hasSinglePath ? ( +
+ Path {index + 1}{' '} + + — received {formatTime(pathData.received_at)} + +
+ ) : ( +
+ )} +
- )} - -
- ))} + {mapExpanded && ( +
+ + } + > + + +
+ )} + +
+ ); + })}
)} diff --git a/frontend/src/components/PathRouteMap.tsx b/frontend/src/components/PathRouteMap.tsx new file mode 100644 index 0000000..b778d82 --- /dev/null +++ b/frontend/src/components/PathRouteMap.tsx @@ -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: `
${label}
`, + }); +} + +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 ( +
+ No nodes in this route have GPS coordinates +
+ ); + } + + const center: [number, number] = points[0]; + + return ( +
+
+ + + + + {/* Sender marker */} + {isValidLocation(resolved.sender.lat, resolved.sender.lon) && ( + + + {senderInfo.name || 'Sender'} + + + )} + + {/* Hop markers */} + {resolved.hops.map((hop, hopIdx) => + hop.matches + .filter((m) => isValidLocation(m.lat, m.lon)) + .map((m, mIdx) => ( + + + {m.name || m.public_key.slice(0, 12)} + + + )) + )} + + {/* Receiver marker */} + {isValidLocation(resolved.receiver.lat, resolved.receiver.lon) && ( + + + {resolved.receiver.name || 'Receiver'} + + + )} + +
+ {someMissingGps && ( +

+ Some nodes in this route have no GPS and are not shown +

+ )} +
+ ); +}