defmodule Aerospike.Geo do @moduledoc """ Typed geospatial values for Aerospike GeoJSON bins. Use `point/2`, `polygon/1`, and `circle/3` to build values that can be written directly to geo bins. `to_json/1` and `from_json/1` convert between those structs and GeoJSON strings. Unsupported or malformed GeoJSON is preserved as `{:geojson, json}` so callers can keep round-tripping values the client does not model directly. """ defmodule Point do @moduledoc """ GeoJSON Point geometry using `[longitude, latitude]` coordinates. """ @enforce_keys [:lng, :lat] defstruct [:lng, :lat] @typedoc """ GeoJSON point using longitude and latitude. Coordinates are normalized to floats by `Aerospike.Geo.point/2`. """ @type t :: %__MODULE__{ lng: float(), lat: float() } end defmodule Polygon do @moduledoc """ GeoJSON Polygon geometry using rings of `[longitude, latitude]` coordinates. """ @enforce_keys [:coordinates] defstruct [:coordinates] @typedoc "Longitude/latitude coordinate pair." @type coordinate_pair :: {float(), float()} @typedoc "One polygon ring represented as longitude/latitude pairs." @type ring :: [coordinate_pair()] @typedoc """ GeoJSON polygon with one or more rings. Coordinates are normalized to floats by `Aerospike.Geo.polygon/1`. """ @type t :: %__MODULE__{ coordinates: [ring()] } end defmodule Circle do @moduledoc """ Aerospike `AeroCircle` geometry using center longitude, latitude, and radius. """ @enforce_keys [:lng, :lat, :radius] defstruct [:lng, :lat, :radius] @typedoc """ Aerospike AeroCircle value. `radius` is expressed in meters, matching Aerospike's GeoJSON extension. """ @type t :: %__MODULE__{ lng: float(), lat: float(), radius: float() } end @typedoc "Supported typed geospatial values accepted by geo bins and filters." @type t :: Point.t() | Polygon.t() | Circle.t() @typedoc "Fallback for unsupported or malformed GeoJSON strings." @type raw_geojson :: {:geojson, String.t()} @doc """ Builds a point from longitude and latitude. """ @spec point(number(), number()) :: Point.t() def point(lng, lat) when is_number(lng) and is_number(lat) do %Point{lng: to_float(lng), lat: to_float(lat)} end @doc """ Builds a polygon from rings of `{longitude, latitude}` coordinate tuples. """ @spec polygon([[{number(), number()}]]) :: Polygon.t() def polygon(coordinates) when is_list(coordinates) do %Polygon{coordinates: normalize_rings(coordinates)} end @doc """ Builds an Aerospike circle from center longitude, latitude, and radius. """ @spec circle(number(), number(), number()) :: Circle.t() def circle(lng, lat, radius) when is_number(lng) and is_number(lat) and is_number(radius) do %Circle{lng: to_float(lng), lat: to_float(lat), radius: to_float(radius)} end @doc """ Encodes a typed geo value as a GeoJSON string. """ @spec to_json(t()) :: String.t() def to_json(%Point{lng: lng, lat: lat}) do coordinates_json = Jason.encode!([lng, lat]) "{\"type\":\"Point\",\"coordinates\":#{coordinates_json}}" end def to_json(%Polygon{coordinates: coordinates}) do polygon_coordinates = Enum.map(coordinates, fn ring -> Enum.map(ring, fn {lng, lat} -> [lng, lat] end) end) coordinates_json = Jason.encode!(polygon_coordinates) "{\"type\":\"Polygon\",\"coordinates\":#{coordinates_json}}" end def to_json(%Circle{lng: lng, lat: lat, radius: radius}) do coordinates_json = Jason.encode!([[lng, lat], radius]) "{\"type\":\"AeroCircle\",\"coordinates\":#{coordinates_json}}" end @doc """ Decodes a GeoJSON string into a typed value when the shape is supported. Unknown geometry types, unsupported coordinate shapes, and invalid JSON return `{:geojson, json}` with the original string. """ @spec from_json(String.t()) :: t() | raw_geojson() def from_json(json) when is_binary(json) do case Jason.decode(json) do {:ok, decoded} -> from_decoded_geojson(decoded, json) {:error, _reason} -> {:geojson, json} end end defp normalize_rings(rings) do Enum.map(rings, &normalize_ring/1) end defp normalize_ring(ring) do Enum.map(ring, &normalize_point/1) end defp normalize_point({lng, lat}) when is_number(lng) and is_number(lat) do {to_float(lng), to_float(lat)} end defp decode_polygon_rings(rings) when is_list(rings) do decode_polygon_rings(rings, []) end defp decode_polygon_rings([], acc), do: {:ok, Enum.reverse(acc)} defp decode_polygon_rings([ring | rest], acc) when is_list(ring) do case decode_polygon_ring(ring, []) do {:ok, normalized_ring} -> decode_polygon_rings(rest, [normalized_ring | acc]) :error -> :error end end defp decode_polygon_rings(_rings, _acc), do: :error defp decode_polygon_ring([], acc), do: {:ok, Enum.reverse(acc)} defp decode_polygon_ring([[lng, lat] | rest], acc) when is_number(lng) and is_number(lat) do decode_polygon_ring(rest, [{to_float(lng), to_float(lat)} | acc]) end defp decode_polygon_ring(_ring, _acc), do: :error defp from_decoded_geojson(%{"type" => "Point", "coordinates" => [lng, lat]}, _json) when is_number(lng) and is_number(lat) do point(lng, lat) end defp from_decoded_geojson(%{"type" => "Polygon", "coordinates" => rings}, json) when is_list(rings) do case decode_polygon_rings(rings) do {:ok, polygon_rings} -> polygon(polygon_rings) :error -> {:geojson, json} end end defp from_decoded_geojson( %{"type" => "AeroCircle", "coordinates" => [[lng, lat], radius]}, _json ) when is_number(lng) and is_number(lat) and is_number(radius) do circle(lng, lat, radius) end defp from_decoded_geojson(_decoded, json), do: {:geojson, json} defp to_float(number) when is_float(number), do: number defp to_float(number) when is_integer(number), do: number * 1.0 end