mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-02 15:03:01 +02:00
Make repeater neighbor pane sortable. Closes #290.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useMemo, lazy, Suspense } from 'react';
|
||||
import { useMemo, useState, useCallback, lazy, Suspense } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RepeaterPane, NotFetched, formatDuration } from './repeaterPaneShared';
|
||||
import { isValidLocation, calculateDistance, formatDistance } from '../../utils/pathUtils';
|
||||
@@ -15,6 +15,49 @@ const NeighborsMiniMap = lazy(() =>
|
||||
import('../NeighborsMiniMap').then((m) => ({ default: m.NeighborsMiniMap }))
|
||||
);
|
||||
|
||||
type SortField = 'name' | 'snr' | 'distance' | 'last_heard';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
// Direction applied when a column is first selected. Name reads naturally A→Z
|
||||
// and nearest-first/most-recent-first are the intuitive starting points; SNR
|
||||
// leads with the strongest signal to preserve the previous default ordering.
|
||||
const DEFAULT_DIR: Record<SortField, SortDir> = {
|
||||
name: 'asc',
|
||||
snr: 'desc',
|
||||
distance: 'asc',
|
||||
last_heard: 'asc',
|
||||
};
|
||||
|
||||
function SortableHeader({
|
||||
label,
|
||||
field,
|
||||
sortField,
|
||||
sortDir,
|
||||
onSort,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
field: SortField;
|
||||
sortField: SortField;
|
||||
sortDir: SortDir;
|
||||
onSort: (field: SortField) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const active = sortField === field;
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
'pb-1 font-medium cursor-pointer select-none hover:text-foreground transition-colors',
|
||||
className
|
||||
)}
|
||||
onClick={() => onSort(field)}
|
||||
aria-sort={active ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||||
>
|
||||
{label} {active ? (sortDir === 'asc' ? '▲' : '▼') : ''}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
export function NeighborsPane({
|
||||
data,
|
||||
state,
|
||||
@@ -71,19 +114,37 @@ export function NeighborsPane({
|
||||
? 'Waiting for repeater position'
|
||||
: 'No repeater position available';
|
||||
|
||||
// Resolve contact data for each neighbor in a single pass — used for
|
||||
// coords (mini-map), distances (table column), and sorted display order.
|
||||
const { neighborsWithCoords, sorted, hasDistances } = useMemo(() => {
|
||||
const [sortField, setSortField] = useState<SortField>('snr');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||
|
||||
const handleSort = useCallback(
|
||||
(field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDir(DEFAULT_DIR[field]);
|
||||
}
|
||||
},
|
||||
[sortField]
|
||||
);
|
||||
|
||||
// Resolve contact data for each neighbor in a single pass — used for coords
|
||||
// (mini-map) and distances (table column + distance sort). The formatted
|
||||
// string drives display; the raw km drives numeric distance sorting.
|
||||
const { neighborsWithCoords, enriched, hasDistances } = useMemo(() => {
|
||||
if (!data) {
|
||||
return {
|
||||
neighborsWithCoords: [] as Array<NeighborInfo & { lat: number | null; lon: number | null }>,
|
||||
sorted: [] as Array<NeighborInfo & { distance: string | null }>,
|
||||
enriched: [] as Array<
|
||||
NeighborInfo & { distance: string | null; distanceKm: number | null }
|
||||
>,
|
||||
hasDistances: false,
|
||||
};
|
||||
}
|
||||
|
||||
const withCoords: Array<NeighborInfo & { lat: number | null; lon: number | null }> = [];
|
||||
const enriched: Array<NeighborInfo & { distance: string | null }> = [];
|
||||
const list: Array<NeighborInfo & { distance: string | null; distanceKm: number | null }> = [];
|
||||
let anyDist = false;
|
||||
|
||||
for (const n of data.neighbors) {
|
||||
@@ -92,29 +153,54 @@ export function NeighborsPane({
|
||||
const nLon = contact?.lon ?? null;
|
||||
|
||||
let dist: string | null = null;
|
||||
let distKm: number | null = null;
|
||||
if (hasValidRepeaterGps && isValidLocation(nLat, nLon)) {
|
||||
const distKm = calculateDistance(positionSource.lat, positionSource.lon, nLat, nLon);
|
||||
if (distKm != null) {
|
||||
dist = formatDistance(distKm, distanceUnit);
|
||||
const km = calculateDistance(positionSource.lat, positionSource.lon, nLat, nLon);
|
||||
if (km != null) {
|
||||
distKm = km;
|
||||
dist = formatDistance(km, distanceUnit);
|
||||
anyDist = true;
|
||||
}
|
||||
}
|
||||
enriched.push({ ...n, distance: dist });
|
||||
list.push({ ...n, distance: dist, distanceKm: distKm });
|
||||
|
||||
if (isValidLocation(nLat, nLon)) {
|
||||
withCoords.push({ ...n, lat: nLat, lon: nLon });
|
||||
}
|
||||
}
|
||||
|
||||
enriched.sort((a, b) => b.snr - a.snr);
|
||||
|
||||
return {
|
||||
neighborsWithCoords: withCoords,
|
||||
sorted: enriched,
|
||||
enriched: list,
|
||||
hasDistances: anyDist,
|
||||
};
|
||||
}, [contacts, data, distanceUnit, hasValidRepeaterGps, positionSource.lat, positionSource.lon]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
return [...enriched].sort((a, b) => {
|
||||
switch (sortField) {
|
||||
case 'name': {
|
||||
const an = (a.name || a.pubkey_prefix).toLowerCase();
|
||||
const bn = (b.name || b.pubkey_prefix).toLowerCase();
|
||||
return an.localeCompare(bn) * dir;
|
||||
}
|
||||
case 'distance': {
|
||||
// Neighbors without a known distance always sort last, regardless of direction.
|
||||
if (a.distanceKm == null && b.distanceKm == null) return 0;
|
||||
if (a.distanceKm == null) return 1;
|
||||
if (b.distanceKm == null) return -1;
|
||||
return (a.distanceKm - b.distanceKm) * dir;
|
||||
}
|
||||
case 'last_heard':
|
||||
return (a.last_heard_seconds - b.last_heard_seconds) * dir;
|
||||
case 'snr':
|
||||
default:
|
||||
return (a.snr - b.snr) * dir;
|
||||
}
|
||||
});
|
||||
}, [enriched, sortField, sortDir]);
|
||||
|
||||
return (
|
||||
<RepeaterPane
|
||||
title="Neighbors"
|
||||
@@ -135,10 +221,39 @@ export function NeighborsPane({
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-muted-foreground text-xs">
|
||||
<th className="pb-1 font-medium">Name</th>
|
||||
<th className="pb-1 font-medium text-right">SNR</th>
|
||||
{hasDistances && <th className="pb-1 font-medium text-right">Dist</th>}
|
||||
<th className="pb-1 font-medium text-right">Last Heard</th>
|
||||
<SortableHeader
|
||||
label="Name"
|
||||
field="name"
|
||||
sortField={sortField}
|
||||
sortDir={sortDir}
|
||||
onSort={handleSort}
|
||||
/>
|
||||
<SortableHeader
|
||||
label="SNR"
|
||||
field="snr"
|
||||
sortField={sortField}
|
||||
sortDir={sortDir}
|
||||
onSort={handleSort}
|
||||
className="text-right"
|
||||
/>
|
||||
{hasDistances && (
|
||||
<SortableHeader
|
||||
label="Dist"
|
||||
field="distance"
|
||||
sortField={sortField}
|
||||
sortDir={sortDir}
|
||||
onSort={handleSort}
|
||||
className="text-right"
|
||||
/>
|
||||
)}
|
||||
<SortableHeader
|
||||
label="Last Heard"
|
||||
field="last_heard"
|
||||
sortField={sortField}
|
||||
sortDir={sortDir}
|
||||
onSort={handleSort}
|
||||
className="text-right"
|
||||
/>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
Reference in New Issue
Block a user