historical packets
This commit is contained in:
parent
d6ac5cccfc
commit
8fdafcf01e
5 changed files with 444 additions and 98 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
113
assets/js/map.ts
113
assets/js/map.ts
|
|
@ -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>`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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", ""))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue