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
+3
View File
@@ -11,3 +11,6 @@ frontend/node_modules/
# reference librarys
references/
# ancillary LLM files
.claude/
+6 -3
View File
@@ -16,7 +16,7 @@ Backend server + browser interface for MeshCore mesh radio networks. Attach your
This is entirely vibecoded slop -- no warranty of fitness for any purpose. It's been lovingly guided by an engineer with a passion for clean code and good tests, but it's still mostly LLM output, so you may find some bugs.
If extending, read the three `CLAUDE.md` files: `./CLAUDE.md`, `./frontend/CLAUDE.md`, and `./app/CLAUDE.md`.
If extending, have your LLM read the three `CLAUDE.md` files: `./CLAUDE.md`, `./frontend/CLAUDE.md`, and `./app/CLAUDE.md`.
## Requirements
@@ -111,9 +111,9 @@ npm run build # Production build to dist/
Run both the backend and `npm run dev` for hot-reloading frontend development.
### Code Quality
### Code Quality & Tests
Please lint, format, and quality check your code before PRing or committing. At the least, run a lint + autoformat + pyright check on the bakend, and a lint + autoformat on the frontend.
Please test, lint, format, and quality check your code before PRing or committing. At the least, run a lint + autoformat + pyright check on the bakend, and a lint + autoformat on the frontend.
<details>
<summary>But how?</summary>
@@ -123,11 +123,14 @@ Please lint, format, and quality check your code before PRing or committing. At
uv run ruff check app/ tests/ --fix # lint + auto-fix
uv run ruff format app/ tests/ # format (always writes)
uv run pyright app/ # type checking
PYTHONPATH=. uv run pytest tests/ -v # backend tests
# frontend
cd frontend
npm run lint:fix # esLint + auto-fix
npm run test:run # run tests
npm run format # prettier (always writes)
npm run build # build the frontend
```
</details>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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-DRuKVg0T.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D9NEpiho.css">
<script type="module" crossorigin src="/assets/index-DlEnSqQ7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-aLTdJARH.css">
</head>
<body>
<div id="root"></div>
+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;
}