Combining Figures¶
xarray-plotly provides helper functions to combine multiple figures:
overlay: Overlay traces on the same axesadd_secondary_y: Plot with two independent y-axes
import plotly.express as px
import xarray as xr
from xarray_plotly import add_secondary_y, config, overlay, xpx
config.notebook()
Load Sample Data¶
# Stock prices
df_stocks = px.data.stocks().set_index("date")
df_stocks.index = df_stocks.index.astype("datetime64[ns]")
stocks = xr.DataArray(
df_stocks.values,
dims=["date", "company"],
coords={"date": df_stocks.index, "company": df_stocks.columns.tolist()},
name="price",
)
# Gapminder data (subset: a few countries)
df_gap = px.data.gapminder()
countries = ["United States", "China", "Germany", "Brazil"]
df_gap = df_gap[df_gap["country"].isin(countries)]
# Convert to xarray
gap_pop = df_gap.pivot(index="year", columns="country", values="pop")
gap_gdp = df_gap.pivot(index="year", columns="country", values="gdpPercap")
gap_life = df_gap.pivot(index="year", columns="country", values="lifeExp")
population = xr.DataArray(
gap_pop.values,
dims=["year", "country"],
coords={"year": gap_pop.index.values, "country": gap_pop.columns.tolist()},
name="Population",
)
gdp_per_capita = xr.DataArray(
gap_gdp.values,
dims=["year", "country"],
coords={"year": gap_gdp.index.values, "country": gap_gdp.columns.tolist()},
name="GDP per Capita",
)
life_expectancy = xr.DataArray(
gap_life.values,
dims=["year", "country"],
coords={"year": gap_life.index.values, "country": gap_life.columns.tolist()},
name="Life Expectancy",
)
overlay¶
Overlay multiple figures on the same axes. Useful for showing data with a trend line, moving average, or different visualizations of related data.
Stock Price with Moving Average¶
# Select one company
goog = stocks.sel(company="GOOG")
# Calculate 20-day moving average
goog_ma = goog.rolling(date=20, center=True).mean()
goog_ma.name = "20-day MA"
# Raw prices as scatter
price_fig = xpx(goog).scatter()
price_fig.update_traces(marker={"size": 4, "opacity": 0.5}, name="Daily Price")
# Moving average as line
ma_fig = xpx(goog_ma).line()
ma_fig.update_traces(line={"color": "red", "width": 3}, name="20-day MA")
combined = overlay(price_fig, ma_fig)
combined.update_layout(title="GOOG: Daily Price with Moving Average")
combined
Multiple Companies with Moving Averages¶
# Select a few companies
subset = stocks.sel(company=["GOOG", "AAPL", "MSFT"])
subset_ma = subset.rolling(date=20, center=True).mean()
# Raw as scatter (faded)
raw_fig = xpx(subset).scatter()
raw_fig.update_traces(marker={"size": 3, "opacity": 0.3})
# MA as lines (bold)
ma_fig = xpx(subset_ma).line()
ma_fig.update_traces(line={"width": 3})
combined = overlay(raw_fig, ma_fig)
combined.update_layout(title="Tech Stocks: Raw Prices + Moving Averages")
combined
With Facets¶
overlay works with faceted figures as long as both have the same structure.
# Faceted by company
raw_faceted = xpx(subset).scatter(facet_col="company")
raw_faceted.update_traces(marker={"size": 3, "opacity": 0.4})
ma_faceted = xpx(subset_ma).line(facet_col="company")
ma_faceted.update_traces(line={"color": "red", "width": 2})
combined = overlay(raw_faceted, ma_faceted)
combined.update_layout(title="Faceted: Price + Moving Average per Company")
combined
With Animation¶
Overlay animated figures - frames are merged correctly.
# Animate through countries, showing population bar + GDP line
# Both use the same animation dimension
pop_anim = xpx(population).bar(animation_frame="country")
pop_anim.update_traces(marker={"opacity": 0.6})
# Create a "target" line (e.g., some reference value)
pop_smooth = population.rolling(year=3, center=True).mean()
smooth_anim = xpx(pop_smooth).line(animation_frame="country")
smooth_anim.update_traces(line={"color": "red", "width": 3})
combined = overlay(pop_anim, smooth_anim)
combined.update_layout(title="Population: Raw + Smoothed (animated by country)")
combined
Static Overlay on Animated Base¶
A static figure can be overlaid on an animated one - the static traces appear in all frames.
# Animated population
pop_anim = xpx(population).bar(animation_frame="country")
pop_anim.update_traces(marker={"opacity": 0.7})
# Static reference line (global average across all countries)
global_avg = population.mean(dim="country")
avg_fig = xpx(global_avg).line()
avg_fig.update_traces(line={"color": "black", "width": 2, "dash": "dash"}, name="Global Avg")
combined = overlay(pop_anim, avg_fig)
combined.update_layout(title="Population by Country vs Global Average")
combined
add_secondary_y¶
Plot two variables with different scales using independent y-axes. Essential when values have different magnitudes.
Population vs GDP per Capita¶
# Select one country
us_pop = population.sel(country="United States")
us_gdp = gdp_per_capita.sel(country="United States")
pop_fig = xpx(us_pop).bar()
pop_fig.update_traces(marker={"color": "steelblue", "opacity": 0.7}, name="Population")
gdp_fig = xpx(us_gdp).line()
gdp_fig.update_traces(line={"color": "red", "width": 3}, name="GDP per Capita")
combined = add_secondary_y(pop_fig, gdp_fig, secondary_y_title="GDP per Capita ($)")
combined.update_layout(
title="United States: Population vs GDP per Capita",
yaxis_title="Population",
)
combined
Why Secondary Y-Axis Matters¶
Without it, one variable dominates due to scale mismatch:
# Same data on single y-axis - GDP looks flat because population is ~1e8, GDP is ~1e4
pop_fig = xpx(us_pop).line()
pop_fig.update_traces(name="Population", line={"color": "blue"})
gdp_fig = xpx(us_gdp).line()
gdp_fig.update_traces(name="GDP per Capita", line={"color": "red"})
bad = overlay(pop_fig, gdp_fig)
bad.update_layout(title="overlay: GDP invisible (scale mismatch)")
bad
# With add_secondary_y - both variables visible
pop_fig = xpx(us_pop).line()
pop_fig.update_traces(name="Population", line={"color": "blue", "width": 2})
gdp_fig = xpx(us_gdp).line()
gdp_fig.update_traces(name="GDP per Capita", line={"color": "red", "width": 2})
good = add_secondary_y(pop_fig, gdp_fig, secondary_y_title="GDP per Capita ($)")
good.update_layout(title="add_secondary_y: Both variables clearly visible")
good
With Animation¶
add_secondary_y supports animated figures with matching frames.
# Animate through countries
pop_anim = xpx(population).bar(animation_frame="country")
pop_anim.update_traces(marker={"color": "steelblue", "opacity": 0.7})
gdp_anim = xpx(gdp_per_capita).line(animation_frame="country")
gdp_anim.update_traces(line={"color": "red", "width": 3})
combined = add_secondary_y(pop_anim, gdp_anim, secondary_y_title="GDP per Capita ($)")
combined.update_layout(title="Population vs GDP (animated by country)")
combined
Static Secondary on Animated Base¶
A static secondary figure is replicated to all animation frames.
# Animated population
pop_anim = xpx(population).bar(animation_frame="country")
pop_anim.update_traces(marker={"color": "steelblue", "opacity": 0.7})
# Static GDP reference (US only, shown in all frames)
us_gdp_static = xpx(us_gdp).line()
us_gdp_static.update_traces(
line={"color": "red", "width": 2, "dash": "dash"}, name="US GDP (reference)"
)
combined = add_secondary_y(pop_anim, us_gdp_static, secondary_y_title="GDP per Capita ($)")
combined.update_layout(title="Population (animated) vs US GDP (static reference)")
combined
With Facets¶
add_secondary_y works with faceted figures when both have the same facet structure.
# Faceted by country - both figures must have same facet structure
pop_faceted = xpx(population).bar(facet_col="country")
pop_faceted.update_traces(marker={"color": "steelblue", "opacity": 0.7})
gdp_faceted = xpx(gdp_per_capita).line(facet_col="country")
gdp_faceted.update_traces(line={"color": "red", "width": 3})
combined = add_secondary_y(pop_faceted, gdp_faceted, secondary_y_title="GDP per Capita ($)")
combined.update_layout(title="Population vs GDP per Capita (faceted by country)")
combined
Limitations (with examples)¶
Both functions validate inputs and raise clear errors when constraints are violated.
overlay: Mismatched Facet Structure¶
Overlay cannot have subplots that don't exist in base.
# Base: no facets
base = xpx(stocks.sel(company="GOOG")).line()
# Overlay: has facets
overlay_fig = xpx(stocks.sel(company=["GOOG", "AAPL"])).line(facet_col="company")
try:
overlay(base, overlay_fig)
except ValueError as e:
print(f"ValueError: {e}")
ValueError: Overlay figure has subplots not present in base figure: {('x2', 'y2')}. Ensure both figures have the same facet structure.
overlay: Animated Overlay on Static Base¶
Cannot add an animated overlay to a static base figure.
# Base: static
static_base = xpx(population.sel(country="United States")).line()
# Overlay: animated
animated_overlay = xpx(population).line(animation_frame="country")
try:
overlay(static_base, animated_overlay)
except ValueError as e:
print(f"ValueError: {e}")
ValueError: Overlay figure has animation frames but base figure does not. Cannot add animated overlay to static base figure.
overlay: Mismatched Animation Frames¶
Animation frame names must match exactly.
# Different countries selected = different frame names
fig1 = xpx(population.sel(country=["United States", "China"])).line(animation_frame="country")
fig2 = xpx(population.sel(country=["Germany", "Brazil"])).line(animation_frame="country")
try:
overlay(fig1, fig2)
except ValueError as e:
print(f"ValueError: {e}")
ValueError: Animation frame names don't match between base and overlay. Missing in overlay: {'United States', 'China'}. Extra in overlay: {'Brazil', 'Germany'}.
add_secondary_y: Mismatched Facet Structure¶
Both figures must have the same facet structure.
# Base with facets
pop_faceted = xpx(population).bar(facet_col="country")
# Secondary without facets (different structure)
gdp_single = xpx(gdp_per_capita.sel(country="United States")).line()
try:
add_secondary_y(pop_faceted, gdp_single)
except ValueError as e:
print(f"ValueError: {e}")
ValueError: Base and secondary figures must have the same facet structure. Base has {('x3', 'y3'), ('x', 'y'), ('x2', 'y2'), ('x4', 'y4')}, secondary has {('x', 'y')}.
add_secondary_y: Animated Secondary on Static Base¶
Cannot add animated secondary to static base.
# Static base
static_pop = xpx(population.sel(country="United States")).bar()
# Animated secondary
animated_gdp = xpx(gdp_per_capita).line(animation_frame="country")
try:
add_secondary_y(static_pop, animated_gdp)
except ValueError as e:
print(f"ValueError: {e}")
ValueError: Overlay figure has animation frames but base figure does not. Cannot add animated overlay to static base figure.
add_secondary_y: Mismatched Animation Frames¶
# Different countries = different frames
pop_some = xpx(population.sel(country=["United States", "China"])).bar(animation_frame="country")
gdp_other = xpx(gdp_per_capita.sel(country=["Germany", "Brazil"])).line(animation_frame="country")
try:
add_secondary_y(pop_some, gdp_other)
except ValueError as e:
print(f"ValueError: {e}")
ValueError: Animation frame names don't match between base and overlay. Missing in overlay: {'United States', 'China'}. Extra in overlay: {'Brazil', 'Germany'}.
Summary¶
| Function | Facets | Animation | Static + Animated |
|---|---|---|---|
overlay |
Yes (must match) | Yes (frames must match) | Static overlay on animated base OK |
add_secondary_y |
Yes (must match) | Yes (frames must match) | Static secondary on animated base OK |