lib/envelope.ex

defmodule Envelope do
  @moduledoc ~S"""
  A library for calculating envelopes of geometries and tools to compare them.
  This is most useful as an approximation of spacial relationships between more
  complicated geometries.

      iex> Envelope.from_geo( %Geo.Polygon{coordinates: [[{2, -2}, {20, -2}, {11, 11}, {2, -2}]]} )
      %Envelope{ min_x: 2, min_y: -2, max_x: 20, max_y: 11 }

      iex> Envelope.from_geo( %Geo.LineString{coordinates: [{1, 3}, {2, -1}, {0, -1}, {1, 3}]} )
      %Envelope{ min_x: 0, min_y: -1, max_x: 2, max_y: 3 }

  You can also expand an existing Envelope with a geometry or another Envelope

      iex> a = Envelope.from_geo( %Geo.Polygon{coordinates: [[{2, -2}, {20, -2}, {11, 11}, {2, -2}]]} )
      ...> b = %Geo.LineString{coordinates: [{1, 3}, {2, -1}, {0, -1}, {1, 3}]}
      ...> Envelope.expand(a, b)
      %Envelope{ min_x: 0, min_y: -2, max_x: 20, max_y: 11 }
  """

  defstruct min_x: 0, min_y: 0, max_x: 0, max_y: 0

  @type t() :: %__MODULE__{
          min_x: number() | nil,
          min_y: number() | nil,
          max_x: number() | nil,
          max_y: number() | nil
        }
  @type point :: {number, number}
  @type points ::
          point
          | list
          | %{coordinates: list}
          | %Geo.Point{}
          | %Geo.MultiPoint{}
          | %Geo.LineString{}
          | %Geo.MultiLineString{}
          | %Geo.Polygon{}
          | %Geo.MultiPolygon{}

  alias Distance.GreatCircle

  @doc ~S"""
  Returns an `Envelope` that represents the extent of the geometry or
  coordinates.

  ## Examples
      iex> Envelope.from_geo %{coordinates: [{11, 10}, {4, 2.5}, {16, 2.5}, {11, 10}]}
      %Envelope{ max_x: 16, max_y: 10, min_x: 4, min_y: 2.5 }

      iex> Envelope.from_geo [{11, 10}, {4, 2.5}, {16, 2.5}, {11, 10}]
      %Envelope{ max_x: 16, max_y: 10, min_x: 4, min_y: 2.5 }

      iex> Envelope.from_geo %Geo.Polygon{coordinates: [[{1, 3}, {2, -1}, {0, -1}, {1, 3}]]}
      %Envelope{ min_x: 0, min_y: -1, max_x: 2, max_y: 3 }

      iex> Envelope.from_geo {1, 3}
      %Envelope{ min_x: 1, min_y: 3, max_x: 1, max_y: 3 }
  """
  @spec from_geo(points()) :: t()
  def from_geo({x, y}) when is_number(x) and is_number(y),
    do: %Envelope{min_x: x, min_y: y, max_x: x, max_y: y}

  def from_geo(%Geo.Point{coordinates: {x, y}}),
    do: %Envelope{min_x: x, min_y: y, max_x: x, max_y: y}

  def from_geo(%{coordinates: coordinates}), do: from_geo(coordinates)

  def from_geo(coordinates) when is_list(coordinates) do
    coordinates
    |> List.flatten()
    |> Enum.reduce(Envelope.empty(), &expand(&2, &1))
  end

  @doc ~S"""
  Returns a `Geo.Polygon`, `Geo.LineString`, or `Geo.Point` that is equal to
  the area covered by the given `Envelope`.

  Note that they exact type of the Geometry returned will depend on the nature
  of the Envelope:

   - `Geo.Point` will be returned when an envelope has zero area and all
     extents are equal.
   - `Geo.LineString` will be returned when an envelope has zero area
     and it extends along only one axes.
   - `Geo.Polygon` will be returned when an envelope has non-zeron area

  ## Examples
      iex> Envelope.to_geo %Envelope{ max_x: 16, max_y: 10, min_x: 4, min_y: 2.5 }
      %Geo.Polygon{coordinates: [[{4, 2.5}, {16, 2.5}, {16, 10}, {4, 10}, {4, 2.5}]]}

      iex> Envelope.to_geo %Envelope{ min_x: 1, min_y: 3, max_x: 1, max_y: 5 }
      %Geo.LineString{coordinates: [{1, 3}, {1, 5}]}

      iex> Envelope.to_geo %Envelope{ min_x: 1, min_y: 3, max_x: 4, max_y: 3 }
      %Geo.LineString{coordinates: [{1, 3}, {4, 3}]}

      iex> Envelope.to_geo %Envelope{ min_x: 1, min_y: 3, max_x: 1, max_y: 3 }
      %Geo.Point{coordinates: {1, 3}}
  """
  @spec to_geo(t()) :: %Geo.Polygon{} | %Geo.Point{} | %Geo.LineString{}
  def to_geo(%Envelope{min_x: x, min_y: y, max_x: x, max_y: y}),
    do: %Geo.Point{coordinates: {x, y}}

  def to_geo(%Envelope{min_x: x, min_y: min_y, max_x: x, max_y: max_y}),
    do: %Geo.LineString{coordinates: [{x, min_y}, {x, max_y}]}

  def to_geo(%Envelope{min_x: min_x, min_y: y, max_x: max_x, max_y: y}),
    do: %Geo.LineString{coordinates: [{min_x, y}, {max_x, y}]}

  def to_geo(%Envelope{} = env),
    do: %Geo.Polygon{
      coordinates: [
        [
          {env.min_x, env.min_y},
          {env.max_x, env.min_y},
          {env.max_x, env.max_y},
          {env.min_x, env.max_y},
          {env.min_x, env.min_y}
        ]
      ]
    }

  @doc ~S"""
  Returns an `Envelope` that represents no extent at all.  This is primarily
  a convenience function for starting an expanding Envelope. Internally,
  "empty" Envelopes are represented with `nil` values for all extents.

  Note that there is a important distinction between an empty Envelope and
  an Envelope around a single Point (where the min and max for each axis are
  real numbers but may represent zero area).

  ## Examples
      iex> Envelope.empty
      %Envelope{max_x: nil, max_y: nil, min_x: nil, min_y: nil}

      iex> Envelope.empty |> Envelope.empty?
      true
  """
  @spec empty() :: t()
  def empty, do: %Envelope{min_x: nil, min_y: nil, max_x: nil, max_y: nil}

  @doc ~S"""
  Returns `true` if the given envelope is empty (has non-existent extent),
  otherwise `false`

  ## Examples
      iex> Envelope.empty |> Envelope.empty?
      true

      iex> %Envelope{ min_x: 0, min_y: -1, max_x: 2, max_y: 3 } |> Envelope.empty?
      false
  """
  @spec empty?(t()) :: boolean()
  def empty?(%Envelope{min_x: nil, min_y: nil, max_x: nil, max_y: nil}), do: true
  def empty?(%Envelope{}), do: false

  @doc ~S"""
  Returns a new Envelope that is expanded to include an additional geometry.

  ## Examples
      iex> a = Envelope.from_geo(%Geo.Polygon{coordinates: [[{2, -2}, {20, -2}, {11, 11}, {2, -2}]]})
      ...> b = %Geo.LineString{coordinates: [{1, 3}, {2, -1}, {0, -1}, {1, 3}]}
      ...> Envelope.expand(a, b)
      %Envelope{ min_x: 0, min_y: -2, max_x: 20, max_y: 11 }

      iex> a = %Envelope{ min_x: 0, min_y: -2, max_x: 20, max_y: 11 }
      ...> b = %Envelope{ min_x: 2, min_y: -3, max_x: 12, max_y: -2 }
      ...> Envelope.expand(a, b)
      %Envelope{ min_x: 0, min_y: -3, max_x: 20, max_y: 11 }

      iex> Envelope.empty
      ...> |> Envelope.expand(%Envelope{ min_x: 0, min_y: -2, max_x: 12, max_y: 11 })
      ...> |> Envelope.expand(%Geo.Polygon{coordinates: [[{2, -2}, {20, -2}, {11, 11}, {2, -2}]]})
      ...> |> Envelope.expand(%{type: "Point", coordinates: {-1, 3}})
      %Envelope{ min_x: -1, min_y: -2, max_x: 20, max_y: 11 }

      iex> Envelope.expand(Envelope.empty, Envelope.empty) |> Envelope.empty?
      true
  """
  @spec expand(t(), point() | t() | points()) :: t()
  def expand(%Envelope{} = env1, %Envelope{} = env2) do
    cond do
      Envelope.empty?(env1) ->
        env2

      Envelope.empty?(env2) ->
        env1

      true ->
        %Envelope{
          min_x: min(env1.min_x, env2.min_x),
          min_y: min(env1.min_y, env2.min_y),
          max_x: max(env1.max_x, env2.max_x),
          max_y: max(env1.max_y, env2.max_y)
        }
    end
  end

  def expand(%Envelope{} = env, other), do: expand(env, from_geo(other))

  @doc ~S"""
  Returns a new Envelope that is expanded in positive and negative directions
  in each axis by `radius`.

  ## Examples
      iex> Envelope.expand_by(Envelope.from_geo(%Geo.Polygon{coordinates: [[{2, -2}, {20, -2}, {11, 11}, {2, -2}]]}), 3)
      %Envelope{ min_x: -1, min_y: -5, max_x: 23, max_y: 14 }

      iex> Envelope.expand_by(Envelope.empty, 4) |> Envelope.empty?
      true
  """
  @spec expand_by(t(), number()) :: t()
  def expand_by(%Envelope{} = env, radius) when is_number(radius) and radius >= 0 do
    case Envelope.empty?(env) do
      true ->
        env

      false ->
        %Envelope{
          min_x: env.min_x - radius,
          min_y: env.min_y - radius,
          max_x: env.max_x + radius,
          max_y: env.max_y + radius
        }
    end
  end

  @doc ~S"""
  Simple distance from the left bounadary to the right boundary of the Envelope.

  ## Examples
      iex> Envelope.width(Envelope.from_geo(%Geo.Polygon{coordinates: [[{2, -2}, {20, -2}, {11, 11}, {2, -2}]]}))
      18
  """
  @spec width(t()) :: number()
  def width(%Envelope{} = env) do
    env.max_x - env.min_x
  end

  @doc ~S"""
  When an Envelope's coordinates are in degress of longitude and latitude, calculates the
  great circle distance between the center of the east and west extent in meters.

  ## Examples
      iex> Envelope.width_gc(Envelope.from_geo(%Geo.Polygon{coordinates: [[{2, -2}, {20, -2}, {11, 11}, {2, -2}]]})) |> round
      1982362
  """
  @spec width_gc(t()) :: number()
  def width_gc(%Envelope{} = env) do
    bottom = GreatCircle.distance({env.min_x, env.min_y}, {env.max_x, env.min_y})
    top = GreatCircle.distance({env.min_x, env.max_y}, {env.max_x, env.max_y})

    (bottom + top) / 2.0
  end

  @doc ~S"""
  Simple distance from the bottom bounadary to the top boundary of the Envelope.

  ## Examples
      iex> Envelope.height(Envelope.from_geo(%Geo.Polygon{coordinates: [[{2, -2}, {20, -2}, {11, 11}, {2, -2}]]}))
      13
  """
  @spec height(t()) :: number()
  def height(%Envelope{} = env) do
    env.max_y - env.min_y
  end

  @doc ~S"""
  When an Envelope's coordinates are in degress of longitude and latitude, calculates the
  great circle distance between the center of the north and south extent in meters.

  ## Examples
      iex> Envelope.height_gc(Envelope.from_geo(%Geo.Polygon{coordinates: [[{2, -2}, {20, -2}, {11, 11}, {2, -2}]]})) |> round
      1445536
  """
  @spec height_gc(t()) :: number()
  def height_gc(%Envelope{} = env) do
    GreatCircle.distance({env.min_x, env.min_y}, {env.min_x, env.max_y})
  end

  @doc ~S"""
  Calculates the simple area of an Envelope.

  ## Examples
      iex> Envelope.area(Envelope.from_geo(%Geo.Polygon{coordinates: [[{2, -2}, {20, -2}, {11, 11}, {2, -2}]]}))
      234
  """
  @spec area(t()) :: number()
  def area(%Envelope{} = env) do
    width(env) * height(env)
  end

  @doc ~S"""
  Estimates the area of an Envelope in square meters when the Envelope's coordinates are in degress of longitude and latitude.

  ## Examples
      iex> Envelope.area_gc(Envelope.from_geo(%Geo.Polygon{coordinates: [[{2, -2}, {20, -2}, {11, 11}, {2, -2}]]})) |> round
      2865575088701
  """
  @spec area_gc(t()) :: number()
  def area_gc(%Envelope{} = env) do
    width_gc(env) * height_gc(env)
  end

  @doc ~S"""
  Returns the center point of an envelope.

  ## Examples
  iex> %Envelope{ min_x: 0, min_y: -1, max_x: 2, max_y: 5 } |> Envelope.center()
  {1.0, 2.0}

  iex> Envelope.empty() |> Envelope.center()
  nil
  """
  @spec center(t()) :: {number(), number()}
  def center(env) do
    if Envelope.empty?(env) do
      nil
    else
      {(env.min_x + env.max_x) / 2.0, (env.min_y + env.max_y) / 2.0}
    end
  end

  @doc ~S"""
  Returns whether one envelope fully contains another envelope or point.

  ## Examples
      iex> Envelope.contains?(
      ...> %Envelope{ min_x: -1, min_y: -5, max_x: 23, max_y: 14 },
      ...> %Envelope{ min_x: 0, min_y: 3, max_x: 7, max_y: 4 })
      true

      iex> Envelope.contains?(
      ...> %Envelope{ min_x: -1, min_y: 5, max_x: 23, max_y: 14 },
      ...> %Envelope{ min_x: -2, min_y: 5, max_x: 7, max_y: 4 })
      false

      iex> Envelope.contains?(
      ...> %Geo.Polygon{ coordinates: [{-1, 3}, {-3, -1}, { 5, -3}, {4, 12}, {-2, 11}, {-1, 3}] },
      ...> {0, 11})
      true
  """
  @spec contains?(t() | points(), t() | points()) :: boolean()
  def contains?(%Envelope{} = env, {x, y}) do
    env.min_x <= x && env.min_y <= y && env.max_x >= x && env.max_y >= y
  end

  def contains?(%Envelope{} = env, %{coordinates: {x, y}}), do: contains?(env, {x, y})

  def contains?(%Envelope{} = env1, %Envelope{} = env2) do
    env1.min_x <= env2.min_x && env1.min_y <= env2.min_y && env1.max_x >= env2.max_x &&
      env1.max_y >= env2.max_y
  end

  def contains?(%Envelope{} = env1, other), do: contains?(env1, from_geo(other))
  def contains?(a, b), do: contains?(from_geo(a), b)

  @doc ~S"""
  The inverse of the relationship tested by Envelope#contains?

  ## Examples
      iex> Envelope.within?(
      ...> %Envelope{ min_x: 0, min_y: 3, max_x: 7, max_y: 4 },
      ...> %Envelope{ min_x: -1, min_y: -5, max_x: 23, max_y: 14 })
      true

      iex> Envelope.within?(
      ...> %Geo.Polygon{ coordinates: [{-1, 3}, {-3, -1}, { 5, -3}, {4, 12}, {-2, 11}, {-1, 3}] },
      ...> {0, 11})
      false
  """
  @spec within?(t() | points(), t() | points()) :: boolean()
  def within?(a, b), do: contains?(b, a)

  @doc ~S"""
  Returns whether two envelopes touch or intersect.

  ## Examples
      iex> Envelope.intersects?(
      ...> %Envelope{ min_x: -1, min_y: -5, max_x: 23, max_y: 14 },
      ...> %Envelope{ min_x: 0, min_y: 3, max_x: 7, max_y: 4 })
      true

      iex> Envelope.intersects?(
      ...> %Envelope{ min_x: -1, min_y: 5, max_x: 23, max_y: 14 },
      ...> %Envelope{ min_x: 0, min_y: -3, max_x: 7, max_y: 4 })
      false
  """
  @spec intersects?(t() | points(), t() | points()) :: boolean()
  def intersects?(%Envelope{} = env1, %Envelope{} = env2) do
    cond do
      env1.min_x > env2.max_x -> false
      env1.max_x < env2.min_x -> false
      env1.min_y > env2.max_y -> false
      env1.max_y < env2.min_y -> false
      true -> true
    end
  end

  def intersects?(a, b), do: intersects?(from_geo(a), from_geo(b))
end