__version__ = "0.4.0dev"
__all__ = ["add_tiles", "add_attribution", "create_tiles_chart", "providers"]
import math
from dataclasses import dataclass
from typing import Final, List, Optional, Union, cast
import altair as alt
import mercantile as mt
import xyzservices.providers as providers
from xyzservices import TileProvider
[docs]def add_tiles(
chart: alt.Chart,
provider: Union[str, TileProvider] = "OpenStreetMap.Mapnik",
zoom: Optional[int] = None,
attribution: Union[str, bool] = True,
) -> alt.LayerChart:
"""Adds tiles to a chart. The chart must have a geoshape mark and a Mercator
projection.
Parameters
----------
chart : alt.Chart
A chart with a Mercator projection.
provider : Union[str, TileProvider], optional
The provider of the tiles. You can access all available preconfigured providers
at `altair_tiles.providers` such as
`altair_tiles.providers.OpenStreetMap.Mapnik`.
For convenience, you can also pass the name as a string,
for example "OpenStreetMap.Mapnik" (this is the default).
You can pass a custom provider as a :class:`TileProvider` instance.
This functionality is provided by the `xyzservices` package.
zoom : Optional[int], optional
If None an appropriate zoom level will be calculated automatically,
by default None
attribution : Union[str, bool], optional
If True, the default attribution text for the provider, if available, is added
to the chart. You can also provide a custom text as a string or disable
the attribution text by setting this to False. By default True
Returns
-------
alt.LayerChart
Raises
------
TypeError
If chart is not an altair.Chart instance.
ValueError
If chart does not have a geoshape mark or a Mercator projection
or no projection.
"""
tiles = create_tiles_chart(
provider=provider,
zoom=zoom,
# Set attribution to False here as we want to add it in the end so it is
# on top of the geoshape layer.
attribution=False,
standalone=False,
)
final_chart = tiles + chart # type: ignore # noqa: PGH003
if attribution:
final_chart = add_attribution( # type: ignore[assignment]
chart=final_chart, provider=provider, attribution=attribution
)
return final_chart
[docs]def create_tiles_chart(
provider: Union[str, TileProvider] = "OpenStreetMap.Mapnik",
zoom: Optional[int] = None,
attribution: Union[str, bool] = True,
standalone: Union[bool, alt.Projection] = True,
) -> Union[alt.LayerChart, alt.Chart]:
"""Creates an Altair chart with tiles.
Parameters
----------
provider : Union[str, TileProvider], optional
_description_, by default "OpenStreetMap.Mapnik"
provider : Union[str, TileProvider], optional
The provider of the tiles. You can access all available preconfigured providers
at `altair_tiles.providers` such as
`altair_tiles.providers.OpenStreetMap.Mapnik`.
For convenience, you can also pass the name as a string,
for example "OpenStreetMap.Mapnik" (this is the default).
You can pass a custom provider as a :class:`TileProvider` instance.
This functionality is provided by the `xyzservices` package.
zoom : Optional[int], optional
If None an appropriate zoom level will be calculated automatically,
by default None
attribution : Union[str, bool], optional
If True, the default attribution text for the provider, if available, is added
to the chart. You can also provide a custom text as a string or disable
the attribution text by setting this to False. By default True
standalone : Union[bool, alt.Projection], optional
If True, the returned chart will have an additional layer with
a geoshape mark and a mercator projection set which allows the tiles
to properly show up and hence you can render the chart as-is.
If False, the chart will be returned in a form where it can be added
to an existing chart which must have a projection.
You could also add a standalone chart to an existing chart
but the resulting specification is slightly simpler if you choose standalone.
To customize the projection which is set in the standalone chart, you can
also pass an alt.Projection instance here which must have at least
type set to mercator, e.g. `alt.Projection(type="mercator")`. If you already
have a chart, you can pass the projection of the chart, e.g. `chart.projection`.
Defaults to True.
Returns
-------
Union[alt.LayerChart, alt.Chart]
Raises
------
TypeError
If zoom is not an integer or None.
"""
provider = _resolve_provider(provider)
if zoom is not None and not isinstance(zoom, int):
raise TypeError("Zoom must be an integer or None.")
# For the tiles to show up, we need to ensure that a Vega Projection is created
# which is used in the p_base_point parameter. This seems to only happen
# if we layer the tiles together with another geoshape chart which also
# has the projection attribute set.
tiles = _create_nonstandalone_tiles_chart(
provider=provider,
zoom=zoom,
attribution=attribution,
)
if standalone:
if standalone is True:
standalone = alt.Projection(type="mercator")
else:
# In this case it already is an instance of alt.Projection
_validate_projection(standalone)
base_layer = alt.Chart().mark_geoshape().properties(projection=standalone)
# If we use tiles as the first layer then the chart is 20px by 20px by default.
# Unclear why but the other way around works fine.
return base_layer + tiles
else:
return tiles
def _create_nonstandalone_tiles_chart(
provider: TileProvider,
zoom: Optional[int],
attribution: Union[str, bool],
) -> Union[alt.Chart, alt.LayerChart]:
# The calculations below are based on initial implementations in Vega
# https://github.com/vega/vega/issues/1212#issuecomment-384680678 and in Vega-Lite
# https://github.com/vega/vega-lite/issues/5758#issuecomment-1462683219.
p_pr_scale = alt.param(expr="geoScale('projection')", name="pr_scale")
evaluated_zoom_level_ceil: Optional[int]
if zoom is not None:
p_zoom_level = alt.param(value=zoom, name="zoom_level")
p_base_tile_size = alt.param(
expr=f"(2 * PI * {p_pr_scale.name}) / pow(2, {p_zoom_level.name})",
name="base_tile_size",
)
evaluated_zoom_level_ceil = math.ceil(zoom)
else:
# Calculate an appropriate zoom level based on the projection scale
# and the tile size.
default_base_tile_size = 256
p_base_tile_size = alt.param(
value=default_base_tile_size, name="base_tile_size"
)
p_zoom_level = alt.param(
expr=f"log((2 * PI * {p_pr_scale.name}) / {p_base_tile_size.name}) /"
+ " log(2)",
name="zoom_level",
)
# As we don't know the scale of the projection yet, we cannot evaluate
# the zoom level.
evaluated_zoom_level_ceil = None
if evaluated_zoom_level_ceil is not None:
_validate_zoom(evaluated_zoom_level_ceil, provider=provider)
p_zoom_ceil = alt.param(expr=f"ceil({p_zoom_level.name})", name="zoom_ceil")
# Number of tiles per column/row, whichever is larger. Total number of tiles
# would then be this number squared. However, this number does not account
# for tiles which will be outside of the view, i.e. what is visible on the chart.
# It is therefore just the maximum if the whole earth would be visible.
# If calculation of this is changed here, it should also be changed in the
# _calculate_one_side_grid_size function which calculates this number
# in Python.
p_max_one_side_tiles_count = alt.param(
expr=f"pow(2, {p_zoom_ceil.name})", name="max_one_side_tiles_count"
)
p_tile_size = alt.param(
expr=p_base_tile_size.name
+ f" * pow(2, {p_zoom_level.name} - {p_zoom_ceil.name})",
name="tile_size",
)
p_base_point = alt.param(expr="invert('projection', [0, 0])", name="base_point")
p_dii = alt.param(
expr=f"({p_base_point.name}[0] + 180) / 360"
+ f" * {p_max_one_side_tiles_count.name}",
name="dii",
)
p_dii_floor = alt.param(expr=f"floor({p_dii.name})", name="dii_floor")
p_dx = alt.param(
expr=f"({p_dii_floor.name} - {p_dii.name}) * {p_tile_size.name}", name="dx"
)
p_djj = alt.param(
expr=f"(1 - log(tan({p_base_point.name}[1] * PI / 180)"
+ f" + 1 / cos({p_base_point.name}[1] * PI / 180)) / PI)"
+ f" / 2 * {p_max_one_side_tiles_count.name}",
name="djj",
)
p_djj_floor = alt.param(expr=f"floor({p_djj.name})", name="djj_floor")
p_dy = alt.param(
expr=f"round(({p_djj_floor.name} - {p_djj.name}) * {p_tile_size.name})",
name="dy",
)
expr_url_x = (
f"((datum.a + {p_dii_floor.name} + {p_max_one_side_tiles_count.name})"
+ f" % {p_max_one_side_tiles_count.name})"
)
expr_url_y = f"(datum.b + {p_djj_floor.name})"
def build_url(provider: TileProvider, x: str, y: str, z: str) -> str:
def format_value(v: str) -> str:
return f"' + {v} + '"
return (
"'"
+ provider.build_url(
x=format_value(x), y=format_value(y), z=format_value(z)
)
+ "'"
)
one_side_grid_size = _calculate_one_side_grid_size(evaluated_zoom_level_ceil)
tile_list = alt.sequence(0, one_side_grid_size, as_="a", name="tile_list")
# Can be a layerchart after adding attribution
tiles = (
alt.Chart(tile_list)
.mark_image(
clip=True,
# For some settings, the tiles would show a fine gap between them. By adding
# 1px to the height and width, we can avoid this.
height=alt.expr(p_tile_size.name + " + 1"),
width=alt.expr(p_tile_size.name + " + 1"),
)
.encode(alt.Url("url:N"), alt.X("x:Q").scale(None), alt.Y("y:Q").scale(None))
.transform_calculate(b=f"sequence(0, {one_side_grid_size})")
.transform_flatten(["b"])
.transform_calculate(
url=build_url(provider, x=expr_url_x, y=expr_url_y, z=p_zoom_ceil.name),
x=f"datum.a * {p_tile_size.name} + {p_dx.name} + ({p_tile_size.name} / 2)",
y=f"datum.b * {p_tile_size.name} + {p_dy.name} + ({p_tile_size.name} / 2)",
)
)
# Remove tiles which would be outside of the chart. Some
# of these tiles might even be duplicated without this step as they
# would be placed again once we are 'around the world' but with x and y
# values which are far outside of the chart. This also greatly speeds up the
# rendering time of a chart and the time it takes to save it with
# e.g. vl-convert-python as even if the tiles would not be visible in the chart,
# they would still be downloaded.
# x and y below refer to the x and y coordinates on the chart, not the x and y
# in the tile urls.
tiles = tiles.transform_filter(
"datum.x < (width + tile_size / 2) && datum.y < (height + tile_size / 2)"
)
# Remove tile urls which are not valid for the given provider. Else, they would
# lead to errors when Vega tries to load and render them.
provider_bounds = getattr(provider, "bounds", None)
if provider_bounds is None:
# Provider does not provide bounds, so we assume that they cover the whole
# earth surface. We therefore only apply some simple heuristics to remove
# invalid URLs
# Min values: Only positive x and y values in URL as
# tiles never have negative values
# Max values: Only load as many tiles as we expect given p_one_side_tiles_count.
# This again helps with speed but also avoids invalid tile urls
# with x and y values which would be too large.
# We need to subtract 1 from the tiles count as the tile indices start at 0
tiles = _transform_filter_url_x_y_bounds(
chart=tiles,
x_min=0,
y_min=0,
x_max=f"({p_max_one_side_tiles_count.name} - 1)",
y_max=f"({p_max_one_side_tiles_count.name} - 1)",
expr_url_x=expr_url_x,
expr_url_y=expr_url_y,
)
else:
# Provider does provide bounds for which they provide tiles. This can happen
# e.g. for providers which focus on a specific region or country.
if evaluated_zoom_level_ceil is None:
raise ValueError(
"The provider only provides tiles for bounds. This currently only"
+ " works if you provide a fixed zoom level."
)
x_y_min_max = _bounds_to_x_y_min_max(
bounds=provider_bounds, zoom=evaluated_zoom_level_ceil
)
tiles = _transform_filter_url_x_y_bounds(
chart=tiles,
x_min=x_y_min_max.x_min,
y_min=x_y_min_max.y_min,
x_max=x_y_min_max.x_max,
y_max=x_y_min_max.y_max,
expr_url_x=expr_url_x,
expr_url_y=expr_url_y,
)
tiles = tiles.add_params(
p_base_tile_size,
p_pr_scale,
p_zoom_level,
p_zoom_ceil,
p_max_one_side_tiles_count,
p_tile_size,
p_base_point,
p_dii,
p_dii_floor,
p_dx,
p_djj,
p_djj_floor,
p_dy,
)
tiles_final: Union[alt.Chart, alt.LayerChart]
if attribution:
tiles_final = add_attribution(tiles, provider, attribution)
else:
tiles_final = tiles
return tiles_final
@dataclass
class _XYMinMax:
x_min: int
y_min: int
x_max: int
y_max: int
def _bounds_to_x_y_min_max(bounds: List[List[float]], zoom: int) -> _XYMinMax:
south_west, north_east = bounds
south, west = south_west
north, east = north_east
valid_tiles = list(
mt.tiles(west=west, south=south, east=east, north=north, zooms=[zoom])
)
x_min = min(tile.x for tile in valid_tiles)
x_max = max(tile.x for tile in valid_tiles)
y_min = min(tile.y for tile in valid_tiles)
y_max = max(tile.y for tile in valid_tiles)
return _XYMinMax(x_min=x_min, y_min=y_min, x_max=x_max, y_max=y_max)
def _transform_filter_url_x_y_bounds(
chart: alt.Chart,
x_min: Union[str, int],
x_max: Union[str, int],
y_min: Union[str, int],
y_max: Union[str, int],
expr_url_x: str,
expr_url_y: str,
) -> alt.Chart:
chart = chart.transform_filter(
# Lower bounds
expr_url_x
+ f" >= {x_min} && "
+ expr_url_y
+ f" >= {y_min}"
+ " && "
+ expr_url_x
+ " <= "
+ str(x_max)
+ " && "
+ expr_url_y
+ " <= "
+ str(y_max)
)
return chart
def _validate_zoom(zoom: int, provider: TileProvider) -> None:
# Follows very closely the implementation in contextily.tile._validate_zoom
# https://github.com/geopandas/contextily/blob/0c8c9ce6d99f29e5fd250ee505f52a9bad30642b/contextily/tile.py#LL538C3-L538C3 # noqa: E501
min_zoom = provider.get("min_zoom", 0)
if "max_zoom" in provider:
max_zoom = provider.get("max_zoom")
max_zoom_known = True
else:
# 22 is known max in existing providers, taking some margin
max_zoom = 30
max_zoom_known = False
if not (min_zoom <= zoom <= max_zoom):
msg = f"The zoom level of {zoom} is not valid for the current tile provider"
if max_zoom_known:
msg += f" (valid zooms: {min_zoom} - {max_zoom})."
else:
msg += "."
raise ValueError(msg)
def _calculate_one_side_grid_size(evaluated_zoom_level_ceil: Optional[int]) -> int:
# Size of tile grid needs to be calculated in Python as it is not possible
# yet to use an expression in a data generator such as a sequence.
# See https://github.com/vega/vega-lite/issues/7410
# The issue with this is that it depends on the size of the chart which
# we cannot yet know at this point as it might be changed by e.g. a theme
# or by the user themselves when working with the returned chart.
# Therefore, we try to make the grid as large as possible but also limit
# it to a reasonable size as at one point Vega will no longer be able to
# generate the sequence if its too large or it just will be very slow.
# Maximum value is arbitrary.
maximum_value: Final[int] = 20
one_side_grid_size: int
if evaluated_zoom_level_ceil is not None:
# This is the same formula as used in the Vega-Lite spec for the
# max_one_side_tiles_count parameter.
evaluated_max_one_side_tiles_count = 2**evaluated_zoom_level_ceil
# Adding 2 to the evaluated tiles is arbitrary to make sure
# that we have enough tiles. This might not be necessary.
one_side_grid_size = min(evaluated_max_one_side_tiles_count + 2, maximum_value)
else:
one_side_grid_size = maximum_value
return one_side_grid_size
[docs]def add_attribution(
chart: Union[alt.Chart, alt.LayerChart],
provider: Union[str, TileProvider] = "OpenStreetMap.Mapnik",
attribution: Union[bool, str] = True,
) -> Union[alt.Chart, alt.LayerChart]:
"""This function is useful if the attribution added by add_tiles or
create_tiles_chart would be partially hidden by another layer. In that case,
you can set `attribution=False` when creating the tiles chart
and then use this function to add the attribution in the end to the final chart.
See the documentation for examples
Parameters
----------
chart : Union[alt.Chart, alt.LayerChart]
A chart to which you want to have the attribution added.
provider : Union[str, TileProvider], optional
The provider of the tiles. You can access all available preconfigured providers
at `altair_tiles.providers` such as
`altair_tiles.providers.OpenStreetMap.Mapnik`.
For convenience, you can also pass the name as a string, for example
"OpenStreetMap.Mapnik" (this is the default).
You can pass a custom provider as a :class:`TileProvider` instance.
This functionality is provided by the `xyzservices` package.
attribution : Union[str, bool], optional
If True, the default attribution text for the provider, if available, is added
to the chart. You can also provide a custom text as a string or disable
the attribution text by setting this to False. By default True
Returns
-------
Union[alt.Chart, alt.LayerChart]
"""
provider = _resolve_provider(provider)
attribution_text: Optional[str]
if attribution:
attribution_text = (
attribution if isinstance(attribution, str) else provider.get("attribution")
)
else:
attribution_text = None
if attribution_text:
text_attrib = (
alt.Chart()
.mark_text(text=attribution_text, dy=-8, dx=3, align="left")
.encode(x=alt.value(0), y=alt.value(alt.expr("height")))
)
chart = chart + text_attrib
return chart
def _resolve_provider(provider: Union[str, TileProvider]) -> TileProvider:
if isinstance(provider, str):
provider = cast(TileProvider, providers.query_name(provider))
return provider
def _validate_projection(projection: alt.Projection) -> None:
if not isinstance(projection, alt.Projection):
raise TypeError("Projection must be an alt.Projection instance.")
if projection.type != "mercator":
raise ValueError("Projection must be of type 'mercator'.")