From d88b77095a0e27b540586ce81663d3f17f1ba13e Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 6 Feb 2026 19:28:55 +1000 Subject: [PATCH 01/10] Add top-aligned ribbon flow plot type and example --- docs/examples/plot_types/11_topic_ribbon.py | 102 ++++++ ultraplot/axes/plot.py | 110 ++++++ ultraplot/axes/plot_types/ribbon.py | 359 ++++++++++++++++++++ ultraplot/tests/test_plot.py | 35 ++ 4 files changed, 606 insertions(+) create mode 100644 docs/examples/plot_types/11_topic_ribbon.py create mode 100644 ultraplot/axes/plot_types/ribbon.py diff --git a/docs/examples/plot_types/11_topic_ribbon.py b/docs/examples/plot_types/11_topic_ribbon.py new file mode 100644 index 000000000..4806b6313 --- /dev/null +++ b/docs/examples/plot_types/11_topic_ribbon.py @@ -0,0 +1,102 @@ +""" +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 ` +* :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() diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 1eefe9ce9..9750cc99f 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -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], diff --git a/ultraplot/axes/plot_types/ribbon.py b/ultraplot/axes/plot_types/ribbon.py new file mode 100644 index 000000000..dcbb92543 --- /dev/null +++ b/ultraplot/axes/plot_types/ribbon.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 +""" +Top-aligned ribbon flow diagram helper. +""" + +from __future__ import annotations + +from collections import Counter, defaultdict +from collections.abc import Mapping, Sequence +from typing import Any + +import numpy as np +import pandas as pd +from matplotlib import patches as mpatches +from matplotlib import path as mpath + + +def _ribbon_path( + x0: float, + y0: float, + x1: float, + y1: float, + thickness: float, + curvature: float, +) -> mpath.Path: + dx = max(x1 - x0, 1e-6) + cx0 = x0 + dx * curvature + cx1 = x1 - dx * curvature + top0 = y0 + thickness / 2 + bot0 = y0 - thickness / 2 + top1 = y1 + thickness / 2 + bot1 = y1 - thickness / 2 + verts = [ + (x0, top0), + (cx0, top0), + (cx1, top1), + (x1, top1), + (x1, bot1), + (cx1, bot1), + (cx0, bot0), + (x0, bot0), + (x0, top0), + ] + codes = [ + mpath.Path.MOVETO, + mpath.Path.CURVE4, + mpath.Path.CURVE4, + mpath.Path.CURVE4, + mpath.Path.LINETO, + mpath.Path.CURVE4, + mpath.Path.CURVE4, + mpath.Path.CURVE4, + mpath.Path.CLOSEPOLY, + ] + return mpath.Path(verts, codes) + + +def ribbon_diagram( + ax: Any, + data: Any, + *, + id_col: str, + period_col: str, + topic_col: str, + 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, + ymargin: float, + row_height_ratio: float, + node_width: float, + flow_curvature: float, + flow_alpha: float, + show_topic_labels: bool, + topic_label_offset: float, + topic_label_size: float, + topic_label_box: bool, + composition_ax: Any | None, + composition: bool, + composition_alpha: float, + composition_ylabel: str, +) -> dict[str, Any]: + """ + Build a fixed-row, top-aligned ribbon flow diagram from long-form assignments. + """ + if isinstance(data, pd.DataFrame): + df = data.copy() + else: + df = pd.DataFrame(data) + required = {id_col, period_col, topic_col} + missing = required - set(df.columns) + if missing: + raise KeyError(f"Missing required columns: {sorted(missing)}") + if value_col is not None and value_col not in df.columns: + raise KeyError(f"Invalid value_col={value_col!r}. Column not found.") + if df.empty: + raise ValueError("Input data is empty.") + + if period_order is None: + periods = list(pd.unique(df[period_col])) + else: + periods = list(period_order) + df = df[df[period_col].isin(periods)] + if len(periods) < 2: + raise ValueError("Need at least two periods for ribbon transitions.") + period_idx = {period: i for i, period in enumerate(periods)} + + if value_col is None: + df["value_internal"] = 1.0 + else: + df["value_internal"] = pd.to_numeric(df[value_col], errors="coerce").fillna(0.0) + df = df[df["value_internal"] > 0] + if df.empty: + raise ValueError("No positive values remain after parsing value column.") + + if topic_order is None: + topic_counts_all = ( + df.groupby(topic_col)["value_internal"].sum().sort_values(ascending=False) + ) + topics = list(topic_counts_all.index) + else: + topics = [topic for topic in topic_order if topic in set(df[topic_col])] + if not topics: + raise ValueError("No topics available after filtering.") + + if group_map is None: + group_map = {topic: topic for topic in topics} + else: + group_map = dict(group_map) + for topic in topics: + group_map.setdefault(topic, topic) + + if group_order is None: + groups = list(dict.fromkeys(group_map[topic] for topic in topics)) + else: + groups = list(group_order) + + # Group topics by group, then keep topic ordering inside groups. + grouped_topics = defaultdict(list) + for topic in topics: + grouped_topics[group_map[topic]].append(topic) + ordered_topics = [] + for group in groups: + ordered_topics.extend(grouped_topics.get(group, [])) + # Append any groups not listed in group_order. + for group, topic_list in grouped_topics.items(): + if group not in groups: + ordered_topics.extend(topic_list) + groups.append(group) + topics = ordered_topics + + cycle = ax._get_patches_for_fill + if group_colors is None: + group_colors = {group: cycle.get_next_color() for group in groups} + else: + group_colors = dict(group_colors) + for group in groups: + group_colors.setdefault(group, cycle.get_next_color()) + topic_colors = {topic: group_colors[group_map[topic]] for topic in topics} + + counts = ( + df.groupby([period_col, topic_col])["value_internal"] + .sum() + .rename("count") + .reset_index() + ) + counts = counts[counts[period_col].isin(periods) & counts[topic_col].isin(topics)] + + # Build consecutive transitions by entity. + transitions = Counter() + for _, group in df.groupby(id_col): + group = group[group[period_col].isin(periods)].copy() + if group.empty: + continue + # If multiple topics for same entity-period, keep strongest assignment. + group = ( + group.sort_values("value_internal", ascending=False) + .drop_duplicates(subset=[period_col], keep="first") + .assign(_pidx=lambda d: d[period_col].map(period_idx)) + .sort_values("_pidx") + ) + rows = list(group.itertuples(index=False)) + for i in range(len(rows) - 1): + curr = rows[i] + nxt = rows[i + 1] + p0 = getattr(curr, period_col) + p1 = getattr(nxt, period_col) + if period_idx[p1] != period_idx[p0] + 1: + continue + t0 = getattr(curr, topic_col) + t1 = getattr(nxt, topic_col) + v = min( + float(getattr(curr, "value_internal")), + float(getattr(nxt, "value_internal")), + ) + if v > 0 and t0 in topics and t1 in topics: + transitions[(p0, t0, p1, t1)] += v + + row_gap = (1.0 - 2 * ymargin) / max(1, len(topics)) + topic_row_top = {topic: 1.0 - ymargin - i * row_gap for i, topic in enumerate(topics)} + topic_label_y = {topic: topic_row_top[topic] - 0.5 * row_gap for topic in topics} + row_height = row_gap * row_height_ratio + + xvals = np.linspace(xmargin, 1.0 - xmargin, len(periods)) + period_x = {period: xvals[i] for i, period in enumerate(periods)} + + max_count = max(float(counts["count"].max()) if not counts.empty else 0.0, 1.0) + node_scale = row_height * 0.85 / max_count + + node_patches = [] + node_geom: dict[tuple[Any, Any], tuple[float, float]] = {} + for row in counts.itertuples(index=False): + period = getattr(row, period_col) + topic = getattr(row, topic_col) + count = float(getattr(row, "count")) + if period not in period_x or topic not in topic_row_top: + continue + height = count * node_scale + x = period_x[period] + y_center = topic_row_top[topic] - height / 2 + node_geom[(period, topic)] = (y_center, height) + patch = mpatches.FancyBboxPatch( + (x - node_width / 2, y_center - height / 2), + node_width, + height, + boxstyle="round,pad=0.0,rounding_size=0.006", + facecolor=topic_colors[topic], + edgecolor="none", + alpha=0.95, + zorder=3, + ) + ax.add_patch(patch) + node_patches.append(patch) + + by_pair = defaultdict(list) + for (p0, t0, p1, t1), value in transitions.items(): + by_pair[(p0, p1)].append((t0, t1, value)) + + flow_patches = [] + for (p0, p1), flows in by_pair.items(): + x0 = period_x[p0] + x1 = period_x[p1] + src_total = defaultdict(float) + tgt_total = defaultdict(float) + for t0, t1, value in flows: + src_total[t0] += value + tgt_total[t1] += value + max_total = max(src_total.values()) if src_total else 1.0 + scale = row_height * 0.75 / max_total + + src_off = {} + for topic, total in src_total.items(): + center, height = node_geom.get((p0, topic), (topic_label_y[topic], total * scale)) + top = center + height / 2 + src_off[topic] = top - total * scale + tgt_off = {} + for topic, total in tgt_total.items(): + center, height = node_geom.get((p1, topic), (topic_label_y[topic], total * scale)) + top = center + height / 2 + tgt_off[topic] = top - total * scale + + ordered_flows = sorted(flows, key=lambda item: (topics.index(item[0]), topics.index(item[1]))) + src_mid = {} + tgt_mid = {} + for t0, t1, value in ordered_flows: + thickness = value * scale + src_mid[(t0, t1)] = (src_off[t0] + thickness / 2, thickness) + src_off[t0] += thickness + for t1, t0, value in sorted( + [(f[1], f[0], f[2]) for f in ordered_flows], + key=lambda item: (topics.index(item[0]), topics.index(item[1])), + ): + thickness = value * scale + tgt_mid[(t0, t1)] = (tgt_off[t1] + thickness / 2, thickness) + tgt_off[t1] += thickness + + for t0, t1, _ in ordered_flows: + y0, thickness = src_mid[(t0, t1)] + y1, _ = tgt_mid[(t0, t1)] + if thickness <= 0: + continue + path = _ribbon_path(x0, y0, x1, y1, thickness, flow_curvature) + patch = mpatches.PathPatch( + path, + facecolor=topic_colors[t0], + edgecolor="none", + alpha=flow_alpha, + zorder=1, + ) + ax.add_patch(patch) + flow_patches.append(patch) + + topic_text = [] + if show_topic_labels: + right_period = periods[-1] + for topic in topics: + text = ax.text( + period_x[right_period] + topic_label_offset, + topic_label_y[topic], + str(topic), + ha="left", + va="center", + fontsize=topic_label_size, + color=topic_colors[topic], + bbox=( + dict(facecolor="white", edgecolor="none", alpha=0.75, pad=0.25) + if topic_label_box + else None + ), + ) + topic_text.append(text) + + period_text = [] + for period in periods: + text = ax.text( + period_x[period], + 1.0 - ymargin / 2, + str(period), + ha="center", + va="bottom", + fontsize=max(topic_label_size + 1, 8), + ) + period_text.append(text) + + if composition and composition_ax is not None: + frame = df[df[period_col].isin(periods) & df[topic_col].isin(topics)].copy() + frame["_group"] = frame[topic_col].map(group_map) + composition_counts = ( + frame.groupby([period_col, "_group"])["value_internal"] + .sum() + .unstack(fill_value=0) + ) + group_cols = [group for group in groups if group in composition_counts.columns] + composition_counts = composition_counts.reindex(index=periods, columns=group_cols) + xpos = np.arange(len(periods)) + composition_ax.stackplot( + xpos, + [composition_counts[col].to_numpy() for col in group_cols], + colors=[group_colors[col] for col in group_cols], + alpha=composition_alpha, + ) + composition_ax.set_xticks(xpos) + composition_ax.set_xticklabels([str(period) for period in periods], rotation=15, ha="right") + composition_ax.format(ylabel=composition_ylabel, xlabel="Period", grid=True, gridalpha=0.3) + + ax.format(xlim=(0, 1), ylim=(0, 1), grid=False) + ax.axis("off") + return { + "node_patches": node_patches, + "flow_patches": flow_patches, + "topic_text": topic_text, + "period_text": period_text, + "periods": periods, + "topics": topics, + "groups": groups, + } diff --git a/ultraplot/tests/test_plot.py b/ultraplot/tests/test_plot.py index 6cafa1373..2c5e51e5e 100644 --- a/ultraplot/tests/test_plot.py +++ b/ultraplot/tests/test_plot.py @@ -1014,6 +1014,41 @@ def test_sankey_label_box_default(): assert resolved["facecolor"] == "white" +def test_ribbon_smoke(): + """Smoke test for top-aligned ribbon flow diagrams.""" + import pandas as pd + + records = [ + ("E1", "P1", "T1"), + ("E1", "P2", "T2"), + ("E1", "P3", "T2"), + ("E2", "P1", "T1"), + ("E2", "P2", "T1"), + ("E2", "P3", "T3"), + ("E3", "P1", "T2"), + ("E3", "P2", "T2"), + ("E3", "P3", "T3"), + ] + data = pd.DataFrame(records, columns=["id", "period", "topic"]) + + fig, axs = uplt.subplots(nrows=2, hratios=(2, 1), share=False) + artists = axs[0].ribbon( + data, + id_col="id", + period_col="period", + topic_col="topic", + period_order=["P1", "P2", "P3"], + topic_order=["T1", "T2", "T3"], + group_map={"T1": "G1", "T2": "G1", "T3": "G2"}, + group_order=["G1", "G2"], + composition=True, + composition_ax=axs[1], + ) + assert artists["node_patches"] + assert artists["flow_patches"] + uplt.close(fig) + + def test_sankey_assign_flow_colors_group_cycle(): """Group cycle should be used for flow colors.""" from ultraplot.axes.plot_types import sankey as sankey_mod From fa34444386a5edbbf4715785083c6ce9f942331b Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 6 Feb 2026 19:34:03 +1000 Subject: [PATCH 02/10] Black formatting --- ultraplot/axes/plot_types/ribbon.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/ultraplot/axes/plot_types/ribbon.py b/ultraplot/axes/plot_types/ribbon.py index dcbb92543..ededb4389 100644 --- a/ultraplot/axes/plot_types/ribbon.py +++ b/ultraplot/axes/plot_types/ribbon.py @@ -200,7 +200,9 @@ def ribbon_diagram( transitions[(p0, t0, p1, t1)] += v row_gap = (1.0 - 2 * ymargin) / max(1, len(topics)) - topic_row_top = {topic: 1.0 - ymargin - i * row_gap for i, topic in enumerate(topics)} + topic_row_top = { + topic: 1.0 - ymargin - i * row_gap for i, topic in enumerate(topics) + } topic_label_y = {topic: topic_row_top[topic] - 0.5 * row_gap for topic in topics} row_height = row_gap * row_height_ratio @@ -253,16 +255,22 @@ def ribbon_diagram( src_off = {} for topic, total in src_total.items(): - center, height = node_geom.get((p0, topic), (topic_label_y[topic], total * scale)) + center, height = node_geom.get( + (p0, topic), (topic_label_y[topic], total * scale) + ) top = center + height / 2 src_off[topic] = top - total * scale tgt_off = {} for topic, total in tgt_total.items(): - center, height = node_geom.get((p1, topic), (topic_label_y[topic], total * scale)) + center, height = node_geom.get( + (p1, topic), (topic_label_y[topic], total * scale) + ) top = center + height / 2 tgt_off[topic] = top - total * scale - ordered_flows = sorted(flows, key=lambda item: (topics.index(item[0]), topics.index(item[1]))) + ordered_flows = sorted( + flows, key=lambda item: (topics.index(item[0]), topics.index(item[1])) + ) src_mid = {} tgt_mid = {} for t0, t1, value in ordered_flows: @@ -334,7 +342,9 @@ def ribbon_diagram( .unstack(fill_value=0) ) group_cols = [group for group in groups if group in composition_counts.columns] - composition_counts = composition_counts.reindex(index=periods, columns=group_cols) + composition_counts = composition_counts.reindex( + index=periods, columns=group_cols + ) xpos = np.arange(len(periods)) composition_ax.stackplot( xpos, @@ -343,8 +353,12 @@ def ribbon_diagram( alpha=composition_alpha, ) composition_ax.set_xticks(xpos) - composition_ax.set_xticklabels([str(period) for period in periods], rotation=15, ha="right") - composition_ax.format(ylabel=composition_ylabel, xlabel="Period", grid=True, gridalpha=0.3) + composition_ax.set_xticklabels( + [str(period) for period in periods], rotation=15, ha="right" + ) + composition_ax.format( + ylabel=composition_ylabel, xlabel="Period", grid=True, gridalpha=0.3 + ) ax.format(xlim=(0, 1), ylim=(0, 1), grid=False) ax.axis("off") From 39ef0fb110a71afbe5d1183b87ac13c387f02265 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Sat, 7 Feb 2026 09:01:50 +1000 Subject: [PATCH 03/10] CI: fix workflow run block for nodeid parser --- .github/workflows/build-ultraplot.yml | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index b1d71002c..0dd47e5c8 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -135,22 +135,7 @@ jobs: echo "TEST_NODEIDS=${TEST_NODEIDS}" # Save PR-selected nodeids for reuse after checkout (if provided) if [ "${TEST_MODE}" = "selected" ] && [ -n "${TEST_NODEIDS}" ]; then - python -c 'import json, os -raw = os.environ.get("TEST_NODEIDS", "").strip() -nodeids = [] -if raw and raw != "[]": - try: - parsed = json.loads(raw) - except json.JSONDecodeError: - parsed = raw.split() - if isinstance(parsed, str): - parsed = [parsed] - if isinstance(parsed, list): - nodeids = [item for item in parsed if isinstance(item, str) and item] -with open("/tmp/pr_selected_nodeids.txt", "w", encoding="utf-8") as fh: - for nodeid in nodeids: - fh.write(f"{nodeid}\n") -print(f"Selected nodeids parsed: {len(nodeids)}")' + python -c 'import json,os; raw=os.environ.get("TEST_NODEIDS","").strip(); parsed=json.loads(raw) if raw and raw!="[]" else []; parsed=[parsed] if isinstance(parsed,str) else parsed; nodeids=[item for item in parsed if isinstance(item,str) and item]; open("/tmp/pr_selected_nodeids.txt","w",encoding="utf-8").write("".join(f"{nodeid}\n" for nodeid in nodeids)); print(f"Selected nodeids parsed: {len(nodeids)}")' else : > /tmp/pr_selected_nodeids.txt fi From 9eb89c087862d528fb0fc694c143ba5665ff9d3b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 01:02:38 +0000 Subject: [PATCH 04/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/examples/plot_types/11_topic_ribbon.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/examples/plot_types/11_topic_ribbon.py b/docs/examples/plot_types/11_topic_ribbon.py index 4806b6313..25d0bd814 100644 --- a/docs/examples/plot_types/11_topic_ribbon.py +++ b/docs/examples/plot_types/11_topic_ribbon.py @@ -22,7 +22,6 @@ import ultraplot as uplt - GROUP_COLORS = { "Group A": "#2E7D32", "Group B": "#6A1B9A", @@ -67,8 +66,12 @@ def build_assignments(): 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) + 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 From b9af1f72e7945e36e8919d1ffc071e7033276e87 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 10 Feb 2026 04:29:50 +1000 Subject: [PATCH 05/10] Refresh baseline cache key for hash-seed-stable compares (cherry picked from commit 1ff58bee82dd0c97a5ceaa56d5a663c567742d34) --- .github/workflows/build-ultraplot.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-ultraplot.yml b/.github/workflows/build-ultraplot.yml index ffb86121c..cd81f92d2 100644 --- a/.github/workflows/build-ultraplot.yml +++ b/.github/workflows/build-ultraplot.yml @@ -121,9 +121,9 @@ jobs: with: path: ./ultraplot/tests/baseline # The directory to cache # Key is based on OS, Python/Matplotlib versions, and the base commit SHA - key: ${{ runner.os }}-baseline-base-v2-${{ github.event.pull_request.base.sha }}-${{ inputs.python-version }}-${{ inputs.matplotlib-version }} + key: ${{ runner.os }}-baseline-base-v3-hs${{ env.PYTHONHASHSEED }}-${{ github.event.pull_request.base.sha }}-${{ inputs.python-version }}-${{ inputs.matplotlib-version }} restore-keys: | - ${{ runner.os }}-baseline-base-v2-${{ github.event.pull_request.base.sha }}-${{ inputs.python-version }}-${{ inputs.matplotlib-version }}- + ${{ runner.os }}-baseline-base-v3-hs${{ env.PYTHONHASHSEED }}-${{ github.event.pull_request.base.sha }}-${{ inputs.python-version }}-${{ inputs.matplotlib-version }}- # Conditional Baseline Generation (Only runs on cache miss) - name: Generate baseline from main From eba1aef4bd37540368e50a38d42663f5d6875789 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 11 Feb 2026 13:44:22 +1000 Subject: [PATCH 06/10] Remove composition --- docs/examples/plot_types/11_topic_ribbon.py | 9 ++---- ultraplot/axes/plot.py | 18 +----------- ultraplot/axes/plot_types/ribbon.py | 31 --------------------- 3 files changed, 4 insertions(+), 54 deletions(-) diff --git a/docs/examples/plot_types/11_topic_ribbon.py b/docs/examples/plot_types/11_topic_ribbon.py index 25d0bd814..db02df427 100644 --- a/docs/examples/plot_types/11_topic_ribbon.py +++ b/docs/examples/plot_types/11_topic_ribbon.py @@ -84,8 +84,8 @@ def build_assignments(): 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( +fig, ax = uplt.subplots(refwidth=6.3) +ax.ribbon( df, id_col="country", period_col="period", @@ -95,11 +95,8 @@ def build_assignments(): 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") +ax.format(title="Category transitions with fixed top-aligned rows") fig.format(suptitle="Top-aligned ribbon flow by period") fig.show() diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 9750cc99f..d6f44f380 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -2329,10 +2329,6 @@ def ribbon( 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. @@ -2350,7 +2346,7 @@ def ribbon( 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 ordering for row arrangement. group_colors : mapping, optional Group-to-color mapping. Missing groups use the patch color cycle. xmargin, ymargin : float, optional @@ -2371,14 +2367,6 @@ def ribbon( 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 ------- @@ -2409,10 +2397,6 @@ def ribbon( 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( diff --git a/ultraplot/axes/plot_types/ribbon.py b/ultraplot/axes/plot_types/ribbon.py index ededb4389..90713806f 100644 --- a/ultraplot/axes/plot_types/ribbon.py +++ b/ultraplot/axes/plot_types/ribbon.py @@ -78,10 +78,6 @@ def ribbon_diagram( topic_label_offset: float, topic_label_size: float, topic_label_box: bool, - composition_ax: Any | None, - composition: bool, - composition_alpha: float, - composition_ylabel: str, ) -> dict[str, Any]: """ Build a fixed-row, top-aligned ribbon flow diagram from long-form assignments. @@ -333,33 +329,6 @@ def ribbon_diagram( ) period_text.append(text) - if composition and composition_ax is not None: - frame = df[df[period_col].isin(periods) & df[topic_col].isin(topics)].copy() - frame["_group"] = frame[topic_col].map(group_map) - composition_counts = ( - frame.groupby([period_col, "_group"])["value_internal"] - .sum() - .unstack(fill_value=0) - ) - group_cols = [group for group in groups if group in composition_counts.columns] - composition_counts = composition_counts.reindex( - index=periods, columns=group_cols - ) - xpos = np.arange(len(periods)) - composition_ax.stackplot( - xpos, - [composition_counts[col].to_numpy() for col in group_cols], - colors=[group_colors[col] for col in group_cols], - alpha=composition_alpha, - ) - composition_ax.set_xticks(xpos) - composition_ax.set_xticklabels( - [str(period) for period in periods], rotation=15, ha="right" - ) - composition_ax.format( - ylabel=composition_ylabel, xlabel="Period", grid=True, gridalpha=0.3 - ) - ax.format(xlim=(0, 1), ylim=(0, 1), grid=False) ax.axis("off") return { From d66b461e445b20b99e926e635212f2818b1cc37e Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 11 Feb 2026 14:31:43 +1000 Subject: [PATCH 07/10] Rm composition from test --- ultraplot/tests/test_plot.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/ultraplot/tests/test_plot.py b/ultraplot/tests/test_plot.py index 2c5e51e5e..6a2f1a331 100644 --- a/ultraplot/tests/test_plot.py +++ b/ultraplot/tests/test_plot.py @@ -1031,8 +1031,8 @@ def test_ribbon_smoke(): ] data = pd.DataFrame(records, columns=["id", "period", "topic"]) - fig, axs = uplt.subplots(nrows=2, hratios=(2, 1), share=False) - artists = axs[0].ribbon( + fig, ax = uplt.subplots(nrows=2, hratios=(2, 1), share=False) + artists = ax.ribbon( data, id_col="id", period_col="period", @@ -1041,8 +1041,6 @@ def test_ribbon_smoke(): topic_order=["T1", "T2", "T3"], group_map={"T1": "G1", "T2": "G1", "T3": "G2"}, group_order=["G1", "G2"], - composition=True, - composition_ax=axs[1], ) assert artists["node_patches"] assert artists["flow_patches"] From 46fcf5aa548f7821a162d3075034107c494381cd Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 11 Feb 2026 18:35:59 +1000 Subject: [PATCH 08/10] Fix ribbon smoke test to use single-axes return contract --- ultraplot/tests/test_plot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ultraplot/tests/test_plot.py b/ultraplot/tests/test_plot.py index 6a2f1a331..69d9eca4b 100644 --- a/ultraplot/tests/test_plot.py +++ b/ultraplot/tests/test_plot.py @@ -1031,7 +1031,7 @@ def test_ribbon_smoke(): ] data = pd.DataFrame(records, columns=["id", "period", "topic"]) - fig, ax = uplt.subplots(nrows=2, hratios=(2, 1), share=False) + fig, ax = uplt.subplots() artists = ax.ribbon( data, id_col="id", From 005dc5fd2dcc742278a47a3c76ebeb6a7d66b6dc Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Feb 2026 07:44:11 +1000 Subject: [PATCH 09/10] Move ribbon visual defaults into rc params --- ultraplot/axes/plot.py | 33 +++++++++++++++------- ultraplot/internals/rcsetup.py | 51 ++++++++++++++++++++++++++++++++++ ultraplot/tests/test_config.py | 16 +++++++++++ 3 files changed, 90 insertions(+), 10 deletions(-) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index d6f44f380..9fb821c2b 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -2319,16 +2319,16 @@ def ribbon( 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, + xmargin: Optional[float] = None, + ymargin: Optional[float] = None, + row_height_ratio: Optional[float] = None, + node_width: Optional[float] = None, + flow_curvature: Optional[float] = None, + flow_alpha: Optional[float] = None, + show_topic_labels: Optional[bool] = None, + topic_label_offset: Optional[float] = None, + topic_label_size: Optional[float] = None, + topic_label_box: Optional[bool] = None, ) -> dict[str, Any]: """ Draw a fixed-row, top-aligned ribbon flow diagram from long-form records. @@ -2375,6 +2375,19 @@ def ribbon( """ from .plot_types.ribbon import ribbon_diagram + xmargin = _not_none(xmargin, rc["ribbon.xmargin"]) + ymargin = _not_none(ymargin, rc["ribbon.ymargin"]) + row_height_ratio = _not_none(row_height_ratio, rc["ribbon.row_height_ratio"]) + node_width = _not_none(node_width, rc["ribbon.node_width"]) + flow_curvature = _not_none(flow_curvature, rc["ribbon.flow.curvature"]) + flow_alpha = _not_none(flow_alpha, rc["ribbon.flow.alpha"]) + show_topic_labels = _not_none(show_topic_labels, rc["ribbon.show_topic_labels"]) + topic_label_offset = _not_none( + topic_label_offset, rc["ribbon.topic_label_offset"] + ) + topic_label_size = _not_none(topic_label_size, rc["ribbon.topic_label_size"]) + topic_label_box = _not_none(topic_label_box, rc["ribbon.topic_label_box"]) + return ribbon_diagram( self, data, diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index bd5f06172..8d7372af0 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -1039,6 +1039,57 @@ def _validator_accepts(validator, value): _validate_color, "Default node facecolor for layered sankey diagrams.", ), + # Ribbon settings + "ribbon.xmargin": ( + 0.12, + _validate_float, + "Horizontal margin around ribbon diagrams (axes-relative units).", + ), + "ribbon.ymargin": ( + 0.08, + _validate_float, + "Vertical margin around ribbon diagrams (axes-relative units).", + ), + "ribbon.row_height_ratio": ( + 2.2, + _validate_float, + "Height scale factor controlling ribbon row occupancy.", + ), + "ribbon.node_width": ( + 0.018, + _validate_float, + "Node width for ribbon diagrams (axes-relative units).", + ), + "ribbon.flow.curvature": ( + 0.45, + _validate_float, + "Flow curvature for ribbon diagrams.", + ), + "ribbon.flow.alpha": ( + 0.58, + _validate_float, + "Flow transparency for ribbon diagrams.", + ), + "ribbon.show_topic_labels": ( + True, + _validate_bool, + "Whether to draw topic labels on the right side of ribbon diagrams.", + ), + "ribbon.topic_label_offset": ( + 0.028, + _validate_float, + "Offset for right-side ribbon topic labels.", + ), + "ribbon.topic_label_size": ( + 7.4, + _validate_float, + "Font size for ribbon topic labels.", + ), + "ribbon.topic_label_box": ( + True, + _validate_bool, + "Whether to draw backing boxes behind ribbon topic labels.", + ), # Stylesheet "style": ( None, diff --git a/ultraplot/tests/test_config.py b/ultraplot/tests/test_config.py index 064808850..b3f9695cc 100644 --- a/ultraplot/tests/test_config.py +++ b/ultraplot/tests/test_config.py @@ -55,6 +55,22 @@ def test_sankey_rc_defaults(): assert uplt.rc["sankey.node.facecolor"] == "0.75" +def test_ribbon_rc_defaults(): + """ + Sanity check ribbon defaults in rc. + """ + assert uplt.rc["ribbon.xmargin"] == 0.12 + assert uplt.rc["ribbon.ymargin"] == 0.08 + assert uplt.rc["ribbon.row_height_ratio"] == 2.2 + assert uplt.rc["ribbon.node_width"] == 0.018 + assert uplt.rc["ribbon.flow.curvature"] == 0.45 + assert uplt.rc["ribbon.flow.alpha"] == 0.58 + assert uplt.rc["ribbon.show_topic_labels"] is True + assert uplt.rc["ribbon.topic_label_offset"] == 0.028 + assert uplt.rc["ribbon.topic_label_size"] == 7.4 + assert uplt.rc["ribbon.topic_label_box"] is True + + import io from importlib.metadata import PackageNotFoundError from unittest.mock import MagicMock, patch From d207c4dc74e66e85c70f2f10731c6e7075bcebbf Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Feb 2026 07:47:53 +1000 Subject: [PATCH 10/10] Align ribbon rc key names with sankey-style conventions --- ultraplot/axes/plot.py | 6 +++--- ultraplot/internals/rcsetup.py | 6 +++--- ultraplot/tests/test_config.py | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/ultraplot/axes/plot.py b/ultraplot/axes/plot.py index 9fb821c2b..5061d68f6 100644 --- a/ultraplot/axes/plot.py +++ b/ultraplot/axes/plot.py @@ -2377,11 +2377,11 @@ def ribbon( xmargin = _not_none(xmargin, rc["ribbon.xmargin"]) ymargin = _not_none(ymargin, rc["ribbon.ymargin"]) - row_height_ratio = _not_none(row_height_ratio, rc["ribbon.row_height_ratio"]) - node_width = _not_none(node_width, rc["ribbon.node_width"]) + row_height_ratio = _not_none(row_height_ratio, rc["ribbon.rowheightratio"]) + node_width = _not_none(node_width, rc["ribbon.nodewidth"]) flow_curvature = _not_none(flow_curvature, rc["ribbon.flow.curvature"]) flow_alpha = _not_none(flow_alpha, rc["ribbon.flow.alpha"]) - show_topic_labels = _not_none(show_topic_labels, rc["ribbon.show_topic_labels"]) + show_topic_labels = _not_none(show_topic_labels, rc["ribbon.topic_labels"]) topic_label_offset = _not_none( topic_label_offset, rc["ribbon.topic_label_offset"] ) diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index 8d7372af0..9f28effe5 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -1050,12 +1050,12 @@ def _validator_accepts(validator, value): _validate_float, "Vertical margin around ribbon diagrams (axes-relative units).", ), - "ribbon.row_height_ratio": ( + "ribbon.rowheightratio": ( 2.2, _validate_float, "Height scale factor controlling ribbon row occupancy.", ), - "ribbon.node_width": ( + "ribbon.nodewidth": ( 0.018, _validate_float, "Node width for ribbon diagrams (axes-relative units).", @@ -1070,7 +1070,7 @@ def _validator_accepts(validator, value): _validate_float, "Flow transparency for ribbon diagrams.", ), - "ribbon.show_topic_labels": ( + "ribbon.topic_labels": ( True, _validate_bool, "Whether to draw topic labels on the right side of ribbon diagrams.", diff --git a/ultraplot/tests/test_config.py b/ultraplot/tests/test_config.py index b3f9695cc..9a53e83d8 100644 --- a/ultraplot/tests/test_config.py +++ b/ultraplot/tests/test_config.py @@ -61,11 +61,11 @@ def test_ribbon_rc_defaults(): """ assert uplt.rc["ribbon.xmargin"] == 0.12 assert uplt.rc["ribbon.ymargin"] == 0.08 - assert uplt.rc["ribbon.row_height_ratio"] == 2.2 - assert uplt.rc["ribbon.node_width"] == 0.018 + assert uplt.rc["ribbon.rowheightratio"] == 2.2 + assert uplt.rc["ribbon.nodewidth"] == 0.018 assert uplt.rc["ribbon.flow.curvature"] == 0.45 assert uplt.rc["ribbon.flow.alpha"] == 0.58 - assert uplt.rc["ribbon.show_topic_labels"] is True + assert uplt.rc["ribbon.topic_labels"] is True assert uplt.rc["ribbon.topic_label_offset"] == 0.028 assert uplt.rc["ribbon.topic_label_size"] == 7.4 assert uplt.rc["ribbon.topic_label_box"] is True