"""Altair based solara components for visualization modelly spaces."""
from collections.abc import Callable
import altair as alt
import solara
from modelly._deprecation import deprecated
from modelly.viz.altair.property_layers import chart_property_layers
from modelly.viz.altair.space_chart import _draw_grid
from modelly.viz.utils import update_counter
__all__ = [
"PlotAltair",
"SpaceAltair",
"chart_property_layers",
"make_altair_plot_component",
"make_altair_space",
"make_space_altair",
]
_PLOT_WIDTH = 450
_PLOT_HEIGHT = 350
[docs]
def make_space_altair(*args, **kwargs): # noqa: D103
deprecated("make_space_altair()", "make_altair_space()")
return make_altair_space(*args, **kwargs)
[docs]
def make_altair_space(
agent_portrayal,
property_layer_portrayal=None,
post_process=None,
**space_drawing_kwargs,
):
"""Create an Altair-based space visualization component.
Args:
agent_portrayal: Function to portray agents.
property_layer_portrayal: Dictionary of property_layer portrayal specifications
post_process :A user specified callable that will be called with the Chart instance from Altair. Allows for fine tuning plots (e.g., control ticks)
space_drawing_kwargs : not yet implemented
``agent_portrayal`` is called with an agent and should return a dict. Valid fields in this dict are "color",
"size", "marker", and "zorder". Other field are ignored and will result in a user warning.
Returns:
function: A function that creates a SpaceMatplotlib component
"""
if agent_portrayal is None:
def agent_portrayal(a):
return {"id": a.unique_id}
def MakeSpaceAltair(model):
return SpaceAltair(
model,
agent_portrayal,
property_layer_portrayal=property_layer_portrayal,
post_process=post_process,
)
return MakeSpaceAltair
@solara.component
def SpaceAltair(
model,
agent_portrayal,
property_layer_portrayal=None,
dependencies: list[any] | None = None,
post_process=None,
):
"""Create an Altair-based space visualization component.
Returns:
a solara FigureAltair instance
"""
update_counter.get()
space = getattr(model, "grid", None)
if space is None:
# Sometimes the space is defined as model.space instead of model.grid
space = model.space
chart = _draw_grid(space, agent_portrayal, property_layer_portrayal)
# Apply post-processing if provided
if post_process is not None:
chart = post_process(chart)
solara.FigureAltair(chart)
[docs]
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.
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, 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, page)
def _measures_to_plot(measure) -> tuple[list, str]:
"""Return the measure names to plot and the y-axis title."""
y_title = "Value"
if isinstance(measure, str):
measures_to_plot = [measure]
y_title = measure
elif isinstance(measure, list | tuple):
measures_to_plot = list(measure)
elif isinstance(measure, dict):
measures_to_plot = list(measure.keys())
return measures_to_plot, y_title
def _line_chart(df_long, y_title: str, grid) -> alt.Chart:
"""Draw the measures as interactive lines over the steps."""
return (
alt.Chart(df_long)
.mark_line()
.encode(
x=alt.X("Step:Q", axis=alt.Axis(tickMinStep=1, title="Step", grid=grid)),
y=alt.Y("Value:Q", axis=alt.Axis(title=y_title, grid=grid)),
tooltip=["Step", "Measure", "Value"],
)
.properties(width=_PLOT_WIDTH, height=_PLOT_HEIGHT)
.interactive()
)
def _color_by_measure(chart: alt.Chart, measure) -> alt.Chart:
"""Color the lines by measure, with the user's colors when ``measure`` is a dict."""
color_args = {}
if isinstance(measure, dict):
color_args["scale"] = alt.Scale(
domain=list(measure.keys()), range=list(measure.values())
)
return chart.encode(color=alt.Color("Measure:N", **color_args))
@solara.component
def PlotAltair(model, measure, post_process: Callable | None = None, grid=False):
"""Create an Altair-based plot for a measure or measures.
Args:
model (modelly.Model): The model instance.
measure (str | dict[str, str] | list[str] | tuple[str]): Measure(s) to plot.
If a dict is given, keys are measure names and values are colors.
post_process: A user-specified callable for post-processing, called
with the Altair Chart instance.
grid: Bool to draw grid or not.
Returns:
solara.FigureAltair: A component for rendering the plot.
"""
update_counter.get()
df = model.datacollector.get_model_vars_dataframe().reset_index()
df = df.rename(columns={"index": "Step"})
measures_to_plot, y_title = _measures_to_plot(measure)
df_long = df.melt(
id_vars=["Step"],
value_vars=measures_to_plot,
var_name="Measure",
value_name="Value",
)
chart = _line_chart(df_long, y_title, grid)
if len(measures_to_plot) > 0:
chart = _color_by_measure(chart, measure)
if post_process is not None:
chart = post_process(chart)
return solara.FigureAltair(chart)