Checkpoint
This commit is contained in:
221
ui/src/pages/aprs/APRSPacketsView.tsx
Normal file
221
ui/src/pages/aprs/APRSPacketsView.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Badge, Card, Stack, Table, Alert } from 'react-bootstrap';
|
||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import VerticalSplit from '../../components/VerticalSplit';
|
||||
import HorizontalSplit from '../../components/HorizontalSplit';
|
||||
import { useAPRSData } from './APRSData';
|
||||
import type { APRSPacketRecord } from './APRSData';
|
||||
|
||||
const HeaderFact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
|
||||
<div className="aprs-fact-row">
|
||||
<span className="aprs-fact-label">{label}</span>
|
||||
<span className="aprs-fact-value">{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const APRSMapPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ packet }) => {
|
||||
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]];
|
||||
};
|
||||
|
||||
const bounds = hasPosition
|
||||
? createBoundsFromCenter(packet.latitude as number, packet.longitude as number)
|
||||
: createBoundsFromCenter(50.0, 5.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>
|
||||
)}
|
||||
</MapContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const PacketDetailsPane: React.FC<{ packet: APRSPacketRecord | null }> = ({ packet }) => {
|
||||
if (!packet) {
|
||||
return (
|
||||
<Card body className="aprs-detail-card h-100">
|
||||
<h6>Select a packet</h6>
|
||||
<div>Click any packet in the list to view details and map.</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={2} className="h-100 aprs-detail-stack">
|
||||
<Card body className="aprs-detail-card">
|
||||
<Stack direction="horizontal" gap={2} className="mb-2">
|
||||
<h6 className="mb-0">Packet Details</h6>
|
||||
<Badge bg="primary">{packet.frame.source.call}</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>}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{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>}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<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">
|
||||
<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)} />}
|
||||
{packet.altitude && <HeaderFact label="Altitude" value={`${packet.altitude.toFixed(0)} m`} />}
|
||||
{packet.speed !== undefined && <HeaderFact label="Speed" value={`${packet.speed} kt`} />}
|
||||
{packet.course !== undefined && <HeaderFact label="Course" value={`${packet.course}°`} />}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{packet.comment && (
|
||||
<Card body className="aprs-detail-card">
|
||||
<h6 className="mb-2">Comment</h6>
|
||||
<div>{packet.comment}</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card body className="aprs-detail-card">
|
||||
<h6 className="mb-2">Raw Data</h6>
|
||||
<code className="aprs-raw-code">{packet.raw}</code>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const PacketTable: React.FC<{
|
||||
packets: APRSPacketRecord[];
|
||||
selectedIndex: number | null;
|
||||
onSelect: (index: number) => void;
|
||||
}> = ({ packets, selectedIndex, onSelect }) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const radioFilter = searchParams.get('radio') || undefined;
|
||||
|
||||
// Filter packets by radio name if specified
|
||||
const filteredPackets = useMemo(() => {
|
||||
if (!radioFilter) return packets;
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
return packets[selectedIndex] ?? null;
|
||||
}, [packets, selectedIndex]);
|
||||
|
||||
return (
|
||||
<VerticalSplit
|
||||
ratio="50/50"
|
||||
left={<PacketTable packets={packets} selectedIndex={selectedIndex} onSelect={setSelectedIndex} />}
|
||||
right={
|
||||
<HorizontalSplit
|
||||
top={<APRSMapPane packet={selectedPacket} />}
|
||||
bottom={<PacketDetailsPane packet={selectedPacket} />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default APRSPacketsView;
|
||||
Reference in New Issue
Block a user