diff --git a/assets/js/map.ts b/assets/js/map.ts index 50f41ef..11b1d28 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -51,51 +51,63 @@ let MapAPRSMap = { } } - // Try to restore from localStorage + // Initialize with URL parameters first (from data attributes) let initialCenter: CenterData, initialZoom: number; + let useUrlParams = false; + try { - const saved = localStorage.getItem("aprs_map_state"); - if (saved) { - const { lat, lng, zoom } = JSON.parse(saved); - if ( - typeof lat === "number" && - typeof lng === "number" && - typeof zoom === "number" && - lat >= -90 && - lat <= 90 && - lng >= -180 && - lng <= 180 && - zoom >= 1 && - zoom <= 20 - ) { - initialCenter = { lat, lng }; - initialZoom = zoom; - } else { - throw new Error("Invalid saved map state"); - } - } else { - throw new Error("No saved map state"); - } - } catch (e) { - // Fallback to server-provided data attributes + const centerData = self.el.dataset.center; + const zoomData = self.el.dataset.zoom; + console.log("Map data attributes - center:", centerData, "zoom:", zoomData); + + if (!centerData || !zoomData) throw new Error("Missing map data attributes"); + initialCenter = JSON.parse(centerData); + initialZoom = parseInt(zoomData); + + console.log("Parsed initial values - center:", initialCenter, "zoom:", initialZoom); + + if ( + !initialCenter || + typeof initialCenter.lat !== "number" || + typeof initialCenter.lng !== "number" + ) + throw new Error("Invalid center data"); + if (isNaN(initialZoom) || initialZoom < 1 || initialZoom > 20) + throw new Error("Invalid zoom data"); + + // Check if URL has explicit parameters (not default values) + const urlParams = new URLSearchParams(window.location.search); + useUrlParams = urlParams.has('lat') || urlParams.has('lng') || urlParams.has('z'); + } catch (error) { + console.error("Error parsing map data attributes:", error); + initialCenter = { lat: 39.8283, lng: -98.5795 }; + initialZoom = 5; + } + + // Only use localStorage if no URL params are present + if (!useUrlParams) { try { - const centerData = self.el.dataset.center; - const zoomData = self.el.dataset.zoom; - if (!centerData || !zoomData) throw new Error("Missing map data attributes"); - initialCenter = JSON.parse(centerData); - initialZoom = parseInt(zoomData); - if ( - !initialCenter || - typeof initialCenter.lat !== "number" || - typeof initialCenter.lng !== "number" - ) - throw new Error("Invalid center data"); - if (isNaN(initialZoom) || initialZoom < 1 || initialZoom > 20) - throw new Error("Invalid zoom data"); - } catch (error) { - console.error("Error parsing map data attributes:", error); - initialCenter = { lat: 39.8283, lng: -98.5795 }; - initialZoom = 5; + const saved = localStorage.getItem("aprs_map_state"); + if (saved) { + const { lat, lng, zoom } = JSON.parse(saved); + if ( + typeof lat === "number" && + typeof lng === "number" && + typeof zoom === "number" && + lat >= -90 && + lat <= 90 && + lng >= -180 && + lng <= 180 && + zoom >= 1 && + zoom <= 20 + ) { + console.log("Using saved state from localStorage:", { lat, lng, zoom }); + initialCenter = { lat, lng }; + initialZoom = zoom; + } + } + } catch (e) { + console.log("Could not load from localStorage:", e); } } @@ -210,8 +222,40 @@ let MapAPRSMap = { self.map!.whenReady(() => { try { self.lastZoom = self.map!.getZoom(); - self.pushEvent("map_ready", {}); - self.sendBoundsToServer(); + + // Ensure we have a valid pushEvent function before using it + if (self.pushEvent && typeof self.pushEvent === 'function') { + console.log("Map ready - sending map_ready event"); + self.pushEvent("map_ready", {}); + // Send initial bounds to trigger historical loading + console.log("Sending initial bounds to server"); + self.sendBoundsToServer(); + + // Also send update_map_state to ensure URL updates and bounds processing + // Increase delay to ensure LiveView is fully connected and ready + setTimeout(() => { + if (self.map && self.pushEvent && !self.isDestroyed) { + console.log("Sending initial update_map_state for historical loading"); + saveMapState(self.map, (event: string, payload: any) => self.pushEvent(event, payload)); + } + }, 500); + } else { + console.warn("pushEvent not available in whenReady callback"); + // Retry after a short delay + setTimeout(() => { + if (self.pushEvent && typeof self.pushEvent === 'function' && !self.isDestroyed) { + self.pushEvent("map_ready", {}); + self.sendBoundsToServer(); + // Also trigger map state update after a delay + setTimeout(() => { + if (self.map && self.pushEvent && !self.isDestroyed) { + console.log("Sending initial update_map_state for historical loading (retry path)"); + saveMapState(self.map, (event: string, payload: any) => self.pushEvent(event, payload)); + } + }, 500); + } + }, 200); + } // Start periodic cleanup of old trail positions (every 5 minutes) self.cleanupInterval = setInterval( @@ -240,7 +284,9 @@ let MapAPRSMap = { if (self.boundsTimer) clearTimeout(self.boundsTimer); self.boundsTimer = setTimeout(() => { - saveMapState(self.map, self.pushEvent); + if (self.map && !self.isDestroyed) { + saveMapState(self.map, (event: string, payload: any) => self.pushEvent(event, payload)); + } }, 300); }; self.map!.on("moveend", moveEndHandler); @@ -266,7 +312,9 @@ let MapAPRSMap = { self.lastZoom = currentZoom; // Save map state and update URL - saveMapState(self.map, self.pushEvent); + if (self.map && !self.isDestroyed) { + saveMapState(self.map, (event: string, payload: any) => self.pushEvent(event, payload)); + } }, 300); }; self.map!.on("zoomend", zoomEndHandler); @@ -637,9 +685,11 @@ let MapAPRSMap = { // Handle progressive loading of historical packets (batch processing) self.handleEvent("add_historical_packets_batch", (data: { packets: MarkerData[], batch: number, is_final: boolean }) => { - if (data.packets && Array.isArray(data.packets)) { - // Process all packets immediately for maximum speed - const packetsByCallsign = new Map(); + console.log("Received historical packet batch:", data.batch, "packet count:", data.packets?.length || 0); + try { + if (data.packets && Array.isArray(data.packets)) { + // Process all packets immediately for maximum speed + const packetsByCallsign = new Map(); data.packets.forEach((packet) => { const callsign = packet.callsign_group || packet.callsign || packet.id; @@ -668,6 +718,10 @@ let MapAPRSMap = { }); }); } + } catch (error) { + console.error("Error processing historical packets batch:", error); + // Continue processing other batches even if one fails + } }); // Handle refresh markers event @@ -681,6 +735,7 @@ let MapAPRSMap = { // Handle clearing historical packets self.handleEvent("clear_historical_packets", () => { + console.log("Clearing historical packets"); // Remove only historical markers (preserve live markers and their trails) const markersToRemove: string[] = []; self.markers!.forEach((marker: any, id: any) => { @@ -744,29 +799,38 @@ let MapAPRSMap = { sendBoundsToServer() { const self = this as unknown as LiveViewHookContext; - if (!self.map) return; + if (!self.map || self.isDestroyed) return; - const bounds = self.map!.getBounds(); - const center = self.map!.getCenter(); - const zoom = self.map!.getZoom(); + try { + const bounds = self.map!.getBounds(); + const center = self.map!.getCenter(); + const zoom = self.map!.getZoom(); - // We're no longer removing markers outside bounds during normal panning/zooming - // to preserve historical positions and the current marker - // self.removeMarkersOutsideBounds(bounds); + // We're no longer removing markers outside bounds during normal panning/zooming + // to preserve historical positions and the current marker + // self.removeMarkersOutsideBounds(bounds); - self.pushEvent("bounds_changed", { - bounds: { - north: bounds.getNorth(), - south: bounds.getSouth(), - east: bounds.getEast(), - west: bounds.getWest(), - }, - center: { - lat: center.lat, - lng: center.lng, - }, - zoom: zoom, - }); + // Use direct pushEvent call with proper context + if (self.pushEvent && typeof self.pushEvent === 'function') { + const boundsData = { + bounds: { + north: bounds.getNorth(), + south: bounds.getSouth(), + east: bounds.getEast(), + west: bounds.getWest(), + }, + center: { + lat: center.lat, + lng: center.lng, + }, + zoom: zoom, + }; + console.log("Sending bounds_changed event:", boundsData); + self.pushEvent("bounds_changed", boundsData); + } + } catch (error) { + console.error("Error sending bounds to server:", error); + } }, addMarker(data: MarkerData & { openPopup?: boolean }) { diff --git a/assets/js/map_helpers.ts b/assets/js/map_helpers.ts index 5226de8..5800b0a 100644 --- a/assets/js/map_helpers.ts +++ b/assets/js/map_helpers.ts @@ -38,42 +38,60 @@ export function getTrailId(data: { callsign_group?: string; callsign?: string; i * Save map state to localStorage and send to server */ export function saveMapState(map: any, pushEvent: Function) { - const center = map.getCenter(); - const zoom = map.getZoom(); + if (!map || !pushEvent) { + console.warn("saveMapState called with invalid map or pushEvent"); + return; + } - // Truncate lat/lng to 5 decimal places for URL - const truncatedLat = Math.round(center.lat * 100000) / 100000; - const truncatedLng = Math.round(center.lng * 100000) / 100000; - - localStorage.setItem( - "aprs_map_state", - JSON.stringify({ lat: truncatedLat, lng: truncatedLng, zoom }), - ); - - // Send combined map state update to server for URL and bounds updating - pushEvent("update_map_state", { - center: { lat: truncatedLat, lng: truncatedLng }, - zoom: zoom, - bounds: { - north: map.getBounds().getNorth(), - south: map.getBounds().getSouth(), - east: map.getBounds().getEast(), - west: map.getBounds().getWest(), - } - }); + try { + const center = map.getCenter(); + const zoom = map.getZoom(); + + // Truncate lat/lng to 5 decimal places for URL + const truncatedLat = Math.round(center.lat * 100000) / 100000; + const truncatedLng = Math.round(center.lng * 100000) / 100000; + + // Always save to localStorage, even if LiveView is disconnected + localStorage.setItem( + "aprs_map_state", + JSON.stringify({ lat: truncatedLat, lng: truncatedLng, zoom }), + ); + + // Send combined map state update to server for URL and bounds updating + const payload = { + center: { lat: truncatedLat, lng: truncatedLng }, + zoom: zoom, + bounds: { + north: map.getBounds().getNorth(), + south: map.getBounds().getSouth(), + east: map.getBounds().getEast(), + west: map.getBounds().getWest(), + } + }; + + console.debug("Sending update_map_state event:", payload); + + // Use safePushEvent to handle disconnected state + safePushEvent(pushEvent, "update_map_state", payload); + } catch (error) { + console.error("Error in saveMapState:", error); + } } /** * Safely push event to LiveView */ export function safePushEvent(pushEvent: Function | undefined, event: string, payload: any): boolean { - if (!pushEvent) return false; + if (!pushEvent || typeof pushEvent !== 'function') { + console.debug(`pushEvent not available for ${event} event`); + return false; + } try { pushEvent(event, payload); return true; } catch (e) { - console.debug(`Unable to send ${event} event - LiveView disconnected`); + console.debug(`Unable to send ${event} event:`, e); return false; } } diff --git a/lib/aprsme/packets/query_builder.ex b/lib/aprsme/packets/query_builder.ex index c082674..2579794 100644 --- a/lib/aprsme/packets/query_builder.ex +++ b/lib/aprsme/packets/query_builder.ex @@ -120,10 +120,18 @@ defmodule Aprsme.Packets.QueryBuilder do @spec within_bounds(Ecto.Query.t(), list(number())) :: Ecto.Query.t() def within_bounds(query, [west, south, east, north]) when is_number(west) and is_number(south) and is_number(east) and is_number(north) do - from p in query, - where: p.has_position == true, - where: p.lat >= ^south and p.lat <= ^north, - where: p.lon >= ^west and p.lon <= ^east + # Handle antimeridian crossing (e.g., west=170, east=-170) + if west > east do + from p in query, + where: p.has_position == true, + where: p.lat >= ^south and p.lat <= ^north, + where: p.lon >= ^west or p.lon <= ^east + else + from p in query, + where: p.has_position == true, + where: p.lat >= ^south and p.lat <= ^north, + where: p.lon >= ^west and p.lon <= ^east + end end def within_bounds(query, _), do: query diff --git a/lib/aprsme_web/live/map_live/index.ex b/lib/aprsme_web/live/map_live/index.ex index 980ec1b..67121fa 100644 --- a/lib/aprsme_web/live/map_live/index.ex +++ b/lib/aprsme_web/live/map_live/index.ex @@ -67,6 +67,8 @@ defmodule AprsmeWeb.MapLive.Index do @impl true def mount(params, _session, socket) do + require Logger + if connected?(socket) do # Subscribe to packet updates Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets") @@ -85,14 +87,16 @@ defmodule AprsmeWeb.MapLive.Index do # Parse map state from URL parameters {map_center, map_zoom} = parse_map_params(params) + Logger.debug("Parsed map params from URL: center=#{inspect(map_center)}, zoom=#{map_zoom}") + Logger.debug("Raw params: #{inspect(params)}") + socket = assign_defaults(socket, one_hour_ago) - socket = assign(socket, map_center: map_center, map_zoom: map_zoom) + + # Initialize the flag to track if initial historical load is completed + socket = assign(socket, initial_historical_completed: false) # Calculate initial bounds based on center and zoom level initial_bounds = calculate_bounds_from_center_and_zoom(map_center, map_zoom) - socket = assign(socket, map_bounds: initial_bounds) - socket = assign(socket, packet_buffer: [], buffer_timer: nil) - socket = assign(socket, all_packets: %{}, station_popup_open: false) if connected?(socket) do Endpoint.subscribe("aprs_messages") @@ -104,9 +108,9 @@ defmodule AprsmeWeb.MapLive.Index do {:ok, assign(socket, map_ready: false, - map_bounds: nil, - map_center: %{lat: 39.8283, lng: -98.5795}, - map_zoom: 4, + map_bounds: initial_bounds, + map_center: map_center, + map_zoom: map_zoom, visible_packets: %{}, historical_packets: %{}, overlay_callsign: "", @@ -115,7 +119,13 @@ defmodule AprsmeWeb.MapLive.Index do packet_age_threshold: one_hour_ago, slideover_open: true, deployed_at: deployed_at, - map_page: true + map_page: true, + packet_buffer: [], + buffer_timer: nil, + all_packets: %{}, + station_popup_open: false, + initial_bounds_loaded: false, + needs_initial_historical_load: false )} end @@ -256,10 +266,19 @@ defmodule AprsmeWeb.MapLive.Index do @impl true def handle_event("map_ready", _params, socket) do - socket = assign(socket, map_ready: true) + require Logger - # Load historical packets immediately since we now have bounds from URL parameters - Process.send_after(self(), :reload_historical_packets, 10) + Logger.debug("map_ready event received - current bounds: #{inspect(socket.assigns.map_bounds)}") + + # Mark map as ready and that we need to load historical packets + socket = + socket + |> assign(map_ready: true) + |> assign(needs_initial_historical_load: true) + + # Wait for JavaScript to send the actual map bounds before loading historical packets + # The calculated bounds might be too small/inaccurate + Logger.debug("Map ready - waiting for JavaScript to send actual bounds before loading historical packets") # If we have pending geolocation, zoom to it now socket = @@ -358,6 +377,10 @@ defmodule AprsmeWeb.MapLive.Index do @impl true def handle_event("update_map_state", %{"center" => center, "zoom" => zoom} = params, socket) do + require Logger + + Logger.debug("update_map_state event received: center=#{inspect(center)}, zoom=#{zoom}") + # Parse center coordinates lat = case center do @@ -383,6 +406,7 @@ defmodule AprsmeWeb.MapLive.Index do # Update URL without page reload new_path = "/?lat=#{lat}&lng=#{lng}&z=#{zoom}" + Logger.debug("Updating URL to: #{new_path}") socket = push_patch(socket, to: new_path, replace: true) # If bounds are included, also process bounds update @@ -396,8 +420,16 @@ defmodule AprsmeWeb.MapLive.Index do west: west } - # Only trigger bounds processing if bounds actually changed - if socket.assigns.map_bounds != map_bounds do + # Trigger bounds processing if bounds changed OR if this is the initial load OR if we need initial historical load + if socket.assigns.map_bounds != map_bounds or + !socket.assigns[:initial_bounds_loaded] or + socket.assigns[:needs_initial_historical_load] do + require Logger + + Logger.debug( + "Sending bounds update (initial_load: #{!socket.assigns[:initial_bounds_loaded]}, needs_historical: #{socket.assigns[:needs_initial_historical_load]}): #{inspect(map_bounds)}" + ) + send(self(), {:process_bounds_update, map_bounds}) end @@ -478,12 +510,25 @@ defmodule AprsmeWeb.MapLive.Index do # Private handler functions for each message type defp handle_info_process_bounds_update(map_bounds, socket) do - if !socket.assigns.initial_bounds_loaded or - not compare_bounds(map_bounds, socket.assigns.map_bounds) do + require Logger + + # Check if we need to process this bounds update + should_process = + !socket.assigns[:initial_bounds_loaded] or + socket.assigns[:needs_initial_historical_load] or + not compare_bounds(map_bounds, socket.assigns.map_bounds) + + Logger.debug( + "handle_info_process_bounds_update - should_process: #{should_process}, initial_bounds_loaded: #{socket.assigns[:initial_bounds_loaded]}, needs_initial_historical_load: #{socket.assigns[:needs_initial_historical_load]}" + ) + + if should_process do + Logger.debug("Processing bounds update: #{inspect(map_bounds)}") socket = process_bounds_update(map_bounds, socket) socket = assign(socket, initial_bounds_loaded: true) {:noreply, socket} else + Logger.debug("Skipping bounds update - no change detected") {:noreply, socket} end end @@ -1154,6 +1199,12 @@ defmodule AprsmeWeb.MapLive.Index do end defp handle_reload_historical_packets(socket) do + require Logger + + Logger.debug( + "handle_reload_historical_packets called - map_ready: #{socket.assigns.map_ready}, map_bounds: #{inspect(socket.assigns.map_bounds)}" + ) + if socket.assigns.map_ready and socket.assigns.map_bounds do # Clear existing historical packets socket = push_event(socket, "clear_historical_packets", %{}) @@ -1163,6 +1214,7 @@ defmodule AprsmeWeb.MapLive.Index do {:noreply, socket} else + Logger.debug("Skipping historical reload - conditions not met") {:noreply, socket} end end @@ -1435,14 +1487,22 @@ defmodule AprsmeWeb.MapLive.Index do # Progressive loading functions using LiveView's efficient update mechanisms @spec start_progressive_historical_loading(Socket.t()) :: Socket.t() defp start_progressive_historical_loading(socket) do - # Clear existing historical packets before loading new ones - socket = push_event(socket, "clear_historical_packets", %{}) + require Logger + + Logger.debug( + "start_progressive_historical_loading called with zoom: #{socket.assigns.map_zoom}, bounds: #{inspect(socket.assigns.map_bounds)}" + ) + + # Don't clear historical packets here - let the caller decide if clearing is needed + # This prevents race conditions where we clear packets that were just loaded # For high zoom levels, load everything in one batch for maximum speed zoom = socket.assigns.map_zoom || 5 if zoom >= 10 do # High zoom - load everything at once for maximum speed + Logger.debug("High zoom (#{zoom}), loading in single batch") + socket |> assign(loading_batch: 0, total_batches: 1) |> load_historical_batch(0) @@ -1507,27 +1567,45 @@ defmodule AprsmeWeb.MapLive.Index do packets_module = Application.get_env(:aprsme, :packets_module, Aprsme.Packets) historical_packets = - if packets_module == Aprsme.Packets do - # Use cached queries for better performance - # Include zoom level in cache key for better cache efficiency - Aprsme.CachedQueries.get_recent_packets_cached(%{ - bounds: bounds, - limit: batch_size, - offset: offset, - zoom: zoom - }) - else - # Fallback for testing - packets_module.get_recent_packets_optimized(%{ - bounds: bounds, - limit: batch_size, - offset: offset - }) + try do + if packets_module == Aprsme.Packets do + # Use cached queries for better performance + # Include zoom level in cache key for better cache efficiency + Aprsme.CachedQueries.get_recent_packets_cached(%{ + bounds: bounds, + limit: batch_size, + offset: offset, + zoom: zoom + }) + else + # Fallback for testing + packets_module.get_recent_packets_optimized(%{ + bounds: bounds, + limit: batch_size, + offset: offset + }) + end + rescue + e -> + require Logger + + Logger.error("Error loading historical packets: #{inspect(e)}") + Logger.error("Stack trace: #{Exception.format_stacktrace()}") + [] end if Enum.any?(historical_packets) do # Process this batch and send to frontend - packet_data_list = build_packet_data_list(historical_packets) + packet_data_list = + try do + build_packet_data_list(historical_packets) + rescue + e -> + require Logger + + Logger.error("Error building packet data list: #{inspect(e)}") + [] + end if Enum.any?(packet_data_list) do # Use LiveView's efficient push_event for incremental updates @@ -1672,20 +1750,21 @@ defmodule AprsmeWeb.MapLive.Index do end defp handle_valid_bounds_update(map_bounds, socket) do - # Only schedule a bounds update if the bounds have actually changed (with rounding) - if compare_bounds(map_bounds, socket.assigns.map_bounds) do - {:noreply, socket} - else - # If this is the first bounds update (map_bounds is nil), process immediately - # to avoid race condition with historical packet loading - if is_nil(socket.assigns.map_bounds) do - # Process immediately for initial bounds + # Force processing if we need initial historical load, regardless of bounds comparison + cond do + socket.assigns[:needs_initial_historical_load] -> + require Logger + + Logger.debug("Processing initial bounds update immediately (forced): #{inspect(map_bounds)}") socket = process_bounds_update(map_bounds, socket) {:noreply, socket} - else + + compare_bounds(map_bounds, socket.assigns.map_bounds) -> + {:noreply, socket} + + true -> # For subsequent updates, use the timer to debounce schedule_bounds_update(map_bounds, socket) - end end end @@ -1701,6 +1780,21 @@ defmodule AprsmeWeb.MapLive.Index do @spec process_bounds_update(map(), Socket.t()) :: Socket.t() defp process_bounds_update(map_bounds, socket) do + require Logger + + Logger.debug("process_bounds_update called with bounds: #{inspect(map_bounds)}") + + # Check if this is the initial load or if bounds have actually changed + is_initial_load = socket.assigns[:needs_initial_historical_load] || !socket.assigns[:initial_bounds_loaded] + bounds_changed = socket.assigns.map_bounds && not compare_bounds(map_bounds, socket.assigns.map_bounds) + + # Check if we've completed the initial historical load + initial_historical_completed = socket.assigns[:initial_historical_completed] || false + + Logger.debug( + "is_initial_load: #{is_initial_load}, bounds_changed: #{bounds_changed}, initial_historical_completed: #{initial_historical_completed}" + ) + # Remove out-of-bounds packets and markers immediately new_visible_packets = socket.assigns.visible_packets @@ -1722,14 +1816,37 @@ defmodule AprsmeWeb.MapLive.Index do end) end - # Remove only out-of-bounds historical packets instead of clearing all + # Only clear historical packets if: + # 1. Bounds actually changed AND + # 2. This is not the initial load AND + # 3. We've already completed the initial historical load + socket = + if bounds_changed and not is_initial_load and initial_historical_completed do + Logger.debug("Bounds changed after initial load - clearing historical packets") + push_event(socket, "clear_historical_packets", %{}) + else + Logger.debug("Initial load or no significant change - keeping existing markers") + socket + end + + # Always filter markers by bounds socket = push_event(socket, "filter_markers_by_bounds", %{bounds: map_bounds}) - # Load historical packets for the new bounds - # Always load historical packets when bounds change to ensure new areas have data + # Update map bounds FIRST so progressive loading uses the correct bounds + socket = + socket + |> assign(map_bounds: map_bounds, visible_packets: new_visible_packets) + |> assign(needs_initial_historical_load: false) + + # Load historical packets for the new bounds (now socket.assigns.map_bounds is correct) + Logger.debug("Starting progressive historical loading for new bounds") socket = start_progressive_historical_loading(socket) - # Update map bounds and visible packets - assign(socket, map_bounds: map_bounds, visible_packets: new_visible_packets) + # Mark initial historical as completed if this was the initial load + if is_initial_load do + assign(socket, initial_historical_completed: true) + else + socket + end end end diff --git a/priv/repo/migrations/20250710175129_test_query.exs b/priv/repo/migrations/20250710175129_test_query.exs new file mode 100644 index 0000000..37de1f9 --- /dev/null +++ b/priv/repo/migrations/20250710175129_test_query.exs @@ -0,0 +1,6 @@ +defmodule Aprsme.Repo.Migrations.TestQuery do + use Ecto.Migration + + def change do + end +end diff --git a/test/aprsme/packets/query_builder_test.exs b/test/aprsme/packets/query_builder_test.exs new file mode 100644 index 0000000..9241ce9 --- /dev/null +++ b/test/aprsme/packets/query_builder_test.exs @@ -0,0 +1,90 @@ +defmodule Aprsme.Packets.QueryBuilderTest do + use Aprsme.DataCase + + import Ecto.Query + + alias Aprsme.Packet + alias Aprsme.Packets.QueryBuilder + + describe "within_bounds/2" do + test "filters packets within normal bounds" do + # Create test packets + {:ok, inside} = + Aprsme.Repo.insert(%Packet{ + sender: "TEST-1", + lat: 40.5, + lon: -73.5, + has_position: true, + received_at: DateTime.truncate(DateTime.utc_now(), :second) + }) + + {:ok, _outside} = + Aprsme.Repo.insert(%Packet{ + sender: "TEST-2", + lat: 50.0, + lon: -73.5, + has_position: true, + received_at: DateTime.truncate(DateTime.utc_now(), :second) + }) + + # Test normal bounds [west, south, east, north] + query = QueryBuilder.within_bounds(Packet, [-74.0, 40.0, -73.0, 41.0]) + results = Aprsme.Repo.all(query) + + assert length(results) == 1 + assert hd(results).id == inside.id + end + + test "handles antimeridian crossing bounds" do + # Create test packets + {:ok, west_side} = + Aprsme.Repo.insert(%Packet{ + sender: "WEST-1", + lat: 0.0, + lon: 175.0, + has_position: true, + received_at: DateTime.truncate(DateTime.utc_now(), :second) + }) + + {:ok, east_side} = + Aprsme.Repo.insert(%Packet{ + sender: "EAST-1", + lat: 0.0, + lon: -175.0, + has_position: true, + received_at: DateTime.truncate(DateTime.utc_now(), :second) + }) + + {:ok, _middle} = + Aprsme.Repo.insert(%Packet{ + sender: "MIDDLE-1", + lat: 0.0, + lon: 0.0, + has_position: true, + received_at: DateTime.truncate(DateTime.utc_now(), :second) + }) + + # Test antimeridian crossing bounds [west=170, south=-10, east=-170, north=10] + query = QueryBuilder.within_bounds(Packet, [170.0, -10.0, -170.0, 10.0]) + results = Aprsme.Repo.all(query) + + assert length(results) == 2 + result_ids = results |> Enum.map(& &1.id) |> Enum.sort() + expected_ids = Enum.sort([west_side.id, east_side.id]) + assert result_ids == expected_ids + end + + test "returns query unchanged with invalid bounds" do + original_query = from(p in Packet) + + # Test with nil + assert QueryBuilder.within_bounds(original_query, nil) == original_query + + # Test with wrong number of elements + assert QueryBuilder.within_bounds(original_query, [1, 2, 3]) == original_query + + # Test with non-numeric values + assert QueryBuilder.within_bounds(original_query, ["a", "b", "c", "d"]) == original_query + end + end +end