Skip to content

Fix line and legend gallery compatibility [mpl compatibility]#248

Draft
sselvakumaran wants to merge 10 commits into
mainfrom
agent/line-legend-gallery-compat
Draft

Fix line and legend gallery compatibility [mpl compatibility]#248
sselvakumaran wants to merge 10 commits into
mainfrom
agent/line-legend-gallery-compat

Conversation

@sselvakumaran

Copy link
Copy Markdown
Contributor

What changed

  • support drawstyle="steps" as Matplotlib's steps-pre alias
  • support 2- and 4-value bbox_to_anchor geometry across browser, SVG, and native PNG
  • return a real Legend, support Axes.add_artist(), and render multiple explicit legend boxes
  • implement PathCollection.legend_elements() for color and size handles
  • preserve explicit scatter marker sizes in legends
  • preserve scalar scatter alpha in the native affine fast path
  • treat a NumPy RGBA row as one bar/legend color
  • honor legend frame styles and Matplotlib's default frame alpha across renderers
  • accept technically correct round dash-cap mutation

Why

These were chained blockers in three official Matplotlib 3.11 line/marker gallery examples. Visual QA also caught two silent fidelity bugs after the scripts began exiting: native PNG dropped scatter alpha, and legend frames/styles were not actually applied.

Official references:

Before, Matplotlib references, and after

Gallery impact

  • horizontal_barchart_distribution.py: newly complete, 1/1 output
  • scatter_with_legend.py: newly complete, 3/3 outputs
  • step_demo.py: newly complete, 2/2 outputs
  • line_demo_dash_control.py advances past round dash caps, then reaches the independent gapcolor blocker; legend(handlelength=4) is also still unsupported

The survey's remaining left tick-label clipping is the margin work owned by #240. This PR deliberately does not change global margins/tick spacing.

Validation

  • 543 passed, 65 skipped across the full pyplot suite
  • Ruff check and format
  • TypeScript typecheck
  • production JavaScript bundle build
  • exact official gallery rerenders visually compared with Matplotlib 3.11

@codspeed-hq

codspeed-hq Bot commented Jul 24, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 26.07%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 1 regressed benchmark
✅ 102 untouched benchmarks
⏩ 1 skipped benchmark1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
test_select_lasso_message_1m 89.3 ms 120.8 ms -26.07%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing agent/line-legend-gallery-compat (cf7cae4) with main (9b08085)

Open in CodSpeed

Footnotes

  1. 1 benchmark was skipped, so the baseline result was used instead. If it was deleted from the codebase, click here and archive it to remove it from the performance reports.

@sselvakumaran

Copy link
Copy Markdown
Contributor Author

for the horizontal barchart the xy one is severely cut off - only ‘estion 1’ is visible of ‘question 1’ for the y axis. as well, the borders on the top and bottom of the chart is not visible probably also due to this cutoff. the legend seems fine but it does not include the border it has (is this intentional or style? if a style choice then keep it but if it was asked for in the test then add it).

also the items in the legend seem too far apart (is this a problem with 2x scale?)

@sselvakumaran sselvakumaran changed the title Fix Matplotlib line and legend gallery compatibility Fix line and legend gallery compatibility [mpl compatibility] Jul 24, 2026

sselvakumaran commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

I reproduced the issues in the exact Matplotlib 3.11 horizontal_barchart_distribution gallery example and pushed c411d80.

Before, Matplotlib 3.11 reference, and after comparison for horizontal_barchart_distribution

The three panels are saved, uncropped renders. Both XY panels intentionally retain the clipped Question prefix so that the separate #240 plot-layout/tick-label reservation issue remains visible rather than being hidden by this evidence.

What changed:

  • legend columns now size to their own label content instead of all inheriting the longest label's width;
  • handle/text/column spacing now follows Matplotlib's default proportions;
  • the DOM legend gets an explicit 1 px frame (the prior PR screenshot was stale and predated the frame work);
  • xaxis.set_visible(False) no longer suppresses the independently visible top/bottom Matplotlib spines in the browser, SVG, or PNG renderers.

The 2x export is not what caused the large spaces: those were equal-width legend cells. The new exact rerender has a visible legend frame and all four axes spines. Focused verification: 62 tests pass, Ruff passes, client typecheck/build passes, and the full 88-example gallery rerender completes with this example paired.

I also tested the published #240 head (3b3c2ab) merged with this head. The combined render still clips every y category label to estion 1, etc.; #240 currently changes data autoscale margins but does not reserve layout width for categorical tick-label text. I have kept that separate from this PR rather than duplicating its plot-padding work.

Copy link
Copy Markdown
Contributor Author

Post-c411d80 audit:

  • There are no submitted reviews, inline review threads, or newer human comments after the existing rendering evidence.
  • I rechecked the exact official horizontal_barchart_distribution output against the rerender whose manifest records Matplotlib 3.11.0. The legend frame, per-column spacing, and all four spines now match the requested behavior. The remaining clipped Question prefix is the separate plot-layout/tick-label reservation issue already called out for Fix pyplot autoscale margins and bar extents [mpl compatibility] #240; it is not a remaining line/legend regression.
  • The CodSpeed lasso result is not attributable to this PR. The benchmark and its complete timed path are byte-identical between main and c411d80: benchmarks/test_codspeed_selection.py, python/xy/channel.py, python/xy/_figure.py, python/xy/interaction.py, python/xy/kernels.py, and src/kernels.rs all have identical Git object IDs. This PR changes legend/renderer code only, the other 101 benchmarks are unchanged, and CodSpeed explicitly reports that base and head ran in different runtime environments. I therefore did not add an unrelated selection-path change.
  • The initially canceled queued jobs were infrastructure rather than test failures. I reran them: Install without Rust, Docs tests and lint, and production docs on Python 3.11 and Python 3.12 all pass. The two canceled dynamic CodeQL shards never started and GitHub does not permit retrying that dynamic run; the Python and JavaScript/TypeScript CodeQL shards passed and the aggregate CodeQL check is neutral.

No additional non-#240 code fix was warranted, so the PR remains clean at c411d80.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e62fbc00-eeda-4023-b1f7-4b389573bcd6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch agent/line-legend-gallery-compat

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

The legend surface gained a public anchor option (2- or 4-value normalized
plot-rectangle geometry, the composition-API spelling of Matplotlib
bbox_to_anchor), and an anchored legend is exempt from the clip-to-plot-rect
that bounds static legends. Record both in spec/api/styling.md: the accepted
shapes, the y-up axes-fraction convention, how loc selects the pinned point,
the dropped containment clamp, and the narrowed static-export guarantee
including the per-legend/per-frame divergence between the SVG and native
raster backends.
The public legend(anchor=) option exempts an anchored legend from the
plot-rectangle clip that bounds static legends, so it can sit outside the
axes. _svg.py scopes that exemption per legend: each legend group keeps or
drops its own clip-path from its own options. The native rasterizer scoped
it per frame instead, computing one flag over the main legend and every
extra and gating the whole clip pass on it, so a single anchored legend
un-clipped every other legend in the figure.

Switch the raster pass to decide per legend. The clip is a stateful
command, so the rectangle is swapped only on a transition: an all-anchored
or all-bounded figure emits exactly the stream it did before, while a mixed
figure now clips its bounded legends and exempts only the anchored ones.

The browser needs no equivalent change -- each legend box is already its
own overflow-auto scroll container sized by --xy-legend-max-width/-height,
so the constraint is per element there rather than per frame.
The legend frame was sized from `len(label) * _LEGEND_CHAR_WIDTH`, a flat
per-character average. That cannot bound a proportional face: DejaVu's `m`
is over three times the width of its `l`, so "gamma" sets 42.6 px at the
11 px legend font against a 31.0 px estimate. Sizing each column to its own
labels removed the slack that had been hiding the error, and the 1 px border
then landed 7.6 px inside its own labels -- "alpha" and "gamma" visibly
crossed it.

Measure instead, using the advances the native rasterizer already blits.
`scripts/gen_font.py` now emits `python/xy/_fontmetrics.py` beside
`src/font.rs`, so the two tables cannot drift. Labels and the title are
ellipsized against the same measured budget, so a truncated string also fits
the column it was sized for. This is what the browser already does natively,
sizing each legend column to `max-content`, so all three renderers agree.

Measured for entries alpha/beta/gamma at 640x480 -- the widest label's right
edge relative to the frame's, where negative means the label overruns:

    merge-base   +8.375
    before       -7.625
    after        +4.000

Matplotlib insets the widest label from its frame by borderpad, +5.56 px on
the equivalent figure, so the sign and scale now agree.

Measuring the title's real extent also settles the titled-legend truncation
this branch had made worse: "Classes" over short entries rendered "Cla..." at
the merge-base and regressed to "Cl...", and now renders "Clas...". The
remaining budget bug -- the box grows by one border pad but the title is
ellipsized against two -- is deliberately left alone; it belongs to the
font-relative legend metrics stacked on top of this branch.

Also close the raster frame rectangle. `_rect_pts` is four corners, and
stroking it as an open polyline painted top/right/bottom while silently
dropping the left edge. Matplotlib's frame is a FancyBboxPatch with all four
sides, as are SVG's `<rect>` and the browser's CSS border.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant