Download notebook (.ipynb)

Alternating Ribbon Fill with Disjoint Groups#

This notebook demonstrates a subtle issue that arises when using geom_ribbon() to visualize piecewise geometry with repeated fill categories.

from math import sin

import pandas as pd

from lets_plot import *
LetsPlot.setup_html()

The setup involves two sine waves with a phase shift, producing alternating intersection regions. We want to highlight the area between the curves using alternating colors (e.g., red, blue, red, blue…).

However, if only two fill categories are used (e.g., "a" and "b"), Lets-Plot will treat each category as a single continuous area. As a result, visually disconnected regions with the same category will be merged — which creates unwanted bridging between non-adjacent segments.

def get_data():
    x = list(range(60))
    a = [sin(v / 10.0) * 20.0 for v in x]
    b = [sin(v / 6.0 + 1.0) * 20.0 - 2.0 for v in x]
    return pd.DataFrame({
        "x": x,
        "min": [min(first, second) for (first, second) in zip(a, b)],
        "max": [max(first, second) for (first, second) in zip(a, b)],
        "label": [("a" if first > second else "b") for (first, second) in zip(a, b)],
    })

df = get_data()
df.head(10)
x min max label
0 0 0.000000 14.829420 b
1 1 1.996668 16.388900 b
2 2 3.973387 17.438758 b
3 3 5.910404 17.949900 b
4 4 7.788367 17.908159 b
5 5 9.588511 17.314693 b
6 6 11.292849 16.185949 b
7 7 12.884354 14.553207 b
8 8 12.461718 14.347122 a
9 9 9.969443 15.666538 a
ggplot(df) + \
    geom_ribbon(aes(x="x", ymin="min", ymax="max", fill="label", color="label"), alpha=.2)

To fix this, we generate unique group labels for each individual segment (e.g., "a1", "a2", "a3", "a4", …). During plotting, we assign the same color to all even segments and another to odd segments using scale_manual(). This ensures proper coloring while keeping the regions visually separated.

def update_data(df):
    return df.assign(
        label="a" + df["label"].ne(df["label"].shift()).cumsum().astype(str)
    )

corrected_df = update_data(df)
corrected_df.head(10)
x min max label
0 0 0.000000 14.829420 a1
1 1 1.996668 16.388900 a1
2 2 3.973387 17.438758 a1
3 3 5.910404 17.949900 a1
4 4 7.788367 17.908159 a1
5 5 9.588511 17.314693 a1
6 6 11.292849 16.185949 a1
7 7 12.884354 14.553207 a1
8 8 12.461718 14.347122 a2
9 9 9.969443 15.666538 a2
ggplot(corrected_df) + \
    geom_ribbon(aes(x="x", ymin="min", ymax="max", fill="label", color="label"), alpha=.2) + \
    scale_manual(["color", "fill"], values=["#e41a1c", "#377eb8"], breaks=[1, 2], labels=["b", "a"])

Additionally, we modify the dataset to eliminate visible gaps between categories by inserting extra points at the segment boundaries.

def get_continuous_df(df, target_col):
    cols = df.columns
    out_rows = []
    for i in range(len(df)):
        if i > 0 and df[target_col].iloc[i] != df[target_col].iloc[i-1]:
            boundary = df.iloc[i].copy()
            boundary[target_col] = df[target_col].iloc[i-1]
            out_rows.append(boundary)
        out_rows.append(df.iloc[i])
    return pd.DataFrame(out_rows, columns=cols).reset_index(drop=True)

continuous_df = get_continuous_df(corrected_df, "label")
continuous_df.head(10)
x min max label
0 0 0.000000 14.829420 a1
1 1 1.996668 16.388900 a1
2 2 3.973387 17.438758 a1
3 3 5.910404 17.949900 a1
4 4 7.788367 17.908159 a1
5 5 9.588511 17.314693 a1
6 6 11.292849 16.185949 a1
7 7 12.884354 14.553207 a1
8 8 12.461718 14.347122 a1
9 8 12.461718 14.347122 a2
ggplot(continuous_df) + \
    geom_ribbon(aes(x="x", ymin="min", ymax="max", fill="label", color="label"), alpha=.2) + \
    scale_manual(["color", "fill"], values=["#e41a1c", "#377eb8"], breaks=[1, 2], labels=["b", "a"])