Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add method to make geospace as a solara component #246

Merged
merged 2 commits into from
Sep 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions mesa_geo/geospace.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,9 @@

if len(agents) > 0:
# Bulk insert agents
index_data = ((id(agent), agent.geometry.bounds, None) for agent in agents)
index_data = (
(agent.unique_id, agent.geometry.bounds, None) for agent in agents
)
self._idx = index.Index(index_data)

def add_agents(self, agents):
Expand All @@ -336,12 +338,12 @@

if isinstance(agents, GeoAgent):
agent = agents
self._id_to_agent[id(agent)] = agent
self._id_to_agent[agent.unique_id] = agent
if self._idx:
self._idx.insert(id(agent), agent.geometry.bounds, None)
self._idx.insert(agent.unique_id, agent.geometry.bounds, None)
else:
for agent in agents:
self._id_to_agent[id(agent)] = agent
self._id_to_agent[agent.unique_id] = agent
if self._idx:
self._recreate_rtree(agents)
self._total_bounds = None
Expand All @@ -351,9 +353,9 @@
Remove an agent from the layer.
"""

del self._id_to_agent[id(agent)]
del self._id_to_agent[agent.unique_id]
if self._idx:
self._idx.delete(id(agent), agent.geometry.bounds)
self._idx.delete(agent.unique_id, agent.geometry.bounds)

Check warning on line 358 in mesa_geo/geospace.py

View check run for this annotation

Codecov / codecov/patch

mesa_geo/geospace.py#L358

Added line #L358 was not covered by tests
self._total_bounds = None

def get_relation(self, agent, relation):
Expand Down
7 changes: 4 additions & 3 deletions mesa_geo/visualization/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Import specific classes or functions from the modules
from mesa_geo.visualization.geojupyter_viz import GeoJupyterViz
from mesa_geo.visualization.leaflet_viz import LeafletViz
from .components.geospace_leaflet import MapModule, make_geospace_leaflet
from .geojupyter_viz import GeoJupyterViz
from .leaflet_viz import LeafletViz

__all__ = ["GeoJupyterViz", "LeafletViz"]
__all__ = ["make_geospace_leaflet", "MapModule", "GeoJupyterViz", "LeafletViz"]
271 changes: 271 additions & 0 deletions mesa_geo/visualization/components/geospace_leaflet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
import dataclasses
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,
):
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(

Check warning on line 36 in mesa_geo/visualization/components/geospace_leaflet.py

View check run for this annotation

Codecov / codecov/patch

mesa_geo/visualization/components/geospace_leaflet.py#L36

Added line #L36 was not covered by tests
xx=[model.space.total_bounds[0], model.space.total_bounds[2]],
yy=[model.space.total_bounds[1], model.space.total_bounds[3]],
)
view = [

Check warning on line 40 in mesa_geo/visualization/components/geospace_leaflet.py

View check run for this annotation

Codecov / codecov/patch

mesa_geo/visualization/components/geospace_leaflet.py#L40

Added line #L40 was not covered by tests
(transformed_yy[0] + transformed_yy[1]) / 2,
(transformed_xx[0] + transformed_xx[1]) / 2,
]

layers = (

Check warning on line 45 in mesa_geo/visualization/components/geospace_leaflet.py

View check run for this annotation

Codecov / codecov/patch

mesa_geo/visualization/components/geospace_leaflet.py#L45

Added line #L45 was not covered by tests
[ipyleaflet.TileLayer.element(url=map_drawer.tiles["url"])] if tiles else []
)
for layer in model_view["layers"]["rasters"]:
layers.append(ipyleaflet.ImageOverlay(element=image_to_url(layer)))

Check warning on line 49 in mesa_geo/visualization/components/geospace_leaflet.py

View check run for this annotation

Codecov / codecov/patch

mesa_geo/visualization/components/geospace_leaflet.py#L49

Added line #L49 was not covered by tests
for layer in model_view["layers"]["vectors"]:
layers.append(ipyleaflet.GeoJSON(element=layer))
ipyleaflet.Map.element(

Check warning on line 52 in mesa_geo/visualization/components/geospace_leaflet.py

View check run for this annotation

Codecov / codecov/patch

mesa_geo/visualization/components/geospace_leaflet.py#L51-L52

Added lines #L51 - L52 were not covered by tests
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


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 view: The initial view of the map. Must be set together with zoom.
If both view and zoom are None, the map will be centered on the total bounds
of the space. Default is None.
:param zoom: The initial zoom level of the map. Must be set together with view.
If both view and zoom are None, the map will be centered on the total bounds
of the space. Default is None.
:param scroll_wheel_zoom: Boolean whether not user can scroll on map with mouse wheel
: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": '&copy; <a href="https://www.mapbox.com/about/maps/" target="_blank">Mapbox</a> &copy; <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 scale_options: A dictionary of options for the map scale. Default is None
(no map scale). The available options can be found at: https://leafletjs.com/reference.html#control-scale-option
"""
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(
image_to_url(layer_to_render.values.transpose([1, 2, 0]))
)
elif isinstance(layer, gpd.GeoDataFrame):
layers["vectors"].append(

Check warning on line 169 in mesa_geo/visualization/components/geospace_leaflet.py

View check run for this annotation

Codecov / codecov/patch

mesa_geo/visualization/components/geospace_leaflet.py#L169

Added line #L169 was not covered by tests
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)

Check warning on line 218 in mesa_geo/visualization/components/geospace_leaflet.py

View check run for this annotation

Codecov / codecov/patch

mesa_geo/visualization/components/geospace_leaflet.py#L218

Added line #L218 was not covered by tests
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(

Check warning on line 223 in mesa_geo/visualization/components/geospace_leaflet.py

View check run for this annotation

Codecov / codecov/patch

mesa_geo/visualization/components/geospace_leaflet.py#L220-L223

Added lines #L220 - L223 were not covered by tests
icon_url=icon_url, icon_size=icon_size, **icon_properties
)
return ipyleaflet.Marker(location=location, icon=icon, **properties)

Check warning on line 226 in mesa_geo/visualization/components/geospace_leaflet.py

View check run for this annotation

Codecov / codecov/patch

mesa_geo/visualization/components/geospace_leaflet.py#L226

Added line #L226 was not covered by tests
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]
Loading
Loading