Add hop display

This commit is contained in:
Jack Kingsman
2026-01-18 15:44:36 -08:00
parent 13220c4a8f
commit 05a830d63f
13 changed files with 1732 additions and 553 deletions
+1
View File
@@ -705,6 +705,7 @@ export function App() {
}
onLoadOlder={fetchOlderMessages}
radioName={config?.name}
config={config}
/>
<MessageInput
ref={messageInputRef}
+91 -4
View File
@@ -1,9 +1,11 @@
import { useEffect, useLayoutEffect, useRef, useCallback, useState, type ReactNode } from 'react';
import type { Contact, Message } from '../types';
import type { Contact, Message, RadioConfig } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import { formatTime, parseSenderFromText } from '../utils/messageParser';
import { pubkeysMatch } from '../utils/pubkey';
import { getHopCount, type SenderInfo } from '../utils/pathUtils';
import { ContactAvatar } from './ContactAvatar';
import { PathModal } from './PathModal';
import { cn } from '@/lib/utils';
interface MessageListProps {
@@ -15,6 +17,7 @@ interface MessageListProps {
onSenderClick?: (sender: string) => void;
onLoadOlder?: () => void;
radioName?: string;
config?: RadioConfig | null;
}
// Helper to render text with highlighted @[Name] mentions
@@ -68,11 +71,16 @@ export function MessageList({
onSenderClick,
onLoadOlder,
radioName,
config,
}: MessageListProps) {
const listRef = useRef<HTMLDivElement>(null);
const prevMessagesLengthRef = useRef<number>(0);
const isInitialLoadRef = useRef<boolean>(true);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [selectedPath, setSelectedPath] = useState<{
path: string;
senderInfo: SenderInfo;
} | null>(null);
// Capture scroll state in the scroll handler BEFORE any state updates
const scrollStateRef = useRef({
@@ -170,6 +178,41 @@ export function MessageList({
return contacts.find((c) => c.name === name) || null;
};
// Build sender info for path modal
const getSenderInfo = (
msg: Message,
contact: Contact | null,
parsedSender: string | null
): SenderInfo => {
if (msg.type === 'PRIV' && contact) {
return {
name: contact.name || contact.public_key.slice(0, 12),
publicKeyOrPrefix: contact.public_key,
lat: contact.lat,
lon: contact.lon,
};
}
// For channel messages, try to find contact by parsed sender name
if (parsedSender) {
const senderContact = getContactByName(parsedSender);
if (senderContact) {
return {
name: parsedSender,
publicKeyOrPrefix: senderContact.public_key,
lat: senderContact.lat,
lon: senderContact.lon,
};
}
}
// Fallback: unknown sender
return {
name: parsedSender || 'Unknown',
publicKeyOrPrefix: msg.conversation_key || '',
lat: null,
lon: null,
};
};
if (loading) {
return (
<div className="flex-1 overflow-y-auto p-5 text-center text-muted-foreground">
@@ -293,6 +336,21 @@ export function MessageList({
<span className="font-normal text-muted-foreground/70 ml-2 text-[11px]">
{formatTime(msg.sender_timestamp || msg.received_at)}
</span>
{!msg.outgoing && msg.path && (
<span
className="font-normal text-muted-foreground/70 ml-1 text-[11px] cursor-pointer hover:text-primary hover:underline"
onClick={(e) => {
e.stopPropagation();
setSelectedPath({
path: msg.path!,
senderInfo: getSenderInfo(msg, contact, sender),
});
}}
title="View message path"
>
({getHopCount(msg.path)} hop{getHopCount(msg.path) !== 1 ? 's' : ''})
</span>
)}
</div>
)}
<div className="break-words whitespace-pre-wrap">
@@ -303,9 +361,26 @@ export function MessageList({
</span>
))}
{!showAvatar && (
<span className="text-[10px] text-muted-foreground/50 ml-2">
{formatTime(msg.sender_timestamp || msg.received_at)}
</span>
<>
<span className="text-[10px] text-muted-foreground/50 ml-2">
{formatTime(msg.sender_timestamp || msg.received_at)}
</span>
{!msg.outgoing && msg.path && (
<span
className="text-[10px] text-muted-foreground/50 ml-1 cursor-pointer hover:text-primary hover:underline"
onClick={(e) => {
e.stopPropagation();
setSelectedPath({
path: msg.path!,
senderInfo: getSenderInfo(msg, contact, sender),
});
}}
title="View message path"
>
({getHopCount(msg.path)} hop{getHopCount(msg.path) !== 1 ? 's' : ''})
</span>
)}
</>
)}
{msg.outgoing && (msg.acked > 0 ? `${msg.acked > 1 ? msg.acked : ''}` : ' ?')}
</div>
@@ -338,6 +413,18 @@ export function MessageList({
</svg>
</button>
)}
{/* Path modal */}
{selectedPath && (
<PathModal
open={true}
onClose={() => setSelectedPath(null)}
path={selectedPath.path}
senderInfo={selectedPath.senderInfo}
contacts={contacts}
config={config ?? null}
/>
)}
</div>
);
}
+284
View File
@@ -0,0 +1,284 @@
import type { Contact, RadioConfig } from '../types';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from './ui/dialog';
import { Button } from './ui/button';
import {
resolvePath,
calculateDistance,
isValidLocation,
type SenderInfo,
type ResolvedPath,
type PathHop,
} from '../utils/pathUtils';
interface PathModalProps {
open: boolean;
onClose: () => void;
path: string;
senderInfo: SenderInfo;
contacts: Contact[];
config: RadioConfig | null;
}
export function PathModal({ open, onClose, path, senderInfo, contacts, config }: PathModalProps) {
const resolved = resolvePath(path, senderInfo, contacts, config);
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Message Path</DialogTitle>
<DialogDescription>
This shows <em>one route</em> that this message traveled through the mesh network. Flood
messages may arrive via multiple paths, and routers may be incorrectly identified due to
prefix collisions between heard and non-heard router advertisements.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-2">
<PathVisualization resolved={resolved} />
</div>
<DialogFooter>
<Button onClick={onClose}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
interface PathVisualizationProps {
resolved: ResolvedPath;
}
function PathVisualization({ resolved }: 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 => {
if (hopIndex === 0) {
// Check if sender has valid location
if (!isValidLocation(resolved.sender.lat, resolved.sender.lon)) {
return null;
}
return { lat: resolved.sender.lat, lon: resolved.sender.lon };
}
const prevHop = resolved.hops[hopIndex - 1];
// If previous hop was ambiguous, we can't show meaningful distances
if (prevHop.matches.length > 1) {
return null;
}
// If previous hop was unknown, we also can't calculate
if (prevHop.matches.length === 0) {
return null;
}
// Check if previous hop has valid location
if (isValidLocation(prevHop.matches[0].lat, prevHop.matches[0].lon)) {
return { lat: prevHop.matches[0].lat, lon: prevHop.matches[0].lon };
}
return null;
};
return (
<div className="space-y-0">
{/* Sender */}
<PathNode
label="Sender"
name={resolved.sender.name}
prefix={resolved.sender.prefix}
distance={null}
isFirst
/>
{/* Hops */}
{resolved.hops.map((hop, index) => (
<HopNode
key={index}
hop={hop}
hopNumber={index + 1}
prevLocation={getPrevLocation(index)}
/>
))}
{/* Receiver */}
<PathNode
label="Receiver (me)"
name={resolved.receiver.name}
prefix={resolved.receiver.prefix}
distance={calculateReceiverDistance(resolved)}
isLast
/>
{/* Total distance */}
{resolved.totalDistances && resolved.totalDistances.length > 0 && (
<div className="pt-3 mt-3 border-t border-border">
<span className="text-sm text-muted-foreground">
Presumed unambiguous distance covered:{' '}
</span>
<span className="text-sm font-medium">{formatDistance(resolved.totalDistances[0])}</span>
</div>
)}
</div>
);
}
interface PathNodeProps {
label: string;
name: string;
prefix: string;
distance: number | null;
isFirst?: boolean;
isLast?: boolean;
}
function PathNode({ label, name, prefix, distance, isFirst, isLast }: PathNodeProps) {
return (
<div className="flex gap-3">
{/* Vertical line and dot column */}
<div className="flex flex-col items-center w-4 flex-shrink-0">
{!isFirst && <div className="w-0.5 h-3 bg-border" />}
<div className="w-3 h-3 rounded-full bg-primary flex-shrink-0" />
{!isLast && <div className="w-0.5 flex-1 bg-border" />}
</div>
{/* Content */}
<div className="pb-3 flex-1 min-w-0">
<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>
</div>
{distance !== null && (
<div className="text-xs text-muted-foreground">{formatDistance(distance)}</div>
)}
</div>
</div>
);
}
interface HopNodeProps {
hop: PathHop;
hopNumber: number;
prevLocation: { lat: number | null; lon: number | null } | null;
}
function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
const isAmbiguous = hop.matches.length > 1;
const isUnknown = hop.matches.length === 0;
// Calculate distance from previous location for a contact
// Returns null if prev location unknown/ambiguous or contact has no valid location
const getDistanceForContact = (contact: {
lat: number | null;
lon: number | null;
}): number | null => {
if (!prevLocation || prevLocation.lat === null || prevLocation.lon === null) {
return null;
}
// Check if contact has valid location
if (!isValidLocation(contact.lat, contact.lon)) {
return null;
}
return calculateDistance(prevLocation.lat, prevLocation.lon, contact.lat, contact.lon);
};
return (
<div className="flex gap-3">
{/* Vertical line and dot column */}
<div className="flex flex-col items-center w-4 flex-shrink-0">
<div className="w-0.5 h-3 bg-border" />
<div className="w-3 h-3 rounded-full bg-muted-foreground flex-shrink-0" />
<div className="w-0.5 flex-1 bg-border" />
</div>
{/* Content */}
<div className="pb-3 flex-1 min-w-0">
<div className="text-xs text-muted-foreground font-medium">
Hop {hopNumber}
{isAmbiguous && <span className="text-yellow-500 ml-1">(ambiguous)</span>}
</div>
{isUnknown ? (
<div className="font-medium text-muted-foreground/70">
&lt;UNKNOWN <span className="font-mono text-sm">{hop.prefix}</span>&gt;
</div>
) : isAmbiguous ? (
<div>
{hop.matches.map((contact) => {
const dist = getDistanceForContact(contact);
return (
<div key={contact.public_key} className="font-medium truncate">
{contact.name || contact.public_key.slice(0, 12)}{' '}
<span className="text-muted-foreground font-mono text-sm">
({contact.public_key.slice(0, 2).toUpperCase()})
</span>
{dist !== null && (
<span className="text-xs text-muted-foreground ml-1">
- {formatDistance(dist)}
</span>
)}
</div>
);
})}
</div>
) : (
<div className="font-medium truncate">
{hop.matches[0].name || hop.matches[0].public_key.slice(0, 12)}{' '}
<span className="text-muted-foreground font-mono text-sm">({hop.prefix})</span>
{hop.distanceFromPrev !== null && (
<span className="text-xs text-muted-foreground ml-1">
- {formatDistance(hop.distanceFromPrev)}
</span>
)}
</div>
)}
</div>
</div>
);
}
function formatDistance(km: number): string {
if (km < 1) {
return `${Math.round(km * 1000)}m`;
}
return `${km.toFixed(1)}km`;
}
function calculateReceiverDistance(resolved: ResolvedPath): number | null {
// Get last hop's location (if any)
let prevLat: number | null = null;
let prevLon: number | null = null;
if (resolved.hops.length > 0) {
const lastHop = resolved.hops[resolved.hops.length - 1];
// Only use last hop if it's unambiguous and has valid location
if (
lastHop.matches.length === 1 &&
isValidLocation(lastHop.matches[0].lat, lastHop.matches[0].lon)
) {
prevLat = lastHop.matches[0].lat;
prevLon = lastHop.matches[0].lon;
}
} else {
// No hops, calculate from sender to receiver (if sender has valid location)
if (isValidLocation(resolved.sender.lat, resolved.sender.lon)) {
prevLat = resolved.sender.lat;
prevLon = resolved.sender.lon;
}
}
if (prevLat === null || prevLon === null) {
return null;
}
// Check receiver has valid location
if (!isValidLocation(resolved.receiver.lat, resolved.receiver.lon)) {
return null;
}
return calculateDistance(prevLat, prevLon, resolved.receiver.lat, resolved.receiver.lon);
}
+476
View File
@@ -0,0 +1,476 @@
import { describe, it, expect } from 'vitest';
import {
parsePathHops,
findContactsByPrefix,
calculateDistance,
sortContactsByDistance,
getHopCount,
resolvePath,
} from '../utils/pathUtils';
import type { Contact, RadioConfig } from '../types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_CLIENT } from '../types';
// Helper to create mock contacts
function createContact(overrides: Partial<Contact> = {}): Contact {
return {
public_key: 'AAAAAAAAAAAABBBBBBBBBBBBCCCCCCCCCCCCDDDDDDDDDDDDEEEEEEEEEEEE',
name: 'Test Contact',
type: CONTACT_TYPE_REPEATER,
flags: 0,
last_path: null,
last_path_len: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
...overrides,
};
}
// Helper to create mock config
function createConfig(overrides: Partial<RadioConfig> = {}): RadioConfig {
return {
public_key: 'FFFFFFFFFFFFEEEEEEEEEEEEDDDDDDDDDDDDCCCCCCCCCCCCBBBBBBBBBBBB',
name: 'MyRadio',
lat: 40.7128,
lon: -74.006,
tx_power: 10,
max_tx_power: 20,
radio: { freq: 915, bw: 250, sf: 10, cr: 8 },
...overrides,
};
}
describe('parsePathHops', () => {
it('returns empty array for null/empty path', () => {
expect(parsePathHops(null)).toEqual([]);
expect(parsePathHops(undefined)).toEqual([]);
expect(parsePathHops('')).toEqual([]);
});
it('parses single hop', () => {
expect(parsePathHops('1A')).toEqual(['1A']);
});
it('parses multiple hops', () => {
expect(parsePathHops('1A2B3C')).toEqual(['1A', '2B', '3C']);
});
it('converts to uppercase', () => {
expect(parsePathHops('1a2b')).toEqual(['1A', '2B']);
});
it('handles odd length by ignoring last character', () => {
expect(parsePathHops('1A2B3')).toEqual(['1A', '2B']);
});
});
describe('findContactsByPrefix', () => {
const contacts: Contact[] = [
createContact({
public_key: '1AAAAA' + 'A'.repeat(52),
name: 'Repeater1',
type: CONTACT_TYPE_REPEATER,
}),
createContact({
public_key: '1ABBBB' + 'B'.repeat(52),
name: 'Repeater2',
type: CONTACT_TYPE_REPEATER,
}),
createContact({
public_key: '2BAAAA' + 'A'.repeat(52),
name: 'Repeater3',
type: CONTACT_TYPE_REPEATER,
}),
createContact({
public_key: '1ACCCC' + 'C'.repeat(52),
name: 'Client1',
type: CONTACT_TYPE_CLIENT,
}),
];
it('finds matching repeaters', () => {
const matches = findContactsByPrefix('1A', contacts, true);
expect(matches).toHaveLength(2);
expect(matches.map((c) => c.name)).toContain('Repeater1');
expect(matches.map((c) => c.name)).toContain('Repeater2');
});
it('returns empty array for no match', () => {
expect(findContactsByPrefix('XX', contacts, true)).toEqual([]);
});
it('is case insensitive', () => {
expect(findContactsByPrefix('1a', contacts, true)).toHaveLength(2);
});
it('excludes non-repeaters when repeatersOnly is true', () => {
const matches = findContactsByPrefix('1A', contacts, true);
expect(matches.every((c) => c.type === CONTACT_TYPE_REPEATER)).toBe(true);
expect(matches.map((c) => c.name)).not.toContain('Client1');
});
it('includes all types when repeatersOnly is false', () => {
const matches = findContactsByPrefix('1A', contacts, false);
expect(matches).toHaveLength(3);
expect(matches.map((c) => c.name)).toContain('Client1');
});
});
describe('calculateDistance', () => {
it('returns null for null coordinates', () => {
expect(calculateDistance(null, 0, 0, 0)).toBeNull();
expect(calculateDistance(0, null, 0, 0)).toBeNull();
expect(calculateDistance(0, 0, null, 0)).toBeNull();
expect(calculateDistance(0, 0, 0, null)).toBeNull();
});
it('returns 0 for same point', () => {
expect(calculateDistance(40.7128, -74.006, 40.7128, -74.006)).toBe(0);
});
it('calculates known distances approximately correctly', () => {
// NYC (40.7128, -74.0060) to LA (34.0522, -118.2437) is approximately 3944 km
const distance = calculateDistance(40.7128, -74.006, 34.0522, -118.2437);
expect(distance).not.toBeNull();
expect(distance).toBeGreaterThan(3900);
expect(distance).toBeLessThan(4000);
});
it('handles short distances', () => {
// About 1km apart in NYC
const distance = calculateDistance(40.7128, -74.006, 40.7218, -74.006);
expect(distance).not.toBeNull();
expect(distance).toBeGreaterThan(0.9);
expect(distance).toBeLessThan(1.1);
});
});
describe('sortContactsByDistance', () => {
const contactClose = createContact({
public_key: 'AA' + 'A'.repeat(62),
name: 'Close',
lat: 40.7228,
lon: -74.006,
});
const contactFar = createContact({
public_key: 'BB' + 'B'.repeat(62),
name: 'Far',
lat: 40.9,
lon: -74.006,
});
const contactNoLocation = createContact({
public_key: 'CC' + 'C'.repeat(62),
name: 'NoLoc',
lat: null,
lon: null,
});
it('sorts by distance ascending', () => {
const sorted = sortContactsByDistance([contactFar, contactClose], 40.7128, -74.006);
expect(sorted[0].name).toBe('Close');
expect(sorted[1].name).toBe('Far');
});
it('places contacts without location at end', () => {
const sorted = sortContactsByDistance(
[contactNoLocation, contactClose, contactFar],
40.7128,
-74.006
);
expect(sorted[0].name).toBe('Close');
expect(sorted[1].name).toBe('Far');
expect(sorted[2].name).toBe('NoLoc');
});
it('returns unsorted if reference is null', () => {
const contacts = [contactFar, contactClose];
const sorted = sortContactsByDistance(contacts, null, -74.006);
expect(sorted[0].name).toBe(contacts[0].name);
});
});
describe('getHopCount', () => {
it('returns 0 for null/empty', () => {
expect(getHopCount(null)).toBe(0);
expect(getHopCount(undefined)).toBe(0);
expect(getHopCount('')).toBe(0);
});
it('counts hops correctly', () => {
expect(getHopCount('1A')).toBe(1);
expect(getHopCount('1A2B')).toBe(2);
expect(getHopCount('1A2B3C')).toBe(3);
});
});
describe('resolvePath', () => {
const repeater1 = createContact({
public_key: '1A' + 'A'.repeat(62),
name: 'Repeater1',
type: CONTACT_TYPE_REPEATER,
lat: 40.75,
lon: -74.0,
});
const repeater2 = createContact({
public_key: '2B' + 'B'.repeat(62),
name: 'Repeater2',
type: CONTACT_TYPE_REPEATER,
lat: 40.8,
lon: -73.95,
});
const contacts = [repeater1, repeater2];
const sender = {
name: 'Sender',
publicKeyOrPrefix: '5E' + 'E'.repeat(62),
lat: 40.7,
lon: -74.05,
};
const config = createConfig({
public_key: 'FF' + 'F'.repeat(62),
name: 'MyRadio',
lat: 40.85,
lon: -73.9,
});
it('resolves simple path with known repeaters', () => {
const result = resolvePath('1A2B', sender, contacts, config);
expect(result.sender.name).toBe('Sender');
expect(result.sender.prefix).toBe('5E');
expect(result.hops).toHaveLength(2);
expect(result.hops[0].prefix).toBe('1A');
expect(result.hops[0].matches).toHaveLength(1);
expect(result.hops[0].matches[0].name).toBe('Repeater1');
expect(result.hops[1].prefix).toBe('2B');
expect(result.hops[1].matches).toHaveLength(1);
expect(result.hops[1].matches[0].name).toBe('Repeater2');
expect(result.receiver.name).toBe('MyRadio');
expect(result.receiver.prefix).toBe('FF');
});
it('handles unknown repeaters (no matches)', () => {
const result = resolvePath('XX', sender, contacts, config);
expect(result.hops).toHaveLength(1);
expect(result.hops[0].prefix).toBe('XX');
expect(result.hops[0].matches).toHaveLength(0);
});
it('handles ambiguous repeaters (multiple matches)', () => {
// Create two repeaters with same prefix
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.hops).toHaveLength(1);
expect(result.hops[0].matches).toHaveLength(2);
// Should be sorted by distance from sender
expect(result.hops[0].matches[0].name).toBe('Repeater1A');
});
it('calculates total distance when all locations known', () => {
const result = resolvePath('1A2B', sender, contacts, config);
expect(result.totalDistances).not.toBeNull();
expect(result.totalDistances!.length).toBeGreaterThan(0);
expect(result.totalDistances![0]).toBeGreaterThan(0);
});
it('returns null totalDistances when locations unknown', () => {
const unknownRepeater = createContact({
public_key: 'XX' + 'X'.repeat(62),
name: 'Unknown',
type: CONTACT_TYPE_REPEATER,
lat: null,
lon: null,
});
const result = resolvePath('XX', sender, [unknownRepeater], config);
expect(result.totalDistances).toBeNull();
});
it('handles empty path', () => {
const result = resolvePath('', sender, contacts, config);
expect(result.hops).toHaveLength(0);
expect(result.sender.name).toBe('Sender');
expect(result.receiver.name).toBe('MyRadio');
});
it('handles null config gracefully', () => {
const result = resolvePath('1A', sender, contacts, null);
expect(result.receiver.name).toBe('Unknown');
expect(result.receiver.prefix).toBe('??');
});
it('excludes receiver distance when receiver location is (0, 0)', () => {
const configAtOrigin = createConfig({
public_key: 'FF' + 'F'.repeat(62),
name: 'MyRadio',
lat: 0,
lon: 0,
});
const result = resolvePath('1A', sender, contacts, configAtOrigin);
// Total distance should NOT include the final leg to receiver
// It should only be sender -> repeater1
expect(result.totalDistances).not.toBeNull();
const senderToRepeater = calculateDistance(
sender.lat,
sender.lon,
repeater1.lat,
repeater1.lon
);
expect(result.totalDistances![0]).toBeCloseTo(senderToRepeater!, 1);
});
it('skips distance after ambiguous hops', () => {
// Create two repeaters with same prefix (ambiguous)
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,
}),
// Known repeater after the ambiguous one
createContact({
public_key: '2B' + 'B'.repeat(62),
name: 'Repeater2',
type: CONTACT_TYPE_REPEATER,
lat: 40.8,
lon: -73.95,
}),
];
const result = resolvePath('1A2B', sender, ambiguousContacts, config);
// First hop is ambiguous, second hop is known
expect(result.hops[0].matches).toHaveLength(2);
expect(result.hops[1].matches).toHaveLength(1);
// First hop is ambiguous, so no single distanceFromPrev
// (UI shows individual distances for each match via getDistanceForContact)
expect(result.hops[0].distanceFromPrev).toBeNull();
// Second hop should also NOT have distanceFromPrev because previous hop was ambiguous
expect(result.hops[1].distanceFromPrev).toBeNull();
});
it('calculates partial distance when sender has no location', () => {
const senderNoLocation = {
name: 'SenderNoLoc',
publicKeyOrPrefix: '5E' + 'E'.repeat(62),
lat: null,
lon: null,
};
const result = resolvePath('1A2B', senderNoLocation, contacts, config);
// First hop has no distance (can't calculate from unknown sender location)
expect(result.hops[0].distanceFromPrev).toBeNull();
// Second hop has distance (from first hop to second hop)
expect(result.hops[1].distanceFromPrev).not.toBeNull();
// Total distance should start from first known hop
expect(result.totalDistances).not.toBeNull();
expect(result.totalDistances![0]).toBeGreaterThan(0);
});
it('returns null totalDistances when all hops have no location', () => {
const noLocationContacts = [
createContact({
public_key: '1A' + 'A'.repeat(62),
name: 'NoLoc1',
type: CONTACT_TYPE_REPEATER,
lat: null,
lon: null,
}),
createContact({
public_key: '2B' + 'B'.repeat(62),
name: 'NoLoc2',
type: CONTACT_TYPE_REPEATER,
lat: null,
lon: null,
}),
];
const senderNoLocation = {
name: 'SenderNoLoc',
publicKeyOrPrefix: '5E' + 'E'.repeat(62),
lat: null,
lon: null,
};
const result = resolvePath('1A2B', senderNoLocation, noLocationContacts, config);
expect(result.totalDistances).toBeNull();
});
it('treats contact at (0, 0) as having no location', () => {
const contactAtOrigin = createContact({
public_key: '1A' + 'A'.repeat(62),
name: 'AtOrigin',
type: CONTACT_TYPE_REPEATER,
lat: 0,
lon: 0,
});
const result = resolvePath('1A', sender, [contactAtOrigin], config);
// Hop should match but have no distance (0, 0 treated as invalid)
expect(result.hops).toHaveLength(1);
expect(result.hops[0].matches).toHaveLength(1);
expect(result.hops[0].distanceFromPrev).toBeNull();
});
it('treats sender at (0, 0) as having no location', () => {
const senderAtOrigin = {
name: 'SenderAtOrigin',
publicKeyOrPrefix: '5E' + 'E'.repeat(62),
lat: 0,
lon: 0,
};
const result = resolvePath('1A2B', senderAtOrigin, contacts, config);
// First hop should have no distance (sender at 0,0 treated as invalid)
expect(result.hops[0].distanceFromPrev).toBeNull();
// But second hop CAN have distance (from first hop)
expect(result.hops[1].distanceFromPrev).not.toBeNull();
});
});
+325
View File
@@ -0,0 +1,325 @@
import type { Contact, RadioConfig } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
export interface PathHop {
prefix: string; // 2-char hex prefix (e.g., "1A")
matches: Contact[]; // Matched repeaters (empty=unknown, multiple=ambiguous)
distanceFromPrev: number | null; // km from previous hop
}
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 };
totalDistances: number[] | null; // Single-element array with sum of unambiguous distances
}
export interface SenderInfo {
name: string;
publicKeyOrPrefix: string;
lat: number | null;
lon: number | null;
}
/**
* Split hex string into 2-char hops
*/
export function parsePathHops(path: string | null | undefined): string[] {
if (!path || path.length === 0) {
return [];
}
const normalized = path.toUpperCase();
const hops: string[] = [];
for (let i = 0; i < normalized.length; i += 2) {
if (i + 1 < normalized.length) {
hops.push(normalized.slice(i, i + 2));
}
}
return hops;
}
/**
* Find contacts matching first 2 chars of public key (repeaters only for intermediate hops)
*/
export function findContactsByPrefix(
prefix: string,
contacts: Contact[],
repeatersOnly: boolean = true
): Contact[] {
const normalizedPrefix = prefix.toUpperCase();
return contacts.filter((c) => {
if (repeatersOnly && c.type !== CONTACT_TYPE_REPEATER) {
return false;
}
return c.public_key.toUpperCase().startsWith(normalizedPrefix);
});
}
/**
* Calculate distance between two points using Haversine formula
* @returns Distance in km, or null if coordinates are missing
*/
export function calculateDistance(
lat1: number | null,
lon1: number | null,
lat2: number | null,
lon2: number | null
): number | null {
if (lat1 === null || lon1 === null || lat2 === null || lon2 === null) {
return null;
}
const R = 6371; // Earth's radius in km
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
function toRad(deg: number): number {
return (deg * Math.PI) / 180;
}
/**
* Check if coordinates represent a valid location
* Returns false for null or (0, 0) which indicates unset location
*/
export function isValidLocation(lat: number | null, lon: number | null): boolean {
if (lat === null || lon === null) {
return false;
}
// (0, 0) is in the Atlantic Ocean - treat as unset
if (lat === 0 && lon === 0) {
return false;
}
return true;
}
/**
* Sort contacts by distance from a reference point
* Contacts without location are placed at the end
*/
export function sortContactsByDistance(
contacts: Contact[],
fromLat: number | null,
fromLon: number | null
): Contact[] {
if (fromLat === null || fromLon === null) {
return contacts;
}
return [...contacts].sort((a, b) => {
const distA = calculateDistance(fromLat, fromLon, a.lat, a.lon);
const distB = calculateDistance(fromLat, fromLon, b.lat, b.lon);
// Null distances go to the end
if (distA === null && distB === null) return 0;
if (distA === null) return 1;
if (distB === null) return -1;
return distA - distB;
});
}
/**
* Get simple hop count from path string
*/
export function getHopCount(path: string | null | undefined): number {
if (!path || path.length === 0) {
return 0;
}
return Math.floor(path.length / 2);
}
/**
* Build complete path resolution with sender, hops, and receiver
*/
export function resolvePath(
path: string | null | undefined,
sender: SenderInfo,
contacts: Contact[],
config: RadioConfig | null
): ResolvedPath {
const hopPrefixes = parsePathHops(path);
// Build sender info
const senderPrefix = sender.publicKeyOrPrefix.toUpperCase().slice(0, 2);
const resolvedSender = {
name: sender.name,
prefix: senderPrefix,
lat: sender.lat,
lon: sender.lon,
};
// Build receiver info from radio config
const receiverPrefix = config?.public_key?.toUpperCase().slice(0, 2) || '??';
const resolvedReceiver = {
name: config?.name || 'Unknown',
prefix: receiverPrefix,
lat: config?.lat ?? null,
lon: config?.lon ?? null,
};
// Build hops
const hops: PathHop[] = [];
let prevLat = sender.lat;
let prevLon = sender.lon;
// Start uncertain if sender has no valid location
let prevHopUncertain = !isValidLocation(sender.lat, sender.lon);
for (const prefix of hopPrefixes) {
const matches = findContactsByPrefix(prefix, contacts, true);
const sortedMatches = sortContactsByDistance(matches, prevLat, prevLon);
// Calculate distance from previous hop
// Can't calculate if previous hop was uncertain (unknown/ambiguous/no location) or current hop is unknown/invalid
let distanceFromPrev: number | null = null;
const currentHasValidLocation =
sortedMatches.length === 1 && isValidLocation(sortedMatches[0].lat, sortedMatches[0].lon);
if (!prevHopUncertain && currentHasValidLocation) {
distanceFromPrev = calculateDistance(
prevLat,
prevLon,
sortedMatches[0].lat,
sortedMatches[0].lon
);
}
hops.push({
prefix,
matches: sortedMatches,
distanceFromPrev,
});
// Update previous location for next hop
if (sortedMatches.length === 0) {
// Unknown hop - can't calculate distance for next hop
prevHopUncertain = true;
prevLat = null;
prevLon = null;
} else if (sortedMatches.length > 1) {
// Ambiguous hop - can't calculate distance for next hop (too many combinations)
prevHopUncertain = true;
// Use first match's location for sorting purposes, but distance won't be shown
if (isValidLocation(sortedMatches[0].lat, sortedMatches[0].lon)) {
prevLat = sortedMatches[0].lat;
prevLon = sortedMatches[0].lon;
} else {
prevLat = null;
prevLon = null;
}
} else if (isValidLocation(sortedMatches[0].lat, sortedMatches[0].lon)) {
prevHopUncertain = false;
prevLat = sortedMatches[0].lat;
prevLon = sortedMatches[0].lon;
} else {
// Known hop but no valid location - treat as uncertain for distance purposes
prevHopUncertain = true;
prevLat = null;
prevLon = null;
}
}
// Calculate total distances (can be multiple if ambiguous)
const totalDistances = calculateTotalDistances(resolvedSender, hops, resolvedReceiver);
return {
sender: resolvedSender,
hops,
receiver: resolvedReceiver,
totalDistances,
};
}
/**
* Calculate total distance(s) for the path
* Returns array for ambiguous paths, null if any segment can't be calculated
* If sender has no location, starts calculating from first hop with location
*/
function calculateTotalDistances(
sender: { lat: number | null; lon: number | null },
hops: PathHop[],
receiver: { lat: number | null; lon: number | null }
): number[] | null {
// Simple case: no hops
if (hops.length === 0) {
if (!isValidLocation(sender.lat, sender.lon) || !isValidLocation(receiver.lat, receiver.lon)) {
return null;
}
const dist = calculateDistance(sender.lat, sender.lon, receiver.lat, receiver.lon);
return dist !== null ? [dist] : null;
}
// Start from sender if it has valid location, otherwise find first hop with valid location
let prevLat = sender.lat;
let prevLon = sender.lon;
let startHopIndex = 0;
if (!isValidLocation(prevLat, prevLon)) {
// Find first hop with a known, unambiguous, valid location
for (let i = 0; i < hops.length; i++) {
const hop = hops[i];
if (hop.matches.length === 1 && isValidLocation(hop.matches[0].lat, hop.matches[0].lon)) {
prevLat = hop.matches[0].lat;
prevLon = hop.matches[0].lon;
startHopIndex = i + 1;
break;
}
}
// If no hop has valid location, can't calculate
if (!isValidLocation(prevLat, prevLon)) {
return null;
}
}
// Sum up only unambiguous segments (where both endpoints are known and unambiguous)
let totalDistance = 0;
let hasAnyDistance = false;
let lastUnambiguousHopIndex = -1; // Track last unambiguous hop for receiver distance
for (let i = startHopIndex; i < hops.length; i++) {
const hop = hops[i];
// Skip if hop is unknown or ambiguous or has no valid location
if (hop.matches.length !== 1 || !isValidLocation(hop.matches[0].lat, hop.matches[0].lon)) {
// Can't include this segment - reset prevLat/prevLon for next potential segment
prevLat = null;
prevLon = null;
continue;
}
// Only calculate distance if previous location is known (unambiguous)
if (prevLat !== null && prevLon !== null) {
const dist = calculateDistance(prevLat, prevLon, hop.matches[0].lat, hop.matches[0].lon);
if (dist !== null) {
totalDistance += dist;
hasAnyDistance = true;
}
}
// Update for next iteration
prevLat = hop.matches[0].lat;
prevLon = hop.matches[0].lon;
lastUnambiguousHopIndex = i;
}
// Add final leg to receiver only if last hop was unambiguous and receiver has valid location
if (lastUnambiguousHopIndex === hops.length - 1 && prevLat !== null && prevLon !== null) {
if (isValidLocation(receiver.lat, receiver.lon)) {
const finalDist = calculateDistance(prevLat, prevLon, receiver.lat, receiver.lon);
if (finalDist !== null) {
totalDistance += finalDist;
hasAnyDistance = true;
}
}
}
// Return total if we calculated any distance
return hasAnyDistance ? [totalDistance] : null;
}