Phase 5: Frontend path rendering

This commit is contained in:
Jack Kingsman
2026-03-07 19:11:04 -08:00
parent b0ffa28e46
commit 5c413bf949
4 changed files with 44 additions and 17 deletions
+7 -2
View File
@@ -1,7 +1,12 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { api } from '../api'; import { api } from '../api';
import { formatTime } from '../utils/messageParser'; import { formatTime } from '../utils/messageParser';
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils'; import {
isValidLocation,
calculateDistance,
formatDistance,
parsePathHops,
} from '../utils/pathUtils';
import { getMapFocusHash } from '../utils/urlHash'; import { getMapFocusHash } from '../utils/urlHash';
import { isFavorite } from '../utils/favorites'; import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y'; import { handleKeyboardActivate } from '../utils/a11y';
@@ -413,7 +418,7 @@ export function ContactInfoPane({
className="flex justify-between items-center text-sm" className="flex justify-between items-center text-sm"
> >
<span className="font-mono text-xs truncate"> <span className="font-mono text-xs truncate">
{p.path ? p.path.match(/.{2}/g)!.join(' → ') : '(direct)'} {p.path ? parsePathHops(p.path, p.path_len).join(' → ') : '(direct)'}
</span> </span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2"> <span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{p.heard_count}x · {formatTime(p.last_seen)} {p.heard_count}x · {formatTime(p.last_seen)}
+2 -2
View File
@@ -52,7 +52,7 @@ export function PathModal({
const resolvedPaths = hasPaths const resolvedPaths = hasPaths
? paths.map((p) => ({ ? paths.map((p) => ({
...p, ...p,
resolved: resolvePath(p.path, senderInfo, contacts, config), resolved: resolvePath(p.path, senderInfo, contacts, config, p.path_len),
})) }))
: []; : [];
@@ -90,7 +90,7 @@ export function PathModal({
{/* Raw path summary */} {/* Raw path summary */}
<div className="text-sm"> <div className="text-sm">
{paths.map((p, index) => { {paths.map((p, index) => {
const hops = parsePathHops(p.path); const hops = parsePathHops(p.path, p.path_len);
const rawPath = hops.length > 0 ? hops.join('->') : 'direct'; const rawPath = hops.length > 0 ? hops.join('->') : 'direct';
return ( return (
<div key={index}> <div key={index}>
+3 -1
View File
@@ -150,10 +150,12 @@ export interface ChannelDetail {
/** A single path that a message took to reach us */ /** A single path that a message took to reach us */
export interface MessagePath { export interface MessagePath {
/** Hex-encoded routing path (2 chars per hop) */ /** Hex-encoded routing path */
path: string; path: string;
/** Unix timestamp when this path was received */ /** Unix timestamp when this path was received */
received_at: number; received_at: number;
/** Hop count (number of intermediate nodes). Null for legacy data (infer as len(path)/2). */
path_len?: number | null;
} }
export interface Message { export interface Message {
+32 -12
View File
@@ -2,7 +2,7 @@ import type { Contact, RadioConfig, MessagePath } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types'; import { CONTACT_TYPE_REPEATER } from '../types';
export interface PathHop { export interface PathHop {
prefix: string; // 2-char hex prefix (e.g., "1A") prefix: string; // Hex hop identifier (e.g., "1A" for 1-byte, "1A2B" for 2-byte)
matches: Contact[]; // Matched repeaters (empty=unknown, multiple=ambiguous) matches: Contact[]; // Matched repeaters (empty=unknown, multiple=ambiguous)
distanceFromPrev: number | null; // km from previous hop distanceFromPrev: number | null; // km from previous hop
} }
@@ -30,22 +30,37 @@ export interface SenderInfo {
} }
/** /**
* Split hex string into 2-char hops * Split hex path string into per-hop chunks.
*
* When hopCount is provided (from path_len metadata), the bytes-per-hop is
* derived from the hex length divided by the hop count. This correctly handles
* multi-byte hop identifiers (1, 2, or 3 bytes per hop).
*
* Falls back to 2-char (1-byte) chunks when hopCount is missing or doesn't
* divide evenly — matching legacy behavior.
*/ */
export function parsePathHops(path: string | null | undefined): string[] { export function parsePathHops(path: string | null | undefined, hopCount?: number | null): string[] {
if (!path || path.length === 0) { if (!path || path.length === 0) {
return []; return [];
} }
const normalized = path.toUpperCase(); const normalized = path.toUpperCase();
const hops: string[] = [];
for (let i = 0; i < normalized.length; i += 2) { // Derive chars-per-hop from metadata when available
if (i + 1 < normalized.length) { let charsPerHop = 2; // default: 1-byte hops
hops.push(normalized.slice(i, i + 2)); if (hopCount && hopCount > 0) {
const derived = normalized.length / hopCount;
// Accept only valid even widths (2, 4, 6) that divide evenly
if (derived >= 2 && derived % 2 === 0 && derived * hopCount === normalized.length) {
charsPerHop = derived;
} }
} }
const hops: string[] = [];
for (let i = 0; i + charsPerHop <= normalized.length; i += charsPerHop) {
hops.push(normalized.slice(i, i + charsPerHop));
}
return hops; return hops;
} }
@@ -146,12 +161,16 @@ function sortContactsByDistance(
} }
/** /**
* Get simple hop count from path string * Get hop count from path, using explicit metadata when available.
*/ */
function getHopCount(path: string | null | undefined): number { function getHopCount(path: string | null | undefined, hopCount?: number | null): number {
if (hopCount != null && hopCount >= 0) {
return hopCount;
}
if (!path || path.length === 0) { if (!path || path.length === 0) {
return 0; return 0;
} }
// Legacy fallback: assume 1-byte (2 hex chars) per hop
return Math.floor(path.length / 2); return Math.floor(path.length / 2);
} }
@@ -170,7 +189,7 @@ export function formatHopCounts(paths: MessagePath[] | null | undefined): {
} }
// Get hop counts for all paths and sort ascending // Get hop counts for all paths and sort ascending
const hopCounts = paths.map((p) => getHopCount(p.path)).sort((a, b) => a - b); const hopCounts = paths.map((p) => getHopCount(p.path, p.path_len)).sort((a, b) => a - b);
const allDirect = hopCounts.every((h) => h === 0); const allDirect = hopCounts.every((h) => h === 0);
const hasMultiple = paths.length > 1; const hasMultiple = paths.length > 1;
@@ -189,9 +208,10 @@ export function resolvePath(
path: string | null | undefined, path: string | null | undefined,
sender: SenderInfo, sender: SenderInfo,
contacts: Contact[], contacts: Contact[],
config: RadioConfig | null config: RadioConfig | null,
hopCount?: number | null
): ResolvedPath { ): ResolvedPath {
const hopPrefixes = parsePathHops(path); const hopPrefixes = parsePathHops(path, hopCount);
// Build sender info // Build sender info
const senderPrefix = sender.publicKeyOrPrefix.toUpperCase().slice(0, 2); const senderPrefix = sender.publicKeyOrPrefix.toUpperCase().slice(0, 2);