Skip to content

Commit 939664f

Browse files
FBumannclaude
andcommitted
fix(add_secondary_y): legend defaults that don't fight the secondary axis
Anchor the legend to the figure container's right edge (xref="container", xanchor="right") and keep its vertical position in paper coords (top of plot area). Combined with automargin=True on each secondary y-axis, Plotly reserves space for the axis title between the plot and the legend, so they no longer overlap. Existing user-set legend.x/y are preserved. Also expand docs/examples/combining.ipynb with two edge-case examples to visually verify features compose: - Multi-trace within facets on both axes - overlay -> add_secondary_y composition Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent df5f02e commit 939664f

3 files changed

Lines changed: 174 additions & 22 deletions

File tree

docs/examples/combining.ipynb

Lines changed: 110 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,94 @@
472472
"cell_type": "markdown",
473473
"id": "29",
474474
"metadata": {},
475+
"source": [
476+
"### Multi-Trace + Facets\n",
477+
"\n",
478+
"Both axes carry multiple traces *within* each facet. This combines facet\n",
479+
"structure, multi-trace legendgroups, and the secondary y-axis layout."
480+
]
481+
},
482+
{
483+
"cell_type": "code",
484+
"execution_count": null,
485+
"id": "30",
486+
"metadata": {},
487+
"outputs": [],
488+
"source": [
489+
"# Synthetic 3D data: month x sensor x site, on two scales (Temperature vs Power).\n",
490+
"# facet_col=\"site\" splits into facets; the \"sensor\" dimension produces multiple\n",
491+
"# traces within each facet, identically named on both axes.\n",
492+
"import numpy as np\n",
493+
"\n",
494+
"rng = np.random.default_rng(0)\n",
495+
"months = np.arange(1, 13)\n",
496+
"sensors = [\"A\", \"B\", \"C\"]\n",
497+
"sites = [\"North\", \"South\"]\n",
498+
"\n",
499+
"temp_da = xr.DataArray(\n",
500+
" 20 + rng.standard_normal((12, 3, 2)) * 5,\n",
501+
" dims=[\"month\", \"sensor\", \"site\"],\n",
502+
" coords={\"month\": months, \"sensor\": sensors, \"site\": sites},\n",
503+
" name=\"Temperature (°C)\",\n",
504+
")\n",
505+
"power_da = xr.DataArray(\n",
506+
" 400 + rng.standard_normal((12, 3, 2)) * 80,\n",
507+
" dims=[\"month\", \"sensor\", \"site\"],\n",
508+
" coords={\"month\": months, \"sensor\": sensors, \"site\": sites},\n",
509+
" name=\"Power (W)\",\n",
510+
")\n",
511+
"\n",
512+
"temp_fig = xpx(temp_da).line(facet_col=\"site\", markers=True)\n",
513+
"power_fig = xpx(power_da).line(facet_col=\"site\")\n",
514+
"\n",
515+
"combined = add_secondary_y(temp_fig, power_fig)\n",
516+
"combined.update_layout(title=\"Sensor Temperature (left) vs Power (right) — faceted by site\")\n",
517+
"combined"
518+
]
519+
},
520+
{
521+
"cell_type": "markdown",
522+
"id": "31",
523+
"metadata": {},
524+
"source": [
525+
"### Composing `overlay` with `add_secondary_y`\n",
526+
"\n",
527+
"`add_secondary_y` accepts any base figure — including one already produced by `overlay`. Useful when you want to overlay a trend on the primary axis, then bring in a second variable on a secondary axis."
528+
]
529+
},
530+
{
531+
"cell_type": "code",
532+
"execution_count": null,
533+
"id": "32",
534+
"metadata": {},
535+
"outputs": [],
536+
"source": [
537+
"# Primary axis: GOOG daily price + 20-day moving average (via overlay).\n",
538+
"# Secondary axis: AAPL daily price for comparison.\n",
539+
"goog = stocks.sel(company=\"GOOG\")\n",
540+
"goog_ma = goog.rolling(date=20, center=True).mean()\n",
541+
"goog_ma.name = \"GOOG 20-day MA\"\n",
542+
"\n",
543+
"price_fig = xpx(goog).scatter()\n",
544+
"price_fig.update_traces(marker={\"size\": 4, \"opacity\": 0.5}, name=\"GOOG Daily\")\n",
545+
"ma_fig = xpx(goog_ma).line()\n",
546+
"ma_fig.update_traces(line={\"color\": \"red\", \"width\": 2}, name=\"GOOG 20-day MA\")\n",
547+
"\n",
548+
"base = overlay(price_fig, ma_fig)\n",
549+
"\n",
550+
"aapl = stocks.sel(company=\"AAPL\")\n",
551+
"aapl_fig = xpx(aapl).line()\n",
552+
"aapl_fig.update_traces(line={\"color\": \"green\", \"width\": 2}, name=\"AAPL\")\n",
553+
"\n",
554+
"combined = add_secondary_y(base, aapl_fig, secondary_y_title=\"AAPL Price\")\n",
555+
"combined.update_layout(title=\"GOOG (left, raw + MA) vs AAPL (right)\")\n",
556+
"combined"
557+
]
558+
},
559+
{
560+
"cell_type": "markdown",
561+
"id": "33",
562+
"metadata": {},
475563
"source": [
476564
"## subplots\n",
477565
"\n",
@@ -481,7 +569,7 @@
481569
},
482570
{
483571
"cell_type": "markdown",
484-
"id": "30",
572+
"id": "34",
485573
"metadata": {},
486574
"source": [
487575
"### Different Variables Side by Side"
@@ -490,7 +578,7 @@
490578
{
491579
"cell_type": "code",
492580
"execution_count": null,
493-
"id": "31",
581+
"id": "35",
494582
"metadata": {},
495583
"outputs": [],
496584
"source": [
@@ -510,7 +598,7 @@
510598
},
511599
{
512600
"cell_type": "markdown",
513-
"id": "32",
601+
"id": "36",
514602
"metadata": {},
515603
"source": [
516604
"### 2x2 Grid\n",
@@ -521,7 +609,7 @@
521609
{
522610
"cell_type": "code",
523611
"execution_count": null,
524-
"id": "33",
612+
"id": "37",
525613
"metadata": {},
526614
"outputs": [],
527615
"source": [
@@ -538,7 +626,7 @@
538626
},
539627
{
540628
"cell_type": "markdown",
541-
"id": "34",
629+
"id": "38",
542630
"metadata": {},
543631
"source": [
544632
"### Mixed Chart Types\n",
@@ -550,7 +638,7 @@
550638
{
551639
"cell_type": "code",
552640
"execution_count": null,
553-
"id": "35",
641+
"id": "39",
554642
"metadata": {},
555643
"outputs": [],
556644
"source": [
@@ -566,7 +654,7 @@
566654
},
567655
{
568656
"cell_type": "markdown",
569-
"id": "36",
657+
"id": "40",
570658
"metadata": {},
571659
"source": [
572660
"### With Facets\n",
@@ -577,7 +665,7 @@
577665
{
578666
"cell_type": "code",
579667
"execution_count": null,
580-
"id": "37",
668+
"id": "41",
581669
"metadata": {},
582670
"outputs": [],
583671
"source": [
@@ -592,7 +680,7 @@
592680
},
593681
{
594682
"cell_type": "markdown",
595-
"id": "38",
683+
"id": "42",
596684
"metadata": {},
597685
"source": [
598686
"---\n",
@@ -604,7 +692,7 @@
604692
},
605693
{
606694
"cell_type": "markdown",
607-
"id": "39",
695+
"id": "43",
608696
"metadata": {},
609697
"source": [
610698
"### overlay: Mismatched Facet Structure\n",
@@ -615,7 +703,7 @@
615703
{
616704
"cell_type": "code",
617705
"execution_count": null,
618-
"id": "40",
706+
"id": "44",
619707
"metadata": {},
620708
"outputs": [],
621709
"source": [
@@ -633,7 +721,7 @@
633721
},
634722
{
635723
"cell_type": "markdown",
636-
"id": "41",
724+
"id": "45",
637725
"metadata": {},
638726
"source": [
639727
"### overlay: Animated Overlay on Static Base\n",
@@ -644,7 +732,7 @@
644732
{
645733
"cell_type": "code",
646734
"execution_count": null,
647-
"id": "42",
735+
"id": "46",
648736
"metadata": {},
649737
"outputs": [],
650738
"source": [
@@ -662,7 +750,7 @@
662750
},
663751
{
664752
"cell_type": "markdown",
665-
"id": "43",
753+
"id": "47",
666754
"metadata": {},
667755
"source": [
668756
"### overlay: Mismatched Animation Frames\n",
@@ -673,7 +761,7 @@
673761
{
674762
"cell_type": "code",
675763
"execution_count": null,
676-
"id": "44",
764+
"id": "48",
677765
"metadata": {},
678766
"outputs": [],
679767
"source": [
@@ -689,7 +777,7 @@
689777
},
690778
{
691779
"cell_type": "markdown",
692-
"id": "45",
780+
"id": "49",
693781
"metadata": {},
694782
"source": [
695783
"### add_secondary_y: Mismatched Facet Structure\n",
@@ -700,7 +788,7 @@
700788
{
701789
"cell_type": "code",
702790
"execution_count": null,
703-
"id": "46",
791+
"id": "50",
704792
"metadata": {},
705793
"outputs": [],
706794
"source": [
@@ -718,7 +806,7 @@
718806
},
719807
{
720808
"cell_type": "markdown",
721-
"id": "47",
809+
"id": "51",
722810
"metadata": {},
723811
"source": [
724812
"### add_secondary_y: Animated Secondary on Static Base\n",
@@ -729,7 +817,7 @@
729817
{
730818
"cell_type": "code",
731819
"execution_count": null,
732-
"id": "48",
820+
"id": "52",
733821
"metadata": {},
734822
"outputs": [],
735823
"source": [
@@ -747,7 +835,7 @@
747835
},
748836
{
749837
"cell_type": "markdown",
750-
"id": "49",
838+
"id": "53",
751839
"metadata": {},
752840
"source": [
753841
"### add_secondary_y: Mismatched Animation Frames"
@@ -756,7 +844,7 @@
756844
{
757845
"cell_type": "code",
758846
"execution_count": null,
759-
"id": "50",
847+
"id": "54",
760848
"metadata": {},
761849
"outputs": [],
762850
"source": [
@@ -772,7 +860,7 @@
772860
},
773861
{
774862
"cell_type": "markdown",
775-
"id": "51",
863+
"id": "55",
776864
"metadata": {},
777865
"source": [
778866
"## Summary\n",

tests/test_figures.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,38 @@ def test_add_secondary_y_multi_trace_shared_legendgroups(self) -> None:
731731
assert all(t.yaxis == "y" for t in combined.data[:3])
732732
assert all(t.yaxis == "y2" for t in combined.data[3:])
733733

734+
def test_add_secondary_y_legend_anchored_to_container(self) -> None:
735+
"""Default layout anchors the legend to the figure container's right edge,
736+
with automargin on the secondary y-axis so the axis title doesn't overlap."""
737+
da1 = xr.DataArray([1, 2, 3], dims=["x"], name="Temperature")
738+
da2 = xr.DataArray([100, 200, 300], dims=["x"], name="Precipitation")
739+
740+
combined = add_secondary_y(xpx(da1).line(), xpx(da2).bar())
741+
742+
assert combined.layout.legend.x == 1.0
743+
assert combined.layout.legend.xanchor == "right"
744+
assert combined.layout.legend.xref == "container"
745+
assert combined.layout.legend.y == 1.0
746+
assert combined.layout.legend.yanchor == "top"
747+
# yref left as default ("paper") so legend top aligns with plot top,
748+
# not figure top (avoids overlapping the figure title).
749+
assert combined.layout.legend.yref != "container"
750+
# Secondary axis reserves its own margin space.
751+
assert combined.layout.yaxis2.automargin is True
752+
753+
def test_add_secondary_y_preserves_user_legend_position(self) -> None:
754+
"""User-set legend.x/y on the base figure is not overridden."""
755+
da1 = xr.DataArray([1, 2, 3], dims=["x"], name="Temperature")
756+
da2 = xr.DataArray([100, 200, 300], dims=["x"], name="Precipitation")
757+
758+
base = xpx(da1).line()
759+
base.update_layout(legend={"x": 0.5, "y": 0.5})
760+
761+
combined = add_secondary_y(base, xpx(da2).bar())
762+
763+
assert combined.layout.legend.x == 0.5
764+
assert combined.layout.legend.y == 0.5
765+
734766
def test_add_secondary_y_after_overlay_keeps_secondary_visible(self) -> None:
735767
"""overlay → add_secondary_y must not hide the secondary's traces."""
736768
da1 = xr.DataArray(

xarray_plotly/figures.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,9 @@ def add_secondary_y(
583583
"showticklabels": is_rightmost,
584584
# Link non-rightmost axes to the rightmost for consistent scaling
585585
"matches": None if is_rightmost else rightmost_secondary_y,
586+
# Reserve margin space for tick labels and title so the legend
587+
# placed at x>=1 can't clip them.
588+
"automargin": True,
586589
}
587590
# Remove None values
588591
axis_config = {k: v for k, v in axis_config.items() if v is not None}
@@ -605,9 +608,38 @@ def add_secondary_y(
605608
cross_source_dedup=False,
606609
)
607610
_fix_animation_axis_ranges(combined)
611+
_set_default_secondary_y_layout(combined)
608612
return combined
609613

610614

615+
def _set_default_secondary_y_layout(fig: go.Figure) -> None:
616+
"""Anchor the legend to the figure container so it doesn't fight the
617+
secondary y-axis for paper-coordinate space.
618+
619+
With ``xref="container"`` the legend's right edge sits at the figure's
620+
right edge regardless of plot width. Combined with ``automargin=True``
621+
on the secondary y-axes (set in ``add_secondary_y``), Plotly reserves
622+
space for the axis title between the plot and the legend, so the two
623+
do not overlap. Only fields the user has not already set are touched,
624+
so explicit ``update_layout(legend=...)`` on the source figures wins.
625+
"""
626+
legend_defaults: dict[str, Any] = {}
627+
legend = fig.layout.legend
628+
if legend.x is None:
629+
# Container-relative x so the legend sits at the figure's right edge
630+
# rather than fighting the secondary y-axis title for paper-coord space.
631+
legend_defaults["x"] = 1.0
632+
legend_defaults["xanchor"] = "right"
633+
legend_defaults["xref"] = "container"
634+
if legend.y is None:
635+
# Paper-relative y so the legend top aligns with the plot top (below
636+
# the figure title) — same vertical position Plotly uses by default.
637+
legend_defaults["y"] = 1.0
638+
legend_defaults["yanchor"] = "top"
639+
if legend_defaults:
640+
fig.update_layout(legend=legend_defaults)
641+
642+
611643
def _merge_secondary_y_frames(
612644
base: go.Figure,
613645
secondary: go.Figure,

0 commit comments

Comments
 (0)