import React, { useMemo, useState } from 'react';
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';
const HeaderFact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
{label}
{value}
);
const Callsign: React.FC<{ call: string; ssid?: string | number; plain?: boolean }> = ({ call, ssid, plain = false }) => (
{call}
{(ssid !== undefined && ssid !== '') ? -{ssid} : null}
);
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(
);
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(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) => (
Lat: {p.latitude?.toFixed(4)}, Lon: {p.longitude?.toFixed(4)}
{p.altitude &&
Alt: {p.altitude.toFixed(0)}m
}
{p.speed &&
Speed: {p.speed}kt
}
{p.comment &&
{p.comment}
}
);
/**
* Render popup content for a cluster
*/
const renderClusterPopup = (cluster: Cluster) => {
const packets = cluster.items as APRSPacketRecord[];
return (
{cluster.count} stations
{packets.map((p, i) => (
))}
Click to zoom in
);
};
/**
* 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)
const createBoundsFromCenter = (lat: number, lng: number): [[number, number], [number, number]] => {
const offset = 2;
return [[lat - offset, lng - offset], [lat + offset, lng + offset]];
};
// Get latest location for each source
const latestBySource = useMemo(() => {
const sourceMap = new Map();
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)
: (defaultBounds ?? [[48.0, 3.0], [52.0, 7.0]]);
return (
{/* Clustered markers for latest location per source */}
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) ? (
Lat: {packet.latitude?.toFixed(4)}, Lon: {packet.longitude?.toFixed(4)}
{packet.altitude &&
Alt: {packet.altitude.toFixed(0)}m
}
{packet.speed &&
Speed: {packet.speed}kt
}
{packet.comment &&
{packet.comment}
}
) : (
Lat: {packet.latitude?.toFixed(4)}, Lon: {packet.longitude?.toFixed(4)}
{packet.altitude &&
Alt: {packet.altitude.toFixed(0)}m
}
{packet.speed &&
Speed: {packet.speed}kt
}
{packet.comment &&
{packet.comment}
}
)
)}
);
};
const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ packet }) => {
if (!packet) {
return (
Select a packet
Click any packet in the list to view details and map.
);
}
return (
Packet Details
}
/>
{packet.radioName && }
}
/>
`${addr.call}${addr.ssid ? `-${addr.ssid}` : ''}${addr.isRepeated ? '*' : ''}`).join(', ') || 'None'} />
{(packet.latitude || packet.longitude || packet.altitude || packet.speed || packet.course) && (
Position Data
{packet.latitude && }
{packet.longitude && }
{packet.altitude && }
{packet.speed !== undefined && }
{packet.course !== undefined && }
)}
{packet.comment && (
Comment
{packet.comment}
)}
Raw Data
{packet.raw}
);
};
const PacketTable: React.FC<{
packets: APRSPacketRecord[];
radioFilter?: string;
selectedPacketKey: string | null;
onSelectPacket: (packet: APRSPacketRecord) => void;
onClearSelection: () => void;
streamReady: boolean;
}> = ({ packets, radioFilter, selectedPacketKey, onSelectPacket, onClearSelection, streamReady }) => {
const scrollRef = React.useRef(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 (
APRS Packets
| Time |
|
Source |
Destination |
|
Comment |
{packets.map((packet, index) => (
onSelectPacket(packet)}
>
| {packet.timestamp.toLocaleTimeString()} |
|
|
|
{packet.symbol && }
|
{packet.comment || '-'} |
))}
{radioFilter && Filtered by radio: {radioFilter}}
setShowShortcuts(false)}
shortcuts={shortcuts}
/>
);
};
const APRSPacketsView: React.FC = () => {
const { packets, streamReady } = useAPRSData();
const [searchParams] = useSearchParams();
const radioFilter = searchParams.get('radio') || undefined;
const [selectedPacketKey, setSelectedPacketKey] = useState(null);
// Filter packets by radio name if specified
const filteredPackets = useMemo(() => {
if (!radioFilter) return packets;
return packets.filter(packet => packet.radioName === radioFilter);
}, [packets, radioFilter]);
const selectedPacket = useMemo(() => {
if (!selectedPacketKey) {
return null;
}
return filteredPackets.find((packet) => getPacketKey(packet) === selectedPacketKey) ?? null;
}, [filteredPackets, selectedPacketKey]);
return (
setSelectedPacketKey(getPacketKey(packet))} onClearSelection={() => setSelectedPacketKey(null)} streamReady={streamReady} />}
right={
setSelectedPacketKey(getPacketKey(packet))} />}
bottom={}
/>
}
/>
);
};
export default APRSPacketsView;