aprs.me/lib/aprsme_web/live/shared/bounds_utils.ex

153 lines
5.1 KiB
Elixir

defmodule AprsmeWeb.Live.Shared.BoundsUtils do
@moduledoc """
Shared bounds validation, comparison, and filtering utilities.
Used across multiple LiveView modules for consistent bounds handling.
"""
alias AprsmeWeb.Live.Shared.CoordinateUtils
@doc """
Validate bounds to prevent invalid coordinates.
"""
@spec valid_bounds?(map()) :: boolean()
def valid_bounds?(%{north: north, south: south, east: east, west: west})
when is_number(north) and is_number(south) and is_number(east) and is_number(west) and north <= 90 and
south >= -90 and north > south and east >= -180 and west <= 180, do: true
def valid_bounds?(_invalid_bounds), do: false
@doc """
Compare two bounds maps for equality (with rounding for floating point comparison).
"""
@spec compare_bounds(map() | nil, map() | nil) :: boolean()
def compare_bounds(nil, nil), do: true
def compare_bounds(nil, _), do: false
def compare_bounds(_, nil), do: false
def compare_bounds(b1, b2), do: compare_bounds_maps(b1, b2)
@doc """
Check if a packet is within the specified bounds.
"""
@spec within_bounds?(map() | struct() | tuple(), map()) :: boolean()
def within_bounds?({lat, lon}, bounds) when is_number(lat) and is_number(lon) do
check_coordinate_bounds(lat, lon, bounds)
end
def within_bounds?(packet, bounds) do
{lat, lon, _data_extended} = CoordinateUtils.get_coordinates(packet)
check_coordinate_bounds(lat, lon, bounds)
end
@doc """
Filter packets by bounds - works with both maps and lists.
"""
@spec filter_packets_by_bounds(map() | list(), map()) :: map() | list()
def filter_packets_by_bounds(packets_map, bounds) when is_map(packets_map) do
packets_map
|> Enum.filter(fn {_k, packet} -> within_bounds?(packet, bounds) end)
|> Map.new()
end
def filter_packets_by_bounds(packets_list, bounds) when is_list(packets_list) do
Enum.filter(packets_list, &within_bounds?(&1, bounds))
end
@doc """
Get packet keys that are outside the specified bounds.
"""
@spec reject_packets_by_bounds(map(), map()) :: list()
def reject_packets_by_bounds(packets_map, bounds) when is_map(packets_map) do
packets_map
|> Enum.reject(fn {_k, packet} -> within_bounds?(packet, bounds) end)
|> Enum.map(fn {k, _} -> k end)
end
@doc """
Normalize map bounds from string keys to atom keys and convert values to floats.
"""
@spec normalize_bounds(map()) :: map() | nil
def normalize_bounds(%{"north" => n, "south" => s, "east" => e, "west" => w}) do
%{
north: to_float(n),
south: to_float(s),
east: to_float(e),
west: to_float(w)
}
end
def normalize_bounds(%{north: _, south: _, east: _, west: _} = bounds) do
# Already normalized with atom keys
bounds
end
def normalize_bounds(_), do: nil
@doc """
Calculate approximate bounds based on center point and zoom level.
"""
@spec calculate_bounds_from_center_and_zoom(map(), integer()) :: map()
def calculate_bounds_from_center_and_zoom(center, zoom) do
# Approximate degrees per pixel at different zoom levels
degrees_per_pixel = calculate_degrees_per_pixel(zoom)
# Assume viewport is roughly 800x600 pixels
# Half of 600px height
lat_offset = degrees_per_pixel * 300
# Half of 800px width
lng_offset = degrees_per_pixel * 400
%{
north: center.lat + lat_offset,
south: center.lat - lat_offset,
east: center.lng + lng_offset,
west: center.lng - lng_offset
}
end
# Private helper functions
defp compare_bounds_maps(b1, b2) do
Enum.all?([:north, :south, :east, :west], fn key ->
round_to_4_places(Map.get(b1, key)) == round_to_4_places(Map.get(b2, key))
end)
end
defp check_coordinate_bounds(nil, _, _), do: false
defp check_coordinate_bounds(_, nil, _), do: false
defp check_coordinate_bounds(lat, lon, bounds) do
# Check latitude bounds (straightforward)
lat_in_bounds = lat >= bounds.south && lat <= bounds.north
# Check longitude bounds (handle potential wrapping)
lng_in_bounds = check_longitude_bounds(lon, bounds.west, bounds.east)
lat_in_bounds && lng_in_bounds
end
defp check_longitude_bounds(lon, west, east) when west <= east do
# Normal case: bounds don't cross antimeridian
lon >= west && lon <= east
end
defp check_longitude_bounds(lon, west, east) do
# Bounds cross antimeridian (e.g., west=170, east=-170)
lon >= west || lon <= east
end
defp calculate_degrees_per_pixel(zoom) when zoom >= 15, do: 0.000005
defp calculate_degrees_per_pixel(zoom) when zoom >= 12, do: 0.00005
defp calculate_degrees_per_pixel(zoom) when zoom >= 10, do: 0.0002
defp calculate_degrees_per_pixel(zoom) when zoom >= 8, do: 0.001
defp calculate_degrees_per_pixel(zoom) when zoom >= 6, do: 0.005
defp calculate_degrees_per_pixel(zoom) when zoom >= 4, do: 0.02
defp calculate_degrees_per_pixel(_), do: 0.1
defp round_to_4_places(n) when is_float(n), do: Float.round(n, 4)
defp round_to_4_places(n) when is_integer(n), do: n * 1.0
defp round_to_4_places(x), do: x
defp to_float(value) do
Aprsme.EncodingUtils.to_float(value) || 0.0
end
end