diff --git a/lib/aprs_web/live/map_live/callsign_view.ex b/lib/aprs_web/live/map_live/callsign_view.ex index 735590f..d7ec274 100644 --- a/lib/aprs_web/live/map_live/callsign_view.ex +++ b/lib/aprs_web/live/map_live/callsign_view.ex @@ -4,7 +4,7 @@ defmodule AprsWeb.MapLive.CallsignView do alias Aprs.EncodingUtils alias Aprs.Packets alias AprsWeb.Endpoint - alias Parser.Types.MicE + alias AprsWeb.MapLive.MapHelpers @default_center %{lat: 39.0, lng: -98.0} @default_zoom 4 @@ -185,6 +185,10 @@ defmodule AprsWeb.MapLive.CallsignView do {:noreply, socket} end + def handle_event("marker_clicked", %{"id" => _id, "callsign" => _callsign, "lat" => _lat, "lng" => _lng}, socket) do + {:noreply, socket} + end + defp handle_bounds_update(bounds, socket) do # Convert string keys to atom keys and parse values normalized_bounds = %{ @@ -197,12 +201,12 @@ defmodule AprsWeb.MapLive.CallsignView do # Remove out-of-bounds visible packets new_visible_packets = socket.assigns.visible_packets - |> Enum.filter(fn {_k, packet} -> within_bounds?(packet, normalized_bounds) end) + |> Enum.filter(fn {_k, packet} -> MapHelpers.within_bounds?(packet, normalized_bounds) end) |> Map.new() markers_to_remove = socket.assigns.visible_packets - |> Enum.reject(fn {_k, packet} -> within_bounds?(packet, normalized_bounds) end) + |> Enum.reject(fn {_k, packet} -> MapHelpers.within_bounds?(packet, normalized_bounds) end) |> Enum.map(fn {k, _} -> k end) socket = @@ -265,14 +269,14 @@ defmodule AprsWeb.MapLive.CallsignView do %Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: payload} -> sanitized_packet = EncodingUtils.sanitize_packet(payload) sanitized_packet = Map.put_new(sanitized_packet, :received_at, DateTime.utc_now()) - {lat, lng} = get_coordinates(sanitized_packet) + {lat, lng, _} = MapHelpers.get_coordinates(sanitized_packet) callsign_key = "#{sanitized_packet.base_callsign}#{if sanitized_packet.ssid, do: "-#{sanitized_packet.ssid}", else: ""}" - if has_position_data?(sanitized_packet) and + if MapHelpers.has_position_data?(sanitized_packet) and packet_matches_callsign?(sanitized_packet, socket.assigns.callsign) and - within_bounds?(%{lat: lat, lon: lng}, socket.assigns.map_bounds) and + MapHelpers.within_bounds?(%{lat: lat, lon: lng}, socket.assigns.map_bounds) and packet_within_time_threshold?( sanitized_packet, socket.assigns.packet_age_threshold @@ -365,7 +369,7 @@ defmodule AprsWeb.MapLive.CallsignView do end defp handle_valid_replay_packet(packet, packet_data, socket) do - historical_packets = Map.put(socket.assigns.historical_packets, packet_data.id, packet) + historical_packets = Map.put(socket.assigns.historical_packets, packet_data["id"], packet) socket = push_event(socket, "historical_packet", Map.put(packet_data, :historical, true)) delay = trunc(1000 / socket.assigns.replay_speed) timer_ref = Process.send_after(self(), :replay_next_packet, delay) @@ -737,119 +741,98 @@ defmodule AprsWeb.MapLive.CallsignView do }) end - defp has_position_data?(packet) do - case packet.data_extended do - %MicE{} -> - true - - %{latitude: lat, longitude: lon} when not is_nil(lat) and not is_nil(lon) -> - true - - _ -> - lat = Map.get(packet, :lat) || Map.get(packet, "lat") - lon = Map.get(packet, :lon) || Map.get(packet, "lon") - not is_nil(lat) and not is_nil(lon) - end - end - - defp get_coordinates(%{data_extended: %MicE{} = mic_e}), do: get_coordinates_from_mic_e(mic_e) - defp get_coordinates(%{data_extended: %{latitude: lat, longitude: lon}}), do: {lat, lon} - defp get_coordinates(_), do: {nil, nil} - - defp get_coordinates_from_mic_e(mic_e) do - lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0 + mic_e.lat_fractional / 6000.0 - lat = if mic_e.lat_direction == :south, do: -lat, else: lat - lng = mic_e.lon_degrees + mic_e.lon_minutes / 60.0 + mic_e.lon_fractional / 6000.0 - lng = if mic_e.lon_direction == :west, do: -lng, else: lng - if lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180, do: {lat, lng}, else: {nil, nil} - end - defp build_packet_data(packet) do - {lat, lng} = get_coordinates(packet) + {lat, lng, _} = MapHelpers.get_coordinates(packet) + callsign = Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", "")) - if lat && lng do - callsign = "#{packet.base_callsign}#{if packet.ssid, do: "-#{packet.ssid}", else: ""}" - - symbol_table_id = get_symbol_table_id(packet.data_extended) - symbol_code = get_symbol_code(packet.data_extended) - comment = get_comment(packet.data_extended) - - %{ - id: "#{callsign}_#{:os.system_time(:millisecond)}", - callsign: callsign, - lat: lat, - lng: lng, - symbol_table_id: symbol_table_id, - symbol_code: symbol_code, - comment: comment, - received_at: packet.received_at || DateTime.utc_now(), - data_extended: build_data_extended(packet.data_extended) - } + if lat != nil and lng != nil and callsign != "" and callsign != nil do + build_packet_map(packet, lat, lng, packet.data_extended) end end - defp get_symbol_table_id(data) do - cond do - is_map(data) && (Map.get(data, :symbol_table_id) || Map.get(data, "symbol_table_id")) -> - Map.get(data, :symbol_table_id) || Map.get(data, "symbol_table_id") + defp build_packet_map(packet, lat, lng, data_extended) do + data_extended = data_extended || %{} - is_map(data) && Map.has_key?(data, :packet) && - (Map.get(data.packet, :symbol_table_id) || Map.get(data.packet, "symbol_table_id")) -> - Map.get(data.packet, :symbol_table_id) || Map.get(data.packet, "symbol_table_id") + callsign = + case {Map.get(packet, :base_callsign), Map.get(packet, :ssid)} do + {base, ssid} when is_binary(base) and ssid not in [nil, "", "0"] -> + "#{base}-#{ssid}" - is_map(data) && Map.has_key?(data, "packet") && - (Map.get(data["packet"], :symbol_table_id) || Map.get(data["packet"], "symbol_table_id")) -> - Map.get(data["packet"], :symbol_table_id) || Map.get(data["packet"], "symbol_table_id") + {base, _} -> + base || "" + end - true -> - "/" - end - end + symbol_table_id = + Map.get(data_extended, :symbol_table_id) || Map.get(data_extended, "symbol_table_id") || "/" - defp get_symbol_code(data) do - cond do - is_map(data) && (Map.get(data, :symbol_code) || Map.get(data, "symbol_code")) -> - Map.get(data, :symbol_code) || Map.get(data, "symbol_code") + symbol_code = + Map.get(data_extended, :symbol_code) || Map.get(data_extended, "symbol_code") || ">" - is_map(data) && Map.has_key?(data, :packet) && - (Map.get(data.packet, :symbol_code) || Map.get(data.packet, "symbol_code")) -> - Map.get(data.packet, :symbol_code) || Map.get(data.packet, "symbol_code") + symbol_description = + Map.get(data_extended, :symbol_description) || Map.get(data_extended, "symbol_description") || + "Symbol: #{symbol_table_id}#{symbol_code}" - is_map(data) && Map.has_key?(data, "packet") && - (Map.get(data["packet"], :symbol_code) || Map.get(data["packet"], "symbol_code")) -> - Map.get(data["packet"], :symbol_code) || Map.get(data["packet"], "symbol_code") + timestamp = + cond do + Map.has_key?(packet, :received_at) && packet.received_at -> + DateTime.to_iso8601(packet.received_at) - true -> - ">" - end - end + Map.has_key?(packet, "received_at") && packet["received_at"] -> + DateTime.to_iso8601(packet["received_at"]) - defp get_comment(%{comment: comment}) when is_binary(comment), do: comment - defp get_comment(_), do: "" + true -> + "" + end - defp build_data_extended(nil), do: nil + comment = Map.get(data_extended, :comment) || Map.get(data_extended, "comment") || "" - defp build_data_extended(data_extended) do - case data_extended do - %MicE{} = mic_e -> - %{ - type: "MicE", - latitude: mic_e.lat_degrees + mic_e.lat_minutes / 60.0 + mic_e.lat_fractional / 6000.0, - longitude: mic_e.lon_degrees + mic_e.lon_minutes / 60.0 + mic_e.lon_fractional / 6000.0, - symbol_table_id: Map.get(mic_e, :symbol_table_id, "/"), - symbol_code: Map.get(mic_e, :symbol_code, ">"), - comment: Map.get(mic_e, :comment, "") - } + to_float = fn + %Decimal{} = d -> + Decimal.to_float(d) - %{} = data -> - # Convert struct to map if needed, filtering out private fields - data - |> Map.from_struct() - |> Map.delete(:__meta__) + n when is_float(n) -> + n + + n when is_integer(n) -> + n * 1.0 + + n when is_binary(n) -> + case Float.parse(n) do + {f, _} -> f + :error -> 0.0 + end _ -> - nil + 0.0 end + + popup = """ +
+
#{callsign}
+
#{symbol_description}
+ #{if comment == "", do: "", else: "
#{comment}
"} +
#{Float.round(to_float.(lat), 4)}, #{Float.round(to_float.(lng), 4)}
+
#{timestamp}
+
+ """ + + %{ + "id" => callsign, + "callsign" => callsign, + "base_callsign" => Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", "")), + "ssid" => Map.get(packet, :ssid, Map.get(packet, "ssid", 0)), + "lat" => to_float.(lat), + "lng" => to_float.(lng), + "data_type" => to_string(Map.get(packet, :data_type, Map.get(packet, "data_type", "unknown"))), + "path" => Map.get(packet, :path, Map.get(packet, "path", "")), + "comment" => comment, + "data_extended" => data_extended || %{}, + "symbol_table_id" => symbol_table_id, + "symbol_code" => symbol_code, + "symbol_description" => symbol_description, + "timestamp" => timestamp, + "popup" => popup + } end defp packet_matches_callsign?(packet, target_callsign) do @@ -900,7 +883,7 @@ defmodule AprsWeb.MapLive.CallsignView do latest_packet = %{callsign: callsign} |> Packets.get_recent_packets() - |> Enum.filter(&has_position_data?/1) + |> Enum.filter(&MapHelpers.has_position_data?/1) |> Enum.sort_by(& &1.received_at, {:desc, DateTime}) |> List.first() @@ -910,7 +893,7 @@ defmodule AprsWeb.MapLive.CallsignView do nil packet -> - {lat, lng} = get_coordinates(packet) + {lat, lng, _} = MapHelpers.get_coordinates(packet) if lat && lng, do: %{lat: lat, lng: lng} end @@ -960,43 +943,4 @@ defmodule AprsWeb.MapLive.CallsignView do latest_symbol_code: latest_symbol_code ) end - - # Helper to check if a packet or lat/lon is within bounds - defp within_bounds?(packet_or_coords, bounds) do - {lat, lon} = - cond do - is_map(packet_or_coords) and Map.has_key?(packet_or_coords, :lat) and - Map.has_key?(packet_or_coords, :lon) -> - {packet_or_coords.lat, packet_or_coords.lon} - - is_map(packet_or_coords) and Map.has_key?(packet_or_coords, "lat") and - Map.has_key?(packet_or_coords, "lon") -> - {packet_or_coords["lat"], packet_or_coords["lon"]} - - is_map(packet_or_coords) and Map.has_key?(packet_or_coords, :latitude) and - Map.has_key?(packet_or_coords, :longitude) -> - {packet_or_coords.latitude, packet_or_coords.longitude} - - is_tuple(packet_or_coords) and tuple_size(packet_or_coords) == 2 -> - packet_or_coords - - true -> - {nil, nil} - end - - if is_nil(lat) or is_nil(lon) do - false - else - lat_in_bounds = lat >= bounds.south && lat <= bounds.north - - lng_in_bounds = - if bounds.west <= bounds.east do - lon >= bounds.west && lon <= bounds.east - else - lon >= bounds.west || lon <= bounds.east - end - - lat_in_bounds && lng_in_bounds - end - end end diff --git a/lib/aprs_web/live/map_live/index.ex b/lib/aprs_web/live/map_live/index.ex index 84994b6..ebe947a 100644 --- a/lib/aprs_web/live/map_live/index.ex +++ b/lib/aprs_web/live/map_live/index.ex @@ -5,6 +5,7 @@ defmodule AprsWeb.MapLive.Index do use AprsWeb, :live_view alias AprsWeb.Endpoint + alias AprsWeb.MapLive.MapHelpers alias Phoenix.LiveView.Socket require Logger @@ -391,7 +392,7 @@ defmodule AprsWeb.MapLive.Index do end defp handle_info_postgres_packet(packet, socket) do - {lat, lon, _data_extended} = get_coordinates(packet) + {lat, lon, _data_extended} = MapHelpers.get_coordinates(packet) callsign_key = if Map.has_key?(packet, "id"), @@ -399,7 +400,7 @@ defmodule AprsWeb.MapLive.Index do else: System.unique_integer([:positive]) Logger.debug( - "[MAP] Incoming packet: id=#{inspect(callsign_key)} lat=#{inspect(lat)} lon=#{inspect(lon)} bounds=#{inspect(socket.assigns.map_bounds)} within_bounds?=#{inspect(within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds))}" + "[MAP] Incoming packet: id=#{inspect(callsign_key)} lat=#{inspect(lat)} lon=#{inspect(lon)} bounds=#{inspect(socket.assigns.map_bounds)} within_bounds?=#{inspect(MapHelpers.within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds))}" ) all_packets = Map.put(socket.assigns.all_packets, callsign_key, packet) @@ -408,7 +409,7 @@ defmodule AprsWeb.MapLive.Index do # Remove marker if packet is out of bounds but present if !is_nil(lat) and !is_nil(lon) and Map.has_key?(socket.assigns.visible_packets, callsign_key) and - not within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds) do + not MapHelpers.within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds) do socket = push_event(socket, "remove_marker", %{id: callsign_key}) new_visible_packets = Map.delete(socket.assigns.visible_packets, callsign_key) {:noreply, assign(socket, visible_packets: new_visible_packets)} @@ -417,7 +418,7 @@ defmodule AprsWeb.MapLive.Index do if is_nil(lat) or is_nil(lon) or Map.has_key?(socket.assigns.visible_packets, callsign_key) do {:noreply, socket} else - if within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds) do + if MapHelpers.within_bounds?(%{lat: lat, lon: lon}, socket.assigns.map_bounds) do handle_valid_postgres_packet(packet, lat, lon, socket) else {:noreply, socket} @@ -755,7 +756,7 @@ defmodule AprsWeb.MapLive.Index do @spec within_bounds?(map() | struct(), map()) :: boolean() defp within_bounds?(packet, bounds) do - {lat, lon, _data_extended} = get_coordinates(packet) + {lat, lon, _data_extended} = MapHelpers.get_coordinates(packet) # Basic validation if is_nil(lat) or is_nil(lon) do @@ -778,45 +779,9 @@ defmodule AprsWeb.MapLive.Index do end end - @spec get_coordinates(map() | struct()) :: {number() | nil, number() | nil, map() | nil} - defp get_coordinates(packet) do - # Safely get data_extended for both atom and string keys - data_extended = Map.get(packet, :data_extended) || Map.get(packet, "data_extended") - - lat = - Map.get(packet, :lat) || - Map.get(packet, "lat") || - (is_map(data_extended) && - (Map.get(data_extended, :latitude) || Map.get(data_extended, "latitude"))) - - lon = - Map.get(packet, :lon) || - Map.get(packet, "lon") || - (is_map(data_extended) && - (Map.get(data_extended, :longitude) || Map.get(data_extended, "longitude"))) - - lat = - cond do - is_struct(lat, Decimal) -> Decimal.to_float(lat) - is_float(lat) -> lat - is_integer(lat) -> lat * 1.0 - true -> lat - end - - lon = - cond do - is_struct(lon, Decimal) -> Decimal.to_float(lon) - is_float(lon) -> lon - is_integer(lon) -> lon * 1.0 - true -> lon - end - - {lat, lon, data_extended} - end - @spec build_packet_data(map() | struct()) :: map() | nil defp build_packet_data(packet) do - {lat, lon, data_extended} = get_coordinates(packet) + {lat, lon, data_extended} = MapHelpers.get_coordinates(packet) callsign = Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", "")) # Only include packets with valid position data and a non-empty callsign if lat && lon && callsign != "" && callsign != nil do @@ -853,12 +818,32 @@ defmodule AprsWeb.MapLive.Index do comment = Map.get(data_extended, :comment) || Map.get(data_extended, "comment") || "" + to_float = fn + %Decimal{} = d -> + Decimal.to_float(d) + + n when is_float(n) -> + n + + n when is_integer(n) -> + n * 1.0 + + n when is_binary(n) -> + case Float.parse(n) do + {f, _} -> f + :error -> 0.0 + end + + _ -> + 0.0 + end + popup = """
#{callsign}
#{symbol_description}
#{if comment == "", do: "", else: "
#{comment}
"} -
#{Float.round(lat, 4)}, #{Float.round(lon, 4)}
+
#{Float.round(to_float.(lat), 4)}, #{Float.round(to_float.(lon), 4)}
#{timestamp}
""" @@ -868,8 +853,8 @@ defmodule AprsWeb.MapLive.Index do "callsign" => callsign, "base_callsign" => Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", "")), "ssid" => Map.get(packet, :ssid, Map.get(packet, "ssid", 0)), - "lat" => lat, - "lng" => lon, + "lat" => to_float.(lat), + "lng" => to_float.(lon), "data_type" => to_string(Map.get(packet, :data_type, Map.get(packet, "data_type", "unknown"))), "path" => Map.get(packet, :path, Map.get(packet, "path", "")), "comment" => comment, diff --git a/lib/aprs_web/live/map_live/map_helpers.ex b/lib/aprs_web/live/map_live/map_helpers.ex new file mode 100644 index 0000000..4aa3915 --- /dev/null +++ b/lib/aprs_web/live/map_live/map_helpers.ex @@ -0,0 +1,118 @@ +defmodule AprsWeb.MapLive.MapHelpers do + @moduledoc """ + Shared helpers for APRS map LiveViews (main map, callsign map, etc). + Provides coordinate extraction, position checks, and bounds logic. + """ + + alias Parser.Types.MicE + + @spec get_coordinates(map() | struct()) :: {number() | nil, number() | nil, map() | nil} + def get_coordinates(%{data_extended: %MicE{} = mic_e}) do + {lat, lon} = get_coordinates_from_mic_e(mic_e) + {lat, lon, mic_e} + end + + def get_coordinates(%{data_extended: %{latitude: lat, longitude: lon}} = packet) do + {lat, lon, packet.data_extended} + end + + def get_coordinates(packet) do + lat = Map.get(packet, :lat) || Map.get(packet, "lat") + lon = Map.get(packet, :lon) || Map.get(packet, "lon") + data_extended = Map.get(packet, :data_extended) || Map.get(packet, "data_extended") + {lat, lon, data_extended} + end + + @spec get_coordinates_from_mic_e(MicE.t()) :: {number() | nil, number() | nil} + def get_coordinates_from_mic_e(mic_e) do + lat = mic_e.lat_degrees + mic_e.lat_minutes / 60.0 + mic_e.lat_fractional / 6000.0 + lat = if mic_e.lat_direction == :south, do: -lat, else: lat + lon = mic_e.lon_degrees + mic_e.lon_minutes / 60.0 + mic_e.lon_fractional / 6000.0 + lon = if mic_e.lon_direction == :west, do: -lon, else: lon + if lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180, do: {lat, lon}, else: {nil, nil} + end + + @spec has_position_data?(map()) :: boolean() + def has_position_data?(packet) do + lat = Map.get(packet, :lat) || Map.get(packet, "lat") + lon = Map.get(packet, :lon) || Map.get(packet, "lon") + + if not is_nil(lat) and not is_nil(lon) do + true + else + data_extended = Map.get(packet, :data_extended) || Map.get(packet, "data_extended") + + case data_extended do + %MicE{} -> true + %{latitude: lat, longitude: lon} when not is_nil(lat) and not is_nil(lon) -> true + _ -> false + end + end + end + + @spec within_bounds?(map() | tuple(), map()) :: boolean() + def within_bounds?(packet_or_coords, bounds) do + to_float = fn + n when is_float(n) -> + n + + n when is_integer(n) -> + n * 1.0 + + %Decimal{} = d -> + Decimal.to_float(d) + + n when is_binary(n) -> + case Float.parse(n) do + {f, _} -> f + :error -> 0.0 + end + + _ -> + 0.0 + end + + {lat, lon} = + cond do + is_map(packet_or_coords) and Map.has_key?(packet_or_coords, :lat) and + Map.has_key?(packet_or_coords, :lon) -> + {packet_or_coords.lat, packet_or_coords.lon} + + is_map(packet_or_coords) and Map.has_key?(packet_or_coords, "lat") and + Map.has_key?(packet_or_coords, "lon") -> + {packet_or_coords["lat"], packet_or_coords["lon"]} + + is_map(packet_or_coords) and Map.has_key?(packet_or_coords, :latitude) and + Map.has_key?(packet_or_coords, :longitude) -> + {packet_or_coords.latitude, packet_or_coords.longitude} + + is_tuple(packet_or_coords) and tuple_size(packet_or_coords) == 2 -> + packet_or_coords + + true -> + {nil, nil} + end + + if is_nil(lat) or is_nil(lon) do + false + else + lat = to_float.(lat) + lon = to_float.(lon) + south = to_float.(bounds.south) + north = to_float.(bounds.north) + west = to_float.(bounds.west) + east = to_float.(bounds.east) + + lat_in_bounds = lat >= south && lat <= north + + lng_in_bounds = + if west <= east do + lon >= west && lon <= east + else + lon >= west || lon <= east + end + + lat_in_bounds && lng_in_bounds + end + end +end diff --git a/test/aprs_web/live/map_live/map_helpers_test.exs b/test/aprs_web/live/map_live/map_helpers_test.exs new file mode 100644 index 0000000..1495ff6 --- /dev/null +++ b/test/aprs_web/live/map_live/map_helpers_test.exs @@ -0,0 +1,127 @@ +defmodule AprsWeb.MapLive.MapHelpersTest do + use ExUnit.Case, async: true + + alias AprsWeb.MapLive.MapHelpers + alias Parser.Types.MicE + + describe "get_coordinates/1" do + test "returns lat/lon/data_extended for map with lat/lon" do + packet = %{lat: 10.0, lon: 20.0, data_extended: %{foo: :bar}} + assert MapHelpers.get_coordinates(packet) == {10.0, 20.0, %{foo: :bar}} + end + + test "returns lat/lon/data_extended for map with latitude/longitude in data_extended" do + packet = %{data_extended: %{latitude: 11.1, longitude: 22.2}} + + assert MapHelpers.get_coordinates(packet) == + {11.1, 22.2, %{latitude: 11.1, longitude: 22.2}} + end + + test "returns lat/lon/mic_e for MicE struct" do + mic_e = %MicE{ + lat_degrees: 12, + lat_minutes: 34, + lat_fractional: 0, + lat_direction: :north, + lon_degrees: 56, + lon_minutes: 78, + lon_fractional: 0, + lon_direction: :east + } + + packet = %{data_extended: mic_e} + {lat, lon, ext} = MapHelpers.get_coordinates(packet) + assert is_number(lat) and is_number(lon) + assert ext == mic_e + end + + test "returns {nil, nil, nil} for missing data" do + assert MapHelpers.get_coordinates(%{}) == {nil, nil, nil} + end + end + + describe "get_coordinates_from_mic_e/1" do + test "returns correct lat/lon for valid MicE" do + mic_e = %MicE{ + lat_degrees: 10, + lat_minutes: 30, + lat_fractional: 0, + lat_direction: :north, + lon_degrees: 20, + lon_minutes: 40, + lon_fractional: 0, + lon_direction: :east + } + + {lat, lon} = MapHelpers.get_coordinates_from_mic_e(mic_e) + assert_in_delta lat, 10.5, 0.0001 + assert_in_delta lon, 20.6667, 0.0001 + end + + test "returns {nil, nil} for out-of-bounds" do + mic_e = %MicE{ + lat_degrees: 100, + lat_minutes: 0, + lat_fractional: 0, + lat_direction: :north, + lon_degrees: 200, + lon_minutes: 0, + lon_fractional: 0, + lon_direction: :east + } + + assert MapHelpers.get_coordinates_from_mic_e(mic_e) == {nil, nil} + end + end + + describe "has_position_data?/1" do + test "true for MicE in data_extended" do + mic_e = %MicE{ + lat_degrees: 1, + lat_minutes: 1, + lat_fractional: 0, + lat_direction: :north, + lon_degrees: 1, + lon_minutes: 1, + lon_fractional: 0, + lon_direction: :east + } + + assert MapHelpers.has_position_data?(%{data_extended: mic_e}) + end + + test "true for latitude/longitude in data_extended" do + assert MapHelpers.has_position_data?(%{data_extended: %{latitude: 1, longitude: 2}}) + end + + test "true for lat/lon at top level" do + assert MapHelpers.has_position_data?(%{lat: 1, lon: 2}) + end + + test "false for missing position" do + refute MapHelpers.has_position_data?(%{}) + end + end + + describe "within_bounds?/2" do + test "true for point in bounds" do + bounds = %{north: 10, south: 0, east: 10, west: 0} + assert MapHelpers.within_bounds?(%{lat: 5, lon: 5}, bounds) + end + + test "false for point out of bounds" do + bounds = %{north: 10, south: 0, east: 10, west: 0} + refute MapHelpers.within_bounds?(%{lat: 15, lon: 5}, bounds) + end + + test "true for tuple input" do + bounds = %{north: 10, south: 0, east: 10, west: 0} + assert MapHelpers.within_bounds?({5, 5}, bounds) + end + + test "false for nil input" do + bounds = %{north: 10, south: 0, east: 10, west: 0} + refute MapHelpers.within_bounds?(%{}, bounds) + end + end +end