mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-09 06:45:02 +02:00
Path display improvements, focusable maps, contact distance display, click to copy keys
This commit is contained in:
+1
-1
File diff suppressed because one or more lines are too long
-542
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
+542
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -13,8 +13,8 @@
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<script type="module" crossorigin src="/assets/index-DlEnSqQ7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-aLTdJARH.css">
|
||||
<script type="module" crossorigin src="/assets/index-r2fyhyDF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DIRlMkt4.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+65
-6
@@ -21,7 +21,8 @@ import { Toaster, toast } from './components/ui/sonner';
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
import { formatTime } from './utils/messageParser';
|
||||
import { pubkeysMatch, getContactDisplayName } from './utils/pubkey';
|
||||
import { parseHashConversation, updateUrlHash } from './utils/urlHash';
|
||||
import { parseHashConversation, updateUrlHash, getMapFocusHash } from './utils/urlHash';
|
||||
import { isValidLocation, calculateDistance, formatDistance } from './utils/pathUtils';
|
||||
import { loadFavorites, toggleFavorite, isFavorite, type Favorite } from './utils/favorites';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
@@ -121,6 +122,8 @@ export function App() {
|
||||
toast.success('Radio connected', {
|
||||
description: data.serial_port ? `Connected to ${data.serial_port}` : undefined,
|
||||
});
|
||||
// Refresh config after reconnection (may have changed after reboot)
|
||||
api.getRadioConfig().then(setConfig).catch(console.error);
|
||||
} else {
|
||||
toast.error('Radio disconnected', {
|
||||
description: 'Check radio connection and power',
|
||||
@@ -272,6 +275,14 @@ export function App() {
|
||||
if (hashConv.type === 'raw') {
|
||||
return { type: 'raw', id: 'raw', name: 'Raw Packet Feed' };
|
||||
}
|
||||
if (hashConv.type === 'map') {
|
||||
return {
|
||||
type: 'map',
|
||||
id: 'map',
|
||||
name: 'Node Map',
|
||||
mapFocusKey: hashConv.mapFocusKey,
|
||||
};
|
||||
}
|
||||
if (hashConv.type === 'channel') {
|
||||
const channel = channels.find(
|
||||
(c) => c.name === hashConv.name || c.name === `#${hashConv.name}`
|
||||
@@ -588,7 +599,7 @@ export function App() {
|
||||
Node Map
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<MapView contacts={contacts} />
|
||||
<MapView contacts={contacts} focusedKey={activeConversation.mapFocusKey} />
|
||||
</div>
|
||||
</>
|
||||
) : activeConversation.type === 'raw' ? (
|
||||
@@ -612,8 +623,22 @@ export function App() {
|
||||
: ''}
|
||||
{activeConversation.name}
|
||||
</span>
|
||||
<span className="font-normal text-sm text-muted-foreground font-mono truncate">
|
||||
{activeConversation.id}
|
||||
<span
|
||||
className="font-normal text-sm text-muted-foreground font-mono truncate cursor-pointer hover:text-primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(activeConversation.id);
|
||||
toast.success(
|
||||
activeConversation.type === 'channel'
|
||||
? 'Room key copied!'
|
||||
: 'Contact key copied!'
|
||||
);
|
||||
}}
|
||||
title="Click to copy"
|
||||
>
|
||||
{activeConversation.type === 'channel'
|
||||
? activeConversation.id.toLowerCase()
|
||||
: activeConversation.id}
|
||||
</span>
|
||||
{activeConversation.type === 'contact' &&
|
||||
(() => {
|
||||
@@ -621,7 +646,7 @@ export function App() {
|
||||
(c) => c.public_key === activeConversation.id
|
||||
);
|
||||
if (!contact) return null;
|
||||
const parts: string[] = [];
|
||||
const parts: React.ReactNode[] = [];
|
||||
if (contact.last_seen) {
|
||||
parts.push(`Last heard: ${formatTime(contact.last_seen)}`);
|
||||
}
|
||||
@@ -634,9 +659,43 @@ export function App() {
|
||||
`${contact.last_path_len} hop${contact.last_path_len > 1 ? 's' : ''}`
|
||||
);
|
||||
}
|
||||
// Add coordinate link if contact has valid location
|
||||
if (isValidLocation(contact.lat, contact.lon)) {
|
||||
// Calculate distance from us if we have valid location
|
||||
const distFromUs =
|
||||
config && isValidLocation(config.lat, config.lon)
|
||||
? calculateDistance(config.lat, config.lon, contact.lat, contact.lon)
|
||||
: null;
|
||||
parts.push(
|
||||
<span key="coords">
|
||||
<span
|
||||
className="font-mono cursor-pointer hover:text-primary hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const url =
|
||||
window.location.origin +
|
||||
window.location.pathname +
|
||||
getMapFocusHash(contact.public_key);
|
||||
window.open(url, '_blank');
|
||||
}}
|
||||
title="View on map"
|
||||
>
|
||||
{contact.lat!.toFixed(3)}, {contact.lon!.toFixed(3)}
|
||||
</span>
|
||||
{distFromUs !== null && ` (${formatDistance(distFromUs)})`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return parts.length > 0 ? (
|
||||
<span className="font-normal text-sm text-muted-foreground flex-shrink-0">
|
||||
({parts.join(', ')})
|
||||
(
|
||||
{parts.map((part, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && ', '}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
)
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
@@ -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='© <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={{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
sortContactsByDistance,
|
||||
getHopCount,
|
||||
resolvePath,
|
||||
formatDistance,
|
||||
} from '../utils/pathUtils';
|
||||
import type { Contact, RadioConfig } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_CLIENT } from '../types';
|
||||
@@ -473,4 +474,102 @@ describe('resolvePath', () => {
|
||||
// But second hop CAN have distance (from first hop)
|
||||
expect(result.hops[1].distanceFromPrev).not.toBeNull();
|
||||
});
|
||||
|
||||
it('sets hasGaps to false when all hops are unambiguous with locations', () => {
|
||||
const result = resolvePath('1A2B', sender, contacts, config);
|
||||
|
||||
expect(result.hasGaps).toBe(false);
|
||||
});
|
||||
|
||||
it('sets hasGaps to true when path has unknown hops', () => {
|
||||
const result = resolvePath('XX', sender, contacts, config);
|
||||
|
||||
expect(result.hasGaps).toBe(true);
|
||||
});
|
||||
|
||||
it('sets hasGaps to true when path has ambiguous hops', () => {
|
||||
const ambiguousContacts = [
|
||||
createContact({
|
||||
public_key: '1A' + 'A'.repeat(62),
|
||||
name: 'Repeater1A',
|
||||
type: CONTACT_TYPE_REPEATER,
|
||||
lat: 40.75,
|
||||
lon: -74.0,
|
||||
}),
|
||||
createContact({
|
||||
public_key: '1A' + 'B'.repeat(62),
|
||||
name: 'Repeater1B',
|
||||
type: CONTACT_TYPE_REPEATER,
|
||||
lat: 40.76,
|
||||
lon: -73.99,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = resolvePath('1A', sender, ambiguousContacts, config);
|
||||
|
||||
expect(result.hasGaps).toBe(true);
|
||||
});
|
||||
|
||||
it('sets hasGaps to true when sender has no location', () => {
|
||||
const senderNoLocation = {
|
||||
name: 'SenderNoLoc',
|
||||
publicKeyOrPrefix: '5E' + 'E'.repeat(62),
|
||||
lat: null,
|
||||
lon: null,
|
||||
};
|
||||
|
||||
const result = resolvePath('1A', senderNoLocation, contacts, config);
|
||||
|
||||
expect(result.hasGaps).toBe(true);
|
||||
});
|
||||
|
||||
it('sets hasGaps to true when receiver has no valid location', () => {
|
||||
const configNoLocation = createConfig({
|
||||
public_key: 'FF' + 'F'.repeat(62),
|
||||
name: 'MyRadio',
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
});
|
||||
|
||||
const result = resolvePath('1A', sender, contacts, configNoLocation);
|
||||
|
||||
expect(result.hasGaps).toBe(true);
|
||||
});
|
||||
|
||||
it('includes receiver public key when config has one', () => {
|
||||
const result = resolvePath('1A', sender, contacts, config);
|
||||
|
||||
expect(result.receiver.publicKey).toBe(config.public_key);
|
||||
});
|
||||
|
||||
it('sets receiver public key to null when config has no public key', () => {
|
||||
const configNoKey = createConfig({
|
||||
public_key: undefined as unknown as string,
|
||||
name: 'NoKeyRadio',
|
||||
});
|
||||
|
||||
const result = resolvePath('1A', sender, contacts, configNoKey);
|
||||
|
||||
expect(result.receiver.publicKey).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDistance', () => {
|
||||
it('formats distances under 1km in meters', () => {
|
||||
expect(formatDistance(0.5)).toBe('500m');
|
||||
expect(formatDistance(0.123)).toBe('123m');
|
||||
expect(formatDistance(0.9999)).toBe('1000m');
|
||||
});
|
||||
|
||||
it('formats distances at or above 1km with one decimal', () => {
|
||||
expect(formatDistance(1)).toBe('1.0km');
|
||||
expect(formatDistance(1.5)).toBe('1.5km');
|
||||
expect(formatDistance(12.34)).toBe('12.3km');
|
||||
expect(formatDistance(100)).toBe('100.0km');
|
||||
});
|
||||
|
||||
it('rounds meters to nearest integer', () => {
|
||||
expect(formatDistance(0.4567)).toBe('457m');
|
||||
expect(formatDistance(0.001)).toBe('1m');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { parseHashConversation, getConversationHash } from '../utils/urlHash';
|
||||
import { parseHashConversation, getConversationHash, getMapFocusHash } from '../utils/urlHash';
|
||||
import type { Conversation } from '../types';
|
||||
|
||||
describe('parseHashConversation', () => {
|
||||
@@ -36,6 +36,38 @@ describe('parseHashConversation', () => {
|
||||
expect(result).toEqual({ type: 'raw', name: 'raw' });
|
||||
});
|
||||
|
||||
it('parses #map as map type', () => {
|
||||
window.location.hash = '#map';
|
||||
|
||||
const result = parseHashConversation();
|
||||
|
||||
expect(result).toEqual({ type: 'map', name: 'map' });
|
||||
});
|
||||
|
||||
it('parses #map/focus/PUBKEY with focus key', () => {
|
||||
window.location.hash = '#map/focus/ABCD1234';
|
||||
|
||||
const result = parseHashConversation();
|
||||
|
||||
expect(result).toEqual({ type: 'map', name: 'map', mapFocusKey: 'ABCD1234' });
|
||||
});
|
||||
|
||||
it('parses #map/focus/ with empty focus as plain map', () => {
|
||||
window.location.hash = '#map/focus/';
|
||||
|
||||
const result = parseHashConversation();
|
||||
|
||||
expect(result).toEqual({ type: 'map', name: 'map' });
|
||||
});
|
||||
|
||||
it('decodes URL-encoded map focus key', () => {
|
||||
window.location.hash = '#map/focus/AB%20CD';
|
||||
|
||||
const result = parseHashConversation();
|
||||
|
||||
expect(result).toEqual({ type: 'map', name: 'map', mapFocusKey: 'AB CD' });
|
||||
});
|
||||
|
||||
it('parses channel hash', () => {
|
||||
window.location.hash = '#channel/Public';
|
||||
|
||||
@@ -108,6 +140,14 @@ describe('getConversationHash', () => {
|
||||
expect(result).toBe('#raw');
|
||||
});
|
||||
|
||||
it('returns #map for map conversation', () => {
|
||||
const conv: Conversation = { type: 'map', id: 'map', name: 'Node Map' };
|
||||
|
||||
const result = getConversationHash(conv);
|
||||
|
||||
expect(result).toBe('#map');
|
||||
});
|
||||
|
||||
it('generates channel hash', () => {
|
||||
const conv: Conversation = { type: 'channel', id: 'key123', name: 'Public' };
|
||||
|
||||
@@ -189,4 +229,28 @@ describe('parseHashConversation and getConversationHash roundtrip', () => {
|
||||
|
||||
expect(parsed).toEqual({ type: 'raw', name: 'raw' });
|
||||
});
|
||||
|
||||
it('map roundtrip preserves type', () => {
|
||||
const conv: Conversation = { type: 'map', id: 'map', name: 'Node Map' };
|
||||
|
||||
const hash = getConversationHash(conv);
|
||||
window.location.hash = hash;
|
||||
const parsed = parseHashConversation();
|
||||
|
||||
expect(parsed).toEqual({ type: 'map', name: 'map' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMapFocusHash', () => {
|
||||
it('generates hash with focus key', () => {
|
||||
const result = getMapFocusHash('ABCD1234');
|
||||
|
||||
expect(result).toBe('#map/focus/ABCD1234');
|
||||
});
|
||||
|
||||
it('encodes special characters in key', () => {
|
||||
const result = getMapFocusHash('AB CD/12');
|
||||
|
||||
expect(result).toBe('#map/focus/AB%20CD%2F12');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,6 +98,8 @@ export interface Conversation {
|
||||
/** PublicKey for contacts, ChannelKey for channels, 'raw'/'map' for special views */
|
||||
id: string;
|
||||
name: string;
|
||||
/** For map view: public key prefix to focus on */
|
||||
mapFocusKey?: string;
|
||||
}
|
||||
|
||||
export interface RawPacket {
|
||||
|
||||
@@ -10,8 +10,16 @@ export interface PathHop {
|
||||
export interface ResolvedPath {
|
||||
sender: { name: string; prefix: string; lat: number | null; lon: number | null };
|
||||
hops: PathHop[];
|
||||
receiver: { name: string; prefix: string; lat: number | null; lon: number | null };
|
||||
receiver: {
|
||||
name: string;
|
||||
prefix: string;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
publicKey: string | null;
|
||||
};
|
||||
totalDistances: number[] | null; // Single-element array with sum of unambiguous distances
|
||||
/** True if path has any gaps (unknown, ambiguous, or missing location hops) */
|
||||
hasGaps: boolean;
|
||||
}
|
||||
|
||||
export interface SenderInfo {
|
||||
@@ -101,6 +109,16 @@ export function isValidLocation(lat: number | null, lon: number | null): boolean
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format distance in human-readable form (m or km)
|
||||
*/
|
||||
export function formatDistance(km: number): string {
|
||||
if (km < 1) {
|
||||
return `${Math.round(km * 1000)}m`;
|
||||
}
|
||||
return `${km.toFixed(1)}km`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort contacts by distance from a reference point
|
||||
* Contacts without location are placed at the end
|
||||
@@ -164,6 +182,7 @@ export function resolvePath(
|
||||
prefix: receiverPrefix,
|
||||
lat: config?.lat ?? null,
|
||||
lon: config?.lon ?? null,
|
||||
publicKey: config?.public_key ?? null,
|
||||
};
|
||||
|
||||
// Build hops
|
||||
@@ -229,11 +248,20 @@ export function resolvePath(
|
||||
// Calculate total distances (can be multiple if ambiguous)
|
||||
const totalDistances = calculateTotalDistances(resolvedSender, hops, resolvedReceiver);
|
||||
|
||||
// Determine if path has any gaps (unknown, ambiguous, or missing location)
|
||||
const hasGaps =
|
||||
!isValidLocation(resolvedSender.lat, resolvedSender.lon) ||
|
||||
!isValidLocation(resolvedReceiver.lat, resolvedReceiver.lon) ||
|
||||
hops.some(
|
||||
(hop) => hop.matches.length !== 1 || !isValidLocation(hop.matches[0].lat, hop.matches[0].lon)
|
||||
);
|
||||
|
||||
return {
|
||||
sender: resolvedSender,
|
||||
hops,
|
||||
receiver: resolvedReceiver,
|
||||
totalDistances,
|
||||
hasGaps,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ import type { Conversation } from '../types';
|
||||
export interface ParsedHashConversation {
|
||||
type: 'channel' | 'contact' | 'raw' | 'map';
|
||||
name: string;
|
||||
/** For map view: public key prefix to focus on */
|
||||
mapFocusKey?: string;
|
||||
}
|
||||
|
||||
// Parse URL hash to get conversation (e.g., #channel/Public or #contact/JohnDoe or #raw)
|
||||
// Parse URL hash to get conversation (e.g., #channel/Public or #contact/JohnDoe or #raw or #map/focus/ABCD1234)
|
||||
export function parseHashConversation(): ParsedHashConversation | null {
|
||||
const hash = window.location.hash.slice(1); // Remove leading #
|
||||
if (!hash) return null;
|
||||
@@ -18,6 +20,15 @@ export function parseHashConversation(): ParsedHashConversation | null {
|
||||
return { type: 'map', name: 'map' };
|
||||
}
|
||||
|
||||
// Check for map with focus: #map/focus/{pubkey_prefix}
|
||||
if (hash.startsWith('map/focus/')) {
|
||||
const focusKey = hash.slice('map/focus/'.length);
|
||||
if (focusKey) {
|
||||
return { type: 'map', name: 'map', mapFocusKey: decodeURIComponent(focusKey) };
|
||||
}
|
||||
return { type: 'map', name: 'map' };
|
||||
}
|
||||
|
||||
const slashIndex = hash.indexOf('/');
|
||||
if (slashIndex === -1) return null;
|
||||
|
||||
@@ -30,6 +41,14 @@ export function parseHashConversation(): ParsedHashConversation | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a URL hash for focusing on a contact in the map view
|
||||
* @param publicKeyPrefix - The public key or prefix to focus on
|
||||
*/
|
||||
export function getMapFocusHash(publicKeyPrefix: string): string {
|
||||
return `#map/focus/${encodeURIComponent(publicKeyPrefix)}`;
|
||||
}
|
||||
|
||||
// Generate URL hash from conversation
|
||||
export function getConversationHash(conv: Conversation | null): string {
|
||||
if (!conv) return '';
|
||||
|
||||
Reference in New Issue
Block a user