defmodule TzWorld do @moduledoc """ Resolve a timezone name from coordinates. """ alias Geo.{Point, PointZ} import TzWorld.Guards @type backend :: module() @reload_backends [ TzWorld.Backend.SpatialIndex, TzWorld.Backend.EtsWithIndexCache, TzWorld.Backend.DetsWithIndexCache ] @doc """ Returns the OTP app name of :tz_world """ def app_name do :tz_world end @doc """ Returns the installed version of time zone data ## Example TzWorld.version => {:ok, "2020d"} """ @spec version :: {:ok, String.t()} | {:error, :enoent} def version do fetch_backend().version() end @doc """ Reload the timezone geometry data. This allows for the data to be reloaded, typically with a new release, without restarting the application. """ def reload_timezone_data do Enum.map(@reload_backends, fn backend -> apply(backend, :reload_timezone_data, []) end) end @doc """ Returns the *first* timezone name found for the given coordinates specified as either a `Geo.Point`, a `Geo.PointZ` or a tuple `{lng, lat}` ## Arguments * `point` is a `Geo.Point.t()` a `Geo.PointZ.t()` or a tuple `{lng, lat}` * `backend` is any backend access module. ## Returns * `{:ok, timezone}` or * `{:error, :time_zone_not_found}` ## Notes Note that the point is always expressed as `lng` followed by `lat`. ## Examples iex> TzWorld.timezone_at(%Geo.Point{coordinates: {3.2, 45.32}}) {:ok, "Europe/Paris"} iex> TzWorld.timezone_at({3.2, 45.32}) {:ok, "Europe/Paris"} iex> TzWorld.timezone_at({0.0, 0.0}) {:error, :time_zone_not_found} The algorithm starts by filtering out timezones whose bounding box does not contain the given point. Once filtered, the *first* timezone which contains the given point is returned, or an error tuple if none of the timezones match. In rare cases, typically due to territorial disputes, one or more timezones may apply to a given location. This function returns the first time zone that matches. """ @spec timezone_at(Geo.Point.t(), backend) :: {:ok, String.t()} | {:error, atom} def timezone_at(point, backend \\ fetch_backend()) def timezone_at(%Point{} = point, backend) when is_atom(backend) do backend.timezone_at(point) end @spec timezone_at(Geo.PointZ.t(), backend) :: {:ok, String.t()} | {:error, atom} def timezone_at(%PointZ{coordinates: {lng, lat, _alt}}, backend) when is_atom(backend) do point = %Point{coordinates: {lng, lat}} backend.timezone_at(point) end @spec timezone_at({lng :: number, lat :: number}, backend) :: {:ok, String.t()} | {:error, atom} def timezone_at({lng, lat}, backend) when is_lng(lng) and is_lat(lat) do point = %Geo.Point{coordinates: {lng, lat}} backend.timezone_at(point) end @doc """ Returns all timezone name found for the given coordinates specified as either a `Geo.Point`, a `Geo.PointZ` or a tuple `{lng, lat}` ## Arguments * `point` is a `Geo.Point.t()` a `Geo.PointZ.t()` or a tuple `{lng, lat}` * `backend` is any backend access module. ## Returns * `{:ok, timezone}` or * `{:error, :time_zone_not_found}` ## Notes Note that the point is always expressed as `lng` followed by `lat`. ## Examples iex> TzWorld.all_timezones_at(%Geo.Point{coordinates: {3.2, 45.32}}) {:ok, ["Europe/Paris"]} iex> TzWorld.all_timezones_at({3.2, 45.32}) {:ok, ["Europe/Paris"]} iex> TzWorld.all_timezones_at({0.0, 0.0}) {:ok, []} The algorithm starts by filtering out timezones whose bounding box does not contain the given point. Once filtered, all timezones which contains the given point is returned, or an error tuple if none of the timezones match. In rare cases, typically due to territorial disputes, one or more timezones may apply to a given location. This function returns all time zones that match. """ @spec all_timezones_at(Geo.Point.t(), backend) :: {:ok, [String.t()]} def all_timezones_at(point, backend \\ fetch_backend()) def all_timezones_at(%Point{} = point, backend) when is_atom(backend) do backend.all_timezones_at(point) end @spec all_timezones_at(Geo.PointZ.t(), backend) :: {:ok, [String.t()]} def all_timezones_at(%PointZ{coordinates: {lng, lat, _alt}}, backend) when is_atom(backend) do point = %Point{coordinates: {lng, lat}} backend.all_timezones_at(point) end @spec all_timezones_at({lng :: number, lat :: number}, backend) :: {:ok, [String.t()]} def all_timezones_at({lng, lat}, backend) when is_lng(lng) and is_lat(lat) do point = %Geo.Point{coordinates: {lng, lat}} backend.all_timezones_at(point) end @doc false def contains?(%Geo.MultiPolygon{} = multi_polygon, %Geo.Point{} = point) do multi_polygon.coordinates |> Enum.any?(fn polygon -> contains?(%Geo.Polygon{coordinates: polygon}, point) end) end def contains?(%Geo.Polygon{coordinates: [envelope | holes]}, %Geo.Point{coordinates: point}) do interior?(envelope, point) && disjoint?(holes, point) end def contains?(bounding_boxes, point) when is_list(bounding_boxes) do Enum.any?(bounding_boxes, &contains?(&1, point)) end defp interior?(ring, {px, py}) do ring = for {x, y} <- ring, do: {x - px, y - py} crosses = count_crossing(ring) rem(crosses, 2) == 1 end defp disjoint?(rings, point) do Enum.all?(rings, fn ring -> !interior?(ring, point) end) end defp count_crossing([_]), do: 0 defp count_crossing([{ax, ay}, {bx, by} | rest]) do crosses = count_crossing([{bx, by} | rest]) if ay > 0 != by > 0 && (ax * by - bx * ay) / (by - ay) > 0 do crosses + 1 else crosses end end @default_backend_precedence [ TzWorld.Backend.SpatialIndex, TzWorld.Backend.EtsWithIndexCache, TzWorld.Backend.DetsWithIndexCache ] def fetch_backend do backends = [Application.get_env(:tz_world, :default_backend) | @default_backend_precedence] |> Enum.uniq() |> Enum.reject(&is_nil/1) Enum.find(backends, &Process.whereis/1) || raise( RuntimeError, "No TzWorld backend appears to be running. " <> "please add one of #{inspect(backends)} to your supervision tree" ) end @doc false require Logger def maybe_log(message, trace? \\ false) def maybe_log(message, true) do memory = trunc(:erlang.memory()[:total] / 1_048_576) Logger.debug("[#{memory} MiB] " <> message) end def maybe_log(_message, false) do nil end end