diff --git a/assets/js/minimal_map.js b/assets/js/minimal_map.js index dd372c0..d53b880 100644 --- a/assets/js/minimal_map.js +++ b/assets/js/minimal_map.js @@ -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 = `
-
${callsign}
+
${callsign}
${symbolDesc}
`; 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(); diff --git a/lib/aprs/encoding_utils.ex b/lib/aprs/encoding_utils.ex index 79685a2..8bf8ae5 100644 --- a/lib/aprs/encoding_utils.ex +++ b/lib/aprs/encoding_utils.ex @@ -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. diff --git a/lib/aprs/packet.ex b/lib/aprs/packet.ex index 9936fd4..f0f2c98 100644 --- a/lib/aprs/packet.ex +++ b/lib/aprs/packet.ex @@ -108,8 +108,6 @@ defmodule Aprs.Packet do :base_callsign, :data_type, :destination, - :information_field, - :path, :sender, :ssid, :received_at diff --git a/lib/aprs/packets.ex b/lib/aprs/packets.ex index 1122c16..261dc37 100644 --- a/lib/aprs/packets.ex +++ b/lib/aprs/packets.ex @@ -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) diff --git a/lib/aprs_web/live/map_live/callsign_view.ex b/lib/aprs_web/live/map_live/callsign_view.ex index cb31ebe..3c8b5fc 100644 --- a/lib/aprs_web/live/map_live/callsign_view.ex +++ b/lib/aprs_web/live/map_live/callsign_view.ex @@ -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 diff --git a/lib/aprs_web/live/map_live/index.ex b/lib/aprs_web/live/map_live/index.ex index 68dbe4b..15e2120 100644 --- a/lib/aprs_web/live/map_live/index.ex +++ b/lib/aprs_web/live/map_live/index.ex @@ -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 diff --git a/test/aprs/packets_encoding_test.exs b/test/aprs/packets_encoding_test.exs new file mode 100644 index 0000000..f69ac0d --- /dev/null +++ b/test/aprs/packets_encoding_test.exs @@ -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