Skip to content

Reserve measured axis-label gutter [mpl compatibility]#273

Draft
sselvakumaran wants to merge 2 commits into
agent/gallery-canvas-guttersfrom
agent/measured-axis-label-gutter
Draft

Reserve measured axis-label gutter [mpl compatibility]#273
sselvakumaran wants to merge 2 commits into
agent/gallery-canvas-guttersfrom
agent/measured-axis-label-gutter

Conversation

@sselvakumaran

@sselvakumaran sselvakumaran commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Summary

  • measure the left chrome gutter from the y axis's own tick-label and title extents instead of a flat 46/62 px
  • place the rotated y-axis title where ChartView places it, by applying the box-to-baseline correction the x-axis title already makes
  • emit the rasterizer's DejaVu advances as python/xy/_fontmetrics.py from scripts/gen_font.py, so the reservation is measured in the metrics of the font that draws the ink
  • ship the measured number on the wire from the pyplot notebook path, so browser, SVG, and PNG share one reservation by construction
  • replace the shim-only _explicit_y_tick_gutter added by Reserve canvas room for categorical tick labels [mpl compatibility] #256 rather than adding a second parallel mechanism

Stacked on #256 (agent/gallery-canvas-gutters), which is itself on #240. See Why this base below.

Root cause

Two fixed constants, in the shared static-layout path rather than in the shim.

1. _axis_label_geometry anchored the wrong box edge (python/xy/_svg.py). ChartView renders the y title as a rotated DOM line box: left:10px; transform:rotate(-90deg) translateX(50%); transform-origin:left puts the box's center on x = 10, spanning [10 − 1.2·font/2, 10 + 1.2·font/2]. The static exporters emitted outside_x = 10 as a baseline, so the ink spanned [10 − ascent, 10 + descent] — one full ascent further toward the canvas edge. At the shim's 13.89 px title font that is x −3.0…13.5 instead of the browser's 1.7…18.4, i.e. clipped. The x-axis title already carries exactly this correction (y += font_size * 0.82, "DOM labels use top positioning; static text commands use a baseline"); the y title never got it.

2. layout()'s gutter ignored the text it had to hold (python/xy/_svg.py). left = 46 if compact else 62 fits 11 px numeric ticks under a 12 px title. Matplotlib's rcParams make both 13.89 px at 100 dpi, and 62 px cannot hold 10 px inset + 8.3 px half-line-box + 5.6 px gap + 34.7 px of "5400" + 9.7 px tick pad = 68.2 px.

3. The notebook pin also pinned the browser (python/xy/pyplot/_mplfig.py). _to_notebook_html sets notebook_padding = [dpi*.15, dpi*.20, dpi*.34, dpi*.41] → left = 41 px to match Matplotlib's inline bbox_inches="tight" canvas. layout() is a Python function, so widening it there fixes the static exporters but not the browser, which reads spec.padding directly. 41 px is 27 px short of the ink.

_explicit_y_tick_gutter (#256) could not reach this: it needs tick_labels/categories/string data (a numeric axis returns 0.0) and self._padding is None (false on the notebook path). Its reservation was also shim-only while the defect is general static-export layout, so this lifts the measurement into _svg.layout() — beside _colorbar_right_axis_room — and deletes the shim helper instead of running two mechanisms.

Does core (non-pyplot) layout change?

Only where the text does not fit. The reservation is a floor, not an override — padding and the 46/62 default both stand whenever they already fit, exactly as the colorbar and right-axis room are additive rather than authoritative. A core chart at the default 11 px ticks / 12 px title needs 10 + 7.1 + 4.8 + 27.5 + 4 = 53.4 px < 62, so it is laid out byte-identically; test_ordinary_numeric_axis_keeps_the_default_gutter and test_short_category_ticks_keep_the_core_default_gutter pin that.

What does change for core charts:

  • a left y axis whose measured text exceeds 46/62 gets a wider gutter and a narrower plot in SVG/PNG/PDF (large label_size/tick_label_size, long category names, wide authored tick_labels)
  • the outside y title moves ~1 ascent inward in SVG/PNG/PDF on every chart that has one — that is the convergence onto the browser's placement, not a new offset. Measured: SVG title ink 1.7…18.2 vs the browser's 1.7…18.4 at 13.89 px, previously −3.0…13.5.
  • right-side y axes are unchanged (still flat 42/54). Their title is pinned plot-relative (plot-right + 40) rather than to a canvas inset, so widening only the static gutter would move their title away from the browser's. Documented as deliberate in spec/api/styling.md, not silently fixed.
  • a live core chart with padding=None still gets ChartView's own 46/62; layout() cannot reach it. Only the pyplot notebook path lifts the number onto the wire. Also documented; the browser degrades differently there by design (it ellipsizes overlong categorical tick labels — an SVG has no such fallback, which is why the static side reserves).

Validation

Matplotlib 3.11.1, real examples/pdsh/data/births.csv (15,547 data rows), cell 23 drawn through one shared draw() so both renderers get identical calls. Text.get_window_extent for the reference; SVG text coordinates plus a pixel-ink scan of the native raster for xy.

Matplotlib 3.11.1 inline xy before xy after
y title ink x 10.0 … 24.3 −3.0 … 13.5 1.7 … 18.2
y tick ink x 29.9 … 65.9 −3.4 … 31.3 23.8 … 58.5
title↔tick overlap −5.6 px (clear) +16.5 px −5.6 px (clear)
title↔tick gap 5.6 px −16.9 px 5.6 px
anything clipped at x<0 no title and ticks no
plot x 75.6 41.0 68.2
plot width 930.0 931.0 930.8
canvas 1015 × 371 992 × 356 1019 × 356

The gap lands on Matplotlib's 5.6 px exactly: it is reserved as 0.4 em of the title font, which is what 5.6 px measures at the shared 13.89 px default.

The browser is fixed by the same number, not by a separate rule. The notebook path now ships padding = [15.0, 20.0, 34.0, 68.2], and ChartView's left:10px line box at 1.7…18.3 against tick labels pinned at plot.x − 9.7 gives 23.8…58.5 — overlap +16.5 px → −5.5 px. _svg.layout() on that same spec returns plot.x = 68.2, identical by construction.

Matplotlib 3.11.1 reference / before / after for the y-title-over-tick-labels collision

Not claimed

  • The text was never truncated. The full "average daily births" is present in all three renderers before and after; it read as clipped because of the collision. Nothing here fixes a truncation.
  • The legend is still wrong. Matplotlib puts it outside upper-right; xy puts it inside upper-left. Independent defect, owned by Reserve Matplotlib legend layout space [mpl compatibility] #254 — visible in the panels above and untouched here.
  • The notebook canvas is still not Matplotlib's. 1019 × 356 vs 1015 × 371. The width moved from 23 px narrow to 4 px wide and the plot box holds Matplotlib's 930 px, but the height (356 vs 371) comes from _to_notebook_html's dpi * 0.48/0.77 constants, which this PR does not touch.
  • x-axis chrome is unchanged. The minor-locator month labels centre differently from the reference; not in scope.

Checks

  • full suite: 179 failed, 1941 passed, 11 skippedfailure set identical to the pristine baseline (baseline 180 failed, 1930 passed; the one extra baseline failure is test_examples_commit_no_static_chart_html, which only fails inside the git archive copy used for baselining). The ~180 failures are this environment's missing python/xy/static/*.js bundles, not regressions.
  • new/rewritten: tests/test_axis_title_gutter.py (7) + tests/pyplot/test_gallery_canvas_gutters.py (5) — all pass. No-overlap and not-clipped assertions on emitted SVG geometry for numeric and long-categorical labels, in both the default and notebook-padding paths, plus a real pixel-ink scan of the native raster's left gutter, plus a guard pinning _fontmetrics advances to src/font.rs so a regenerated atlas cannot leave the reservation measuring a different font than the one drawn.
  • cargo test: 114 passed (no Rust change)
  • ruff check, ruff format --check, pre-commit run --all-files: pass
  • ty check: 11 diagnostics on the baseline, 10 here — no new ones; the baseline's invalid-argument-type on sorted went away with the deleted shim helper. The remaining 10 are pre-existing (unresolved anywidget/traitlets/IPython.display imports in this environment, plus 3 long-standing annotation diagnostics).

Measurement provenance

Before and after were rendered from two pinned trees with PYTHONPATH and XY_NATIVE_LIB set, verifying in-process that xy.__file__ and the resolved dylib are the intended tree. This PR touches no Rust, so the control is that the native core is constant: both panels loaded dylib sha256 552d955f…, src/ hashes to de61616e… in both trees, git status src/ Cargo.* is empty, and src/font.rs (the measuring ruler) is 3fbeb135… in both. The Python layout sources provably differ (_svg.py 9ba42c59…7b67342e…), so the pixel and geometry deltas are the Python change.

Not verified

js/src/50_chartview.ts is unchanged, and no JS was built or typechecked: npm/node are unavailable here and python/xy/static/ does not exist. That is deliberate rather than a gap left open — the browser's left:10px is a canvas-edge inset on a centered line box, which is correct once the gutter holds the box, and the arithmetic above shows it landing within 0.2 px of the new static placement. Shipping an unverifiable client change to chase that 0.2 px would trade a measured fix for an unmeasured risk. The residual browser divergence (an over-wide authored padding keeps the browser's title at the canvas edge while the static exporters keep it beside the ticks) is recorded in spec/api/styling.md.

Spec

  • spec/api/styling.md — new Chrome gutters and the rotated y-axis title: the gutter formula, the floor-not-override contract, the box-to-baseline correction, and both deliberate asymmetries (right-side axes, live core charts)
  • spec/matplotlib/compat.md — the ylabel row now states the measured reservation and the 0.4 em reference gap

Why this base

Branched from agent/gallery-canvas-gutters (#256) rather than main so this can replace its shim-only _explicit_y_tick_gutter instead of racing a second gutter mechanism against it. Its long-category case is still covered — by the shared measurement, asserted on emitted geometry rather than on a padding[3] >= 100 threshold. Neither #240 nor #256 was rebased or force-pushed.

Adjacent: #272 (agent/fix-pyplot-frame-geometry, on #206) also edits python/xy/pyplot/_mplfig.py and also pins a plot rect against "the gutters the renderers reserve outside the padding". Different functions — #272 owns _axes_rect/get_position and the multi-panel grid; this owns _to_notebook_html's single-axes branch and the core layout() reservation — but whichever lands second should re-check that the measured left floor and the reported axes rectangle still agree.

The rotated y-axis title was drawn on top of the y tick labels. Two fixed
constants caused it, and each is now measured from the ink it has to hold.

`_axis_label_geometry` anchored the title's *baseline* at a 10 px canvas
inset, while ChartView centers its rotated line *box* on that same inset —
so static exporters drew the title one full ascent further toward the canvas
edge than the browser does. Apply the same box-to-baseline correction the
x-axis title already makes, shifting by (ascent - descent) / 2.

`layout()` handed every chart a flat 46/62 px left gutter. Floor it at what
the axis's own text measures: the title's line box, Matplotlib's 0.4 em
title-to-tick gap, the tick padding, and the widest tick label's advance.
Widths come from the DejaVu advances the Rust rasterizer blits, so the
reservation is measured in the metrics of the font that draws the ink.
`scripts/gen_font.py` now emits those advances as `python/xy/_fontmetrics.py`
alongside `src/font.rs`, from one face in one run.

The floor never overrides: an authored `padding` and the 46/62 default both
stand whenever they already fit, exactly as the colorbar and right-axis room
are additive rather than authoritative. It rises only where the alternative
is ink off the canvas or over the tick labels, which a static export cannot
recover from the way the DOM can.

`_to_notebook_html` pins a padding to match Matplotlib's inline canvas, and
that pin also became the browser's gutter. Ask `layout()` for the measured
number first and ship that, widening the canvas by the shortfall so the plot
box keeps Matplotlib's 0.775 width fraction. Browser, SVG, and PNG then share
one reservation by construction instead of two implementations agreeing.

This subsumes the shim-only `_explicit_y_tick_gutter`, which covered authored
and categorical labels but returned 0 for a numeric axis and was skipped
entirely once a padding was set.
TEMPORARY: PR review asset only, removed before merge. Matplotlib 3.11.1
reference / before / after for the y-title-over-tick-labels collision on
examples/pdsh/pdsh_04_09_text_and_annotation.ipynb cell 23.
@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: 9bd4159c-1259-4e6e-8fa5-ba3b692dad89

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/measured-axis-label-gutter

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

@codspeed-hq

codspeed-hq Bot commented Jul 24, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 102 untouched benchmarks
⏩ 1 skipped benchmark1


Comparing agent/measured-axis-label-gutter (7d766de) with agent/gallery-canvas-gutters (adff265)

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.

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