historical packets

This commit is contained in:
Graham McIntire 2025-06-21 13:28:20 -05:00
parent d6ac5cccfc
commit 8fdafcf01e
No known key found for this signature in database
5 changed files with 444 additions and 98 deletions

View file

@ -181,8 +181,22 @@ body.home-page main > div {
/* Trail polyline hover effect */
.leaflet-interactive:hover {
stroke-width: 4;
stroke-width: 5;
stroke-opacity: 1 !important;
}
/* Historical trail lines */
.leaflet-interactive[stroke="#1E90FF"] {
stroke-width: 3;
stroke-opacity: 0.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.leaflet-interactive[stroke="#1E90FF"]:hover {
stroke-width: 5;
stroke-opacity: 1;
filter: drop-shadow(0 0 4px #1e90ff);
}
/* Position dot hover effect */
@ -210,3 +224,52 @@ body.home-page main > div {
background-color: #1e90ff;
color: white;
}
/* Historical dot marker styles */
.historical-dot-marker {
cursor: pointer;
transition: transform 0.2s ease-out;
}
.historical-dot-marker:hover {
transform: scale(1.5);
z-index: 1000 !important;
}
.historical-dot-marker div {
box-shadow: 0 0 4px rgba(0, 0, 0, 0.4);
transition: all 0.2s ease-out;
}
.historical-dot-marker:hover div {
box-shadow: 0 0 8px rgba(255, 107, 107, 0.8);
border-color: #ff6b6b !important;
}
/* Historical trail line styling */
.historical-trail-line {
stroke: #1e90ff;
stroke-width: 3;
stroke-opacity: 0.8;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
transition: all 0.2s ease-out;
z-index: 1;
}
.historical-trail-line:hover {
stroke-width: 5;
stroke-opacity: 1;
filter: drop-shadow(0 0 4px #1e90ff);
z-index: 2;
}
/* Ensure markers are above trail lines */
.leaflet-marker-icon {
z-index: 10 !important;
}
.historical-dot-marker {
z-index: 15 !important;
}

View file

@ -32,29 +32,62 @@ export class TrailManager {
}
}
addPosition(markerId: string, lat: number, lng: number, timestamp: number) {
addPosition(
markerId: string,
lat: number,
lng: number,
timestamp: number,
isHistoricalDot: boolean = false,
) {
if (!this.showTrails) return;
let trailState = this.trails.get(markerId);
// Extract base callsign from markerId to group positions by callsign
const baseCallsign = this.extractBaseCallsign(markerId);
let trailState = this.trails.get(baseCallsign);
if (!trailState) {
trailState = { positions: [] };
this.trails.set(markerId, trailState);
this.trails.set(baseCallsign, trailState);
}
// Only add if position is different from the last one
// Only add if position is different from the last one (more than ~1 meter)
const lastPos = trailState.positions[trailState.positions.length - 1];
if (!lastPos || Math.abs(lastPos.lat - lat) > 0.0001 || Math.abs(lastPos.lng - lng) > 0.0001) {
if (
!lastPos ||
Math.abs(lastPos.lat - lat) > 0.00001 ||
Math.abs(lastPos.lng - lng) > 0.00001
) {
trailState.positions.push({ lat, lng, timestamp });
}
// Filter positions to keep only recent ones
const cutoffTime = Date.now() - this.trailDuration;
trailState.positions = trailState.positions.filter((pos) => pos.timestamp >= cutoffTime);
// For historical dots, keep all positions. For live positions, filter by time.
if (!isHistoricalDot) {
const cutoffTime = Date.now() - this.trailDuration;
trailState.positions = trailState.positions.filter((pos) => {
// Ensure timestamp is a number for comparison
const posTimestamp =
typeof pos.timestamp === "string" ? new Date(pos.timestamp).getTime() : pos.timestamp;
return posTimestamp >= cutoffTime;
});
}
this.updateTrailVisualization(markerId, trailState);
// Always update trail visualization
this.updateTrailVisualization(baseCallsign, trailState);
}
private updateTrailVisualization(markerId: string, trailState: TrailState) {
private extractBaseCallsign(markerId: string | any): string {
// Ensure markerId is a string
const id = typeof markerId === "string" ? markerId : String(markerId);
// Extract base callsign from various ID formats
if (id.startsWith("hist_")) {
// Remove hist_ prefix and any trailing index
return id.replace(/^hist_/, "").replace(/_\d+$/, "");
}
return id;
}
private updateTrailVisualization(baseCallsign: string, trailState: TrailState) {
// Remove old trail and dots
if (trailState.trail) {
this.trailLayer.removeLayer(trailState.trail);
@ -69,45 +102,25 @@ export class TrailManager {
const L = (window as any).L;
const latLngs = trailState.positions.map((pos) => [pos.lat, pos.lng]);
// Create polyline for the trail
// Create blue polyline connecting the historical position dots
trailState.trail = L.polyline(latLngs, {
color: "#1E90FF",
weight: 3,
opacity: 0.5,
opacity: 0.8,
smoothFactor: 1,
lineCap: "round",
lineJoin: "round",
className: "historical-trail-line",
}).addTo(this.trailLayer);
// Create dots for each position
trailState.dots = trailState.positions.map((pos, index) => {
const age = (Date.now() - pos.timestamp) / this.trailDuration;
const opacity = Math.max(0.4, 1 - age * 0.6);
const isCurrentPosition = index === trailState.positions.length - 1;
const radius = isCurrentPosition ? 5 : 3;
const dot = L.circleMarker([pos.lat, pos.lng], {
radius: radius,
fillColor: "#FF4500",
color: "#8B0000",
weight: 1,
opacity: opacity,
fillOpacity: opacity * 0.8,
});
// Add tooltip with timestamp
const time = new Date(pos.timestamp);
dot.bindTooltip(time.toLocaleTimeString(), {
permanent: false,
direction: "top",
className: "trail-tooltip",
});
return dot.addTo(this.trailLayer);
});
// Don't create additional dots here since historical positions are now shown as markers
trailState.dots = [];
}
}
removeTrail(markerId: string) {
const trailState = this.trails.get(markerId);
const baseCallsign = this.extractBaseCallsign(markerId);
const trailState = this.trails.get(baseCallsign);
if (trailState) {
if (trailState.trail) {
this.trailLayer.removeLayer(trailState.trail);
@ -115,7 +128,7 @@ export class TrailManager {
if (trailState.dots) {
trailState.dots.forEach((dot) => this.trailLayer.removeLayer(dot));
}
this.trails.delete(markerId);
this.trails.delete(baseCallsign);
}
}
@ -126,12 +139,21 @@ export class TrailManager {
}
cleanupOldPositions() {
const cutoffTime = Date.now() - this.trailDuration;
// Don't clean up historical positions - only clean up very old live positions
const veryOldCutoff = Date.now() - 24 * 60 * 60 * 1000; // 24 hours
this.trails.forEach((trailState, markerId) => {
trailState.positions = trailState.positions.filter((pos) => pos.timestamp >= cutoffTime);
const originalLength = trailState.positions.length;
trailState.positions = trailState.positions.filter((pos) => {
// Ensure timestamp is a number for comparison
const posTimestamp =
typeof pos.timestamp === "string" ? new Date(pos.timestamp).getTime() : pos.timestamp;
// Keep all positions newer than 24 hours (includes all historical data we care about)
return posTimestamp >= veryOldCutoff;
});
if (trailState.positions.length === 0) {
this.removeTrail(markerId);
} else {
} else if (trailState.positions.length !== originalLength) {
this.updateTrailVisualization(markerId, trailState);
}
});

View file

@ -39,6 +39,8 @@ interface MarkerData {
historical?: boolean;
color?: string;
timestamp?: number;
is_most_recent_for_callsign?: boolean;
callsign_group?: string;
}
interface BoundsData {
@ -60,6 +62,8 @@ interface MarkerState {
symbol_code: string;
popup?: string;
historical?: boolean;
is_most_recent_for_callsign?: boolean;
callsign_group?: string;
}
interface MapEventData {
@ -536,11 +540,41 @@ let MapAPRSMap = {
// Handle bulk loading of historical packets
self.handleEvent("add_historical_packets", (data: { packets: MarkerData[] }) => {
if (data.packets && Array.isArray(data.packets)) {
// Group packets by callsign to process them in chronological order for proper trail drawing
const packetsByCallsign = new Map<string, MarkerData[]>();
data.packets.forEach((packet) => {
self.addMarker({
...packet,
historical: true,
popup: self.buildPopupContent(packet),
const callsign = packet.callsign_group || packet.callsign || packet.id;
if (!packetsByCallsign.has(callsign)) {
packetsByCallsign.set(callsign, []);
}
packetsByCallsign.get(callsign)!.push(packet);
});
// Process each callsign group in chronological order (oldest first)
packetsByCallsign.forEach((packets, callsign) => {
// Sort by timestamp (oldest first) to ensure proper trail line drawing
const sortedPackets = packets.sort((a, b) => {
const timeA = a.timestamp
? typeof a.timestamp === "number"
? a.timestamp
: new Date(a.timestamp).getTime()
: 0;
const timeB = b.timestamp
? typeof b.timestamp === "number"
? b.timestamp
: new Date(b.timestamp).getTime()
: 0;
return timeA - timeB;
});
// Add markers in chronological order
sortedPackets.forEach((packet) => {
self.addMarker({
...packet,
historical: true,
popup: self.buildPopupContent(packet),
});
});
});
}
@ -561,7 +595,7 @@ let MapAPRSMap = {
const markersToRemove: string[] = [];
self.markers!.forEach((marker: any, id: any) => {
if ((marker as any)._isHistorical) {
markersToRemove.push(id);
markersToRemove.push(String(id));
}
});
markersToRemove.forEach((id) => self.removeMarker(id));
@ -643,7 +677,13 @@ let MapAPRSMap = {
if (positionChanged && self.trailManager) {
// Position changed, update trail
self.trailManager.addPosition(data.id, lat, lng, data.timestamp || Date.now());
const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign;
const timestamp = data.timestamp
? typeof data.timestamp === "string"
? new Date(data.timestamp).getTime()
: data.timestamp
: Date.now();
self.trailManager.addPosition(data.id, lat, lng, timestamp, isHistoricalDot);
}
if (!positionChanged && !dataChanged) {
@ -659,7 +699,7 @@ let MapAPRSMap = {
// Remove existing marker if it exists
self.removeMarker(data.id);
// Create marker using APRS symbol icon
// Create marker - use simple dot for older historical positions, APRS icon for most recent
const marker = L.marker([lat, lng], {
icon: self.createMarkerIcon(data),
});
@ -697,11 +737,19 @@ let MapAPRSMap = {
symbol_code: data.symbol_code || ">",
popup: data.popup,
historical: data.historical,
is_most_recent_for_callsign: data.is_most_recent_for_callsign,
callsign_group: data.callsign_group,
});
// Initialize trail for new marker
// Initialize trail for new marker - always add to trail for line drawing
if (self.trailManager) {
self.trailManager.addPosition(data.id, lat, lng, data.timestamp || Date.now());
const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign;
const timestamp = data.timestamp
? typeof data.timestamp === "string"
? new Date(data.timestamp).getTime()
: data.timestamp
: Date.now();
self.trailManager.addPosition(data.id, lat, lng, timestamp, isHistoricalDot);
}
// Open popup if requested
@ -710,18 +758,21 @@ let MapAPRSMap = {
}
},
removeMarker(id: string) {
removeMarker(id: string | any) {
const self = this as unknown as LiveViewHookContext;
const marker = self.markers!.get(id);
// Ensure id is a string
const markerId = typeof id === "string" ? id : String(id);
const marker = self.markers!.get(markerId);
if (marker) {
self.markerLayer!.removeLayer(marker);
self.markers!.delete(id);
self.markerStates!.delete(id);
self.markers!.delete(markerId);
self.markerStates!.delete(markerId);
}
// Remove trail
if (self.trailManager) {
self.trailManager.removeTrail(id);
self.trailManager.removeTrail(markerId);
}
},
@ -743,7 +794,13 @@ let MapAPRSMap = {
if (positionChanged) {
existingMarker.setLatLng([lat, lng]);
if (self.trailManager) {
self.trailManager.addPosition(data.id, lat, lng, data.timestamp || Date.now());
const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign;
const timestamp = data.timestamp
? typeof data.timestamp === "string"
? new Date(data.timestamp).getTime()
: data.timestamp
: Date.now();
self.trailManager.addPosition(data.id, lat, lng, timestamp, isHistoricalDot);
}
}
}
@ -802,10 +859,32 @@ let MapAPRSMap = {
});
// Remove out-of-bounds markers
markersToRemove.forEach((id) => self.removeMarker(id));
markersToRemove.forEach((id) => self.removeMarker(String(id)));
},
createMarkerIcon(data: MarkerData): L.DivIcon {
// For historical packets that are not the most recent for their callsign,
// show a simple red dot (only positions where lat/lon actually changed)
if (data.historical && !data.is_most_recent_for_callsign) {
const iconHtml = `<div style="
width: 8px;
height: 8px;
background-color: #FF6B6B;
border: 2px solid #FFFFFF;
border-radius: 50%;
opacity: 0.8;
box-shadow: 0 0 2px rgba(0,0,0,0.3);
" title="Historical position for ${data.callsign} (position changed)"></div>`;
return L.divIcon({
html: iconHtml,
className: "historical-dot-marker",
iconSize: [12, 12],
iconAnchor: [6, 6],
});
}
// For current packets or most recent historical packets, show the full APRS symbol icon
const symbolTableId = data.symbol_table_id || "/";
const symbolCode = getValidSymbolCode(data.symbol_code, symbolTableId);
@ -841,7 +920,7 @@ let MapAPRSMap = {
background-size: 512px 192px;
background-repeat: no-repeat;
image-rendering: pixelated;
opacity: ${data.historical ? "0.7" : "1.0"};
opacity: ${data.historical && data.is_most_recent_for_callsign ? "0.9" : "1.0"};
overflow: hidden;
" data-symbol-table="${symbolTableId}" data-symbol-code="${symbolCode}" data-sprite-position="${x},${y}" data-expected-index="${index}" title="Symbol: ${symbolCode} (${charCode}) at row ${row}, col ${column}"></div>`;

View file

@ -720,21 +720,94 @@ defmodule AprsWeb.MapLive.CallsignView do
now
)
socket =
if Enum.empty?(packets) do
# No historical packets found
socket
else
# Clear any previous historical packets from the map
socket = push_event(socket, "clear_historical_packets", %{})
# Sort packets by inserted_at to identify the most recent
# Convert NaiveDateTime to DateTime if needed for proper comparison
sorted_packets =
Enum.sort_by(
packets,
fn packet ->
case packet.inserted_at do
%NaiveDateTime{} = naive_dt ->
DateTime.from_naive!(naive_dt, "Etc/UTC")
%DateTime{} = dt ->
dt
_other ->
DateTime.utc_now()
end
end,
{:desc, DateTime}
)
# Filter out packets with unchanged positions (only keep if lat/lon changed)
unique_position_packets = filter_unique_positions(sorted_packets)
# Build packet data for all positions, marking which is the most recent
packet_data_list =
unique_position_packets
|> Enum.with_index()
|> Enum.map(fn {packet, index} ->
case build_packet_data(packet) do
nil ->
nil
packet_data ->
# Generate a unique ID for this historical packet
packet_id =
"hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}_#{index}"
is_most_recent = index == 0
callsign = socket.assigns.callsign
packet_data
|> Map.put("id", packet_id)
|> Map.put("is_historical", true)
|> Map.put("is_most_recent_for_callsign", is_most_recent)
|> Map.put("callsign_group", callsign)
|> Map.put(
"timestamp",
case packet.inserted_at do
%NaiveDateTime{} = naive_dt ->
DateTime.to_unix(DateTime.from_naive!(naive_dt, "Etc/UTC"), :millisecond)
%DateTime{} = dt ->
DateTime.to_unix(dt, :millisecond)
_other ->
DateTime.to_unix(DateTime.utc_now(), :millisecond)
end
)
end
end)
|> Enum.filter(& &1)
# Send all historical packets at once
socket = push_event(socket, "add_historical_packets", %{packets: packet_data_list})
# Store historical packets in assigns for reference
historical_packets_map =
packet_data_list
|> Enum.zip(unique_position_packets)
|> Enum.reduce(%{}, fn {packet_data, packet}, acc ->
Map.put(acc, packet_data["id"], packet)
end)
assign(socket,
replay_packets: packets,
historical_packets: historical_packets_map,
replay_packets: [],
replay_index: 0,
replay_start_time: one_hour_ago,
replay_end_time: now,
historical_packets: %{}
replay_end_time: now
)
# Start replay immediately if we have packets
if length(packets) > 0 do
Process.send_after(self(), :replay_next_packet, 100)
end
socket
end
defp fetch_historical_packets_for_callsign(callsign, start_time, end_time) do
@ -746,6 +819,43 @@ defmodule AprsWeb.MapLive.CallsignView do
})
end
# Filter packets to only include those with unique positions (lat/lon changed)
@spec filter_unique_positions([struct()]) :: [struct()]
defp filter_unique_positions(packets) do
packets
|> Enum.reduce([], fn packet, acc ->
{lat, lng, _} = MapHelpers.get_coordinates(packet)
if lat && lng do
# Check if this position is different from the last position
case acc do
[] ->
# First packet, always include
[packet | acc]
[last_packet | _] ->
if position_changed?(packet, last_packet) do
[packet | acc]
else
acc
end
end
else
acc
end
end)
|> Enum.reverse()
end
# Check if position changed significantly between two packets (more than ~1 meter)
@spec position_changed?(struct(), struct()) :: boolean()
defp position_changed?(packet1, packet2) do
{lat1, lng1, _} = MapHelpers.get_coordinates(packet1)
{lat2, lng2, _} = MapHelpers.get_coordinates(packet2)
abs(lat1 - lat2) > 0.00001 || abs(lng1 - lng2) > 0.00001
end
defp build_packet_data(packet) do
{lat, lng, _} = MapHelpers.get_coordinates(packet)
callsign = Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))

View file

@ -594,43 +594,79 @@ defmodule AprsWeb.MapLive.Index do
# Clear any previous historical packets from the map
socket = push_event(socket, "clear_historical_packets", %{})
# Group packets by callsign and get only the most recent for each
latest_packets_by_callsign =
# Group packets by callsign-ssid and process all positions
packet_data_list =
historical_packets
|> Enum.group_by(&generate_callsign/1)
|> Enum.map(fn {_callsign, packets} ->
# Sort by received_at descending and take the most recent
Enum.max_by(packets, & &1.received_at, DateTime)
|> Enum.flat_map(fn {callsign, packets} ->
# Sort packets by inserted_at to identify the most recent
# Convert NaiveDateTime to DateTime if needed for proper comparison
sorted_packets =
Enum.sort_by(
packets,
fn packet ->
case packet.inserted_at do
%NaiveDateTime{} = naive_dt ->
DateTime.from_naive!(naive_dt, "Etc/UTC")
%DateTime{} = dt ->
dt
_other ->
DateTime.utc_now()
end
end,
{:desc, DateTime}
)
# Filter out packets with unchanged positions (only keep if lat/lon changed)
unique_position_packets = filter_unique_positions(sorted_packets)
# Process all packets with unique positions, marking which is the most recent
unique_position_packets
|> Enum.with_index()
|> Enum.map(fn {packet, index} ->
case build_packet_data(packet) do
nil ->
nil
packet_data ->
# Generate a unique ID for this historical packet
packet_id =
"hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}_#{index}"
is_most_recent = index == 0
packet_data
|> Map.put("id", packet_id)
|> Map.put("is_historical", true)
|> Map.put("is_most_recent_for_callsign", is_most_recent)
|> Map.put("callsign_group", callsign)
|> Map.put(
"timestamp",
case packet.inserted_at do
%NaiveDateTime{} = naive_dt ->
DateTime.to_unix(DateTime.from_naive!(naive_dt, "Etc/UTC"), :millisecond)
%DateTime{} = dt ->
DateTime.to_unix(dt, :millisecond)
_other ->
DateTime.to_unix(DateTime.utc_now(), :millisecond)
end
)
end
end)
|> Enum.filter(& &1)
end)
# Build packet data for all latest packets
packet_data_list =
latest_packets_by_callsign
|> Enum.map(fn packet ->
case build_packet_data(packet) do
nil ->
nil
packet_data ->
# Generate a unique ID for this historical packet
packet_id =
"hist_#{if Map.has_key?(packet, :id), do: packet.id, else: System.unique_integer([:positive])}"
packet_data
|> Map.put("id", packet_id)
|> Map.put("is_historical", true)
end
end)
# Remove nil values
|> Enum.filter(& &1)
# Send all historical packets at once
socket = push_event(socket, "add_historical_packets", %{packets: packet_data_list})
# Store historical packets in assigns for reference
historical_packets_map =
packet_data_list
|> Enum.zip(latest_packets_by_callsign)
|> Enum.zip(historical_packets)
|> Enum.reduce(%{}, fn {packet_data, packet}, acc ->
Map.put(acc, packet_data["id"], packet)
end)
@ -645,6 +681,42 @@ defmodule AprsWeb.MapLive.Index do
end
end
# Filter packets to only include those with unique positions (lat/lon changed)
@spec filter_unique_positions([struct()]) :: [struct()]
defp filter_unique_positions(packets) do
packets
|> Enum.reduce([], fn packet, acc ->
{lat, lng, _} = MapHelpers.get_coordinates(packet)
if lat && lng do
case acc do
[] ->
# First packet, always include
[packet | acc]
[last_packet | _] ->
if position_changed?(packet, last_packet) do
[packet | acc]
else
acc
end
end
else
acc
end
end)
|> Enum.reverse()
end
# Check if position changed significantly between two packets (more than ~1 meter)
@spec position_changed?(struct(), struct()) :: boolean()
defp position_changed?(packet1, packet2) do
{lat1, lng1, _} = MapHelpers.get_coordinates(packet1)
{lat2, lng2, _} = MapHelpers.get_coordinates(packet2)
abs(lat1 - lat2) > 0.00001 || abs(lng1 - lng2) > 0.00001
end
# Fetch historical packets from the database
@spec fetch_historical_packets(list(), DateTime.t(), DateTime.t()) :: [struct()]
defp fetch_historical_packets(bounds, start_time, end_time) do