Skip to content

Commit bd9ff0b

Browse files
Feature: Add top-aligned ribbon flow plot type (#559)
Add a top aligned ribbon graph. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 1041ac9 commit bd9ff0b

6 files changed

Lines changed: 651 additions & 0 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""
2+
Top-aligned ribbon flow
3+
=======================
4+
5+
Fixed-row ribbon flows for category transitions across adjacent periods.
6+
7+
Why UltraPlot here?
8+
-------------------
9+
This is a distinct flow layout from Sankey: topic rows are fixed globally and
10+
flows are stacked from each row top, so vertical position is semantically stable.
11+
12+
Key function: :py:meth:`ultraplot.axes.PlotAxes.ribbon`.
13+
14+
See also
15+
--------
16+
* :doc:`2D plot types </2dplots>`
17+
* :doc:`Layered Sankey diagram <07_sankey>`
18+
"""
19+
20+
import numpy as np
21+
import pandas as pd
22+
23+
import ultraplot as uplt
24+
25+
GROUP_COLORS = {
26+
"Group A": "#2E7D32",
27+
"Group B": "#6A1B9A",
28+
"Group C": "#5D4037",
29+
"Group D": "#0277BD",
30+
"Group E": "#F57C00",
31+
"Group F": "#C62828",
32+
"Group G": "#D84315",
33+
}
34+
35+
TOPIC_TO_GROUP = {
36+
"Topic 01": "Group A",
37+
"Topic 02": "Group A",
38+
"Topic 03": "Group B",
39+
"Topic 04": "Group B",
40+
"Topic 05": "Group C",
41+
"Topic 06": "Group C",
42+
"Topic 07": "Group D",
43+
"Topic 08": "Group D",
44+
"Topic 09": "Group E",
45+
"Topic 10": "Group E",
46+
"Topic 11": "Group F",
47+
"Topic 12": "Group F",
48+
"Topic 13": "Group G",
49+
"Topic 14": "Group G",
50+
}
51+
52+
53+
def build_assignments():
54+
"""Synthetic entity-category assignments by period."""
55+
state = np.random.RandomState(51423)
56+
countries = [f"Entity {i:02d}" for i in range(1, 41)]
57+
periods = ["1990-1999", "2000-2009", "2010-2019", "2020-2029"]
58+
topics = list(TOPIC_TO_GROUP.keys())
59+
60+
rows = []
61+
for country in countries:
62+
topic = state.choice(topics)
63+
rows.append((country, periods[0], topic))
64+
for period in periods[1:]:
65+
if state.rand() < 0.68:
66+
next_topic = topic
67+
else:
68+
group = TOPIC_TO_GROUP[topic]
69+
same_group = [
70+
t for t in topics if TOPIC_TO_GROUP[t] == group and t != topic
71+
]
72+
next_topic = state.choice(
73+
same_group if same_group and state.rand() < 0.6 else topics
74+
)
75+
topic = next_topic
76+
rows.append((country, period, topic))
77+
return pd.DataFrame(rows, columns=["country", "period", "topic"]), periods
78+
79+
80+
df, periods = build_assignments()
81+
82+
group_order = list(GROUP_COLORS)
83+
topic_order = []
84+
for group in group_order:
85+
topic_order.extend(sorted([t for t, g in TOPIC_TO_GROUP.items() if g == group]))
86+
87+
fig, ax = uplt.subplots(refwidth=6.3)
88+
ax.ribbon(
89+
df,
90+
id_col="country",
91+
period_col="period",
92+
topic_col="topic",
93+
period_order=periods,
94+
topic_order=topic_order,
95+
group_map=TOPIC_TO_GROUP,
96+
group_order=group_order,
97+
group_colors=GROUP_COLORS,
98+
)
99+
100+
ax.format(title="Category transitions with fixed top-aligned rows")
101+
fig.format(suptitle="Top-aligned ribbon flow by period")
102+
fig.show()

ultraplot/axes/plot.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2305,6 +2305,113 @@ def _looks_like_links(values):
23052305
diagrams = sankey.finish()
23062306
return diagrams[0] if len(diagrams) == 1 else diagrams
23072307

2308+
@docstring._snippet_manager
2309+
def ribbon(
2310+
self,
2311+
data: Any,
2312+
*,
2313+
id_col: str = "id",
2314+
period_col: str = "period",
2315+
topic_col: str = "topic",
2316+
value_col: str | None = None,
2317+
period_order: Sequence[Any] | None = None,
2318+
topic_order: Sequence[Any] | None = None,
2319+
group_map: Mapping[Any, Any] | None = None,
2320+
group_order: Sequence[Any] | None = None,
2321+
group_colors: Mapping[Any, Any] | None = None,
2322+
xmargin: Optional[float] = None,
2323+
ymargin: Optional[float] = None,
2324+
row_height_ratio: Optional[float] = None,
2325+
node_width: Optional[float] = None,
2326+
flow_curvature: Optional[float] = None,
2327+
flow_alpha: Optional[float] = None,
2328+
show_topic_labels: Optional[bool] = None,
2329+
topic_label_offset: Optional[float] = None,
2330+
topic_label_size: Optional[float] = None,
2331+
topic_label_box: Optional[bool] = None,
2332+
) -> dict[str, Any]:
2333+
"""
2334+
Draw a fixed-row, top-aligned ribbon flow diagram from long-form records.
2335+
2336+
Parameters
2337+
----------
2338+
data : pandas.DataFrame or mapping-like
2339+
Long-form records with entity id, period, and topic columns.
2340+
id_col, period_col, topic_col : str, optional
2341+
Column names for entity id, period, and topic.
2342+
value_col : str, optional
2343+
Optional weight column. If omitted, each record is weighted as 1.
2344+
period_order, topic_order : sequence, optional
2345+
Explicit ordering for periods and topic rows.
2346+
group_map : mapping, optional
2347+
Topic-to-group mapping used for grouped ordering and colors.
2348+
group_order : sequence, optional
2349+
Group ordering for row arrangement.
2350+
group_colors : mapping, optional
2351+
Group-to-color mapping. Missing groups use the patch color cycle.
2352+
xmargin, ymargin : float, optional
2353+
Plot-space margins in normalized axes coordinates.
2354+
row_height_ratio : float, optional
2355+
Scale factor controlling row occupancy by nodes/flows.
2356+
node_width : float, optional
2357+
Node column width in normalized axes coordinates.
2358+
flow_curvature : float, optional
2359+
Bezier curvature for ribbons.
2360+
flow_alpha : float, optional
2361+
Ribbon alpha.
2362+
show_topic_labels : bool, optional
2363+
Whether to draw topic labels on the right.
2364+
topic_label_offset : float, optional
2365+
Offset for right-side topic labels.
2366+
topic_label_size : float, optional
2367+
Topic label font size.
2368+
topic_label_box : bool, optional
2369+
Whether to draw white backing boxes behind topic labels.
2370+
2371+
Returns
2372+
-------
2373+
dict
2374+
Mapping of created artists and resolved orders.
2375+
"""
2376+
from .plot_types.ribbon import ribbon_diagram
2377+
2378+
xmargin = _not_none(xmargin, rc["ribbon.xmargin"])
2379+
ymargin = _not_none(ymargin, rc["ribbon.ymargin"])
2380+
row_height_ratio = _not_none(row_height_ratio, rc["ribbon.rowheightratio"])
2381+
node_width = _not_none(node_width, rc["ribbon.nodewidth"])
2382+
flow_curvature = _not_none(flow_curvature, rc["ribbon.flow.curvature"])
2383+
flow_alpha = _not_none(flow_alpha, rc["ribbon.flow.alpha"])
2384+
show_topic_labels = _not_none(show_topic_labels, rc["ribbon.topic_labels"])
2385+
topic_label_offset = _not_none(
2386+
topic_label_offset, rc["ribbon.topic_label_offset"]
2387+
)
2388+
topic_label_size = _not_none(topic_label_size, rc["ribbon.topic_label_size"])
2389+
topic_label_box = _not_none(topic_label_box, rc["ribbon.topic_label_box"])
2390+
2391+
return ribbon_diagram(
2392+
self,
2393+
data,
2394+
id_col=id_col,
2395+
period_col=period_col,
2396+
topic_col=topic_col,
2397+
value_col=value_col,
2398+
period_order=period_order,
2399+
topic_order=topic_order,
2400+
group_map=group_map,
2401+
group_order=group_order,
2402+
group_colors=group_colors,
2403+
xmargin=xmargin,
2404+
ymargin=ymargin,
2405+
row_height_ratio=row_height_ratio,
2406+
node_width=node_width,
2407+
flow_curvature=flow_curvature,
2408+
flow_alpha=flow_alpha,
2409+
show_topic_labels=show_topic_labels,
2410+
topic_label_offset=topic_label_offset,
2411+
topic_label_size=topic_label_size,
2412+
topic_label_box=topic_label_box,
2413+
)
2414+
23082415
def circos(
23092416
self,
23102417
sectors: Mapping[str, Any],

0 commit comments

Comments
 (0)