mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-07 05:45:11 +02:00
Add hop display
This commit is contained in:
@@ -11,3 +11,6 @@ frontend/node_modules/
|
||||
|
||||
# reference librarys
|
||||
references/
|
||||
|
||||
# ancillary LLM files
|
||||
.claude/
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
-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
+1
-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-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>
|
||||
|
||||
@@ -705,6 +705,7 @@ export function App() {
|
||||
}
|
||||
onLoadOlder={fetchOlderMessages}
|
||||
radioName={config?.name}
|
||||
config={config}
|
||||
/>
|
||||
<MessageInput
|
||||
ref={messageInputRef}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<UNKNOWN <span className="font-mono text-sm">{hop.prefix}</span>>
|
||||
</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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user