Packages

phoenix_kit

2.60.0
2.60.2 2.60.1 2.60.0 2.59.0 2.58.0 2.57.1 2.57.0 2.56.1 2.56.0 2.55.1 2.55.0 2.54.2 2.54.1 2.54.0 2.53.0 2.52.2 2.52.1 2.52.0 2.51.0 2.50.0 2.49.1 2.49.0 2.48.0 2.47.0 2.46.0 2.45.0 2.44.0 2.43.1 2.43.0 2.42.1 2.42.0 2.41.6 2.41.4 2.41.3 2.41.2 2.41.1 2.41.0 2.40.1 2.40.0 2.39.0 2.38.1 2.38.0 2.37.5 2.37.4 2.37.3 2.37.2 2.37.1 2.37.0 2.36.1 2.36.0 2.35.0 2.34.0 2.33.0 2.32.1 2.32.0 2.31.1 2.31.0 2.30.0 2.29.1 2.29.0 2.28.2 2.28.1 2.28.0 2.27.2 2.27.1 2.27.0 2.26.1 2.26.0 2.25.0 2.24.0 2.23.3 2.23.2 2.23.1 2.23.0 2.22.24 2.22.23 2.22.22 2.22.21 2.22.20 2.22.19 2.22.18 2.22.17 2.22.16 2.22.15 2.22.14 2.22.13 2.22.12 2.22.11 2.22.10 2.22.9 2.22.8 2.22.7 2.22.6 2.22.5 2.22.4 2.22.3 2.22.2 2.22.1 2.22.0 2.21.5 2.21.4 2.21.3 2.21.2 2.21.1 2.21.0 2.20.0 2.19.0 2.18.1 2.18.0 2.17.0 2.16.0 2.15.1 2.15.0 2.14.2 2.14.1 2.14.0 2.13.19 2.13.18 2.13.17 2.13.16 2.13.15 2.13.13 2.13.12 2.13.11 2.13.10 2.13.9 2.13.8 2.13.7 2.13.6 2.13.5 2.13.4 2.13.3 2.13.2 2.13.1 2.13.0 2.12.1 2.12.0 2.11.0 2.10.0 2.9.0 2.8.1 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.0 2.0.1 2.0.0 1.7.236 1.7.235 1.7.234 1.7.233 1.7.232 1.7.231 1.7.230 1.7.229 1.7.228 1.7.227 1.7.226 1.7.225 1.7.224 1.7.223 1.7.222 1.7.221 1.7.220 1.7.219 1.7.218 1.7.217 1.7.216 1.7.215 1.7.214 1.7.213 1.7.212 1.7.211 1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib modules storage geo.ex
Raw

lib/modules/storage/geo.ex

defmodule PhoenixKit.Modules.Storage.Geo do
  @moduledoc """
  Photos on a map: "what was taken in this area?" as one indexed lookup.

  A photo's EXIF position is in `phoenix_kit_files.latitude` / `longitude`
  (V215). The index is a GiST index on `point(longitude, latitude)`, which
  Postgres has built in (no PostGIS), and a box on the map is

      point(longitude, latitude) <@ box(point(west, south), point(east, north))

  A **bounds** is `{south, west, north, east}` in degrees, the corners of the
  visible map: latitude -90..90, longitude -180..180. A box that crosses the
  antimeridian (the Pacific, Fiji, eastern Russia) has `west > east`, and is
  searched as two.

      Storage.list_files_in_scope(nil, bounds: {46.0, 14.0, 48.0, 16.0})
      Geo.within(File, {46.0, 14.0, 48.0, 16.0})
      Geo.parse_bounds("46,14,48,16")

  A file with no position is never inside any box.
  """

  import Ecto.Query, only: [dynamic: 2, where: 3]

  @type bounds :: {number(), number(), number(), number()}

  @doc """
  Narrows a query over files to those inside `bounds`. `nil` leaves it as it is.
  A bounds that is not four numbers raises, so a typo does not become "no filter".
  """
  @spec within(Ecto.Queryable.t(), bounds() | nil) :: Ecto.Query.t()
  def within(query, nil), do: query

  def within(query, {south, west, north, east} = bounds)
      when is_number(south) and is_number(west) and is_number(north) and is_number(east) do
    condition =
      case boxes(bounds) do
        [box] -> box_condition(box)
        [a, b] -> dynamic([f], ^box_condition(a) or ^box_condition(b))
      end

    where(query, [f], ^condition)
  end

  # The longitude ranges the box covers: one, or two when it crosses ±180 (after
  # a longitude past the edge of the world is wrapped back), or the whole world
  # when it spans it. The latitudes are clamped to the globe.
  defp boxes({south, west, north, east}) do
    south = clamp(south, -90, 90)
    north = clamp(north, -90, 90)

    if east - west >= 360 do
      [{south, -180.0, north, 180.0}]
    else
      {west, east} = {wrap(west), wrap(east)}

      if west <= east,
        do: [{south, west, north, east}],
        else: [{south, west, north, 180.0}, {south, -180.0, north, east}]
    end
  end

  defp box_condition({south, west, north, east}) do
    dynamic(
      [f],
      fragment(
        "point(?, ?) <@ box(point(?, ?), point(?, ?))",
        f.longitude,
        f.latitude,
        ^(west * 1.0),
        ^(south * 1.0),
        ^(east * 1.0),
        ^(north * 1.0)
      )
    )
  end

  defp clamp(value, low, high), do: value |> max(low) |> min(high) |> Kernel.*(1.0)

  # A longitude past ±180 (a map panned around the world) is the same place.
  defp wrap(lon) when lon > 180 or lon < -180 do
    remainder = :math.fmod(lon + 180.0, 360.0)
    Float.round(if(remainder < 0, do: remainder + 360.0, else: remainder) - 180.0, 9)
  end

  defp wrap(lon), do: lon * 1.0

  @doc """
  A bounds from what a URL or a form carries: `"south,west,north,east"`, a list
  of four, or a map with `"south"`, `"west"`, `"north"`, `"east"`. `nil` when it
  is not four numbers, or the south is above the north.
  """
  @spec parse_bounds(term()) :: bounds() | nil
  def parse_bounds(value) when is_binary(value), do: value |> String.split(",") |> parse_bounds()

  def parse_bounds(%{} = map) do
    parse_bounds(Enum.map(~w(south west north east), &Map.get(map, &1)))
  end

  def parse_bounds([_, _, _, _] = parts) do
    case Enum.map(parts, &to_number/1) do
      [s, w, n, e]
      when is_number(s) and is_number(w) and is_number(n) and is_number(e) and s <= n ->
        {s, w, n, e}

      _ ->
        nil
    end
  end

  def parse_bounds(_), do: nil

  defp to_number(value) when is_number(value), do: value

  defp to_number(value) when is_binary(value) do
    case Float.parse(String.trim(value)) do
      {number, ""} -> number
      _ -> nil
    end
  rescue
    # Float.parse/1 raises on a few hundred digits; a URL can carry them.
    ArgumentError -> nil
  end

  defp to_number(_), do: nil
end