Packages
An Elixir implementation of Open Location Code (Plus Codes). Plus Codes are short, easy to share location codes that can be used instead of street addresses. This library provides functions to encode/decode coordinates, validate codes, and handle shortened codes.
Current section
Files
Jump to
Current section
Files
lib/pluscode.ex
defmodule Pluscode do
@moduledoc """
Convert locations to and from Open Location Codes (Plus Codes).
Plus Codes are short, 10-11 character codes that can be used instead
of street addresses. The codes can be generated and decoded offline, and use
a reduced character set that minimizes the chance of codes including words.
Codes are able to be shortened relative to a nearby location. This means that
in many cases, only four to seven characters of the code are needed.
To recover the original code, the same location is not required, as long as
a nearby location is provided.
Codes represent rectangular areas rather than points, and the longer the
code, the smaller the area. A 10 character code represents a 13.5x13.5
meter area (at the equator). An 11 character code represents approximately
a 2.8x3.5 meter area.
"""
require Logger
# A separator used to break the code into two parts to aid memorability.
@separator ?+
# The number of characters to place before the separator.
@separator_position 8
# The character used to pad codes.
@padding ?0
# The character set used to encode the values.
@code_alphabet ~c"23456789CFGHJMPQRVWX"
@code_values @code_alphabet
|> Enum.with_index()
|> Map.new()
# The base to use to convert numbers to/from.
@encoding_base length(@code_alphabet)
# The maximum value for latitude in degrees.
@latitude_max 90
# The maximum value for longitude in degrees.
@longitude_max 180
# The min number of digits to process in a Plus Code.
@min_digit_count 2
# The max number of digits to process in a Plus Code.
@max_digit_count 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
# First place value of the pairs (if the last pair value is 1).
@pair_first_place_value @encoding_base |> :math.pow(@pair_code_length / 2 - 1) |> trunc()
# Inverse of the precision of the pair section of the code.
@pair_precision @encoding_base |> :math.pow(3) |> trunc()
# The resolution values in degrees for each position in the lat/lng pair
# encoding. These give the place value of each position, and therefore the
# dimensions of the resulting area.
@pair_resolutions [20.0, 1.0, 0.05, 0.0025, 0.000125]
# Number of digits in the grid precision part of the code.
@grid_code_length @max_digit_count - @pair_code_length
# Number of columns in the grid refinement method.
@grid_columns 4
# Number of rows in the grid refinement method.
@grid_rows 5
# First place value of the latitude grid (if the last place is 1).
@grid_lat_first_place_value @grid_rows |> :math.pow(@grid_code_length - 1) |> trunc()
# First place value of the longitude grid (if the last place is 1).
@grid_lng_first_place_value @grid_columns |> :math.pow(@grid_code_length - 1) |> trunc()
# Multiply latitude by this much to make it a multiple of the finest
# precision.
@final_lat_precision trunc(
@pair_precision *
:math.pow(@grid_rows, @max_digit_count - @pair_code_length)
)
# Multiply longitude by this much to make it a multiple of the finest
# precision.
@final_lng_precision trunc(
@pair_precision *
:math.pow(@grid_columns, @max_digit_count - @pair_code_length)
)
# Minimum length of a code that can be shortened.
@min_trimmable_code_len 6
@doc """
A struct representing the coordinates of a decoded Open Location Code.
The coordinates include the latitude and longitude of the lower left and
upper right corners and the center of the bounding box for the area the
code represents.
## Fields
* `:latitude_lo` - The latitude of the SW corner in degrees.
* `:longitude_lo` - The longitude of the SW corner in degrees.
* `:latitude_hi` - The latitude of the NE corner in degrees.
* `:longitude_hi` - The longitude of the NE corner in degrees.
* `:latitude_center` - The latitude of the center in degrees.
* `:longitude_center` - The longitude of the center in degrees.
* `:code_length` - The number of significant characters that were in the code (excluding separator).
"""
defstruct [
:latitude_lo,
:longitude_lo,
:latitude_hi,
:longitude_hi,
:latitude_center,
:longitude_center,
:code_length
]
@type t :: %__MODULE__{
latitude_lo: float(),
longitude_lo: float(),
latitude_hi: float(),
longitude_hi: float(),
latitude_center: float(),
longitude_center: float(),
code_length: integer()
}
@doc """
Determines if a code is valid.
To be valid, all characters must be from the Open Location Code character
set with at most one separator. The separator can be in any even-numbered
position up to the eighth digit.
## Args
* `code` - The code to validate.
## Returns
`true` if the code is valid, `false` otherwise.
## Examples
iex> Pluscode.valid?("8FVC9G8F+6X")
true
iex> Pluscode.valid?("8FVC9G8F+")
true
iex> Pluscode.valid?("invalid")
false
"""
def valid?(code) when is_binary(code), do: valid?(String.to_charlist(code))
def valid?(code) when is_list(code) do
with {_, true} <- {:separator_count, count_char(code, @separator) <= 1},
{_, true} <- {:code_length, length(code) > 1},
sep_pos = Enum.find_index(code, &(&1 == @separator)),
{_, true} <- {:separator_exists, not is_nil(sep_pos)},
{_, true} <- {:separator_position, separator_valid?(sep_pos)},
pad_pos = Enum.find_index(code, &(&1 == @padding)),
{_, true} <- {:padding_valid, padding_valid?(pad_pos, sep_pos, code)},
{_, true} <- {:chars_after_sep, length(code) - sep_pos - 1 != 1},
{_, true} <- {:valid_chars, only_valid_chars?(code)} do
true
else
{validation_step, false} ->
Logger.debug("Open Location Code validation failed at: #{validation_step}")
false
end
end
defp separator_valid?(nil), do: false
defp separator_valid?(sep_pos) when sep_pos > @separator_position, do: false
defp separator_valid?(sep_pos) when rem(sep_pos, 2) == 1, do: false
defp separator_valid?(_), do: true
defp padding_valid?(nil, _sep_pos, _code), do: true
defp padding_valid?(0, _sep_pos, _code), do: false
defp padding_valid?(_, sep_pos, _code) when sep_pos < @separator_position, do: false
defp padding_valid?(pad_pos, sep_pos, code) do
# Get padding substring
pads = Enum.slice(code, pad_pos..(sep_pos - 1))
# Check padding conditions
pads |> length() |> rem(2) == 0 and
Enum.all?(pads, &(&1 == @padding)) and
List.last(code) == @separator
end
defp only_valid_chars?(code) do
valid_chars = @code_alphabet ++ [@separator, @padding]
code
|> upcase_list()
|> Enum.all?(fn ch ->
ch in valid_chars
end)
end
defp count_char(list, char) do
Enum.count(list, &(&1 == char))
end
@doc """
Determines if a code is a valid short code.
A short Open Location Code is a sequence created by removing four or more
digits from an Open Location Code. It must include a separator character.
## Args
* `code` - The code to check.
## Returns
`true` if the code is a valid short code, `false` otherwise.
## Examples
iex> Pluscode.short?("9G8F+6X")
true
iex> Pluscode.short?("8FVC9G8F+6X")
false
"""
def short?(code) when is_binary(code), do: short?(String.to_charlist(code))
def short?(code) do
if valid?(code) do
# If there are less characters than expected before the SEPARATOR.
case Enum.find_index(code, &(&1 == @separator)) do
nil -> false
0 -> true
sep -> sep < @separator_position
end
else
false
end
end
@doc """
Determines if a code is a valid full Open Location Code.
Not all possible combinations of Open Location Code characters decode to
valid latitude and longitude values. This checks that a code is valid
and also that the latitude and longitude values are legal.
## Args
* `code` - The code to check.
## Returns
`true` if the code is a valid full code, `false` otherwise.
## Rules
* If the prefix character is present, it must be the first character.
* If the separator character is present, it must be after four characters.
* The first latitude character must decode to a valid latitude (<90 degrees).
* The first longitude character must decode to a valid longitude (<180 degrees).
## Examples
iex> Pluscode.full?("8FVC9G8F+6X")
true
iex> Pluscode.full?("9G8F+6X")
false
"""
def full?(code) when is_binary(code), do: full?(String.to_charlist(code))
def full?(code) do
with {:valid, true} <- {:valid, valid?(code)},
{:not_short, false} <- {:not_short, short?(code)},
first_lat_value = get_alphabet_index(hd(code)) * @encoding_base,
{:lat_range, true} <- {:lat_range, first_lat_value < @latitude_max * 2},
{:lng_range, true} <- {:lng_range, validate_longitude_value(code)} do
true
else
{:valid, false} ->
Logger.debug("Code is not valid")
false
{:not_short, true} ->
Logger.debug("Code is short")
false
{:lat_range, false} ->
Logger.debug("Latitude would decode to >= 90 degrees")
false
{:lng_range, false} ->
Logger.debug("Longitude would decode to >= 180 degrees")
false
end
end
defp validate_longitude_value(code) do
first_lng_value =
if length(code) > 1 do
get_alphabet_index(Enum.at(code, 1)) * @encoding_base
else
0
end
first_lng_value < @longitude_max * 2
end
@doc false
# Returns the index of a character in the code alphabet.
# Handles both uppercase and lowercase characters.
#
# ## Args
# * `char` - The character to look up (as an integer).
#
# ## Returns
# The index of the character in the code alphabet, or nil if not found.
defp get_alphabet_index(char) when is_integer(char) do
upcase_char = if char >= ?a and char <= ?z, do: char - 32, else: char
@code_values[upcase_char]
end
@doc """
Encode a location into an Open Location Code.
## Args
* `latitude` - The latitude in signed decimal degrees. Must be between -90 and 90.
* `longitude` - The longitude in signed decimal degrees. Must be between -180 and 180.
* `code_length` - The number of significant digits in the output code (default: 10).
The length determines the precision of the code.
## Returns
A string containing the Open Location Code.
## Examples
iex> Pluscode.encode(47.365590, 8.524997)
"8FVC9G8F+6X"
iex> Pluscode.encode(47.365590, 8.524997, 11)
"8FVC9G8F+6XQ"
"""
def encode(lat, lon, code_length \\ @pair_code_length)
def encode(_lat, _lon, code_length) when code_length < @min_digit_count do
raise "Invalid Open Location Code length"
end
def encode(_lat, _lon, code_length) when code_length < @pair_code_length and rem(code_length, 2) == 1 do
raise ArgumentError, "Code length must be even - odd lengths have sides in ratio of 20:1"
end
def encode(latitude, longitude, code_length) when code_length > @max_digit_count do
encode(latitude, longitude, @max_digit_count)
end
def encode(latitude, longitude, code_length) do
latitude = clip_latitude(latitude)
longitude = normalize_longitude(longitude)
# Latitude 90 needs to be adjusted to be just less, so the returned code
# can also be decoded.
latitude =
if latitude == 90 do
latitude - compute_latitude_precision(code_length)
else
latitude
end
# Compute the code.
# This approach converts each value to an integer after multiplying it by
# the final precision. This allows us to use only integer operations, so
# avoiding any accumulation of floating point representation errors.
# Multiply values by their precision and convert to positive.
# Force to integers so the division operations will have integer results.
# Note: Python requires rounding before truncating to ensure precision!
lat_val = trunc(Float.round((latitude + @latitude_max) * @final_lat_precision, 6))
lng_val = trunc(Float.round((longitude + @longitude_max) * @final_lng_precision, 6))
code = []
{code, lat_val, lng_val} =
if code_length > @pair_code_length do
Enum.reduce(
0..(@max_digit_count - @pair_code_length - 1),
{code, lat_val, lng_val},
fn _i, {code, lat_val, lng_val} ->
lat_digit = rem(lat_val, @grid_rows)
lng_digit = rem(lng_val, @grid_columns)
ndx = lat_digit * @grid_columns + lng_digit
new_code = [Enum.at(@code_alphabet, ndx) | code]
new_lat_val = div(lat_val, @grid_rows)
new_lng_val = div(lng_val, @grid_columns)
{new_code, new_lat_val, new_lng_val}
end
)
else
lat_val = div(lat_val, trunc(:math.pow(@grid_rows, @grid_code_length)))
lng_val = div(lng_val, trunc(:math.pow(@grid_columns, @grid_code_length)))
{code, lat_val, lng_val}
end
{final_code, _, _} =
Enum.reduce(
0..(div(@pair_code_length, 2) - 1),
{code, lat_val, lng_val},
fn _i, {code, lat_val, lng_val} ->
new_code =
[
Enum.at(@code_alphabet, rem(lat_val, @encoding_base)),
Enum.at(@code_alphabet, rem(lng_val, @encoding_base)) | code
]
new_lat_val = div(lat_val, @encoding_base)
new_lng_val = div(lng_val, @encoding_base)
{new_code, new_lat_val, new_lng_val}
end
)
code =
List.insert_at(final_code, @separator_position, @separator)
if_result =
if code_length >= @separator_position do
Enum.take(code, code_length + 1)
else
Enum.concat([
Enum.take(code, code_length),
Enum.map(1..(@separator_position - code_length), fn _ -> "0" end),
[@separator]
])
end
to_string(if_result)
end
@doc """
Decodes an Open Location Code into the location coordinates.
Returns a Pluscode struct that includes the coordinates of the bounding
box - the lower left, center and upper right.
Args:
code: The Open Location Code to decode.
Returns:
A Pluscode struct that provides the latitude and longitude of two of the
corners of the area, the center, and the length of the original code.
"""
def decode(code) when is_binary(code), do: decode(String.to_charlist(code))
def decode(code) do
code =
code
|> upcase_list()
|> Enum.reject(&(&1 in [?0, ?+]))
|> Enum.take(@max_digit_count)
normal_lat = -@latitude_max * @pair_precision
normal_lng = -@longitude_max * @pair_precision
grid_lat = 0
grid_lng = 0
digits = min(length(code), @pair_code_length)
last_loop = div(digits, 2)
pv = @pair_first_place_value
{normal_lat, normal_lng, pv} =
code
|> Enum.take(digits)
|> Enum.chunk_every(2)
|> Enum.with_index(1)
|> Enum.reduce(
{normal_lat, normal_lng, pv},
fn {[lat_char, lng_char], idx}, {lat_acc, lng_acc, pv} ->
lat_value = get_alphabet_index(lat_char)
lng_value = get_alphabet_index(lng_char)
lat = lat_acc + lat_value * pv
lng = lng_acc + lng_value * pv
new_pv = if idx == last_loop, do: pv, else: div(pv, @encoding_base)
{lat, lng, new_pv}
end
)
lat_precision = pv / @pair_precision
lng_precision = pv / @pair_precision
{grid_lat, grid_lng, lat_precision, lng_precision} =
if length(code) > @pair_code_length do
{grid_lat, grid_lng, row_pv, col_pv} =
code
|> Enum.drop(@pair_code_length)
|> Enum.reverse()
|> Enum.with_index()
|> Enum.reverse()
|> Enum.reduce(
{grid_lat, grid_lng, @grid_lat_first_place_value, @grid_lng_first_place_value},
fn {char, i}, {grid_lat, grid_lng, row_pv, col_pv} ->
digit_val = get_alphabet_index(char)
row = div(digit_val, @grid_columns)
col = rem(digit_val, @grid_columns)
new_grid_lat = grid_lat + row * row_pv
new_grid_lng = grid_lng + col * col_pv
if i == 0 do
{new_grid_lat, new_grid_lng, row_pv, col_pv}
else
new_row_pv = div(row_pv, @grid_rows)
new_col_pv = div(col_pv, @grid_columns)
{new_grid_lat, new_grid_lng, new_row_pv, new_col_pv}
end
end
)
new_lat_precision = row_pv / @final_lat_precision
new_lng_precision = col_pv / @final_lng_precision
{grid_lat, grid_lng, new_lat_precision, new_lng_precision}
else
{0, 0, lat_precision, lng_precision}
end
lat_lo = normal_lat / @pair_precision + grid_lat / @final_lat_precision
lng_lo = normal_lng / @pair_precision + grid_lng / @final_lng_precision
code_length = min(length(code), @max_digit_count)
lat_lo = Float.round(lat_lo, 14)
lng_lo = Float.round(lng_lo, 14)
lat_hi = Float.round(lat_lo + lat_precision, 14)
lng_hi = Float.round(lng_lo + lng_precision, 14)
%__MODULE__{
latitude_lo: lat_lo,
longitude_lo: lng_lo,
latitude_hi: lat_hi,
longitude_hi: lng_hi,
latitude_center: (lat_lo + lat_hi) / 2,
longitude_center: (lng_lo + lng_hi) / 2,
code_length: code_length
}
end
@doc """
Remove characters from the start of an Open Location Code.
This uses a reference location to determine how many initial characters
can be removed from the OLC code. The number of characters that can be
removed depends on the distance between the code center and the reference
location.
## Args
* `code` - A full Open Location Code to shorten.
* `latitude` - The reference latitude in signed decimal degrees.
* `longitude` - The reference longitude in signed decimal degrees.
## Returns
A string containing the shortened Open Location Code.
## Rules
* The minimum number of characters that will be removed is four.
* If more than four characters can be removed, the additional characters
will be replaced with the padding character (0).
* At most eight characters will be removed.
* The reference location must be within 50% of the maximum range.
## Examples
iex> Pluscode.shorten("8FVC9G8F+6X", 47.5, 8.5)
"9G8F+6X"
"""
def shorten(code, latitude, longitude) when is_binary(code), do: shorten(String.to_charlist(code), latitude, longitude)
def shorten(code, latitude, longitude) do
with {_, true} <- {:full, full?(code)},
false <- @padding in code,
code_area = decode(code),
true <- code_area.code_length >= @min_trimmable_code_len do
# Ensure that latitude and longitude are valid
latitude = clip_latitude(latitude)
longitude = normalize_longitude(longitude)
# How close are the latitude and longitude to the code center
code_range =
max(
abs(code_area.latitude_center - latitude),
abs(code_area.longitude_center - longitude)
)
# Find appropriate shortening length by checking each resolution
# from largest to smallest
shortened =
@pair_resolutions
|> Enum.with_index()
|> Enum.reverse()
|> Enum.drop(1)
|> Enum.reduce_while(
code,
fn
{_resolution, 0}, code ->
{:halt, code}
{resolution, i}, code ->
if code_range < resolution * 0.3 do
# We can trim it - remove 2*(i+1) characters from the start
{:halt, Enum.drop(code, (i + 1) * 2)}
else
{:cont, code}
end
end
)
to_string(shortened)
else
{:full, false} ->
raise "Passed code is not valid and full: #{code}"
true ->
raise "Cannot shorten padded codes: #{code}"
false ->
raise "Code length must be at least #{@min_trimmable_code_len}"
end
end
@doc """
Recover the nearest matching code to a specified location.
Recover the nearest matching full code to a specified location.
Given a short code of between four and seven characters, this recovers
the nearest matching full code to the specified location.
## Args
* `code` - A valid short Open Location Code.
* `reference_latitude` - The reference latitude in signed decimal degrees.
* `reference_longitude` - The reference longitude in signed decimal degrees.
## Returns
A string containing the nearest matching full Open Location Code.
## Examples
iex> Pluscode.recover_nearest("9G8F+6X", 47.4, 8.6)
"8FVC9G8F+6X"
"""
def recover_nearest(code, reference_latitude, reference_longitude) when is_binary(code),
do: recover_nearest(String.to_charlist(code), reference_latitude, reference_longitude)
def recover_nearest(code, reference_latitude, reference_longitude) do
with_result =
with false <- full?(code),
true <- short?(code) do
# Clean up and normalize inputs
code = upcase_list(code)
reference_latitude = clip_latitude(reference_latitude)
reference_longitude = normalize_longitude(reference_longitude)
# Compute padding length and resolution
padding_length = @separator_position - Enum.find_index(code, &(&1 == @separator))
resolution = :math.pow(20, 2 - padding_length / 2)
half_resolution = resolution / 2.0
# Get the reference area
reference_prefix =
reference_latitude
|> encode(reference_longitude)
|> String.slice(0, padding_length)
code_area = decode(reference_prefix <> to_string(code))
# Adjust latitude if needed
code_area =
cond do
reference_latitude + half_resolution < code_area.latitude_center and
code_area.latitude_center - resolution >= -@latitude_max ->
# Move south
%{code_area | latitude_center: code_area.latitude_center - resolution}
reference_latitude - half_resolution > code_area.latitude_center and
code_area.latitude_center + resolution <= @latitude_max ->
# Move north
%{code_area | latitude_center: code_area.latitude_center + resolution}
true ->
code_area
end
# Adjust longitude if needed
code_area =
cond do
reference_longitude + half_resolution < code_area.longitude_center ->
%{code_area | longitude_center: code_area.longitude_center - resolution}
reference_longitude - half_resolution > code_area.longitude_center ->
%{code_area | longitude_center: code_area.longitude_center + resolution}
true ->
code_area
end
# Encode the adjusted position
encode(
code_area.latitude_center,
code_area.longitude_center,
code_area.code_length
)
else
true ->
# If it's already a full code, just return it uppercased
upcase_list(code)
false ->
raise "Passed short code is not valid - #{code}"
end
to_string(with_result)
end
@doc false
defp clip_latitude(latitude) do
min(90, max(-90, latitude))
end
@doc """
Compute the latitude precision value for a given code length.
## Args
* `code_length` - The length of the code to compute precision for.
## Returns
The precision value in degrees. For lengths <= 10, latitude and longitude
have the same precision. For lengths > 10, latitude has a different precision
due to the grid method having fewer columns than rows.
## Examples
iex> Pluscode.compute_latitude_precision(10)
0.000125
iex> Pluscode.compute_latitude_precision(11)
2.5e-5
"""
def compute_latitude_precision(code_length) do
if code_length <= 10 do
:math.pow(20, :math.floor(code_length / -2 + 2))
else
:math.pow(20, -3) / :math.pow(@grid_rows, code_length - 10)
end
end
@doc """
Normalize a longitude into the range -180 to 180, not including 180.
## Args
* `longitude` - A longitude in signed decimal degrees.
## Returns
The normalized longitude value.
## Examples
iex> Pluscode.normalize_longitude(185)
-175
iex> Pluscode.normalize_longitude(-185)
175
"""
def normalize_longitude(longitude) when longitude < -180, do: normalize_longitude(longitude + 360)
def normalize_longitude(longitude) when longitude >= 180, do: normalize_longitude(longitude - 360)
def normalize_longitude(longitude), do: longitude
@doc false
# Converts all lowercase ASCII characters in a charlist to uppercase.
# Non-ASCII characters and non-lowercase characters are left unchanged.
#
# ## Args
# * `code` - The charlist to convert.
#
# ## Returns
# The converted charlist with all ASCII lowercase characters converted to uppercase.
defp upcase_list(code), do: Enum.map(code, &if(&1 in ?a..?z, do: &1 - 32, else: &1))
end