Skip to content

ENG-10167 pyplot notebook parity: subplot placement, pandas datetime, annotations, ticks, fonts, and client layering - #44

Merged
Alek99 merged 14 commits into
mainfrom
claude/matplotlib-subplot-fix-zk9lbj
Jul 15, 2026
Merged

ENG-10167 pyplot notebook parity: subplot placement, pandas datetime, annotations, ticks, fonts, and client layering#44
Alek99 merged 14 commits into
mainfrom
claude/matplotlib-subplot-fix-zk9lbj

Conversation

@FarhanAliRaza

@FarhanAliRaza FarhanAliRaza commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Driven by the PDSH ch. 4 dual-engine notebooks (matplotlib reference vs xy.pyplot, cell by cell), this branch closes the compatibility gaps that made real notebooks render wrong or crash. One concern per commit.

Figure & panel layout

  • subplots_adjust + absolute free-form panel placement (ee96469): add_axes rects, inset axes, and GridSpec spans now place panels at their matplotlib figure rectangles in HTML/SVG (the PNG compositor already did), with document order giving draw order. sharex/sharey domain union and linked pan/zoom apply to free-form figures.
  • Notebook grid parity, optional controls, upright imshow (e0041af): dense subplot grids ship one document with one render client instead of an iframe per panel (Chrome's WebGL context cap blanked later panels), panels land on exact gridspec rectangles, interactive controls default off like matplotlib inline, and canvas imshow matches the exporters' row orientation.

Datetime axes (PDSH 04.09)

  • pandas datetime plotting (1a27f05): series.plot(ax=ax) works — get_xdata(orig=False) returns the engine's canonical ms-since-epoch floats (no more DTypePromotionError in pandas' get_xlim), pandas' period-ordinal tickers are compat-noops, auto-domains include datetime entries.
  • plt.dates + labeled-minor promotion (d0b465c): MonthLocator/YearLocator/DayLocator/DateFormatter equivalents in ms space, and the centered month-label idiom (blank majors + labeled minors) promotes the labeled pair to the drawn tick set.
  • date-string text coordinates (062fce4): ax.text("2012-1-1", ...) parses on datetime axes instead of raising at display time; categorical axes keep strings.

Tick locators (PDSH 04.10)

  • exact MaxNLocator port (24fe1d1): the old nice-step heuristic on the (1, 2, 2.5, 5, 10) table couldn't produce matplotlib's ticks — MaxNLocator(3) on a unit axis gave 0/0.5/1 where matplotlib labels 0.0/0.4/0.8. Now a faithful port of MaxNLocator._raw_ticks (full step table, staircase extension, scale_range offsets, integer=, min_n_ticks walk-down, view-overrunning edge ticks clipped at draw time), verified against matplotlib 3.11 over ~5k randomized ranges with zero mismatches. AutoLocator becomes what it is in matplotlib — MaxNLocator(nbins="auto") — with the axes-size tick budget wired in, so explicit locators adapt to panel size like the default axis does.
  • dataless axes view exactly (0, 1) (94e127f): empty panels had no explicit domain, so the engine's 3% autorange padding widened the live-client view and dropped the 0.5 midpoint tick at small-panel tick budgets (0/1 instead of 0.0/0.5/1.0; exporters were unpadded, so image audits missed it). Dataless axes now pin matplotlib's exact default view, and sharex/sharey unions autoscale over the panels that have data.

Figure chrome parity

  • facecolor (ad9a457): subplots(facecolor=...) forwards **fig_kw and paints the figure patch behind notebook, show(), and savefig(html) output.
  • font sizes (75a7c5a): default-rc tick labels and text render at matplotlib's font.size-derived 13.9 px instead of the client's 11 px fallback, across HTML/SVG/PNG.

Annotations

  • client layering fix (423e020): an opaque --chart-bg on the WebGL marks canvas was hiding everything the chrome canvas drew inside the plot rect — grid lines, axvline/axvspan, arrows — in the live client only (exporters were fine, so image audits missed it). The marks canvas now clears transparent; the plot background paints under the grid.
  • matplotlib-shaped annotate arrows and boxes (48dcf8c): offset-point annotations become engine callouts (arrow pinned label→point across zoom); arrowstyles map to real shapes (-> open V, -|> filled, |-| bar caps, fancy/simple/wedge filled tapered shafts) with connectionstyle arc3/angle3/angle as quadratic curves and alpha dimming only the arrow; bbox= renders as the label's box (fill/edge/round corners/pad). Path geometry is shared by all three renderers (_arrowgeom.py + its JS mirror).

Side-by-side renders of the PDSH annotation cell (mpl left, xy right):
the births plot's curved/tapered arrows, H-bar, and padded boxes now match.

Branch health

  • Merged origin/main (legend/dash parity from Improve pyplot legend and dash parity #42); resolved the import conflicts in pyplot/__init__.py and _axes.py and rebuilt the static JS bundles (a27968a).
  • Fixed the Python 3.11 floor gate: the new test file needed from __future__ import annotations (68b37b8).

Verification

  • Full suite 1805 passed; ruff check/format, ty, pre-commit, ABI + WebGL render smokes green.
  • Regression tests per bug in tests/pyplot/test_pdsh_gap_features.py (locator tick values pinned to matplotlib 3.11, grid render labels, callout conversion, arrowstyle/connectionstyle mapping, date-string endpoints, bbox styles, arrow-only alpha, export parity, explicit font sizes).
  • docs/matplotlib-compat.md updated (locator row now documents the exact MaxN/Auto port; plt.dates, datetime/pandas, annotate rows).

FarhanAliRaza and others added 2 commits July 14, 2026 20:29
…TML/SVG

Free-form figures (add_axes rects, plt.axes([l, b, w, h]) insets, GridSpec
spans) previously rendered as side-by-side CSS-grid panels in notebook HTML
and grid-tiled SVG, even though the PNG compositor already positioned them.
compose_html/compose_svg now take the same whole-panel positions the PNG
path uses and place panels absolutely on a fixed-size canvas, with document
order providing matplotlib's draw order (insets render on top).

Figure.subplots_adjust(left/right/top/bottom/wspace/hspace) is implemented
instead of raising NotImplementedError: values update the SubplotParams
frame, subplot grids resolve to per-cell figure rectangles via the GridSpec
geometry, and all three exporters position panels at those rectangles.
Out-of-order frames raise ValueError like matplotlib. sharex/sharey domain
union and pan/zoom linking now also apply to free-form figures.

Verified against matplotlib 3.11 references: inset-over-axes, stacked
add_axes sin/cos, and the PDSH 2x3 subplots_adjust grid; the two formerly
failing PDSH subplots_adjust cells (04.08, 04.10) now run and export.
@codspeed-hq

codspeed-hq Bot commented Jul 15, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 80 untouched benchmarks
⏩ 1 skipped benchmark1


Comparing claude/matplotlib-subplot-fix-zk9lbj (94e127f) with main (bba11a0)

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.

A dense subplot grid (8x8 imshow) rendered as a broken wall of panels:
zoom controls and scrollbars on every 120px-floored cell, ~40% of the
grid hidden behind scrollbars, only the first ~dozen panels painted,
and every image upside down on canvas. Four root causes:

- Interactive controls are now off by default in the shim, matching
  Matplotlib's inline backend (rcParams["toolbar"] = "none"; opt back in
  with figure/subplots toolbar=True or rcParams["toolbar"]="toolbar2").
  The client also auto-hides a modebar that cannot fit its plot box —
  the overflowing buttons alone grew scrollbars on every panel.

- Multi-panel grids now place each plot box at its matplotlib gridspec
  rectangle on a figsize canvas instead of a CSS grid of 120px-floored
  cells, so a 6-inch 8x8 grid occupies exactly 600px like matplotlib.
  The client honors small explicit chart sizes (fluid floors unchanged).

- Panel composition ships one document with one render client instead
  of a sandboxed iframe per panel: 64 iframes each carried a full JS
  bundle and their own WebGL context governor, exceeding Chrome's
  per-page context cap so later panels never rendered. The shared
  governor now snapshots a panel's last frame into a 2D stand-in before
  releasing its context, letting visible over-budget panels keep
  looking rendered (pointer entry revives a live context).

- The WebGL heatmap sampled grid rows in data space while the raster
  and SVG exporters anchor row 0 to the display-space bottom edge, so
  the same spec drew inverted-y imshow upside down on canvas but
  upright in every export (which is why image-parity audits missed
  it). Draw and hover now follow the exporters' convention.

The PDSH dual-engine sync tool now prefixes cross-cell state variables
(fig/ax/grid) per engine so a "# Matplotlib reference" cell can no
longer read an xy object written by the paired xy cell (this crashed
mpl_plt.subplot(grid[...]) with an xy _SubplotSpec); corpus regenerated.
pandas' timeseries plot path calls get_xdata(orig=False) expecting
matplotlib's unit-converted floats back, and installs its own
period-ordinal tickers; the shim returned raw datetime64 (np.nanmin
over datetime64 vs float raised DTypePromotionError inside get_xlim)
and pandas' TimeSeries_Date* tickers raised NotImplementedError.
Datetime-likes now convert to the engine's canonical f64 ms since
epoch (columns.py), pandas.plotting tickers are accepted as
compat-noops so the native date ticks keep rendering, auto-domains
include datetime entries, and Figure.get_axes plus
axis.get_major/minorticklabels exist for pandas' touch-up loops.
MonthLocator/YearLocator/DayLocator/DateFormatter equivalents of the
matplotlib.dates classes gallery scripts use, locating and formatting
in the engine's canonical ms-since-epoch axis unit (interval
approximates rrule by epoch-anchored occurrence counting). The engine
draws a single tick set, so a labeled minor pair under a blanked major
formatter — the PDSH centered month-label idiom — is promoted to the
drawn set. The dual-engine sync script now maps `import xy.pyplot as
mpl` back to `import matplotlib as mpl` so reference cells stop
calling mpl.dates on matplotlib.pyplot; corpus notebooks regenerated.
ax.text("2012-1-1", 3950, ...) on a date axis raised "annotation.x
must be a finite coordinate" at notebook display time: matplotlib's
pandas-registered converter parses such strings, the shim treated them
as categorical everywhere. Date strings now parse at build time
(strptime, unpadded fields included) when the axis holds datetimes;
categorical axes keep their strings. Also accept annotate(size=) as
matplotlib's fontsize alias.
subplots(facecolor=...) painted nothing: subplots() silently dropped
matplotlib's **fig_kw, and the html export wrapped the document in a
colored div that an opaque body{background:#fff} then covered. fig_kw
now forwards to figure(), and the facecolor lands as a later
same-element-specificity body background override in the document
head — one path shared by _repr_html_, show(), and the savefig html
branch (which had the same latent div-wrap flaw).
Default-rc charts rendered tick labels and text at the render client's
11 px fallback instead of matplotlib's font.size-derived 13.9 px
(10 pt at dpi 100), because the shim only emitted sizes when rc values
differed from "medium". Axis styles now always carry tick_label_size
and label_size, and @text entries default their font_size, so the HTML
client, SVG, and PNG all match matplotlib's inline text size.
The WebGL marks canvas sits above the chrome canvas over the plot
rect; clearing it to an opaque --chart-bg hid everything chrome draws
inside the plot — grid lines, rules, bands, and annotation arrows —
in the live client only. The static exporters were unaffected, which
is why image-parity audits never caught it, and pyplot always sets
--chart-bg, so every shim chart lost its grid and axvline/axvspan in
notebooks. The marks canvas now always clears transparent and
_drawChrome paints the plot background at the bottom of the chrome
canvas, preserving bg → grid → annotation shapes → data marks
stacking.
annotate(arrowprops=) drew only a straight data-space shaft and
silently dropped arrows for offset-point text; bbox= was discarded.
Offset-placed annotations now become engine callouts (the arrow stays
pinned from label to point across zoom), date-string coordinates
convert on datetime axes, and arrowstyles map to real shapes: "->" an
open V, "-|>" filled, "|-|"/brackets bar caps, fancy/simple/wedge
filled tapered shafts sized by the text's mutation scale — with
connectionstyle arc3/angle3/angle as quadratic curves (arc3 rad bulge;
angle control points at the departure/arrival ray intersection) and
alpha dimming only the arrow. bbox= becomes label box styles (fill,
edge, round corners, pad) on the HTML label.

Path geometry is shared by all three renderers: _arrowgeom.py for the
SVG/raster exporters and its mirror in 51_annotations.js for the
canvas client (kept in sync by comment contract). The exporters gain
the callout kind and label_color/vertical_align; client labels get
width:max-content (edge labels wrapped word-per-word before the
translate shift) and a shape-style filter so an arrow's shaft width
can never land as CSS width on its label.
@FarhanAliRaza FarhanAliRaza changed the title Implement subplots_adjust and absolute free-form panel placement in HTML/SVG pyplot notebook parity: subplot placement, pandas datetime, annotations, fonts, and client layering Jul 15, 2026
…plot-fix-zk9lbj

# Conflicts:
#	python/xy/pyplot/__init__.py
#	python/xy/pyplot/_axes.py
scripts/check_python_floor.py requires annotated files to use
`from __future__ import annotations`; the new gap-feature tests added a
typed helper without it, failing the floor gate in CI.
@FarhanAliRaza FarhanAliRaza changed the title pyplot notebook parity: subplot placement, pandas datetime, annotations, fonts, and client layering ENG-10167 pyplot notebook parity: subplot placement, pandas datetime, annotations, fonts, and client layering Jul 15, 2026
@linear-code

linear-code Bot commented Jul 15, 2026

Copy link
Copy Markdown

MaxNLocator used a nice-step heuristic on the (1, 2, 2.5, 5, 10) table,
so MaxNLocator(3) on a unit axis gave 0/0.5/1 where matplotlib labels
0.0/0.4/0.8 (PDSH 04.10). Port MaxNLocator._raw_ticks faithfully: the
full (1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10) step table with the staircase
extension, scale_range offsets, integer filtering, min_n_ticks walk-down,
and edge ticks that overrun the view (the axis clips them like
matplotlib trims at draw time). AutoLocator becomes what it is in
matplotlib — MaxNLocator(nbins="auto") on the restricted step table —
with the axes-size tick budget wired in from the Axes, so explicit
AutoLocator/MaxNLocator("auto") users now adapt to panel size the same
way the default axis does. Verified against matplotlib 3.11 across ~5k
randomized ranges with zero mismatches.
An empty matplotlib axis views exactly (0, 1) — margins only apply to
data. The shim left dataless axes without a domain, so the engine's 3%
autorange padding widened the live-client view to (-0.03, 1.03); at the
small-panel tick budget of 2 that snapped the step from 0.5 to 1 and the
PDSH 04.10 default 4x4 grid lost its 0.5 midpoint tick (labels 0/1
instead of 0.0/0.5/1.0). Static exporters used the unpadded domain,
so image audits never saw it. Pin (0, 1) as an explicit domain on
dataless axes, and make sharex/sharey unions autoscale over the panels
that have data so a dataless panel follows its group instead of dragging
(0, 1) into the shared limits.
@FarhanAliRaza FarhanAliRaza changed the title ENG-10167 pyplot notebook parity: subplot placement, pandas datetime, annotations, fonts, and client layering ENG-10167 pyplot notebook parity: subplot placement, pandas datetime, annotations, ticks, fonts, and client layering Jul 15, 2026
@Alek99
Alek99 merged commit 036bdd7 into main Jul 15, 2026
28 checks passed
FarhanAliRaza added a commit that referenced this pull request Jul 15, 2026
* fix(pyplot): close the eight parity gaps from the PR #44 review

Independent adversarial testing against Matplotlib 3.11 found eight
reproducible defects in the notebook-parity seams; every reference
value below was re-verified against 3.11.0.

- MaxNLocator: keep scale/offset math for narrow far-offset ranges
  instead of bailing to (1, 0), which left the axis tickless.
- Absolute panels: axes titles widen panel chrome instead of eating
  26-30px of the requested plot rect (matplotlib never moves the axes).
- Dataless axes: the (0, 1) empty view applies to the render snapshot
  only; data plotted after a render autoscales as if never rendered.
- GridSpec spans keep their SubplotSpec so subplots_adjust re-resolves
  their rects; cell_rect falls back per-key to the figure's adjusted
  SubplotParams (subplots_adjust also overrides set_position, as 3.11
  does for subplotspec-backed axes).
- Calendar locators: interval counting is phased at matplotlib's
  view-derived dtstart (vmin - relativedelta(vmax, vmin)) rather than
  the 1970 epoch; month occurrences stride all months so bymonth acts
  as an rrule filter.
- Third-party locators are never mutated: the _nbins_hint density hint
  is only written to xy Locator instances.
- annotate arrows survive an empty label or empty arrowprops dict, and
  offset placement is detected from the entry itself, not the defaulted
  copy.
- Figure facecolor specs (RGBA tuples et al.) normalize through
  resolve_color before any HTML escaping.

* fix(pyplot): annotation arrows clear their labels; baseline label anchoring

Two visual parity defects against matplotlib 3.11 (PDSH 04.09 scenes):

- Annotation arrows started exactly at the text anchor, striking through
  their own label ("local minimum") and hiding inside bbox patches.
  matplotlib clips every annotation arrow at the text patch (patchA +
  shrink). The shared arrow geometry (python/xy/_arrowgeom.py +
  js/src/51_annotations.js, kept in sync) now accepts a `label_clear`
  "left,right,up,down" rectangle — the label's estimated extents around
  the start point — and trims the start to where the departure tangent
  exits it. annotate() emits it for data-coordinate arrows and offset
  callouts, sized from text length, font, ha/va, and bbox padding, with
  matplotlib's visible ~0.35em + 2pt gap.

- The DOM client top-anchored annotation labels while the SVG/raster
  exporters (and matplotlib) anchor the text BASELINE at its position, so
  client labels sat a line below the other renderers ("Halloween" under
  its arrow instead of beside it). The client default is now baseline
  (translate -100% + 0.35em), with va top/center/bottom unchanged.

- Bbox'd labels (annotate bbox=...) anchored by their box edge, eating
  matplotlib's clearance and overlapping ("Thanksgiving"/"Christmas");
  the padded box now grows outward around the anchored text edge, as a
  matplotlib bbox patch does.

* fix(pyplot): annotation arrows attach at the text-patch center, test-first

The baseline-anchored arrow start from the previous commit put the shaft
under the label: matplotlib attaches annotation arrows at the text patch
CENTER (relpos default (0.5, 0.5)), clipped at the patch edge — measured
on 3.11.0: for "local minimum" at 13.89px the patch spans
anchor+[0,106]x[-11.0,+3.3] (y-down) and the arrow starts at
(+111.6, -5.5), not at the baseline.

The shared geometry gains `start_offset` ("x,y" px, applied before
control points and tangents); the shim emits anchor->box-center offsets
with box-centered symmetric `label_clear` extents. Rendered pixel
relations are now pinned quantitatively: the tests run the real pipeline
(entries -> spec -> px transform -> shared arrow geometry) and assert
the recorded matplotlib 3.11.0 start positions within a few px — both
the rightward (patch-edge exit) and up-left (patch-TOP exit) cases, so
future attach regressions fail in pytest instead of surfacing in
notebook screenshots.
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.

2 participants