diff --git a/assets/js/map.ts b/assets/js/map.ts index 31ef11f..a787b8b 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -271,6 +271,9 @@ let MapAPRSMap = { // Track marker states to prevent unnecessary operations self.markerStates = new Map(); + + // Store RF path lines for hover visualization + self.rfPathLines = []; // Force initial size calculation try { @@ -897,6 +900,76 @@ let MapAPRSMap = { } }); + // Handle drawing RF path lines when hovering over a station + self.handleEvent("draw_rf_path", (data: { + station_lat: number, + station_lng: number, + path_stations: Array<{callsign: string, lat: number, lng: number}> + }) => { + if (!self.map || !data.path_stations || data.path_stations.length === 0) return; + + // Clear any existing path lines + if (self.rfPathLines) { + self.rfPathLines.forEach(line => self.map!.removeLayer(line)); + } + self.rfPathLines = []; + + // Draw lines from station to each digipeater/igate in sequence + let prevLat = data.station_lat; + let prevLng = data.station_lng; + + data.path_stations.forEach((station, index) => { + // Draw line from previous position to this station + const line = L.polyline( + [[prevLat, prevLng], [station.lat, station.lng]], + { + color: '#FF6B6B', + weight: 3, + opacity: 0.8, + dashArray: index === 0 ? null : '5, 10', // Solid line for first hop, dashed for subsequent + } + ); + + line.addTo(self.map!); + self.rfPathLines.push(line); + + // Add a small circle marker at the digipeater/igate location + const circle = L.circleMarker([station.lat, station.lng], { + radius: 6, + fillColor: '#2563eb', + color: '#fff', + weight: 2, + opacity: 1, + fillOpacity: 0.8 + }); + + circle.bindTooltip(station.callsign, { + permanent: false, + direction: 'top', + offset: [0, -10] + }); + + circle.addTo(self.map!); + self.rfPathLines.push(circle); + + // Update prev position for next line + prevLat = station.lat; + prevLng = station.lng; + }); + }); + + // Handle clearing RF path lines + self.handleEvent("clear_rf_path", () => { + if (self.rfPathLines) { + self.rfPathLines.forEach(line => { + if (self.map && self.map.hasLayer(line)) { + self.map.removeLayer(line); + } + }); + self.rfPathLines = []; + } + }); + // Handle bounds-based marker filtering self.handleEvent("filter_markers_by_bounds", (data: { bounds: BoundsData }) => { if (data.bounds) { @@ -1156,6 +1229,45 @@ let MapAPRSMap = { lng: lng, }); }); + + // Handle marker hover for RF path visualization + // Check for non-empty RF path (not TCPIP and not empty string) + if (data.path && data.path.trim() !== "" && !data.path.includes("TCPIP")) { + console.log("Adding hover handlers for RF packet:", data.callsign, "path:", data.path); + marker.on("mouseover", () => { + console.log("Marker hover start for:", data.callsign); + // Check if LiveView is still connected before sending event + if (self.pushEvent && !self.isDestroyed) { + try { + self.pushEvent("marker_hover_start", { + id: data.id, + callsign: data.callsign, + path: data.path, + lat: lat, + lng: lng, + }); + } catch (error) { + console.warn("Failed to send marker_hover_start event:", error); + } + } else { + console.warn("LiveView not connected, cannot send hover event"); + } + }); + + marker.on("mouseout", () => { + console.log("Marker hover end for:", data.callsign); + // Check if LiveView is still connected before sending event + if (self.pushEvent && !self.isDestroyed) { + try { + self.pushEvent("marker_hover_end", { + id: data.id, + }); + } catch (error) { + console.warn("Failed to send marker_hover_end event:", error); + } + } + }); + } // Mark historical markers for identification diff --git a/assets/js/types/map.d.ts b/assets/js/types/map.d.ts index 7ecf25b..edc2381 100644 --- a/assets/js/types/map.d.ts +++ b/assets/js/types/map.d.ts @@ -28,6 +28,9 @@ export interface LiveViewHookContext { popupNavigationHandler?: (e: Event) => void; moveEndHandler?: () => void; zoomEndHandler?: () => void; + rfPathLines?: any[]; // Array of Leaflet polylines and markers for RF path visualization + trailLayer?: LayerGroup; + markerClusterGroup?: any; [key: string]: any; } @@ -48,6 +51,7 @@ export interface MarkerData { callsign_group?: string; symbol_html?: string; openPopup?: boolean; + path?: string; } export interface BoundsData { diff --git a/lib/aprsme/packets.ex b/lib/aprsme/packets.ex index d35e821..d92544d 100644 --- a/lib/aprsme/packets.ex +++ b/lib/aprsme/packets.ex @@ -424,8 +424,13 @@ defmodule Aprsme.Packets do @impl true @spec get_recent_packets(map()) :: [struct()] def get_recent_packets(opts \\ %{}) do - # Always limit to the last 24 hours for more symbol variety - opts_with_time = Map.put(opts, :hours_back, 24) + # Use provided hours_back or default to 24 hours + opts_with_time = + if Map.has_key?(opts, :hours_back) do + opts + else + Map.put(opts, :hours_back, 24) + end opts_with_time |> QueryBuilder.recent_position_packets() @@ -439,8 +444,9 @@ defmodule Aprsme.Packets do @impl true @spec get_recent_packets_optimized(map()) :: [struct()] def get_recent_packets_optimized(opts \\ %{}) do - # Always limit to the last 24 hours for more symbol variety - one_hour_ago = TimeUtils.one_day_ago() + # Use hours_back from opts if provided, otherwise default to 24 hours + hours_back = Map.get(opts, :hours_back, 24) + time_ago = DateTime.add(DateTime.utc_now(), -hours_back * 3600, :second) limit = Map.get(opts, :limit, 200) offset = Map.get(opts, :offset, 0) @@ -448,7 +454,7 @@ defmodule Aprsme.Packets do base_query = from(p in Packet, where: p.has_position == true, - where: p.received_at >= ^one_hour_ago + where: p.received_at >= ^time_ago ) # Apply spatial and other filters BEFORE limiting diff --git a/lib/aprsme_web/components/core_components.ex b/lib/aprsme_web/components/core_components.ex index 329651f..d80179c 100644 --- a/lib/aprsme_web/components/core_components.ex +++ b/lib/aprsme_web/components/core_components.ex @@ -752,13 +752,33 @@ defmodule AprsmeWeb.CoreComponents do attr :class, :string, default: "" attr :variant, :atom, values: [:horizontal, :vertical], default: :horizontal attr :current_user, :any, default: nil + attr :map_state, :map, default: nil + attr :tracked_callsign, :string, default: "" def navigation(assigns) do + # Build home URL with map state if available + home_url = + if assigns[:map_state] do + base_url = "/?lat=#{assigns.map_state.lat}&lng=#{assigns.map_state.lng}&z=#{assigns.map_state.zoom}" + + if assigns[:tracked_callsign] && assigns.tracked_callsign != "" do + base_url <> "&call=#{assigns.tracked_callsign}" + else + base_url + end + else + "/" + end + + assigns = assign(assigns, :home_url, home_url) + ~H""" <%= if @variant == :horizontal do %> <% else %> -
  • <.link navigate="/" class="text-gray-900 hover:text-gray-700">{gettext("Home")}
  • +
  • + <.link navigate={@home_url} class="text-gray-900 hover:text-gray-700"> + {gettext("Home")} + +
  • <.link navigate="/api" class="text-gray-900 hover:text-gray-700">API
  • <.link navigate="/about" class="text-gray-900 hover:text-gray-700">{gettext("About")} diff --git a/lib/aprsme_web/live/map_live/index.ex b/lib/aprsme_web/live/map_live/index.ex index b3c76c5..0bcc09d 100644 --- a/lib/aprsme_web/live/map_live/index.ex +++ b/lib/aprsme_web/live/map_live/index.ex @@ -8,6 +8,7 @@ defmodule AprsmeWeb.MapLive.Index do import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1] import Phoenix.LiveView, only: [connected?: 1, push_event: 3, push_navigate: 2, push_patch: 2] + alias Aprsme.CachedQueries alias Aprsme.GeoUtils alias Aprsme.Packets.Clustering alias AprsmeWeb.Endpoint @@ -92,7 +93,7 @@ defmodule AprsmeWeb.MapLive.Index do # Check for IP geolocation in session # Check if URL params were explicitly provided (not just defaults) - has_explicit_url_params = params["lat"] || params["lng"] || params["z"] + has_explicit_url_params = !!(params["lat"] || params["lng"] || params["z"]) {map_center, map_zoom, should_skip_initial_url_update} = case session["ip_geolocation"] do @@ -118,9 +119,6 @@ defmodule AprsmeWeb.MapLive.Index do # 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) - if connected?(socket) do Endpoint.subscribe("aprs_messages") Phoenix.PubSub.subscribe(Aprsme.PubSub, "postgres:aprsme_packets") @@ -129,12 +127,29 @@ defmodule AprsmeWeb.MapLive.Index do # Check for callsign parameter tracked_callsign = Map.get(params, "call", "") + # If tracking a callsign and no explicit map parameters, center on that callsign + {final_map_center, final_map_zoom} = + if tracked_callsign != "" and not has_explicit_url_params do + case CachedQueries.get_latest_packet_for_callsign_cached(tracked_callsign) do + %{lat: lat, lon: lon} when is_number(lat) and is_number(lon) -> + {%{lat: lat, lng: lon}, 12} + + _ -> + {map_center, map_zoom} + end + else + {map_center, map_zoom} + end + + # Calculate initial bounds based on final center and zoom level + initial_bounds = calculate_bounds_from_center_and_zoom(final_map_center, final_map_zoom) + {:ok, assign(socket, map_ready: false, map_bounds: initial_bounds, - map_center: map_center, - map_zoom: map_zoom, + map_center: final_map_center, + map_zoom: final_map_zoom, should_skip_initial_url_update: should_skip_initial_url_update, visible_packets: %{}, historical_packets: %{}, @@ -151,7 +166,7 @@ defmodule AprsmeWeb.MapLive.Index do all_packets: %{}, station_popup_open: false, initial_bounds_loaded: false, - needs_initial_historical_load: false + needs_initial_historical_load: tracked_callsign != "" )} end @@ -302,11 +317,9 @@ defmodule AprsmeWeb.MapLive.Index do def handle_event("map_ready", _params, socket) do require Logger - # Mark map as ready and that we need to load historical packets - socket = - socket - |> assign(map_ready: true) - |> assign(needs_initial_historical_load: true) + # Mark map as ready - preserve existing needs_initial_historical_load state + # (it's already set correctly in mount based on whether we're tracking a callsign) + socket = assign(socket, map_ready: true) # If we have non-default center coordinates (e.g., from geolocation), apply them now socket = @@ -334,6 +347,41 @@ defmodule AprsmeWeb.MapLive.Index do {:noreply, assign(socket, station_popup_open: true)} end + @impl true + def handle_event("marker_hover_start", %{"id" => _id, "path" => path, "lat" => lat, "lng" => lng}, socket) do + require Logger + + Logger.info("RF path hover start - path: #{inspect(path)}") + + # Parse the path to find digipeater/igate stations + path_stations = parse_rf_path(path) + Logger.info("Parsed path stations: #{inspect(path_stations)}") + + # Query for positions of path stations + path_station_positions = get_path_station_positions(path_stations, socket) + Logger.info("Found #{length(path_station_positions)} station positions") + + # Send event to draw the RF path lines + socket = + if length(path_station_positions) > 0 do + push_event(socket, "draw_rf_path", %{ + station_lat: lat, + station_lng: lng, + path_stations: path_station_positions + }) + else + socket + end + + {:noreply, socket} + end + + @impl true + def handle_event("marker_hover_end", _params, socket) do + # Clear the RF path lines + {:noreply, push_event(socket, "clear_rf_path", %{})} + end + @impl true def handle_event("update_callsign", %{"callsign" => callsign}, socket) do {:noreply, assign(socket, overlay_callsign: callsign)} @@ -1222,7 +1270,13 @@ defmodule AprsmeWeb.MapLive.Index do {gettext("Navigation")} - <.navigation variant={:vertical} class="text-sm" current_user={@current_user} /> + <.navigation + variant={:vertical} + class="text-sm" + current_user={@current_user} + map_state={%{lat: @map_center.lat, lng: @map_center.lng, zoom: @map_zoom}} + tracked_callsign={@tracked_callsign} + /> @@ -1461,6 +1515,7 @@ defmodule AprsmeWeb.MapLive.Index do "timestamp" => DateTime.to_unix(packet.received_at || DateTime.utc_now(), :millisecond), "historical" => !is_most_recent, "is_most_recent_for_callsign" => is_most_recent, + "path" => packet.path || "", "popup" => build_simple_popup(packet, has_weather) } end @@ -1671,6 +1726,10 @@ defmodule AprsmeWeb.MapLive.Index do Map.put(params, :callsign, socket.assigns.tracked_callsign) end + # Use the historical_hours setting from the UI dropdown + historical_hours = String.to_integer(socket.assigns.historical_hours || "1") + params = Map.put(params, :hours_back, historical_hours) + Aprsme.CachedQueries.get_recent_packets_cached(params) else # Fallback for testing @@ -1993,4 +2052,77 @@ defmodule AprsmeWeb.MapLive.Index do socket end end + + # Parse RF path string to extract digipeater/igate callsigns + defp parse_rf_path(path) when is_binary(path) do + # Split by comma and filter out empty strings + path + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + # Skip TCPIP entries and qA* entries (but preserve the actual digipeater/igate callsigns) + |> Enum.reject(&String.contains?(&1, "TCPIP")) + |> Enum.reject(&String.starts_with?(&1, "qA")) + |> Enum.map(fn callsign -> + # Remove any asterisk (used to mark heard stations) and WIDE patterns + callsign + |> String.replace("*", "") + |> String.trim() + end) + # Filter out WIDE patterns (both WIDE1-1 and WIDE1 forms) + |> Enum.reject(fn callsign -> + String.starts_with?(callsign, "WIDE") + end) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + end + + defp parse_rf_path(_), do: [] + + # Get positions of path stations from database + defp get_path_station_positions(callsigns, socket) when is_list(callsigns) do + # Query for the most recent position of each station + # Show complete RF path regardless of map bounds - only the originating station needs to be visible + _bounds = socket.assigns.map_bounds + + query = """ + WITH latest_positions AS ( + SELECT DISTINCT ON (sender) + sender, + lat, + lon, + received_at + FROM packets + WHERE + sender = ANY($1::text[]) + AND lat IS NOT NULL + AND lon IS NOT NULL + AND received_at > NOW() - INTERVAL '7 days' + ORDER BY sender, received_at DESC + ) + SELECT sender, lat, lon + FROM latest_positions + ORDER BY array_position($1::text[], sender) + """ + + case Ecto.Adapters.SQL.query( + Aprsme.Repo, + query, + [callsigns] + ) do + {:ok, %{rows: rows}} -> + Enum.map(rows, fn [callsign, lat, lon] -> + %{ + callsign: callsign, + lat: Decimal.to_float(lat), + lng: Decimal.to_float(lon) + } + end) + + {:error, _} -> + [] + end + end + + defp get_path_station_positions(_, _), do: [] end diff --git a/lib/aprsme_web/live/map_live/packet_utils.ex b/lib/aprsme_web/live/map_live/packet_utils.ex index b5acf74..3a0d6c2 100644 --- a/lib/aprsme_web/live/map_live/packet_utils.ex +++ b/lib/aprsme_web/live/map_live/packet_utils.ex @@ -268,6 +268,8 @@ defmodule AprsmeWeb.MapLive.PacketUtils do 32 ) + path_value = get_packet_field(packet, :path, "") + %{ "id" => packet_id, "callsign" => packet_info.callsign, @@ -276,7 +278,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do "lat" => to_float(lat), "lng" => to_float(lon), "data_type" => to_string(get_packet_field(packet, :data_type, "unknown")), - "path" => get_packet_field(packet, :path, ""), + "path" => path_value, "comment" => packet_info.comment, "data_extended" => packet_info.safe_data_extended || %{}, "symbol_table_id" => packet_info.symbol_table_id, diff --git a/lib/aprsme_web/time_helpers.ex b/lib/aprsme_web/time_helpers.ex index c68a9d9..d6f9aab 100644 --- a/lib/aprsme_web/time_helpers.ex +++ b/lib/aprsme_web/time_helpers.ex @@ -7,6 +7,14 @@ defmodule AprsmeWeb.TimeHelpers do @doc """ Returns a human-readable string for how long ago the given DateTime was. """ + def time_ago_in_words(nil), do: gettext("unknown time") <> " " <> gettext("ago") + + def time_ago_in_words(%NaiveDateTime{} = naive_datetime) do + # Convert NaiveDateTime to DateTime assuming UTC + datetime = DateTime.from_naive!(naive_datetime, "Etc/UTC") + time_ago_in_words(datetime) + end + def time_ago_in_words(datetime) do now = DateTime.utc_now() diff_seconds = DateTime.diff(now, datetime, :second) diff --git a/test/aprsme_web/live/map_live/rf_path_test.exs b/test/aprsme_web/live/map_live/rf_path_test.exs new file mode 100644 index 0000000..42db171 --- /dev/null +++ b/test/aprsme_web/live/map_live/rf_path_test.exs @@ -0,0 +1,281 @@ +defmodule AprsmeWeb.MapLive.RfPathTest do + use AprsmeWeb.ConnCase + + import Phoenix.LiveViewTest + + alias Aprsme.Packet + alias Aprsme.Repo + alias AprsmeWeb.MapLive.Index + + describe "RF path parsing" do + test "parses RF paths correctly", %{conn: conn} do + # Test the parse_rf_path function indirectly through module + # Since it's a private function, we test the behavior + + {:ok, _digi1} = + Repo.insert(%Packet{ + sender: "K5GVL-10", + base_callsign: "K5GVL", + ssid: "10", + lat: Decimal.new("33.1000"), + lon: Decimal.new("-96.6000"), + has_position: true, + received_at: DateTime.truncate(DateTime.utc_now(), :second), + data_type: "position", + symbol_table_id: "#", + symbol_code: "r" + }) + + {:ok, view, _html} = live(conn, "/") + + # Send a marker_hover_start event + render_hook(view, "marker_hover_start", %{ + "id" => "test-1", + "path" => "K5GVL-10*,WIDE1-1,WIDE2-1", + "lat" => 33.2837, + "lng" => -96.5728 + }) + + # The function should parse the path and try to find positions + # We can't directly test the private function, but we can verify no errors occur + assert view.module == Index + end + + test "parses complex RF paths with multiple stations", %{conn: conn} do + # Create multiple stations that could be in the path + {:ok, _station1} = + Repo.insert(%Packet{ + sender: "N5ABC", + base_callsign: "N5ABC", + ssid: nil, + lat: Decimal.new("33.2000"), + lon: Decimal.new("-96.6000"), + has_position: true, + received_at: DateTime.truncate(DateTime.utc_now(), :second), + data_type: "position" + }) + + {:ok, _station2} = + Repo.insert(%Packet{ + sender: "WB5DEF-1", + base_callsign: "WB5DEF", + ssid: "1", + lat: Decimal.new("33.3000"), + lon: Decimal.new("-96.7000"), + has_position: true, + received_at: DateTime.truncate(DateTime.utc_now(), :second), + data_type: "position" + }) + + {:ok, view, _html} = live(conn, "/") + + # Test path with multiple digipeaters + render_hook(view, "marker_hover_start", %{ + "id" => "test-complex", + "path" => "N5ABC,WB5DEF-1,WIDE2-1,qAR,K5VOM-10", + "lat" => 33.2837, + "lng" => -96.5728 + }) + + # Should handle complex paths without errors + assert view.module == Index + end + + test "handles paths with asterisks correctly", %{conn: conn} do + {:ok, _station} = + Repo.insert(%Packet{ + sender: "WA5VHU-8", + base_callsign: "WA5VHU", + ssid: "8", + lat: Decimal.new("33.1500"), + lon: Decimal.new("-96.5500"), + has_position: true, + received_at: DateTime.truncate(DateTime.utc_now(), :second), + data_type: "position" + }) + + {:ok, view, _html} = live(conn, "/") + + # Test path with asterisks (used digipeaters) + render_hook(view, "marker_hover_start", %{ + "id" => "test-asterisk", + "path" => "WA5VHU-8,WIDE1*,WIDE2-1,qAR,K5VOM-10", + "lat" => 33.2837, + "lng" => -96.5728 + }) + + # Should handle asterisks without errors + assert view.module == Index + end + + test "filters out TCPIP from paths", %{conn: conn} do + {:ok, view, _html} = live(conn, "/") + + # Send a hover event with TCPIP in path + render_hook(view, "marker_hover_start", %{ + "id" => "test-2", + "path" => "TCPIP*,qAC,T2TEXAS", + "lat" => 33.2837, + "lng" => -96.5728 + }) + + # Should not crash and should filter out TCPIP + assert view.module == Index + end + + test "handles empty paths gracefully", %{conn: conn} do + {:ok, view, _html} = live(conn, "/") + + # Send hover event with empty path + render_hook(view, "marker_hover_start", %{"id" => "test-3", "path" => "", "lat" => 33.2837, "lng" => -96.5728}) + + # Should not crash + assert view.module == Index + end + + test "marker hover end event works", %{conn: conn} do + {:ok, view, _html} = live(conn, "/") + + # Send hover end event + render_hook(view, "marker_hover_end", %{"id" => "test-1"}) + + # Should not crash + assert view.module == Index + end + end + + describe "path station position queries" do + setup do + # Create test packets with positions + {:ok, _digi1} = + Repo.insert(%Packet{ + sender: "K5GVL-10", + base_callsign: "K5GVL", + ssid: "10", + lat: Decimal.new("33.1000"), + lon: Decimal.new("-96.6000"), + has_position: true, + received_at: DateTime.truncate(DateTime.utc_now(), :second), + data_type: "position" + }) + + {:ok, _digi2} = + Repo.insert(%Packet{ + sender: "N5TXZ-10", + base_callsign: "N5TXZ", + ssid: "10", + lat: Decimal.new("33.2000"), + lon: Decimal.new("-96.5000"), + has_position: true, + received_at: DateTime.truncate(DateTime.utc_now(), :second), + data_type: "position" + }) + + :ok + end + + test "finds positions for digipeaters in path", %{conn: conn} do + {:ok, view, _html} = live(conn, "/") + + # Send hover event with known digipeaters + render_hook(view, "marker_hover_start", %{ + "id" => "test-4", + "path" => "K5GVL-10*,N5TXZ-10", + "lat" => 33.2837, + "lng" => -96.5728 + }) + + # The function should find these stations + assert view.module == Index + end + end + + describe "RF path with stations outside bounds" do + setup do + # Create test packets - one inside a small bounds, one outside + # Station inside typical Texas bounds + {:ok, _inside_station} = + Repo.insert(%Packet{ + sender: "K5INSIDE-10", + base_callsign: "K5INSIDE", + ssid: "10", + # Inside Texas area + lat: Decimal.new("32.5000"), + # Inside Texas area + lon: Decimal.new("-96.5000"), + has_position: true, + received_at: DateTime.truncate(DateTime.utc_now(), :second), + data_type: "position" + }) + + # Station outside bounds (way outside in Boston area) + {:ok, _outside_station} = + Repo.insert(%Packet{ + sender: "W1OUTSIDE-10", + base_callsign: "W1OUTSIDE", + ssid: "10", + # Boston area - far from Texas + lat: Decimal.new("42.0000"), + # Boston area - far from Texas + lon: Decimal.new("-71.0000"), + has_position: true, + received_at: DateTime.truncate(DateTime.utc_now(), :second), + data_type: "position" + }) + + :ok + end + + test "includes path stations outside map bounds", %{conn: conn} do + {:ok, view, _html} = live(conn, "/") + + # Set map bounds to a small area around Texas that excludes the outside station + bounds = %{ + "north" => "33.0", + "south" => "32.0", + "east" => "-96.0", + "west" => "-97.0" + } + + render_hook(view, "bounds_changed", %{"bounds" => bounds}) + + # Now hover over a marker with a path that includes both inside and outside stations + render_hook(view, "marker_hover_start", %{ + "id" => "test-bounds", + # Both inside and outside stations + "path" => "K5INSIDE-10*,W1OUTSIDE-10", + "lat" => 32.5, + "lng" => -96.5 + }) + + # With the fix, both stations should be found regardless of bounds + # The test passes if no errors occur during the hover event + assert view.module == Index + end + + test "finds stations regardless of bounds constraints", %{conn: conn} do + {:ok, view, _html} = live(conn, "/") + + # Set very restrictive bounds that exclude both stations + bounds = %{ + "north" => "31.0", + "south" => "30.0", + "east" => "-95.0", + "west" => "-96.0" + } + + render_hook(view, "bounds_changed", %{"bounds" => bounds}) + + # Hover with path containing stations outside these bounds + render_hook(view, "marker_hover_start", %{ + "id" => "test-no-bounds", + "path" => "K5INSIDE-10,W1OUTSIDE-10", + "lat" => 32.5, + "lng" => -96.5 + }) + + # Should work without errors - stations found regardless of bounds + assert view.module == Index + end + end +end