lib/topo.ex

defmodule Topo do
  alias Topo.Intersects
  alias Topo.Contains
  alias Topo.Cleaner

  @moduledoc ~S"""
  A Geometry library for Elixir that calculates relationships between two
  geometries.  Geometries can be of any of the following types:

  * Point
  * LineString
  * Polygon
  * MultiPoint
  * MultiLineString
  * MultiPolygon

  Each of these functions can be passed any two Geometries in either a Map with a
  `:type` and `:coordinates` keys or as a struct generated via the Geo library
  (https://github.com/bryanjos/geo). Coordinates are represented as atoms `{x, y}`
  and multiple coordinates as Lists.

  ```elixir
  a = %{type: "Polygon", coordinates: [[{2, 2}, {20, 2}, {11, 11}, {2, 2}]]}
  b = %Geo.Polygon{coordinates: [[{2, 2}, {20, 2}, {11, 11}, {2, 2}]]}

  Topo.equals? a, b # => true
  ```

  Instead of a Point geometry, just a single coordinate can be used.

  ```elixir
  a = %{type: "Polygon", coordinates: [[{2, 2}, {20, 2}, {11, 11}, {2, 2}]]}

  Topo.intersects? a, {4, 6} # => true
  ```

  The `Topo` library's functions will automatically attempt to "clean" geometries
  passed to them:

  * Linear Rings (including Polygons) will be reordered to a counter-clockwise
  direction.
  * Polygon's Linear Rings will automatically be closed if the first point is not
  repeated as the last point.
  * Points that are equal or collinear with surrounding points are removed from
  LineStrings or Polygons.
  """

  @type geometry ::
          {number, number}
          | %{type: String.t(), coordinates: list}
          | %Geo.Point{}
          | %Geo.MultiPoint{}
          | %Geo.LineString{}
          | %Geo.MultiLineString{}
          | %Geo.Polygon{}
          | %Geo.MultiPolygon{}

  @doc ~S"""
  Returns `true` if geometries **A** and **B** share at least one point in
  common.

  ## Examples
      iex> Topo.intersects?(
      ...>   %{type: "Polygon", coordinates: [[{2, 2}, {20, 2}, {11, 11}, {2, 2}]]},
      ...>   %{type: "LineString", coordinates: [{11, 10}, {4, 2.5}, {16, 2.5}, {11, 10}]}
      ...> )
      true

      iex> Topo.intersects?(
      ...>   %{type: "Polygon", coordinates: [
      ...>     [{60, 120}, {60, 40}, {160, 40}, {160, 120}, {60, 120}],
      ...>     [{140, 100}, {80, 100}, {80, 60}, {140, 60}, {140, 100}]]},
      ...>   %{type: "MultiPoint", coordinates: [{70, 35}, {100, 80}]}
      ...> )
      false
  """
  @spec intersects?(geometry, geometry) :: boolean
  def intersects?(a, b), do: Intersects.intersects?(Cleaner.clean(a), Cleaner.clean(b))

  @doc ~S"""
  Returns `true` if geometries **A** and **B** do not have any points in
  common.

  ## Examples
      iex> Topo.disjoint?({1, -3}, %{type: "MultiPoint", coordinates: [{70, 35}, {100, 80}]})
      true

      iex> Topo.disjoint?(
      ...>   %{type: "Polygon", coordinates: [
      ...>     [{60, 120}, {60, 40}, {160, 40}, {160, 120}, {60, 120}],
      ...>     [{140, 100}, {80, 100}, {80, 60}, {140, 60}, {140, 100}]]},
      ...>   %Geo.MultiPoint{coordinates: [{70, 35}, {100, 80}]}
      ...> )
      true
  """
  @spec disjoint?(geometry, geometry) :: boolean
  def disjoint?(a, b), do: !Intersects.intersects?(Cleaner.clean(a), Cleaner.clean(b))

  @doc ~S"""
  Returns `true` if all points of geometry **B** lie within **A**.

  There are a few non-obvious special cases that are worth mentioning:

    - A Polygon does not contain its own boundary.  Specifically a LineString that
    is the exact same as a Polygon's exterior Linear ring is not contained within a
    that Polygon.

    ```elixir
    a = %Geo.Polygon{coordinates: [[{2, 2}, {20, 2}, {11, 11}, {2, 2}]]}
    b = %Geo.LineString{coordinates: [{2, 2}, {20, 2}, {11, 11}, {2, 2}]}

    Topo.contains? a, b # => false
    Topo.intersects? a, b  # => true
    ```

    - A LineString does not contain it's own first and last point (unless those
    points are the same, as in a LinearRing)

    ```elixir
    a = %Geo.LineString{coordinates: [{1, 3}, {2, -1}, {0, -1}]}
    b = %Geo.LineString{coordinates: [{1, 3}, {2, -1}, {0, -1}, {1, 3}]}

    Topo.contains? a, {1, 3} # => false
    Topo.intersects? a, {1, 3} # => true
    Topo.contains? b, {1, 3} # => true
    ```

  ## Examples
      iex> Topo.contains?(
      ...>   %{type: "Polygon", coordinates: [[{20, 20}, {20, 180}, {220, 180}, {220, 20}, {20, 20}]]},
      ...>   %{type: "Polygon", coordinates: [[{160, 60}, {20, 20}, {100, 140}, {160, 60}]]}
      ...> )
      true

      iex> Topo.contains?(
      ...>   %{type: "Polygon", coordinates: [[{160, 60}, {20, 20}, {100, 140}, {160, 60}]]},
      ...>   %{type: "Polygon", coordinates: [[{20, 20}, {20, 180}, {220, 180}, {220, 20}, {20, 20}]]}
      ...> )
      false
  """
  @spec contains?(geometry, geometry) :: boolean
  def contains?(a, b), do: Contains.contains?(Cleaner.clean(a), Cleaner.clean(b))

  @doc ~S"""
  This is the direct converse of `contains?`.  All points of
  geometry **A** lie within geometry **B**.

  ## Examples
      iex> Topo.within?(
      ...>   %{type: "Polygon", coordinates: [[{20, 20}, {20, 180}, {220, 180}, {220, 20}, {20, 20}]]},
      ...>   %{type: "Polygon", coordinates: [[{160, 60}, {20, 20}, {100, 140}, {160, 60}]]}
      ...> )
      false

      iex> Topo.within?(
      ...>   %{type: "Polygon", coordinates: [[{160, 60}, {20, 20}, {100, 140}, {160, 60}]]},
      ...>   %{type: "Polygon", coordinates: [[{20, 20}, {20, 180}, {220, 180}, {220, 20}, {20, 20}]]}
      ...> )
      true
  """
  @spec within?(geometry, geometry) :: boolean
  def within?(a, b), do: Contains.contains?(Cleaner.clean(b), Cleaner.clean(a))

  @doc ~S"""
  Geometries **A** and **B** are equivalent and cover the exact
  same set of points.  By definition, **A** and **B** are equal if **A** contains
  **B** and **B** contains **A**.  Equality does not necessarily mean that the
  geometries are of the same type.  A Point **A** is equal to a MultiPoint that
  contains only the same Point **A**.

  ## Examples
      iex> Topo.equals?(
      ...>   %{type: "Point", coordinates: {2, -3}},
      ...>   %{type: "MultiPoint", coordinates: [{2, -3}]}
      ...> )
      true

      iex> Topo.equals?(
      ...>   %{type: "Polygon", coordinates: [[{160, 60}, {20, 20}, {100, 140}, {160, 60}]]},
      ...>   %{type: "Polygon", coordinates: [[{20, 20}, {20, 180}, {220, 180}, {220, 20}, {20, 20}]]}
      ...> )
      false
  """
  @spec equals?(geometry, geometry) :: boolean
  def equals?(a, b), do: do_equals?(Cleaner.clean(a), Cleaner.clean(b))

  defp do_equals?(a, b), do: Contains.contains?(a, b) && Contains.contains?(b, a)
end