additional map fix

This commit is contained in:
Graham McIntire 2025-06-16 15:16:15 -05:00
parent c5d7a32b60
commit 389f8aa5ed
No known key found for this signature in database
7 changed files with 624 additions and 90 deletions

View file

@ -127,14 +127,7 @@ let MinimalAPRSMap = {
maxZoom: 19,
});
tileLayer.addTo(this.map);
console.log("Tile layer added successfully");
// Listen for tile layer events
tileLayer.on("loading", () => console.log("Tiles loading..."));
tileLayer.on("load", () => console.log("Tiles loaded"));
tileLayer.on("tileerror", (e) => console.error("Tile error:", e));
} catch (error) {
console.error("Error adding tile layer:", error);
this.errors.push("Tile layer failed: " + error.message);
}
@ -142,22 +135,20 @@ let MinimalAPRSMap = {
this.markers = new Map();
this.markerLayer = L.layerGroup().addTo(this.map);
// Track marker states to prevent unnecessary operations
this.markerStates = new Map();
// Force initial size calculation
try {
this.map.invalidateSize();
console.log("Map size invalidated");
} catch (error) {
console.error("Error invalidating map size:", error);
}
// Track when map is ready
this.map.whenReady(() => {
console.log("Minimal map is ready");
console.log("Map container size:", this.map.getSize());
console.log("Map zoom:", this.map.getZoom());
console.log("Map center:", this.map.getCenter());
try {
this.lastZoom = this.map.getZoom();
this.pushEvent("map_ready", {});
this.sendBoundsToServer();
} catch (error) {
@ -173,9 +164,28 @@ let MinimalAPRSMap = {
}, 300);
});
// Handle zoom changes with optimization for large zoom differences
this.map.on("zoomend", () => {
if (this.boundsTimer) clearTimeout(this.boundsTimer);
this.boundsTimer = setTimeout(() => {
const currentZoom = this.map.getZoom();
const zoomDifference = this.lastZoom ? Math.abs(currentZoom - this.lastZoom) : 0;
// If zoom changed significantly (more than 2 levels), clear and reload
if (zoomDifference > 2) {
console.log(
`Large zoom change detected (${zoomDifference} levels), clearing and reloading markers`,
);
this.pushEvent("clear_and_reload_markers", {});
}
this.sendBoundsToServer();
this.lastZoom = currentZoom;
}, 300);
});
// Handle resize
this.resizeHandler = () => {
console.log("Window resized, invalidating map size");
try {
if (this.map) {
this.map.invalidateSize();
@ -188,13 +198,10 @@ let MinimalAPRSMap = {
// Add a delayed size check
setTimeout(() => {
console.log("Delayed map size check");
const rect = this.el.getBoundingClientRect();
console.log("Map element dimensions after delay:", rect);
if (this.map) {
try {
this.map.invalidateSize();
console.log("Map size re-invalidated after delay");
} catch (error) {
console.error("Error re-invalidating map size:", error);
}
@ -265,8 +272,6 @@ let MinimalAPRSMap = {
// Zoom to location
this.handleEvent("zoom_to_location", (data) => {
console.log("Zoom to location event received:", data);
if (!this.map) {
console.error("Map not initialized, cannot zoom");
return;
@ -293,7 +298,6 @@ let MinimalAPRSMap = {
try {
// Check element dimensions before zoom
const beforeRect = this.el.getBoundingClientRect();
console.log("Element dimensions before zoom:", beforeRect);
// Force map size recalculation before zoom
this.map.invalidateSize();
@ -305,7 +309,6 @@ let MinimalAPRSMap = {
animate: true,
duration: 1,
});
console.log("Zoom completed successfully");
// Check element dimensions after zoom
setTimeout(() => {
@ -369,9 +372,12 @@ let MinimalAPRSMap = {
// Handle refresh markers event
this.handleEvent("refresh_markers", () => {
// Remove markers for packets that are no longer visible
// This could be implemented to clean up old markers based on timestamp
console.log("Refresh markers event received");
// Remove markers that are outside current bounds
if (this.map) {
const bounds = this.map.getBounds();
this.removeMarkersOutsideBounds(bounds);
}
console.log("Refresh markers event received - cleaned up out-of-bounds markers");
});
// Handle clearing historical packets
@ -385,6 +391,24 @@ let MinimalAPRSMap = {
});
markersToRemove.forEach((id) => this.removeMarker(id));
});
// Handle bounds-based marker filtering
this.handleEvent("filter_markers_by_bounds", (data) => {
if (data.bounds) {
// Create Leaflet bounds object from server data
const bounds = L.latLngBounds(
[data.bounds.south, data.bounds.west],
[data.bounds.north, data.bounds.east],
);
this.removeMarkersOutsideBounds(bounds);
}
});
// Handle clearing all markers and reloading visible ones
this.handleEvent("clear_and_reload_markers", () => {
console.log("Clear and reload markers event received");
// This event is just a trigger - the server will handle clearing and adding markers
});
},
sendBoundsToServer() {
@ -394,6 +418,9 @@ let MinimalAPRSMap = {
const center = this.map.getCenter();
const zoom = this.map.getZoom();
// Remove markers that are now outside the visible bounds
this.removeMarkersOutsideBounds(bounds);
this.pushEvent("bounds_changed", {
bounds: {
north: bounds.getNorth(),
@ -424,6 +451,26 @@ let MinimalAPRSMap = {
return;
}
// Check if marker already exists with same position and data
const existingMarker = this.markers.get(data.id);
const existingState = this.markerStates.get(data.id);
if (existingMarker && existingState) {
// Check if marker needs updating
const currentPos = existingMarker.getLatLng();
const positionChanged =
Math.abs(currentPos.lat - lat) > 0.0001 || Math.abs(currentPos.lng - lng) > 0.0001;
const dataChanged =
existingState.symbol_table !== data.symbol_table ||
existingState.symbol_code !== data.symbol_code ||
existingState.popup !== data.popup;
if (!positionChanged && !dataChanged) {
// No changes needed, skip update
return;
}
}
// Remove existing marker if it exists
this.removeMarker(data.id);
@ -456,6 +503,16 @@ let MinimalAPRSMap = {
// Add to map and store reference
marker.addTo(this.markerLayer);
this.markers.set(data.id, marker);
// Store marker state for optimization
this.markerStates.set(data.id, {
lat: lat,
lng: lng,
symbol_table: data.symbol_table,
symbol_code: data.symbol_code,
popup: data.popup,
historical: data.historical,
});
},
removeMarker(id) {
@ -463,6 +520,7 @@ let MinimalAPRSMap = {
if (marker) {
this.markerLayer.removeLayer(marker);
this.markers.delete(id);
this.markerStates.delete(id);
}
},
@ -499,6 +557,42 @@ let MinimalAPRSMap = {
clearAllMarkers() {
this.markerLayer.clearLayers();
this.markers.clear();
this.markerStates.clear();
},
removeMarkersOutsideBounds(bounds) {
if (!bounds || !this.markers) return;
const markersToRemove = [];
this.markers.forEach((marker, id) => {
const position = marker.getLatLng();
const lat = position.lat;
const lng = position.lng;
// Check latitude bounds (straightforward)
const latOutOfBounds = lat < bounds.getSouth() || lat > bounds.getNorth();
// Check longitude bounds (handle potential wrapping)
let lngOutOfBounds;
if (bounds.getWest() <= bounds.getEast()) {
// Normal case: bounds don't cross antimeridian
lngOutOfBounds = lng < bounds.getWest() || lng > bounds.getEast();
} else {
// Bounds cross antimeridian (e.g., west=170, east=-170)
lngOutOfBounds = lng < bounds.getWest() && lng > bounds.getEast();
}
// Check if marker is outside the current bounds
if (latOutOfBounds || lngOutOfBounds) {
markersToRemove.push(id);
}
});
// Remove out-of-bounds markers
markersToRemove.forEach((id) => this.removeMarker(id));
console.log(`Removed ${markersToRemove.length} markers outside bounds`);
},
createMarkerIcon(data) {
@ -553,7 +647,7 @@ let MinimalAPRSMap = {
const symbolDesc = data.symbol_description || "Unknown symbol";
let content = `<div class="aprs-popup">
<div class="aprs-callsign"><strong>${callsign}</strong></div>
<div class="aprs-callsign"><strong><a href="/${callsign}">${callsign}</a></strong></div>
<div class="aprs-symbol-info">${symbolDesc}</div>`;
if (comment) {
@ -592,6 +686,10 @@ let MinimalAPRSMap = {
this.markers.clear();
}
if (this.markerStates) {
this.markerStates.clear();
}
// Clean up map
if (this.map) {
this.map.remove();

View file

@ -23,12 +23,9 @@ defmodule Aprs.EncodingUtils do
"HelloWorld"
"""
def sanitize_string(binary) when is_binary(binary) do
if String.valid?(binary) do
binary
else
# Replace invalid UTF-8 sequences with replacement character or remove them
scrub_invalid_utf8(binary)
end
# Always scrub problematic bytes, even from valid UTF-8 strings
# PostgreSQL rejects null bytes and other control characters even if they're valid UTF-8
scrub_problematic_bytes(binary)
end
def sanitize_string(nil), do: nil
@ -81,22 +78,52 @@ defmodule Aprs.EncodingUtils do
# Private helper functions
defp scrub_invalid_utf8(binary) do
binary
|> :binary.bin_to_list()
|> Enum.filter(&valid_byte?/1)
|> Enum.map(&scrub_byte/1)
|> :binary.list_to_bin()
|> String.trim()
defp scrub_problematic_bytes(binary) do
# Handle both invalid UTF-8 sequences and problematic control characters
cleaned =
if String.valid?(binary) do
# String is valid UTF-8, just remove control characters
binary
# Remove null bytes
|> String.replace(<<0>>, "")
# Remove other control chars
|> String.replace(~r/[\x01-\x08\x0B\x0C\x0E-\x1F\x7F]/, "")
else
# String has invalid UTF-8, filter byte by byte
binary
|> :binary.bin_to_list()
|> Enum.filter(&valid_utf8_byte?/1)
|> :binary.list_to_bin()
|> ensure_valid_utf8()
end
String.trim(cleaned)
end
# Check if byte should be kept
defp valid_byte?(byte) when byte >= 32 and byte <= 126, do: true
defp valid_byte?(byte) when byte in [9, 10, 13], do: true
defp valid_byte?(_byte), do: false
# Check if byte should be kept (ASCII printable + safe whitespace)
defp valid_utf8_byte?(byte) when byte >= 32 and byte <= 126, do: true
defp valid_utf8_byte?(byte) when byte in [9, 10, 13], do: true
defp valid_utf8_byte?(_), do: false
# Keep valid bytes as-is
defp scrub_byte(byte), do: byte
# Ensure the final result is valid UTF-8
defp ensure_valid_utf8(binary) do
if String.valid?(binary) do
binary
else
# If still invalid, try to convert to valid UTF-8
case :unicode.characters_to_binary(binary, :latin1, :utf8) do
result when is_binary(result) ->
result
_ ->
# Last resort: keep only ASCII
binary
|> :binary.bin_to_list()
|> Enum.filter(fn byte -> byte >= 32 and byte <= 126 end)
|> :binary.list_to_bin()
end
end
end
@doc """
Converts a binary to a hex string representation for debugging.

View file

@ -108,8 +108,6 @@ defmodule Aprs.Packet do
:base_callsign,
:data_type,
:destination,
:information_field,
:path,
:sender,
:ssid,
:received_at

View file

@ -5,6 +5,7 @@ defmodule Aprs.Packets do
import Ecto.Query, warn: false
alias Aprs.EncodingUtils
alias Aprs.Packet
alias Aprs.Repo
@ -30,6 +31,9 @@ defmodule Aprs.Packets do
packet_data
end
# Sanitize all string fields to prevent UTF-8 encoding errors
packet_attrs = sanitize_packet_strings(packet_attrs)
# Convert data_type to string if it's an atom
packet_attrs =
case packet_attrs do
@ -58,7 +62,7 @@ defmodule Aprs.Packets do
|> Map.put(:has_position, true)
|> Map.put(:region, "#{Float.round(lat, 1)},#{Float.round(lon, 1)}")
else
Logger.warning("Invalid coordinates for packet from #{packet_attrs[:sender]}: lat=#{lat}, lon=#{lon}")
Logger.debug("Invalid coordinates for packet from #{packet_attrs[:sender]}: lat=#{lat}, lon=#{lon}")
# Set region based on callsign if coordinates are invalid
sender_region = if packet_attrs[:sender], do: String.slice(packet_attrs.sender || "", 0, 3), else: "unknown"
Map.put(packet_attrs, :region, "call:#{sender_region}")
@ -359,6 +363,65 @@ defmodule Aprs.Packets do
lat >= -90 and lat <= 90 and lon >= -180 and lon <= 180
end
# Helper to sanitize all string fields in packet data before database storage
defp sanitize_packet_strings(packet_attrs) when is_map(packet_attrs) do
packet_attrs
|> sanitize_field(:base_callsign, &sanitize_and_ensure_string/1)
|> sanitize_field(:data_type, &sanitize_and_ensure_string/1)
|> sanitize_field(:destination, &sanitize_and_ensure_string/1)
|> sanitize_required_field(:information_field)
|> sanitize_required_field(:path)
|> sanitize_field(:sender, &sanitize_and_ensure_string/1)
|> sanitize_field(:ssid, &sanitize_and_ensure_string/1)
|> sanitize_field(:region, &EncodingUtils.sanitize_string/1)
|> sanitize_field(:raw_packet, &EncodingUtils.sanitize_string/1)
|> sanitize_field(:symbol_code, &EncodingUtils.sanitize_string/1)
|> sanitize_field(:symbol_table_id, &EncodingUtils.sanitize_string/1)
|> sanitize_field(:comment, &EncodingUtils.sanitize_string/1)
|> sanitize_field(:timestamp, &EncodingUtils.sanitize_string/1)
|> sanitize_field(:manufacturer, &EncodingUtils.sanitize_string/1)
|> sanitize_field(:equipment_type, &EncodingUtils.sanitize_string/1)
|> sanitize_field(:addressee, &EncodingUtils.sanitize_string/1)
|> sanitize_field(:message_text, &EncodingUtils.sanitize_string/1)
|> sanitize_field(:message_number, &EncodingUtils.sanitize_string/1)
|> sanitize_data_extended_field()
end
# Helper to sanitize a field if it exists
defp sanitize_field(map, key, sanitizer_func) do
case Map.get(map, key) do
nil -> map
value -> Map.put(map, key, sanitizer_func.(value))
end
end
# Helper to sanitize required fields, ensuring they exist and are not nil
defp sanitize_required_field(map, key) do
value = Map.get(map, key)
sanitized_value = sanitize_required_string(value)
Map.put(map, key, sanitized_value)
end
# Helper to sanitize and ensure required string fields are never nil
defp sanitize_required_string(nil), do: ""
defp sanitize_required_string(value), do: EncodingUtils.sanitize_string(value)
# Helper to sanitize and ensure non-required string fields
defp sanitize_and_ensure_string(nil), do: nil
defp sanitize_and_ensure_string(value), do: EncodingUtils.sanitize_string(value)
# Helper to sanitize the data_extended field
defp sanitize_data_extended_field(packet_attrs) do
case packet_attrs do
%{data_extended: data_extended} when not is_nil(data_extended) ->
sanitized_data_extended = EncodingUtils.sanitize_data_extended(data_extended)
Map.put(packet_attrs, :data_extended, sanitized_data_extended)
_ ->
packet_attrs
end
end
# Get packets from last hour only - used to initialize the map
def get_last_hour_packets do
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)

View file

@ -13,7 +13,6 @@ defmodule AprsWeb.MapLive.CallsignView do
def mount(%{"callsign" => callsign}, _session, socket) do
# Normalize callsign to uppercase
normalized_callsign = String.upcase(callsign)
IO.puts("CallsignView: Mounting for callsign #{normalized_callsign}")
# Calculate one hour ago for packet age filtering
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
@ -60,12 +59,10 @@ defmodule AprsWeb.MapLive.CallsignView do
)
if connected?(socket) do
IO.puts("CallsignView: Socket connected, subscribing to messages")
Endpoint.subscribe("aprs_messages")
# Load recent packets for this callsign
socket = load_callsign_packets(socket, normalized_callsign)
IO.puts("CallsignView: Loaded packets, map_center: #{inspect(socket.assigns.map_center)}")
# Schedule regular cleanup of old packets from the map
Process.send_after(self(), :cleanup_old_packets, 60_000)
@ -74,7 +71,6 @@ defmodule AprsWeb.MapLive.CallsignView do
{:ok, socket}
else
IO.puts("CallsignView: Socket not connected")
{:ok, socket}
end
end
@ -143,16 +139,13 @@ defmodule AprsWeb.MapLive.CallsignView do
end
def handle_event("map_ready", _params, socket) do
IO.puts("CallsignView: Map ready event received")
socket = assign(socket, map_ready: true)
# Auto-start replay if it hasn't been started yet - TEMPORARILY DISABLED FOR DEBUGGING
socket =
if socket.assigns.replay_started do
IO.puts("CallsignView: Replay already started")
socket
else
IO.puts("CallsignView: Skipping replay start for debugging")
# socket = start_historical_replay(socket)
# assign(socket, replay_started: true, replay_active: true)
socket
@ -162,18 +155,15 @@ defmodule AprsWeb.MapLive.CallsignView do
socket =
if socket.assigns.pending_geolocation do
location = socket.assigns.pending_geolocation
IO.puts("CallsignView: Scheduling zoom to pending geolocation: #{inspect(location)}")
Process.send_after(self(), {:zoom_to_location, location.lat, location.lng, 12}, 1500)
socket
else
# If we have a last known position, zoom to it after a delay
if socket.assigns.last_known_position do
pos = socket.assigns.last_known_position
IO.puts("CallsignView: Scheduling zoom to last known position: #{inspect(pos)}")
Process.send_after(self(), {:zoom_to_location, pos.lat, pos.lng, 12}, 1500)
socket
else
IO.puts("CallsignView: No position data to zoom to")
socket
end
end
@ -206,7 +196,6 @@ defmodule AprsWeb.MapLive.CallsignView do
end
def handle_info({:zoom_to_location, lat, lng, zoom}, socket) do
IO.puts("CallsignView: Executing delayed zoom to #{lat}, #{lng} at zoom #{zoom}")
socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: zoom})
{:noreply, socket}
end
@ -853,7 +842,6 @@ defmodule AprsWeb.MapLive.CallsignView do
defp load_callsign_packets(socket, callsign) do
# Load recent packets (last hour) for this specific callsign
recent_packets = Packets.get_recent_packets(%{callsign: callsign})
IO.puts("CallsignView: Found #{length(recent_packets)} recent packets for #{callsign}")
# Find the most recent packet with position data for auto-zoom
last_known_position =
@ -863,7 +851,6 @@ defmodule AprsWeb.MapLive.CallsignView do
|> List.first()
|> case do
nil ->
IO.puts("CallsignView: No packets with position data found")
nil
packet ->
@ -871,11 +858,7 @@ defmodule AprsWeb.MapLive.CallsignView do
if lat && lng do
position = %{lat: lat, lng: lng}
IO.puts("CallsignView: Last known position: #{inspect(position)}")
position
else
IO.puts("CallsignView: Could not extract coordinates from packet")
nil
end
end
@ -887,15 +870,11 @@ defmodule AprsWeb.MapLive.CallsignView do
|> Stream.filter(&(&1 != nil))
|> Enum.to_list()
IO.puts("CallsignView: Converted #{length(packet_data_list)} packets to marker data")
# Send packets to map if any exist
socket =
if length(packet_data_list) > 0 do
IO.puts("CallsignView: Sending markers to map")
push_event(socket, "add_markers", %{markers: packet_data_list})
else
IO.puts("CallsignView: No markers to send to map")
socket
end

View file

@ -53,7 +53,11 @@ defmodule AprsWeb.MapLive.Index do
# Flag to prevent multiple replay starts
replay_started: false,
# Pending geolocation to zoom to after map is ready
pending_geolocation: nil
pending_geolocation: nil,
# Timer for debounced bounds updates
bounds_update_timer: nil,
# Pending bounds update data
pending_bounds: nil
)
if connected?(socket) do
@ -201,6 +205,41 @@ defmodule AprsWeb.MapLive.Index do
{:noreply, assign(socket, replay_speed: speed_float)}
end
@impl true
def handle_event("clear_and_reload_markers", _params, socket) do
# Clear all markers on the client first
socket = push_event(socket, "clear_markers", %{})
# Filter visible packets to only include those within current bounds
filtered_packets =
socket.assigns.visible_packets
|> Enum.filter(fn {_callsign, packet} ->
within_bounds?(packet, socket.assigns.map_bounds) &&
packet_within_time_threshold?(packet, socket.assigns.packet_age_threshold)
end)
|> Map.new()
# Convert filtered packets to packet data for the map
visible_packets_list =
filtered_packets
|> Enum.map(fn {_callsign, packet} -> build_packet_data(packet) end)
# Remove any nil packet data
|> Enum.filter(& &1)
# Update the state to only include the filtered packets
socket = assign(socket, visible_packets: filtered_packets)
# Add the visible markers back to the map
socket =
if Enum.any?(visible_packets_list) do
push_event(socket, "add_markers", %{markers: visible_packets_list})
else
socket
end
{:noreply, socket}
end
@impl true
def handle_event("map_ready", _params, socket) do
IO.puts("Map ready event received")
@ -229,21 +268,64 @@ defmodule AprsWeb.MapLive.Index do
west: bounds["west"]
}
# Don't clear markers - let the client preserve those still in view
# Recalculate packet count based on new bounds
visible_packets =
# Validate bounds to prevent invalid coordinates
if map_bounds.north > 90 or map_bounds.south < -90 or
map_bounds.north <= map_bounds.south do
# Invalid bounds, skip update
{:noreply, socket}
else
# Cancel any pending bounds update timer
if socket.assigns[:bounds_update_timer] do
Process.cancel_timer(socket.assigns.bounds_update_timer)
end
# Set a debounced update timer to prevent excessive processing
timer_ref = Process.send_after(self(), {:process_bounds_update, map_bounds}, 250)
socket = assign(socket, bounds_update_timer: timer_ref, pending_bounds: map_bounds)
{:noreply, socket}
end
end
defp process_bounds_update(map_bounds, socket) do
# Filter visible packets to only include those within the new bounds and time threshold
new_visible_packets =
socket.assigns.visible_packets
|> Enum.filter(fn {_callsign, packet} ->
within_bounds?(packet, map_bounds)
within_bounds?(packet, map_bounds) &&
packet_within_time_threshold?(packet, socket.assigns.packet_age_threshold)
end)
|> Map.new()
# Get packets that are no longer visible (to remove from map)
packets_to_remove =
socket.assigns.visible_packets
|> Enum.reject(fn {_callsign, packet} ->
within_bounds?(packet, map_bounds)
end)
|> Enum.map(fn {callsign, _packet} -> callsign end)
# Clear markers that are outside the new bounds
socket =
if Enum.any?(packets_to_remove) do
# Remove markers that are outside bounds
socket =
Enum.reduce(packets_to_remove, socket, fn callsign, acc_socket ->
push_event(acc_socket, "remove_marker", %{id: callsign})
end)
# Also send a general filter event to clean up any remaining out-of-bounds markers
push_event(socket, "filter_markers_by_bounds", %{bounds: map_bounds})
else
socket
end
# If replay is not active, update the replay packets based on the new bounds
socket =
if socket.assigns.replay_active do
socket
else
# Clear any existing replay data when bounds change
# Clear any existing replay data when bounds change significantly
socket =
assign(socket,
replay_packets: [],
@ -252,17 +334,25 @@ defmodule AprsWeb.MapLive.Index do
map_ready: true
)
# Don't automatically start replay on every bounds change
# We'll handle this in initialize_replay to avoid too many restarts
socket
end
{:noreply, assign(socket, map_bounds: map_bounds, visible_packets: visible_packets)}
assign(socket,
map_bounds: map_bounds,
visible_packets: new_visible_packets,
bounds_update_timer: nil,
pending_bounds: nil
)
end
@impl true
def handle_info(msg, socket) do
case msg do
{:process_bounds_update, map_bounds} ->
# Process the debounced bounds update
socket = process_bounds_update(map_bounds, socket)
{:noreply, socket}
{:delayed_zoom, %{lat: lat, lng: lng}} ->
IO.puts("Processing delayed zoom to lat=#{lat}, lng=#{lng}")
socket = push_event(socket, "zoom_to_location", %{lat: lat, lng: lng, zoom: 12})
@ -586,22 +676,49 @@ defmodule AprsWeb.MapLive.Index do
# Update the packet age threshold to current time minus one hour
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
# Filter out packets older than one hour
# Get packets that need to be removed (old or outside bounds)
packets_to_remove =
socket.assigns.visible_packets
|> Enum.reject(fn {_key, packet} ->
packet_within_time_threshold?(packet, one_hour_ago) &&
within_bounds?(packet, socket.assigns.map_bounds)
end)
|> Enum.map(fn {callsign, _packet} -> callsign end)
# Filter out packets older than one hour and outside bounds
visible_packets =
socket.assigns.visible_packets
|> Enum.filter(fn {_key, packet} ->
packet_within_time_threshold?(packet, one_hour_ago)
packet_within_time_threshold?(packet, one_hour_ago) &&
within_bounds?(packet, socket.assigns.map_bounds)
end)
|> Map.new()
# Push event to remove old markers from the map
# Only update if there are actual changes to prevent unnecessary events
socket =
socket
|> push_event("refresh_markers", %{})
|> assign(
visible_packets: visible_packets,
packet_age_threshold: one_hour_ago
)
if map_size(visible_packets) == map_size(socket.assigns.visible_packets) do
# Just update the threshold without map changes
assign(socket, packet_age_threshold: one_hour_ago)
# Remove old and out-of-bounds markers from the map
# Explicitly remove markers for packets that are no longer valid
else
socket =
socket
|> push_event("refresh_markers", %{})
|> assign(
visible_packets: visible_packets,
packet_age_threshold: one_hour_ago
)
if Enum.any?(packets_to_remove) do
Enum.reduce(packets_to_remove, socket, fn callsign, acc_socket ->
push_event(acc_socket, "remove_marker", %{id: callsign})
end)
else
socket
end
end
# Schedule the next cleanup in 1 minute
if connected?(socket), do: Process.send_after(self(), :cleanup_old_packets, 60_000)
@ -710,9 +827,25 @@ defmodule AprsWeb.MapLive.Index do
defp within_bounds?(packet, bounds) do
{lat, lng} = get_coordinates(packet)
lat && lng &&
lat >= bounds.south && lat <= bounds.north &&
lng >= bounds.west && lng <= bounds.east
# Basic validation
if is_nil(lat) or is_nil(lng) do
false
else
# Check latitude bounds (straightforward)
lat_in_bounds = lat >= bounds.south && lat <= bounds.north
# Check longitude bounds (handle potential wrapping)
lng_in_bounds =
if bounds.west <= bounds.east do
# Normal case: bounds don't cross antimeridian
lng >= bounds.west && lng <= bounds.east
else
# Bounds cross antimeridian (e.g., west=170, east=-170)
lng >= bounds.west || lng <= bounds.east
end
lat_in_bounds && lng_in_bounds
end
end
defp get_coordinates(packet) do
@ -906,7 +1039,22 @@ defmodule AprsWeb.MapLive.Index do
defp to_float(value) when is_binary(value) do
case Float.parse(value) do
{float_val, _} -> float_val
:error -> raise ArgumentError, "Invalid float string: #{value}"
:error -> 0.0
end
end
@impl true
def terminate(_reason, socket) do
# Clean up any pending bounds update timer
if socket.assigns[:bounds_update_timer] do
Process.cancel_timer(socket.assigns.bounds_update_timer)
end
# Clean up replay timer
if socket.assigns[:replay_timer_ref] do
Process.cancel_timer(socket.assigns.replay_timer_ref)
end
:ok
end
end

View file

@ -0,0 +1,221 @@
defmodule Aprs.PacketsEncodingTest do
use Aprs.DataCase
alias Aprs.EncodingUtils
alias Aprs.Packets
describe "store_packet/1 with encoding issues" do
test "handles invalid UTF-8 bytes in information_field" do
packet_data = %{
base_callsign: "XE2CT",
ssid: "10",
data_type: "position",
destination: "APDR15",
information_field: "Test with invalid byte: " <> <<0xB0>>,
path: "WIDE1-1,WIDE2-1",
sender: "XE2CT-10",
lat: 19.12345,
lon: -99.54321,
has_position: true
}
assert {:ok, stored_packet} = Packets.store_packet(packet_data)
assert stored_packet.information_field == "Test with invalid byte:"
assert stored_packet.sender == "XE2CT-10"
end
test "handles invalid UTF-8 bytes in sender field" do
packet_data = %{
base_callsign: "TEST",
ssid: "1",
data_type: "position",
destination: "APDR15",
information_field: "Valid information",
path: "WIDE1-1,WIDE2-1",
sender: "TEST" <> <<0xB0>> <> "-1",
lat: 19.12345,
lon: -99.54321,
has_position: true
}
assert {:ok, stored_packet} = Packets.store_packet(packet_data)
assert stored_packet.sender == "TEST-1"
assert stored_packet.information_field == "Valid information"
end
test "handles invalid UTF-8 bytes in comment field" do
packet_data = %{
base_callsign: "TEST",
ssid: "1",
data_type: "position",
destination: "APDR15",
information_field: "Valid information",
path: "WIDE1-1,WIDE2-1",
sender: "TEST-1",
comment: "Comment with invalid bytes: " <> <<0xB0, 0xFF, 0x80>>,
lat: 19.12345,
lon: -99.54321,
has_position: true
}
assert {:ok, stored_packet} = Packets.store_packet(packet_data)
assert stored_packet.comment == "Comment with invalid bytes:"
assert stored_packet.sender == "TEST-1"
end
test "handles multiple invalid UTF-8 bytes across different fields" do
packet_data = %{
base_callsign: "TEST" <> <<0xB0>>,
ssid: "1",
data_type: "position",
destination: "APDR" <> <<0xFF>> <> "15",
information_field: "Info " <> <<0x80, 0x81>>,
path: "WIDE1-1" <> <<0xB0>> <> ",WIDE2-1",
sender: "TEST-1",
comment: "Comment " <> <<0xB0>>,
lat: 19.12345,
lon: -99.54321,
has_position: true
}
assert {:ok, stored_packet} = Packets.store_packet(packet_data)
assert stored_packet.base_callsign == "TEST"
assert stored_packet.destination == "APDR15"
assert stored_packet.information_field == "Info"
assert stored_packet.path == "WIDE1-1,WIDE2-1"
assert stored_packet.comment == "Comment"
end
test "preserves valid UTF-8 characters" do
packet_data = %{
base_callsign: "TEST",
ssid: "1",
data_type: "position",
destination: "APDR15",
information_field: "Valid UTF-8: ñ, é, 中文, 🚀",
path: "WIDE1-1,WIDE2-1",
sender: "TEST-1",
comment: "More UTF-8: αβγ, русский",
lat: 19.12345,
lon: -99.54321,
has_position: true
}
assert {:ok, stored_packet} = Packets.store_packet(packet_data)
assert stored_packet.information_field == "Valid UTF-8: ñ, é, 中文, 🚀"
assert stored_packet.comment == "More UTF-8: αβγ, русский"
end
test "handles data_extended with invalid UTF-8" do
data_extended = %{
comment: "Extended comment with invalid: " <> <<0xB0>>,
manufacturer: "Manufacturer " <> <<0xFF>>,
equipment_type: "Equipment" <> <<0x80>>
}
packet_data = %{
base_callsign: "TEST",
ssid: "1",
data_type: "position",
destination: "APDR15",
information_field: "Valid information",
path: "WIDE1-1,WIDE2-1",
sender: "TEST-1",
data_extended: data_extended,
lat: 19.12345,
lon: -99.54321,
has_position: true
}
assert {:ok, stored_packet} = Packets.store_packet(packet_data)
# The data_extended should be sanitized through the EncodingUtils
# Note: data_extended might be nil if not properly handled in the schema
assert stored_packet.sender == "TEST-1"
end
test "handles nil and empty string values" do
packet_data = %{
base_callsign: "TEST",
ssid: "1",
data_type: "position",
destination: "APDR15",
information_field: "",
path: "",
sender: "TEST-1",
comment: nil,
lat: 19.12345,
lon: -99.54321,
has_position: true
}
assert {:ok, stored_packet} = Packets.store_packet(packet_data)
# Empty strings may be stored as nil or empty string depending on Ecto behavior
assert stored_packet.information_field in [nil, ""]
assert stored_packet.path in [nil, ""]
assert is_nil(stored_packet.comment)
end
test "handles binary data that looks like control characters" do
# Test with various problematic byte sequences
packet_data = %{
base_callsign: "TEST",
ssid: "1",
data_type: "position",
destination: "APDR15",
information_field: "Control chars: " <> <<0x00, 0x01, 0x02, 0x1F, 0x7F>>,
path: "WIDE1-1,WIDE2-1",
sender: "TEST-1",
lat: 19.12345,
lon: -99.54321,
has_position: true
}
assert {:ok, stored_packet} = Packets.store_packet(packet_data)
# Control characters should be filtered out
assert stored_packet.information_field == "Control chars:"
end
test "handles the specific 0xb0 byte that caused the original error" do
# This is the exact scenario from the error message
packet_data = %{
base_callsign: "XE2CT",
ssid: "10",
data_type: "position",
destination: "APDR15",
information_field: "Some text" <> <<0xB0>> <> "more text",
path: "WIDE1-1,WIDE2-1",
sender: "XE2CT-10",
lat: 19.12345,
lon: -99.54321,
has_position: true
}
# This should not raise a Postgrex.Error anymore
assert {:ok, stored_packet} = Packets.store_packet(packet_data)
assert stored_packet.sender == "XE2CT-10"
# The 0xb0 byte should be removed
assert stored_packet.information_field == "Some textmore text"
end
end
describe "EncodingUtils integration" do
test "sanitize_string removes invalid UTF-8 sequences" do
invalid_string = "Valid text" <> <<0xB0>> <> "more text"
sanitized = EncodingUtils.sanitize_string(invalid_string)
assert sanitized == "Valid textmore text"
end
test "sanitize_string preserves valid UTF-8" do
valid_string = "Hello 世界! 🌍"
sanitized = EncodingUtils.sanitize_string(valid_string)
assert sanitized == valid_string
end
test "encoding_info detects invalid UTF-8" do
invalid_string = "Hello" <> <<0xB0>>
info = EncodingUtils.encoding_info(invalid_string)
assert info.valid_utf8 == false
assert info.invalid_at == 5
end
end
end