lib/geometry.ex

defmodule Geometry do
  @moduledoc """
  A set of geometry types for WKT/WKB and GeoJson.
  """

  alias Geometry.{
    Feature,
    FeatureCollection,
    GeoJson,
    GeometryCollection,
    GeometryCollectionM,
    GeometryCollectionZ,
    GeometryCollectionZM,
    LineString,
    LineStringM,
    LineStringZ,
    LineStringZM,
    MultiLineString,
    MultiLineStringM,
    MultiLineStringZ,
    MultiLineStringZM,
    MultiPoint,
    MultiPointM,
    MultiPointZ,
    MultiPointZM,
    MultiPolygon,
    MultiPolygonM,
    MultiPolygonZ,
    MultiPolygonZM,
    Point,
    PointM,
    PointZ,
    PointZM,
    Polygon,
    PolygonM,
    PolygonZ,
    PolygonZM
  }

  alias Geometry.{WKB, WKT}

  @geometries [
    GeometryCollection,
    GeometryCollectionM,
    GeometryCollectionZ,
    GeometryCollectionZM,
    LineString,
    LineStringM,
    LineStringZ,
    LineStringZM,
    MultiLineString,
    MultiLineStringM,
    MultiLineStringZ,
    MultiLineStringZM,
    MultiPoint,
    MultiPointM,
    MultiPointZ,
    MultiPointZM,
    MultiPolygon,
    MultiPolygonM,
    MultiPolygonZ,
    MultiPolygonZM,
    Polygon,
    PolygonM,
    PolygonZ,
    PolygonZM,
    Point,
    PointM,
    PointZ,
    PointZM
  ]

  @geo_json [
    Feature,
    FeatureCollection
  ]

  @typedoc """
  A geometry is one of the provided geometries or geometry-collections.
  """
  @type t ::
          GeometryCollection.t()
          | GeometryCollectionM.t()
          | GeometryCollectionZ.t()
          | GeometryCollectionZM.t()
          | LineString.t()
          | LineStringM.t()
          | LineStringZ.t()
          | LineStringZM.t()
          | MultiLineString.t()
          | MultiLineStringM.t()
          | MultiLineStringZ.t()
          | MultiLineStringZM.t()
          | MultiPoint.t()
          | MultiPointM.t()
          | MultiPointZ.t()
          | MultiPointZM.t()
          | MultiPolygon.t()
          | MultiPolygonM.t()
          | MultiPolygonZ.t()
          | MultiPolygonZM.t()
          | Polygon.t()
          | PolygonM.t()
          | PolygonZ.t()
          | PolygonZM.t()
          | Point.t()
          | PointM.t()
          | PointZ.t()
          | PointZM.t()

  @typedoc """
  An n-dimensional coordinate.
  """
  @type coordinate :: [number(), ...]

  @typedoc """
  A list of n-dimensional coordinates.
  """
  @type coordinates :: [coordinate()]

  @typedoc """
  The Spatial Reference System Identifier to identify projected, unprojected,
  and local spatial coordinate system definitions.
  """
  @type srid :: non_neg_integer()

  @typedoc """
  [Well-known text](https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry)
  (WKT) is a text markup language for representing vector geometry objects.
  """
  @type wkt :: String.t()

  @typedoc """
  [Well-known binary](https://en.wikipedia.org/wiki/Well-known_text_representation_of_geometry#Well-known_binary)
  The binary representation of WKT.
  """
  @type wkb :: binary()

  @typedoc """
  Errors that can occur when a geometry is generating from WKT.
  """
  @type wkt_error ::
          {:error, %{expected: t(), got: t()}}
          | {
              :error,
              message :: String.t(),
              rest :: String.t(),
              {line :: pos_integer(), offset :: non_neg_integer()},
              offset :: non_neg_integer()
            }

  @typedoc """
  Errors that can occur when a geometry is generating from WKT.
  """
  @type wkb_error ::
          {:error, %{expected: t(), got: t()}}
          | {:error, message :: String.t(), rest :: binary(), offset :: non_neg_integer()}

  @typedoc """
  A [GeoJson](https://geojson.org) term.
  """
  @type geo_json_term :: map()

  @typedoc """
  Errors that can occur when a geometry is generating from GeoJson.
  """
  @type geo_json_error ::
          {:error,
           :coordinates_not_found
           | :geometries_not_found
           | :invalid_data
           | :type_not_found
           | :unknown_type}

  @typedoc """
  Byte order.

  - `:ndr`: Little endian byte order encoding
  - `:xdr`: Big endian byte order encoding
  """
  @type endian :: :ndr | :xdr

  @type mode :: :binary | :hex

  @doc """
  Returns true if a geometry is empty.

  ## Examples

      iex> Geometry.empty?(Point.new(1, 2))
      false
      iex> Geometry.empty?(Point.new())
      true
      iex> Geometry.empty?(LineString.new([]))
      true
  """
  @spec empty?(t()) :: boolean
  def empty?(%module{} = geometry)
      when module in @geometries or module in @geo_json do
    module.empty?(geometry)
  end

  @doc """
  Returns the WKB representation of a geometry.

  With option `:srid` an EWKB representation with the SRID is returned.

  The option `:endian` indicates whether `:xdr` big endian or `:ndr` little
  endian is returned. The default is `:xdr`.

  The `:mode` determines whether a hex-string or binary is returned. The default
  is `:binary`.

  ## Examples

      iex> Geometry.to_wkb(PointZ.new(1, 2, 3), endian: :ndr, mode: :hex)
      "0101000080000000000000F03F00000000000000400000000000000840"

      iex> Geometry.to_wkb(Point.new(1, 2), srid: 4711) |> Hex.from_binary()
      "0020000001000012673FF00000000000004000000000000000"
  """
  @spec to_wkb(t(), opts) :: String.t()
        when opts: [endian: endian(), srid: srid(), mode: mode()]
  def to_wkb(%module{} = geometry, opts \\ []) when module in @geometries do
    module.to_wkb(geometry, opts)
  end

  @doc """
  Returns an `:ok` tuple with the geometry from the given WKT string. Otherwise
  returns an `:error` tuple.

  If WKB contains an SRID the tuple is extended by the id.

  The optional second argument determines if a `:hex`-string or a `:binary`
  input is expected. The default is `:binary`.

  ## Examples

      iex> Geometry.from_wkb("0101000080000000000000F03F00000000000000400000000000000840", :hex)
      {:ok, %PointZ{coordinate: [1.0, 2.0, 3.0]}}

      iex> Geometry.from_wkb("0020000001000012673FF00000000000004000000000000000", :hex)
      {:ok, {%Point{coordinate: [1.0, 2.0]}, 4711}}
  """
  @spec from_wkb(wkb(), mode()) :: {:ok, t() | {t(), srid()}} | wkb_error
  def from_wkb(wkb, mode \\ :binary), do: WKB.Parser.parse(wkb, mode)

  @doc """
  The same as `from_wkb/2`, but raises a `Geometry.Error` exception if it fails.
  """
  @spec from_wkb!(wkb(), mode()) :: t() | {t(), srid()}
  def from_wkb!(wkb, mode \\ :binary) do
    case WKB.Parser.parse(wkb, mode) do
      {:ok, geometry} -> geometry
      error -> raise Geometry.Error, error
    end
  end

  @doc """
  Returns the WKT representation of a geometry. An optional `:srid` can be set
  in the options.

  ## Examples

      iex> Geometry.to_wkt(Point.new(1, 2))
      "Point (1 2)"

      iex> Geometry.to_wkt(PointZ.new(1.1, 2.2, 3.3), srid: 4211)
      "SRID=4211;Point Z (1.1 2.2 3.3)"

      iex> Geometry.to_wkt(LineString.new([Point.new(1, 2), Point.new(3, 4)]))
      "LineString (1 2, 3 4)"
  """
  @spec to_wkt(t(), opts) :: String.t()
        when opts: [srid: srid()]
  def to_wkt(%module{} = geometry, opts \\ []) when module in @geometries do
    module.to_wkt(geometry, opts)
  end

  @doc """
  Returns an `:ok` tuple with the geometry from the given WKT string. Otherwise
  returns an `:error` tuple.

  If the geometry contains a SRID the id is added to the tuple.

  ## Examples

      iex> Geometry.from_wkt("Point ZM (1 2 3 4)")
      {:ok, %PointZM{coordinate: [1, 2, 3, 4]}}

      iex> Geometry.from_wkt("SRID=42;Point (1.1 2.2)")
      {:ok, {%Point{coordinate: [1.1, 2.2]}, 42}}
  """
  @spec from_wkt(wkt()) :: {:ok, t() | {t(), srid()}} | wkt_error
  def from_wkt(wkt), do: WKT.Parser.parse(wkt)

  @doc """
  The same as `from_wkt/1`, but raises a `Geometry.Error` exception if it fails.
  """
  @spec from_wkt!(wkt()) :: t() | {t(), srid()}
  def from_wkt!(wkt) do
    case WKT.Parser.parse(wkt) do
      {:ok, geometry} -> geometry
      error -> raise Geometry.Error, error
    end
  end

  @doc """
  Returns the GeoJSON term representation of a geometry.

  ## Examples

      iex> Geometry.to_geo_json(PointZ.new(1.2, 3.4, 5.6))
      %{"type" => "Point", "coordinates" => [1.2, 3.4, 5.6]}

      iex> Geometry.to_geo_json(LineString.new([Point.new(1, 2), Point.new(3, 4)]))
      %{"type" => "LineString", "coordinates" => [[1, 2], [3, 4]]}
  """
  @spec to_geo_json(t() | Feature.t() | FeatureCollection.t()) :: geo_json_term
  def to_geo_json(%module{} = geometry)
      when module in @geometries or module in @geo_json do
    module.to_geo_json(geometry)
  end

  @doc """
  Returns an `:ok` tuple with the geometry from the given GeoJSON term.
  Otherwise returns an `:error` tuple.

  The `:type` option specifies which type is expected. The
  possible values are `:z`, `:m`, and `:zm`.

  ## Examples

      iex> ~s({"type": "Point", "coordinates": [1, 2]})
      iex> |> Jason.decode!()
      iex> |> Geometry.from_geo_json()
      {:ok, %Point{coordinate: [1, 2]}}

      iex> ~s({"type": "Point", "coordinates": [1, 2, 3, 4]})
      iex> |> Jason.decode!()
      iex> |> Geometry.from_geo_json(type: :zm)
      {:ok, %PointZM{coordinate: [1, 2, 3, 4]}}
  """
  @spec from_geo_json(geo_json_term(), opts) ::
          {:ok, t() | Feature.t() | FeatureCollection.t()} | geo_json_error
        when opts: [type: :z | :m | :zm]
  def from_geo_json(json, opts \\ []), do: GeoJson.to_geometry(json, opts)

  @doc """
  The same as `from_geo_josn/1`, but raises a `Geometry.Error` exception if it
  fails.
  """
  @spec from_geo_json!(geo_json_term(), opts) :: t() | Feature.t() | FeatureCollection.t()
        when opts: [type: :z | :m | :zm]
  def from_geo_json!(json, opts \\ []) do
    case GeoJson.to_geometry(json, opts) do
      {:ok, geometry} -> geometry
      error -> raise Geometry.Error, error
    end
  end

  @doc false
  @spec default_endian :: endian()
  def default_endian, do: :xdr

  @doc false
  @spec default_mode :: mode()
  def default_mode, do: :binary
end