Skip to content

feat: add figure combination utilities (overlay, add_secondary_y, update_traces)#16

Merged
FBumann merged 10 commits intomainfrom
feature/combine_plots
Jan 22, 2026
Merged

feat: add figure combination utilities (overlay, add_secondary_y, update_traces)#16
FBumann merged 10 commits intomainfrom
feature/combine_plots

Conversation

@FBumann
Copy link
Owner

@FBumann FBumann commented Jan 22, 2026

Summary

Adds utilities for combining and manipulating Plotly figures created from xarray data:

  • overlay() - Combine multiple figures on the same axes (supports facets and animation)
  • add_secondary_y() - Create dual y-axis plots for different scales (supports facets and animation)
  • update_traces() - Update trace properties across animation frames
  • xpx() - Typed function for IDE autocomplete (alternative to da.plotly accessor)

API

from xarray_plotly import xpx, overlay, add_secondary_y, update_traces

# Create plots
area = xpx(da).area()
line = xpx(da).line()

# Overlay traces on same axes
combined = overlay(area, line)

# Dual y-axis (different scales)
combined = add_secondary_y(temp_fig, precip_fig, secondary_y_title="Precipitation (mm)")

# Update traces in animated figures
fig = update_traces(fig, line_width=3)

Changes

Added Purpose
overlay() Combine figures on same axes
add_secondary_y() Dual y-axis plots
update_traces() Update animation frame traces
xpx() Typed accessor function

Documentation

Test plan

  • 103+ tests passing
  • Facet support for both overlay and add_secondary_y
  • Animation support for all utilities

Summary by CodeRabbit

Release Notes

  • New Features

    • Added overlay() function to combine figures with support for facets and animations
    • Added add_secondary_y() function to create secondary y-axes on figures
    • Added update_traces() function to update traces consistently across animated frames
  • Documentation

    • New notebook demonstrating figure combining using overlay and secondary y-axes techniques
    • New notebook covering figure manipulation, styling, and animation control

✏️ Tip: You can customize this high-level summary in your review settings.

  ┌───────────────────────────────┬───────────────────────────────────────────────────────────────────────────────┐
  │             File              │                                    Changes                                    │
  ├───────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
  │ xarray_plotly/figures.py      │ Renamed combine_figures → overlay_figures (with alias), added add_secondary_y │
  ├───────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
  │ xarray_plotly/__init__.py     │ Exported overlay_figures, add_secondary_y, combine_figures                    │
  ├───────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
  │ tests/test_figures.py         │ Added 16 new tests for add_secondary_y and alias verification (34 total)      │
  ├───────────────────────────────┼───────────────────────────────────────────────────────────────────────────────┤
  │ docs/examples/combining.ipynb │ New notebook demonstrating both methods                                       │
  └───────────────────────────────┴───────────────────────────────────────────────────────────────────────────────┘
  API:

  from xarray_plotly import overlay_figures, add_secondary_y

  # Overlay traces on same axes
  combined = overlay_figures(area_fig, line_fig)

  # Dual y-axis (different scales)
  combined = add_secondary_y(temp_fig, precip_fig, secondary_y_title="Rain (mm)")

  Features:
  - overlay_figures: Supports facets, animation, multiple overlays
  - add_secondary_y: Supports animation, custom y-axis title
  - Both create deep copies (originals not modified)
  - Both validate compatibility and raise clear errors

  Test results: 99 tests passing
… secondary y-axes for each facet subplot (e.g., y→y4, y2→y5, y3→y6)

  2. Updated tests - Added 6 new tests for faceted secondary y-axis:
    - test_matching_facets_works
    - test_facets_creates_multiple_secondary_axes
    - test_secondary_traces_remapped_to_correct_axes
    - test_mismatched_facets_raises
    - test_mismatched_facets_reversed_raises
    - test_facets_with_custom_title
  3. Updated notebook (docs/examples/combining.ipynb):
    - Added new "With Facets" section showing add_secondary_y working with faceted figures
    - Changed "No Facet Support" limitation to "Mismatched Facet Structure" showing the error when structures don't match
    - Updated summary table: add_secondary_y now shows "Yes (must match)" for Facets
  4. All 103 tests pass
  1. Use overlay_figures for adding traces - shown under "Easy: Adding traces to faceted/animated figures"
  2. Keep just two helpers:
    - update_animation_traces() - the main pain point
    - set_animation_speed() - for the deeply nested API
  3. Added facets + animation example showing the helper works for both

  So the final picture is:
  ┌─────────────────────────────────────────────────────┬──────────────────────────────────────────┐
  │                 What you want to do                 │                 Solution                 │
  ├─────────────────────────────────────────────────────┼──────────────────────────────────────────┤
  │ Add traces to animated/faceted figures              │ overlay_figures() ✅ already in library  │
  ├─────────────────────────────────────────────────────┼──────────────────────────────────────────┤
  │ Update trace style (line_width, etc.) on animations │ update_animation_traces() - needs helper │
  ├─────────────────────────────────────────────────────┼──────────────────────────────────────────┤
  │ Change animation speed                              │ set_animation_speed() - needs helper     │
  ├─────────────────────────────────────────────────────┼──────────────────────────────────────────┤
  │ Everything else                                     │ Works out of the box                     │
  └─────────────────────────────────────────────────────┴──────────────────────────────────────────┘
  Should we add update_animation_traces() and set_animation_speed() to xarray_plotly.figures as proper exported functions? They're simple but solve real pain points.
  from xarray_plotly import (
      xpx,                      # Main entry point - accessor with IDE completion
      overlay,                  # Combine figures on same axes
      add_secondary_y,          # Dual y-axis plots
      update_animation_traces,  # Update traces in animation frames
      config,                   # Configuration settings
  )

  Changes Summary
  ┌─────────────────────────┬──────────────────────────────────┐
  │         Removed         │              Reason              │
  ├─────────────────────────┼──────────────────────────────────┤
  │ overlay_figures         │ Renamed to overlay               │
  ├─────────────────────────┼──────────────────────────────────┤
  │ combine_figures         │ Alias removed                    │
  ├─────────────────────────┼──────────────────────────────────┤
  │ SLOT_ORDERS             │ Implementation detail            │
  ├─────────────────────────┼──────────────────────────────────┤
  │ DataArrayPlotlyAccessor │ Users use xpx()                  │
  ├─────────────────────────┼──────────────────────────────────┤
  │ DatasetPlotlyAccessor   │ Users use xpx()                  │
  ├─────────────────────────┼──────────────────────────────────┤
  │ auto                    │ Rarely needed                    │
  ├─────────────────────────┼──────────────────────────────────┤
  │ set_animation_speed     │ Kept as local helper in notebook │
  └─────────────────────────┴──────────────────────────────────┘
  ┌─────────────────────────┬─────────────────────────┐
  │          Added          │         Reason          │
  ├─────────────────────────┼─────────────────────────┤
  │ update_animation_traces │ Was hidden, now exposed │
  └─────────────────────────┴─────────────────────────┘
@coderabbitai
Copy link

coderabbitai bot commented Jan 22, 2026

📝 Walkthrough

Walkthrough

This PR introduces figure composition and manipulation utilities to xarray_plotly. A new figures.py module provides three functions (overlay, add_secondary_y, update_traces) for combining and modifying Plotly figures. Comprehensive documentation and test coverage are included.

Changes

Cohort / File(s) Summary
Core Implementation
xarray_plotly/figures.py, xarray_plotly/__init__.py
New figures.py module with overlay, add_secondary_y, and update_traces functions. Includes subplot/animation compatibility validation, frame merging logic, and secondary y-axis mapping. Public API updated to export these three new functions.
Documentation
docs/examples/combining.ipynb, docs/examples/manipulation.ipynb
Two new example notebooks demonstrating figure composition (overlay, add_secondary_y) and trace/layout manipulation. Covers basic usage, faceted plots, animations, edge cases, and error handling.
Tests
tests/test_figures.py
Comprehensive test suite covering overlay behavior (basic, faceted, animated), add_secondary_y functionality, frame merging, deep copy guarantees, error handling for incompatible structures, and edge cases across facet and animation combinations.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant overlay as overlay()
    participant Validator
    participant FrameMerge as Frame Merger
    participant Result as Result Figure

    User->>overlay: overlay(base, overlay1, overlay2)
    overlay->>Validator: Validate subplot compatibility
    Validator-->>overlay: ✓ Compatible
    
    overlay->>Validator: Validate animation frames
    Validator-->>overlay: ✓ Frames match
    
    overlay->>FrameMerge: Merge animation frames<br/>(if animated)
    FrameMerge-->>overlay: Merged frames
    
    overlay->>Result: Combine traces + layout<br/>+ merged frames
    Result-->>User: New figure (deep copy)
Loading
sequenceDiagram
    participant User
    participant add_secondary_y as add_secondary_y()
    participant Mapper as Mapping Builder
    participant FrameMerge as Secondary Frame Merger
    participant Result as Result Figure

    User->>add_secondary_y: add_secondary_y(base, secondary)
    add_secondary_y->>Mapper: Build y-axis mapping<br/>(primary → secondary)
    Mapper-->>add_secondary_y: y2 mapping
    
    add_secondary_y->>add_secondary_y: Remap secondary traces<br/>to y2 axis
    
    alt Has Animation Frames
        add_secondary_y->>FrameMerge: Merge secondary frames
        FrameMerge-->>add_secondary_y: Merged frames
    end
    
    add_secondary_y->>Result: Configure axes + combine<br/>traces + frames
    Result-->>User: New figure with<br/>secondary y-axis
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 Figures dance and overlay blend,
Secondary axes around the bend,
Traces merge through animation frames,
Subplots validate their nested names,
Hoppy plotting, new magic unfolds! 🎨✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the main change: introducing three figure combination utilities (overlay, add_secondary_y, update_traces) as public API exports.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@FBumann FBumann changed the title Feature/combine plots Add figure combination utilities (overlay, add_secondary_y, update_traces) Jan 22, 2026
@FBumann FBumann changed the title Add figure combination utilities (overlay, add_secondary_y, update_traces) feat: add figure combination utilities (overlay, add_secondary_y, update_traces) Jan 22, 2026
@FBumann FBumann merged commit f40ecda into main Jan 22, 2026
8 of 9 checks passed
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@xarray_plotly/figures.py`:
- Around line 86-129: The zip call in _merge_frames uses the Python 3.10-only
keyword strict=False which breaks on Python 3.9; remove the strict=False
argument and use zip(overlays, overlay_trace_counts) so the loop still pairs
overlays with their trace counts while remaining Python ≥3.9 compatible (keep
the rest of the logic in _merge_frames, including handling overlay.frames vs
overlay.data and construction of go.Frame).
- Around line 227-336: The secondary y-axes are being anchored as "free" which
can detach them from subplot domains; update add_secondary_y to anchor each
secondary axis to its matching x-axis instead of "free". In the loop that builds
axis_config (inside add_secondary_y, using y_mapping and layout_prop), compute
the corresponding x-axis name by replacing the leading "y" with "x" (e.g., "y2"
-> "x2") and set axis_config["anchor"] to that x-axis for non-primary axes
(instead of "free"); keep the primary axis anchor behavior unchanged for "y".
This ensures secondary y-axes stay aligned with their facets.
- Around line 31-49: The current _validate_compatible_structure uses axes from
_get_subplot_axes (derived from traces) which misses truly empty facets; update
_validate_compatible_structure to also extract subplot axes from figure.layout
(inspect layout.xaxis*, layout.yaxis*, and any layout.grid/annotations that
indicate subplot references) and union those with overlay_axes/base_axes before
computing extra_axes, or alternatively add a docstring note in
_validate_compatible_structure calling out the limitation for truly empty
categories; reference _validate_compatible_structure and _get_subplot_axes so
you augment the axes set derivation to include layout-defined axes.
♻️ Duplicate comments (1)
tests/test_figures.py (1)

520-521: Duplicate: zip(..., strict=False) requires Python ≥3.10.

Same compatibility concern as in figures.py; align this with python_requires (or remove strict).

Comment on lines +31 to +49
def _validate_compatible_structure(base: go.Figure, overlay: go.Figure) -> None:
"""Validate that overlay's subplot structure is compatible with base.

Args:
base: The base figure.
overlay: The overlay figure to check.

Raises:
ValueError: If overlay has subplots not present in base.
"""
base_axes = _get_subplot_axes(base)
overlay_axes = _get_subplot_axes(overlay)

extra_axes = overlay_axes - base_axes
if extra_axes:
raise ValueError(
f"Overlay figure has subplots not present in base figure: {extra_axes}. "
"Ensure both figures have the same facet structure."
)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Find the _get_subplot_axes function to understand how it works
rg "_get_subplot_axes" --type py -B 2 -A 10

Repository: FBumann/xarray_plotly

Length of output: 2377


🏁 Script executed:

# Look for test files related to this module
fd "test.*\.py$" | grep -i figure | head -20

Repository: FBumann/xarray_plotly

Length of output: 86


🏁 Script executed:

# Check the overall structure of the figures.py file
wc -l xarray_plotly/figures.py

Repository: FBumann/xarray_plotly

Length of output: 93


🏁 Script executed:

# Get full _get_subplot_axes implementation
sed -n '50,70p' xarray_plotly/figures.py

Repository: FBumann/xarray_plotly

Length of output: 739


🏁 Script executed:

# Read test_figures.py to see if there are tests for empty facets
cat tests/test_figures.py

Repository: FBumann/xarray_plotly

Length of output: 22178


🏁 Script executed:

# Get the complete _get_subplot_axes function
sed -n '50,65p' xarray_plotly/figures.py

Repository: FBumann/xarray_plotly

Length of output: 560


🏁 Script executed:

# Look for the full function definition starting from line 50
sed -n '50,70p' xarray_plotly/figures.py | cat -n

Repository: FBumann/xarray_plotly

Length of output: 886


🏁 Script executed:

# Get more context around the function definition
head -100 xarray_plotly/figures.py | tail -50

Repository: FBumann/xarray_plotly

Length of output: 1925


🏁 Script executed:

# Search for _get_subplot_axes function definition
rg "def _get_subplot_axes" -A 15

Repository: FBumann/xarray_plotly

Length of output: 936


🏁 Script executed:

# Check what axes are extracted from a Plotly trace
python3 << 'EOF'
import plotly.graph_objects as go

# Create a simple figure to understand trace axis attributes
fig = go.Figure()
fig.add_trace(go.Scatter(x=[1, 2, 3], y=[1, 2, 3]))
trace = fig.data[0]

# Show attributes related to axes
print("Trace attributes related to axes:")
print(f"xaxis: {getattr(trace, 'xaxis', 'NOT SET')}")
print(f"yaxis: {getattr(trace, 'yaxis', 'NOT SET')}")
print(f"xaxis default: {getattr(trace, 'xaxis', None) or 'x'}")
print(f"yaxis default: {getattr(trace, 'yaxis', None) or 'y'}")
EOF

Repository: FBumann/xarray_plotly

Length of output: 151


🏁 Script executed:

# Search for how empty facets are handled - check layout.xaxis, layout.yaxis definitions
rg "layout\\.xaxis|layout\\.yaxis" --type py -B 2 -A 2 | head -50

Repository: FBumann/xarray_plotly

Length of output: 1754


🏁 Script executed:

# Check how xarray_plotly handles faceting
rg "facet" xarray_plotly/figures.py | head -20

Repository: FBumann/xarray_plotly

Length of output: 717


🏁 Script executed:

# Look for how traces are created with NaN data
rg "dropna|NaN|empty" xarray_plotly/figures.py | head -20

Repository: FBumann/xarray_plotly

Length of output: 47


🏁 Script executed:

# Check if there's a function that directly accesses layout to get axes
rg "layout\." xarray_plotly/figures.py | grep -i "axis\|subplot" | head -20

Repository: FBumann/xarray_plotly

Length of output: 213


🏁 Script executed:

# Search for any handling of empty data or all-NaN columns
rg "all.*nan\|empty.*data" -i xarray_plotly/

Repository: FBumann/xarray_plotly

Length of output: 47


🏁 Script executed:

# Check how xarray_plotly uses plotly.express for faceting
rg "px\." xarray_plotly/ --type py | head -20

Repository: FBumann/xarray_plotly

Length of output: 576


🏁 Script executed:

# Look for the xpx implementation and how it handles facets
fd "*.py" xarray_plotly/ | xargs grep -l "def xpx\|class.*xpx"

Repository: FBumann/xarray_plotly

Length of output: 350


🏁 Script executed:

# Check if there's a main module that defines xpx
head -100 xarray_plotly/__init__.py

Repository: FBumann/xarray_plotly

Length of output: 3006


🏁 Script executed:

# Check if plotly.express creates axes in layout even for empty facets
python3 << 'EOF'
import plotly.express as px
import pandas as pd
import numpy as np

# Create a DataFrame with a category that has all NaN values
df = pd.DataFrame({
    'x': [1, 2, 3, 4, 5],
    'y': [1, 2, 3, np.nan, np.nan],
    'category': ['A', 'A', 'B', 'B', 'B']
})

# Create a faceted plot
fig = px.scatter(df, x='x', y='y', facet_col='category')

# Check the layout
print("Layout keys:", list(fig.layout.to_plotly_json().keys()))
print("\nXaxis configs:")
for key in fig.layout.to_plotly_json():
    if 'xaxis' in key:
        print(f"  {key}: present")

print("\nYaxis configs:")
for key in fig.layout.to_plotly_json():
    if 'yaxis' in key:
        print(f"  {key}: present")

print("\nNumber of traces:", len(fig.data))
print("Trace axes:")
for i, trace in enumerate(fig.data):
    xaxis = getattr(trace, 'xaxis', None) or 'x'
    yaxis = getattr(trace, 'yaxis', None) or 'y'
    print(f"  Trace {i}: ({xaxis}, {yaxis})")
EOF

Repository: FBumann/xarray_plotly

Length of output: 183


🏁 Script executed:

# Create a test case with truly empty facets (facet_col category with no matching data)
python3 << 'EOF'
import plotly.express as px
import pandas as pd
import numpy as np

# Create a DataFrame where one facet category has only NaN data
df = pd.DataFrame({
    'x': [1, 2, 3, 1, 2, 3],
    'y': [1, 2, 3, np.nan, np.nan, np.nan],
    'category': ['A', 'A', 'A', 'B', 'B', 'B']
})

# Create a faceted scatter plot
fig = px.scatter(df, x='x', y='y', facet_col='category')

print("=== Figure with one facet containing only NaN ===")
print("Number of traces:", len(fig.data))
print("Trace axes:")
for i, trace in enumerate(fig.data):
    xaxis = getattr(trace, 'xaxis', None) or 'x'
    yaxis = getattr(trace, 'yaxis', None) or 'y'
    print(f"  Trace {i}: ({xaxis}, {yaxis}), len(y)={len(trace.y)}")

print("\nLayout axes present:")
layout_json = fig.layout.to_plotly_json()
for key in sorted(layout_json.keys()):
    if 'axis' in key:
        print(f"  {key}")
EOF

Repository: FBumann/xarray_plotly

Length of output: 183


🏁 Script executed:

# Check behavior when a facet category is completely missing from data
python3 << 'EOF'
import plotly.express as px
import pandas as pd

# Create a DataFrame where one category is completely missing
df = pd.DataFrame({
    'x': [1, 2, 3],
    'y': [10, 20, 30],
    'category': ['A', 'A', 'A']
})

# Try to facet by a category that has values we want to include
df_with_category = pd.DataFrame({
    'x': [1, 2, 3],
    'y': [10, 20, 30],
    'category': pd.Categorical([0, 0, 0], categories=[0, 1])  # category 1 is empty
})

fig = px.scatter(df_with_category, x='x', y='y', facet_col='category')

print("=== Figure with empty categorical facet ===")
print("Number of traces:", len(fig.data))
print("Trace axes:")
for i, trace in enumerate(fig.data):
    xaxis = getattr(trace, 'xaxis', None) or 'x'
    yaxis = getattr(trace, 'yaxis', None) or 'y'
    print(f"  Trace {i}: ({xaxis}, {yaxis})")

print("\nLayout axes present:")
layout_json = fig.layout.to_plotly_json()
for key in sorted(layout_json.keys()):
    if 'axis' in key:
        print(f"  {key}")
EOF

Repository: FBumann/xarray_plotly

Length of output: 183


🏁 Script executed:

# Install plotly and required dependencies
pip install plotly pandas numpy --quiet

Repository: FBumann/xarray_plotly

Length of output: 514


🏁 Script executed:

# Now test with plotly installed
python3 << 'EOF'
import plotly.express as px
import pandas as pd
import numpy as np

# Create a DataFrame where one facet category has only NaN data
df = pd.DataFrame({
    'x': [1, 2, 3, 1, 2, 3],
    'y': [1, 2, 3, np.nan, np.nan, np.nan],
    'category': ['A', 'A', 'A', 'B', 'B', 'B']
})

# Create a faceted scatter plot
fig = px.scatter(df, x='x', y='y', facet_col='category')

print("=== Figure with one facet containing only NaN ===")
print("Number of traces:", len(fig.data))
print("Trace axes:")
for i, trace in enumerate(fig.data):
    xaxis = getattr(trace, 'xaxis', None) or 'x'
    yaxis = getattr(trace, 'yaxis', None) or 'y'
    print(f"  Trace {i}: ({xaxis}, {yaxis}), len(y)={len(trace.y)}")

print("\nLayout axes present:")
layout_json = fig.layout.to_plotly_json()
for key in sorted(layout_json.keys()):
    if 'axis' in key:
        print(f"  {key}")
EOF

Repository: FBumann/xarray_plotly

Length of output: 183


🏁 Script executed:

# Test with completely missing category (using Categorical with unused categories)
python3 << 'EOF'
import plotly.express as px
import pandas as pd

# Create DataFrame with categorical data where one category has no data
df = pd.DataFrame({
    'x': [1, 2, 3],
    'y': [10, 20, 30],
    'category': pd.Categorical([0, 0, 0], categories=[0, 1])  # category 1 is empty
})

fig = px.scatter(df, x='x', y='y', facet_col='category')

print("=== Figure with empty categorical facet ===")
print("Number of traces:", len(fig.data))
print("Trace axes:")
for i, trace in enumerate(fig.data):
    xaxis = getattr(trace, 'xaxis', None) or 'x'
    yaxis = getattr(trace, 'yaxis', None) or 'y'
    print(f"  Trace {i}: ({xaxis}, {yaxis})")

print("\nLayout axes present:")
layout_json = fig.layout.to_plotly_json()
for key in sorted(layout_json.keys()):
    if 'axis' in key:
        print(f"  {key}")
EOF

Repository: FBumann/xarray_plotly

Length of output: 183


🏁 Script executed:

# Check if plotly is installed and where
python3 -m pip list | grep -i plotly

Repository: FBumann/xarray_plotly

Length of output: 111


🏁 Script executed:

# Try installing with user flag
pip install --user plotly pandas numpy --quiet && python3 << 'EOF'
import sys
sys.path.insert(0, '/home/jailuser/.local/lib/python3.11/site-packages')
import plotly.express as px
import pandas as pd
import numpy as np

# Test 1: NaN data in a facet
df = pd.DataFrame({
    'x': [1, 2, 3, 1, 2, 3],
    'y': [1, 2, 3, np.nan, np.nan, np.nan],
    'category': ['A', 'A', 'A', 'B', 'B', 'B']
})

fig = px.scatter(df, x='x', y='y', facet_col='category')
print("=== Test 1: One facet with only NaN data ===")
print("Number of traces:", len(fig.data))
traces_axes = set()
for i, trace in enumerate(fig.data):
    xaxis = getattr(trace, 'xaxis', None) or 'x'
    yaxis = getattr(trace, 'yaxis', None) or 'y'
    traces_axes.add((xaxis, yaxis))
    print(f"Trace {i}: ({xaxis}, {yaxis})")
print("Axes from traces:", traces_axes)

layout_json = fig.layout.to_plotly_json()
layout_axes = set()
for key in sorted(layout_json.keys()):
    if key.startswith('xaxis'):
        layout_axes.add(('x' + key[5:], 'y' + key[5:]))
print("Expected layout axes:", layout_axes)
print()

# Test 2: Empty categorical
df2 = pd.DataFrame({
    'x': [1, 2, 3],
    'y': [10, 20, 30],
    'category': pd.Categorical([0, 0, 0], categories=[0, 1])
})

fig2 = px.scatter(df2, x='x', y='y', facet_col='category')
print("=== Test 2: Empty categorical facet ===")
print("Number of traces:", len(fig2.data))
traces_axes2 = set()
for i, trace in enumerate(fig2.data):
    xaxis = getattr(trace, 'xaxis', None) or 'x'
    yaxis = getattr(trace, 'yaxis', None) or 'y'
    traces_axes2.add((xaxis, yaxis))
    print(f"Trace {i}: ({xaxis}, {yaxis})")
print("Axes from traces:", traces_axes2)

layout_json2 = fig2.layout.to_plotly_json()
layout_axes2 = set()
for key in sorted(layout_json2.keys()):
    if key.startswith('xaxis'):
        layout_axes2.add(('x' + key[5:], 'y' + key[5:]))
print("Expected layout axes:", layout_axes2)
EOF

Repository: FBumann/xarray_plotly

Length of output: 400


🏁 Script executed:

# Check if xarray_plotly creates figures differently than plotly.express
rg "go\.Figure\|go\.Scatter\|go\.Bar" xarray_plotly/plotting.py | head -20

Repository: FBumann/xarray_plotly

Length of output: 47


🏁 Script executed:

# Look at the plotting module to see if it ever manually creates axes
head -200 xarray_plotly/plotting.py

Repository: FBumann/xarray_plotly

Length of output: 5405


🏁 Script executed:

# Examine the test that validates extra subplots
rg "test_overlay_with_extra_subplots_raises" tests/test_figures.py -A 10

Repository: FBumann/xarray_plotly

Length of output: 545


🏁 Script executed:

# Check if there are any integration tests or real-world scenarios
rg "empty\|nan\|NaN" tests/test_figures.py | head -20

Repository: FBumann/xarray_plotly

Length of output: 47


🏁 Script executed:

# Look for documentation or comments about this limitation
rg "limitation\|limitation\|edge case\|empty" xarray_plotly/figures.py | head -20

Repository: FBumann/xarray_plotly

Length of output: 47


🏁 Script executed:

# Check if there's any existing handling for deriving axes from layout
rg "layout\.xaxis\|layout\.yaxis" xarray_plotly/figures.py -B 3 -A 3

Repository: FBumann/xarray_plotly

Length of output: 47


🏁 Script executed:

# Find all places where _validate_compatible_structure is called
rg "_validate_compatible_structure" xarray_plotly/figures.py -B 2 -A 2

Repository: FBumann/xarray_plotly

Length of output: 394


🏁 Script executed:

# Test with xarray_plotly to see if it behaves differently than px
python3 << 'EOF'
import sys
sys.path.insert(0, '/home/jailuser/.local/lib/python3.11/site-packages')
sys.path.insert(0, '.')

import numpy as np
import xarray as xr
from xarray_plotly import xpx

# Test 1: xarray with all-NaN in one facet
da = xr.DataArray(
    np.array([[1, 2, 3], [np.nan, np.nan, np.nan]]),
    dims=['facet', 'x'],
    coords={'facet': ['A', 'B'], 'x': [1, 2, 3]},
    name='value'
)

fig = xpx(da).line(facet_col='facet')

print("=== xarray_plotly with all-NaN facet ===")
print("Number of traces:", len(fig.data))
traces_axes = set()
for i, trace in enumerate(fig.data):
    xaxis = getattr(trace, 'xaxis', None) or 'x'
    yaxis = getattr(trace, 'yaxis', None) or 'y'
    traces_axes.add((xaxis, yaxis))
    print(f"Trace {i}: ({xaxis}, {yaxis}), len={len(trace.y)}")
print("Axes from traces:", traces_axes)
EOF

Repository: FBumann/xarray_plotly

Length of output: 183


🏁 Script executed:

# Now test the validation function directly
python3 << 'EOF'
import sys
sys.path.insert(0, '/home/jailuser/.local/lib/python3.11/site-packages')
sys.path.insert(0, '.')

import numpy as np
import xarray as xr
from xarray_plotly import xpx
from xarray_plotly.figures import _get_subplot_axes, _validate_compatible_structure

# Create base without facets
da_base = xr.DataArray(
    [1, 2, 3],
    dims=['x'],
    coords={'x': [1, 2, 3]},
    name='base'
)

# Create overlay with facets
da_overlay = xr.DataArray(
    np.array([[1, 2, 3], [np.nan, np.nan, np.nan]]),
    dims=['facet', 'x'],
    coords={'facet': ['A', 'B'], 'x': [1, 2, 3]},
    name='overlay'
)

base_fig = xpx(da_base).line()
overlay_fig = xpx(da_overlay).line(facet_col='facet')

print("=== Validation Test ===")
print("Base axes:", _get_subplot_axes(base_fig))
print("Overlay axes:", _get_subplot_axes(overlay_fig))
print("Overlay traces:", len(overlay_fig.data))

try:
    _validate_compatible_structure(base_fig, overlay_fig)
    print("Validation passed (unexpected)")
except ValueError as e:
    print(f"Validation failed (expected): {e}")
EOF

Repository: FBumann/xarray_plotly

Length of output: 183


🏁 Script executed:

# Install dependencies
pip install xarray --quiet

Repository: FBumann/xarray_plotly

Length of output: 47


🏁 Script executed:

# Now test with xarray_plotly
python3 << 'EOF'
import sys
sys.path.insert(0, '/home/jailuser/.local/lib/python3.11/site-packages')
sys.path.insert(0, '.')

import numpy as np
import xarray as xr
from xarray_plotly import xpx
from xarray_plotly.figures import _get_subplot_axes

# Test 1: xarray with all-NaN in one facet
da = xr.DataArray(
    np.array([[1.0, 2.0, 3.0], [np.nan, np.nan, np.nan]]),
    dims=['facet', 'x'],
    coords={'facet': ['A', 'B'], 'x': [1, 2, 3]},
    name='value'
)

fig = xpx(da).line(facet_col='facet')

print("=== xarray_plotly with all-NaN facet ===")
print("Number of traces:", len(fig.data))
print("_get_subplot_axes result:", _get_subplot_axes(fig))
traces_axes = set()
for i, trace in enumerate(fig.data):
    xaxis = getattr(trace, 'xaxis', None) or 'x'
    yaxis = getattr(trace, 'yaxis', None) or 'y'
    traces_axes.add((xaxis, yaxis))
    print(f"  Trace {i}: ({xaxis}, {yaxis}), len(y)={len(trace.y) if trace.y else 0}")
EOF

Repository: FBumann/xarray_plotly

Length of output: 183


🏁 Script executed:

# Let me verify the plotly behavior one more time with a clearer test
python3 << 'EOF'
import sys
sys.path.insert(0, '/home/jailuser/.local/lib/python3.11/site-packages')

import plotly.express as px
import pandas as pd
import numpy as np

# Test case from the review comment: all-NaN data
df = pd.DataFrame({
    'x': [1, 2, 3, 1, 2, 3],
    'y': [1.0, 2.0, 3.0, np.nan, np.nan, np.nan],
    'facet': ['A', 'A', 'A', 'B', 'B', 'B']
})

fig = px.scatter(df, x='x', y='y', facet_col='facet')

print("Test: One facet with all-NaN values (the example from review)")
print("-" * 60)
print(f"Number of traces created: {len(fig.data)}")
print(f"Are traces created for all-NaN facet? {len(fig.data) > 1}")
print()

# Extract axes from traces
axes_from_traces = set()
for trace in fig.data:
    xaxis = getattr(trace, 'xaxis', None) or 'x'
    yaxis = getattr(trace, 'yaxis', None) or 'y'
    axes_from_traces.add((xaxis, yaxis))
    
print(f"Axes extracted from traces: {axes_from_traces}")
print()
print("Conclusion: plotly.express DOES create traces for all-NaN data,")
print("so _get_subplot_axes WILL detect these axes.")
print()

# Now test truly empty facets
print("=" * 60)
print("Test: Truly empty categorical (no rows for that value)")
print("-" * 60)

df2 = pd.DataFrame({
    'x': [1, 2, 3],
    'y': [10.0, 20.0, 30.0],
    'facet': pd.Categorical([0, 0, 0], categories=[0, 1])
})

fig2 = px.scatter(df2, x='x', y='y', facet_col='facet')

print(f"Number of traces created: {len(fig2.data)}")
print(f"Are traces created for empty facet category? {len(fig2.data) > 1}")
print()

axes_from_traces2 = set()
for trace in fig2.data:
    xaxis = getattr(trace, 'xaxis', None) or 'x'
    yaxis = getattr(trace, 'yaxis', None) or 'y'
    axes_from_traces2.add((xaxis, yaxis))
    
print(f"Axes extracted from traces: {axes_from_traces2}")
print()
print("Conclusion: plotly.express does NOT create traces for empty categories,")
print("so _get_subplot_axes will NOT detect these axes.")
EOF

Repository: FBumann/xarray_plotly

Length of output: 845


🏁 Script executed:

# Check if the review's specific example (all-NaN) is the actual concern
# or if it's truly empty facets
rg "all.*nan\|all-NaN" --type py xarray_plotly/

Repository: FBumann/xarray_plotly

Length of output: 47


🏁 Script executed:

# Check the review one more time and understand what it's really saying
cat << 'EOF'
The review says:
"This relies only on trace axes; if the base layout defines subplots with no traces 
(e.g., facets with all‑NaN data), overlays targeting those axes will be rejected."

But my tests show:
- all-NaN data DOES create traces (so axes ARE detected)
- truly empty categorical facets don't create traces (so axes are NOT detected)

The example given (all-NaN) is INCORRECT based on how plotly.express works.
EOF

Repository: FBumann/xarray_plotly

Length of output: 483


🏁 Script executed:

# Verify by checking the test for extra subplots - what exactly does it test?
sed -n '80,95p' tests/test_figures.py

Repository: FBumann/xarray_plotly

Length of output: 570


Clarify the empty-facet scenario—the all-NaN example is incorrect.

The concern about deriving axes only from traces is valid, but the example is misleading. Plotly Express creates traces even for all-NaN data, so those axes are detected. The actual limitation occurs only with truly empty categories (where a facet has no rows at all)—a rare edge case not covered by tests. Consider documenting this limitation or updating the validation to derive axes from the layout definition as well.

🤖 Prompt for AI Agents
In `@xarray_plotly/figures.py` around lines 31 - 49, The current
_validate_compatible_structure uses axes from _get_subplot_axes (derived from
traces) which misses truly empty facets; update _validate_compatible_structure
to also extract subplot axes from figure.layout (inspect layout.xaxis*,
layout.yaxis*, and any layout.grid/annotations that indicate subplot references)
and union those with overlay_axes/base_axes before computing extra_axes, or
alternatively add a docstring note in _validate_compatible_structure calling out
the limitation for truly empty categories; reference
_validate_compatible_structure and _get_subplot_axes so you augment the axes set
derivation to include layout-defined axes.

Comment on lines +86 to +129
def _merge_frames(
base: go.Figure,
overlays: list[go.Figure],
base_trace_count: int,
overlay_trace_counts: list[int],
) -> list:
"""Merge animation frames from base and overlay figures.

Args:
base: The base figure with animation frames.
overlays: List of overlay figures (may or may not have frames).
base_trace_count: Number of traces in the base figure.
overlay_trace_counts: Number of traces in each overlay figure.

Returns:
List of merged frames.
"""
import plotly.graph_objects as go

merged_frames = []

for base_frame in base.frames:
frame_name = base_frame.name
merged_data = list(base_frame.data)

for overlay, _overlay_trace_count in zip(overlays, overlay_trace_counts, strict=False):
if overlay.frames:
# Find matching frame in overlay
overlay_frame = next((f for f in overlay.frames if f.name == frame_name), None)
if overlay_frame:
merged_data.extend(overlay_frame.data)
else:
# Static overlay: replicate traces to this frame
merged_data.extend(overlay.data)

merged_frames.append(
go.Frame(
data=merged_data,
name=frame_name,
traces=list(range(base_trace_count + sum(overlay_trace_counts))),
)
)

return merged_frames
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find Python version requirements
echo "=== Searching for Python version requirements ==="
fd -a 'pyproject.toml|setup.cfg|setup.py' -t f | while read file; do
  echo "--- $file ---"
  cat "$file" | rg -n "requires-python|python_requires|Programming Language :: Python" || echo "(no match)"
done

echo -e "\n=== Searching for zip(strict=...) usages ==="
rg -n "zip\(.*strict=" -g'*.py'

Repository: FBumann/xarray_plotly

Length of output: 186


🌐 Web query:

xarray_plotly Python version requirements pyproject.toml

💡 Result:

The xarray_plotly package supports Python 3.9 and newer. A suitable pyproject.toml requires-python entry is, for example:

requires-python = ">=3.9"

Sources: PiWheels package page and package security listing. [1][2]

Sources

  • PiWheels — xarray-plotly (package releases / Python compatibility). [1]
  • Safety DB / xarray-plotly (latest release metadata). [2]

🌐 Web query:

Python zip strict parameter 3.10 compatibility

💡 Result:

Short answer: The zip(strict=...) keyword was added in Python 3.10 (PEP 618). strict defaults to False; set strict=True to make zip raise ValueError if iterables differ in length. Code using zip(..., strict=...) is not compatible with Python versions older than 3.10 — use a runtime version check or a fallback (e.g. itertools.zip_longest plus length checks) if you must support older Pythons. [1][2][3]

Sources:

  • PEP 618 (Add Optional Length-Checking To zip). [1]
  • Python docs — builtins (Changed in version 3.10: Added the strict argument). [2]
  • Real Python article describing zip(strict=True). [3]

References:
[1] PEP 618 — Add Optional Length-Checking To zip.
[2] Python built-ins documentation (zip) — changed in 3.10.
[3] Real Python — Using the Python zip() function.


Remove the strict=False parameter from zip() to maintain Python 3.9 compatibility.

The strict parameter for zip() was added in Python 3.10. The project supports Python ≥3.9, so this code breaks on Python 3.9 with TypeError: zip() got an unexpected keyword argument 'strict'. Since strict=False is the default behavior anyway and both lists are derived from the same source, removing it maintains compatibility without affecting functionality.

Proposed fix
-        for overlay, _overlay_trace_count in zip(overlays, overlay_trace_counts, strict=False):
+        for overlay, _overlay_trace_count in zip(overlays, overlay_trace_counts):
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _merge_frames(
base: go.Figure,
overlays: list[go.Figure],
base_trace_count: int,
overlay_trace_counts: list[int],
) -> list:
"""Merge animation frames from base and overlay figures.
Args:
base: The base figure with animation frames.
overlays: List of overlay figures (may or may not have frames).
base_trace_count: Number of traces in the base figure.
overlay_trace_counts: Number of traces in each overlay figure.
Returns:
List of merged frames.
"""
import plotly.graph_objects as go
merged_frames = []
for base_frame in base.frames:
frame_name = base_frame.name
merged_data = list(base_frame.data)
for overlay, _overlay_trace_count in zip(overlays, overlay_trace_counts, strict=False):
if overlay.frames:
# Find matching frame in overlay
overlay_frame = next((f for f in overlay.frames if f.name == frame_name), None)
if overlay_frame:
merged_data.extend(overlay_frame.data)
else:
# Static overlay: replicate traces to this frame
merged_data.extend(overlay.data)
merged_frames.append(
go.Frame(
data=merged_data,
name=frame_name,
traces=list(range(base_trace_count + sum(overlay_trace_counts))),
)
)
return merged_frames
def _merge_frames(
base: go.Figure,
overlays: list[go.Figure],
base_trace_count: int,
overlay_trace_counts: list[int],
) -> list:
"""Merge animation frames from base and overlay figures.
Args:
base: The base figure with animation frames.
overlays: List of overlay figures (may or may not have frames).
base_trace_count: Number of traces in the base figure.
overlay_trace_counts: Number of traces in each overlay figure.
Returns:
List of merged frames.
"""
import plotly.graph_objects as go
merged_frames = []
for base_frame in base.frames:
frame_name = base_frame.name
merged_data = list(base_frame.data)
for overlay, _overlay_trace_count in zip(overlays, overlay_trace_counts):
if overlay.frames:
# Find matching frame in overlay
overlay_frame = next((f for f in overlay.frames if f.name == frame_name), None)
if overlay_frame:
merged_data.extend(overlay_frame.data)
else:
# Static overlay: replicate traces to this frame
merged_data.extend(overlay.data)
merged_frames.append(
go.Frame(
data=merged_data,
name=frame_name,
traces=list(range(base_trace_count + sum(overlay_trace_counts))),
)
)
return merged_frames
🤖 Prompt for AI Agents
In `@xarray_plotly/figures.py` around lines 86 - 129, The zip call in
_merge_frames uses the Python 3.10-only keyword strict=False which breaks on
Python 3.9; remove the strict=False argument and use zip(overlays,
overlay_trace_counts) so the loop still pairs overlays with their trace counts
while remaining Python ≥3.9 compatible (keep the rest of the logic in
_merge_frames, including handling overlay.frames vs overlay.data and
construction of go.Frame).

Comment on lines +227 to +336
def add_secondary_y(
base: go.Figure,
secondary: go.Figure,
*,
secondary_y_title: str | None = None,
) -> go.Figure:
"""Add a secondary y-axis with traces from another figure.

Creates a new figure with the base figure's layout and secondary y-axes
on the right side. All traces from the secondary figure are plotted against
the secondary y-axes. Supports faceted figures when both have matching
facet structure.

Args:
base: The base figure (left y-axis).
secondary: The figure whose traces use the secondary y-axis (right).
secondary_y_title: Optional title for the secondary y-axis.
If not provided, uses the secondary figure's y-axis title.

Returns:
A new figure with both primary and secondary y-axes.

Raises:
ValueError: If facet structures don't match, or if animation
frames don't match.

Example:
>>> import numpy as np
>>> import xarray as xr
>>> from xarray_plotly import xpx, add_secondary_y
>>>
>>> # Two variables with different scales
>>> temp = xr.DataArray([20, 22, 25, 23], dims=["time"], name="Temperature (°C)")
>>> precip = xr.DataArray([0, 5, 12, 2], dims=["time"], name="Precipitation (mm)")
>>>
>>> temp_fig = xpx(temp).line()
>>> precip_fig = xpx(precip).bar()
>>> combined = add_secondary_y(temp_fig, precip_fig)
>>>
>>> # With facets
>>> data = xr.DataArray(np.random.rand(10, 3), dims=["x", "facet"])
>>> fig1 = xpx(data).line(facet_col="facet")
>>> fig2 = xpx(data * 100).bar(facet_col="facet") # Different scale
>>> combined = add_secondary_y(fig1, fig2)
"""
import plotly.graph_objects as go

# Get axis pairs from both figures
base_axes = _get_subplot_axes(base)
secondary_axes = _get_subplot_axes(secondary)

# Validate same facet structure
if base_axes != secondary_axes:
raise ValueError(
f"Base and secondary figures must have the same facet structure. "
f"Base has {base_axes}, secondary has {secondary_axes}."
)

# Validate animation compatibility
_validate_animation_compatibility(base, secondary)

# Build mapping from primary y-axes to secondary y-axes
y_mapping = _build_secondary_y_mapping(base_axes)

# Create new figure with base's layout
combined = go.Figure(layout=copy.deepcopy(base.layout))

# Add all traces from base (primary y-axis)
for trace in base.data:
combined.add_trace(copy.deepcopy(trace))

# Add all traces from secondary, remapped to secondary y-axes
for trace in secondary.data:
trace_copy = copy.deepcopy(trace)
original_yaxis = getattr(trace_copy, "yaxis", None) or "y"
trace_copy.yaxis = y_mapping[original_yaxis]
combined.add_trace(trace_copy)

# Configure secondary y-axes
for primary_yaxis, secondary_yaxis in y_mapping.items():
# Get title - only set on first secondary axis or use provided title
title = None
if secondary_y_title is not None:
# Only set title on the first secondary axis to avoid repetition
if primary_yaxis == "y":
title = secondary_y_title
elif primary_yaxis == "y" and secondary.layout.yaxis and secondary.layout.yaxis.title:
# Try to get from secondary's layout
title = secondary.layout.yaxis.title.text

# Configure the secondary axis
axis_config = {
"title": title,
"overlaying": primary_yaxis,
"side": "right",
"anchor": "free" if primary_yaxis != "y" else None,
}
# Remove None values
axis_config = {k: v for k, v in axis_config.items() if v is not None}

# Convert y2 -> yaxis2, y3 -> yaxis3, etc. for layout property name
layout_prop = "yaxis" if secondary_yaxis == "y" else f"yaxis{secondary_yaxis[1:]}"
combined.update_layout(**{layout_prop: axis_config})

# Handle animation frames
if base.frames:
merged_frames = _merge_secondary_y_frames(base, secondary, y_mapping)
combined.frames = merged_frames

return combined
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Plotly secondary y-axis anchor overlaying subplots facets best practice

💡 Result:

Short best-practices for using secondary y-axes with Plotly subplots/facets

  1. Prefer make_subplots(..., specs=[[{"secondary_y": True}, ...], ...]) to create per-cell secondary axes rather than manually adding yaxisN. This keeps axes tied to the correct subplot. [1][2]

  2. When you must overlay axes, set overlaying and side explicitly (e.g. overlaying='y', side='right') and use anchor/position/domain so the overlayed axis sits beside the correct subplot instead of covering others. Use anchor='free' with position to place multiple axis ticks without collision. [2][6]

  3. Use automargin / autoshift (and fig.update_layout(..., automargin=True) or yaxisN.autoshift=True) so Plotly shifts tick labels when multiple axes would overlap. This reduces label collisions for stacked/adjacent subplots. [3][5]

  4. Keep each subplot’s axes scoped to its domain (don’t reuse a single y axis across different subplot domains) — if you need shared behavior, share data scales by transforming data rather than forcing a shared axis across different subplot domains. This avoids paining traces on top of each other or mis-anchored axes. [1][7]

  5. For complex overlays (many axes in one cell) consider:

    • manually set domain/position for each yaxis to control exact placement, and
    • prefer inset subplots if you want axes that truly overlay a specific cell. These tools are supported by make_subplots. [1][2]
  6. Test interactively and iterate: use small examples to verify anchor/overlaying/position behavior before applying to full facet grids; consult the Plotly docs and community examples for patterns that match your layout. [1][3][6]

Selected references:

  • Plotly subplots & make_subplots (secondary_y, insets, domain/position). [1][2]
  • Examples / community tips on overlaying, autoshift and automargin. [3][5]
  • StackOverflow / forum examples showing domain/overlaying/position fixes for multi-axis subplots. [6][7]

🌐 Web query:

Plotly yaxis anchor x-axis pairing overlaying faceted subplots domain

💡 Result:

Short answer: control axis pairing in faceted/stacked Plotly subplots by (1) using make_subplots (it assigns xaxis/yaxis ids and domains), (2) set each axis's anchor to the corresponding opposite-axis id (e.g. xaxis anchor='y2'), and (3) use overlaying/side/position/domain when you want axes to share space or overlay. Example below shows a 3-row column of subplots where each x is anchored to its subplot's y, and a second y (y2) is overlaid on the first and placed on the right via domain/overlaying/side.

References: Plotly axes docs and make_subplots behavior. [1][2][3]

Example (Python, minimal):

from plotly.subplots import make_subplots
import plotly.graph_objects as go

fig = make_subplots(rows=3, cols=1) # creates xaxis/xaxis2/xaxis3 and yaxis/yaxis2/yaxis3 with domains

traces (assign row, col to use the subplot's axes)

fig.add_trace(go.Scatter(x=[1,2,3], y=[1,2,3], name='A'), row=1, col=1)
fig.add_trace(go.Scatter(x=[1,2,3], y=[2,1,3], name='B'), row=2, col=1)
fig.add_trace(go.Scatter(x=[1,2,3], y=[3,1,2], name='C'), row=3, col=1)

add a secondary (overlaid) y-axis for row=2 using the same x-axis but right side

fig.update_layout(

explicitly anchor xaxes to their matching yaxes if you need non-default anchors

xaxis=dict(anchor='y'), # xaxis ↔ yaxis
xaxis2=dict(anchor='y2'), # xaxis2 ↔ yaxis2 (make_subplots normally sets this)
xaxis3=dict(anchor='y3'),

create/modify an overlaid y-axis (y4) and anchor it to xaxis2 (row2)

yaxis4=dict(overlaying='y2', side='right', anchor='x2', title='Right Y (row2)'),

optionally tune domains/positions if you want tighter control

yaxis2=dict(domain=[0.33,0.66]),
yaxis3=dict(domain=[0.0,0.33])
)

fig.show()

Notes / tips:

  • make_subplots prepopulates axis ids and domains; inspect fig.layout or use print_grid()/plotly_json to see exact ids and domains. [2][3]
  • overlaying requires the same axis-letter id (overlaying='y' or overlaying='y2'); set side='right' to place on opposite side; if anchor='free' use position to place the axis in normalized coordinates. [1][2]
  • For faceted plots made with plotly.express, axis linking/anchors are handled differently (use matches or facet options). [1]

Sources:
[1] Plotly axes docs — "Setting the domain", overlaying/position/anchor.
[2] plotly.subplots.make_subplots reference (how axis ids/domains are assigned).
[3] StackOverflow discussion on anchoring xaxes to specific yaxes in subplots.


Anchor secondary axes to their matching x-axes in faceted layouts.

Using anchor="free" without a position property can detach secondary y-axes from their subplot domains, misaligning them in faceted layouts. Anchor each secondary y-axis to its corresponding x-axis (x, x2, x3, …) so they remain aligned with their facets.

🛠️ Proposed fix
-        axis_config = {
-            "title": title,
-            "overlaying": primary_yaxis,
-            "side": "right",
-            "anchor": "free" if primary_yaxis != "y" else None,
-        }
+        anchor = "x" if primary_yaxis == "y" else f"x{primary_yaxis[1:]}"
+        axis_config = {
+            "title": title,
+            "overlaying": primary_yaxis,
+            "side": "right",
+            "anchor": anchor,
+        }
🤖 Prompt for AI Agents
In `@xarray_plotly/figures.py` around lines 227 - 336, The secondary y-axes are
being anchored as "free" which can detach them from subplot domains; update
add_secondary_y to anchor each secondary axis to its matching x-axis instead of
"free". In the loop that builds axis_config (inside add_secondary_y, using
y_mapping and layout_prop), compute the corresponding x-axis name by replacing
the leading "y" with "x" (e.g., "y2" -> "x2") and set axis_config["anchor"] to
that x-axis for non-primary axes (instead of "free"); keep the primary axis
anchor behavior unchanged for "y". This ensures secondary y-axes stay aligned
with their facets.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant