diff --git a/src/App.tsx b/src/App.tsx
index 28fcb79..a39ee0b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -256,6 +256,23 @@ function AppInner() {
if (id === null) dropSelectionParam("observer");
}, [dropSelectionParam]);
+ // Jump from a node's detail panel to its location on the Map tab, framed close on that node.
+ // Bypasses handleTabChange (like handleViewObserverStats) so selectedNodeId survives the switch on
+ // mobile too — the point of this action is to keep the same node's panel open, now over the map.
+ const handleViewOnMap = useCallback(
+ (lat: number, lng: number) => {
+ setSearchParams((prev) => {
+ const next = new URLSearchParams(prev);
+ next.set("tab", "Map");
+ next.set("lat", lat.toFixed(5));
+ next.set("lng", lng.toFixed(5));
+ next.set("zoom", "14");
+ return next;
+ });
+ },
+ [setSearchParams],
+ );
+
// Jump from an observer's detail panel to its telemetry on the Stats tab (Stats → Observer, preselected).
const handleViewObserverStats = useCallback(
(id: string) => {
@@ -340,6 +357,7 @@ function AppInner() {
}}
onViewNode={setSelectedNodeId}
onAnalyzePacket={setOverlayPacketHash}
+ onViewOnMap={handleViewOnMap}
/>
)}
{overlayNodeId && (
@@ -351,6 +369,15 @@ function AppInner() {
setSelectedObserverId(observerId);
}}
onViewNode={setOverlayNodeId}
+ onViewOnMap={(lat, lng) => {
+ // handleViewOnMap bypasses handleTabChange (see its own comment), so the overlay
+ // won't be cleared as a side effect here the way onViewObserver's handleTabChange
+ // call clears it above — close it and promote the node into the shared selection
+ // explicitly, so NodeDetailPanel (not this overlay) is what reopens on the Map tab.
+ handleViewOnMap(lat, lng);
+ setSelectedNodeId(overlayNodeId);
+ setOverlayNodeId(null);
+ }}
/>
)}
{overlayPacketHash && (
diff --git a/src/features/nodes/NodeDetailOverlay.tsx b/src/features/nodes/NodeDetailOverlay.tsx
index 514c949..ef448de 100644
--- a/src/features/nodes/NodeDetailOverlay.tsx
+++ b/src/features/nodes/NodeDetailOverlay.tsx
@@ -4,11 +4,12 @@ import { ModalOverlay } from "../../components/ModalOverlay";
// Node detail shown as a modal over the packet analyzer: the panel sits where the packet drawer is
// (right side) and the rest dims, so a user can peek at a path hop's node and close back to the packet.
-export function NodeDetailOverlay({ nodeId, onClose, onViewObserver, onViewNode }: {
+export function NodeDetailOverlay({ nodeId, onClose, onViewObserver, onViewNode, onViewOnMap }: {
nodeId: string;
onClose: () => void;
onViewObserver: (observerId: string) => void;
onViewNode?: (nodeId: string) => void;
+ onViewOnMap?: (lat: number, lng: number) => void;
}) {
useEffect(() => {
function onKey(e: KeyboardEvent) {
@@ -20,7 +21,7 @@ export function NodeDetailOverlay({ nodeId, onClose, onViewObserver, onViewNode
return (
-
+
);
}
diff --git a/src/features/nodes/NodeDetailPanel.tsx b/src/features/nodes/NodeDetailPanel.tsx
index 2073738..72245c3 100644
--- a/src/features/nodes/NodeDetailPanel.tsx
+++ b/src/features/nodes/NodeDetailPanel.tsx
@@ -73,9 +73,10 @@ interface NodeDetailPanelProps {
onViewObserver: (observerId: string) => void;
onViewNode?: (nodeId: string) => void;
onAnalyzePacket?: (hash: string) => void;
+ onViewOnMap?: (lat: number, lng: number) => void;
}
-export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, onAnalyzePacket }: NodeDetailPanelProps) {
+export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, onAnalyzePacket, onViewOnMap }: NodeDetailPanelProps) {
const { data: node, isLoading } = useQuery({
queryKey: ["node", nodeId],
queryFn: () => getNode(nodeId),
@@ -145,6 +146,19 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
{node.lng != null && }
{node.locationSource && }
+ {hasLocation && onViewOnMap && (
+
+ )}
)}