ENG-10167 pyplot notebook parity: subplot placement, pandas datetime, annotations, ticks, fonts, and client layering - #44
Merged
Conversation
…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.
Merging this PR will not alter performance
Comparing Footnotes
|
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.
…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.
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/shareydomain union and linked pan/zoom apply to free-form figures.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)
1a27f05):series.plot(ax=ax)works —get_xdata(orig=False)returns the engine's canonical ms-since-epoch floats (no moreDTypePromotionErrorin pandas'get_xlim), pandas' period-ordinal tickers are compat-noops, auto-domains include datetime entries.plt.dates+ labeled-minor promotion (d0b465c):MonthLocator/YearLocator/DayLocator/DateFormatterequivalents in ms space, and the centered month-label idiom (blank majors + labeled minors) promotes the labeled pair to the drawn tick set.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)
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 ofMaxNLocator._raw_ticks(full step table, staircase extension,scale_rangeoffsets,integer=,min_n_tickswalk-down, view-overrunning edge ticks clipped at draw time), verified against matplotlib 3.11 over ~5k randomized ranges with zero mismatches.AutoLocatorbecomes 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.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, andsharex/shareyunions autoscale over the panels that have data.Figure chrome parity
ad9a457):subplots(facecolor=...)forwards**fig_kwand paints the figure patch behind notebook,show(), andsavefig(html)output.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
423e020): an opaque--chart-bgon 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.annotatearrows 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/wedgefilled tapered shafts) withconnectionstylearc3/angle3/angle as quadratic curves andalphadimming 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
origin/main(legend/dash parity from Improve pyplot legend and dash parity #42); resolved the import conflicts inpyplot/__init__.pyand_axes.pyand rebuilt the static JS bundles (a27968a).from __future__ import annotations(68b37b8).Verification
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.mdupdated (locator row now documents the exact MaxN/Auto port; plt.dates, datetime/pandas, annotate rows).