lib/geo/json/encoder.ex

defmodule Geo.JSON.Encoder do
  @moduledoc false

  alias Geo.{
    Point,
    PointZ,
    LineString,
    LineStringZ,
    LineStringZM,
    Polygon,
    PolygonZ,
    MultiPoint,
    MultiPointZ,
    MultiLineString,
    MultiLineStringZ,
    MultiPolygon,
    MultiPolygonZ,
    GeometryCollection
  }

  defmodule EncodeError do
    @type t :: %__MODULE__{message: String.t(), value: any}

    defexception [:message, :value]

    def message(%{message: nil, value: value}) do
      "unable to encode value: #{inspect(value)}"
    end

    def message(%{message: message}) do
      message
    end
  end

  @doc """
  Takes a Geometry and returns a map representing the GeoJSON.
  """
  @spec encode!(Geo.geometry()) :: map()
  def encode!(geom, opts \\ [])

  def encode!(geom, []) do
    case geom do
      %GeometryCollection{geometries: geometries, srid: srid, properties: properties} ->
        %{"type" => "GeometryCollection", "geometries" => Enum.map(geometries, &encode!(&1))}
        |> add_crs(srid)
        |> add_properties(properties)

      _ ->
        geom
        |> do_encode()
        |> add_crs(geom.srid)
        |> add_properties(geom.properties)
    end
  end

  # translate a %GeometryCollection{} to a GeoJSON FeatureCollection (3.3).
  #
  # GeometryCollections and their encapsulated Geo.geometry() structs MAY have
  # properties. GeoJSON Features MUST have properties even if empty (3.2), and
  # properties objects on other types are considered "foreign members" (6.1).
  #
  # This function attempts to merge accordingly into individual Feature
  # properties. Geo.geometry() properties override Collection properties.

  # This function disregards SRID information as GeoJSON is expected to be in
  # WGS 84, deviating only in pre-agreed cases (4).
  #
  # see:
  #    https://tools.ietf.org/html/rfc7946#section-3.2
  #    https://tools.ietf.org/html/rfc7946#section-3.3
  #    https://tools.ietf.org/html/rfc7946#section-4
  #    https://tools.ietf.org/html/rfc7946#section-6.1
  #
  def encode!(%GeometryCollection{geometries: gs, properties: ps}, feature: true) do
    ps = Enum.reduce(ps, %{}, &properties_reduce/2)

    %{
      "type" => "FeatureCollection",
      "features" => Enum.map(gs, &encode!(&1, feature: true, properties: ps))
    }
  end

  # translate a Geo.geometry() to a GeoJSON Feature (3.2), with optional default
  # properties.
  #
  # This function disregards SRID information as GeoJSON is expected to be in
  # WGS 84, deviating only in pre-agreed cases (4).
  #
  # see:
  #    https://tools.ietf.org/html/rfc7946#section-3.2
  #    https://tools.ietf.org/html/rfc7946#section-4
  #
  def encode!(geom, opts) do
    if Keyword.get(opts, :feature, false) do
      ps =
        Enum.reduce(
          geom.properties,
          Keyword.get(opts, :properties, %{}),
          &properties_reduce/2
        )

      %{
        "type" => "Feature",
        "properties" => ps,
        "geometry" => do_encode(geom)
      }
    else
      encode!(geom)
    end
  end

  defp properties_reduce({k, v}, m) when is_atom(k), do: Map.put(m, Atom.to_string(k), v)
  defp properties_reduce({k, v}, m), do: Map.put(m, k, v)

  @doc """
  Takes a Geometry and returns a map representing the GeoJSON.
  """
  @spec encode(Geo.geometry()) :: {:ok, map()} | {:error, EncodeError.t()}
  def encode(geom, opts \\ []) do
    {:ok, encode!(geom, opts)}
  rescue
    exception in [EncodeError] ->
      {:error, exception}
  end

  defp do_encode(%Point{coordinates: {x, y}}) do
    %{"type" => "Point", "coordinates" => [x, y]}
  end

  defp do_encode(%Point{coordinates: nil}) do
    %{"type" => "Point", "coordinates" => []}
  end

  defp do_encode(%PointZ{coordinates: {x, y, z}}) do
    %{"type" => "Point", "coordinates" => [x, y, z]}
  end

  defp do_encode(%LineString{coordinates: coordinates}) do
    coordinates = Enum.map(coordinates, &Tuple.to_list(&1))

    %{"type" => "LineString", "coordinates" => coordinates}
  end

  defp do_encode(%LineStringZ{coordinates: coordinates}) do
    coordinates = Enum.map(coordinates, &Tuple.to_list(&1))

    %{"type" => "LineStringZ", "coordinates" => coordinates}
  end

  defp do_encode(%LineStringZM{coordinates: coordinates}) do
    # GeoJSON does not allow "m", and does not recognize "LineStringZM" as
    # a type 
    coordinates = Enum.map(coordinates, fn {x, y, z, _m} -> [x, y, z] end)
    %{"type" => "LineString", "coordinates" => coordinates}
  end

  defp do_encode(%Polygon{coordinates: coordinates}) do
    coordinates =
      Enum.map(coordinates, fn sub_coordinates ->
        Enum.map(sub_coordinates, &Tuple.to_list(&1))
      end)

    %{"type" => "Polygon", "coordinates" => coordinates}
  end

  defp do_encode(%PolygonZ{coordinates: coordinates}) do
    coordinates =
      Enum.map(coordinates, fn sub_coordinates ->
        Enum.map(sub_coordinates, &Tuple.to_list(&1))
      end)

    %{"type" => "PolygonZ", "coordinates" => coordinates}
  end

  defp do_encode(%MultiPoint{coordinates: coordinates}) do
    coordinates = Enum.map(coordinates, &Tuple.to_list(&1))

    %{"type" => "MultiPoint", "coordinates" => coordinates}
  end

  defp do_encode(%MultiPointZ{coordinates: coordinates}) do
    coordinates = Enum.map(coordinates, &Tuple.to_list(&1))

    %{"type" => "MultiPointZ", "coordinates" => coordinates}
  end

  defp do_encode(%MultiLineString{coordinates: coordinates}) do
    coordinates =
      Enum.map(coordinates, fn sub_coordinates ->
        Enum.map(sub_coordinates, &Tuple.to_list(&1))
      end)

    %{"type" => "MultiLineString", "coordinates" => coordinates}
  end

  defp do_encode(%MultiLineStringZ{coordinates: coordinates}) do
    coordinates =
      Enum.map(coordinates, fn sub_coordinates ->
        Enum.map(sub_coordinates, &Tuple.to_list(&1))
      end)

    %{"type" => "MultiLineStringZ", "coordinates" => coordinates}
  end

  defp do_encode(%MultiPolygon{coordinates: coordinates}) do
    coordinates =
      Enum.map(coordinates, fn sub_coordinates ->
        Enum.map(sub_coordinates, fn third_sub_coordinates ->
          Enum.map(third_sub_coordinates, &Tuple.to_list(&1))
        end)
      end)

    %{"type" => "MultiPolygon", "coordinates" => coordinates}
  end

  defp do_encode(%MultiPolygonZ{coordinates: coordinates}) do
    coordinates =
      Enum.map(coordinates, fn sub_coordinates ->
        Enum.map(sub_coordinates, fn third_sub_coordinates ->
          Enum.map(third_sub_coordinates, &Tuple.to_list(&1))
        end)
      end)

    %{"type" => "MultiPolygon", "coordinates" => coordinates}
  end

  defp do_encode(data) do
    raise EncodeError, message: "Unable to encode given value: #{inspect(data)}"
  end

  defp add_crs(map, nil) do
    map
  end

  defp add_crs(map, srid) do
    Map.put(map, "crs", %{"type" => "name", "properties" => %{"name" => "EPSG:#{srid}"}})
  end

  def add_properties(map, props) do
    if Enum.empty?(props) do
      map
    else
      Map.put(map, "properties", props)
    end
  end
end