Skip to content

Commit 2a53e0f

Browse files
committed
feat: add vertical upset plot orientation with horizontal cardinality
Implemented a new 'orientation' parameter for UpSet plots that allows switching between horizontal and vertical layouts: - Horizontal (default): cardinality bars on top (vertical), set sizes on right (horizontal) - Vertical (new): cardinality bars on left (horizontal), set sizes on top (vertical) Changes: - Added 'orientation' parameter to UpSetAltair() function with validation - Updated create_vertical_bar() to support both orientations by swapping X/Y axes - Updated create_matrix_view() to swap axes for vertical orientation - Updated create_horizontal_bar() to support vertical layout with Y-axis for set sizes - Refactored layout logic to use hconcat for vertical orientation - Added comprehensive tests for vertical orientation - Updated test normalization to handle auto-generated param and view names All existing tests pass, and the feature is fully backward compatible.
1 parent d02f8a1 commit 2a53e0f

14 files changed

Lines changed: 516 additions & 274 deletions

altair_upset/components.py

Lines changed: 173 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -12,27 +12,53 @@ def create_vertical_bar(
1212
tooltip,
1313
vertical_bar_label_size,
1414
vertical_bar_y_axis_orient,
15+
orientation="horizontal",
1516
):
16-
"""Creates the vertical bar chart component."""
17-
vertical_bar = base.mark_bar(color=main_color, size=vertical_bar_size).encode(
18-
x=alt.X(
19-
"intersection_id:N",
20-
axis=alt.Axis(grid=False, labels=False, ticks=False, domain=True),
21-
sort=x_sort,
22-
title=None,
23-
),
24-
y=alt.Y(
25-
"max(count):Q",
26-
axis=alt.Axis(grid=False, tickCount=3, orient=vertical_bar_y_axis_orient),
27-
title="Intersection Size",
28-
),
29-
color=brush_color,
30-
tooltip=tooltip,
31-
)
32-
33-
vertical_bar_text = vertical_bar.mark_text(
34-
color=main_color, dy=-10, size=vertical_bar_label_size
35-
).encode(text=alt.Text("count:Q", format=".0f"))
17+
"""Creates the vertical bar chart component.
18+
19+
In horizontal orientation: bars go up (Y-axis shows cardinality)
20+
In vertical orientation: bars go right (X-axis shows cardinality)
21+
"""
22+
if orientation == "horizontal":
23+
vertical_bar = base.mark_bar(color=main_color, size=vertical_bar_size).encode(
24+
x=alt.X(
25+
"intersection_id:N",
26+
axis=alt.Axis(grid=False, labels=False, ticks=False, domain=True),
27+
sort=x_sort,
28+
title=None,
29+
),
30+
y=alt.Y(
31+
"max(count):Q",
32+
axis=alt.Axis(grid=False, tickCount=3, orient=vertical_bar_y_axis_orient),
33+
title="Intersection Size",
34+
),
35+
color=brush_color,
36+
tooltip=tooltip,
37+
)
38+
39+
vertical_bar_text = vertical_bar.mark_text(
40+
color=main_color, dy=-10, size=vertical_bar_label_size
41+
).encode(text=alt.Text("count:Q", format=".0f"))
42+
else: # vertical orientation - swap X and Y
43+
vertical_bar = base.mark_bar(color=main_color, size=vertical_bar_size).encode(
44+
y=alt.Y(
45+
"intersection_id:N",
46+
axis=alt.Axis(grid=False, labels=False, ticks=False, domain=True),
47+
sort=x_sort,
48+
title=None,
49+
),
50+
x=alt.X(
51+
"max(count):Q",
52+
axis=alt.Axis(grid=False, tickCount=3, orient=vertical_bar_y_axis_orient),
53+
title="Intersection Size",
54+
),
55+
color=brush_color,
56+
tooltip=tooltip,
57+
)
58+
59+
vertical_bar_text = vertical_bar.mark_text(
60+
color=main_color, dx=10, size=vertical_bar_label_size
61+
).encode(text=alt.Text("count:Q", format=".0f"))
3662

3763
return vertical_bar, vertical_bar_text
3864

@@ -45,38 +71,75 @@ def create_matrix_view(
4571
brush_color,
4672
line_connection_size,
4773
main_color,
74+
orientation="horizontal",
4875
):
49-
"""Creates the matrix view component."""
50-
circle_bg = vertical_bar.mark_circle(size=glyph_size, opacity=1).encode(
51-
x=alt.X(
52-
"intersection_id:N",
53-
axis=alt.Axis(grid=False, labels=False, ticks=False, domain=False),
54-
sort=x_sort,
55-
title=None,
56-
),
57-
y=alt.Y(
58-
"set_order:N",
59-
axis=alt.Axis(grid=False, labels=False, ticks=False, domain=False),
60-
title=None,
61-
),
62-
color=alt.value("#E6E6E6"),
63-
)
64-
65-
rect_bg = (
66-
circle_bg.mark_rect()
67-
.transform_filter(alt.datum["set_order"] % 2 == 1)
68-
.encode(color=alt.value("#F7F7F7"))
69-
)
70-
71-
circle = circle_bg.transform_filter(alt.datum["is_intersect"] == 1).encode(
72-
color=brush_color
73-
)
74-
75-
line_connection = (
76-
vertical_bar.mark_bar(size=line_connection_size, color=main_color)
77-
.transform_filter(alt.datum["is_intersect"] == 1)
78-
.encode(y=alt.Y("min(set_order):N"), y2=alt.Y2("max(set_order):N"))
79-
)
76+
"""Creates the matrix view component.
77+
78+
In horizontal orientation: X = intersection_id, Y = set_order
79+
In vertical orientation: X = set_order, Y = intersection_id
80+
"""
81+
if orientation == "horizontal":
82+
circle_bg = vertical_bar.mark_circle(size=glyph_size, opacity=1).encode(
83+
x=alt.X(
84+
"intersection_id:N",
85+
axis=alt.Axis(grid=False, labels=False, ticks=False, domain=False),
86+
sort=x_sort,
87+
title=None,
88+
),
89+
y=alt.Y(
90+
"set_order:N",
91+
axis=alt.Axis(grid=False, labels=False, ticks=False, domain=False),
92+
title=None,
93+
),
94+
color=alt.value("#E6E6E6"),
95+
)
96+
97+
rect_bg = (
98+
circle_bg.mark_rect()
99+
.transform_filter(alt.datum["set_order"] % 2 == 1)
100+
.encode(color=alt.value("#F7F7F7"))
101+
)
102+
103+
circle = circle_bg.transform_filter(alt.datum["is_intersect"] == 1).encode(
104+
color=brush_color
105+
)
106+
107+
line_connection = (
108+
vertical_bar.mark_bar(size=line_connection_size, color=main_color)
109+
.transform_filter(alt.datum["is_intersect"] == 1)
110+
.encode(y=alt.Y("min(set_order):N"), y2=alt.Y2("max(set_order):N"))
111+
)
112+
else: # vertical orientation - swap X and Y
113+
circle_bg = vertical_bar.mark_circle(size=glyph_size, opacity=1).encode(
114+
y=alt.Y(
115+
"intersection_id:N",
116+
axis=alt.Axis(grid=False, labels=False, ticks=False, domain=False),
117+
sort=x_sort,
118+
title=None,
119+
),
120+
x=alt.X(
121+
"set_order:N",
122+
axis=alt.Axis(grid=False, labels=False, ticks=False, domain=False),
123+
title=None,
124+
),
125+
color=alt.value("#E6E6E6"),
126+
)
127+
128+
rect_bg = (
129+
circle_bg.mark_rect()
130+
.transform_filter(alt.datum["set_order"] % 2 == 1)
131+
.encode(color=alt.value("#F7F7F7"))
132+
)
133+
134+
circle = circle_bg.transform_filter(alt.datum["is_intersect"] == 1).encode(
135+
color=brush_color
136+
)
137+
138+
line_connection = (
139+
vertical_bar.mark_bar(size=line_connection_size, color=main_color)
140+
.transform_filter(alt.datum["is_intersect"] == 1)
141+
.encode(x=alt.X("min(set_order):N"), x2=alt.X2("max(set_order):N"))
142+
)
80143

81144
return circle_bg, rect_bg, circle, line_connection
82145

@@ -90,34 +153,68 @@ def create_horizontal_bar(
90153
horizontal_bar_label_bg_color,
91154
horizontal_bar_size,
92155
horizontal_bar_chart_width,
156+
orientation="horizontal",
93157
):
94-
"""Creates the horizontal bar chart component."""
95-
horizontal_bar_label_bg = base.mark_circle(size=set_label_bg_size).encode(
96-
y=alt.Y(
97-
"set_order:N",
98-
axis=alt.Axis(grid=False, labels=False, ticks=False, domain=False),
99-
title=None,
100-
),
101-
color=alt.Color(
102-
"set:N", scale=alt.Scale(domain=sets, range=color_range), title=None
103-
),
104-
opacity=alt.value(1),
105-
)
106-
107-
horizontal_bar_label = horizontal_bar_label_bg.mark_text(
108-
align=("center" if is_show_horizontal_bar_label_bg else "center")
109-
).encode(
110-
text=alt.Text("set_abbre:N"), color=alt.value(horizontal_bar_label_bg_color)
111-
)
112-
113-
horizontal_bar = (
114-
horizontal_bar_label_bg.mark_bar(size=horizontal_bar_size)
115-
.transform_filter(alt.datum["is_intersect"] == 1)
116-
.encode(
158+
"""Creates the horizontal bar chart component.
159+
160+
In horizontal orientation: Y = set_order, X = sum(count) (bars go right)
161+
In vertical orientation: X = set_order, Y = sum(count) (bars go up)
162+
"""
163+
if orientation == "horizontal":
164+
horizontal_bar_label_bg = base.mark_circle(size=set_label_bg_size).encode(
165+
y=alt.Y(
166+
"set_order:N",
167+
axis=alt.Axis(grid=False, labels=False, ticks=False, domain=False),
168+
title=None,
169+
),
170+
color=alt.Color(
171+
"set:N", scale=alt.Scale(domain=sets, range=color_range), title=None
172+
),
173+
opacity=alt.value(1),
174+
)
175+
176+
horizontal_bar_label = horizontal_bar_label_bg.mark_text(
177+
align=("center" if is_show_horizontal_bar_label_bg else "center")
178+
).encode(
179+
text=alt.Text("set_abbre:N"), color=alt.value(horizontal_bar_label_bg_color)
180+
)
181+
182+
horizontal_bar = (
183+
horizontal_bar_label_bg.mark_bar(size=horizontal_bar_size)
184+
.transform_filter(alt.datum["is_intersect"] == 1)
185+
.encode(
186+
x=alt.X(
187+
"sum(count):Q", axis=alt.Axis(grid=False, tickCount=3), title="Set Size"
188+
)
189+
)
190+
)
191+
else: # vertical orientation - swap X and Y
192+
horizontal_bar_label_bg = base.mark_circle(size=set_label_bg_size).encode(
117193
x=alt.X(
118-
"sum(count):Q", axis=alt.Axis(grid=False, tickCount=3), title="Set Size"
194+
"set_order:N",
195+
axis=alt.Axis(grid=False, labels=False, ticks=False, domain=False),
196+
title=None,
197+
),
198+
color=alt.Color(
199+
"set:N", scale=alt.Scale(domain=sets, range=color_range), title=None
200+
),
201+
opacity=alt.value(1),
202+
)
203+
204+
horizontal_bar_label = horizontal_bar_label_bg.mark_text(
205+
align=("center" if is_show_horizontal_bar_label_bg else "center")
206+
).encode(
207+
text=alt.Text("set_abbre:N"), color=alt.value(horizontal_bar_label_bg_color)
208+
)
209+
210+
horizontal_bar = (
211+
horizontal_bar_label_bg.mark_bar(size=horizontal_bar_size)
212+
.transform_filter(alt.datum["is_intersect"] == 1)
213+
.encode(
214+
y=alt.Y(
215+
"sum(count):Q", axis=alt.Axis(grid=False, tickCount=3), title="Set Size"
216+
)
119217
)
120218
)
121-
)
122219

123220
return horizontal_bar_label_bg, horizontal_bar_label, horizontal_bar

altair_upset/upset.py

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ def UpSetAltair(
7171
abbre: Optional[List[str]] = None,
7272
sort_by: str = "frequency",
7373
sort_order: str = "ascending",
74+
orientation: str = "horizontal",
7475
width: int = 1200,
7576
height: int = 700,
7677
height_ratio: float = 0.6,
@@ -119,6 +120,10 @@ def UpSetAltair(
119120
- "degree": sort by number of sets in intersection
120121
sort_order : {"ascending", "descending"}, default "ascending"
121122
Order of sorting for intersections.
123+
orientation : {"horizontal", "vertical"}, default "horizontal"
124+
Orientation of the plot:
125+
- "horizontal": cardinality bars are vertical (top), set sizes are horizontal (right)
126+
- "vertical": cardinality bars are horizontal (left), set sizes are vertical (top)
122127
width : int, default 1200
123128
Total width of the plot in pixels.
124129
height : int, default 700
@@ -200,6 +205,8 @@ def UpSetAltair(
200205
raise ValueError("if provided, abbre must have the same length as sets")
201206
if vertical_bar_y_axis_orient not in ["left", "right"]:
202207
raise ValueError("vertical bar y axis orient must be 'left' or 'right'")
208+
if orientation not in ["horizontal", "vertical"]:
209+
raise ValueError("orientation must be either 'horizontal' or 'vertical'")
203210

204211
# Apply theme if specified
205212
if theme is not None:
@@ -261,6 +268,7 @@ def UpSetAltair(
261268
tooltip,
262269
vertical_bar_label_size,
263270
vertical_bar_y_axis_orient,
271+
orientation,
264272
)
265273
vertical_bar_chart = (
266274
(vertical_bar + vertical_bar_text)
@@ -276,6 +284,7 @@ def UpSetAltair(
276284
brush_color,
277285
line_connection_size,
278286
main_color,
287+
orientation,
279288
)
280289
matrix_view = (
281290
(circle + rect_bg + circle_bg + line_connection + circle)
@@ -293,25 +302,43 @@ def UpSetAltair(
293302
horizontal_bar_label_bg_color,
294303
horizontal_bar_size,
295304
horizontal_bar_chart_width,
305+
orientation,
296306
)
297307
)
298308
horizontal_bar_axis = (
299309
(horizontal_bar_label_bg + horizontal_bar_label)
300310
if is_show_horizontal_bar_label_bg
301311
else horizontal_bar_label
302-
).properties(width=horizontal_bar_chart_width)
303-
304-
# Combine components
305-
upsetaltair = alt.vconcat(
306-
vertical_bar_chart,
307-
alt.hconcat(
308-
matrix_view,
309-
horizontal_bar_axis,
310-
horizontal_bar.properties(width=horizontal_bar_chart_width),
311-
spacing=0, # Minimize spacing between components
312-
).resolve_scale(x="shared", y="shared"), # X shared also
313-
spacing=5,
314-
).add_params(legend_selection)
312+
).properties(width=horizontal_bar_chart_width if orientation == "horizontal" else matrix_width)
313+
314+
# Combine components based on orientation
315+
if orientation == "horizontal":
316+
# Horizontal layout: cardinality bars on top, set sizes on right
317+
upsetaltair = alt.vconcat(
318+
vertical_bar_chart,
319+
alt.hconcat(
320+
matrix_view,
321+
horizontal_bar_axis,
322+
horizontal_bar.properties(width=horizontal_bar_chart_width),
323+
spacing=0,
324+
).resolve_scale(x="shared", y="shared"),
325+
spacing=5,
326+
).add_params(legend_selection)
327+
else:
328+
# Vertical layout: cardinality bars on left, set sizes on top
329+
upsetaltair = alt.hconcat(
330+
vertical_bar_chart.properties(width=vertical_bar_chart_height, height=matrix_height),
331+
alt.vconcat(
332+
alt.hconcat(
333+
horizontal_bar_axis,
334+
horizontal_bar.properties(width=matrix_width, height=horizontal_bar_chart_width),
335+
spacing=0,
336+
).resolve_scale(x="shared", y="shared"),
337+
matrix_view.properties(height=matrix_height),
338+
spacing=5,
339+
),
340+
spacing=5,
341+
).add_params(legend_selection)
315342

316343
# Apply configuration
317344
chart = upsetaltair_top_level_configuration(
-1.81 KB
Loading

0 commit comments

Comments
 (0)