diff --git a/CHANGELOG.md b/CHANGELOG.md index 8183879..a3cf7d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Programmatic highlighting via `highlight` parameter (`"least"`, `"greatest"`, index, or list of indices) + +### Changed + +- Out-of-bounds highlight indices now raise errors instead of failing silently + ## [0.4.0] - 2025-01-20 ### Added diff --git a/altair_upset/upset.py b/altair_upset/upset.py index 71522cf..0e03cb8 100644 --- a/altair_upset/upset.py +++ b/altair_upset/upset.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Union +from typing import List, Literal, Optional, Union import altair as alt import pandas as pd @@ -9,6 +9,64 @@ from .transforms import create_base_chart +def _determine_highlighted_intersections( + data: pd.DataFrame, + highlight: Union[Literal["least", "greatest"], int, List[int]], +) -> List[float]: + """Determine which intersection IDs to highlight based on the highlight parameter. + + Parameters + ---------- + data : pd.DataFrame + The preprocessed data with intersection_id and count columns + highlight : "least", "greatest", int, or list of int + The highlighting criteria + + Returns + ------- + list of float + List of intersection_ids to highlight + + Raises + ------ + IndexError + If a single integer index is out of bounds + ValueError + If any index in a list is out of bounds + """ + # Get unique intersections with their counts + intersections = ( + data.groupby("intersection_id")["count"] + .first() + .reset_index() + .sort_index() + ) + + if isinstance(highlight, str): + if highlight == "least": + min_idx = intersections["count"].idxmin() + return [intersections.loc[min_idx, "intersection_id"]] + else: # highlight == "greatest" + max_idx = intersections["count"].idxmax() + return [intersections.loc[max_idx, "intersection_id"]] + elif isinstance(highlight, int): + if highlight >= len(intersections): + raise IndexError( + f"highlight index {highlight} is out of bounds for " + f"{len(intersections)} intersections" + ) + return [intersections.iloc[highlight]["intersection_id"]] + else: # isinstance(highlight, list) + # Validate all indices first + invalid_indices = [i for i in highlight if i >= len(intersections)] + if invalid_indices: + raise ValueError( + f"highlight indices {invalid_indices} are out of bounds for " + f"{len(intersections)} intersections" + ) + return [intersections.iloc[i]["intersection_id"] for i in highlight] + + class UpSetChart: """A wrapper class for UpSet plots.""" @@ -84,6 +142,7 @@ def UpSetAltair( "#BDC6CA", ], highlight_color: str = "#EA4667", + highlight: Optional[Union[Literal["least", "greatest"], int, List[int]]] = None, glyph_size: int = 100, # Reduced from 200 set_label_bg_size: int = 500, # Reduced from 1000 line_connection_size: int = 1, # Reduced from 2 @@ -130,7 +189,14 @@ def UpSetAltair( color_range : list of str List of colors for the sets. Defaults to a colorblind-friendly palette. highlight_color : str, default "#EA4667" - Color used for highlighting on hover. + Color used for highlighting on hover or programmatic highlighting. + highlight : str, int, or list of int, optional + Specifies which intersections to highlight programmatically: + - None (default): interactive hover highlighting + - "least": highlight the intersection with the smallest size + - "greatest": highlight the intersection with the largest size + - int: highlight the intersection at the specified index (0-based) + - list of int: highlight multiple intersections by their indices glyph_size : int, default 200 Size of the matrix glyphs in pixels. set_label_bg_size : int, default 1000 @@ -200,6 +266,18 @@ def UpSetAltair( raise ValueError("if provided, abbre must have the same length as sets") if vertical_bar_y_axis_orient not in ["left", "right"]: raise ValueError("vertical bar y axis orient must be 'left' or 'right'") + if highlight is not None: + if isinstance(highlight, str): + if highlight not in ["least", "greatest"]: + raise ValueError("highlight string must be 'least' or 'greatest'") + elif isinstance(highlight, int): + if highlight < 0: + raise ValueError("highlight index must be non-negative") + elif isinstance(highlight, list): + if not all(isinstance(i, int) and i >= 0 for i in highlight): + raise ValueError("highlight list must contain non-negative integers") + else: + raise TypeError("highlight must be None, str, int, or list of int") # Apply theme if specified if theme is not None: @@ -212,7 +290,20 @@ def UpSetAltair( # Setup selections for interactivity legend_selection = alt.selection_point(fields=["set"], bind="legend") - color_selection = alt.selection_point(fields=["intersection_id"], on="mouseover") + + # Setup color selection based on highlight parameter + if highlight is None: + # Default hover behavior + color_selection = alt.selection_point( + fields=["intersection_id"], on="mouseover" + ) + else: + # Determine which intersections to highlight and create fixed selection + highlighted_ids = _determine_highlighted_intersections(data, highlight) + color_selection = alt.selection_point( + fields=["intersection_id"], + value=[{"intersection_id": id_} for id_ in highlighted_ids], + ) # Calculate dimensions if horizontal_bar_chart_width is None: diff --git a/docs/examples/advanced_features.rst b/docs/examples/advanced_features.rst index 95c918a..51862f3 100644 --- a/docs/examples/advanced_features.rst +++ b/docs/examples/advanced_features.rst @@ -92,3 +92,75 @@ Let's analyze the engagement patterns across different platform combinations: print(f"Avg Time: {row['avg_time']:.1f} minutes") print(f"Avg Posts: {row['avg_posts']:.1f} per week") print(f"Avg Engagement: {row['avg_engagement']:.1f}%") + +Programmatic Highlighting +------------------------- + +You can programmatically highlight specific intersections using the ``highlight`` parameter. +This is useful for drawing attention to specific patterns without requiring user interaction. + +Highlighting the Largest Intersection +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Highlight the intersection with the most users: + +.. altair-plot:: + + au.UpSetAltair( + data=data, + sets=platforms, + title="Social Media Platform Usage - Largest Intersection Highlighted", + highlight="greatest", + width=800, + height=500 + ).chart + +Highlighting the Smallest Intersection +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Highlight the intersection with the fewest users: + +.. altair-plot:: + + au.UpSetAltair( + data=data, + sets=platforms, + title="Social Media Platform Usage - Smallest Intersection Highlighted", + highlight="least", + width=800, + height=500 + ).chart + +Highlighting Specific Intersections by Index +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Highlight specific intersections by their index (0-based): + +.. altair-plot:: + + # Highlight the first intersection + au.UpSetAltair( + data=data, + sets=platforms, + title="Social Media Platform Usage - First Intersection Highlighted", + highlight=0, + width=800, + height=500 + ).chart + +Highlighting Multiple Intersections +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Highlight multiple intersections at once: + +.. altair-plot:: + + # Highlight the first three intersections + au.UpSetAltair( + data=data, + sets=platforms, + title="Social Media Platform Usage - Multiple Intersections Highlighted", + highlight=[0, 1, 2], + width=800, + height=500 + ).chart diff --git a/tests/__snapshots__/test_covid_mutations/test_covid_mutations[image].png b/tests/__snapshots__/test_covid_mutations/test_covid_mutations[image].png index 778d679..38eb4f5 100644 Binary files a/tests/__snapshots__/test_covid_mutations/test_covid_mutations[image].png and b/tests/__snapshots__/test_covid_mutations/test_covid_mutations[image].png differ diff --git a/tests/__snapshots__/test_covid_mutations/test_covid_mutations[vega_spec].json b/tests/__snapshots__/test_covid_mutations/test_covid_mutations[vega_spec].json index 2f20701..0155240 100644 --- a/tests/__snapshots__/test_covid_mutations/test_covid_mutations[vega_spec].json +++ b/tests/__snapshots__/test_covid_mutations/test_covid_mutations[vega_spec].json @@ -36,7 +36,7 @@ "params": [ { "bind": "legend", - "name": "param_23", + "name": "param_35", "select": { "fields": [ "set" @@ -44,14 +44,14 @@ "type": "point" }, "views": [ - "view_45", - "view_46", - "view_47", - "view_48" + "view_65", + "view_66", + "view_67", + "view_68" ] }, { - "name": "param_24", + "name": "param_36", "select": { "fields": [ "intersection_id" @@ -60,8 +60,8 @@ "type": "point" }, "views": [ - "view_45", - "view_46" + "view_65", + "view_66" ] } ], @@ -142,7 +142,7 @@ "size": 20, "type": "bar" }, - "name": "view_48", + "name": "view_68", "transform": [ { "aggregate": [ @@ -195,12 +195,12 @@ }, { "filter": { - "param": "param_23" + "param": "param_35" } }, { "filter": { - "param": "param_23" + "param": "param_35" } }, { @@ -272,7 +272,7 @@ "condition": { "test": { "not": { - "param": "param_24" + "param": "param_36" } }, "value": "#3A3A3A" @@ -323,7 +323,7 @@ "size": 100, "type": "circle" }, - "name": "view_46", + "name": "view_66", "transform": [ { "aggregate": [ @@ -376,12 +376,12 @@ }, { "filter": { - "param": "param_23" + "param": "param_35" } }, { "filter": { - "param": "param_23" + "param": "param_35" } }, { @@ -450,7 +450,7 @@ "condition": { "test": { "not": { - "param": "param_24" + "param": "param_36" } }, "value": "#3A3A3A" @@ -553,12 +553,12 @@ }, { "filter": { - "param": "param_23" + "param": "param_35" } }, { "filter": { - "param": "param_23" + "param": "param_35" } }, { @@ -627,7 +627,7 @@ "condition": { "test": { "not": { - "param": "param_24" + "param": "param_36" } }, "value": "#3A3A3A" @@ -728,12 +728,12 @@ }, { "filter": { - "param": "param_23" + "param": "param_35" } }, { "filter": { - "param": "param_23" + "param": "param_35" } }, { @@ -894,12 +894,12 @@ }, { "filter": { - "param": "param_23" + "param": "param_35" } }, { "filter": { - "param": "param_23" + "param": "param_35" } }, { @@ -1061,12 +1061,12 @@ }, { "filter": { - "param": "param_23" + "param": "param_35" } }, { "filter": { - "param": "param_23" + "param": "param_35" } }, { @@ -1186,7 +1186,7 @@ "size": 650, "type": "circle" }, - "name": "view_47", + "name": "view_67", "transform": [ { "aggregate": [ @@ -1236,12 +1236,12 @@ }, { "filter": { - "param": "param_23" + "param": "param_35" } }, { "filter": { - "param": "param_23" + "param": "param_35" } }, { @@ -1381,12 +1381,12 @@ }, { "filter": { - "param": "param_23" + "param": "param_35" } }, { "filter": { - "param": "param_23" + "param": "param_35" } }, { @@ -1470,7 +1470,7 @@ "condition": { "test": { "not": { - "param": "param_24" + "param": "param_36" } }, "value": "#3A3A3A" @@ -1576,12 +1576,12 @@ }, { "filter": { - "param": "param_23" + "param": "param_35" } }, { "filter": { - "param": "param_23" + "param": "param_35" } }, { @@ -1650,7 +1650,7 @@ "condition": { "test": { "not": { - "param": "param_24" + "param": "param_36" } }, "value": "#3A3A3A" @@ -1701,7 +1701,7 @@ "size": 30, "type": "bar" }, - "name": "view_45", + "name": "view_65", "transform": [ { "aggregate": [ @@ -1751,12 +1751,12 @@ }, { "filter": { - "param": "param_23" + "param": "param_35" } }, { "filter": { - "param": "param_23" + "param": "param_35" } }, { diff --git a/tests/__snapshots__/test_covid_mutations/test_covid_mutations_subset[image].png b/tests/__snapshots__/test_covid_mutations/test_covid_mutations_subset[image].png index d60a8ab..bb184dc 100644 Binary files a/tests/__snapshots__/test_covid_mutations/test_covid_mutations_subset[image].png and b/tests/__snapshots__/test_covid_mutations/test_covid_mutations_subset[image].png differ diff --git a/tests/__snapshots__/test_covid_mutations/test_covid_mutations_subset[vega_spec].json b/tests/__snapshots__/test_covid_mutations/test_covid_mutations_subset[vega_spec].json index 3b0d69f..f099f73 100644 --- a/tests/__snapshots__/test_covid_mutations/test_covid_mutations_subset[vega_spec].json +++ b/tests/__snapshots__/test_covid_mutations/test_covid_mutations_subset[vega_spec].json @@ -36,7 +36,7 @@ "params": [ { "bind": "legend", - "name": "param_25", + "name": "param_37", "select": { "fields": [ "set" @@ -44,14 +44,14 @@ "type": "point" }, "views": [ - "view_49", - "view_50", - "view_51", - "view_52" + "view_69", + "view_70", + "view_71", + "view_72" ] }, { - "name": "param_26", + "name": "param_38", "select": { "fields": [ "intersection_id" @@ -60,8 +60,8 @@ "type": "point" }, "views": [ - "view_49", - "view_50" + "view_69", + "view_70" ] } ], @@ -133,7 +133,7 @@ "size": 20, "type": "bar" }, - "name": "view_52", + "name": "view_72", "transform": [ { "aggregate": [ @@ -176,12 +176,12 @@ }, { "filter": { - "param": "param_25" + "param": "param_37" } }, { "filter": { - "param": "param_25" + "param": "param_37" } }, { @@ -253,7 +253,7 @@ "condition": { "test": { "not": { - "param": "param_26" + "param": "param_38" } }, "value": "#3A3A3A" @@ -304,7 +304,7 @@ "size": 100, "type": "circle" }, - "name": "view_50", + "name": "view_70", "transform": [ { "aggregate": [ @@ -347,12 +347,12 @@ }, { "filter": { - "param": "param_25" + "param": "param_37" } }, { "filter": { - "param": "param_25" + "param": "param_37" } }, { @@ -421,7 +421,7 @@ "condition": { "test": { "not": { - "param": "param_26" + "param": "param_38" } }, "value": "#3A3A3A" @@ -514,12 +514,12 @@ }, { "filter": { - "param": "param_25" + "param": "param_37" } }, { "filter": { - "param": "param_25" + "param": "param_37" } }, { @@ -588,7 +588,7 @@ "condition": { "test": { "not": { - "param": "param_26" + "param": "param_38" } }, "value": "#3A3A3A" @@ -679,12 +679,12 @@ }, { "filter": { - "param": "param_25" + "param": "param_37" } }, { "filter": { - "param": "param_25" + "param": "param_37" } }, { @@ -835,12 +835,12 @@ }, { "filter": { - "param": "param_25" + "param": "param_37" } }, { "filter": { - "param": "param_25" + "param": "param_37" } }, { @@ -992,12 +992,12 @@ }, { "filter": { - "param": "param_25" + "param": "param_37" } }, { "filter": { - "param": "param_25" + "param": "param_37" } }, { @@ -1108,7 +1108,7 @@ "size": 500, "type": "circle" }, - "name": "view_51", + "name": "view_71", "transform": [ { "aggregate": [ @@ -1148,12 +1148,12 @@ }, { "filter": { - "param": "param_25" + "param": "param_37" } }, { "filter": { - "param": "param_25" + "param": "param_37" } }, { @@ -1283,12 +1283,12 @@ }, { "filter": { - "param": "param_25" + "param": "param_37" } }, { "filter": { - "param": "param_25" + "param": "param_37" } }, { @@ -1372,7 +1372,7 @@ "condition": { "test": { "not": { - "param": "param_26" + "param": "param_38" } }, "value": "#3A3A3A" @@ -1468,12 +1468,12 @@ }, { "filter": { - "param": "param_25" + "param": "param_37" } }, { "filter": { - "param": "param_25" + "param": "param_37" } }, { @@ -1542,7 +1542,7 @@ "condition": { "test": { "not": { - "param": "param_26" + "param": "param_38" } }, "value": "#3A3A3A" @@ -1593,7 +1593,7 @@ "size": 30, "type": "bar" }, - "name": "view_49", + "name": "view_69", "transform": [ { "aggregate": [ @@ -1633,12 +1633,12 @@ }, { "filter": { - "param": "param_25" + "param": "param_37" } }, { "filter": { - "param": "param_25" + "param": "param_37" } }, { diff --git a/tests/__snapshots__/test_covid_symptoms/test_upset_by_degree[image].png b/tests/__snapshots__/test_covid_symptoms/test_upset_by_degree[image].png index 2abb26d..c22ab98 100644 Binary files a/tests/__snapshots__/test_covid_symptoms/test_upset_by_degree[image].png and b/tests/__snapshots__/test_covid_symptoms/test_upset_by_degree[image].png differ diff --git a/tests/__snapshots__/test_covid_symptoms/test_upset_by_degree[vega_spec].json b/tests/__snapshots__/test_covid_symptoms/test_upset_by_degree[vega_spec].json index e70a7d4..cb3e2e0 100644 --- a/tests/__snapshots__/test_covid_symptoms/test_upset_by_degree[vega_spec].json +++ b/tests/__snapshots__/test_covid_symptoms/test_upset_by_degree[vega_spec].json @@ -36,7 +36,7 @@ "params": [ { "bind": "legend", - "name": "param_29", + "name": "param_41", "select": { "fields": [ "set" @@ -44,14 +44,14 @@ "type": "point" }, "views": [ - "view_57", - "view_58", - "view_59", - "view_60" + "view_77", + "view_78", + "view_79", + "view_80" ] }, { - "name": "param_30", + "name": "param_42", "select": { "fields": [ "intersection_id" @@ -60,8 +60,8 @@ "type": "point" }, "views": [ - "view_57", - "view_58" + "view_77", + "view_78" ] } ], @@ -134,7 +134,7 @@ "size": 20, "type": "bar" }, - "name": "view_60", + "name": "view_80", "transform": [ { "aggregate": [ @@ -179,12 +179,12 @@ }, { "filter": { - "param": "param_29" + "param": "param_41" } }, { "filter": { - "param": "param_29" + "param": "param_41" } }, { @@ -256,7 +256,7 @@ "condition": { "test": { "not": { - "param": "param_30" + "param": "param_42" } }, "value": "#3A3A3A" @@ -307,7 +307,7 @@ "size": 100, "type": "circle" }, - "name": "view_58", + "name": "view_78", "transform": [ { "aggregate": [ @@ -352,12 +352,12 @@ }, { "filter": { - "param": "param_29" + "param": "param_41" } }, { "filter": { - "param": "param_29" + "param": "param_41" } }, { @@ -426,7 +426,7 @@ "condition": { "test": { "not": { - "param": "param_30" + "param": "param_42" } }, "value": "#3A3A3A" @@ -521,12 +521,12 @@ }, { "filter": { - "param": "param_29" + "param": "param_41" } }, { "filter": { - "param": "param_29" + "param": "param_41" } }, { @@ -595,7 +595,7 @@ "condition": { "test": { "not": { - "param": "param_30" + "param": "param_42" } }, "value": "#3A3A3A" @@ -688,12 +688,12 @@ }, { "filter": { - "param": "param_29" + "param": "param_41" } }, { "filter": { - "param": "param_29" + "param": "param_41" } }, { @@ -846,12 +846,12 @@ }, { "filter": { - "param": "param_29" + "param": "param_41" } }, { "filter": { - "param": "param_29" + "param": "param_41" } }, { @@ -1005,12 +1005,12 @@ }, { "filter": { - "param": "param_29" + "param": "param_41" } }, { "filter": { - "param": "param_29" + "param": "param_41" } }, { @@ -1122,7 +1122,7 @@ "size": 500, "type": "circle" }, - "name": "view_59", + "name": "view_79", "transform": [ { "aggregate": [ @@ -1164,12 +1164,12 @@ }, { "filter": { - "param": "param_29" + "param": "param_41" } }, { "filter": { - "param": "param_29" + "param": "param_41" } }, { @@ -1301,12 +1301,12 @@ }, { "filter": { - "param": "param_29" + "param": "param_41" } }, { "filter": { - "param": "param_29" + "param": "param_41" } }, { @@ -1390,7 +1390,7 @@ "condition": { "test": { "not": { - "param": "param_30" + "param": "param_42" } }, "value": "#3A3A3A" @@ -1488,12 +1488,12 @@ }, { "filter": { - "param": "param_29" + "param": "param_41" } }, { "filter": { - "param": "param_29" + "param": "param_41" } }, { @@ -1562,7 +1562,7 @@ "condition": { "test": { "not": { - "param": "param_30" + "param": "param_42" } }, "value": "#3A3A3A" @@ -1613,7 +1613,7 @@ "size": 26.875, "type": "bar" }, - "name": "view_57", + "name": "view_77", "transform": [ { "aggregate": [ @@ -1655,12 +1655,12 @@ }, { "filter": { - "param": "param_29" + "param": "param_41" } }, { "filter": { - "param": "param_29" + "param": "param_41" } }, { diff --git a/tests/__snapshots__/test_covid_symptoms/test_upset_by_degree_custom[image].png b/tests/__snapshots__/test_covid_symptoms/test_upset_by_degree_custom[image].png index 5e06653..360f674 100644 Binary files a/tests/__snapshots__/test_covid_symptoms/test_upset_by_degree_custom[image].png and b/tests/__snapshots__/test_covid_symptoms/test_upset_by_degree_custom[image].png differ diff --git a/tests/__snapshots__/test_covid_symptoms/test_upset_by_degree_custom[vega_spec].json b/tests/__snapshots__/test_covid_symptoms/test_upset_by_degree_custom[vega_spec].json index 546223b..c578f00 100644 --- a/tests/__snapshots__/test_covid_symptoms/test_upset_by_degree_custom[vega_spec].json +++ b/tests/__snapshots__/test_covid_symptoms/test_upset_by_degree_custom[vega_spec].json @@ -36,7 +36,7 @@ "params": [ { "bind": "legend", - "name": "param_31", + "name": "param_43", "select": { "fields": [ "set" @@ -44,14 +44,14 @@ "type": "point" }, "views": [ - "view_61", - "view_62", - "view_63", - "view_64" + "view_81", + "view_82", + "view_83", + "view_84" ] }, { - "name": "param_32", + "name": "param_44", "select": { "fields": [ "intersection_id" @@ -60,8 +60,8 @@ "type": "point" }, "views": [ - "view_61", - "view_62" + "view_81", + "view_82" ] } ], @@ -134,7 +134,7 @@ "size": 16, "type": "bar" }, - "name": "view_64", + "name": "view_84", "transform": [ { "aggregate": [ @@ -179,12 +179,12 @@ }, { "filter": { - "param": "param_31" + "param": "param_43" } }, { "filter": { - "param": "param_31" + "param": "param_43" } }, { @@ -256,7 +256,7 @@ "condition": { "test": { "not": { - "param": "param_32" + "param": "param_44" } }, "value": "#3A3A3A" @@ -307,7 +307,7 @@ "size": 100, "type": "circle" }, - "name": "view_62", + "name": "view_82", "transform": [ { "aggregate": [ @@ -352,12 +352,12 @@ }, { "filter": { - "param": "param_31" + "param": "param_43" } }, { "filter": { - "param": "param_31" + "param": "param_43" } }, { @@ -426,7 +426,7 @@ "condition": { "test": { "not": { - "param": "param_32" + "param": "param_44" } }, "value": "#3A3A3A" @@ -521,12 +521,12 @@ }, { "filter": { - "param": "param_31" + "param": "param_43" } }, { "filter": { - "param": "param_31" + "param": "param_43" } }, { @@ -595,7 +595,7 @@ "condition": { "test": { "not": { - "param": "param_32" + "param": "param_44" } }, "value": "#3A3A3A" @@ -688,12 +688,12 @@ }, { "filter": { - "param": "param_31" + "param": "param_43" } }, { "filter": { - "param": "param_31" + "param": "param_43" } }, { @@ -846,12 +846,12 @@ }, { "filter": { - "param": "param_31" + "param": "param_43" } }, { "filter": { - "param": "param_31" + "param": "param_43" } }, { @@ -1005,12 +1005,12 @@ }, { "filter": { - "param": "param_31" + "param": "param_43" } }, { "filter": { - "param": "param_31" + "param": "param_43" } }, { @@ -1122,7 +1122,7 @@ "size": 650, "type": "circle" }, - "name": "view_63", + "name": "view_83", "transform": [ { "aggregate": [ @@ -1164,12 +1164,12 @@ }, { "filter": { - "param": "param_31" + "param": "param_43" } }, { "filter": { - "param": "param_31" + "param": "param_43" } }, { @@ -1301,12 +1301,12 @@ }, { "filter": { - "param": "param_31" + "param": "param_43" } }, { "filter": { - "param": "param_31" + "param": "param_43" } }, { @@ -1390,7 +1390,7 @@ "condition": { "test": { "not": { - "param": "param_32" + "param": "param_44" } }, "value": "#3A3A3A" @@ -1488,12 +1488,12 @@ }, { "filter": { - "param": "param_31" + "param": "param_43" } }, { "filter": { - "param": "param_31" + "param": "param_43" } }, { @@ -1562,7 +1562,7 @@ "condition": { "test": { "not": { - "param": "param_32" + "param": "param_44" } }, "value": "#3A3A3A" @@ -1613,7 +1613,7 @@ "size": 16.875, "type": "bar" }, - "name": "view_61", + "name": "view_81", "transform": [ { "aggregate": [ @@ -1655,12 +1655,12 @@ }, { "filter": { - "param": "param_31" + "param": "param_43" } }, { "filter": { - "param": "param_31" + "param": "param_43" } }, { diff --git a/tests/__snapshots__/test_covid_symptoms/test_upset_by_frequency[image].png b/tests/__snapshots__/test_covid_symptoms/test_upset_by_frequency[image].png index d686487..a837716 100644 Binary files a/tests/__snapshots__/test_covid_symptoms/test_upset_by_frequency[image].png and b/tests/__snapshots__/test_covid_symptoms/test_upset_by_frequency[image].png differ diff --git a/tests/__snapshots__/test_covid_symptoms/test_upset_by_frequency[vega_spec].json b/tests/__snapshots__/test_covid_symptoms/test_upset_by_frequency[vega_spec].json index 976dc31..46b88d1 100644 --- a/tests/__snapshots__/test_covid_symptoms/test_upset_by_frequency[vega_spec].json +++ b/tests/__snapshots__/test_covid_symptoms/test_upset_by_frequency[vega_spec].json @@ -36,7 +36,7 @@ "params": [ { "bind": "legend", - "name": "param_27", + "name": "param_39", "select": { "fields": [ "set" @@ -44,14 +44,14 @@ "type": "point" }, "views": [ - "view_53", - "view_54", - "view_55", - "view_56" + "view_73", + "view_74", + "view_75", + "view_76" ] }, { - "name": "param_28", + "name": "param_40", "select": { "fields": [ "intersection_id" @@ -60,8 +60,8 @@ "type": "point" }, "views": [ - "view_53", - "view_54" + "view_73", + "view_74" ] } ], @@ -134,7 +134,7 @@ "size": 20, "type": "bar" }, - "name": "view_56", + "name": "view_76", "transform": [ { "aggregate": [ @@ -179,12 +179,12 @@ }, { "filter": { - "param": "param_27" + "param": "param_39" } }, { "filter": { - "param": "param_27" + "param": "param_39" } }, { @@ -256,7 +256,7 @@ "condition": { "test": { "not": { - "param": "param_28" + "param": "param_40" } }, "value": "#3A3A3A" @@ -307,7 +307,7 @@ "size": 100, "type": "circle" }, - "name": "view_54", + "name": "view_74", "transform": [ { "aggregate": [ @@ -352,12 +352,12 @@ }, { "filter": { - "param": "param_27" + "param": "param_39" } }, { "filter": { - "param": "param_27" + "param": "param_39" } }, { @@ -426,7 +426,7 @@ "condition": { "test": { "not": { - "param": "param_28" + "param": "param_40" } }, "value": "#3A3A3A" @@ -521,12 +521,12 @@ }, { "filter": { - "param": "param_27" + "param": "param_39" } }, { "filter": { - "param": "param_27" + "param": "param_39" } }, { @@ -595,7 +595,7 @@ "condition": { "test": { "not": { - "param": "param_28" + "param": "param_40" } }, "value": "#3A3A3A" @@ -688,12 +688,12 @@ }, { "filter": { - "param": "param_27" + "param": "param_39" } }, { "filter": { - "param": "param_27" + "param": "param_39" } }, { @@ -846,12 +846,12 @@ }, { "filter": { - "param": "param_27" + "param": "param_39" } }, { "filter": { - "param": "param_27" + "param": "param_39" } }, { @@ -1005,12 +1005,12 @@ }, { "filter": { - "param": "param_27" + "param": "param_39" } }, { "filter": { - "param": "param_27" + "param": "param_39" } }, { @@ -1122,7 +1122,7 @@ "size": 500, "type": "circle" }, - "name": "view_55", + "name": "view_75", "transform": [ { "aggregate": [ @@ -1164,12 +1164,12 @@ }, { "filter": { - "param": "param_27" + "param": "param_39" } }, { "filter": { - "param": "param_27" + "param": "param_39" } }, { @@ -1301,12 +1301,12 @@ }, { "filter": { - "param": "param_27" + "param": "param_39" } }, { "filter": { - "param": "param_27" + "param": "param_39" } }, { @@ -1390,7 +1390,7 @@ "condition": { "test": { "not": { - "param": "param_28" + "param": "param_40" } }, "value": "#3A3A3A" @@ -1488,12 +1488,12 @@ }, { "filter": { - "param": "param_27" + "param": "param_39" } }, { "filter": { - "param": "param_27" + "param": "param_39" } }, { @@ -1562,7 +1562,7 @@ "condition": { "test": { "not": { - "param": "param_28" + "param": "param_40" } }, "value": "#3A3A3A" @@ -1613,7 +1613,7 @@ "size": 26.875, "type": "bar" }, - "name": "view_53", + "name": "view_73", "transform": [ { "aggregate": [ @@ -1655,12 +1655,12 @@ }, { "filter": { - "param": "param_27" + "param": "param_39" } }, { "filter": { - "param": "param_27" + "param": "param_39" } }, { diff --git a/tests/test_advanced_features.py b/tests/test_advanced_features.py index fdb20a8..186d68a 100644 --- a/tests/test_advanced_features.py +++ b/tests/test_advanced_features.py @@ -35,6 +35,31 @@ def basic_chart(sample_data): return au.UpSetAltair(data=sample_data, sets=["A", "B", "C"], title="Test Chart") +def _get_color_selection_param(chart): + """Helper to extract the color selection parameter from a chart. + + Parameters + ---------- + chart : UpSetChart + The chart to extract the parameter from + + Returns + ------- + dict or None + The color selection parameter, or None if not found + """ + spec = chart.chart.to_dict() + params = spec.get("params", []) + + return next( + (p for p in params + if "select" in p and "fields" in p["select"] + and "intersection_id" in p["select"]["fields"] + and "value" in p), + None + ) + + def test_basic_chart_structure(basic_chart): """Test that the basic chart has all required components.""" # The chart should be a VConcatChart (vertical concatenation) @@ -210,3 +235,199 @@ def test_vertical_bar_y_axis_orient_invalid_value(sample_data): au.UpSetAltair( data=sample_data, sets=["A", "B", "C"], vertical_bar_y_axis_orient="bottom" ) + + +def test_highlight_least(sample_data): + """Test highlighting the intersection with the smallest size.""" + chart = au.UpSetAltair(data=sample_data, sets=["A", "B", "C"], highlight="least") + + # Calculate the expected result independently from the processed data + processed_data = chart.data + intersection_counts = ( + processed_data.groupby("intersection_id")["count"] + .first() + .reset_index() + ) + expected_min_id = intersection_counts.loc[ + intersection_counts["count"].idxmin(), "intersection_id" + ] + + # Extract actual highlighted intersection from chart spec + color_param = _get_color_selection_param(chart) + assert color_param is not None, "No selection parameter with intersection_id found" + + # Verify the correct intersection is highlighted + actual_ids = [v["intersection_id"] for v in color_param["value"]] + assert len(actual_ids) == 1, ( + f"Expected 1 highlighted intersection, got {len(actual_ids)}" + ) + assert actual_ids[0] == expected_min_id, ( + f"Expected intersection {expected_min_id} (smallest), " + f"but got {actual_ids[0]}" + ) + + # Verify it's actually the minimum count + min_count = intersection_counts.loc[ + intersection_counts["intersection_id"] == expected_min_id, "count" + ].values[0] + assert min_count == intersection_counts["count"].min(), ( + "Highlighted intersection doesn't have the minimum count" + ) + + +def test_highlight_greatest(sample_data): + """Test highlighting the intersection with the largest size.""" + chart = au.UpSetAltair(data=sample_data, sets=["A", "B", "C"], highlight="greatest") + + # Calculate the expected result independently from the processed data + processed_data = chart.data + intersection_counts = ( + processed_data.groupby("intersection_id")["count"] + .first() + .reset_index() + ) + expected_max_id = intersection_counts.loc[ + intersection_counts["count"].idxmax(), "intersection_id" + ] + + # Extract actual highlighted intersection from chart spec + color_param = _get_color_selection_param(chart) + assert color_param is not None, "No selection parameter with intersection_id found" + + # Verify the correct intersection is highlighted + actual_ids = [v["intersection_id"] for v in color_param["value"]] + assert len(actual_ids) == 1, ( + f"Expected 1 highlighted intersection, got {len(actual_ids)}" + ) + assert actual_ids[0] == expected_max_id, ( + f"Expected intersection {expected_max_id} (largest), " + f"but got {actual_ids[0]}" + ) + + # Verify it's actually the maximum count + max_count = intersection_counts.loc[ + intersection_counts["intersection_id"] == expected_max_id, "count" + ].values[0] + assert max_count == intersection_counts["count"].max(), ( + "Highlighted intersection doesn't have the maximum count" + ) + + +def test_highlight_specific_index(sample_data): + """Test highlighting a specific intersection by index.""" + chart = au.UpSetAltair(data=sample_data, sets=["A", "B", "C"], highlight=0) + + # Calculate the expected result - index 0 should be the first intersection + processed_data = chart.data + intersections = ( + processed_data.groupby("intersection_id")["count"] + .first() + .reset_index() + .sort_index() + ) + expected_id = intersections.iloc[0]["intersection_id"] + + # Extract actual highlighted intersection from chart spec + color_param = _get_color_selection_param(chart) + assert color_param is not None, "No selection parameter with intersection_id found" + + # Verify the correct intersection is highlighted + actual_ids = [v["intersection_id"] for v in color_param["value"]] + assert len(actual_ids) == 1, ( + f"Expected 1 highlighted intersection, got {len(actual_ids)}" + ) + assert actual_ids[0] == expected_id, ( + f"Expected intersection {expected_id} at index 0, " + f"but got {actual_ids[0]}" + ) + + +def test_highlight_multiple_indices(sample_data): + """Test highlighting multiple intersections by indices.""" + chart = au.UpSetAltair(data=sample_data, sets=["A", "B", "C"], highlight=[0, 1, 2]) + + # Calculate the expected results - indices 0, 1, 2 + processed_data = chart.data + intersections = ( + processed_data.groupby("intersection_id")["count"] + .first() + .reset_index() + .sort_index() + ) + expected_ids = [ + intersections.iloc[0]["intersection_id"], + intersections.iloc[1]["intersection_id"], + intersections.iloc[2]["intersection_id"] + ] + + # Extract actual highlighted intersections from chart spec + color_param = _get_color_selection_param(chart) + assert color_param is not None, "No selection parameter with intersection_id found" + + # Verify the correct intersections are highlighted + actual_ids = [v["intersection_id"] for v in color_param["value"]] + assert len(actual_ids) == 3, ( + f"Expected 3 highlighted intersections, got {len(actual_ids)}" + ) + assert set(actual_ids) == set(expected_ids), ( + f"Expected intersections {expected_ids}, but got {actual_ids}" + ) + + +def test_highlight_none_default_hover(sample_data): + """Test that None (default) enables hover behavior.""" + chart = au.UpSetAltair(data=sample_data, sets=["A", "B", "C"], highlight=None) + + # Get the chart spec and check for mouseover selection + spec = chart.chart.to_dict() + params = spec.get("params", []) + assert len(params) > 0 + + # Check that at least one selection has "on" set to "mouseover" + has_mouseover = any( + "select" in p and "on" in p["select"] and p["select"]["on"] == "mouseover" + for p in params + ) + assert has_mouseover, "Expected to find a selection parameter with mouseover" + + +def test_highlight_invalid_string(sample_data): + """Test that invalid string values for highlight raise ValueError.""" + with pytest.raises( + ValueError, match="highlight string must be 'least' or 'greatest'" + ): + au.UpSetAltair(data=sample_data, sets=["A", "B", "C"], highlight="invalid") + + +def test_highlight_negative_index(sample_data): + """Test that negative indices for highlight raise ValueError.""" + with pytest.raises(ValueError, match="highlight index must be non-negative"): + au.UpSetAltair(data=sample_data, sets=["A", "B", "C"], highlight=-1) + + +def test_highlight_invalid_list(sample_data): + """Test that invalid list values for highlight raise ValueError.""" + with pytest.raises( + ValueError, match="highlight list must contain non-negative integers" + ): + au.UpSetAltair(data=sample_data, sets=["A", "B", "C"], highlight=[0, -1, 2]) + + +def test_highlight_invalid_type(sample_data): + """Test that invalid types for highlight raise TypeError.""" + with pytest.raises( + TypeError, match="highlight must be None, str, int, or list of int" + ): + au.UpSetAltair(data=sample_data, sets=["A", "B", "C"], highlight=1.5) + + +def test_highlight_index_out_of_bounds(sample_data): + """Test that out of bounds single index raises IndexError.""" + with pytest.raises(IndexError, match="highlight index .* is out of bounds"): + au.UpSetAltair(data=sample_data, sets=["A", "B", "C"], highlight=999) + + +def test_highlight_list_indices_out_of_bounds(sample_data): + """Test that out of bounds indices in list raise ValueError.""" + with pytest.raises(ValueError, match="highlight indices .* are out of bounds"): + au.UpSetAltair(data=sample_data, sets=["A", "B", "C"], highlight=[0, 1, 999])