defmodule MoundHunters.Boundary do @moduledoc """ Ohio state boundary checking using point-in-polygon algorithm. """ # Simplified Ohio bounding box for fast preliminary check # More precise polygon would be loaded from GeoJSON @ohio_bbox %{ min_lat: 38.403, max_lat: 42.327, min_lng: -84.820, max_lng: -80.519 } # Ohio state boundary polygon (simplified) # Source: Github -> PublicaMundi/MappingAPI us-states.json # Coordinates are [lng, lat] pairs per GeoJSON spec @ohio_polygon [ {-80.518598, 41.978802}, {-80.518598, 40.636951}, {-80.666475, 40.582182}, {-80.595275, 40.472643}, {-80.600752, 40.319289}, {-80.737675, 40.078303}, {-80.830783, 39.711348}, {-81.219646, 39.388209}, {-81.345616, 39.344393}, {-81.455155, 39.410117}, {-81.570170, 39.267716}, {-81.685186, 39.273193}, {-81.811156, 39.081500}, {-81.783771, 38.966484}, {-81.887833, 38.873376}, {-82.035710, 39.026731}, {-82.221926, 38.785745}, {-82.172634, 38.632391}, {-82.293127, 38.577622}, {-82.331465, 38.446175}, {-82.594358, 38.424267}, {-82.731282, 38.561191}, {-82.846298, 38.588575}, {-82.890113, 38.758361}, {-83.032514, 38.725499}, {-83.142052, 38.626914}, {-83.519961, 38.703591}, {-83.678792, 38.632391}, {-83.903347, 38.769315}, {-84.215533, 38.807653}, {-84.231963, 38.895284}, {-84.434610, 39.103408}, {-84.817996, 39.103408}, {-84.801565, 40.500028}, {-84.807042, 41.694001}, {-83.454238, 41.732339}, {-83.065375, 41.595416}, {-82.933929, 41.513262}, {-82.835344, 41.589939}, {-82.616266, 41.431108}, {-82.479343, 41.381815}, {-82.013803, 41.513262}, {-81.739956, 41.485877}, {-81.444201, 41.672093}, {-81.011523, 41.852832}, {-80.518598, 41.978802}, {-80.518598, 41.978802} ] @doc """ Check if coordinates are within Ohio boundaries. Returns :ok or {:error, reason} """ def check_bounds(lat, lng) when is_number(lat) and is_number(lng) do cond do not in_bounding_box?(lat, lng) -> {:error, "Coordinates outside Ohio bounding box"} not in_polygon?(lng, lat, @ohio_polygon) -> {:error, "Coordinates outside Ohio boundary"} true -> :ok end end def check_bounds(_lat, _lng) do {:error, "Invalid coordinates"} end defp in_bounding_box?(lat, lng) do lat >= @ohio_bbox.min_lat and lat <= @ohio_bbox.max_lat and lng >= @ohio_bbox.min_lng and lng <= @ohio_bbox.max_lng end # Ray casting algorithm for point-in-polygon test # Returns true if point (x, y) is inside the polygon defp in_polygon?(x, y, polygon) do n = length(polygon) polygon |> Enum.with_index() |> Enum.reduce(false, fn {{xi, yi}, i}, inside -> j = rem(i + n - 1, n) {xj, yj} = Enum.at(polygon, j) intersects = yi > y != yj > y and x < (xj - xi) * (y - yi) / (yj - yi) + xi if intersects, do: not inside, else: inside end) end @doc """ Format coordinates to 6 decimal places for consistent lookups. """ def format_lookup_id(lat, lng) do lat_str = :erlang.float_to_binary(lat / 1.0, decimals: 6) lng_str = :erlang.float_to_binary(lng / 1.0, decimals: 6) "#{lat_str},#{lng_str}" end end