lib/geo_stream_data.ex

defmodule GeoStreamData do
  @moduledoc """
  A generator for property-based testing of geospatial data.


  """

  # use ExUnitProperties

  @type envelope() :: %{min_x: number(), max_x: number(), min_y: number(), max_y: number()}

  @type geometry_type() ::
          Geo.Point.t()
          | Geo.LineString.t()
          | Geo.Polygon.t()
          | Geo.MultiPoint.t()
          | Geo.MultiLineString.t()
          | Geo.MultiPolygon.t()

  @geometry_types ~w(point line_string polygon multi_point multi_line_string multi_polygon)a
  @default_env %{min_x: -180, max_x: 180, min_y: -90, max_y: 90}

  @spec geometry(envelope()) :: StreamData.t(geometry_type())
  def geometry(env \\ @default_env) do
    StreamData.bind(StreamData.member_of(@geometry_types), fn
      :point -> point(env)
      :line_string -> line_string(env)
      :polygon -> polygon(env)
      :multi_point -> multi_point(env)
      :multi_line_string -> multi_line_string(env)
      :multi_polygon -> multi_polygon(env)
    end)
  end

  @spec point_tuple(envelope()) :: StreamData.t({number(), number()})
  def point_tuple(env \\ @default_env) do
    StreamData.bind(StreamData.float(min: env.min_x, max: env.max_x), fn x ->
      StreamData.bind(StreamData.float(min: env.min_y, max: env.max_y), fn y ->
        StreamData.constant({x, y} |> enforce_env(env))
      end)
    end)
  end

  @spec point(envelope()) :: StreamData.t(Geo.Point.t())
  def point(env \\ @default_env) do
    StreamData.bind(point_tuple(env), fn point ->
      %Geo.Point{coordinates: point} |> StreamData.constant()
    end)
  end

  @spec line_string(envelope()) :: StreamData.t(Geo.LineString.t())
  def line_string(env \\ @default_env) do
    StreamData.uniq_list_of(point_tuple(env), min_length: 3)
    |> StreamData.bind_filter(fn points ->
      sorted = points |> sort_radially() |> shift_random() |> reverse_sometimes()

      line_string = %Geo.LineString{coordinates: sorted}

      if __MODULE__.Utils.simple?(line_string) do
        {:cont, StreamData.constant(line_string)}
      else
        :skip
      end
    end)
  end

  @spec polygon(envelope()) :: StreamData.t(Geo.Polygon.t())
  def polygon(env \\ @default_env) do
    StreamData.uniq_list_of(point_tuple(env), min_length: 3)
    |> StreamData.bind_filter(fn points ->
      sorted = points |> sort_radially() |> shift_random()

      polygon = %Geo.Polygon{coordinates: [sorted ++ Enum.take(sorted, 1)]}

      if __MODULE__.Utils.simple?(polygon) do
        {:cont, StreamData.constant(polygon)}
      else
        :skip
      end
    end)
  end

  @spec multi_point(envelope()) :: StreamData.t(Geo.MultiPoint.t())
  def multi_point(env \\ @default_env) do
    StreamData.list_of(point_tuple(env), min_length: 1)
    |> StreamData.bind(fn points ->
      %Geo.MultiPoint{coordinates: points}
      |> StreamData.constant()
    end)
  end

  @spec multi_line_string(envelope()) :: StreamData.t(Geo.MultiLineString.t())
  def multi_line_string(env \\ @default_env) do
    StreamData.list_of(line_string(env), min_length: 1)
    |> StreamData.bind(fn line_strings ->
      %Geo.MultiLineString{coordinates: Enum.map(line_strings, & &1.coordinates)}
      |> StreamData.constant()
    end)
  end

  @spec multi_polygon(envelope()) :: StreamData.t(Geo.MultiPolygon.t())
  def multi_polygon(env \\ @default_env) do
    StreamData.list_of(polygon(env), min_length: 1)
    |> StreamData.bind(fn polygons ->
      %Geo.MultiPolygon{coordinates: Enum.map(polygons, & &1.coordinates)}
      |> StreamData.constant()
    end)
  end

  defp sort_radially(points) do
    {min_x, max_x} = Enum.map(points, &elem(&1, 0)) |> Enum.min_max()
    {min_y, max_y} = Enum.map(points, &elem(&1, 1)) |> Enum.min_max()

    center = {(min_x + max_x) / 2, (min_y + max_y) / 2}
    points |> Enum.sort_by(&angle(&1, center))
  end

  defp angle({x, y}, {cx, cy}), do: {do_angle(cx - x, cy - y), cy - y}

  defp do_angle(dx, dy) when dx == 0 and dy < 0, do: 3 * :math.pi() / 2.0
  defp do_angle(dx, _) when dx == 0, do: :math.pi() / 2.0
  defp do_angle(dx, dy) when dy == 0 and dx < 0, do: :math.pi()
  defp do_angle(_, dy) when dy == 0, do: 0.0

  defp do_angle(dx, dy) when dx < 0, do: :math.pi() + :math.atan(dy / dx)
  defp do_angle(dx, dy), do: :math.atan(dy / dx)

  defp reverse_sometimes(points) do
    if :rand.uniform() > 0.5 do
      points
    else
      points |> Enum.reverse()
    end
  end

  defp shift_random(points) do
    points
    |> Enum.split(:rand.uniform(length(points)))
    |> Tuple.to_list()
    |> Enum.reverse()
    |> List.flatten()
  end

  # Occasionally, StreamData's float generator generates a float that is outside of the min/max
  # provide due to rounding differences.  This just ensures that the points generated stay inside
  # the given envelope.
  defp enforce_env({x, y}, env) do
    {x |> min(env.max_x) |> max(env.min_x), y |> min(env.max_y) |> max(env.min_y)}
  end
end