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
{packets.map((packet, index) => ( onSelectPacket(packet)} > ))}
Time   Source Destination   Comment
{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;