Current section
Files
Jump to
Current section
Files
lib/open_location_code.ex
defmodule OpenLocationCode do
@moduledoc """
Documentation for OpenLocationCode.
"""
require Integer
# The character set used to encode coordinates.
@code_alphabet "23456789CFGHJMPQRVWX"
# The character used to pad a code
@padding "0"
# A separator used to separate the code into two parts.
@separator "+"
# The max number of characters can be placed before the separator.
@separator_position 8
# Maximum number of digits to process in a plus code.
@max_code_length 15
# Maximum code length using lat/lng pair encoding. The area of such a
# code is approximately 13x13 meters (at the equator), and should be suitable
# for identifying buildings. This excludes prefix and separator characters.
@pair_code_length 10
# Inverse of the precision of the pair code section.
@pair_code_precision 8000
# Precision of the latitude grid.
@lat_grid_precision round(:math.pow(5, @max_code_length - @pair_code_length))
# Precision of the longitude grid.
@lng_grid_precision round(:math.pow(4, @max_code_length - @pair_code_length))
@decode Enum.reduce(String.graphemes(@code_alphabet) ++ [@padding, @separator], %{}, fn c,
ary ->
case :binary.match(@code_alphabet, c) do
:nomatch ->
ary
|> Map.put(:binary.first(c), -1)
|> Map.put(:binary.first(String.downcase(c)), -1)
{index, _} ->
ary
|> Map.put(:binary.first(c), index)
|> Map.put(
:binary.first(String.downcase(c)),
:binary.match(@code_alphabet, c) |> elem(0)
)
end
end)
@doc """
Generates a Open Location Code from the given coordinates.
latitude and longitude
## Examples
iex> OpenLocationCode.encode(29.952062, -90.077188)
{:ok, "76XFXW2F+R4"}
"""
@spec encode(number(), number(), integer()) :: {:ok, binary()} | {:error, binary()}
def encode(latitude, longitude, code_length \\ @pair_code_length) do
try do
code = encode!(latitude, longitude, code_length)
{:ok, code}
rescue
x ->
{:error, x.message}
end
end
@doc """
Same as encode/1 except will throw an exception if invalid
## Examples
iex> OpenLocationCode.encode!(29.952062, -90.077188)
"76XFXW2F+R4"
"""
@spec encode!(number(), number(), integer()) :: binary()
def encode!(latitude, longitude, code_length \\ @pair_code_length) do
if invalid_length?(code_length),
do: raise(ArgumentError, message: "Invalid Open Location Code length")
code_length =
if code_length > @max_code_length do
@max_code_length
else
code_length
end
latitude = clip_latitude(latitude)
longitude = normalize_longitude(longitude)
latitude =
if latitude == 90 do
latitude - precision_by_length(code_length)
else
latitude
end
lat_val = 90 * @pair_code_precision * @lat_grid_precision
lat_val = lat_val + latitude * @pair_code_precision * @lat_grid_precision
lng_val = 180 * @pair_code_precision * @lng_grid_precision
lng_val = lng_val + longitude * @pair_code_precision * @lng_grid_precision
lat_val = Float.floor(lat_val) |> round()
lng_val = Float.floor(lng_val) |> round()
{code, lat_val, lng_val} =
if code_length > @pair_code_length do
range = 0..(@max_code_length - @pair_code_length - 1)
init_value = {"", lat_val, lng_val}
Enum.reduce(range, init_value, fn _, {code, lat_val, lng_val} ->
index = Integer.mod(lat_val, 5) * 4 + Integer.mod(lng_val, 4)
code = String.at(@code_alphabet, index) <> code
lat_val = Integer.floor_div(lat_val, 5)
lng_val = Integer.floor_div(lng_val, 4)
{code, lat_val, lng_val}
end)
else
lat_val = Integer.floor_div(lat_val, @lat_grid_precision)
lng_val = Integer.floor_div(lng_val, @lng_grid_precision)
{"", lat_val, lng_val}
end
range = 0..(round(@pair_code_length / 2) - 1)
init_value = {code, lat_val, lng_val}
{code, _lat_val, _lng_val} =
Enum.reduce(
range,
init_value,
fn i, {code, lat_val, lng_val} ->
code = String.at(@code_alphabet, Integer.mod(lng_val, 20)) <> code
code = String.at(@code_alphabet, Integer.mod(lat_val, 20)) <> code
lat_val = Integer.floor_div(lat_val, 20)
lng_val = Integer.floor_div(lng_val, 20)
code = if i == 0, do: @separator <> code, else: code
{code, lat_val, lng_val}
end
)
format_code(code, code_length)
end
@doc """
Generates an OpenLocationCode.CodeArea struct from a given Open Location Code
"""
@spec decode(binary()) :: {:ok, OpenLocationCode.CodeArea.t()} | {:error, binary()}
def decode(code) do
try do
point = decode!(code)
{:ok, point}
rescue
x ->
{:error, x.message}
end
end
@doc """
Same as decode/1 except will throw an exception if invalid
"""
@spec decode!(binary()) :: OpenLocationCode.CodeArea.t()
def decode!(code) do
unless full?(code) do
raise ArgumentError,
message: "Open Location Code is not a valid full code: #{code}"
end
code =
code
|> String.replace(@separator, "")
|> String.replace(~r/#{@padding}+/, "")
|> String.upcase()
code_length = min(String.length(code), @max_code_length)
south_latitude = -90.0
west_longitude = -180.0
lat_resolution = 400
lng_resolution = 400
digit = 0
{south_latitude, west_longitude, lat_resolution, lng_resolution, digit} =
do_decode(
code,
code_length,
digit,
lat_resolution,
lng_resolution,
south_latitude,
west_longitude
)
latitude_center = south_latitude + lat_resolution / 2.0
longitude_center = west_longitude + lng_resolution / 2.0
%OpenLocationCode.CodeArea{
south_latitude: south_latitude,
west_longitude: west_longitude,
latitude_height: lat_resolution,
longitude_width: lng_resolution,
code_length: digit,
latitude_center: latitude_center,
longitude_center: longitude_center
}
end
defp do_decode(
_code,
code_length,
digit,
lat_resolution,
lng_resolution,
south_latitude,
west_longitude
)
when digit >= code_length do
{south_latitude, west_longitude, lat_resolution, lng_resolution, digit}
end
defp do_decode(
code,
code_length,
digit,
lat_resolution,
lng_resolution,
south_latitude,
west_longitude
)
when digit < @pair_code_length do
lat_resolution = lat_resolution / 20
lng_resolution = lng_resolution / 20
south_latitude =
south_latitude + lat_resolution * Map.get(@decode, :binary.first(String.at(code, digit)))
west_longitude =
west_longitude +
lng_resolution * Map.get(@decode, :binary.first(String.at(code, digit + 1)))
do_decode(
code,
code_length,
digit + 2,
lat_resolution,
lng_resolution,
south_latitude,
west_longitude
)
end
defp do_decode(
code,
code_length,
digit,
lat_resolution,
lng_resolution,
south_latitude,
west_longitude
) do
lat_resolution = lat_resolution / 5
lng_resolution = lng_resolution / 4
row = Map.get(@decode, :binary.first(String.at(code, digit))) |> div(4)
column = Map.get(@decode, :binary.first(String.at(code, digit))) |> Integer.mod(4)
south_latitude = south_latitude + lat_resolution * row
west_longitude = west_longitude + lng_resolution * column
do_decode(
code,
code_length,
digit + 1,
lat_resolution,
lng_resolution,
south_latitude,
west_longitude
)
end
@doc """
Determines if a string is a valid sequence of Open Location Code characters.
"""
def valid?(code) do
valid_length?(code) && valid_separator?(code) && valid_padding?(code) &&
valid_character?(code)
end
@doc """
Determines if a string is a valid short Open Location Code.
"""
@spec short?(binary()) :: boolean()
def short?(code) do
valid?(code) && :binary.match(code, @separator) |> elem(0) < @separator_position
end
@doc """
Determines if a string is a valid full Open Location Code
"""
@spec full?(binary()) :: boolean()
def full?(code) do
valid?(code) && !short?(code)
end
@doc """
Recovers a full Open Location Code from a short code and a
reference location.
"""
def recover_nearest(short_code, reference_latitude, reference_longitude) do
cond do
full?(short_code) ->
String.upcase(short_code)
!short?(short_code) ->
raise ArgumentError,
message: "Open Location Code is not valid: #{short_code}"
true ->
reference_latitude = clip_latitude(reference_latitude)
reference_longitude = normalize_longitude(reference_longitude)
prefix_len = @separator_position - (:binary.match(short_code, @separator) |> elem(0))
code =
prefix_by_reference(reference_latitude, reference_longitude, prefix_len) <> short_code
code_area = decode!(code)
resolution = precision_by_length(prefix_len)
half_resolution = resolution / 2
latitude = code_area.latitude_center
latitude =
cond do
reference_latitude + half_resolution < latitude && latitude - resolution >= -90 ->
latitude - resolution
reference_latitude - half_resolution > latitude && latitude + resolution <= 90 ->
latitude + resolution
true ->
latitude
end
longitude = code_area.longitude_center
longitude =
cond do
reference_longitude + half_resolution < longitude ->
longitude - resolution
reference_longitude - half_resolution > longitude ->
longitude + resolution
true ->
longitude
end
encode!(latitude, longitude, String.length(code) - String.length(@separator))
end
end
@doc """
Removes four, six or eight digits from the front of an Open Location Code
given a reference location.
"""
@spec shorten(binary(), number(), number()) :: binary()
def shorten(code, latitude, longitude) do
unless full?(code) do
raise ArgumentError,
message: "Open Location Code is a valid full code: #{code}"
end
unless :binary.match(code, @padding) == :nomatch do
raise ArgumentError,
message: "Cannot shorten padded codes: #{code}"
end
code_area = decode!(code)
lat_diff = abs(latitude - code_area.latitude_center)
lng_diff = abs(longitude - code_area.longitude_center)
max_diff = max(lat_diff, lng_diff)
code =
Enum.reduce_while([8, 6, 4], code, fn removal_len, code ->
area_edge = precision_by_length(removal_len) * 0.3
if max_diff < area_edge do
{:halt, String.slice(code, removal_len..-1)}
else
{:cont, code}
end
end)
String.upcase(code)
end
defp invalid_length?(code_length) when code_length < 2 do
true
end
defp invalid_length?(code_length)
when code_length < @pair_code_length and Integer.is_odd(code_length) do
true
end
defp invalid_length?(_) do
false
end
defp clip_latitude(latitude) do
max = max(-90.0, latitude)
min(90.0, max)
end
defp normalize_longitude(longitude) when longitude <= 180 and longitude >= -180 do
longitude
end
defp normalize_longitude(longitude) when longitude > 180 do
normalize_longitude(longitude - 360)
end
defp normalize_longitude(longitude) when longitude < -180 do
normalize_longitude(longitude + 360)
end
defp precision_by_length(code_length) when code_length <= @pair_code_length do
value = Integer.floor_div(code_length, -2)
:math.pow(20, value + 2)
end
defp precision_by_length(code_length) do
1.0 / (:math.pow(20, 3) * :math.pow(5, code_length - @pair_code_length))
end
defp format_code(code, code_length) when code_length >= @separator_position do
String.slice(code, 0, code_length + 1)
end
defp format_code(code, code_length) when @separator_position - code_length > 0 do
String.slice(code, 0, code_length) <>
String.duplicate(@padding, @separator_position - code_length) <> @separator
end
defp valid_length?(nil), do: false
defp valid_length?(code) when byte_size(code) < 2 + byte_size(@separator), do: false
defp valid_length?(code) do
code
|> String.split(@separator)
|> List.last()
|> String.length() != 1
end
defp valid_separator?(code) do
separator_idx = :binary.match(code, @separator) |> elem(0)
length(String.split(code, @separator)) == 2 && separator_idx <= @separator_position &&
Integer.is_even(separator_idx)
end
defp valid_padding?(code) do
paddings = Regex.scan(~r/#{@padding}+/, code)
if String.contains?(code, @padding) do
cond do
:binary.match(code, @separator) |> elem(0) < @separator_position ->
false
String.starts_with?(code, @padding) ->
false
String.slice(code, -2..-1) != @padding <> @separator ->
false
length(paddings) > 1 ->
false
Enum.at(paddings, 0) |> hd |> String.length() |> Integer.is_odd() ->
false
Enum.at(paddings, 0) |> length() > @separator_position - 2 ->
false
true ->
true
end
else
true
end
end
defp valid_character?(code) do
code =
code
|> String.replace(@separator, "")
|> String.replace(~r/#{@padding}+/, "")
|> String.upcase()
Enum.all?(String.graphemes(code), fn ch -> String.contains?(@code_alphabet, ch) end)
end
defp prefix_by_reference(latitude, longitude, prefix_len) do
precision = precision_by_length(prefix_len)
rounded_latitude = Float.floor(latitude / precision) * precision
rounded_longitude = Float.floor(longitude / precision) * precision
code = encode!(rounded_latitude, rounded_longitude)
spliced_code = String.slice(code, 0..(prefix_len - 1))
spliced_code
end
end