Path display improvements, focusable maps, contact distance display, click to copy keys

This commit is contained in:
Jack Kingsman
2026-01-18 16:08:39 -08:00
parent 05a830d63f
commit cc1a2c57c2
14 changed files with 971 additions and 573 deletions
+48 -6
View File
@@ -1,13 +1,16 @@
import { useEffect, useState, useMemo } from 'react';
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { MapContainer, TileLayer, CircleMarker, Popup, useMap } from 'react-leaflet';
import type { LatLngBoundsExpression } from 'leaflet';
import type { LatLngBoundsExpression, CircleMarker as LeafletCircleMarker } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import type { Contact } from '../types';
import { formatTime } from '../utils/messageParser';
import { CONTACT_TYPE_REPEATER } from '../types';
import { pubkeysMatch } from '../utils/pubkey';
interface MapViewProps {
contacts: Contact[];
/** Public key (or prefix) of contact to focus on and open popup */
focusedKey?: string | null;
}
// Calculate marker color based on how recently the contact was heard
@@ -24,11 +27,24 @@ function getMarkerColor(lastSeen: number): string {
}
// Component to handle map bounds fitting
function MapBoundsHandler({ contacts }: { contacts: Contact[] }) {
function MapBoundsHandler({
contacts,
focusedContact,
}: {
contacts: Contact[];
focusedContact: Contact | null;
}) {
const map = useMap();
const [hasInitialized, setHasInitialized] = useState(false);
useEffect(() => {
// If we have a focused contact, center on it immediately (even if already initialized)
if (focusedContact && focusedContact.lat != null && focusedContact.lon != null) {
map.setView([focusedContact.lat, focusedContact.lon], 12);
setHasInitialized(true);
return;
}
if (hasInitialized) return;
const fitToContacts = () => {
@@ -72,12 +88,12 @@ function MapBoundsHandler({ contacts }: { contacts: Contact[] }) {
// No geolocation support - fit to contacts
fitToContacts();
}
}, [map, contacts, hasInitialized]);
}, [map, contacts, hasInitialized, focusedContact]);
return null;
}
export function MapView({ contacts }: MapViewProps) {
export function MapView({ contacts, focusedKey }: MapViewProps) {
// Filter to contacts with GPS coordinates, heard within the last 7 days
const mappableContacts = useMemo(() => {
const sevenDaysAgo = Date.now() / 1000 - 7 * 24 * 60 * 60;
@@ -86,6 +102,31 @@ export function MapView({ contacts }: MapViewProps) {
);
}, [contacts]);
// Find the focused contact by key prefix
const focusedContact = useMemo(() => {
if (!focusedKey) return null;
return mappableContacts.find((c) => pubkeysMatch(c.public_key, focusedKey)) || null;
}, [focusedKey, mappableContacts]);
// Track marker refs to open popup programmatically
const markerRefs = useRef<Record<string, LeafletCircleMarker | null>>({});
// Store ref for a marker
const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => {
markerRefs.current[key] = ref;
}, []);
// Open popup for focused contact after map is ready
useEffect(() => {
if (focusedContact && markerRefs.current[focusedContact.public_key]) {
// Small delay to ensure map has finished rendering
const timer = setTimeout(() => {
markerRefs.current[focusedContact.public_key]?.openPopup();
}, 100);
return () => clearTimeout(timer);
}
}, [focusedContact]);
return (
<div className="flex flex-col h-full">
{/* Info bar */}
@@ -122,7 +163,7 @@ export function MapView({ contacts }: MapViewProps) {
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapBoundsHandler contacts={mappableContacts} />
<MapBoundsHandler contacts={mappableContacts} focusedContact={focusedContact} />
{mappableContacts.map((contact) => {
const isRepeater = contact.type === CONTACT_TYPE_REPEATER;
@@ -132,6 +173,7 @@ export function MapView({ contacts }: MapViewProps) {
return (
<CircleMarker
key={contact.public_key}
ref={(ref) => setMarkerRef(contact.public_key, ref)}
center={[contact.lat!, contact.lon!]}
radius={isRepeater ? 10 : 7}
pathOptions={{
+97 -12
View File
@@ -12,10 +12,12 @@ import {
resolvePath,
calculateDistance,
isValidLocation,
formatDistance,
type SenderInfo,
type ResolvedPath,
type PathHop,
} from '../utils/pathUtils';
import { getMapFocusHash } from '../utils/urlHash';
interface PathModalProps {
open: boolean;
@@ -42,7 +44,7 @@ export function PathModal({ open, onClose, path, senderInfo, contacts, config }:
</DialogHeader>
<div className="flex-1 overflow-y-auto py-2">
<PathVisualization resolved={resolved} />
<PathVisualization resolved={resolved} senderInfo={senderInfo} />
</div>
<DialogFooter>
@@ -55,9 +57,10 @@ export function PathModal({ open, onClose, path, senderInfo, contacts, config }:
interface PathVisualizationProps {
resolved: ResolvedPath;
senderInfo: SenderInfo;
}
function PathVisualization({ resolved }: PathVisualizationProps) {
function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
// Track previous location for each hop to calculate distances
// Returns null if previous hop was ambiguous or has invalid location
const getPrevLocation = (hopIndex: number): { lat: number | null; lon: number | null } | null => {
@@ -93,6 +96,9 @@ function PathVisualization({ resolved }: PathVisualizationProps) {
prefix={resolved.sender.prefix}
distance={null}
isFirst
lat={resolved.sender.lat}
lon={resolved.sender.lon}
publicKey={senderInfo.publicKeyOrPrefix}
/>
{/* Hops */}
@@ -112,6 +118,9 @@ function PathVisualization({ resolved }: PathVisualizationProps) {
prefix={resolved.receiver.prefix}
distance={calculateReceiverDistance(resolved)}
isLast
lat={resolved.receiver.lat}
lon={resolved.receiver.lon}
publicKey={resolved.receiver.publicKey ?? undefined}
/>
{/* Total distance */}
@@ -120,9 +129,36 @@ function PathVisualization({ resolved }: PathVisualizationProps) {
<span className="text-sm text-muted-foreground">
Presumed unambiguous distance covered:{' '}
</span>
<span className="text-sm font-medium">{formatDistance(resolved.totalDistances[0])}</span>
<span className="text-sm font-medium">
{resolved.hasGaps ? '>' : ''}
{formatDistance(resolved.totalDistances[0])}
</span>
</div>
)}
{/* Straight-line distance (when both sender and receiver have coordinates) */}
{isValidLocation(resolved.sender.lat, resolved.sender.lon) &&
isValidLocation(resolved.receiver.lat, resolved.receiver.lon) && (
<div
className={
resolved.totalDistances && resolved.totalDistances.length > 0
? 'pt-1'
: 'pt-3 mt-3 border-t border-border'
}
>
<span className="text-sm text-muted-foreground">Straight-line distance: </span>
<span className="text-sm font-medium">
{formatDistance(
calculateDistance(
resolved.sender.lat,
resolved.sender.lon,
resolved.receiver.lat,
resolved.receiver.lon
)!
)}
</span>
</div>
)}
</div>
);
}
@@ -134,9 +170,26 @@ interface PathNodeProps {
distance: number | null;
isFirst?: boolean;
isLast?: boolean;
/** Optional coordinates for map link */
lat?: number | null;
lon?: number | null;
/** Public key for map focus link (required if lat/lon provided) */
publicKey?: string;
}
function PathNode({ label, name, prefix, distance, isFirst, isLast }: PathNodeProps) {
function PathNode({
label,
name,
prefix,
distance,
isFirst,
isLast,
lat,
lon,
publicKey,
}: PathNodeProps) {
const hasLocation = isValidLocation(lat ?? null, lon ?? null) && publicKey;
return (
<div className="flex gap-3">
{/* Vertical line and dot column */}
@@ -151,10 +204,11 @@ function PathNode({ label, name, prefix, distance, isFirst, isLast }: PathNodePr
<div className="text-xs text-muted-foreground font-medium">{label}</div>
<div className="font-medium truncate">
{name} <span className="text-muted-foreground font-mono text-sm">({prefix})</span>
{distance !== null && (
<span className="text-xs text-muted-foreground ml-1">- {formatDistance(distance)}</span>
)}
{hasLocation && <CoordinateLink lat={lat!} lon={lon!} publicKey={publicKey!} />}
</div>
{distance !== null && (
<div className="text-xs text-muted-foreground">{formatDistance(distance)}</div>
)}
</div>
</div>
);
@@ -210,6 +264,7 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
<div>
{hop.matches.map((contact) => {
const dist = getDistanceForContact(contact);
const hasLocation = isValidLocation(contact.lat, contact.lon);
return (
<div key={contact.public_key} className="font-medium truncate">
{contact.name || contact.public_key.slice(0, 12)}{' '}
@@ -221,6 +276,13 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
- {formatDistance(dist)}
</span>
)}
{hasLocation && (
<CoordinateLink
lat={contact.lat!}
lon={contact.lon!}
publicKey={contact.public_key}
/>
)}
</div>
);
})}
@@ -234,6 +296,13 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
- {formatDistance(hop.distanceFromPrev)}
</span>
)}
{isValidLocation(hop.matches[0].lat, hop.matches[0].lon) && (
<CoordinateLink
lat={hop.matches[0].lat!}
lon={hop.matches[0].lon!}
publicKey={hop.matches[0].public_key}
/>
)}
</div>
)}
</div>
@@ -241,11 +310,27 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
);
}
function formatDistance(km: number): string {
if (km < 1) {
return `${Math.round(km * 1000)}m`;
}
return `${km.toFixed(1)}km`;
/**
* Render clickable coordinates that open the map focused on the contact
*/
function CoordinateLink({ lat, lon, publicKey }: { lat: number; lon: number; publicKey: string }) {
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// Open map in new tab with focus on this contact
const url = window.location.origin + window.location.pathname + getMapFocusHash(publicKey);
window.open(url, '_blank');
};
return (
<span
className="text-xs text-muted-foreground/70 font-mono cursor-pointer hover:text-primary hover:underline ml-1"
onClick={handleClick}
title="View on map"
>
({lat.toFixed(4)}, {lon.toFixed(4)})
</span>
);
}
function calculateReceiverDistance(resolved: ResolvedPath): number | null {