defmodule MapLibre do
@moduledoc """
Elixir bindings to [MapLibre Style
Specification](https://maplibre.org/maplibre-gl-js-docs/style-spec/).
A MapLibre style is a document that defines the visual appearance of a map: what data to draw,
the order to draw it in, and how to style the data when drawing it. A style document is a JSON
object with specific root level and nested properties. To learn more about the style
specification and its properties, please see the
[documentation](https://maplibre.org/maplibre-gl-js-docs/style-spec/)
## Composing maps
Laying out a basic MapLibre map consists of the following steps:
alias MapLibre, as: Ml
# Initialize the specification with the initial style and optionally some other root properties.
# If you don't provide a initial style, the default style will be loaded for you
Ml.new(center: {-74.5, 40}, zoom: 6)
# Add sources to make their data available
|> Ml.add_source("rwanda-provinces",
type: :geojson,
data: "https://maplibre.org/maplibre-gl-js-docs/assets/rwanda-provinces.geojson"
)
# Add layers and refer them to sources to define their visual representation and make them visible
|> Ml.add_layer(id: "rwanda-provinces",
type: :fill,
source: "rwanda-provinces",
paint: [fill_color: "#4A9661"]
)
## Expressions
Expressions are extremely powerful and useful to render complex data. To use them just ensure
that you pass valid expressions following the rules and syntax of the [official
documentation](https://maplibre.org/maplibre-gl-js-docs/style-spec/expressions/)
## Options
To provide a more Elixir-friendly experience, the options are automatically normalized, so you
can use keyword lists and snake-case atom keys.
"""
alias MapLibre.Utils
alias MapLibre.Styles
@to_kebab Utils.kebab_case_properties()
@geometries [Geo.Point, Geo.LineString, Geo.Polygon, Geo.GeometryCollection]
@query_base "https://nominatim.openstreetmap.org/search?format=geojson&limit=1&polygon_geojson=1"
defstruct spec: %{}
@type t() :: %__MODULE__{spec: spec()}
@type spec :: map()
@type coordinates_format :: :lng_lat | :lat_lng
@type coordinates_combined :: {coordinates_format(), column :: String.t()}
@type coordinates_columns :: {coordinates_format(), columns :: nonempty_list(String.t())}
@type coordinates_spec :: coordinates_combined() | coordinates_columns()
@doc """
Returns a style specification wrapped in the `MapLibre` struct. If you don't provide a initial
style, the [default style](https://demotiles.maplibre.org/style.json) will be loaded for you. If
you wish to build a new style completely from scratch, pass an empty map `%{}` as `:style`
option. The style specification version will be automatically set to 8.
## Options
Only the following properties are allowed directly on `new/1`
* `:bearing` - Default bearing, in degrees. The bearing is the compass direction that is
"up"; for example, a bearing of 90° orients the map so that east is up. This value will be
used only if the map has not been positioned by other means (e.g. map options or user
interaction). Default: 0
* `:center` - Default map center in longitude and latitude. The style center will be used only
if the map has not been positioned by other means (e.g. map options or user interaction).
Default: {0, 0}
* `:name` - A human-readable name for the style.
* `:pitch` - Default pitch, in degrees. Zero is perpendicular to the surface, for a look
straight down at the map, while a greater value like 60 looks ahead towards the horizon. The
style pitch will be used only if the map has not been positioned by other means (e.g. map
options or user interaction). Default: 0
* `:zoom` - Default zoom level. The style zoom will be used only if the map has not been
positioned by other means (e.g. map options or user interaction).
* `:style` - The initial style specification. Three built-in initial styles are available:
`:default`, `:street` and `:terrain`.
To manipulate any other [style root
properties](https://maplibre.org/maplibre-gl-js-docs/style-spec/root/), use the
corresponding functions
## Examples
Ml.new(
center: {-74.5, 40},
zoom: 9,
name: "Rwanda population density"
)
|> ...
See [the docs](https://maplibre.org/maplibre-gl-js-docs/style-spec/) for more details.
"""
@spec new(keyword()) :: t()
def new(opts \\ []) do
validade_new_opts!(opts)
style = opts |> Keyword.get(:style, :default) |> to_style()
ml = %MapLibre{spec: style}
ml_props = opts |> Keyword.delete(:style) |> opts_to_ml_props()
update_in(ml.spec, fn spec -> Map.merge(spec, ml_props) end)
end
defp validade_new_opts!(opts) do
new_options = [:bearing, :center, :name, :pitch, :zoom, :style]
options = new_options |> Enum.map_join(", ", &inspect/1)
for {option, _value} <- opts do
if option not in new_options do
raise ArgumentError,
"unknown option, expected one of #{options}, got: #{inspect(option)}"
end
end
end
@doc """
Returns the underlying MapLibre specification. The result is a nested Elixir data structure that
serializes to MapLibre style JSON specification.
See [the docs](https://maplibre.org/maplibre-gl-js-docs/style-spec/) for more details.
"""
@spec to_spec(t()) :: spec()
def to_spec(ml) do
ml.spec
end
@doc """
Adds a data source to the sources in the specification.
Sources state which data the map should display. Specify the type of source with the `:type`
property, which must be one of `:vector`, `:raster`, `:raster_dem`, `:geojson`, `:image` or `:video`.
## Examples
Ml.new()
|> Ml.add_source("rwanda-provinces",
type: :geojson,
data: "https://maplibre.org/maplibre-gl-js-docs/assets/rwanda-provinces.geojson"
)
See [the docs](https://maplibre.org/maplibre-gl-js-docs/style-spec/sources/) for more details.
"""
@spec add_source(t(), String.t(), keyword()) :: t()
def add_source(ml, source, opts) do
validate_source!(opts)
source = %{source => opts_to_ml_props(opts)}
put_source(ml, source)
end
@doc """
Adds a GEO data to the sources in the specification.
For the `:geojson` type, provides integration with the [Geo](https://hexdocs.pm/geo/readme.html)
package.
geom = %Geo.LineString{coordinates: [
{-122.48369693756104, 37.83381888486939},
{-122.48348236083984, 37.83317489144141},
{-122.48339653015138, 37.83270036637107}
]}
Ml.new()
|> Ml.add_geo_source("route", geom)
"""
@spec add_geo_source(t(), String.t(), struct(), keyword()) :: t()
def add_geo_source(ml, source, %module{} = geom, opts \\ []) when module in @geometries do
data = Geo.JSON.encode!(geom, feature: true)
source_props = opts_to_ml_props(opts) |> Map.merge(%{"type" => "geojson", "data" => data})
source = %{source => source_props}
put_source(ml, source)
end
@doc """
Adds points from tabular data to the sources in the specification.
For the `:geojson` type, provides integration with tabular data that implements the
[Table](https://hexdocs.pm/table/Table.html) protocol.
Supports data where the coordinates information is either in distinct columns or combined in a
single one. In both cases you need to provide the pattern followed by the coordinates data:
`:lng_lat` or `:lat_lng`
Properties are also supported as a list of columns in the options.
earthquakes = %{
"latitude" => [32.3646, 32.3357, -9.0665, 52.0779, -57.7326],
"longitude" => [101.8781, 101.8413, -71.2103, 178.2851, 148.6945],
"mag" => [5.9, 5.6, 6.5, 6.3, 6.4]
}
Ml.new()
|> Ml.add_table_source("earthquakes", earthquakes, {:lng_lat, ["longitude", "latitude"]})
earthquakes = %{
"coordinates" => ["32.3646, 101.8781", "32.3357, 101.8413", "-9.0665, -71.2103"],
"mag" => [5.9, 5.6, 6.5]
}
Ml.new()
|> Ml.add_table_source("earthquakes", earthquakes, {:lat_lng, "coordinates"}, properties: ["mag"])
"""
@spec add_table_source(t(), String.t(), term(), coordinates_spec(), keyword()) :: t()
def add_table_source(ml, source, data, coordinates, opts \\ []) do
validate_coordinates!(coordinates)
properties = Keyword.get(opts, :properties, [])
data = geometry_from_table(data, coordinates, properties)
source_props =
opts
|> Keyword.delete(:properties)
|> opts_to_ml_props()
|> Map.merge(%{"type" => "geojson", "data" => data})
source = %{source => source_props}
put_source(ml, source)
end
@doc """
Adds a data source by a given geocode to the sources in the specification.
For the `:geojson` type, provides integration with [Nominatim](https://nominatim.org).
Any valid geocode is support in a free-form query string.
Ml.new()
|> Ml.add_geocode_source("brazil", "brazil")
|> Ml.add_geocode_source("pilkington-avenue", "pilkington avenue, birmingham")
"""
@spec add_geocode_source(t(), String.t(), String.t()) :: t()
def add_geocode_source(ml, source, query) do
query = String.replace(query, " ", "+")
data = "#{@query_base}&q=#{query}"
source = %{source => %{"type" => "geojson", "data" => data}}
put_source(ml, source)
end
@doc """
Same as `add_geocode_source/3` but for structured queries.
The last argument is an atom for the geocode type.
Supported types: `:street`, `:city`, `:county`, `:state`, `:country` and `:postalcode`
Ml.new()
|> Ml.add_geocode_source("new-york", "new york", :city)
|> Ml.add_geocode_source("ny", "new york", :state)
"""
@spec add_geocode_source(t(), String.t(), String.t(), atom()) :: t()
def add_geocode_source(ml, source, query, type) do
validate_geocode_type!(type)
data = "#{@query_base}&#{type}=#{query}"
source = %{source => %{"type" => "geojson", "data" => data}}
put_source(ml, source)
end
defp validate_source!(opts) do
type = opts[:type]
validate_source_type!(type)
if type == :geojson, do: validate_geojson!(opts)
end
defp validate_source_type!(nil) do
raise ArgumentError,
"source type is required"
end
defp validate_source_type!(type) do
source_types = [:vector, :raster, :raster_dem, :geojson, :image, :video]
if type not in source_types do
types = source_types |> Enum.map_join(", ", &inspect/1)
raise ArgumentError,
"unknown source type, expected one of #{types}, got: #{inspect(type)}"
end
end
defp validate_geojson!(opts) do
data = opts[:data]
if is_nil(data) || data == [] do
raise ArgumentError,
~s(The GeoJSON data must be given using the "data" property, whose value can be a URL or inline GeoJSON.)
end
end
defp validate_coordinates!(coordinates) do
case coordinates do
{format, column} when format in [:lng_lat, :lat_lng] and is_binary(column) ->
nil
{format, [lng, lat]}
when format in [:lng_lat, :lat_lng] and is_binary(lng) and is_binary(lat) ->
nil
_ ->
raise(
ArgumentError,
"unsupported coordinates format. Expects a tuple of two elements, the first being the format (:lng_lat or :lat_lng) and the second being the column or the list of the two columns containing the coordinates"
)
end
end
defp validate_geocode_type!(type) do
geocode_types = [:street, :city, :county, :state, :country, :postalcode]
if type not in geocode_types do
types = geocode_types |> Enum.map_join(", ", &inspect/1)
raise ArgumentError,
"unknown geocode type, expected one of #{types}, got: #{inspect(type)}"
end
end
@doc """
Adds a layer to the layers list in the specification.
A style's layers property lists all the layers available in the style. The type of layer is
specified by the `:type` property, and must be one of `:background`, `:fill`, `:line`,
`:symbol`, `:raster`, `:circle`, `:fill_extrusion`, `:heatmap`, `:hillshade`.
Except for layers of the `:background` type, each layer needs to refer to a source. Layers take
the data that they get from a source, optionally filter features, and then define how those
features are styled.
## Required
* `:id` - Unique layer name.
* `:type` - One of:
* `:fill` - A filled polygon with an optional stroked border.
* `:line` - A stroked line.
* `:symbol` - An icon or a text label.
* `:circle` - A filled circle.
* `:heatmap` - A heatmap.
* `:fill_extrusion` - An extruded (3D) polygon.
* `:raster` - Raster map textures such as satellite imagery.
* `:hillshade` - Client-side hillshading visualization based on DEM data.
* `:background` - The background color or pattern of the map.
* `:source` - Name of a source description to be used for the layer. Required for all layer
types except `:background`.
## Options
* `:filter` - A expression specifying conditions on source features. Only features that match
the filter are displayed.
* `:layout` - Layout properties for the layer.
* `:maxzoom` - Optional number between 0 and 24 inclusive. The maximum zoom level for the
layer. At zoom levels equal to or greater than the `:maxzoom`, the layer will be hidden
* `:metadata` - Arbitrary properties useful to track with the layer, but do not influence
rendering. Properties should be prefixed to avoid collisions
* `:minzoom` - Optional number between 0 and 24 inclusive. The minimum zoom level for the
layer. At zoom levels less than the `:minzoom`, the layer will be hidden.
* `:paint` - Default paint properties for this layer.
## Type specific
* `:source_layer` - Layer to use from a vector tile source. Required for vector tile sources;
prohibited for all other source types, including GeoJSON sources.
## Examples
|> Ml.add_layer(id: "rwanda-provinces",
type: :fill,
source: "rwanda-provinces",
paint: [fill_color: "#4A9661"]
)
See [the docs](https://maplibre.org/maplibre-gl-js-docs/style-spec/layers/) for more details.
"""
@spec add_layer(t(), keyword()) :: t()
def add_layer(ml, opts) do
validade_layer!(ml, opts)
layer = opts_to_ml_props(opts)
layers = if ml.spec["layers"], do: List.insert_at(ml.spec["layers"], -1, layer), else: [layer]
update_in(ml.spec, fn spec -> Map.put(spec, "layers", layers) end)
end
@doc """
Same as `add_layer/2` but puts the given layer immediately below the labels
"""
@spec add_layer_below_labels(t(), keyword()) :: t()
def add_layer_below_labels(%_{spec: %{"layers" => layers}} = ml, opts) do
validade_layer!(ml, opts)
labels = Enum.find_index(layers, &(&1["type"] == "symbol"))
layer = opts_to_ml_props(opts)
updated_layers = List.insert_at(layers, labels, layer)
update_in(ml.spec, fn spec -> Map.put(spec, "layers", updated_layers) end)
end
@doc """
Updates a layer that was already defined in the specification
"""
@spec update_layer(t(), String.t(), keyword()) :: t()
def update_layer(%_{spec: %{"layers" => layers}} = ml, id, opts) do
updated_fields = opts_to_ml_props(opts)
index = Enum.find_index(layers, &(&1["id"] == id))
validate_layer_update!(index, id, layers, ml, opts)
updated_layer = layers |> Enum.at(index) |> Map.merge(updated_fields)
updated_layers = List.replace_at(layers, index, updated_layer)
update_in(ml.spec, fn spec -> Map.put(spec, "layers", updated_layers) end)
end
defp validade_layer!(ml, opts) do
id = opts[:id]
type = opts[:type]
source = opts[:source]
validate_layer_id!(ml, id)
validate_layer_type!(type)
if type != :background, do: validate_layer_source!(ml, source)
end
defp validate_layer_update!(index, id, layers, ml, opts) do
if index == nil do
layers = Enum.map_join(layers, ", ", &inspect(&1["id"]))
raise ArgumentError,
"layer #{inspect(id)} was not found. Current available layers are: #{layers}"
end
type = opts[:type]
source = opts[:source]
if type, do: validate_layer_type!(type)
if source, do: validate_layer_source!(ml, source)
end
defp validate_layer_id!(_ml, nil) do
raise ArgumentError,
"layer id is required"
end
defp validate_layer_id!(ml, id) do
if ml.spec["layers"] && Enum.find(ml.spec["layers"], &(&1["id"] == id)) do
raise ArgumentError,
"The #{inspect(id)} layer already exists on the map. If you want to update a layer, use the #{inspect("update_layer/3")} function instead"
end
end
defp validate_layer_type!(nil) do
raise ArgumentError,
"layer type is required"
end
defp validate_layer_type!(type) do
layer_types = [
:background,
:fill,
:line,
:symbol,
:raster,
:circle,
:fill_extrusion,
:heatmap,
:hillshade
]
if type not in layer_types do
types = layer_types |> Enum.map_join(", ", &inspect/1)
raise ArgumentError,
"unknown layer type, expected one of #{types}, got: #{inspect(type)}"
end
end
defp validate_layer_source!(_ml, nil) do
raise ArgumentError,
"layer source is required"
end
defp validate_layer_source!(ml, source) do
if not Map.has_key?(ml.spec["sources"], source) do
sources = Map.keys(ml.spec["sources"]) |> Enum.map_join(", ", &inspect/1)
raise ArgumentError,
"source #{inspect(source)} was not found. The source must be present in the style before it can be associated with a layer. Current available sources are: #{sources}"
end
end
@doc """
Sets the light options in the specification.
A style's light property provides a global light source for that style. Since this property is
the light used to light extruded features, you will only see visible changes to your map style
when modifying this property if you are using extrusions.
## Options
* `:anchor` - Whether extruded geometries are lit relative to the map or viewport. "map": The
position of the light source is aligned to the rotation of the map. "viewport": The position
of the light source is aligned to the rotation of the viewport. Default: "viewport"
* `:color` - Color tint for lighting extruded geometries. Default: "#ffffff"
* `:intensity` - Intensity of lighting (on a scale from 0 to 1). Higher numbers will present
as more extreme contrast. Default: 0.5
* `:position` - Position of the light source relative to lit (extruded) geometries, in {r
radial coordinate, a azimuthal angle, p polar angle} where r indicates the distance from the
center of the base of an object to its light, a indicates the position of the light relative
to 0° (0° when light.anchor is set to viewport corresponds to the top of the viewport, or 0°
when light.anchor is set to map corresponds to due north, and degrees proceed clockwise),
and p indicates the height of the light (from 0°, directly above, to 180°, directly below).
Default: {1.15, 210, 30}
See [the docs](https://maplibre.org/maplibre-gl-js-docs/style-spec/light/) for more details.
"""
@spec light(t(), keyword()) :: t()
def light(ml, opts) do
light = opts_to_ml_props(opts)
update_in(ml.spec, fn spec -> Map.put(spec, "light", light) end)
end
@doc """
Sets the sprite url in the specification.
A style's sprite property supplies a URL template for loading small images to use in rendering
`:background_pattern`, `:fill_pattern`, `:line_pattern`,`:fill_extrusion_pattern` and `:icon_image` style
properties.
See [the docs](https://maplibre.org/maplibre-gl-js-docs/style-spec/sprite/) for more details.
"""
@spec sprite(t(), String.t()) :: t()
def sprite(ml, sprite) when is_binary(sprite) do
update_in(ml.spec, fn spec -> Map.put(spec, "sprite", sprite) end)
end
@doc """
Sets the glyphs url in the specification.
A style's glyphs property provides a URL template for loading signed-distance-field glyph sets
in PBF format.
See [the docs](https://maplibre.org/maplibre-gl-js-docs/style-spec/glyphs/) for more details.
"""
@spec glyphs(t(), String.t()) :: t()
def glyphs(ml, glyphs) when is_binary(glyphs) do
update_in(ml.spec, fn spec -> Map.put(spec, "glyphs", glyphs) end)
end
@doc """
Defines a global default transition settings in the specification.
A transition property controls timing for the interpolation between a transitionable style
property's previous value and new value. A style's root transition property provides global
transition defaults for that style.
See [the docs](https://maplibre.org/maplibre-gl-js-docs/style-spec/transition/) for more
details.
"""
@spec transition(t(), keyword()) :: t()
def transition(ml, opts) do
transition = opts_to_ml_props(opts)
update_in(ml.spec, fn spec -> Map.put(spec, "transition", transition) end)
end
@doc """
Adds or updates the map metadata properties. Metadata are arbitrary properties useful to track
with the style, but do not influence rendering. Properties should be prefixed to avoid
collisions, like "mapbox:".
"""
@spec metadata(t(), String.t(), String.t()) :: t()
def metadata(ml, key, value) do
metadata = %{key => value}
current_metadata = if ml.spec["metadata"], do: ml.spec["metadata"], else: %{}
updated_metadata = Map.merge(current_metadata, metadata)
update_in(ml.spec, fn spec -> Map.put(spec, "metadata", updated_metadata) end)
end
# Helpers
defp opts_to_ml_props(opts) do
opts |> Map.new() |> to_ml()
end
defp to_ml(value) when value in [true, false, nil], do: value
defp to_ml(atom) when is_atom(atom), do: to_ml_key(atom)
defp to_ml(map) when is_map(map) do
Map.new(map, fn {key, value} ->
{to_ml(key), to_ml(value)}
end)
end
defp to_ml([{key, _} | _] = keyword) when is_atom(key) do
Map.new(keyword, fn {key, value} ->
{to_ml(key), to_ml(value)}
end)
end
defp to_ml(list) when is_list(list) do
Enum.map(list, &to_ml/1)
end
defp to_ml(tuple) when is_tuple(tuple) do
tuple |> Tuple.to_list() |> Enum.map(&to_ml/1)
end
defp to_ml(value), do: value
defp to_ml_key(key) when is_atom(key) and key in @to_kebab do
key |> to_string() |> snake_to_kebab()
end
defp to_ml_key(key) when is_atom(key) do
key |> to_string() |> snake_to_camel()
end
defp snake_to_kebab(string) do
String.replace(string, "_", "-")
end
defp snake_to_camel(string) do
[part | parts] = String.split(string, "_")
Enum.join([String.downcase(part, :ascii) | Enum.map(parts, &String.capitalize(&1, :ascii))])
end
@compile {:no_warn_undefined, {Req, :get!, 2}}
defp to_style(%{}), do: %{"version" => 8}
defp to_style(style) when is_atom(style), do: Styles.style(style)
defp to_style(style) when is_map(style), do: style
defp to_style("http" <> _rest = style) do
Utils.assert_req!()
Req.get!(style, http_errors: :raise).body
end
defp geometry_from_table(data, spec, []) do
geometries =
data
|> points(spec)
|> Enum.map(&%Geo.Point{coordinates: &1})
Geo.JSON.encode!(%Geo.GeometryCollection{geometries: geometries}, feature: true)
end
defp geometry_from_table(data, spec, properties) do
points = points(data, spec)
properties = properties(data, properties)
geometries = Enum.zip(points, properties)
geometries =
for {point, props} <- geometries, do: %Geo.Point{coordinates: point, properties: props}
Geo.JSON.encode!(%Geo.GeometryCollection{geometries: geometries}, feature: true)
end
def points(data, {format, [lng, lat]}) do
validate_columns!(data, [lng, lat])
table = Table.to_columns(data, only: [lng, lat])
Enum.zip_with(table[lng], table[lat], &parse_coordinates({&1, &2}, format))
end
def points(data, {format, coordinates}) do
validate_columns!(data, [coordinates])
data
|> Table.to_columns(only: [coordinates])
|> Map.get(coordinates)
|> Enum.map(&parse_coordinates(&1, format))
end
defp properties(data, properties) do
validate_columns!(data, properties)
data
|> Table.to_rows(only: properties)
|> Enum.to_list()
end
defp parse_coordinates({lng, lat}, :lng_lat), do: {lng, lat}
defp parse_coordinates({lng, lat}, :lat_lng), do: {lat, lng}
defp parse_coordinates(coordinates, format) when is_binary(coordinates) do
Regex.named_captures(~r/(?<lng>-?\d+\.?\d*)\s*[,;\s]\s*(?<lat>-?\d+\.?\d*)/, coordinates)
|> case do
%{"lat" => lat, "lng" => lng} ->
if format == :lng_lat, do: {lng, lat}, else: {lat, lng}
_ ->
raise ArgumentError,
"unsupported coordinates data, expected it two contain two numbers separated by comma (,), colon (;) or space"
end
end
defp parse_coordinates(_, _) do
raise ArgumentError,
"unsupported coordinates data, expected it two contain two numbers separated by comma (,), colon (;) or space"
end
defp validate_columns!(data, columns) do
data_columns = columns_for(data)
missing_column = Enum.find(columns, &(&1 not in data_columns))
if missing_column,
do: raise(ArgumentError, "column #{inspect(missing_column)} was not found")
end
defp columns_for(data) do
with {_, %{columns: columns}, _} <- Table.Reader.init(data) do
for column <- columns do
if is_atom(column), do: Atom.to_string(column), else: column
end
else
_ -> nil
end
end
defp put_source(ml, source) do
sources = if ml.spec["sources"], do: Map.merge(ml.spec["sources"], source), else: source
update_in(ml.spec, fn spec -> Map.put(spec, "sources", sources) end)
end
end