More APRS enhancements
This commit is contained in:
@@ -1,9 +1,18 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Badge, Card, Stack, Table, Alert } from 'react-bootstrap';
|
||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||
import { Badge, Card, Stack, Table } from 'react-bootstrap';
|
||||
import { MapContainer, TileLayer, Popup, useMap, CircleMarker, Marker } from 'react-leaflet';
|
||||
import { divIcon, type DivIcon } from 'leaflet';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import VerticalSplit from '../../components/VerticalSplit';
|
||||
import HorizontalSplit from '../../components/HorizontalSplit';
|
||||
import CountryFlag from '../../components/CountryFlag';
|
||||
import KeyboardShortcutsModal from '../../components/KeyboardShortcutsModal';
|
||||
import StreamStatus from '../../components/StreamStatus';
|
||||
import { APRSSymbol } from '../../components/aprs';
|
||||
import { ClusteredMarkers } from '../../components/map';
|
||||
import type { ClusterableItem, Cluster } from '../../components/map';
|
||||
import { useKeyboardListNavigation } from '../../hooks/useKeyboardListNavigation';
|
||||
import { useAPRSData } from './APRSData';
|
||||
import type { APRSPacketRecord } from './APRSData';
|
||||
|
||||
@@ -14,7 +23,229 @@ const HeaderFact: React.FC<{ label: string; value: React.ReactNode }> = ({ label
|
||||
</div>
|
||||
);
|
||||
|
||||
const APRSMapPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ packet }) => {
|
||||
const Callsign: React.FC<{ call: string; ssid?: string | number; plain?: boolean }> = ({ call, ssid, plain = false }) => (
|
||||
<span className={plain ? 'callsign callsign--plain' : 'callsign'}>
|
||||
{call}
|
||||
{(ssid !== undefined && ssid !== '') ? <span>-{ssid}</span> : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
const getPacketKey = (packet: APRSPacketRecord): string => {
|
||||
const ssid = packet.frame.source.ssid ?? 0;
|
||||
return `${packet.frame.source.call}-${ssid}-${packet.timestamp.toISOString()}-${packet.raw}`;
|
||||
};
|
||||
|
||||
const calculateBounds = (packets: APRSPacketRecord[]): [[number, number], [number, number]] | null => {
|
||||
const validPackets = packets.filter(p => p.latitude !== undefined && p.longitude !== undefined);
|
||||
|
||||
if (validPackets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (validPackets.length === 1) {
|
||||
const lat = validPackets[0].latitude!;
|
||||
const lng = validPackets[0].longitude!;
|
||||
const offset = 2;
|
||||
return [[lat - offset, lng - offset], [lat + offset, lng + offset]];
|
||||
}
|
||||
|
||||
let minLat = validPackets[0].latitude!;
|
||||
let maxLat = validPackets[0].latitude!;
|
||||
let minLng = validPackets[0].longitude!;
|
||||
let maxLng = validPackets[0].longitude!;
|
||||
|
||||
validPackets.forEach(packet => {
|
||||
const lat = packet.latitude!;
|
||||
const lng = packet.longitude!;
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
if (lng < minLng) minLng = lng;
|
||||
if (lng > maxLng) maxLng = lng;
|
||||
});
|
||||
|
||||
// Add 10% padding to the bounds
|
||||
const latPadding = (maxLat - minLat) * 0.1 || 0.5;
|
||||
const lngPadding = (maxLng - minLng) * 0.1 || 0.5;
|
||||
|
||||
return [
|
||||
[minLat - latPadding, minLng - lngPadding],
|
||||
[maxLat + latPadding, maxLng + lngPadding]
|
||||
];
|
||||
};
|
||||
|
||||
const toRadians = (degrees: number): number => (degrees * Math.PI) / 180;
|
||||
|
||||
const distanceKm = (lat1: number, lng1: number, lat2: number, lng2: number): number => {
|
||||
const earthRadiusKm = 6371;
|
||||
const dLat = toRadians(lat2 - lat1);
|
||||
const dLng = toRadians(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) *
|
||||
Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return earthRadiusKm * c;
|
||||
};
|
||||
|
||||
const median = (values: number[]): number | null => {
|
||||
if (values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
|
||||
if (sorted.length % 2 === 0) {
|
||||
return (sorted[middle - 1] + sorted[middle]) / 2;
|
||||
}
|
||||
|
||||
return sorted[middle];
|
||||
};
|
||||
|
||||
const filterByClusterRadius = (packets: APRSPacketRecord[], radiusKm: number): APRSPacketRecord[] => {
|
||||
const validPackets = packets.filter(p => p.latitude !== undefined && p.longitude !== undefined);
|
||||
if (validPackets.length <= 1) {
|
||||
return validPackets;
|
||||
}
|
||||
|
||||
const latCenter = median(validPackets.map((p) => p.latitude!));
|
||||
const lngCenter = median(validPackets.map((p) => p.longitude!));
|
||||
if (latCenter === null || lngCenter === null) {
|
||||
return validPackets;
|
||||
}
|
||||
|
||||
const filtered = validPackets.filter((p) => distanceKm(latCenter, lngCenter, p.latitude!, p.longitude!) <= radiusKm);
|
||||
return filtered.length > 0 ? filtered : validPackets;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate if an APRS symbol string is valid
|
||||
*/
|
||||
const isValidSymbol = (symbol: string | undefined): boolean => {
|
||||
if (!symbol || symbol.length < 2) return false;
|
||||
|
||||
const table = symbol.charAt(0);
|
||||
const code = symbol.charAt(1);
|
||||
const charCode = code.charCodeAt(0);
|
||||
|
||||
// Primary and secondary tables
|
||||
if (table === '/' || table === '\\') {
|
||||
return charCode >= 33 && charCode <= 125;
|
||||
}
|
||||
|
||||
// Overlay characters: 0-9, A-Z
|
||||
if ((charCode >= 48 && charCode <= 57) || (charCode >= 65 && charCode <= 90)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a Leaflet DivIcon for an APRS symbol
|
||||
* Uses 32px source image rendered at 16px for crisp display
|
||||
*/
|
||||
const createAPRSIcon = (symbol: string) => {
|
||||
const iconHtml = renderToStaticMarkup(
|
||||
<APRSSymbol symbol={symbol} size={32} className="aprs-map-marker" />
|
||||
);
|
||||
|
||||
return divIcon({
|
||||
html: iconHtml,
|
||||
className: 'aprs-marker-icon',
|
||||
iconSize: [16, 16],
|
||||
iconAnchor: [8, 8],
|
||||
popupAnchor: [0, -8],
|
||||
});
|
||||
};
|
||||
|
||||
const MapPanHandler: React.FC<{
|
||||
packet: APRSPacketRecord | null;
|
||||
defaultBounds: [[number, number], [number, number]] | null;
|
||||
}> = ({ packet, defaultBounds }) => {
|
||||
const map = useMap();
|
||||
const prevPacketRef = React.useRef<APRSPacketRecord | null>(packet);
|
||||
const prevBoundsRef = React.useRef<[[number, number], [number, number]] | null>(defaultBounds);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (packet && packet.latitude && packet.longitude) {
|
||||
const currentZoom = map.getZoom();
|
||||
// Don't zoom out if we're already zoomed in further than default
|
||||
const targetZoom = Math.max(currentZoom, 12);
|
||||
|
||||
map.flyTo([packet.latitude as number, packet.longitude as number], targetZoom, {
|
||||
duration: 1.5,
|
||||
easeLinearity: 0.25,
|
||||
});
|
||||
prevPacketRef.current = packet;
|
||||
} else if (!packet) {
|
||||
// No packet selected - show all markers
|
||||
if (defaultBounds && (!prevPacketRef.current || JSON.stringify(prevBoundsRef.current) !== JSON.stringify(defaultBounds))) {
|
||||
map.fitBounds(defaultBounds, {
|
||||
padding: [50, 50],
|
||||
maxZoom: 15,
|
||||
});
|
||||
prevBoundsRef.current = defaultBounds;
|
||||
}
|
||||
prevPacketRef.current = null;
|
||||
}
|
||||
}, [packet, defaultBounds, map]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Render popup content for an APRS packet
|
||||
*/
|
||||
const renderPacketPopup = (p: APRSPacketRecord) => (
|
||||
<div>
|
||||
<Callsign call={p.frame.source.call} ssid={p.frame.source.ssid} />
|
||||
<br />
|
||||
Lat: {p.latitude?.toFixed(4)}, Lon: {p.longitude?.toFixed(4)}
|
||||
{p.altitude && <div>Alt: {p.altitude.toFixed(0)}m</div>}
|
||||
{p.speed && <div>Speed: {p.speed}kt</div>}
|
||||
{p.comment && <div style={{ marginTop: '0.25rem', fontSize: '0.875rem' }}>{p.comment}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Render popup content for a cluster
|
||||
*/
|
||||
const renderClusterPopup = (cluster: Cluster<ClusterableItem>) => {
|
||||
const packets = cluster.items as APRSPacketRecord[];
|
||||
return (
|
||||
<div>
|
||||
<strong>{cluster.count} stations</strong>
|
||||
<div style={{ marginTop: '0.5rem', maxHeight: '200px', overflowY: 'auto' }}>
|
||||
{packets.map((p, i) => (
|
||||
<div key={i} style={{ marginBottom: '0.25rem' }}>
|
||||
<Callsign call={p.frame.source.call} ssid={p.frame.source.ssid} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: '#666' }}>
|
||||
Click to zoom in
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get icon for an APRS packet marker
|
||||
*/
|
||||
const getPacketIcon = (item: ClusterableItem): DivIcon | null => {
|
||||
const packet = item as APRSPacketRecord;
|
||||
if (isValidSymbol(packet.symbol)) {
|
||||
return createAPRSIcon(packet.symbol!);
|
||||
}
|
||||
return null; // Will use default blue circle
|
||||
};
|
||||
|
||||
const APRSMapPane: React.FC<{
|
||||
packet: APRSPacketRecord | null;
|
||||
packets: APRSPacketRecord[];
|
||||
onSelectPacket: (packet: APRSPacketRecord) => void;
|
||||
}> = ({ packet, packets, onSelectPacket }) => {
|
||||
const hasPosition = packet && packet.latitude && packet.longitude;
|
||||
|
||||
// Create bounds from center point (offset by ~2 degrees)
|
||||
@@ -23,41 +254,101 @@ const APRSMapPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ packet })
|
||||
return [[lat - offset, lng - offset], [lat + offset, lng + offset]];
|
||||
};
|
||||
|
||||
// Get latest location for each source
|
||||
const latestBySource = useMemo(() => {
|
||||
const sourceMap = new Map<string, APRSPacketRecord>();
|
||||
packets.forEach(p => {
|
||||
if (p.latitude !== undefined && p.longitude !== undefined) {
|
||||
const sourceSsid = p.frame.source.ssid ?? '';
|
||||
const key = `${p.frame.source.call}-${sourceSsid}`;
|
||||
// Keep the most recent packet for each source
|
||||
if (!sourceMap.has(key) || p.timestamp > sourceMap.get(key)!.timestamp) {
|
||||
sourceMap.set(key, p);
|
||||
}
|
||||
}
|
||||
});
|
||||
return Array.from(sourceMap.values());
|
||||
}, [packets]);
|
||||
|
||||
const overviewLocations = useMemo(() => filterByClusterRadius(latestBySource, 250), [latestBySource]);
|
||||
|
||||
const defaultBounds = useMemo(() => calculateBounds(overviewLocations), [overviewLocations]);
|
||||
|
||||
const bounds = hasPosition
|
||||
? createBoundsFromCenter(packet.latitude as number, packet.longitude as number)
|
||||
: createBoundsFromCenter(50.0, 5.0);
|
||||
: (defaultBounds ?? [[48.0, 3.0], [52.0, 7.0]]);
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
bounds={bounds}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
className="aprs-map"
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
{hasPosition && (
|
||||
<Marker position={[packet.latitude as number, packet.longitude as number]}>
|
||||
<Popup>
|
||||
<div>
|
||||
<strong>{packet.frame.source.call}</strong>
|
||||
{packet.frame.source.ssid && <span>-{packet.frame.source.ssid}</span>}
|
||||
<br />
|
||||
Lat: {packet.latitude?.toFixed(4)}, Lon: {packet.longitude?.toFixed(4)}
|
||||
{packet.altitude && <div>Alt: {packet.altitude.toFixed(0)}m</div>}
|
||||
{packet.speed && <div>Speed: {packet.speed}kt</div>}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
)}
|
||||
<Card className="data-table-card h-100" style={{ display: 'flex', flexDirection: 'column', padding: 0 }}>
|
||||
<MapContainer
|
||||
bounds={bounds}
|
||||
style={{ flex: 1, width: '100%' }}
|
||||
className="aprs-map"
|
||||
>
|
||||
<MapPanHandler packet={packet} defaultBounds={defaultBounds} />
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
{/* Clustered markers for latest location per source */}
|
||||
<ClusteredMarkers<ClusterableItem>
|
||||
items={overviewLocations}
|
||||
getItemKey={(item) => getPacketKey(item as APRSPacketRecord)}
|
||||
onItemClick={(item) => onSelectPacket(item as APRSPacketRecord)}
|
||||
getIcon={getPacketIcon}
|
||||
renderPopupContent={(item) => renderPacketPopup(item as APRSPacketRecord)}
|
||||
renderClusterPopupContent={renderClusterPopup}
|
||||
/>
|
||||
{/* Highlight selected packet - use APRS symbol or red circle */}
|
||||
{hasPosition && (
|
||||
isValidSymbol(packet.symbol) ? (
|
||||
<Marker
|
||||
position={[packet.latitude as number, packet.longitude as number]}
|
||||
icon={createAPRSIcon(packet.symbol!)}
|
||||
>
|
||||
<Popup>
|
||||
<div>
|
||||
<Callsign call={packet.frame.source.call} ssid={packet.frame.source.ssid} />
|
||||
<br />
|
||||
Lat: {packet.latitude?.toFixed(4)}, Lon: {packet.longitude?.toFixed(4)}
|
||||
{packet.altitude && <div>Alt: {packet.altitude.toFixed(0)}m</div>}
|
||||
{packet.speed && <div>Speed: {packet.speed}kt</div>}
|
||||
{packet.comment && <div style={{ marginTop: '0.25rem', fontSize: '0.875rem' }}>{packet.comment}</div>}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
) : (
|
||||
<CircleMarker
|
||||
center={[packet.latitude as number, packet.longitude as number]}
|
||||
radius={8}
|
||||
pathOptions={{
|
||||
color: '#dc3545',
|
||||
fillColor: '#dc3545',
|
||||
fillOpacity: 0.8,
|
||||
weight: 3,
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div>
|
||||
<Callsign call={packet.frame.source.call} ssid={packet.frame.source.ssid} />
|
||||
<br />
|
||||
Lat: {packet.latitude?.toFixed(4)}, Lon: {packet.longitude?.toFixed(4)}
|
||||
{packet.altitude && <div>Alt: {packet.altitude.toFixed(0)}m</div>}
|
||||
{packet.speed && <div>Speed: {packet.speed}kt</div>}
|
||||
{packet.comment && <div style={{ marginTop: '0.25rem', fontSize: '0.875rem' }}>{packet.comment}</div>}
|
||||
</div>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
)
|
||||
)}
|
||||
</MapContainer>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ packet }) => {
|
||||
if (!packet) {
|
||||
return (
|
||||
<Card body className="aprs-detail-card h-100">
|
||||
<Card body className="data-table-card h-100">
|
||||
<h6>Select a packet</h6>
|
||||
<div>Click any packet in the list to view details and map.</div>
|
||||
</Card>
|
||||
@@ -66,36 +357,28 @@ const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ pack
|
||||
|
||||
return (
|
||||
<Stack gap={2} className="h-100 aprs-detail-stack">
|
||||
<Card body className="aprs-detail-card">
|
||||
<Card body className="data-table-card">
|
||||
<Stack direction="horizontal" gap={2} className="mb-2">
|
||||
<h6 className="mb-0">Packet Details</h6>
|
||||
<Badge bg="primary">{packet.frame.source.call}</Badge>
|
||||
<Badge bg="primary">
|
||||
<Callsign call={packet.frame.source.call} ssid={packet.frame.source.ssid} plain />
|
||||
</Badge>
|
||||
</Stack>
|
||||
<HeaderFact label="Timestamp" value={packet.timestamp.toLocaleTimeString()} />
|
||||
<HeaderFact
|
||||
label="Source"
|
||||
value={
|
||||
<>
|
||||
{packet.frame.source.call}
|
||||
{packet.frame.source.ssid && <span>-{packet.frame.source.ssid}</span>}
|
||||
</>
|
||||
}
|
||||
value={<Callsign call={packet.frame.source.call} ssid={packet.frame.source.ssid} />}
|
||||
/>
|
||||
{packet.radioName && <HeaderFact label="Radio" value={packet.radioName} />}
|
||||
<HeaderFact
|
||||
label="Destination"
|
||||
value={
|
||||
<>
|
||||
{packet.frame.destination.call}
|
||||
{packet.frame.destination.ssid && <span>-{packet.frame.destination.ssid}</span>}
|
||||
</>
|
||||
}
|
||||
value={<Callsign call={packet.frame.destination.call} ssid={packet.frame.destination.ssid} />}
|
||||
/>
|
||||
<HeaderFact label="Path" value={packet.frame.path.map((addr) => `${addr.call}${addr.ssid ? `-${addr.ssid}` : ''}${addr.isRepeated ? '*' : ''}`).join(', ') || 'None'} />
|
||||
</Card>
|
||||
|
||||
{(packet.latitude || packet.longitude || packet.altitude || packet.speed || packet.course) && (
|
||||
<Card body className="aprs-detail-card">
|
||||
<Card body className="data-table-card">
|
||||
<h6 className="mb-2">Position Data</h6>
|
||||
{packet.latitude && <HeaderFact label="Latitude" value={packet.latitude.toFixed(6)} />}
|
||||
{packet.longitude && <HeaderFact label="Longitude" value={packet.longitude.toFixed(6)} />}
|
||||
@@ -106,13 +389,13 @@ const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ pack
|
||||
)}
|
||||
|
||||
{packet.comment && (
|
||||
<Card body className="aprs-detail-card">
|
||||
<Card body className="data-table-card">
|
||||
<h6 className="mb-2">Comment</h6>
|
||||
<div>{packet.comment}</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card body className="aprs-detail-card">
|
||||
<Card body className="data-table-card">
|
||||
<h6 className="mb-2">Raw Data</h6>
|
||||
<code className="aprs-raw-code">{packet.raw}</code>
|
||||
</Card>
|
||||
@@ -122,11 +405,102 @@ const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ pack
|
||||
|
||||
const PacketTable: React.FC<{
|
||||
packets: APRSPacketRecord[];
|
||||
selectedIndex: number | null;
|
||||
onSelect: (index: number) => void;
|
||||
}> = ({ packets, selectedIndex, onSelect }) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
radioFilter?: string;
|
||||
selectedPacketKey: string | null;
|
||||
onSelectPacket: (packet: APRSPacketRecord) => void;
|
||||
onClearSelection: () => void;
|
||||
streamReady: boolean;
|
||||
}> = ({ packets, radioFilter, selectedPacketKey, onSelectPacket, onClearSelection, streamReady }) => {
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedIndex = useMemo(() => {
|
||||
if (!selectedPacketKey) {
|
||||
return null;
|
||||
}
|
||||
const index = packets.findIndex((packet) => getPacketKey(packet) === selectedPacketKey);
|
||||
return index >= 0 ? index : null;
|
||||
}, [packets, selectedPacketKey]);
|
||||
|
||||
const { showShortcuts, setShowShortcuts, shortcuts } = useKeyboardListNavigation({
|
||||
itemCount: packets.length,
|
||||
selectedIndex,
|
||||
onSelectIndex: (index) => {
|
||||
if (index === null) {
|
||||
onClearSelection();
|
||||
return;
|
||||
}
|
||||
const packet = packets[index];
|
||||
if (packet) {
|
||||
onSelectPacket(packet);
|
||||
}
|
||||
},
|
||||
scrollContainerRef: scrollRef,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="data-table-card h-100 d-flex flex-column">
|
||||
<Card.Header className="data-table-header d-flex justify-content-between align-items-center">
|
||||
<span>APRS Packets</span>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<StreamStatus ready={streamReady} />
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Body className="data-table-body p-0">
|
||||
<div className="data-table-scroll" ref={scrollRef}>
|
||||
<Table hover responsive className="data-table mb-0" size="sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th> </th>
|
||||
<th>Source</th>
|
||||
<th>Destination</th>
|
||||
<th> </th>
|
||||
<th>Comment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{packets.map((packet, index) => (
|
||||
<tr
|
||||
key={getPacketKey(packet)}
|
||||
data-nav-item="true"
|
||||
className={selectedIndex === index ? 'is-selected' : ''}
|
||||
onClick={() => onSelectPacket(packet)}
|
||||
>
|
||||
<td style={{ verticalAlign: 'top' }}>{packet.timestamp.toLocaleTimeString()}</td>
|
||||
<td style={{ verticalAlign: 'top' }}>
|
||||
<CountryFlag callsign={packet.frame.source.call} size={1.25} />
|
||||
</td>
|
||||
<td style={{ verticalAlign: 'top' }}>
|
||||
<Callsign call={packet.frame.source.call} ssid={packet.frame.source.ssid} />
|
||||
</td>
|
||||
<td style={{ verticalAlign: 'top' }}>
|
||||
<Callsign call={packet.frame.destination.call} ssid={packet.frame.destination.ssid} />
|
||||
</td>
|
||||
<td style={{ verticalAlign: 'top' }}>
|
||||
{packet.symbol && <APRSSymbol symbol={packet.symbol} size={24} />}
|
||||
</td>
|
||||
<td style={{ verticalAlign: 'top' }}>{packet.comment || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card.Body>
|
||||
{radioFilter && <Card.Footer className="text-muted" style={{ fontSize: '0.875rem' }}>Filtered by radio: {radioFilter}</Card.Footer>}
|
||||
<KeyboardShortcutsModal
|
||||
show={showShortcuts}
|
||||
onHide={() => setShowShortcuts(false)}
|
||||
shortcuts={shortcuts}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const APRSPacketsView: React.FC = () => {
|
||||
const { packets, streamReady } = useAPRSData();
|
||||
const [searchParams] = useSearchParams();
|
||||
const radioFilter = searchParams.get('radio') || undefined;
|
||||
const [selectedPacketKey, setSelectedPacketKey] = useState<string | null>(null);
|
||||
|
||||
// Filter packets by radio name if specified
|
||||
const filteredPackets = useMemo(() => {
|
||||
@@ -134,83 +508,20 @@ const PacketTable: React.FC<{
|
||||
return packets.filter(packet => packet.radioName === radioFilter);
|
||||
}, [packets, radioFilter]);
|
||||
|
||||
return (
|
||||
<Card className="aprs-table-card h-100 d-flex flex-column">
|
||||
<Card.Header className="aprs-table-header">APRS Packets</Card.Header>
|
||||
<Card.Body className="aprs-table-body p-0">
|
||||
{radioFilter && (
|
||||
<Alert variant="info" className="m-2 mb-0 d-flex align-items-center justify-content-between" style={{ fontSize: '0.875rem', padding: '0.5rem 0.75rem' }}>
|
||||
<span>Filtering by radio: <strong>{radioFilter}</strong></span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close btn-close-white"
|
||||
style={{ fontSize: '0.7rem' }}
|
||||
onClick={() => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.delete('radio');
|
||||
setSearchParams(newParams);
|
||||
}}
|
||||
aria-label="Clear radio filter"
|
||||
/>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="aprs-table-scroll">
|
||||
<Table hover responsive className="aprs-table mb-0" size="sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Source</th>
|
||||
<th>Destination</th>
|
||||
<th>Position</th>
|
||||
<th>Comment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredPackets.map((packet, index) => (
|
||||
<tr
|
||||
key={`${packet.frame.source.call}-${packet.timestamp.toISOString()}`}
|
||||
className={selectedIndex === index ? 'is-selected' : ''}
|
||||
onClick={() => onSelect(index)}
|
||||
>
|
||||
<td>{packet.timestamp.toLocaleTimeString()}</td>
|
||||
<td>
|
||||
{packet.frame.source.call}
|
||||
{packet.frame.source.ssid && <span>-{packet.frame.source.ssid}</span>}
|
||||
</td>
|
||||
<td>
|
||||
{packet.frame.destination.call}
|
||||
{packet.frame.destination.ssid && <span>-{packet.frame.destination.ssid}</span>}
|
||||
</td>
|
||||
<td>{packet.latitude && packet.longitude ? '✓' : '-'}</td>
|
||||
<td>{packet.comment || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const APRSPacketsView: React.FC = () => {
|
||||
const { packets } = useAPRSData();
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
|
||||
const selectedPacket = useMemo(() => {
|
||||
if (selectedIndex === null || selectedIndex < 0 || selectedIndex >= packets.length) {
|
||||
if (!selectedPacketKey) {
|
||||
return null;
|
||||
}
|
||||
return packets[selectedIndex] ?? null;
|
||||
}, [packets, selectedIndex]);
|
||||
return filteredPackets.find((packet) => getPacketKey(packet) === selectedPacketKey) ?? null;
|
||||
}, [filteredPackets, selectedPacketKey]);
|
||||
|
||||
return (
|
||||
<VerticalSplit
|
||||
ratio="50/50"
|
||||
left={<PacketTable packets={packets} selectedIndex={selectedIndex} onSelect={setSelectedIndex} />}
|
||||
ratio="1:1"
|
||||
left={<PacketTable packets={filteredPackets} radioFilter={radioFilter} selectedPacketKey={selectedPacketKey} onSelectPacket={(packet) => setSelectedPacketKey(getPacketKey(packet))} onClearSelection={() => setSelectedPacketKey(null)} streamReady={streamReady} />}
|
||||
right={
|
||||
<HorizontalSplit
|
||||
top={<APRSMapPane packet={selectedPacket} />}
|
||||
top={<APRSMapPane packet={selectedPacket} packets={filteredPackets} onSelectPacket={(packet) => setSelectedPacketKey(getPacketKey(packet))} />}
|
||||
bottom={<PacketDetailsPane packet={selectedPacket} />}
|
||||
/>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user