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"])