From d88b77095a0e27b540586ce81663d3f17f1ba13e Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Fri, 6 Feb 2026 19:28:55 +1000 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] [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