-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat: Add tooltip to Altair agent portrayal (#2795) #2838
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
base: main
Are you sure you want to change the base?
Changes from all commits
2e1e9da
05bff03
d93ba35
845833b
f79ceac
7333ab8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,9 @@ | ||
| # noqa: D100 | ||
| """Altair-based renderer for Mesa spaces. | ||
|
|
||
| This module provides an Altair-based renderer for visualizing Mesa model spaces, | ||
| agents, and property layers with interactive charting capabilities. | ||
| """ | ||
|
|
||
| import warnings | ||
| from collections.abc import Callable | ||
| from dataclasses import fields | ||
|
|
@@ -75,6 +80,7 @@ def collect_agent_data( | |
| "stroke": [], # Stroke color | ||
| "strokeWidth": [], | ||
| "filled": [], | ||
| "tooltip": [], | ||
| } | ||
|
|
||
| # Import here to avoid circular import issues | ||
|
|
@@ -133,6 +139,7 @@ def collect_agent_data( | |
| linewidths=dict_data.pop( | ||
| "linewidths", style_fields.get("linewidths") | ||
| ), | ||
| tooltip=dict_data.pop("tooltip", None), | ||
| ) | ||
| if dict_data: | ||
| ignored_keys = list(dict_data.keys()) | ||
|
|
@@ -188,6 +195,7 @@ def collect_agent_data( | |
| # FIXME: Make filled user-controllable | ||
| filled_value = True | ||
| arguments["filled"].append(filled_value) | ||
| arguments["tooltip"].append(aps.tooltip) | ||
|
|
||
| final_data = {} | ||
| for k, v in arguments.items(): | ||
|
|
@@ -221,79 +229,83 @@ def draw_agents( | |
| if arguments["loc"].size == 0: | ||
| return None | ||
|
|
||
| # To get a continuous scale for color the domain should be between [0, 1] | ||
| # that's why changing the the domain of strokeWidth beforehand. | ||
| stroke_width = [data / 10 for data in arguments["strokeWidth"]] | ||
|
|
||
| # Agent data preparation | ||
| df_data = { | ||
| "x": arguments["loc"][:, 0], | ||
| "y": arguments["loc"][:, 1], | ||
| "size": arguments["size"], | ||
| "shape": arguments["shape"], | ||
| "opacity": arguments["opacity"], | ||
| "strokeWidth": stroke_width, | ||
| "original_color": arguments["color"], | ||
| "is_filled": arguments["filled"], | ||
| "original_stroke": arguments["stroke"], | ||
| } | ||
| df = pd.DataFrame(df_data) | ||
|
|
||
| # To ensure distinct shapes according to agent portrayal | ||
| unique_shape_names_in_data = df["shape"].unique().tolist() | ||
|
|
||
| fill_colors = [] | ||
| stroke_colors = [] | ||
| for i in range(len(df)): | ||
| filled = df["is_filled"][i] | ||
| main_color = df["original_color"][i] | ||
| stroke_spec = ( | ||
| df["original_stroke"][i] | ||
| if isinstance(df["original_stroke"][i], str) | ||
| else None | ||
| ) | ||
| if filled: | ||
| fill_colors.append(main_color) | ||
| stroke_colors.append(stroke_spec) | ||
|
Comment on lines
-225
to
-257
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. again, it seems that more is changed then just adding a tooltip. Can you explain what changed here and why?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The previous approach of creating separate DataFrames and joining them (df.join(tooltip_df)) was causing a ValueError: Dataframe contains invalid column name: 0. Pandas was creating integer-based column names, which Altair cannot handle.The new code fixes this by building a list of dictionaries (records), where each dictionary represents a single agent's complete data (position, style, and tooltip). Creating the DataFrame from this list (pd.DataFrame(records)) is a more robust method that ensures all column names are correctly handled as strings.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @Sahil-Chhoker Can you review these changes? You are best positioned to judge whether this is all done correctly.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it's a needed change to make the dataframe flexible but I've to do some testing before I can say anything. |
||
| # Prepare a list of dictionaries, which is a robust way to create a DataFrame | ||
DipayanDasgupta marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| records = [] | ||
| for i in range(len(arguments["loc"])): | ||
| record = { | ||
| "x": arguments["loc"][i][0], | ||
| "y": arguments["loc"][i][1], | ||
| "size": arguments["size"][i], | ||
| "shape": arguments["shape"][i], | ||
| "opacity": arguments["opacity"][i], | ||
| "strokeWidth": arguments["strokeWidth"][i] | ||
| / 10, # Scale for continuous domain | ||
| "original_color": arguments["color"][i], | ||
| } | ||
| # Add tooltip data if available | ||
| tooltip = arguments["tooltip"][i] | ||
| if tooltip: | ||
| record.update(tooltip) | ||
|
|
||
| # Determine fill and stroke colors | ||
| if arguments["filled"][i]: | ||
| record["viz_fill_color"] = arguments["color"][i] | ||
| record["viz_stroke_color"] = ( | ||
| arguments["stroke"][i] | ||
| if isinstance(arguments["stroke"][i], str) | ||
| else None | ||
| ) | ||
DipayanDasgupta marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| else: | ||
| fill_colors.append(None) | ||
| stroke_colors.append(main_color) | ||
| df["viz_fill_color"] = fill_colors | ||
| df["viz_stroke_color"] = stroke_colors | ||
|
|
||
| # Extract additional parameters from kwargs | ||
| # FIXME: Add more parameters to kwargs | ||
DipayanDasgupta marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| title = kwargs.pop("title", "") | ||
| xlabel = kwargs.pop("xlabel", "") | ||
| ylabel = kwargs.pop("ylabel", "") | ||
| record["viz_fill_color"] = None | ||
| record["viz_stroke_color"] = arguments["color"][i] | ||
|
|
||
| # Tooltip list for interactivity | ||
| # FIXME: Add more fields to tooltip (preferably from agent_portrayal) | ||
| tooltip_list = ["x", "y"] | ||
| records.append(record) | ||
|
|
||
| # Handle custom colormapping | ||
| cmap = kwargs.pop("cmap", "viridis") | ||
| vmin = kwargs.pop("vmin", None) | ||
| vmax = kwargs.pop("vmax", None) | ||
| df = pd.DataFrame(records) | ||
|
|
||
| color_is_numeric = np.issubdtype(df["original_color"].dtype, np.number) | ||
DipayanDasgupta marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if color_is_numeric: | ||
| color_min = vmin if vmin is not None else df["original_color"].min() | ||
| color_max = vmax if vmax is not None else df["original_color"].max() | ||
| # Ensure all columns that should be numeric are, handling potential Nones | ||
| numeric_cols = ["x", "y", "size", "opacity", "strokeWidth"] | ||
| for col in numeric_cols: | ||
| if col in df.columns: | ||
| df[col] = pd.to_numeric(df[col], errors="coerce") | ||
|
|
||
| fill_encoding = alt.Fill( | ||
| "original_color:Q", | ||
| scale=alt.Scale(scheme=cmap, domain=[color_min, color_max]), | ||
| # Handle color numeric conversion safely | ||
| if "original_color" in df.columns: | ||
| color_values = arguments["color"] | ||
| color_is_numeric = all( | ||
| isinstance(x, int | float | np.number) or x is None | ||
| for x in color_values | ||
| ) | ||
| else: | ||
| fill_encoding = alt.Fill( | ||
| "viz_fill_color:N", | ||
| scale=None, | ||
| title="Color", | ||
| if color_is_numeric: | ||
| df["original_color"] = pd.to_numeric( | ||
| df["original_color"], errors="coerce" | ||
| ) | ||
|
|
||
| # Get tooltip keys from the first valid record | ||
| tooltip_list = ["x", "y"] | ||
| if any(t is not None for t in arguments["tooltip"]): | ||
| first_valid_tooltip = next( | ||
| (t for t in arguments["tooltip"] if t is not None), None | ||
| ) | ||
| if first_valid_tooltip is not None: | ||
| tooltip_list.extend(first_valid_tooltip.keys()) | ||
|
|
||
| # Extract additional parameters from kwargs | ||
| title = kwargs.pop("title", "") | ||
| xlabel = kwargs.pop("xlabel", "") | ||
| ylabel = kwargs.pop("ylabel", "") | ||
| # FIXME: Add more parameters to kwargs | ||
|
|
||
| color_is_numeric = pd.api.types.is_numeric_dtype(df["original_color"]) | ||
| fill_encoding = ( | ||
| alt.Fill("original_color:Q") | ||
| if color_is_numeric | ||
| else alt.Fill("viz_fill_color:N", scale=None, title="Color") | ||
| ) | ||
|
|
||
| # Determine space dimensions | ||
| xmin, xmax, ymin, ymax = self.space_drawer.get_viz_limits() | ||
| unique_shape_names_in_data = df["shape"].dropna().unique().tolist() | ||
|
|
||
| chart = ( | ||
| alt.Chart(df) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.