Repository: projectmesa/mesa
Pull request #2299 (2024-09-18 09:03:30)

SolaraViz Updates
Some more work on the SolaraViz Frontend. Highlights

1. Much better docstrings
2. Removed `make_text` function. It was used like `make_text(renderer)`, but now you can use `renderer` directly as a component
3. Merged Play/Pause button and disabled step button while model is running
4. Reorganised the Sidebar to look like this
![image](https://github.com/user-attachments/assets/b56e059c-f2c4-4a5a-b11f-982f196d22b5)
 

github-actions[bot]: Performance benchmarks:

| Model           | Size   | Init time [95% CI]      | Run time [95% CI]       |
|-----------------|--------|-------------------------|-------------------------|
| BoltzmannWealth | small  | 🔵 -0.8% [-1.6%, +0.0%] | 🔵 -0.4% [-0.6%, -0.2%] |
| BoltzmannWealth | large  | 🔵 +0.1% [-0.8%, +1.2%] | 🔵 +1.8% [-0.7%, +5.7%] |
| Schelling       | small  | 🔵 +0.1% [-0.2%, +0.4%] | 🔵 -0.7% [-0.9%, -0.5%] |
| Schelling       | large  | 🔵 +0.8% [+0.2%, +1.4%] | 🔵 -1.2% [-2.6%, +0.4%] |
| WolfSheep       | small  | 🔵 +0.1% [-0.6%, +0.7%] | 🔵 -0.1% [-0.4%, +0.1%] |
| WolfSheep       | large  | 🔵 +1.6% [+0.9%, +2.3%] | 🔵 +0.9% [-2.4%, +4.3%] |
| BoidFlockers    | small  | 🔵 -1.3% [-1.6%, -0.8%] | 🔵 -1.0% [-1.5%, -0.5%] |
| BoidFlockers    | large  | 🔵 -1.2% [-1.5%, -1.0%] | 🔵 -0.5% [-0.9%, -0.0%] |


Patch:
diff --git a/docs/apis/agent.md b/docs/apis/agent.md
new file mode 100644
index 00000000..52ea7a9d
--- /dev/null
+++ b/docs/apis/agent.md
@@ -0,0 +1,8 @@
+# Agent
+
+
+```{eval-rst}
+.. automodule:: mesa.agent
+   :members:
+   :inherited-members:
+```
diff --git a/docs/apis/api_main.md b/docs/apis/api_main.md
index e2e45242..61b29f13 100644
--- a/docs/apis/api_main.md
+++ b/docs/apis/api_main.md
@@ -3,7 +3,8 @@
 ```{toctree}
 :maxdepth: 3
 
-Base Classes <init>
+model
+agent
 time
 space
 datacollection
diff --git a/docs/apis/batchrunner.md b/docs/apis/batchrunner.md
index 29074843..89bd93ca 100644
--- a/docs/apis/batchrunner.md
+++ b/docs/apis/batchrunner.md
@@ -1,3 +1,5 @@
+# Batchrunner
+
 ```{eval-rst}
 .. automodule:: batchrunner
    :members:
diff --git a/docs/apis/datacollection.md b/docs/apis/datacollection.md
index 2455aac7..9e34fd8d 100644
--- a/docs/apis/datacollection.md
+++ b/docs/apis/datacollection.md
@@ -1,3 +1,5 @@
+# Data collection
+
 ```{eval-rst}
 .. automodule:: datacollection
    :members:
diff --git a/docs/apis/experimental.md b/docs/apis/experimental.md
index 0d20ba6f..d5f662c0 100644
--- a/docs/apis/experimental.md
+++ b/docs/apis/experimental.md
@@ -1,9 +1,5 @@
 # Experimental
-
-```{eval-rst}
-.. automodule:: experimental.__init__
-   :members:
-```
+This namespace contains experimental features. These are under development, and their API is not necessarily stable.
 
 ## Cell Space
 
diff --git a/docs/apis/init.md b/docs/apis/init.md
deleted file mode 100644
index caa45449..00000000
--- a/docs/apis/init.md
+++ /dev/null
@@ -1,11 +0,0 @@
-# Base Classes
-
-```{eval-rst}
-.. autoclass:: mesa.Agent
-   :members:
-```
-
-```{eval-rst}
-.. autoclass:: mesa.Model
-   :members:
-```
diff --git a/docs/apis/model.md b/docs/apis/model.md
new file mode 100644
index 00000000..d58c6908
--- /dev/null
+++ b/docs/apis/model.md
@@ -0,0 +1,7 @@
+# Model
+
+```{eval-rst}
+.. automodule:: mesa.model
+   :members:
+   :inherited-members:
+```
diff --git a/docs/apis/space.md b/docs/apis/space.md
index 86ad12f7..0a76f798 100644
--- a/docs/apis/space.md
+++ b/docs/apis/space.md
@@ -1,3 +1,5 @@
+# Spaces
+
 ```{eval-rst}
 .. automodule:: mesa.space
    :members:
diff --git a/docs/apis/time.md b/docs/apis/time.md
index df69d86c..afcbfa3f 100644
--- a/docs/apis/time.md
+++ b/docs/apis/time.md
@@ -1,3 +1,5 @@
+# Time
+
 ```{eval-rst}
 .. automodule:: mesa.time
    :members:
diff --git a/mesa/agent.py b/mesa/agent.py
index 6d63ac79..aa917e17 100644
--- a/mesa/agent.py
+++ b/mesa/agent.py
@@ -1,6 +1,6 @@
-"""The agent class for Mesa framework.
+"""Agent related classes
 
-Core Objects: Agent
+Core Objects: Agent and AgentSet.
 """
 
 # Mypy; for the `|` operator purpose
diff --git a/mesa/experimental/cell_space/__init__.py b/mesa/experimental/cell_space/__init__.py
index 33bdbe9b..792dde61 100644
--- a/mesa/experimental/cell_space/__init__.py
+++ b/mesa/experimental/cell_space/__init__.py
@@ -1,4 +1,9 @@
-"""Cell spaces."""
+"""Cell spaces.
+
+Cell spaces offer an alternative API for discrete spaces. It is experimental and under development. The API is more
+expressive that the default grids available in `mesa.space`.
+
+"""
 
 from mesa.experimental.cell_space.cell import Cell
 from mesa.experimental.cell_space.cell_agent import CellAgent
diff --git a/mesa/experimental/cell_space/cell.py b/mesa/experimental/cell_space/cell.py
index 4e9a8f15..381d0f6c 100644
--- a/mesa/experimental/cell_space/cell.py
+++ b/mesa/experimental/cell_space/cell.py
@@ -9,8 +9,11 @@ from typing import TYPE_CHECKING
 from mesa.experimental.cell_space.cell_collection import CellCollection
 
 if TYPE_CHECKING:
+    from mesa.agent import Agent
     from mesa.experimental.cell_space.cell_agent import CellAgent
 
+Coordinate = tuple[int, ...]
+
 
 class Cell:
     """The cell represents a position in a discrete space.
@@ -26,7 +29,7 @@ class Cell:
 
     __slots__ = [
         "coordinate",
-        "_connections",
+        "connections",
         "agents",
         "capacity",
         "properties",
@@ -44,7 +47,7 @@ class Cell:
 
     def __init__(
         self,
-        coordinate: tuple[int, ...],
+        coordinate: Coordinate,
         capacity: float | None = None,
         random: Random | None = None,
     ) -> None:
@@ -58,20 +61,25 @@ class Cell:
         """
         super().__init__()
         self.coordinate = coordinate
-        self._connections: list[Cell] = []  # TODO: change to CellCollection?
-        self.agents = []  # TODO:: change to AgentSet or weakrefs? (neither is very performant, )
+        self.connections: dict[Coordinate, Cell] = {}
+        self.agents: list[
+            Agent
+        ] = []  # TODO:: change to AgentSet or weakrefs? (neither is very performant, )
         self.capacity = capacity
-        self.properties: dict[str, object] = {}
+        self.properties: dict[Coordinate, object] = {}
         self.random = random
 
-    def connect(self, other: Cell) -> None:
+    def connect(self, other: Cell, key: Coordinate | None = None) -> None:
         """Connects this cell to another cell.
 
         Args:
             other (Cell): other cell to connect to
+            key (Tuple[int, ...]): key for the connection. Should resemble a relative coordinate
 
         """
-        self._connections.append(other)
+        if key is None:
+            key = other.coordinate
+        self.connections[key] = other
 
     def disconnect(self, other: Cell) -> None:
         """Disconnects this cell from another cell.
@@ -80,7 +88,9 @@ class Cell:
             other (Cell): other cell to remove from connections
 
         """
-        self._connections.remove(other)
+        keys_to_remove = [k for k, v in self.connections.items() if v == other]
+        for key in keys_to_remove:
+            del self.connections[key]
 
     def add_agent(self, agent: CellAgent) -> None:
         """Adds an agent to the cell.
@@ -123,30 +133,34 @@ class Cell:
 
     # FIXME: Revisit caching strategy on methods
     @cache  # noqa: B019
-    def neighborhood(self, radius=1, include_center=False):
+    def neighborhood(self, radius: int = 1, include_center: bool = False):
         """Returns a list of all neighboring cells."""
-        return CellCollection(
+        return CellCollection[Cell](
             self._neighborhood(radius=radius, include_center=include_center),
             random=self.random,
         )
 
     # FIXME: Revisit caching strategy on methods
     @cache  # noqa: B019
-    def _neighborhood(self, radius=1, include_center=False):
+    def _neighborhood(
+        self, radius: int = 1, include_center: bool = False
+    ) -> dict[Cell, list[Agent]]:
         # if radius == 0:
         #     return {self: self.agents}
         if radius < 1:
             raise ValueError("radius must be larger than one")
         if radius == 1:
-            neighborhood = {neighbor: neighbor.agents for neighbor in self._connections}
+            neighborhood = {
+                neighbor: neighbor.agents for neighbor in self.connections.values()
+            }
             if not include_center:
                 return neighborhood
             else:
                 neighborhood[self] = self.agents
                 return neighborhood
         else:
-            neighborhood = {}
-            for neighbor in self._connections:
+            neighborhood: dict[Cell, list[Agent]] = {}
+            for neighbor in self.connections.values():
                 neighborhood.update(
                     neighbor._neighborhood(radius - 1, include_center=True)
                 )
diff --git a/mesa/experimental/cell_space/discrete_space.py b/mesa/experimental/cell_space/discrete_space.py
index f9a35de5..0d3af169 100644
--- a/mesa/experimental/cell_space/discrete_space.py
+++ b/mesa/experimental/cell_space/discrete_space.py
@@ -62,11 +62,11 @@ class DiscreteSpace(Generic[T]):
     def __iter__(self):  # noqa
         return iter(self._cells.values())
 
-    def __getitem__(self, key):  # noqa
+    def __getitem__(self, key: tuple[int, ...]) -> T:  # noqa: D105
         return self._cells[key]
 
     @property
-    def empties(self) -> CellCollection:
+    def empties(self) -> CellCollection[T]:
         """Return all empty in spaces."""
         return self.all_cells.select(lambda cell: cell.is_empty)
 
diff --git a/mesa/experimental/cell_space/grid.py b/mesa/experimental/cell_space/grid.py
index ae33fbe4..4d525964 100644
--- a/mesa/experimental/cell_space/grid.py
+++ b/mesa/experimental/cell_space/grid.py
@@ -12,7 +12,7 @@ from mesa.experimental.cell_space import Cell, DiscreteSpace
 T = TypeVar("T", bound=Cell)
 
 
-class Grid(DiscreteSpace, Generic[T]):
+class Grid(DiscreteSpace[T], Generic[T]):
     """Base class for all grid classes.
 
     Attributes:
@@ -100,7 +100,7 @@ class Grid(DiscreteSpace, Generic[T]):
             if self.torus:
                 n_coord = tuple(nc % d for nc, d in zip(n_coord, self.dimensions))
             if all(0 <= nc < d for nc, d in zip(n_coord, self.dimensions)):
-                cell.connect(self._cells[n_coord])
+                cell.connect(self._cells[n_coord], d_coord)
 
     def _connect_single_cell_2d(self, cell: T, offsets: list[tuple[int, int]]) -> None:
         i, j = cell.coordinate
@@ -111,7 +111,7 @@ class Grid(DiscreteSpace, Generic[T]):
             if self.torus:
                 ni, nj = ni % height, nj % width
             if 0 <= ni < height and 0 <= nj < width:
-                cell.connect(self._cells[ni, nj])
+                cell.connect(self._cells[ni, nj], (di, dj))
 
 
 class OrthogonalMooreGrid(Grid[T]):
@@ -133,7 +133,6 @@ class OrthogonalMooreGrid(Grid[T]):
             ( 1, -1), ( 1, 0), ( 1, 1),
         ]
         # fmt: on
-        height, width = self.dimensions
 
         for cell in self.all_cells:
             self._connect_single_cell_2d(cell, offsets)
@@ -165,13 +164,12 @@ class OrthogonalVonNeumannGrid(Grid[T]):
                     ( 1, 0),
         ]
         # fmt: on
-        height, width = self.dimensions
 
         for cell in self.all_cells:
             self._connect_single_cell_2d(cell, offsets)
 
     def _connect_cells_nd(self) -> None:
-        offsets = []
+        offsets: list[tuple[int, ...]] = []
         dimensions = len(self.dimensions)
         for dim in range(dimensions):
             for delta in [
diff --git a/mesa/experimental/cell_space/network.py b/mesa/experimental/cell_space/network.py
index 3920511c..55130526 100644
--- a/mesa/experimental/cell_space/network.py
+++ b/mesa/experimental/cell_space/network.py
@@ -7,7 +7,7 @@ from mesa.experimental.cell_space.cell import Cell
 from mesa.experimental.cell_space.discrete_space import DiscreteSpace
 
 
-class Network(DiscreteSpace):
+class Network(DiscreteSpace[Cell]):
     """A networked discrete space."""
 
     def __init__(
@@ -37,6 +37,6 @@ class Network(DiscreteSpace):
         for cell in self.all_cells:
             self._connect_single_cell(cell)
 
-    def _connect_single_cell(self, cell):
+    def _connect_single_cell(self, cell: Cell):
         for node_id in self.G.neighbors(cell.coordinate):
-            cell.connect(self._cells[node_id])
+            cell.connect(self._cells[node_id], node_id)
diff --git a/mesa/experimental/cell_space/voronoi.py b/mesa/experimental/cell_space/voronoi.py
index 0d712f3e..2cde122c 100644
--- a/mesa/experimental/cell_space/voronoi.py
+++ b/mesa/experimental/cell_space/voronoi.py
@@ -216,8 +216,8 @@ class VoronoiGrid(DiscreteSpace):
 
         for point in self.triangulation.export_triangles():
             for i, j in combinations(point, 2):
-                self._cells[i].connect(self._cells[j])
-                self._cells[j].connect(self._cells[i])
+                self._cells[i].connect(self._cells[j], (i, j))
+                self._cells[j].connect(self._cells[i], (j, i))
 
     def _validate_parameters(self) -> None:
         if self.capacity is not None and not isinstance(self.capacity, float | int):
@@ -233,9 +233,10 @@ class VoronoiGrid(DiscreteSpace):
 
     def _get_voronoi_regions(self) -> tuple:
         if self.voronoi_coordinates is None or self.regions is None:
-            self.voronoi_coordinates, self.regions = (
-                self.triangulation.export_voronoi_regions()
-            )
+            (
+                self.voronoi_coordinates,
+                self.regions,
+            ) = self.triangulation.export_voronoi_regions()
         return self.voronoi_coordinates, self.regions
 
     @staticmethod
diff --git a/mesa/space.py b/mesa/space.py
index 2daa3d0f..fc089b0f 100644
--- a/mesa/space.py
+++ b/mesa/space.py
@@ -2,13 +2,12 @@
 
 Objects used to add a spatial component to a model.
 
-Grid: base grid, which creates a rectangular grid.
-SingleGrid: extension to Grid which strictly enforces one agent per cell.
-MultiGrid: extension to Grid where each cell can contain a set of agents.
-HexGrid: extension to Grid to handle hexagonal neighbors.
-ContinuousSpace: a two-dimensional space where each agent has an arbitrary
-                 position of `float`'s.
-NetworkGrid: a network where each node contains zero or more agents.
+* Grid: base grid, which creates a rectangular grid.
+* SingleGrid: extension to Grid which strictly enforces one agent per cell.
+* MultiGrid: extension to Grid where each cell can contain a set of agents.
+* HexGrid: extension to Grid to handle hexagonal neighbors.
+* ContinuousSpace: a two-dimensional space where each agent has an arbitrary position of `float`'s.
+* NetworkGrid: a network where each node contains zero or more agents.
 """
 
 # Mypy; for the `|` operator purpose
diff --git a/mesa/visualization/__init__.py b/mesa/visualization/__init__.py
index 3208642d..d6e50c37 100644
--- a/mesa/visualization/__init__.py
+++ b/mesa/visualization/__init__.py
@@ -2,13 +2,12 @@
 
 from .components.altair import make_space_altair
 from .components.matplotlib import make_plot_measure, make_space_matplotlib
-from .solara_viz import JupyterViz, SolaraViz, make_text
+from .solara_viz import JupyterViz, SolaraViz
 from .UserParam import Slider
 
 __all__ = [
     "JupyterViz",
     "SolaraViz",
-    "make_text",
     "Slider",
     "make_space_altair",
     "make_space_matplotlib",
diff --git a/mesa/visualization/solara_viz.py b/mesa/visualization/solara_viz.py
index 7b1a0464..129a9486 100644
--- a/mesa/visualization/solara_viz.py
+++ b/mesa/visualization/solara_viz.py
@@ -7,7 +7,6 @@ Key features:
     - SolaraViz: Main component for creating visualizations, supporting grid displays and plots
     - ModelController: Handles model execution controls (step, play, pause, reset)
     - UserInputs: Generates UI elements for adjusting model parameters
-    - Card: Renders individual visualization elements (space, measures)
 
 The module uses Solara for rendering in Jupyter notebooks or as standalone web applications.
 It supports various types of visualizations including matplotlib plots, agent grids, and
@@ -22,10 +21,14 @@ Usage:
 See the Visualization Tutorial and example models for more details.
 """
 
+from __future__ import annotations
+
 import copy
 import time
+from collections.abc import Callable
 from typing import TYPE_CHECKING, Literal
 
+import reacton.core
 import solara
 from solara.alias import rv
 
@@ -89,31 +92,57 @@ def Card(
 
 @solara.component
 def SolaraViz(
-    model: "Model" | solara.Reactive["Model"],
-    components: list[solara.component] | Literal["default"] = "default",
-    play_interval=100,
+    model: Model | solara.Reactive[Model],
+    components: list[reacton.core.Component]
+    | list[Callable[[Model], reacton.core.Component]]
+    | Literal["default"] = "default",
+    play_interval: int = 100,
     model_params=None,
-    seed=0,
+    seed: float = 0,
     name: str | None = None,
 ):
     """Solara visualization component.
 
+    This component provides a visualization interface for a given model using Solara.
+    It supports various visualization components and allows for interactive model
+    stepping and parameter adjustments.
+
     Args:
-        model: a Model instance
-        components: list of solara components
-        play_interval: int
-        model_params: parameters for instantiating a model
-        seed: the seed for the rng
-        name: str
+        model (Model | solara.Reactive[Model]): A Model instance or a reactive Model.
+            This is the main model to be visualized. If a non-reactive model is provided,
+            it will be converted to a reactive model.
+        components (list[solara.component] | Literal["default"], optional): List of solara
+            components or functions that return a solara component.
+            These components are used to render different parts of the model visualization.
+            Defaults to "default", which uses the default Altair space visualization.
+        play_interval (int, optional): Interval for playing the model steps in milliseconds.
+            This controls the speed of the model's automatic stepping. Defaults to 100 ms.
+        model_params (dict, optional): Parameters for (re-)instantiating a model.
+            Can include user-adjustable parameters and fixed parameters. Defaults to None.
+        seed (int, optional): Seed for the random number generator. This ensures reproducibility
+            of the model's behavior. Defaults to 0.
+        name (str | None, optional): Name of the visualization. Defaults to the models class name.
 
+    Returns:
+        solara.component: A Solara component that renders the visualization interface for the model.
+
+    Example:
+        >>> model = MyModel()
+        >>> page = SolaraViz(model)
+        >>> page
+
+    Notes:
+        - The `model` argument can be either a direct model instance or a reactive model. If a direct
+          model instance is provided, it will be converted to a reactive model using `solara.use_reactive`.
+        - The `play_interval` argument controls the speed of the model's automatic stepping. A lower
+          value results in faster stepping, while a higher value results in slower stepping.
     """
-    update_counter.get()
     if components == "default":
         components = [components_altair.make_space_altair()]
 
     # Convert model to reactive
     if not isinstance(model, solara.Reactive):
-        model = solara.use_reactive(model)
+        model = solara.use_reactive(model)  # noqa: SH102, RUF100
 
     def connect_to_model():
         # Patch the step function to force updates
@@ -133,39 +162,68 @@ def SolaraViz(
     with solara.AppBar():
         solara.AppBarTitle(name if name else model.value.__class__.__name__)
 
-    with solara.Sidebar():
-        with solara.Card("Controls", margin=1, elevation=2):
-            if model_params is not None:
+    with solara.Sidebar(), solara.Column():
+        with solara.Card("Controls"):
+            ModelController(model, play_interval)
+
+        if model_params is not None:
+            with solara.Card("Model Parameters"):
                 ModelCreator(
                     model,
                     model_params,
                     seed=seed,
                 )
-            ModelController(model, play_interval)
-        with solara.Card("Information", margin=1, elevation=2):
+        with solara.Card("Information"):
             ShowSteps(model.value)
 
-    solara.Column(
-        [
-            *(component(model.value) for component in components),
-        ]
-    )
+    ComponentsView(components, model.value)
+
+
+def _wrap_component(
+    component: reacton.core.Component | Callable[[Model], reacton.core.Component],
+) -> reacton.core.Component:
+    """Wrap a component in an auto-updated Solara component if needed."""
+    if isinstance(component, reacton.core.Component):
+        return component
+
+    @solara.component
+    def WrappedComponent(model):
+        update_counter.get()
+        return component(model)
+
+    return WrappedComponent
+
+
+@solara.component
+def ComponentsView(
+    components: list[reacton.core.Component]
+    | list[Callable[[Model], reacton.core.Component]],
+    model: Model,
+):
+    """Display a list of components.
+
+    Args:
+        components: List of components to display
+        model: Model instance to pass to each component
+    """
+    wrapped_components = [_wrap_component(component) for component in components]
+
+    with solara.Column():
+        for component in wrapped_components:
+            component(model)
 
 
 JupyterViz = SolaraViz
 
 
 @solara.component
-def ModelController(model: solara.Reactive["Model"], play_interval=100):
+def ModelController(model: solara.Reactive[Model], play_interval=100):
     """Create controls for model execution (step, play, pause, reset).
 
     Args:
-        model: The reactive model being visualized
-        play_interval: Interval between steps during play
+        model (solara.Reactive[Model]): Reactive model instance
+        play_interval (int, optional): Interval for playing the model steps in milliseconds.
     """
-    if not isinstance(model, solara.Reactive):
-        model = solara.use_reactive(model)
-
     playing = solara.use_reactive(False)
     original_model = solara.use_reactive(None)
 
@@ -188,24 +246,25 @@ def ModelController(model: solara.Reactive["Model"], play_interval=100):
         """Advance the model by one step."""
         model.value.step()
 
-    def do_play():
-        """Run the model continuously."""
-        playing.value = True
-
-    def do_pause():
-        """Pause the model execution."""
-        playing.value = False
-
     def do_reset():
         """Reset the model to its initial state."""
         playing.value = False
         model.value = copy.deepcopy(original_model.value)
 
+    def do_play_pause():
+        """Toggle play/pause."""
+        playing.value = not playing.value
+
     with solara.Row(justify="space-between"):
         solara.Button(label="Reset", color="primary", on_click=do_reset)
-        solara.Button(label="Step", color="primary", on_click=do_step)
-        solara.Button(label="▶", color="primary", on_click=do_play)
-        solara.Button(label="⏸︎", color="primary", on_click=do_pause)
+        solara.Button(
+            label="▶" if not playing.value else "❚❚",
+            color="primary",
+            on_click=do_play_pause,
+        )
+        solara.Button(
+            label="Step", color="primary", on_click=do_step, disabled=playing.value
+        )
 
 
 def split_model_params(model_params):
@@ -246,13 +305,34 @@ def check_param_is_fixed(param):
 
 @solara.component
 def ModelCreator(model, model_params, seed=1):
-    """Helper class to create a new Model instance.
+    """Solara component for creating and managing a model instance with user-defined parameters.
+
+    This component allows users to create a model instance with specified parameters and seed.
+    It provides an interface for adjusting model parameters and reseeding the model's random
+    number generator.
 
     Args:
-        model: model instance
-        model_params: model parameters
-        seed: the seed to use for the random number generator
+        model (solara.Reactive[Model]): A reactive model instance. This is the main model to be created and managed.
+        model_params (dict): Dictionary of model parameters. This includes both user-adjustable parameters and fixed parameters.
+        seed (int, optional): Initial seed for the random number generator. Defaults to 1.
 
+    Returns:
+        solara.component: A Solara component that renders the model creation and management interface.
+
+    Example:
+        >>> model = solara.reactive(MyModel())
+        >>> model_params = {
+        >>>     "param1": {"type": "slider", "value": 10, "min": 0, "max": 100},
+        >>>     "param2": {"type": "slider", "value": 5, "min": 1, "max": 10},
+        >>> }
+        >>> creator = ModelCreator(model, model_params)
+        >>> creator
+
+    Notes:
+        - The `model_params` argument should be a dictionary where keys are parameter names and values either fixed values
+          or are dictionaries containing parameter details such as type, value, min, and max.
+        - The `seed` argument ensures reproducibility by setting the initial seed for the model's random number generator.
+        - The component provides an interface for adjusting user-defined parameters and reseeding the model.
 
     """
     user_params, fixed_params = split_model_params(model_params)
@@ -279,13 +359,14 @@ def ModelCreator(model, model_params, seed=1):
 
     solara.use_effect(create_model, [model_parameters, reactive_seed.value])
 
-    solara.InputText(
-        label="Seed",
-        value=reactive_seed,
-        continuous_update=True,
-    )
+    with solara.Row(justify="space-between"):
+        solara.InputText(
+            label="Seed",
+            value=reactive_seed,
+            continuous_update=True,
+        )
 
-    solara.Button(label="Reseed", color="primary", on_click=do_reseed)
+        solara.Button(label="Reseed", color="primary", on_click=do_reseed)
 
     UserInputs(user_params, on_change=on_change)
 
@@ -358,22 +439,6 @@ def UserInputs(user_params, on_change=None):
             raise ValueError(f"{input_type} is not a supported input type")
 
 
-def make_text(renderer):
-    """Create a function that renders text using Markdown.
-
-    Args:
-        renderer: Function that takes a model and returns a string
-
-    Returns:
-        function: A function that renders the text as Markdown
-    """
-
-    def function(model):
-        solara.Markdown(renderer(model))
-
-    return function
-
-
 def make_initial_grid_layout(layout_types):
     """Create an initial grid layout for visualization components.
 
@@ -397,6 +462,7 @@ def make_initial_grid_layout(layout_types):
 
 
 @solara.component
-def ShowSteps(model):  # noqa: D103
+def ShowSteps(model):
+    """Display the current step of the model."""
     update_counter.get()
     return solara.Text(f"Step: {model.steps}")


Test patch:
diff --git a/tests/test_cell_space.py b/tests/test_cell_space.py
index 6d88a90b..5ef4c44e 100644
--- a/tests/test_cell_space.py
+++ b/tests/test_cell_space.py
@@ -26,46 +26,46 @@ def test_orthogonal_grid_neumann():
     assert len(grid._cells) == width * height
 
     # von neumann neighborhood, torus false, top left corner
-    assert len(grid._cells[(0, 0)]._connections) == 2
-    for connection in grid._cells[(0, 0)]._connections:
+    assert len(grid._cells[(0, 0)].connections.values()) == 2
+    for connection in grid._cells[(0, 0)].connections.values():
         assert connection.coordinate in {(0, 1), (1, 0)}
 
     # von neumann neighborhood, torus false, top right corner
-    for connection in grid._cells[(0, width - 1)]._connections:
+    for connection in grid._cells[(0, width - 1)].connections.values():
         assert connection.coordinate in {(0, width - 2), (1, width - 1)}
 
     # von neumann neighborhood, torus false, bottom left corner
-    for connection in grid._cells[(height - 1, 0)]._connections:
+    for connection in grid._cells[(height - 1, 0)].connections.values():
         assert connection.coordinate in {(height - 1, 1), (height - 2, 0)}
 
     # von neumann neighborhood, torus false, bottom right corner
-    for connection in grid._cells[(height - 1, width - 1)]._connections:
+    for connection in grid._cells[(height - 1, width - 1)].connections.values():
         assert connection.coordinate in {
             (height - 1, width - 2),
             (height - 2, width - 1),
         }
 
     # von neumann neighborhood middle of grid
-    assert len(grid._cells[(5, 5)]._connections) == 4
-    for connection in grid._cells[(5, 5)]._connections:
+    assert len(grid._cells[(5, 5)].connections.values()) == 4
+    for connection in grid._cells[(5, 5)].connections.values():
         assert connection.coordinate in {(4, 5), (5, 4), (5, 6), (6, 5)}
 
     # von neumann neighborhood, torus True, top corner
     grid = OrthogonalVonNeumannGrid((width, height), torus=True, capacity=None)
-    assert len(grid._cells[(0, 0)]._connections) == 4
-    for connection in grid._cells[(0, 0)]._connections:
+    assert len(grid._cells[(0, 0)].connections.values()) == 4
+    for connection in grid._cells[(0, 0)].connections.values():
         assert connection.coordinate in {(0, 1), (1, 0), (0, 9), (9, 0)}
 
     # von neumann neighborhood, torus True, top right corner
-    for connection in grid._cells[(0, width - 1)]._connections:
+    for connection in grid._cells[(0, width - 1)].connections.values():
         assert connection.coordinate in {(0, 8), (0, 0), (1, 9), (9, 9)}
 
     # von neumann neighborhood, torus True, bottom left corner
-    for connection in grid._cells[(9, 0)]._connections:
+    for connection in grid._cells[(9, 0)].connections.values():
         assert connection.coordinate in {(9, 1), (9, 9), (0, 0), (8, 0)}
 
     # von neumann neighborhood, torus True, bottom right corner
-    for connection in grid._cells[(9, 9)]._connections:
+    for connection in grid._cells[(9, 9)].connections.values():
         assert connection.coordinate in {(9, 0), (9, 8), (8, 9), (0, 9)}
 
 
@@ -79,12 +79,12 @@ def test_orthogonal_grid_neumann_3d():
     assert len(grid._cells) == width * height * depth
 
     # von neumann neighborhood, torus false, top left corner
-    assert len(grid._cells[(0, 0, 0)]._connections) == 3
-    for connection in grid._cells[(0, 0, 0)]._connections:
+    assert len(grid._cells[(0, 0, 0)].connections.values()) == 3
+    for connection in grid._cells[(0, 0, 0)].connections.values():
         assert connection.coordinate in {(0, 0, 1), (0, 1, 0), (1, 0, 0)}
 
     # von neumann neighborhood, torus false, top right corner
-    for connection in grid._cells[(0, width - 1, 0)]._connections:
+    for connection in grid._cells[(0, width - 1, 0)].connections.values():
         assert connection.coordinate in {
             (0, width - 1, 1),
             (0, width - 2, 0),
@@ -92,7 +92,7 @@ def test_orthogonal_grid_neumann_3d():
         }
 
     # von neumann neighborhood, torus false, bottom left corner
-    for connection in grid._cells[(height - 1, 0, 0)]._connections:
+    for connection in grid._cells[(height - 1, 0, 0)].connections.values():
         assert connection.coordinate in {
             (height - 1, 0, 1),
             (height - 1, 1, 0),
@@ -100,7 +100,7 @@ def test_orthogonal_grid_neumann_3d():
         }
 
     # von neumann neighborhood, torus false, bottom right corner
-    for connection in grid._cells[(height - 1, width - 1, 0)]._connections:
+    for connection in grid._cells[(height - 1, width - 1, 0)].connections.values():
         assert connection.coordinate in {
             (height - 1, width - 1, 1),
             (height - 1, width - 2, 0),
@@ -108,8 +108,8 @@ def test_orthogonal_grid_neumann_3d():
         }
 
     # von neumann neighborhood middle of grid
-    assert len(grid._cells[(5, 5, 5)]._connections) == 6
-    for connection in grid._cells[(5, 5, 5)]._connections:
+    assert len(grid._cells[(5, 5, 5)].connections.values()) == 6
+    for connection in grid._cells[(5, 5, 5)].connections.values():
         assert connection.coordinate in {
             (4, 5, 5),
             (5, 4, 5),
@@ -121,8 +121,8 @@ def test_orthogonal_grid_neumann_3d():
 
     # von neumann neighborhood, torus True, top corner
     grid = OrthogonalVonNeumannGrid((width, height, depth), torus=True, capacity=None)
-    assert len(grid._cells[(0, 0, 0)]._connections) == 6
-    for connection in grid._cells[(0, 0, 0)]._connections:
+    assert len(grid._cells[(0, 0, 0)].connections.values()) == 6
+    for connection in grid._cells[(0, 0, 0)].connections.values():
         assert connection.coordinate in {
             (0, 0, 1),
             (0, 1, 0),
@@ -140,13 +140,13 @@ def test_orthogonal_grid_moore():
 
     # Moore neighborhood, torus false, top corner
     grid = OrthogonalMooreGrid((width, height), torus=False, capacity=None)
-    assert len(grid._cells[(0, 0)]._connections) == 3
-    for connection in grid._cells[(0, 0)]._connections:
+    assert len(grid._cells[(0, 0)].connections.values()) == 3
+    for connection in grid._cells[(0, 0)].connections.values():
         assert connection.coordinate in {(0, 1), (1, 0), (1, 1)}
 
     # Moore neighborhood middle of grid
-    assert len(grid._cells[(5, 5)]._connections) == 8
-    for connection in grid._cells[(5, 5)]._connections:
+    assert len(grid._cells[(5, 5)].connections.values()) == 8
+    for connection in grid._cells[(5, 5)].connections.values():
         # fmt: off
         assert connection.coordinate in {(4, 4), (4, 5), (4, 6),
                                          (5, 4),         (5, 6),
@@ -155,8 +155,8 @@ def test_orthogonal_grid_moore():
 
     # Moore neighborhood, torus True, top corner
     grid = OrthogonalMooreGrid([10, 10], torus=True, capacity=None)
-    assert len(grid._cells[(0, 0)]._connections) == 8
-    for connection in grid._cells[(0, 0)]._connections:
+    assert len(grid._cells[(0, 0)].connections.values()) == 8
+    for connection in grid._cells[(0, 0)].connections.values():
         # fmt: off
         assert connection.coordinate in {(9, 9), (9, 0), (9, 1),
                                          (0, 9),         (0, 1),
@@ -172,8 +172,8 @@ def test_orthogonal_grid_moore_3d():
 
     # Moore neighborhood, torus false, top corner
     grid = OrthogonalMooreGrid((width, height, depth), torus=False, capacity=None)
-    assert len(grid._cells[(0, 0, 0)]._connections) == 7
-    for connection in grid._cells[(0, 0, 0)]._connections:
+    assert len(grid._cells[(0, 0, 0)].connections.values()) == 7
+    for connection in grid._cells[(0, 0, 0)].connections.values():
         assert connection.coordinate in {
             (0, 0, 1),
             (0, 1, 0),
@@ -185,8 +185,8 @@ def test_orthogonal_grid_moore_3d():
         }
 
     # Moore neighborhood middle of grid
-    assert len(grid._cells[(5, 5, 5)]._connections) == 26
-    for connection in grid._cells[(5, 5, 5)]._connections:
+    assert len(grid._cells[(5, 5, 5)].connections.values()) == 26
+    for connection in grid._cells[(5, 5, 5)].connections.values():
         # fmt: off
         assert connection.coordinate in {(4, 4, 4), (4, 4, 5), (4, 4, 6), (4, 5, 4), (4, 5, 5), (4, 5, 6), (4, 6, 4), (4, 6, 5), (4, 6, 6),
                                          (5, 4, 4), (5, 4, 5), (5, 4, 6), (5, 5, 4),             (5, 5, 6), (5, 6, 4), (5, 6, 5), (5, 6, 6),
@@ -195,8 +195,8 @@ def test_orthogonal_grid_moore_3d():
 
     # Moore neighborhood, torus True, top corner
     grid = OrthogonalMooreGrid((width, height, depth), torus=True, capacity=None)
-    assert len(grid._cells[(0, 0, 0)]._connections) == 26
-    for connection in grid._cells[(0, 0, 0)]._connections:
+    assert len(grid._cells[(0, 0, 0)].connections.values()) == 26
+    for connection in grid._cells[(0, 0, 0)].connections.values():
         # fmt: off
         assert connection.coordinate in {(9, 9, 9), (9, 9, 0), (9, 9, 1), (9, 0, 9), (9, 0, 0), (9, 0, 1), (9, 1, 9), (9, 1, 0), (9, 1, 1),
                                          (0, 9, 9), (0, 9, 0), (0, 9, 1), (0, 0, 9),             (0, 0, 1), (0, 1, 9), (0, 1, 0), (0, 1, 1),
@@ -213,8 +213,8 @@ def test_orthogonal_grid_moore_4d():
 
     # Moore neighborhood, torus false, top corner
     grid = OrthogonalMooreGrid((width, height, depth, time), torus=False, capacity=None)
-    assert len(grid._cells[(0, 0, 0, 0)]._connections) == 15
-    for connection in grid._cells[(0, 0, 0, 0)]._connections:
+    assert len(grid._cells[(0, 0, 0, 0)].connections.values()) == 15
+    for connection in grid._cells[(0, 0, 0, 0)].connections.values():
         assert connection.coordinate in {
             (0, 0, 0, 1),
             (0, 0, 1, 0),
@@ -234,8 +234,8 @@ def test_orthogonal_grid_moore_4d():
         }
 
     # Moore neighborhood middle of grid
-    assert len(grid._cells[(5, 5, 5, 5)]._connections) == 80
-    for connection in grid._cells[(5, 5, 5, 5)]._connections:
+    assert len(grid._cells[(5, 5, 5, 5)].connections.values()) == 80
+    for connection in grid._cells[(5, 5, 5, 5)].connections.values():
         # fmt: off
         assert connection.coordinate in {(4, 4, 4, 4), (4, 4, 4, 5), (4, 4, 4, 6), (4, 4, 5, 4), (4, 4, 5, 5), (4, 4, 5, 6), (4, 4, 6, 4), (4, 4, 6, 5), (4, 4, 6, 6),
                                          (4, 5, 4, 4), (4, 5, 4, 5), (4, 5, 4, 6), (4, 5, 5, 4), (4, 5, 5, 5), (4, 5, 5, 6), (4, 5, 6, 4), (4, 5, 6, 5), (4, 5, 6, 6),
@@ -255,19 +255,19 @@ def test_orthogonal_grid_moore_1d():
 
     # Moore neighborhood, torus false, left edge
     grid = OrthogonalMooreGrid((width,), torus=False, capacity=None)
-    assert len(grid._cells[(0,)]._connections) == 1
-    for connection in grid._cells[(0,)]._connections:
+    assert len(grid._cells[(0,)].connections.values()) == 1
+    for connection in grid._cells[(0,)].connections.values():
         assert connection.coordinate in {(1,)}
 
     # Moore neighborhood middle of grid
-    assert len(grid._cells[(5,)]._connections) == 2
-    for connection in grid._cells[(5,)]._connections:
+    assert len(grid._cells[(5,)].connections.values()) == 2
+    for connection in grid._cells[(5,)].connections.values():
         assert connection.coordinate in {(4,), (6,)}
 
     # Moore neighborhood, torus True, left edge
     grid = OrthogonalMooreGrid((width,), torus=True, capacity=None)
-    assert len(grid._cells[(0,)]._connections) == 2
-    for connection in grid._cells[(0,)]._connections:
+    assert len(grid._cells[(0,)].connections.values()) == 2
+    for connection in grid._cells[(0,)].connections.values():
         assert connection.coordinate in {(1,), (9,)}
 
 
@@ -321,21 +321,21 @@ def test_hexgrid():
     assert len(grid._cells) == width * height
 
     # first row
-    assert len(grid._cells[(0, 0)]._connections) == 2
-    for connection in grid._cells[(0, 0)]._connections:
+    assert len(grid._cells[(0, 0)].connections.values()) == 2
+    for connection in grid._cells[(0, 0)].connections.values():
         assert connection.coordinate in {(0, 1), (1, 0)}
 
     # second row
-    assert len(grid._cells[(1, 0)]._connections) == 5
-    for connection in grid._cells[(1, 0)]._connections:
+    assert len(grid._cells[(1, 0)].connections.values()) == 5
+    for connection in grid._cells[(1, 0)].connections.values():
         # fmt: off
         assert connection.coordinate in {(0, 0), (0, 1),
                                                     (1, 1),
                                          (2, 0), (2, 1)}
 
     # middle odd row
-    assert len(grid._cells[(5, 5)]._connections) == 6
-    for connection in grid._cells[(5, 5)]._connections:
+    assert len(grid._cells[(5, 5)].connections.values()) == 6
+    for connection in grid._cells[(5, 5)].connections.values():
         # fmt: off
         assert connection.coordinate in {(4, 5), (4, 6),
                                       (5, 4),       (5, 6),
@@ -344,8 +344,8 @@ def test_hexgrid():
         # fmt: on
 
     # middle even row
-    assert len(grid._cells[(4, 4)]._connections) == 6
-    for connection in grid._cells[(4, 4)]._connections:
+    assert len(grid._cells[(4, 4)].connections.values()) == 6
+    for connection in grid._cells[(4, 4)].connections.values():
         # fmt: off
         assert connection.coordinate in {(3, 3), (3, 4),
                                       (4, 3),       (4, 5),
@@ -357,8 +357,8 @@ def test_hexgrid():
     assert len(grid._cells) == width * height
 
     # first row
-    assert len(grid._cells[(0, 0)]._connections) == 6
-    for connection in grid._cells[(0, 0)]._connections:
+    assert len(grid._cells[(0, 0)].connections.values()) == 6
+    for connection in grid._cells[(0, 0)].connections.values():
         # fmt: off
         assert connection.coordinate in {(9, 9), (9, 0),
                                       (0, 9),       (0, 1),
@@ -380,7 +380,7 @@ def test_networkgrid():
     assert len(grid._cells) == n
 
     for i, cell in grid._cells.items():
-        for connection in cell._connections:
+        for connection in cell.connections.values():
             assert connection.coordinate in G.neighbors(i)
 
 
@@ -393,8 +393,8 @@ def test_voronoigrid():
     assert len(grid._cells) == len(points)
 
     # Check cell neighborhood
-    assert len(grid._cells[0]._connections) == 2
-    for connection in grid._cells[0]._connections:
+    assert len(grid._cells[0].connections.values()) == 2
+    for connection in grid._cells[0].connections.values():
         assert connection.coordinate in [[1, 1], [1, 3]]
 
     with pytest.raises(ValueError):
@@ -434,15 +434,14 @@ def test_cell():
 
     # connect
     cell1.connect(cell2)
-    assert cell2 in cell1._connections
+    assert cell2 in cell1.connections.values()
 
     # disconnect
     cell1.disconnect(cell2)
-    assert cell2 not in cell1._connections
+    assert cell2 not in cell1.connections.values()
 
     # remove cell not in connections
-    with pytest.raises(ValueError):
-        cell1.disconnect(cell2)
+    cell1.disconnect(cell2)
 
     # add_agent
     model = Model()
diff --git a/tests/test_solara_viz.py b/tests/test_solara_viz.py
index 798777bb..301294f2 100644
--- a/tests/test_solara_viz.py
+++ b/tests/test_solara_viz.py
@@ -1,12 +1,13 @@
 """Test Solara visualizations."""
 
 import unittest
-from unittest.mock import Mock
 
 import ipyvuetify as vw
 import solara
 
 import mesa
+import mesa.visualization.components.altair
+import mesa.visualization.components.matplotlib
 from mesa.visualization.components.matplotlib import make_space_matplotlib
 from mesa.visualization.solara_viz import Slider, SolaraViz, UserInputs
 
@@ -86,10 +87,12 @@ class TestMakeUserInput(unittest.TestCase):  # noqa: D101
 
 
 def test_call_space_drawer(mocker):  # noqa: D103
-    mock_space_matplotlib = mocker.patch(
-        "mesa.visualization.components.matplotlib.SpaceMatplotlib"
+    mock_space_matplotlib = mocker.spy(
+        mesa.visualization.components.matplotlib, "SpaceMatplotlib"
     )
 
+    mock_space_altair = mocker.spy(mesa.visualization.components.altair, "SpaceAltair")
+
     model = mesa.Model()
     mocker.patch.object(mesa.Model, "__init__", return_value=None)
 
@@ -105,13 +108,19 @@ def test_call_space_drawer(mocker):  # noqa: D103
 
     # specify no space should be drawn
     mock_space_matplotlib.reset_mock()
-    solara.render(SolaraViz(model, components=[]))
+    solara.render(SolaraViz(model))
     # should call default method with class instance and agent portrayal
     assert mock_space_matplotlib.call_count == 0
+    assert mock_space_altair.call_count > 0
 
     # specify a custom space method
-    altspace_drawer = Mock()
-    solara.render(SolaraViz(model, components=[altspace_drawer]))
+    class AltSpace:
+        @staticmethod
+        def drawer(model):
+            return
+
+    altspace_drawer = mocker.spy(AltSpace, "drawer")
+    solara.render(SolaraViz(model, components=[AltSpace.drawer]))
     altspace_drawer.assert_called_with(model)
 
     # check voronoi space drawer