additional map fix
This commit is contained in:
parent
c5d7a32b60
commit
389f8aa5ed
7 changed files with 624 additions and 90 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -108,8 +108,6 @@ defmodule Aprs.Packet do
|
|||
:base_callsign,
|
||||
:data_type,
|
||||
:destination,
|
||||
:information_field,
|
||||
:path,
|
||||
:sender,
|
||||
:ssid,
|
||||
:received_at
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
221
test/aprs/packets_encoding_test.exs
Normal file
221
test/aprs/packets_encoding_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue