import dataclasses
import warnings
from dataclasses import dataclass
import geopandas as gpd
import ipyleaflet
import solara
import xyzservices
from folium.utilities import image_to_url
from mesa.visualization.utils import update_counter
from shapely.geometry import Point, mapping
from mesa_geo.raster_layers import RasterBase, RasterLayer
from mesa_geo.tile_layers import LeafletOption, RasterWebTile
def make_geospace_leaflet(
agent_portrayal,
view=None,
tiles=xyzservices.providers.OpenStreetMap.Mapnik,
**kwargs,
):
warnings.warn(
"make_geospace_leaflet is deprecated, use make_geospace_component instead",
DeprecationWarning,
stacklevel=2,
)
return make_geospace_component(agent_portrayal, view, tiles, **kwargs)
[docs]
def make_geospace_component(
agent_portrayal,
view=None,
tiles=xyzservices.providers.OpenStreetMap.Mapnik,
**kwargs,
):
"""
Create a Solara component that displays a Leaflet map for a model's GeoSpace.
This function returns a factory callable that can be supplied to Mesa's
`SolaraViz` to embed an interactive Leaflet map showing the model's
:class:`~mesa_geo.geospace.GeoSpace`. The map is rendered using ipyleaflet
and will draw raster layers, vector layers, and agents with their portrayals,
using a user-provided `agent_portrayal` function.
For a raster Cell, the portrayal method should return a (r, g, b, a) tuple.
For a GeoAgent, the portrayal method should return a dictionary.
- For a Line or a Polygon, the available options can be found at: https://leafletjs.com/reference.html#path-option
- For a Point, the available options can be found at: https://leafletjs.com/reference.html#circlemarker-option
- In addition, the portrayal dictionary can contain a "description" key, which will be used as the popup text.
:param agent_portrayal: A method that takes a GeoAgent (or a Cell) and returns
a dictionary of options (or a (r, g, b, a) tuple) for Leaflet.js.
:param view: Initial map center as ``(latitude, longitude)``. If not provided,
the map is centered from ``model.space.total_bounds``.
:param tiles: An optional tile layer to use. Can be a :class:`RasterWebTile` or
a :class:`xyzservices.TileProvider`. Default is `xyzservices.providers.OpenStreetMap.Mapnik`.
If the tile provider requires registration, you can pass the API key inside
the `options` parameter of the :class:`RasterWebTile` constructor.
For example, to use the `Mapbox` raster tile provider, you can use:
.. code-block:: python
import mesa_geo as mg
mg.RasterWebTile(
url="https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}.png?access_token={access_token}",
options={
"access_token": "my-private-ACCESS_TOKEN",
"attribution": '© <a href="https://www.mapbox.com/about/maps/" target="_blank">Mapbox</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors <a href="https://www.mapbox.com/map-feedback/" target="_blank">Improve this map</a>',
},
)
Note that `access_token` can have different names depending on the provider,
e.g., `api_key` or `key`. You can check the documentation of the provider
for more details.
`xyzservices` provides a list of providers requiring registration as well:
https://xyzservices.readthedocs.io/en/stable/registration.html
For example, you may use the following code to use the `Mapbox` provider:
.. code-block:: python
import xyzservices.providers as xyz
xyz.MapBox(id="<insert map_ID here>", accessToken="my-private-ACCESS_TOKEN")
:param **kwargs: Extra keyword arguments forwarded to :class:`ipyleaflet.Map`
(e.g., ``zoom=``, ``scroll_wheel_zoom=``). The available options can be found
at: https://ipyleaflet.readthedocs.io/en/latest/api_reference/index.html#ipyleaflet.leaflet.Map
:return: A factory callable to be passed as a SolaraViz component.
:rtype: Callable[[mesa.Model], solara.Element]
.. warning::
When using this component with :class:`~mesa.visualization.SolaraViz`,
pass the list of components via the ``components=`` keyword argument
(not as a positional argument). See the SolaraViz docs:
https://mesa.readthedocs.io/latest/apis/visualization.html
.. rubric:: Example
Define a custom portrayal for agents and add a map component to SolaraViz:
.. code-block:: python
import mesa_geo as mg
from mesa.visualization import SolaraViz, make_plot_component
from mesa_geo.visualization import make_geospace_component
def agent_portrayal(agent):
# Return Leaflet style options or RGBA tuple
if isinstance(agent, mg.GeoAgent):
return {"radius": 4, "color": "blue"}
elif isinstance(agent, mg.Cell):
return (255, 0, 0, 1) # Red color for raster cells
page = SolaraViz(
model,
name="Geo Model",
model_params=model_params,
components=[
make_geospace_component(agent_portrayal),
make_plot_component(["happy", "unhappy"]),
],
)
"""
def MakeSpaceMatplotlib(model):
return GeoSpaceLeaflet(model, agent_portrayal, view, tiles, **kwargs)
return MakeSpaceMatplotlib
@solara.component
def GeoSpaceLeaflet(model, agent_portrayal, view, tiles, **kwargs):
update_counter.get()
map_drawer = MapModule(portrayal_method=agent_portrayal, tiles=tiles)
model_view = map_drawer.render(model)
if view is None:
# longlat [min_x, min_y, max_x, max_y] to latlong [min_y, min_x, max_y, max_x]
transformed_xx, transformed_yy = model.space.transformer.transform(
xx=[model.space.total_bounds[0], model.space.total_bounds[2]],
yy=[model.space.total_bounds[1], model.space.total_bounds[3]],
)
view = [
(transformed_yy[0] + transformed_yy[1]) / 2,
(transformed_xx[0] + transformed_xx[1]) / 2,
]
layers = (
[ipyleaflet.TileLayer.element(url=map_drawer.tiles["url"])] if tiles else []
)
for layer in model_view["layers"]["rasters"]:
layers.append(
ipyleaflet.ImageOverlay(
url=layer["url"],
bounds=layer["bounds"],
)
)
for layer in model_view["layers"]["vectors"]:
layers.append(ipyleaflet.GeoJSON(element=layer))
ipyleaflet.Map.element(
center=view,
layers=[
*layers,
ipyleaflet.GeoJSON.element(data=model_view["agents"][0]),
*model_view["agents"][1],
],
**kwargs,
)
@dataclass
class LeafletViz:
"""A dataclass defining the portrayal of a GeoAgent in Leaflet map.
The fields are defined to be consistent with GeoJSON options in
Leaflet.js: https://leafletjs.com/reference.html#geojson
"""
style: dict[str, LeafletOption] | None = None
popupProperties: dict[str, LeafletOption] | None = None # noqa: N815
[docs]
class MapModule:
"""A MapModule for Leaflet maps that uses a user-defined portrayal method
to generate a portrayal of a raster Cell or a GeoAgent.
For a raster Cell, the portrayal method should return a (r, g, b, a) tuple.
For a GeoAgent, the portrayal method should return a dictionary.
- For a Line or a Polygon, the available options can be found at: https://leafletjs.com/reference.html#path-option
- For a Point, the available options can be found at: https://leafletjs.com/reference.html#circlemarker-option
- In addition, the portrayal dictionary can contain a "description" key, which will be used as the popup text.
"""
def __init__(
self,
portrayal_method,
tiles,
):
"""
Create a new MapModule.
:param portrayal_method: A method that takes a GeoAgent (or a Cell) and returns
a dictionary of options (or a (r, g, b, a) tuple) for Leaflet.js.
:param tiles: An optional tile layer to use. Can be a :class:`RasterWebTile` or
a :class:`xyzservices.TileProvider`. Default is `xyzservices.providers.OpenStreetMap.Mapnik`.
If the tile provider requires registration, you can pass the API key inside
the `options` parameter of the :class:`RasterWebTile` constructor.
For example, to use the `Mapbox` raster tile provider, you can use:
.. code-block:: python
import mesa_geo as mg
mg.RasterWebTile(
url="https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}.png?access_token={access_token}",
options={
"access_token": "my-private-ACCESS_TOKEN",
"attribution": '© <a href="https://www.mapbox.com/about/maps/" target="_blank">Mapbox</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors <a href="https://www.mapbox.com/map-feedback/" target="_blank">Improve this map</a>',
},
)
Note that `access_token` can have different names depending on the provider,
e.g., `api_key` or `key`. You can check the documentation of the provider
for more details.
`xyzservices` provides a list of providers requiring registration as well:
https://xyzservices.readthedocs.io/en/stable/registration.html
For example, you may use the following code to use the `Mapbox` provider:
.. code-block:: python
import xyzservices.providers as xyz
xyz.MapBox(id="<insert map_ID here>", accessToken="my-private-ACCESS_TOKEN")
"""
self.portrayal_method = portrayal_method
self._crs = "epsg:4326"
if isinstance(tiles, xyzservices.TileProvider):
tiles = RasterWebTile.from_xyzservices(tiles).to_dict()
self.tiles = tiles
def render(self, model):
return {
"layers": self._render_layers(model),
"agents": self._render_agents(model),
}
def _render_layers(self, model):
layers = {"rasters": [], "vectors": [], "total_bounds": []}
for layer in model.space.layers:
if isinstance(layer, RasterBase):
if isinstance(layer, RasterLayer):
layer_to_render = layer.to_image(
colormap=self.portrayal_method
).to_crs(self._crs)
else:
layer_to_render = layer.to_crs(self._crs)
layers["rasters"].append(
{
"url": image_to_url(
layer_to_render.values.transpose([1, 2, 0])
),
# longlat [min_x, min_y, max_x, max_y] to latlong [[min_y, min_x], [max_y, max_x]]
"bounds": [
[
layer_to_render.total_bounds[1],
layer_to_render.total_bounds[0],
],
[
layer_to_render.total_bounds[3],
layer_to_render.total_bounds[2],
],
],
}
)
elif isinstance(layer, gpd.GeoDataFrame):
layers["vectors"].append(
layer.to_crs(self._crs)[["geometry"]].__geo_interface__
)
# longlat [min_x, min_y, max_x, max_y] to latlong [min_y, min_x, max_y, max_x]
if model.space.total_bounds is not None:
transformed_xx, transformed_yy = model.space.transformer.transform(
xx=[model.space.total_bounds[0], model.space.total_bounds[2]],
yy=[model.space.total_bounds[1], model.space.total_bounds[3]],
)
layers["total_bounds"] = [
[transformed_yy[0], transformed_xx[0]], # min_y, min_x
[transformed_yy[1], transformed_xx[1]], # max_y, max_x
]
return layers
def _get_marker(self, location, properties):
"""
takes point objects and transforms them to ipyleaflet marker objects
allowed marker types are point marker types from ipyleaflet
https://ipyleaflet.readthedocs.io/en/latest/layers/index.html
default is circle with radius 5
Parameters
----------
location: iterable
iterable of location in models geometry
properties : dict
properties passed in through agent portrayal
Returns
-------
ipyleaflet marker element
"""
if "marker_type" not in properties: # make circle default marker type
properties["marker_type"] = "Circle"
properties["radius"] = 5
marker = properties["marker_type"]
if marker == "Circle":
return ipyleaflet.Circle(location=location, **properties)
elif marker == "CircleMarker":
return ipyleaflet.CircleMarker(location=location, **properties)
elif marker == "Marker":
return ipyleaflet.Marker(location=location, **properties)
elif marker == "Icon":
icon_url = properties["icon_url"]
icon_size = properties.get("icon_size", [20, 20])
icon_properties = properties.get("icon_properties", {})
icon = ipyleaflet.Icon(
icon_url=icon_url, icon_size=icon_size, **icon_properties
)
return ipyleaflet.Marker(location=location, icon=icon, **properties)
elif marker == "AwesomeIcon":
name = properties["name"]
icon_properties = properties.get("icon_properties", {})
icon = ipyleaflet.AwesomeIcon(name=name, **icon_properties)
return ipyleaflet.Marker(location=location, icon=icon, **properties)
else:
raise ValueError(
f"Unsupported marker type:{marker}",
)
def _render_agents(self, model):
feature_collection = {"type": "FeatureCollection", "features": []}
point_markers = []
agent_portrayal = {}
for agent in model.space.agents:
transformed_geometry = agent.get_transformed_geometry(
model.space.transformer
)
if self.portrayal_method:
properties = self.portrayal_method(agent)
agent_portrayal = LeafletViz(
popupProperties=properties.pop("description", None)
)
if isinstance(agent.geometry, Point):
location = mapping(transformed_geometry)
# for some reason points are reversed
location = (location["coordinates"][1], location["coordinates"][0])
point_markers.append(self._get_marker(location, properties))
else:
agent_portrayal.style = properties
agent_portrayal = dataclasses.asdict(
agent_portrayal,
dict_factory=lambda x: {k: v for (k, v) in x if v is not None},
)
feature_collection["features"].append(
{
"type": "Feature",
"geometry": mapping(transformed_geometry),
"properties": agent_portrayal,
}
)
return [feature_collection, point_markers]