lib/geo/turf/math.ex

defmodule Geo.Turf.Math do
  @moduledoc """
  All sorts of mathematical functions
  """

  @type si_length_uk :: :meters | :kilometers | :centimeters | :millimeters
  @type si_length_us :: :metres | :kilometres | :centimetres | :millimetres
  @type imperial_length :: :miles | :nauticalmiles | :inches | :yards | :feet
  @type length_unit :: si_length_uk | si_length_us | imperial_length

  @earth_radius 6_371_008.8
  @factors %{
    centimeters: @earth_radius * 100,
    centimetres: @earth_radius * 100,
    degrees: @earth_radius / 111_325,
    feet: @earth_radius * 3.28084,
    inches: @earth_radius * 39.370,
    kilometers: @earth_radius / 1000,
    kilometres: @earth_radius / 1000,
    meters: @earth_radius,
    metres: @earth_radius,
    miles: @earth_radius / 1609.344,
    millimeters: @earth_radius * 1000,
    millimetres: @earth_radius * 1000,
    nauticalmiles: @earth_radius / 1852,
    radians: 1,
    yards: @earth_radius / 1.0936
  }
  @units_factors %{
    centimeters: 100,
    centimetres: 100,
    degrees: 1 / 111_325,
    feet: 3.28084,
    inches: 39.370,
    kilometers: 1 / 1000,
    kilometres: 1 / 1000,
    meters: 1,
    metres: 1,
    miles: 1 / 1609.344,
    millimeters: 1000,
    millimetres: 1000,
    nauticalmiles: 1 / 1852,
    radians: 1 / @earth_radius,
    yards: 1 / 1.0936
  }
  @area_factors %{
    acres: 0.000247105,
    centimeters: 10_000,
    centimetres: 10_000,
    feet: 10.763910417,
    inches: 1550.003100006,
    kilometers: 0.000001,
    kilometres: 0.000001,
    meters: 1,
    metres: 1,
    miles: 3.86e-7,
    millimeters: 1_000_000,
    millimetres: 1_000_000,
    yards: 1.195990046
  }
  @tau :math.pi() * 2

  @doc false
  @spec factor(:atom) :: Number.t()
  def factor(factor), do: @factors[factor]

  @doc false
  @spec units_factors(:atom) :: Number.t()
  def units_factors(factor), do: @units_factors[factor]

  @doc false
  @spec area_factors(:atom) :: Number.t()
  def area_factors(factor), do: @area_factors[factor]

  @doc false
  @spec earth_radius() :: Number.t()

  def earth_radius(), do: @earth_radius

  @spec radians_to_length(number(), length_unit) :: number()
  def radians_to_length(radians, unit \\ :kilometers) when is_number(radians) do
    radians * @factors[unit]
  end

  @spec length_to_radians(number(), length_unit) :: float()
  def length_to_radians(length, unit \\ :kilometers) when is_number(length) do
    length / @factors[unit]
  end

  @spec length_to_degrees(number(), length_unit) :: float()
  def length_to_degrees(length, units \\ :kilometers) when is_number(length) do
    radians_to_degrees(length_to_radians(length, units))
  end

  @spec radians_to_degrees(number()) :: float()
  def radians_to_degrees(radians) when is_number(radians) do
    degrees = mod(radians, @tau)
    degrees * 180 / :math.pi()
  end

  @spec degrees_to_radians(number()) :: float()
  def degrees_to_radians(degrees) when is_number(degrees) do
    radians = mod(degrees, 360)
    radians * :math.pi() / 180
  end

  @spec bearing_to_azimuth(number()) :: number()
  def bearing_to_azimuth(bearing) when is_number(bearing) do
    angle = mod(bearing, 360)
    if angle < 0, do: angle + 360, else: angle
  end

  @doc """
  Round number to precision

  ## Example

      iex> Geo.Turf.Math.rounded(120.4321)
      120

      iex> Geo.Turf.Math.rounded(120.4321, 3)
      120.432

  """
  def rounded(number, precision \\ 0)
      when is_number(number) and is_integer(precision) and precision >= 0 do
    multiplier = :math.pow(10, precision)

    case precision do
      0 -> round(round(number * multiplier) / multiplier)
      _ -> round(number * multiplier) / multiplier
    end
  end

  @spec convert_length(number, length_unit, length_unit) :: number
  def convert_length(length, from \\ :kilometers, to \\ :kilometers)
      when is_number(length) and length >= 0 do
    radians_to_length(length_to_radians(length, from), to)
  end

  @spec convert_area(number, length_unit, length_unit) :: number
  def convert_area(area, from \\ :meters, to \\ :kilometers) when is_number(area) and area >= 0 do
    area / @area_factors[from] * @area_factors[to]
  end

  @doc """
  Calculates the modulo of a number (integer or float).

  Note that this function uses `floored division` whereas the builtin `rem`
  function uses `truncated division`. See `Decimal.rem/2` if you want a
  `truncated division` function for Decimals that will return the same value as
  the BIF `rem/2` but in Decimal form.

  See [Wikipedia](https://en.wikipedia.org/wiki/Modulo_operation) for an
  explanation of the difference.

  Taken from [cldr_utils](https://hex.pm/packages/cldr_utils) with thanks and gratitude.

  ## Examples

      iex> Geo.Turf.Math.mod(1234.0, 5)
      4.0

  """
  @spec mod(number(), number()) :: number()

  def mod(number, modulus) when number < 0, do: -mod(abs(number), modulus)

  def mod(number, modulus) when is_float(number) and is_number(modulus) do
    number - Float.floor(number / modulus) * modulus
  end

  def mod(number, modulus) when is_integer(number) and is_integer(modulus) do
    modulo =
      number
      |> Integer.floor_div(modulus)
      |> Kernel.*(modulus)

    number - modulo
  end

  def mod(number, modulus) when is_integer(number) and is_number(modulus) do
    modulo =
      number
      |> Kernel./(modulus)
      |> Float.floor()
      |> Kernel.*(modulus)

    number - modulo
  end
end