Skip to content
105 changes: 105 additions & 0 deletions docs/examples/plot_types/11_topic_ribbon.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""
Top-aligned ribbon flow
=======================

Fixed-row ribbon flows for category transitions across adjacent periods.

Why UltraPlot here?
-------------------
This is a distinct flow layout from Sankey: topic rows are fixed globally and
flows are stacked from each row top, so vertical position is semantically stable.

Key function: :py:meth:`ultraplot.axes.PlotAxes.ribbon`.

See also
--------
* :doc:`2D plot types </2dplots>`
* :doc:`Layered Sankey diagram <07_sankey>`
"""

import numpy as np
import pandas as pd

import ultraplot as uplt

GROUP_COLORS = {
"Group A": "#2E7D32",
"Group B": "#6A1B9A",
"Group C": "#5D4037",
"Group D": "#0277BD",
"Group E": "#F57C00",
"Group F": "#C62828",
"Group G": "#D84315",
}

TOPIC_TO_GROUP = {
"Topic 01": "Group A",
"Topic 02": "Group A",
"Topic 03": "Group B",
"Topic 04": "Group B",
"Topic 05": "Group C",
"Topic 06": "Group C",
"Topic 07": "Group D",
"Topic 08": "Group D",
"Topic 09": "Group E",
"Topic 10": "Group E",
"Topic 11": "Group F",
"Topic 12": "Group F",
"Topic 13": "Group G",
"Topic 14": "Group G",
}


def build_assignments():
"""Synthetic entity-category assignments by period."""
state = np.random.RandomState(51423)
countries = [f"Entity {i:02d}" for i in range(1, 41)]
periods = ["1990-1999", "2000-2009", "2010-2019", "2020-2029"]
topics = list(TOPIC_TO_GROUP.keys())

rows = []
for country in countries:
topic = state.choice(topics)
rows.append((country, periods[0], topic))
for period in periods[1:]:
if state.rand() < 0.68:
next_topic = topic
else:
group = TOPIC_TO_GROUP[topic]
same_group = [
t for t in topics if TOPIC_TO_GROUP[t] == group and t != topic
]
next_topic = state.choice(
same_group if same_group and state.rand() < 0.6 else topics
)
topic = next_topic
rows.append((country, period, topic))
return pd.DataFrame(rows, columns=["country", "period", "topic"]), periods


df, periods = build_assignments()

group_order = list(GROUP_COLORS)
topic_order = []
for group in group_order:
topic_order.extend(sorted([t for t, g in TOPIC_TO_GROUP.items() if g == group]))

fig, axs = uplt.subplots(nrows=2, hratios=(3.0, 0.8), refwidth=6.3, share=False)
axs[0].ribbon(
df,
id_col="country",
period_col="period",
topic_col="topic",
period_order=periods,
topic_order=topic_order,
group_map=TOPIC_TO_GROUP,
group_order=group_order,
group_colors=GROUP_COLORS,
composition=True,
composition_ax=axs[1],
composition_ylabel="Assigned topics",
)

axs[0].format(title="Category transitions with fixed top-aligned rows")
fig.format(suptitle="Top-aligned ribbon flow by period")
fig.show()
110 changes: 110 additions & 0 deletions ultraplot/axes/plot.py
Original file line number Diff line number Diff line change
Expand Up @@ -2305,6 +2305,116 @@ def _looks_like_links(values):
diagrams = sankey.finish()
return diagrams[0] if len(diagrams) == 1 else diagrams

@docstring._snippet_manager
def ribbon(
self,
data: Any,
*,
id_col: str = "id",
period_col: str = "period",
topic_col: str = "topic",
value_col: str | None = None,
period_order: Sequence[Any] | None = None,
topic_order: Sequence[Any] | None = None,
group_map: Mapping[Any, Any] | None = None,
group_order: Sequence[Any] | None = None,
group_colors: Mapping[Any, Any] | None = None,
xmargin: float = 0.12,
ymargin: float = 0.08,
row_height_ratio: float = 2.2,
node_width: float = 0.018,
flow_curvature: float = 0.45,
flow_alpha: float = 0.58,
show_topic_labels: bool = True,
topic_label_offset: float = 0.028,
topic_label_size: float = 7.4,
topic_label_box: bool = True,
composition_ax: Any | None = None,
composition: bool = False,
composition_alpha: float = 0.86,
composition_ylabel: str = "Assigned topics",
) -> dict[str, Any]:
"""
Draw a fixed-row, top-aligned ribbon flow diagram from long-form records.

Parameters
----------
data : pandas.DataFrame or mapping-like
Long-form records with entity id, period, and topic columns.
id_col, period_col, topic_col : str, optional
Column names for entity id, period, and topic.
value_col : str, optional
Optional weight column. If omitted, each record is weighted as 1.
period_order, topic_order : sequence, optional
Explicit ordering for periods and topic rows.
group_map : mapping, optional
Topic-to-group mapping used for grouped ordering and colors.
group_order : sequence, optional
Group ordering for row arrangement and composition stacking.
group_colors : mapping, optional
Group-to-color mapping. Missing groups use the patch color cycle.
xmargin, ymargin : float, optional
Plot-space margins in normalized axes coordinates.
row_height_ratio : float, optional
Scale factor controlling row occupancy by nodes/flows.
node_width : float, optional
Node column width in normalized axes coordinates.
flow_curvature : float, optional
Bezier curvature for ribbons.
flow_alpha : float, optional
Ribbon alpha.
show_topic_labels : bool, optional
Whether to draw topic labels on the right.
topic_label_offset : float, optional
Offset for right-side topic labels.
topic_label_size : float, optional
Topic label font size.
topic_label_box : bool, optional
Whether to draw white backing boxes behind topic labels.
composition_ax : `~ultraplot.axes.Axes`, optional
Optional secondary axes for a stacked group composition panel.
composition : bool, optional
Whether to draw composition stackplot on `composition_ax`.
composition_alpha : float, optional
Alpha for composition stack areas.
composition_ylabel : str, optional
Y label for composition panel.

Returns
-------
dict
Mapping of created artists and resolved orders.
"""
from .plot_types.ribbon import ribbon_diagram

return ribbon_diagram(
self,
data,
id_col=id_col,
period_col=period_col,
topic_col=topic_col,
value_col=value_col,
period_order=period_order,
topic_order=topic_order,
group_map=group_map,
group_order=group_order,
group_colors=group_colors,
xmargin=xmargin,
ymargin=ymargin,
row_height_ratio=row_height_ratio,
node_width=node_width,
flow_curvature=flow_curvature,
flow_alpha=flow_alpha,
show_topic_labels=show_topic_labels,
topic_label_offset=topic_label_offset,
topic_label_size=topic_label_size,
topic_label_box=topic_label_box,
composition_ax=composition_ax,
composition=composition,
composition_alpha=composition_alpha,
composition_ylabel=composition_ylabel,
)

def circos(
self,
sectors: Mapping[str, Any],
Expand Down
Loading
Loading