diff --git a/docs/tutorials/4_visualization_basic.ipynb b/docs/tutorials/4_visualization_basic.ipynb index 67b613e41b4..e2e73dbeecd 100644 --- a/docs/tutorials/4_visualization_basic.ipynb +++ b/docs/tutorials/4_visualization_basic.ipynb @@ -269,6 +269,42 @@ "> You can make the small window full screen by clicking the button in the top-right corner of the title bar." ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Page Tab View\n", + "\n", + "#### **Plot Components**\n", + "You can place different components (except the renderer) on separate pages according to your preference. There are no restrictions on page numbering — pages do not need to be sequential or positive. Each page acts as an independent window where components may or may not exist.\n", + "\n", + "The default page is `page=0`. If pages are not sequential (e.g., `page=1` and `page=10`), the system will automatically create the 8 empty pages in between to maintain consistent indexing. To avoid empty pages in your dashboard, use sequential page numbers.\n", + "\n", + "To assign a plot component to a specific page, pass the `page` keyword argument to `make_plot_component`. For example, the following will display the plot component on page 1:\n", + "\n", + "```python\n", + "plot_comp = make_plot_component(\"encoding\", page=1)\n", + "```\n", + "\n", + "#### **Custom Components**\n", + "In tutorial 8, you will learn how to create custom components for the Solara dashboard. If you want a custom component to appear on a specific page, you must pass it as a tuple containing the component and the page number.\n", + "\n", + "```python\n", + "@solara.component\n", + "def CustomComponent():\n", + " ...\n", + "\n", + "page = SolaraViz(\n", + " model,\n", + " renderer,\n", + " components=[(CustomComponent, 1)] # Custom component will appear on page 1\n", + ")\n", + "```\n", + "\n", + "> ⚠️ **Warning**\n", + "> Running the model can be performance-intensive. It is strongly recommended to pause the model in the dashboard before switching pages." + ] + }, { "cell_type": "code", "execution_count": null, @@ -282,7 +318,7 @@ " agent_portrayal=agent_portrayal\n", ")\n", "\n", - "GiniPlot = make_plot_component(\"Gini\")\n", + "GiniPlot = make_plot_component(\"Gini\", page=1)\n", "\n", "page = SolaraViz(\n", " money_model,\n", diff --git a/docs/tutorials/5_visualization_dynamic_agents.ipynb b/docs/tutorials/5_visualization_dynamic_agents.ipynb index 5ecb793faba..b3782dd774f 100644 --- a/docs/tutorials/5_visualization_dynamic_agents.ipynb +++ b/docs/tutorials/5_visualization_dynamic_agents.ipynb @@ -263,6 +263,42 @@ "3. Press reset " ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Page Tab View\n", + "\n", + "#### **Plot Components**\n", + "You can place different components (except the renderer) on separate pages according to your preference. There are no restrictions on page numbering — pages do not need to be sequential or positive. Each page acts as an independent window where components may or may not exist.\n", + "\n", + "The default page is `page=0`. If pages are not sequential (e.g., `page=1` and `page=10`), the system will automatically create the 8 empty pages in between to maintain consistent indexing. To avoid empty pages in your dashboard, use sequential page numbers.\n", + "\n", + "To assign a plot component to a specific page, pass the `page` keyword argument to `make_plot_component`. For example, the following will display the plot component on page 1:\n", + "\n", + "```python\n", + "plot_comp = make_plot_component(\"encoding\", page=1)\n", + "```\n", + "\n", + "#### **Custom Components**\n", + "In tutorial 8, you will learn how to create custom components for the Solara dashboard. If you want a custom component to appear on a specific page, you must pass it as a tuple containing the component and the page number.\n", + "\n", + "```python\n", + "@solara.component\n", + "def CustomComponent():\n", + " ...\n", + "\n", + "page = SolaraViz(\n", + " model,\n", + " renderer,\n", + " components=[(CustomComponent, 1)] # Custom component will appear on page 1\n", + ")\n", + "```\n", + "\n", + "> ⚠️ **Warning**\n", + "> Running the model can be performance-intensive. It is strongly recommended to pause the model in the dashboard before switching pages." + ] + }, { "cell_type": "code", "execution_count": null, diff --git a/docs/tutorials/6_visualization_rendering_with_space_renderer.ipynb b/docs/tutorials/6_visualization_rendering_with_space_renderer.ipynb index 84310f7a102..120f205066b 100644 --- a/docs/tutorials/6_visualization_rendering_with_space_renderer.ipynb +++ b/docs/tutorials/6_visualization_rendering_with_space_renderer.ipynb @@ -247,6 +247,42 @@ " * It also ensures the visualization only updates at fixed intervals, improving performance and responsiveness during rapid simulations." ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Page Tab View\n", + "\n", + "#### **Plot Components**\n", + "You can place different components (except the renderer) on separate pages according to your preference. There are no restrictions on page numbering — pages do not need to be sequential or positive. Each page acts as an independent window where components may or may not exist.\n", + "\n", + "The default page is `page=0`. If pages are not sequential (e.g., `page=1` and `page=10`), the system will automatically create the 8 empty pages in between to maintain consistent indexing. To avoid empty pages in your dashboard, use sequential page numbers.\n", + "\n", + "To assign a plot component to a specific page, pass the `page` keyword argument to `make_plot_component`. For example, the following will display the plot component on page 1:\n", + "\n", + "```python\n", + "plot_comp = make_plot_component(\"encoding\", page=1)\n", + "```\n", + "\n", + "#### **Custom Components**\n", + "In tutorial 8, you will learn how to create custom components for the Solara dashboard. If you want a custom component to appear on a specific page, you must pass it as a tuple containing the component and the page number.\n", + "\n", + "```python\n", + "@solara.component\n", + "def CustomComponent():\n", + " ...\n", + "\n", + "page = SolaraViz(\n", + " model,\n", + " renderer,\n", + " components=[(CustomComponent, 1)] # Custom component will appear on page 1\n", + ")\n", + "```\n", + "\n", + "> ⚠️ **Warning**\n", + "> Running the model can be performance-intensive. It is strongly recommended to pause the model in the dashboard before switching pages." + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/docs/tutorials/7_visualization_propertylayer_visualization.ipynb b/docs/tutorials/7_visualization_propertylayer_visualization.ipynb index 917609648a3..01c9074d593 100644 --- a/docs/tutorials/7_visualization_propertylayer_visualization.ipynb +++ b/docs/tutorials/7_visualization_propertylayer_visualization.ipynb @@ -256,6 +256,42 @@ " * It also ensures the visualization only updates at fixed intervals, improving performance and responsiveness during rapid simulations." ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Page Tab View\n", + "\n", + "#### **Plot Components**\n", + "You can place different components (except the renderer) on separate pages according to your preference. There are no restrictions on page numbering — pages do not need to be sequential or positive. Each page acts as an independent window where components may or may not exist.\n", + "\n", + "The default page is `page=0`. If pages are not sequential (e.g., `page=1` and `page=10`), the system will automatically create the 8 empty pages in between to maintain consistent indexing. To avoid empty pages in your dashboard, use sequential page numbers.\n", + "\n", + "To assign a plot component to a specific page, pass the `page` keyword argument to `make_plot_component`. For example, the following will display the plot component on page 1:\n", + "\n", + "```python\n", + "plot_comp = make_plot_component(\"encoding\", page=1)\n", + "```\n", + "\n", + "#### **Custom Components**\n", + "In the next tutorial, you will learn how to create custom components for the Solara dashboard. If you want a custom component to appear on a specific page, you must pass it as a tuple containing the component and the page number.\n", + "\n", + "```python\n", + "@solara.component\n", + "def CustomComponent():\n", + " ...\n", + "\n", + "page = SolaraViz(\n", + " model,\n", + " renderer,\n", + " components=[(CustomComponent, 1)] # Custom component will appear on page 1\n", + ")\n", + "```\n", + "\n", + "> ⚠️ **Warning**\n", + "> Running the model can be performance-intensive. It is strongly recommended to pause the model in the dashboard before switching pages." + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/docs/tutorials/8_visualization_custom.ipynb b/docs/tutorials/8_visualization_custom.ipynb index c8c7925f8ef..0f36f2fcda9 100644 --- a/docs/tutorials/8_visualization_custom.ipynb +++ b/docs/tutorials/8_visualization_custom.ipynb @@ -255,6 +255,43 @@ "}" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Page Tab View\n", + "\n", + "#### **Plot Components**\n", + " \n", + "You can place different components (except the renderer) on separate pages according to your preference. There are no restrictions on page numbering — pages do not need to be sequential or positive. Each page acts as an independent window where components may or may not exist.\n", + "\n", + "The default page is `page=0`. If pages are not sequential (e.g., `page=1` and `page=10`), the system will automatically create the 8 empty pages in between to maintain consistent indexing. To avoid empty pages in your dashboard, use sequential page numbers.\n", + "\n", + "To assign a plot component to a specific page, pass the `page` keyword argument to `make_plot_component`. For example, the following will display the plot component on page 1:\n", + "\n", + "```python\n", + "plot_comp = make_plot_component(\"encoding\", page=1)\n", + "```\n", + "\n", + "#### **Custom Components**\n", + "If you want a custom component to appear on a specific page, you must pass it as a tuple containing the component and the page number.\n", + "\n", + "```python\n", + "@solara.component\n", + "def CustomComponent():\n", + " ...\n", + "\n", + "page = SolaraViz(\n", + " model,\n", + " renderer,\n", + " components=[(CustomComponent, 1)] # Custom component will appear on page 1\n", + ")\n", + "```\n", + "\n", + "> ⚠️ **Warning**\n", + "> Running the model can be performance-intensive. It is strongly recommended to pause the model in the dashboard before switching pages." + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -305,11 +342,11 @@ "money_model = MoneyModel(n=50, width=10, height=10)\n", "\n", "SpaceGraph = make_space_component(agent_portrayal)\n", - "GiniPlot = make_plot_component(\"Gini\")\n", + "GiniPlot = make_plot_component(\"Gini\", page=1)\n", "\n", "page = SolaraViz(\n", " money_model,\n", - " components=[SpaceGraph, GiniPlot, Histogram],\n", + " components=[SpaceGraph, GiniPlot, (Histogram, 2)],\n", " model_params=model_params,\n", " name=\"Boltzmann Wealth Model\",\n", ")\n", diff --git a/mesa/examples/advanced/sugarscape_g1mt/app.py b/mesa/examples/advanced/sugarscape_g1mt/app.py index 10623f63ee3..0fb767e1e4e 100644 --- a/mesa/examples/advanced/sugarscape_g1mt/app.py +++ b/mesa/examples/advanced/sugarscape_g1mt/app.py @@ -63,12 +63,14 @@ def post_process(chart): post_process=post_process, ) +# Note: It is advised to switch the pages after pausing the model +# on the Solara dashboard. page = SolaraViz( model, renderer, components=[ - make_plot_component("#Traders"), - make_plot_component("Price"), + make_plot_component("#Traders", page=1), + make_plot_component("Price", page=1), ], model_params=model_params, name="Sugarscape {G1, M, T}", diff --git a/mesa/visualization/components/__init__.py b/mesa/visualization/components/__init__.py index bdd43207fda..d3370aa778b 100644 --- a/mesa/visualization/components/__init__.py +++ b/mesa/visualization/components/__init__.py @@ -74,6 +74,7 @@ def make_plot_component( measure: str | dict[str, str] | list[str] | tuple[str], post_process: Callable | None = None, backend: str = "matplotlib", + page: int = 0, **plot_drawing_kwargs, ): """Create a plotting function for a specified measure using the specified backend. @@ -82,15 +83,20 @@ def make_plot_component( measure (str | dict[str, str] | list[str] | tuple[str]): Measure(s) to plot. post_process: a user-specified callable to do post-processing called with the Axes instance. backend: the backend to use {"matplotlib", "altair"} + page: Page number where the plot should be displayed (default 0). plot_drawing_kwargs: additional keyword arguments to pass onto the backend specific function for making a plotting component Returns: - function: A function that creates a plot component + (function, page): A tuple of a function and page number that creates a plot component on that specific page. """ if backend == "matplotlib": - return make_mpl_plot_component(measure, post_process, **plot_drawing_kwargs) + return make_mpl_plot_component( + measure, post_process, page, **plot_drawing_kwargs + ) elif backend == "altair": - return make_altair_plot_component(measure, post_process, **plot_drawing_kwargs) + return make_altair_plot_component( + measure, post_process, page, **plot_drawing_kwargs + ) else: raise ValueError( f"unknown backend {backend}, must be one of matplotlib, altair" diff --git a/mesa/visualization/components/altair_components.py b/mesa/visualization/components/altair_components.py index 9df5636d251..d8cf7dfe7ab 100644 --- a/mesa/visualization/components/altair_components.py +++ b/mesa/visualization/components/altair_components.py @@ -454,6 +454,7 @@ def apply_rgba(val, vmin=vmin, vmax=vmax, alpha=alpha, portrayal=portrayal): def make_altair_plot_component( measure: str | dict[str, str] | list[str] | tuple[str], post_process: Callable | None = None, + page: int = 0, grid=False, ): """Create a plotting function for a specified measure. @@ -461,16 +462,17 @@ def make_altair_plot_component( Args: measure (str | dict[str, str] | list[str] | tuple[str]): Measure(s) to plot. post_process: a user-specified callable to do post-processing called with the Axes instance. + page: Page number where the plot should be displayed. grid: Bool to draw grid or not. Returns: - function: A function that creates a PlotAltair component. + (function, page): A tuple of a function that creates a PlotAltair component and a page number. """ def MakePlotAltair(model): return PlotAltair(model, measure, post_process=post_process, grid=grid) - return MakePlotAltair + return (MakePlotAltair, page) @solara.component diff --git a/mesa/visualization/components/matplotlib_components.py b/mesa/visualization/components/matplotlib_components.py index 9e8464f22fa..edee3bd5e50 100644 --- a/mesa/visualization/components/matplotlib_components.py +++ b/mesa/visualization/components/matplotlib_components.py @@ -107,6 +107,7 @@ def make_plot_measure(*args, **kwargs): # noqa: D103 def make_mpl_plot_component( measure: str | dict[str, str] | list[str] | tuple[str], post_process: Callable | None = None, + page: int = 0, save_format="png", ): """Create a plotting function for a specified measure. @@ -114,10 +115,11 @@ def make_mpl_plot_component( Args: measure (str | dict[str, str] | list[str] | tuple[str]): Measure(s) to plot. post_process: a user-specified callable to do post-processing called with the Axes instance. + page: Page number where the plot should be displayed. save_format: save format of figure in solara backend Returns: - function: A function that creates a PlotMatplotlib component. + (function, page): A tuple of a function that creates a PlotMatplotlib component and a page number. """ def MakePlotMatplotlib(model): @@ -125,7 +127,7 @@ def MakePlotMatplotlib(model): model, measure, post_process=post_process, save_format=save_format ) - return MakePlotMatplotlib + return (MakePlotMatplotlib, page) @solara.component diff --git a/mesa/visualization/solara_viz.py b/mesa/visualization/solara_viz.py index 1bcbf6aea62..3bd33e5b432 100644 --- a/mesa/visualization/solara_viz.py +++ b/mesa/visualization/solara_viz.py @@ -24,6 +24,7 @@ from __future__ import annotations import asyncio +import collections import inspect import itertools import threading @@ -57,8 +58,8 @@ def SolaraViz( model: Model | solara.Reactive[Model], renderer: SpaceRenderer | None = None, - components: list[reacton.core.Component] - | list[Callable[[Model], reacton.core.Component]] + components: list[tuple[reacton.core.Component], int] + | list[tuple[Callable[[Model], reacton.core.Component], 0]] | Literal["default"] = [], # noqa: B006 *, play_interval: int = 100, @@ -80,10 +81,10 @@ def SolaraViz( This is the main model to be visualized. If a non-reactive model is provided, it will be converted to a reactive model. renderer (SpaceRenderer): A SpaceRenderer instance to render the model's space. - components (list[solara.component] | Literal["default"], optional): List of solara - components or functions that return a solara component. + components (list[tuple[solara.component], int] | Literal["default"], optional): List of solara + (components, page) or functions that return a solara (component, page). These components are used to render different parts of the model visualization. - Defaults to "default", which uses the default Altair space visualization. + Defaults to "default", which uses the default Altair space visualization on page 0. 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. render_interval (int, optional): Controls how often plots are updated during a simulation, @@ -119,8 +120,13 @@ def SolaraViz( """ if components == "default": components = [ - components_altair.make_altair_space( - agent_portrayal=None, propertylayer_portrayal=None, post_process=None + ( + components_altair.make_altair_space( + agent_portrayal=None, + propertylayer_portrayal=None, + post_process=None, + ), + 0, ) ] if model_params is None: @@ -142,7 +148,7 @@ def SolaraViz( if renderer is not None: if isinstance(renderer, SpaceRenderer): renderer = solara.use_reactive(renderer) # noqa: RUF100 # noqa: SH102 - display_components.insert(0, create_space_component(renderer.value)) + display_components.insert(0, (create_space_component(renderer.value), 0)) with solara.AppBar(): solara.AppBarTitle(name if name else model.value.__class__.__name__) @@ -356,27 +362,88 @@ def WrappedComponent(model): @solara.component def ComponentsView( - components: list[reacton.core.Component] - | list[Callable[[Model], reacton.core.Component]], + components: list[tuple[reacton.core.Component], int] + | list[tuple[Callable[[Model], reacton.core.Component], int]], model: Model, ): """Display a list of components. Args: - components: List of components to display + components: List of (components, page) to display model: Model instance to pass to each component """ - wrapped_components = [_wrap_component(component) for component in components] - items = [component(model) for component in wrapped_components] - grid_layout_initial = make_initial_grid_layout(num_components=len(items)) - grid_layout, set_grid_layout = solara.use_state(grid_layout_initial) - solara.GridDraggable( - items=items, - grid_layout=grid_layout, - resizable=True, - draggable=True, - on_grid_layout=set_grid_layout, - ) + if not components: + return + + # Backward's compatibility, page = 0 if not passed. + for i, comp in enumerate(components): + if not isinstance(comp, tuple): + components[i] = (comp, 0) + + # Build pages mapping + pages = collections.defaultdict(list) + for component, page_index in components: + pages[page_index].append(_wrap_component(component)) + + # Fill in missing page indices for sequential tab order + all_indices = sorted(pages.keys()) + if len(all_indices) > 1: + min_page, max_page = all_indices[0], all_indices[-1] + all_indices = list(range(min_page, max_page + 1)) + for idx in all_indices: + pages.setdefault(idx, []) + + sorted_page_indices = all_indices + + # State for current tab and layouts + current_tab_index, set_current_tab_index = solara.use_state(0) + layouts, set_layouts = solara.use_state({}) + + # Keep layouts in sync with pages + def sync_layouts(): + current_keys = set(pages.keys()) + layout_keys = set(layouts.keys()) + + # Add layouts for new pages + new_layouts = { + index: make_initial_grid_layout(len(pages[index])) + for index in current_keys - layout_keys + } + + # Remove layouts for deleted pages + cleaned_layouts = {k: v for k, v in layouts.items() if k in current_keys} + + if new_layouts or len(cleaned_layouts) != len(layouts): + set_layouts({**cleaned_layouts, **new_layouts}) + + solara.use_effect(sync_layouts, list(pages.keys())) + + # Tab Navigation + with solara.v.Tabs(v_model=current_tab_index, on_v_model=set_current_tab_index): + for index in sorted_page_indices: + solara.v.Tab(children=[f"Page {index}"]) + + with solara.v.TabsItems(v_model=current_tab_index): + for _, page_id in enumerate(sorted_page_indices): + with solara.v.TabItem(): + if page_id == current_tab_index: + page_components = pages[page_id] + page_layout = layouts.get(page_id) + + if page_layout: + + def on_layout_change(new_layout, current_page_id=page_id): + set_layouts( + lambda old: {**old, current_page_id: new_layout} + ) + + solara.GridDraggable( + items=[c(model) for c in page_components], + grid_layout=page_layout, + resizable=True, + draggable=True, + on_grid_layout=on_layout_change, + ) JupyterViz = SolaraViz