diff --git a/docs/matplotlib-compat-changelog.md b/docs/matplotlib-compat-changelog.md index 925b5f67..f40e2a1e 100644 --- a/docs/matplotlib-compat-changelog.md +++ b/docs/matplotlib-compat-changelog.md @@ -242,5 +242,23 @@ colorbar domains) fully cleared. using typed list reductions; the complete shippable Python package now type checks cleanly. +### Free-form layout parity — 2026-07-14 (Matplotlib 3.11.0 reference) + +- HTML output now places free-form panels absolutely at their figure + rectangles on a fixed-size canvas, matching the PNG compositor: `add_axes` + rects stack and overlap exactly as in Matplotlib (an inset `plt.axes([...])` + draws on top of a default axes instead of rendering as a side-by-side grid + panel), with document order providing Matplotlib's draw order. SVG export + gained the same absolute-placement path. +- `Figure.subplots_adjust(left=, right=, top=, bottom=, wspace=, hspace=)` is + now implemented instead of raising `NotImplementedError` (which superseded + the earlier silent discard): the values update the figure's SubplotParams + frame, subplot grids resolve to per-cell figure rectangles through the + GridSpec geometry (wspace/hspace as fractions of the average cell size, like + Matplotlib), and all three exporters position panels at those rectangles. + Out-of-order frames (`left >= right`, `bottom >= top`) raise `ValueError` + as in Matplotlib. This clears the two loud PDSH `subplots_adjust` cells + (04.08 2×3 labeled grid, 04.10 5×5 zero-gap faces). + Future entries must identify the Matplotlib release/revision, inventory additions or removals, and any compatibility-level changes. diff --git a/docs/matplotlib-compat.md b/docs/matplotlib-compat.md index d07bb468..3b613c51 100644 --- a/docs/matplotlib-compat.md +++ b/docs/matplotlib-compat.md @@ -64,16 +64,17 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `quiver`, `barbs`, `streamplot` | Native vector endpoint/arrowhead and bounded streamline kernels feeding one instanced segment mark. Barbs are a visual approximation: magnitude maps to a bounded tick count, not WMO 50/10/5 increments. Streamplot always uses the shim's own bounded fixed-step integrator (identical output with or without Matplotlib installed, but paths approximate Matplotlib's adaptive ones); `start_points`, `integration_direction`, array widths/colors and `num_arrows` are honored, and remaining non-default integration options fail loudly | | `tripcolor`, `triplot`, `tricontour`, `tricontourf` | Explicit topology or native dependency-free Delaunay triangulation; indexed geometry and isolines stay in Rust | | `pie` / `pie_label` | Native pie/donut tessellation and the Matplotlib 3.11 `PieContainer` (`values`, `fracs`, grouped text labels) | -| `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported; `arrowprops` is approximated as callout text | +| `axhline` / `axvline` / `axhspan` / `axvspan`, `text`, `annotate`, `table` | Fractional span bounds plus data/axes/figure text coordinates are supported. `annotate(arrowprops=)` draws real arrows in every output: offset-point text becomes an engine callout (arrow pinned from label to point across zoom), data-coordinate text an arrow annotation; date-string coordinates convert on datetime axes. Arrowstyles map to head/tail shapes (`->` open V, `-|>` filled, `\|-\|`/brackets bar caps, `fancy`/`simple`/`wedge` filled tapered shafts sized by the text's mutation scale) and `connectionstyle` arc3/angle3/angle become quadratic curves (corner rounding approximated); `alpha` dims the arrow only. `bbox=` becomes label box styles (fill/edge/round corners/`pad`) on the HTML label; static exporters keep the plain label | | `xlabel` / `ylabel` / `title` / `suptitle` | Suptitles are retained in HTML and multi-panel PNG/SVG | | `legend()` | `loc`, columns, title/font size/colors, frame styling, `borderpad`, `labelspacing`, `fancybox`, `framealpha`, and `shadow` are retained across browser and static output. `loc='best'` chooses the least occupied corner from bounded samples of the current data | | `grid(True/False)` | toggles the grid via the theme | | `xlim` / `ylim`, axis scales, `invert_xaxis/yaxis` | linear/log are native; symlog/logit/asinh use dependency-free monotone data transforms with inverse limit/tick semantics. Automatic linear ticks include Matplotlib's 2.5 step and use uniform decimal padding across a tick set; locations refresh as data arrives. Artist `get_data()` reflects the transformed space; logit masks values at/outside (0, 1) | -| `set_major_locator` / `set_major_formatter`, `plt.NullLocator/FixedLocator/MultipleLocator/MaxNLocator/LinearLocator/LogLocator`, `plt.NullFormatter/FixedFormatter/FuncFormatter/FormatStrFormatter/StrMethodFormatter/ScalarFormatter` | xy-owned re-implementations resolved at build time against live data limits (Null/Fixed/Multiple are position-exact; MaxN is a nice-step heuristic, not Matplotlib's edge-extension algorithm). Third-party locator objects work if they implement `tick_values(vmin, vmax)`; minor locators/formatters are retained for round-tripping but minor ticks do not render | -| datetime, timedelta, and string coordinates | datetime inputs use the engine's automatic date ticks, timedeltas are bounded to elapsed seconds, and common strings use categorical ticks; the general Matplotlib units registry is intentionally out of scope | +| `set_major_locator` / `set_major_formatter`, `plt.NullLocator/FixedLocator/MultipleLocator/MaxNLocator/LinearLocator/LogLocator`, `plt.NullFormatter/FixedFormatter/FuncFormatter/FormatStrFormatter/StrMethodFormatter/ScalarFormatter` | xy-owned re-implementations resolved at build time against live data limits (Null/Fixed/Multiple/Linear are position-exact; MaxN/Auto port Matplotlib's `MaxNLocator._raw_ticks` — same step tables, edge extension, and offset handling — with `nbins="auto"` budgeted from the estimated plot rect like `Axis.get_tick_space()`; Log remains approximate). Third-party locator objects work if they implement `tick_values(vmin, vmax)`; minor locators/formatters are retained for round-tripping but minor ticks do not render, except that a labeled minor pair under a blanked major formatter (the centered date-label idiom) is promoted to the drawn tick set | +| `plt.dates.MonthLocator/YearLocator/DayLocator/DateFormatter` | xy-owned equivalents of the `matplotlib.dates` classes gallery scripts use; they locate and format in the engine's canonical ms-since-epoch axis unit (not Matplotlib's day floats), and `interval` approximates rrule by epoch-anchored occurrence counting | +| datetime, timedelta, and string coordinates | datetime inputs use the engine's automatic date ticks, timedeltas are bounded to elapsed seconds, and common strings use categorical ticks; the general Matplotlib units registry is intentionally out of scope. pandas datetime plotting (`series.plot(ax=ax)`) works against that contract: `get_{x,y}data(orig=False)` returns ms-since-epoch floats, and pandas' period-ordinal tickers (`TimeSeries_Date*`) are accepted as no-ops so the native date ticks keep rendering | | `xticks(positions, labels, rotation=)` / `tick_params(labelrotation=)` | Exact positions and strings render in browser, PNG, and SVG | | `twinx()`, `secondary_xaxis()`, `secondary_yaxis()` | second data axes and linked tick-only secondary axes with callable forward/inverse conversions. Secondary-axis ticks are evenly spaced conversions of the primary domain (not Matplotlib's secondary-unit locators) and currently reach the interactive HTML client only — PNG/SVG export does not draw them yet | -| `fig, ax = plt.subplots()`; `plt.subplots(n, m, figsize=, dpi=, squeeze=, sharex=, sharey=)` | Grid renders as CSS-grid HTML and stitched PNG/SVG; shared axes use common domains and live linked pan/zoom. `Figure.subplots_adjust(...)` is rejected because those frame/spacing values are not representable by this renderer; use `GridSpec(..., wspace=, hspace=)` for supported explicit layout | +| `fig, ax = plt.subplots()`; `plt.subplots(n, m, figsize=, dpi=, squeeze=, sharex=, sharey=)` | Grid renders as CSS-grid HTML and stitched PNG/SVG; shared axes use common domains and live linked pan/zoom. `Figure.subplots_adjust(left=, right=, top=, bottom=, wspace=, hspace=)` moves the SubplotParams frame: the grid resolves to explicit figure rectangles and every exporter (HTML, PNG, SVG) positions panels at those rectangles | | `fig.add_subplot(2, 2, 1)` / `add_subplot(221)` | | | `gca` / `gcf` / `sca` / `figure(num)` / `close(...)` | matplotlib's implicit-state semantics | | `savefig('x.png' / '.svg' / '.html', dpi=)` | Browser-free PNG/SVG supports both single and multi-panel figures; file-like targets require an explicit `format=` and unsupported metadata/layout/export formats fail loudly | @@ -85,7 +86,7 @@ dependency-free `triangles=` shorthand into Matplotlib's equivalent | `plt.colorbar()` / `fig.colorbar()` / `plt.clim()` / `plt.gci()` | Returns a live handle (`set_label`, `set_ticks`); with no mappable it uses the current image the way pyplot does. `ticks=`/`extend=` render in PNG and SVG (the HTML colorbar stays a minimal gradient without tick text); `clim` retargets the mappable's color window and any colorbar derived from it | | `rcParams` | Figure size/DPI, line width/marker size, image cmap/origin, axes color cycle, and all four `axes.spines.*` switches affect every exporter. Pyplot axes default to Matplotlib's four-sided box and each spine can be hidden independently. The chrome keys (axes face/edge/label/title styles, font family/size, tick colors/sizes, legend defaults, figure facecolor) reach the HTML renderer and multi-panel PNG stitching; single-chart PNG and SVG export currently render their own fixed chrome and ignore them. Unknown keys warn once | | `plt.style.use(...)` / `plt.style.context(...)` | `"default"`, `"xy"`, bounded rcParam dictionaries, ordered lists, and the stock sheets fivethirtyeight, ggplot, bmh, dark_background, grayscale, and seaborn-v0_8-white(grid) — reduced to the supported rcParams subset (colors, grid, cycle, line width, font size; per-sheet keys outside that subset are not carried). `context()` snapshots and restores. Unknown sheet names fail precisely | -| `plt.GridSpec(r, c, wspace=, hspace=, width_ratios=)` + slice specs | Spans (`grid[0, 1:]`, `grid[:-1, 0]`) and custom spacing resolve to explicit figure rectangles using Matplotlib's SubplotParams frame; default-geometry single cells keep the uniform grid. Spanning layouts position exactly in PNG; HTML/SVG sequence free-form panels rather than overlaying the true rectangles | +| `plt.GridSpec(r, c, wspace=, hspace=, width_ratios=)` + slice specs | Spans (`grid[0, 1:]`, `grid[:-1, 0]`) and custom spacing resolve to explicit figure rectangles using Matplotlib's SubplotParams frame; default-geometry single cells keep the uniform grid. Spanning layouts position exactly in HTML, PNG, and SVG: free-form panels (including `add_axes` rects and insets) render absolutely at their figure rectangles in every exporter, with later axes stacked above earlier ones | | `add_subplot(spec, sharex=, sharey=, xticklabels=[], ...)` | per-axes sharing aliases the axis-property store (static domains, as `twiny` does), not Matplotlib's live Grouper; `get_shared_x_axes()` reflects it | ## Outside 2-D chart-method compatibility @@ -100,7 +101,9 @@ Unknown keyword arguments on supported calls raise `TypeError` naming the offending keyword. Known material options that the native marks cannot honor raise `NotImplementedError`, with these documented exceptions that are accepted as visual approximations rather than rejected: the barbs glyph and imshow -smoothing collapse above, `annotate(arrowprops=...)` reduced to callout text, +smoothing collapse above, `annotate(arrowprops=...)` connection curves and +fancy/wedge outlines drawn as quadratic-curve tapered fills rather than +Matplotlib's exact patch paths, `bbox=` boxes drawn only by the HTML label, and errorbar limit flags rendered as one-sided bars without Matplotlib's caret arrows. diff --git a/docs/matplotlib-shim-todo.md b/docs/matplotlib-shim-todo.md index 7e5b6dae..45e51fa3 100644 --- a/docs/matplotlib-shim-todo.md +++ b/docs/matplotlib-shim-todo.md @@ -163,7 +163,10 @@ The shim can be called complete for ordinary 2-D scripts when: - [x] Implement `subplots_adjust()` parameters (`left`, `right`, `top`, `bottom`, `wspace`, `hspace`) for HTML, PNG, and SVG grids. Evidence: `tests/pyplot/test_layout_noops.py::test_subplots_adjust_records_supported_spacing_values` - records all supported spacing values for grid exporters and rejects unknown kwargs. + verifies the adjusted SubplotParams frame resolves to per-cell figure + rectangles (and rejects unknown kwargs and out-of-order frames); + `tests/pyplot/test_layout_text_parity_fixes.py::test_subplots_adjust_positions_grid_panels_in_every_exporter` + verifies HTML/PNG/SVG all position the adjusted panels. - [x] Implement `Figure.autofmt_xdate()` label rotation/alignment. Evidence: `tests/pyplot/test_layout_noops.py::test_autofmt_xdate_rotates_x_tick_labels_on_all_axes` verifies rotation and horizontal alignment state on every axes. diff --git a/examples/pdsh/README.md b/examples/pdsh/README.md index b6735f83..c396ba1c 100644 --- a/examples/pdsh/README.md +++ b/examples/pdsh/README.md @@ -57,16 +57,21 @@ signal. A "cell ok" also implies a non-empty `savefig` PNG export. | 04.05 Histograms and Binnings | 10/10 | 10/10 | | 04.06 Customizing Legends | 11/11 | 10/11 | | 04.07 Customizing Colorbars | 13/13 | 13/13 | -| 04.08 Multiple Subplots | 10/10 | 9/10 | +| 04.08 Multiple Subplots | 10/10 | 10/10 | | 04.09 Text and Annotation | 9/9 | 6/9 | -| 04.10 Customizing Ticks | 11/11 | 10/11 | +| 04.10 Customizing Ticks | 11/11 | 11/11 | | 04.11 Settings and Stylesheets | 15/15 | 15/15 | | 04.12 Three-Dimensional Plotting¹ | 17/17 | 7/17 | | 04.14 Visualization with Seaborn² | 31/31 | 30/31 | -| **Total** | **171/171** | **154/171 (90%)** | +| **Total** | **171/171** | **156/171 (91%)** | -Excluding the out-of-scope 3D notebook: 147/154 (95%). The first -measurement, before the gap-closing pass, was 121/171 (71%). +Excluding the out-of-scope 3D notebook: 149/154 (97%). The first +measurement, before the gap-closing pass, was 121/171 (71%). The 04.08 +and 04.10 rows reflect the 2026-07-14 `subplots_adjust` implementation: +both formerly rejected cells re-ran and exported (04.10's re-run used +identically shaped stand-in images because the measurement sandbox +cannot download the Olivetti faces; the previously recorded failure was +the `subplots_adjust` rejection itself, raised before any drawing). ¹ 3D projections are outside xy's 2-D chart-method compatibility target (see [docs/matplotlib-compat.md](../../docs/matplotlib-compat.md)); @@ -103,13 +108,12 @@ fallback onto matplotlib will ever be added). ## Remaining failures -All 17 are one of: 3-D projection cells (10, loud rejections by +All 15 are one of: 3-D projection cells (10, loud rejections by design), the second-legend `Legend` class (1, a documented loud rejection), -`subplots_adjust` calls whose values the renderer cannot honor (2, now loud -rather than silently discarded), pandas -`Series.plot(ax=ax)` datetime interop (3, a real gap — a dtype error +pandas `Series.plot(ax=ax)` datetime interop (3, a real gap — a dtype error inside the pandas plotting path), and `axhline(marker=)` via seaborn -(1, loud rejection). +(1, loud rejection). The two `subplots_adjust` cells counted here until +2026-07-14 pass now that the shim implements the SubplotParams frame. The 2026-07-14 fidelity follow-up also compared the affected PDSH cells directly against Matplotlib 3.11: directional markers and `x`/`+` remain diff --git a/examples/pdsh/pdsh_04_00_introduction_to_matplotlib.ipynb b/examples/pdsh/pdsh_04_00_introduction_to_matplotlib.ipynb index f859b8e5..74808447 100644 --- a/examples/pdsh/pdsh_04_00_introduction_to_matplotlib.ipynb +++ b/examples/pdsh/pdsh_04_00_introduction_to_matplotlib.ipynb @@ -74,7 +74,7 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "import matplotlib.pyplot as mpl_mpl\n", + "import matplotlib as mpl_mpl\n", "import matplotlib.pyplot as mpl_plt" ] }, @@ -196,7 +196,7 @@ "\n", "x = np.linspace(0, 10, 100)\n", "\n", - "fig = mpl_plt.figure()\n", + "mpl_fig = mpl_plt.figure()\n", "mpl_plt.plot(x, np.sin(x), \"-\")\n", "mpl_plt.plot(x, np.cos(x), \"--\");" ] @@ -216,7 +216,7 @@ "\n", "x = np.linspace(0, 10, 100)\n", "\n", - "fig = xy_plt.figure()\n", + "xy_fig = xy_plt.figure()\n", "xy_plt.plot(x, np.sin(x), \"-\")\n", "xy_plt.plot(x, np.cos(x), \"--\");" ] @@ -244,7 +244,7 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "fig.savefig(\"my_figure.png\")" + "mpl_fig.savefig(\"my_figure.png\")" ] }, { @@ -258,7 +258,7 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "fig.savefig(\"my_figure.png\")" + "xy_fig.savefig(\"my_figure.png\")" ] }, { @@ -304,7 +304,7 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "fig.canvas.get_supported_filetypes()" + "mpl_fig.canvas.get_supported_filetypes()" ] }, { @@ -318,7 +318,7 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "fig.canvas.get_supported_filetypes()" + "xy_fig.canvas.get_supported_filetypes()" ] }, { @@ -412,13 +412,13 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "# First create a grid of plots\n", - "# ax will be an array of two Axes objects\n", - "fig, ax = mpl_plt.subplots(2)\n", + "# First create a mpl_grid of plots\n", + "# mpl_ax will be an array of two Axes objects\n", + "mpl_fig, mpl_ax = mpl_plt.subplots(2)\n", "\n", "# Call plot() method on the appropriate object\n", - "ax[0].plot(x, np.sin(x))\n", - "ax[1].plot(x, np.cos(x));" + "mpl_ax[0].plot(x, np.sin(x))\n", + "mpl_ax[1].plot(x, np.cos(x));" ] }, { @@ -432,13 +432,13 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "# First create a grid of plots\n", - "# ax will be an array of two Axes objects\n", - "fig, ax = xy_plt.subplots(2)\n", + "# First create a xy_grid of plots\n", + "# xy_ax will be an array of two Axes objects\n", + "xy_fig, xy_ax = xy_plt.subplots(2)\n", "\n", "# Call plot() method on the appropriate object\n", - "ax[0].plot(x, np.sin(x))\n", - "ax[1].plot(x, np.cos(x));" + "xy_ax[0].plot(x, np.sin(x))\n", + "xy_ax[1].plot(x, np.cos(x));" ] } ], diff --git a/examples/pdsh/pdsh_04_01_simple_line_plots.ipynb b/examples/pdsh/pdsh_04_01_simple_line_plots.ipynb index 16a3d8f2..1870139f 100644 --- a/examples/pdsh/pdsh_04_01_simple_line_plots.ipynb +++ b/examples/pdsh/pdsh_04_01_simple_line_plots.ipynb @@ -84,8 +84,8 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "fig = mpl_plt.figure()\n", - "ax = mpl_plt.axes()" + "mpl_fig = mpl_plt.figure()\n", + "mpl_ax = mpl_plt.axes()" ] }, { @@ -99,8 +99,8 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "fig = xy_plt.figure()\n", - "ax = xy_plt.axes()" + "xy_fig = xy_plt.figure()\n", + "xy_ax = xy_plt.axes()" ] }, { @@ -114,11 +114,11 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "fig = mpl_plt.figure()\n", - "ax = mpl_plt.axes()\n", + "mpl_fig = mpl_plt.figure()\n", + "mpl_ax = mpl_plt.axes()\n", "\n", "x = np.linspace(0, 10, 1000)\n", - "ax.plot(x, np.sin(x));" + "mpl_ax.plot(x, np.sin(x));" ] }, { @@ -132,11 +132,11 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "fig = xy_plt.figure()\n", - "ax = xy_plt.axes()\n", + "xy_fig = xy_plt.figure()\n", + "xy_ax = xy_plt.axes()\n", "\n", "x = np.linspace(0, 10, 1000)\n", - "ax.plot(x, np.sin(x));" + "xy_ax.plot(x, np.sin(x));" ] }, { @@ -570,9 +570,9 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "ax = mpl_plt.axes()\n", - "ax.plot(x, np.sin(x))\n", - "ax.set(xlim=(0, 10), ylim=(-2, 2), xlabel=\"x\", ylabel=\"sin(x)\", title=\"A Simple Plot\");" + "mpl_ax = mpl_plt.axes()\n", + "mpl_ax.plot(x, np.sin(x))\n", + "mpl_ax.set(xlim=(0, 10), ylim=(-2, 2), xlabel=\"x\", ylabel=\"sin(x)\", title=\"A Simple Plot\");" ] }, { @@ -586,9 +586,9 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "ax = xy_plt.axes()\n", - "ax.plot(x, np.sin(x))\n", - "ax.set(xlim=(0, 10), ylim=(-2, 2), xlabel=\"x\", ylabel=\"sin(x)\", title=\"A Simple Plot\");" + "xy_ax = xy_plt.axes()\n", + "xy_ax.plot(x, np.sin(x))\n", + "xy_ax.set(xlim=(0, 10), ylim=(-2, 2), xlabel=\"x\", ylabel=\"sin(x)\", title=\"A Simple Plot\");" ] } ], diff --git a/examples/pdsh/pdsh_04_05_histograms_and_binnings.ipynb b/examples/pdsh/pdsh_04_05_histograms_and_binnings.ipynb index 412b956a..1728c419 100644 --- a/examples/pdsh/pdsh_04_05_histograms_and_binnings.ipynb +++ b/examples/pdsh/pdsh_04_05_histograms_and_binnings.ipynb @@ -414,7 +414,7 @@ "data = np.vstack([x, y])\n", "kde = gaussian_kde(data)\n", "\n", - "# evaluate on a regular grid\n", + "# evaluate on a regular mpl_grid\n", "xgrid = np.linspace(-3.5, 3.5, 40)\n", "ygrid = np.linspace(-6, 6, 40)\n", "Xgrid, Ygrid = np.meshgrid(xgrid, ygrid)\n", @@ -443,7 +443,7 @@ "data = np.vstack([x, y])\n", "kde = gaussian_kde(data)\n", "\n", - "# evaluate on a regular grid\n", + "# evaluate on a regular xy_grid\n", "xgrid = np.linspace(-3.5, 3.5, 40)\n", "ygrid = np.linspace(-6, 6, 40)\n", "Xgrid, Ygrid = np.meshgrid(xgrid, ygrid)\n", diff --git a/examples/pdsh/pdsh_04_06_customizing_legends.ipynb b/examples/pdsh/pdsh_04_06_customizing_legends.ipynb index dcc2346b..7279a288 100644 --- a/examples/pdsh/pdsh_04_06_customizing_legends.ipynb +++ b/examples/pdsh/pdsh_04_06_customizing_legends.ipynb @@ -111,11 +111,11 @@ "source": [ "# Matplotlib reference\n", "x = np.linspace(0, 10, 1000)\n", - "fig, ax = mpl_plt.subplots()\n", - "ax.plot(x, np.sin(x), \"-b\", label=\"Sine\")\n", - "ax.plot(x, np.cos(x), \"--r\", label=\"Cosine\")\n", - "ax.axis(\"equal\")\n", - "leg = ax.legend()" + "mpl_fig, mpl_ax = mpl_plt.subplots()\n", + "mpl_ax.plot(x, np.sin(x), \"-b\", label=\"Sine\")\n", + "mpl_ax.plot(x, np.cos(x), \"--r\", label=\"Cosine\")\n", + "mpl_ax.axis(\"equal\")\n", + "leg = mpl_ax.legend()" ] }, { @@ -130,11 +130,11 @@ "source": [ "# xy.pyplot\n", "x = np.linspace(0, 10, 1000)\n", - "fig, ax = xy_plt.subplots()\n", - "ax.plot(x, np.sin(x), \"-b\", label=\"Sine\")\n", - "ax.plot(x, np.cos(x), \"--r\", label=\"Cosine\")\n", - "ax.axis(\"equal\")\n", - "leg = ax.legend()" + "xy_fig, xy_ax = xy_plt.subplots()\n", + "xy_ax.plot(x, np.sin(x), \"-b\", label=\"Sine\")\n", + "xy_ax.plot(x, np.cos(x), \"--r\", label=\"Cosine\")\n", + "xy_ax.axis(\"equal\")\n", + "leg = xy_ax.legend()" ] }, { @@ -148,8 +148,8 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "ax.legend(loc=\"upper left\", frameon=True)\n", - "fig" + "mpl_ax.legend(loc=\"upper left\", frameon=True)\n", + "mpl_fig" ] }, { @@ -163,8 +163,8 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "ax.legend(loc=\"upper left\", frameon=True)\n", - "fig" + "xy_ax.legend(loc=\"upper left\", frameon=True)\n", + "xy_fig" ] }, { @@ -178,8 +178,8 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "ax.legend(loc=\"lower center\", ncol=2)\n", - "fig" + "mpl_ax.legend(loc=\"lower center\", ncol=2)\n", + "mpl_fig" ] }, { @@ -193,8 +193,8 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "ax.legend(loc=\"lower center\", ncol=2)\n", - "fig" + "xy_ax.legend(loc=\"lower center\", ncol=2)\n", + "xy_fig" ] }, { @@ -208,8 +208,8 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "ax.legend(frameon=True, fancybox=True, framealpha=1, shadow=True, borderpad=1)\n", - "fig" + "mpl_ax.legend(frameon=True, fancybox=True, framealpha=1, shadow=True, borderpad=1)\n", + "mpl_fig" ] }, { @@ -223,8 +223,8 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "ax.legend(frameon=True, fancybox=True, framealpha=1, shadow=True, borderpad=1)\n", - "fig" + "xy_ax.legend(frameon=True, fancybox=True, framealpha=1, shadow=True, borderpad=1)\n", + "xy_fig" ] }, { @@ -454,24 +454,24 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "fig, ax = mpl_plt.subplots()\n", + "mpl_fig, mpl_ax = mpl_plt.subplots()\n", "\n", "lines = []\n", "styles = [\"-\", \"--\", \"-.\", \":\"]\n", "x = np.linspace(0, 10, 1000)\n", "\n", "for i in range(4):\n", - " lines += ax.plot(x, np.sin(x - i * np.pi / 2), styles[i], color=\"black\")\n", - "ax.axis(\"equal\")\n", + " lines += mpl_ax.plot(x, np.sin(x - i * np.pi / 2), styles[i], color=\"black\")\n", + "mpl_ax.axis(\"equal\")\n", "\n", "# Specify the lines and labels of the first legend\n", - "ax.legend(lines[:2], [\"line A\", \"line B\"], loc=\"upper right\")\n", + "mpl_ax.legend(lines[:2], [\"line A\", \"line B\"], loc=\"upper right\")\n", "\n", "# Create the second legend and add the artist manually\n", "from matplotlib.legend import Legend as mpl_Legend # was matplotlib.legend\n", "\n", - "leg = mpl_Legend(ax, lines[2:], [\"line C\", \"line D\"], loc=\"lower right\")\n", - "ax.add_artist(leg);" + "leg = mpl_Legend(mpl_ax, lines[2:], [\"line C\", \"line D\"], loc=\"lower right\")\n", + "mpl_ax.add_artist(leg);" ] }, { @@ -485,24 +485,24 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "fig, ax = xy_plt.subplots()\n", + "xy_fig, xy_ax = xy_plt.subplots()\n", "\n", "lines = []\n", "styles = [\"-\", \"--\", \"-.\", \":\"]\n", "x = np.linspace(0, 10, 1000)\n", "\n", "for i in range(4):\n", - " lines += ax.plot(x, np.sin(x - i * np.pi / 2), styles[i], color=\"black\")\n", - "ax.axis(\"equal\")\n", + " lines += xy_ax.plot(x, np.sin(x - i * np.pi / 2), styles[i], color=\"black\")\n", + "xy_ax.axis(\"equal\")\n", "\n", "# Specify the lines and labels of the first legend\n", - "ax.legend(lines[:2], [\"line A\", \"line B\"], loc=\"upper right\")\n", + "xy_ax.legend(lines[:2], [\"line A\", \"line B\"], loc=\"upper right\")\n", "\n", "# Create the second legend and add the artist manually\n", "from xy.pyplot import Legend as xy_Legend # was matplotlib.legend\n", "\n", - "leg = xy_Legend(ax, lines[2:], [\"line C\", \"line D\"], loc=\"lower right\")\n", - "ax.add_artist(leg);" + "leg = xy_Legend(xy_ax, lines[2:], [\"line C\", \"line D\"], loc=\"lower right\")\n", + "xy_ax.add_artist(leg);" ] } ], diff --git a/examples/pdsh/pdsh_04_07_customizing_colorbars.ipynb b/examples/pdsh/pdsh_04_07_customizing_colorbars.ipynb index 805b1d6a..0701e7fd 100644 --- a/examples/pdsh/pdsh_04_07_customizing_colorbars.ipynb +++ b/examples/pdsh/pdsh_04_07_customizing_colorbars.ipynb @@ -223,9 +223,9 @@ " cmap = grayscale_cmap(cmap)\n", " grayscale = cmap(np.arange(cmap.N))\n", "\n", - " fig, ax = mpl_plt.subplots(2, figsize=(6, 2), subplot_kw=dict(xticks=[], yticks=[]))\n", - " ax[0].imshow([colors], extent=[0, 10, 0, 1])\n", - " ax[1].imshow([grayscale], extent=[0, 10, 0, 1])" + " mpl_fig, mpl_ax = mpl_plt.subplots(2, figsize=(6, 2), subplot_kw=dict(xticks=[], yticks=[]))\n", + " mpl_ax[0].imshow([colors], extent=[0, 10, 0, 1])\n", + " mpl_ax[1].imshow([grayscale], extent=[0, 10, 0, 1])" ] }, { @@ -264,9 +264,9 @@ " cmap = grayscale_cmap(cmap)\n", " grayscale = cmap(np.arange(cmap.N))\n", "\n", - " fig, ax = xy_plt.subplots(2, figsize=(6, 2), subplot_kw=dict(xticks=[], yticks=[]))\n", - " ax[0].imshow([colors], extent=[0, 10, 0, 1])\n", - " ax[1].imshow([grayscale], extent=[0, 10, 0, 1])" + " xy_fig, xy_ax = xy_plt.subplots(2, figsize=(6, 2), subplot_kw=dict(xticks=[], yticks=[]))\n", + " xy_ax[0].imshow([colors], extent=[0, 10, 0, 1])\n", + " xy_ax[1].imshow([grayscale], extent=[0, 10, 0, 1])" ] }, { @@ -491,8 +491,8 @@ "\n", "digits = load_digits(n_class=6)\n", "\n", - "fig, ax = mpl_plt.subplots(8, 8, figsize=(6, 6))\n", - "for i, axi in enumerate(ax.flat):\n", + "mpl_fig, mpl_ax = mpl_plt.subplots(8, 8, figsize=(6, 6))\n", + "for i, axi in enumerate(mpl_ax.flat):\n", " axi.imshow(digits.images[i], cmap=\"binary\")\n", " axi.set(xticks=[], yticks=[])" ] @@ -513,8 +513,8 @@ "\n", "digits = load_digits(n_class=6)\n", "\n", - "fig, ax = xy_plt.subplots(8, 8, figsize=(6, 6))\n", - "for i, axi in enumerate(ax.flat):\n", + "xy_fig, xy_ax = xy_plt.subplots(8, 8, figsize=(6, 6))\n", + "for i, axi in enumerate(xy_ax.flat):\n", " axi.imshow(digits.images[i], cmap=\"binary\")\n", " axi.set(xticks=[], yticks=[])" ] diff --git a/examples/pdsh/pdsh_04_08_multiple_subplots.ipynb b/examples/pdsh/pdsh_04_08_multiple_subplots.ipynb index 60480278..e77f32a8 100644 --- a/examples/pdsh/pdsh_04_08_multiple_subplots.ipynb +++ b/examples/pdsh/pdsh_04_08_multiple_subplots.ipynb @@ -126,9 +126,9 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "fig = mpl_plt.figure()\n", - "ax1 = fig.add_axes([0.1, 0.5, 0.8, 0.4], xticklabels=[], ylim=(-1.2, 1.2))\n", - "ax2 = fig.add_axes([0.1, 0.1, 0.8, 0.4], ylim=(-1.2, 1.2))\n", + "mpl_fig = mpl_plt.figure()\n", + "ax1 = mpl_fig.add_axes([0.1, 0.5, 0.8, 0.4], xticklabels=[], ylim=(-1.2, 1.2))\n", + "ax2 = mpl_fig.add_axes([0.1, 0.1, 0.8, 0.4], ylim=(-1.2, 1.2))\n", "\n", "x = np.linspace(0, 10)\n", "ax1.plot(np.sin(x))\n", @@ -146,9 +146,9 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "fig = xy_plt.figure()\n", - "ax1 = fig.add_axes([0.1, 0.5, 0.8, 0.4], xticklabels=[], ylim=(-1.2, 1.2))\n", - "ax2 = fig.add_axes([0.1, 0.1, 0.8, 0.4], ylim=(-1.2, 1.2))\n", + "xy_fig = xy_plt.figure()\n", + "ax1 = xy_fig.add_axes([0.1, 0.5, 0.8, 0.4], xticklabels=[], ylim=(-1.2, 1.2))\n", + "ax2 = xy_fig.add_axes([0.1, 0.1, 0.8, 0.4], ylim=(-1.2, 1.2))\n", "\n", "x = np.linspace(0, 10)\n", "ax1.plot(np.sin(x))\n", @@ -210,11 +210,11 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "fig = mpl_plt.figure()\n", - "fig.subplots_adjust(hspace=0.4, wspace=0.4)\n", + "mpl_fig = mpl_plt.figure()\n", + "mpl_fig.subplots_adjust(hspace=0.4, wspace=0.4)\n", "for i in range(1, 7):\n", - " ax = fig.add_subplot(2, 3, i)\n", - " ax.text(0.5, 0.5, str((2, 3, i)), fontsize=18, ha=\"center\")" + " mpl_ax = mpl_fig.add_subplot(2, 3, i)\n", + " mpl_ax.text(0.5, 0.5, str((2, 3, i)), fontsize=18, ha=\"center\")" ] }, { @@ -228,11 +228,11 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "fig = xy_plt.figure()\n", - "fig.subplots_adjust(hspace=0.4, wspace=0.4)\n", + "xy_fig = xy_plt.figure()\n", + "xy_fig.subplots_adjust(hspace=0.4, wspace=0.4)\n", "for i in range(1, 7):\n", - " ax = fig.add_subplot(2, 3, i)\n", - " ax.text(0.5, 0.5, str((2, 3, i)), fontsize=18, ha=\"center\")" + " xy_ax = xy_fig.add_subplot(2, 3, i)\n", + " xy_ax.text(0.5, 0.5, str((2, 3, i)), fontsize=18, ha=\"center\")" ] }, { @@ -258,7 +258,7 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "fig, ax = mpl_plt.subplots(2, 3, sharex=\"col\", sharey=\"row\")" + "mpl_fig, mpl_ax = mpl_plt.subplots(2, 3, sharex=\"col\", sharey=\"row\")" ] }, { @@ -272,7 +272,7 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "fig, ax = xy_plt.subplots(2, 3, sharex=\"col\", sharey=\"row\")" + "xy_fig, xy_ax = xy_plt.subplots(2, 3, sharex=\"col\", sharey=\"row\")" ] }, { @@ -289,8 +289,8 @@ "# axes are in a two-dimensional array, indexed by [row, col]\n", "for i in range(2):\n", " for j in range(3):\n", - " ax[i, j].text(0.5, 0.5, str((i, j)), fontsize=18, ha=\"center\")\n", - "fig" + " mpl_ax[i, j].text(0.5, 0.5, str((i, j)), fontsize=18, ha=\"center\")\n", + "mpl_fig" ] }, { @@ -307,8 +307,8 @@ "# axes are in a two-dimensional array, indexed by [row, col]\n", "for i in range(2):\n", " for j in range(3):\n", - " ax[i, j].text(0.5, 0.5, str((i, j)), fontsize=18, ha=\"center\")\n", - "fig" + " xy_ax[i, j].text(0.5, 0.5, str((i, j)), fontsize=18, ha=\"center\")\n", + "xy_fig" ] }, { @@ -334,7 +334,7 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "grid = mpl_plt.GridSpec(2, 3, wspace=0.4, hspace=0.3)" + "mpl_grid = mpl_plt.GridSpec(2, 3, wspace=0.4, hspace=0.3)" ] }, { @@ -348,7 +348,7 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "grid = xy_plt.GridSpec(2, 3, wspace=0.4, hspace=0.3)" + "xy_grid = xy_plt.GridSpec(2, 3, wspace=0.4, hspace=0.3)" ] }, { @@ -362,10 +362,10 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "mpl_plt.subplot(grid[0, 0])\n", - "mpl_plt.subplot(grid[0, 1:])\n", - "mpl_plt.subplot(grid[1, :2])\n", - "mpl_plt.subplot(grid[1, 2]);" + "mpl_plt.subplot(mpl_grid[0, 0])\n", + "mpl_plt.subplot(mpl_grid[0, 1:])\n", + "mpl_plt.subplot(mpl_grid[1, :2])\n", + "mpl_plt.subplot(mpl_grid[1, 2]);" ] }, { @@ -379,10 +379,10 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "xy_plt.subplot(grid[0, 0])\n", - "xy_plt.subplot(grid[0, 1:])\n", - "xy_plt.subplot(grid[1, :2])\n", - "xy_plt.subplot(grid[1, 2]);" + "xy_plt.subplot(xy_grid[0, 0])\n", + "xy_plt.subplot(xy_grid[0, 1:])\n", + "xy_plt.subplot(xy_grid[1, :2])\n", + "xy_plt.subplot(xy_grid[1, 2]);" ] }, { @@ -403,11 +403,11 @@ "x, y = rng.multivariate_normal(mean, cov, 3000).T\n", "\n", "# Set up the axes with GridSpec\n", - "fig = mpl_plt.figure(figsize=(6, 6))\n", - "grid = mpl_plt.GridSpec(4, 4, hspace=0.2, wspace=0.2)\n", - "main_ax = fig.add_subplot(grid[:-1, 1:])\n", - "y_hist = fig.add_subplot(grid[:-1, 0], xticklabels=[], sharey=main_ax)\n", - "x_hist = fig.add_subplot(grid[-1, 1:], yticklabels=[], sharex=main_ax)\n", + "mpl_fig = mpl_plt.figure(figsize=(6, 6))\n", + "mpl_grid = mpl_plt.GridSpec(4, 4, hspace=0.2, wspace=0.2)\n", + "main_ax = mpl_fig.add_subplot(mpl_grid[:-1, 1:])\n", + "y_hist = mpl_fig.add_subplot(mpl_grid[:-1, 0], xticklabels=[], sharey=main_ax)\n", + "x_hist = mpl_fig.add_subplot(mpl_grid[-1, 1:], yticklabels=[], sharex=main_ax)\n", "\n", "# Scatter points on the main axes\n", "main_ax.plot(x, y, \"ok\", markersize=3, alpha=0.2)\n", @@ -438,11 +438,11 @@ "x, y = rng.multivariate_normal(mean, cov, 3000).T\n", "\n", "# Set up the axes with GridSpec\n", - "fig = xy_plt.figure(figsize=(6, 6))\n", - "grid = xy_plt.GridSpec(4, 4, hspace=0.2, wspace=0.2)\n", - "main_ax = fig.add_subplot(grid[:-1, 1:])\n", - "y_hist = fig.add_subplot(grid[:-1, 0], xticklabels=[], sharey=main_ax)\n", - "x_hist = fig.add_subplot(grid[-1, 1:], yticklabels=[], sharex=main_ax)\n", + "xy_fig = xy_plt.figure(figsize=(6, 6))\n", + "xy_grid = xy_plt.GridSpec(4, 4, hspace=0.2, wspace=0.2)\n", + "main_ax = xy_fig.add_subplot(xy_grid[:-1, 1:])\n", + "y_hist = xy_fig.add_subplot(xy_grid[:-1, 0], xticklabels=[], sharey=main_ax)\n", + "x_hist = xy_fig.add_subplot(xy_grid[-1, 1:], yticklabels=[], sharex=main_ax)\n", "\n", "# Scatter points on the main axes\n", "main_ax.plot(x, y, \"ok\", markersize=3, alpha=0.2)\n", diff --git a/examples/pdsh/pdsh_04_09_text_and_annotation.ipynb b/examples/pdsh/pdsh_04_09_text_and_annotation.ipynb index 5ecfba3e..6a7adfc4 100644 --- a/examples/pdsh/pdsh_04_09_text_and_annotation.ipynb +++ b/examples/pdsh/pdsh_04_09_text_and_annotation.ipynb @@ -51,7 +51,7 @@ "source": [ "# Matplotlib reference\n", "import matplotlib.pyplot as mpl_plt\n", - "import matplotlib.pyplot as mpl_mpl\n", + "import matplotlib as mpl_mpl\n", "\n", "mpl_plt.style.use(\"default\")\n", "import numpy as np\n", @@ -188,8 +188,8 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "fig, ax = mpl_plt.subplots(figsize=(12, 4))\n", - "births_by_date.plot(ax=ax);" + "mpl_fig, mpl_ax = mpl_plt.subplots(figsize=(12, 4))\n", + "births_by_date.plot(ax=mpl_ax);" ] }, { @@ -203,8 +203,8 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "fig, ax = xy_plt.subplots(figsize=(12, 4))\n", - "births_by_date.plot(ax=ax);" + "xy_fig, xy_ax = xy_plt.subplots(figsize=(12, 4))\n", + "births_by_date.plot(ax=xy_ax);" ] }, { @@ -218,27 +218,27 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "fig, ax = mpl_plt.subplots(figsize=(12, 4))\n", - "births_by_date.plot(ax=ax)\n", + "mpl_fig, mpl_ax = mpl_plt.subplots(figsize=(12, 4))\n", + "births_by_date.plot(ax=mpl_ax)\n", "\n", "# Add labels to the plot\n", "style = dict(size=10, color=\"gray\")\n", "\n", - "ax.text(\"2012-1-1\", 3950, \"New Year's Day\", **style)\n", - "ax.text(\"2012-7-4\", 4250, \"Independence Day\", ha=\"center\", **style)\n", - "ax.text(\"2012-9-4\", 4850, \"Labor Day\", ha=\"center\", **style)\n", - "ax.text(\"2012-10-31\", 4600, \"Halloween\", ha=\"right\", **style)\n", - "ax.text(\"2012-11-25\", 4450, \"Thanksgiving\", ha=\"center\", **style)\n", - "ax.text(\"2012-12-25\", 3850, \"Christmas \", ha=\"right\", **style)\n", + "mpl_ax.text(\"2012-1-1\", 3950, \"New Year's Day\", **style)\n", + "mpl_ax.text(\"2012-7-4\", 4250, \"Independence Day\", ha=\"center\", **style)\n", + "mpl_ax.text(\"2012-9-4\", 4850, \"Labor Day\", ha=\"center\", **style)\n", + "mpl_ax.text(\"2012-10-31\", 4600, \"Halloween\", ha=\"right\", **style)\n", + "mpl_ax.text(\"2012-11-25\", 4450, \"Thanksgiving\", ha=\"center\", **style)\n", + "mpl_ax.text(\"2012-12-25\", 3850, \"Christmas \", ha=\"right\", **style)\n", "\n", "# Label the axes\n", - "ax.set(title=\"USA births by day of year (1969-1988)\", ylabel=\"average daily births\")\n", + "mpl_ax.set(title=\"USA births by day of year (1969-1988)\", ylabel=\"average daily births\")\n", "\n", "# Format the x-axis with centered month labels\n", - "ax.xaxis.set_major_locator(mpl_mpl.dates.MonthLocator())\n", - "ax.xaxis.set_minor_locator(mpl_mpl.dates.MonthLocator(bymonthday=15))\n", - "ax.xaxis.set_major_formatter(mpl_plt.NullFormatter())\n", - "ax.xaxis.set_minor_formatter(mpl_mpl.dates.DateFormatter(\"%h\"));" + "mpl_ax.xaxis.set_major_locator(mpl_mpl.dates.MonthLocator())\n", + "mpl_ax.xaxis.set_minor_locator(mpl_mpl.dates.MonthLocator(bymonthday=15))\n", + "mpl_ax.xaxis.set_major_formatter(mpl_plt.NullFormatter())\n", + "mpl_ax.xaxis.set_minor_formatter(mpl_mpl.dates.DateFormatter(\"%h\"));" ] }, { @@ -252,27 +252,27 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "fig, ax = xy_plt.subplots(figsize=(12, 4))\n", - "births_by_date.plot(ax=ax)\n", + "xy_fig, xy_ax = xy_plt.subplots(figsize=(12, 4))\n", + "births_by_date.plot(ax=xy_ax)\n", "\n", "# Add labels to the plot\n", "style = dict(size=10, color=\"gray\")\n", "\n", - "ax.text(\"2012-1-1\", 3950, \"New Year's Day\", **style)\n", - "ax.text(\"2012-7-4\", 4250, \"Independence Day\", ha=\"center\", **style)\n", - "ax.text(\"2012-9-4\", 4850, \"Labor Day\", ha=\"center\", **style)\n", - "ax.text(\"2012-10-31\", 4600, \"Halloween\", ha=\"right\", **style)\n", - "ax.text(\"2012-11-25\", 4450, \"Thanksgiving\", ha=\"center\", **style)\n", - "ax.text(\"2012-12-25\", 3850, \"Christmas \", ha=\"right\", **style)\n", + "xy_ax.text(\"2012-1-1\", 3950, \"New Year's Day\", **style)\n", + "xy_ax.text(\"2012-7-4\", 4250, \"Independence Day\", ha=\"center\", **style)\n", + "xy_ax.text(\"2012-9-4\", 4850, \"Labor Day\", ha=\"center\", **style)\n", + "xy_ax.text(\"2012-10-31\", 4600, \"Halloween\", ha=\"right\", **style)\n", + "xy_ax.text(\"2012-11-25\", 4450, \"Thanksgiving\", ha=\"center\", **style)\n", + "xy_ax.text(\"2012-12-25\", 3850, \"Christmas \", ha=\"right\", **style)\n", "\n", "# Label the axes\n", - "ax.set(title=\"USA births by day of year (1969-1988)\", ylabel=\"average daily births\")\n", + "xy_ax.set(title=\"USA births by day of year (1969-1988)\", ylabel=\"average daily births\")\n", "\n", "# Format the x-axis with centered month labels\n", - "ax.xaxis.set_major_locator(xy_mpl.dates.MonthLocator())\n", - "ax.xaxis.set_minor_locator(xy_mpl.dates.MonthLocator(bymonthday=15))\n", - "ax.xaxis.set_major_formatter(xy_plt.NullFormatter())\n", - "ax.xaxis.set_minor_formatter(xy_mpl.dates.DateFormatter(\"%h\"));" + "xy_ax.xaxis.set_major_locator(xy_mpl.dates.MonthLocator())\n", + "xy_ax.xaxis.set_minor_locator(xy_mpl.dates.MonthLocator(bymonthday=15))\n", + "xy_ax.xaxis.set_major_formatter(xy_plt.NullFormatter())\n", + "xy_ax.xaxis.set_minor_formatter(xy_mpl.dates.DateFormatter(\"%h\"));" ] }, { @@ -298,13 +298,13 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "fig, ax = mpl_plt.subplots(facecolor=\"lightgray\")\n", - "ax.axis([0, 10, 0, 10])\n", + "mpl_fig, mpl_ax = mpl_plt.subplots(facecolor=\"lightgray\")\n", + "mpl_ax.axis([0, 10, 0, 10])\n", "\n", - "# transform=ax.transData is the default, but we'll specify it anyway\n", - "ax.text(1, 5, \". Data: (1, 5)\", transform=ax.transData)\n", - "ax.text(0.5, 0.1, \". Axes: (0.5, 0.1)\", transform=ax.transAxes)\n", - "ax.text(0.2, 0.2, \". Figure: (0.2, 0.2)\", transform=fig.transFigure);" + "# transform=mpl_ax.transData is the default, but we'll specify it anyway\n", + "mpl_ax.text(1, 5, \". Data: (1, 5)\", transform=mpl_ax.transData)\n", + "mpl_ax.text(0.5, 0.1, \". Axes: (0.5, 0.1)\", transform=mpl_ax.transAxes)\n", + "mpl_ax.text(0.2, 0.2, \". Figure: (0.2, 0.2)\", transform=mpl_fig.transFigure);" ] }, { @@ -318,13 +318,13 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "fig, ax = xy_plt.subplots(facecolor=\"lightgray\")\n", - "ax.axis([0, 10, 0, 10])\n", + "xy_fig, xy_ax = xy_plt.subplots(facecolor=\"lightgray\")\n", + "xy_ax.axis([0, 10, 0, 10])\n", "\n", - "# transform=ax.transData is the default, but we'll specify it anyway\n", - "ax.text(1, 5, \". Data: (1, 5)\", transform=ax.transData)\n", - "ax.text(0.5, 0.1, \". Axes: (0.5, 0.1)\", transform=ax.transAxes)\n", - "ax.text(0.2, 0.2, \". Figure: (0.2, 0.2)\", transform=fig.transFigure);" + "# transform=xy_ax.transData is the default, but we'll specify it anyway\n", + "xy_ax.text(1, 5, \". Data: (1, 5)\", transform=xy_ax.transData)\n", + "xy_ax.text(0.5, 0.1, \". Axes: (0.5, 0.1)\", transform=xy_ax.transAxes)\n", + "xy_ax.text(0.2, 0.2, \". Figure: (0.2, 0.2)\", transform=xy_fig.transFigure);" ] }, { @@ -338,9 +338,9 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "ax.set_xlim(0, 2)\n", - "ax.set_ylim(-6, 6)\n", - "fig" + "mpl_ax.set_xlim(0, 2)\n", + "mpl_ax.set_ylim(-6, 6)\n", + "mpl_fig" ] }, { @@ -354,9 +354,9 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "ax.set_xlim(0, 2)\n", - "ax.set_ylim(-6, 6)\n", - "fig" + "xy_ax.set_xlim(0, 2)\n", + "xy_ax.set_ylim(-6, 6)\n", + "xy_fig" ] }, { @@ -382,17 +382,17 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "fig, ax = mpl_plt.subplots()\n", + "mpl_fig, mpl_ax = mpl_plt.subplots()\n", "\n", "x = np.linspace(0, 20, 1000)\n", - "ax.plot(x, np.cos(x))\n", - "ax.axis(\"equal\")\n", + "mpl_ax.plot(x, np.cos(x))\n", + "mpl_ax.axis(\"equal\")\n", "\n", - "ax.annotate(\n", + "mpl_ax.annotate(\n", " \"local maximum\", xy=(6.28, 1), xytext=(10, 4), arrowprops=dict(facecolor=\"black\", shrink=0.05)\n", ")\n", "\n", - "ax.annotate(\n", + "mpl_ax.annotate(\n", " \"local minimum\",\n", " xy=(5 * np.pi, -1),\n", " xytext=(2, -6),\n", @@ -411,17 +411,17 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "fig, ax = xy_plt.subplots()\n", + "xy_fig, xy_ax = xy_plt.subplots()\n", "\n", "x = np.linspace(0, 20, 1000)\n", - "ax.plot(x, np.cos(x))\n", - "ax.axis(\"equal\")\n", + "xy_ax.plot(x, np.cos(x))\n", + "xy_ax.axis(\"equal\")\n", "\n", - "ax.annotate(\n", + "xy_ax.annotate(\n", " \"local maximum\", xy=(6.28, 1), xytext=(10, 4), arrowprops=dict(facecolor=\"black\", shrink=0.05)\n", ")\n", "\n", - "ax.annotate(\n", + "xy_ax.annotate(\n", " \"local minimum\",\n", " xy=(5 * np.pi, -1),\n", " xytext=(2, -6),\n", @@ -440,11 +440,11 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "fig, ax = mpl_plt.subplots(figsize=(12, 4))\n", - "births_by_date.plot(ax=ax)\n", + "mpl_fig, mpl_ax = mpl_plt.subplots(figsize=(12, 4))\n", + "births_by_date.plot(ax=mpl_ax)\n", "\n", "# Add labels to the plot\n", - "ax.annotate(\n", + "mpl_ax.annotate(\n", " \"New Year's Day\",\n", " xy=(\"2012-1-1\", 4100),\n", " xycoords=\"data\",\n", @@ -453,7 +453,7 @@ " arrowprops=dict(arrowstyle=\"->\", connectionstyle=\"arc3,rad=-0.2\"),\n", ")\n", "\n", - "ax.annotate(\n", + "mpl_ax.annotate(\n", " \"Independence Day\",\n", " xy=(\"2012-7-4\", 4250),\n", " xycoords=\"data\",\n", @@ -464,7 +464,7 @@ " arrowprops=dict(arrowstyle=\"->\"),\n", ")\n", "\n", - "ax.annotate(\n", + "mpl_ax.annotate(\n", " \"Labor Day Weekend\",\n", " xy=(\"2012-9-4\", 4850),\n", " xycoords=\"data\",\n", @@ -472,7 +472,7 @@ " xytext=(0, -20),\n", " textcoords=\"offset points\",\n", ")\n", - "ax.annotate(\n", + "mpl_ax.annotate(\n", " \"\",\n", " xy=(\"2012-9-1\", 4850),\n", " xytext=(\"2012-9-7\", 4850),\n", @@ -483,7 +483,7 @@ " },\n", ")\n", "\n", - "ax.annotate(\n", + "mpl_ax.annotate(\n", " \"Halloween\",\n", " xy=(\"2012-10-31\", 4600),\n", " xycoords=\"data\",\n", @@ -494,7 +494,7 @@ " ),\n", ")\n", "\n", - "ax.annotate(\n", + "mpl_ax.annotate(\n", " \"Thanksgiving\",\n", " xy=(\"2012-11-25\", 4500),\n", " xycoords=\"data\",\n", @@ -505,7 +505,7 @@ ")\n", "\n", "\n", - "ax.annotate(\n", + "mpl_ax.annotate(\n", " \"Christmas\",\n", " xy=(\"2012-12-25\", 3850),\n", " xycoords=\"data\",\n", @@ -518,14 +518,14 @@ " arrowprops=dict(arrowstyle=\"wedge,tail_width=0.5\", alpha=0.1),\n", ")\n", "# Label the axes\n", - "ax.set(title=\"USA births by day of year (1969-1988)\", ylabel=\"average daily births\")\n", + "mpl_ax.set(title=\"USA births by day of year (1969-1988)\", ylabel=\"average daily births\")\n", "\n", "# Format the x-axis with centered month labels\n", - "ax.xaxis.set_major_locator(mpl_mpl.dates.MonthLocator())\n", - "ax.xaxis.set_minor_locator(mpl_mpl.dates.MonthLocator(bymonthday=15))\n", - "ax.xaxis.set_major_formatter(mpl_plt.NullFormatter())\n", - "ax.xaxis.set_minor_formatter(mpl_mpl.dates.DateFormatter(\"%h\"))\n", - "ax.set_ylim(3600, 5400);" + "mpl_ax.xaxis.set_major_locator(mpl_mpl.dates.MonthLocator())\n", + "mpl_ax.xaxis.set_minor_locator(mpl_mpl.dates.MonthLocator(bymonthday=15))\n", + "mpl_ax.xaxis.set_major_formatter(mpl_plt.NullFormatter())\n", + "mpl_ax.xaxis.set_minor_formatter(mpl_mpl.dates.DateFormatter(\"%h\"))\n", + "mpl_ax.set_ylim(3600, 5400);" ] }, { @@ -539,11 +539,11 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "fig, ax = xy_plt.subplots(figsize=(12, 4))\n", - "births_by_date.plot(ax=ax)\n", + "xy_fig, xy_ax = xy_plt.subplots(figsize=(12, 4))\n", + "births_by_date.plot(ax=xy_ax)\n", "\n", "# Add labels to the plot\n", - "ax.annotate(\n", + "xy_ax.annotate(\n", " \"New Year's Day\",\n", " xy=(\"2012-1-1\", 4100),\n", " xycoords=\"data\",\n", @@ -552,7 +552,7 @@ " arrowprops=dict(arrowstyle=\"->\", connectionstyle=\"arc3,rad=-0.2\"),\n", ")\n", "\n", - "ax.annotate(\n", + "xy_ax.annotate(\n", " \"Independence Day\",\n", " xy=(\"2012-7-4\", 4250),\n", " xycoords=\"data\",\n", @@ -563,7 +563,7 @@ " arrowprops=dict(arrowstyle=\"->\"),\n", ")\n", "\n", - "ax.annotate(\n", + "xy_ax.annotate(\n", " \"Labor Day Weekend\",\n", " xy=(\"2012-9-4\", 4850),\n", " xycoords=\"data\",\n", @@ -571,7 +571,7 @@ " xytext=(0, -20),\n", " textcoords=\"offset points\",\n", ")\n", - "ax.annotate(\n", + "xy_ax.annotate(\n", " \"\",\n", " xy=(\"2012-9-1\", 4850),\n", " xytext=(\"2012-9-7\", 4850),\n", @@ -582,7 +582,7 @@ " },\n", ")\n", "\n", - "ax.annotate(\n", + "xy_ax.annotate(\n", " \"Halloween\",\n", " xy=(\"2012-10-31\", 4600),\n", " xycoords=\"data\",\n", @@ -593,7 +593,7 @@ " ),\n", ")\n", "\n", - "ax.annotate(\n", + "xy_ax.annotate(\n", " \"Thanksgiving\",\n", " xy=(\"2012-11-25\", 4500),\n", " xycoords=\"data\",\n", @@ -604,7 +604,7 @@ ")\n", "\n", "\n", - "ax.annotate(\n", + "xy_ax.annotate(\n", " \"Christmas\",\n", " xy=(\"2012-12-25\", 3850),\n", " xycoords=\"data\",\n", @@ -617,14 +617,14 @@ " arrowprops=dict(arrowstyle=\"wedge,tail_width=0.5\", alpha=0.1),\n", ")\n", "# Label the axes\n", - "ax.set(title=\"USA births by day of year (1969-1988)\", ylabel=\"average daily births\")\n", + "xy_ax.set(title=\"USA births by day of year (1969-1988)\", ylabel=\"average daily births\")\n", "\n", "# Format the x-axis with centered month labels\n", - "ax.xaxis.set_major_locator(xy_mpl.dates.MonthLocator())\n", - "ax.xaxis.set_minor_locator(xy_mpl.dates.MonthLocator(bymonthday=15))\n", - "ax.xaxis.set_major_formatter(xy_plt.NullFormatter())\n", - "ax.xaxis.set_minor_formatter(xy_mpl.dates.DateFormatter(\"%h\"))\n", - "ax.set_ylim(3600, 5400);" + "xy_ax.xaxis.set_major_locator(xy_mpl.dates.MonthLocator())\n", + "xy_ax.xaxis.set_minor_locator(xy_mpl.dates.MonthLocator(bymonthday=15))\n", + "xy_ax.xaxis.set_major_formatter(xy_plt.NullFormatter())\n", + "xy_ax.xaxis.set_minor_formatter(xy_mpl.dates.DateFormatter(\"%h\"))\n", + "xy_ax.set_ylim(3600, 5400);" ] } ], diff --git a/examples/pdsh/pdsh_04_10_customizing_ticks.ipynb b/examples/pdsh/pdsh_04_10_customizing_ticks.ipynb index 2374fcb3..4bc3db36 100644 --- a/examples/pdsh/pdsh_04_10_customizing_ticks.ipynb +++ b/examples/pdsh/pdsh_04_10_customizing_ticks.ipynb @@ -96,9 +96,9 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "ax = mpl_plt.axes(xscale=\"log\", yscale=\"log\")\n", - "ax.set(xlim=(1, 1e3), ylim=(1, 1e3))\n", - "ax.grid(True);" + "mpl_ax = mpl_plt.axes(xscale=\"log\", yscale=\"log\")\n", + "mpl_ax.set(xlim=(1, 1e3), ylim=(1, 1e3))\n", + "mpl_ax.grid(True);" ] }, { @@ -112,9 +112,9 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "ax = xy_plt.axes(xscale=\"log\", yscale=\"log\")\n", - "ax.set(xlim=(1, 1e3), ylim=(1, 1e3))\n", - "ax.grid(True);" + "xy_ax = xy_plt.axes(xscale=\"log\", yscale=\"log\")\n", + "xy_ax.set(xlim=(1, 1e3), ylim=(1, 1e3))\n", + "xy_ax.grid(True);" ] }, { @@ -128,8 +128,8 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "print(ax.xaxis.get_major_locator())\n", - "print(ax.xaxis.get_minor_locator())" + "print(mpl_ax.xaxis.get_major_locator())\n", + "print(mpl_ax.xaxis.get_minor_locator())" ] }, { @@ -143,8 +143,8 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "print(ax.xaxis.get_major_locator())\n", - "print(ax.xaxis.get_minor_locator())" + "print(xy_ax.xaxis.get_major_locator())\n", + "print(xy_ax.xaxis.get_minor_locator())" ] }, { @@ -158,8 +158,8 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "print(ax.xaxis.get_major_formatter())\n", - "print(ax.xaxis.get_minor_formatter())" + "print(mpl_ax.xaxis.get_major_formatter())\n", + "print(mpl_ax.xaxis.get_minor_formatter())" ] }, { @@ -173,8 +173,8 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "print(ax.xaxis.get_major_formatter())\n", - "print(ax.xaxis.get_minor_formatter())" + "print(xy_ax.xaxis.get_major_formatter())\n", + "print(xy_ax.xaxis.get_minor_formatter())" ] }, { @@ -200,13 +200,13 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "ax = mpl_plt.axes()\n", + "mpl_ax = mpl_plt.axes()\n", "rng = np.random.default_rng(1701)\n", - "ax.plot(rng.random(50))\n", - "ax.grid()\n", + "mpl_ax.plot(rng.random(50))\n", + "mpl_ax.grid()\n", "\n", - "ax.yaxis.set_major_locator(mpl_plt.NullLocator())\n", - "ax.xaxis.set_major_formatter(mpl_plt.NullFormatter())" + "mpl_ax.yaxis.set_major_locator(mpl_plt.NullLocator())\n", + "mpl_ax.xaxis.set_major_formatter(mpl_plt.NullFormatter())" ] }, { @@ -220,13 +220,13 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "ax = xy_plt.axes()\n", + "xy_ax = xy_plt.axes()\n", "rng = np.random.default_rng(1701)\n", - "ax.plot(rng.random(50))\n", - "ax.grid()\n", + "xy_ax.plot(rng.random(50))\n", + "xy_ax.grid()\n", "\n", - "ax.yaxis.set_major_locator(xy_plt.NullLocator())\n", - "ax.xaxis.set_major_formatter(xy_plt.NullFormatter())" + "xy_ax.yaxis.set_major_locator(xy_plt.NullLocator())\n", + "xy_ax.xaxis.set_major_formatter(xy_plt.NullFormatter())" ] }, { @@ -240,8 +240,8 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "fig, ax = mpl_plt.subplots(5, 5, figsize=(5, 5))\n", - "fig.subplots_adjust(hspace=0, wspace=0)\n", + "mpl_fig, mpl_ax = mpl_plt.subplots(5, 5, figsize=(5, 5))\n", + "mpl_fig.subplots_adjust(hspace=0, wspace=0)\n", "\n", "# Get some face data from Scikit-Learn\n", "from sklearn.datasets import fetch_olivetti_faces\n", @@ -250,9 +250,9 @@ "\n", "for i in range(5):\n", " for j in range(5):\n", - " ax[i, j].xaxis.set_major_locator(mpl_plt.NullLocator())\n", - " ax[i, j].yaxis.set_major_locator(mpl_plt.NullLocator())\n", - " ax[i, j].imshow(faces[10 * i + j], cmap=\"binary_r\")" + " mpl_ax[i, j].xaxis.set_major_locator(mpl_plt.NullLocator())\n", + " mpl_ax[i, j].yaxis.set_major_locator(mpl_plt.NullLocator())\n", + " mpl_ax[i, j].imshow(faces[10 * i + j], cmap=\"binary_r\")" ] }, { @@ -266,8 +266,8 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "fig, ax = xy_plt.subplots(5, 5, figsize=(5, 5))\n", - "fig.subplots_adjust(hspace=0, wspace=0)\n", + "xy_fig, xy_ax = xy_plt.subplots(5, 5, figsize=(5, 5))\n", + "xy_fig.subplots_adjust(hspace=0, wspace=0)\n", "\n", "# Get some face data from Scikit-Learn\n", "from sklearn.datasets import fetch_olivetti_faces\n", @@ -276,9 +276,9 @@ "\n", "for i in range(5):\n", " for j in range(5):\n", - " ax[i, j].xaxis.set_major_locator(xy_plt.NullLocator())\n", - " ax[i, j].yaxis.set_major_locator(xy_plt.NullLocator())\n", - " ax[i, j].imshow(faces[10 * i + j], cmap=\"binary_r\")" + " xy_ax[i, j].xaxis.set_major_locator(xy_plt.NullLocator())\n", + " xy_ax[i, j].yaxis.set_major_locator(xy_plt.NullLocator())\n", + " xy_ax[i, j].imshow(faces[10 * i + j], cmap=\"binary_r\")" ] }, { @@ -304,7 +304,7 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "fig, ax = mpl_plt.subplots(4, 4, sharex=True, sharey=True)" + "mpl_fig, mpl_ax = mpl_plt.subplots(4, 4, sharex=True, sharey=True)" ] }, { @@ -318,7 +318,7 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "fig, ax = xy_plt.subplots(4, 4, sharex=True, sharey=True)" + "xy_fig, xy_ax = xy_plt.subplots(4, 4, sharex=True, sharey=True)" ] }, { @@ -333,10 +333,10 @@ "source": [ "# Matplotlib reference\n", "# For every axis, set the x and y major locator\n", - "for axi in ax.flat:\n", + "for axi in mpl_ax.flat:\n", " axi.xaxis.set_major_locator(mpl_plt.MaxNLocator(3))\n", " axi.yaxis.set_major_locator(mpl_plt.MaxNLocator(3))\n", - "fig" + "mpl_fig" ] }, { @@ -351,10 +351,10 @@ "source": [ "# xy.pyplot\n", "# For every axis, set the x and y major locator\n", - "for axi in ax.flat:\n", + "for axi in xy_ax.flat:\n", " axi.xaxis.set_major_locator(xy_plt.MaxNLocator(3))\n", " axi.yaxis.set_major_locator(xy_plt.MaxNLocator(3))\n", - "fig" + "xy_fig" ] }, { @@ -381,16 +381,16 @@ "source": [ "# Matplotlib reference\n", "# Plot a sine and cosine curve\n", - "fig, ax = mpl_plt.subplots()\n", + "mpl_fig, mpl_ax = mpl_plt.subplots()\n", "x = np.linspace(0, 3 * np.pi, 1000)\n", - "ax.plot(x, np.sin(x), lw=3, label=\"Sine\")\n", - "ax.plot(x, np.cos(x), lw=3, label=\"Cosine\")\n", + "mpl_ax.plot(x, np.sin(x), lw=3, label=\"Sine\")\n", + "mpl_ax.plot(x, np.cos(x), lw=3, label=\"Cosine\")\n", "\n", - "# Set up grid, legend, and limits\n", - "ax.grid(True)\n", - "ax.legend(frameon=False)\n", - "ax.axis(\"equal\")\n", - "ax.set_xlim(0, 3 * np.pi);" + "# Set up mpl_grid, legend, and limits\n", + "mpl_ax.grid(True)\n", + "mpl_ax.legend(frameon=False)\n", + "mpl_ax.axis(\"equal\")\n", + "mpl_ax.set_xlim(0, 3 * np.pi);" ] }, { @@ -405,16 +405,16 @@ "source": [ "# xy.pyplot\n", "# Plot a sine and cosine curve\n", - "fig, ax = xy_plt.subplots()\n", + "xy_fig, xy_ax = xy_plt.subplots()\n", "x = np.linspace(0, 3 * np.pi, 1000)\n", - "ax.plot(x, np.sin(x), lw=3, label=\"Sine\")\n", - "ax.plot(x, np.cos(x), lw=3, label=\"Cosine\")\n", + "xy_ax.plot(x, np.sin(x), lw=3, label=\"Sine\")\n", + "xy_ax.plot(x, np.cos(x), lw=3, label=\"Cosine\")\n", "\n", - "# Set up grid, legend, and limits\n", - "ax.grid(True)\n", - "ax.legend(frameon=False)\n", - "ax.axis(\"equal\")\n", - "ax.set_xlim(0, 3 * np.pi);" + "# Set up xy_grid, legend, and limits\n", + "xy_ax.grid(True)\n", + "xy_ax.legend(frameon=False)\n", + "xy_ax.axis(\"equal\")\n", + "xy_ax.set_xlim(0, 3 * np.pi);" ] }, { @@ -428,9 +428,9 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "ax.xaxis.set_major_locator(mpl_plt.MultipleLocator(np.pi / 2))\n", - "ax.xaxis.set_minor_locator(mpl_plt.MultipleLocator(np.pi / 4))\n", - "fig" + "mpl_ax.xaxis.set_major_locator(mpl_plt.MultipleLocator(np.pi / 2))\n", + "mpl_ax.xaxis.set_minor_locator(mpl_plt.MultipleLocator(np.pi / 4))\n", + "mpl_fig" ] }, { @@ -444,9 +444,9 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "ax.xaxis.set_major_locator(xy_plt.MultipleLocator(np.pi / 2))\n", - "ax.xaxis.set_minor_locator(xy_plt.MultipleLocator(np.pi / 4))\n", - "fig" + "xy_ax.xaxis.set_major_locator(xy_plt.MultipleLocator(np.pi / 2))\n", + "xy_ax.xaxis.set_minor_locator(xy_plt.MultipleLocator(np.pi / 4))\n", + "xy_fig" ] }, { @@ -475,8 +475,8 @@ " return rf\"${N // 2}\\pi$\"\n", "\n", "\n", - "ax.xaxis.set_major_formatter(mpl_plt.FuncFormatter(format_func))\n", - "fig" + "mpl_ax.xaxis.set_major_formatter(mpl_plt.FuncFormatter(format_func))\n", + "mpl_fig" ] }, { @@ -505,8 +505,8 @@ " return rf\"${N // 2}\\pi$\"\n", "\n", "\n", - "ax.xaxis.set_major_formatter(xy_plt.FuncFormatter(format_func))\n", - "fig" + "xy_ax.xaxis.set_major_formatter(xy_plt.FuncFormatter(format_func))\n", + "xy_fig" ] }, { diff --git a/examples/pdsh/pdsh_04_11_settings_and_stylesheets.ipynb b/examples/pdsh/pdsh_04_11_settings_and_stylesheets.ipynb index bd6dd0d1..bb522a12 100644 --- a/examples/pdsh/pdsh_04_11_settings_and_stylesheets.ipynb +++ b/examples/pdsh/pdsh_04_11_settings_and_stylesheets.ipynb @@ -127,30 +127,30 @@ "source": [ "# Matplotlib reference\n", "# use a gray background\n", - "fig = mpl_plt.figure(facecolor=\"white\")\n", - "ax = mpl_plt.axes(facecolor=\"#E6E6E6\")\n", - "ax.set_axisbelow(True)\n", + "mpl_fig = mpl_plt.figure(facecolor=\"white\")\n", + "mpl_ax = mpl_plt.axes(facecolor=\"#E6E6E6\")\n", + "mpl_ax.set_axisbelow(True)\n", "\n", "# draw solid white gridlines\n", "mpl_plt.grid(color=\"w\", linestyle=\"solid\")\n", "\n", "# hide axis spines\n", - "for spine in ax.spines.values():\n", + "for spine in mpl_ax.spines.values():\n", " spine.set_visible(False)\n", "\n", "# hide top and right ticks\n", - "ax.xaxis.tick_bottom()\n", - "ax.yaxis.tick_left()\n", + "mpl_ax.xaxis.tick_bottom()\n", + "mpl_ax.yaxis.tick_left()\n", "\n", "# lighten ticks and labels\n", - "ax.tick_params(colors=\"gray\", direction=\"out\")\n", - "for tick in ax.get_xticklabels():\n", + "mpl_ax.tick_params(colors=\"gray\", direction=\"out\")\n", + "for tick in mpl_ax.get_xticklabels():\n", " tick.set_color(\"gray\")\n", - "for tick in ax.get_yticklabels():\n", + "for tick in mpl_ax.get_yticklabels():\n", " tick.set_color(\"gray\")\n", "\n", "# control face and edge color of histogram\n", - "ax.hist(x, edgecolor=\"#E6E6E6\", color=\"#EE6666\");" + "mpl_ax.hist(x, edgecolor=\"#E6E6E6\", color=\"#EE6666\");" ] }, { @@ -165,30 +165,30 @@ "source": [ "# xy.pyplot\n", "# use a gray background\n", - "fig = xy_plt.figure(facecolor=\"white\")\n", - "ax = xy_plt.axes(facecolor=\"#E6E6E6\")\n", - "ax.set_axisbelow(True)\n", + "xy_fig = xy_plt.figure(facecolor=\"white\")\n", + "xy_ax = xy_plt.axes(facecolor=\"#E6E6E6\")\n", + "xy_ax.set_axisbelow(True)\n", "\n", "# draw solid white gridlines\n", "xy_plt.grid(color=\"w\", linestyle=\"solid\")\n", "\n", "# hide axis spines\n", - "for spine in ax.spines.values():\n", + "for spine in xy_ax.spines.values():\n", " spine.set_visible(False)\n", "\n", "# hide top and right ticks\n", - "ax.xaxis.tick_bottom()\n", - "ax.yaxis.tick_left()\n", + "xy_ax.xaxis.tick_bottom()\n", + "xy_ax.yaxis.tick_left()\n", "\n", "# lighten ticks and labels\n", - "ax.tick_params(colors=\"gray\", direction=\"out\")\n", - "for tick in ax.get_xticklabels():\n", + "xy_ax.tick_params(colors=\"gray\", direction=\"out\")\n", + "for tick in xy_ax.get_xticklabels():\n", " tick.set_color(\"gray\")\n", - "for tick in ax.get_yticklabels():\n", + "for tick in xy_ax.get_yticklabels():\n", " tick.set_color(\"gray\")\n", "\n", "# control face and edge color of histogram\n", - "ax.hist(x, edgecolor=\"#E6E6E6\", color=\"#EE6666\");" + "xy_ax.hist(x, edgecolor=\"#E6E6E6\", color=\"#EE6666\");" ] }, { @@ -364,11 +364,11 @@ "# Matplotlib reference\n", "def hist_and_lines():\n", " np.random.seed(0)\n", - " fig, ax = mpl_plt.subplots(1, 2, figsize=(11, 4))\n", - " ax[0].hist(np.random.randn(1000))\n", + " mpl_fig, mpl_ax = mpl_plt.subplots(1, 2, figsize=(11, 4))\n", + " mpl_ax[0].hist(np.random.randn(1000))\n", " for i in range(3):\n", - " ax[1].plot(np.random.rand(10))\n", - " ax[1].legend([\"a\", \"b\", \"c\"], loc=\"lower left\")" + " mpl_ax[1].plot(np.random.rand(10))\n", + " mpl_ax[1].legend([\"a\", \"b\", \"c\"], loc=\"lower left\")" ] }, { @@ -384,11 +384,11 @@ "# xy.pyplot\n", "def hist_and_lines():\n", " np.random.seed(0)\n", - " fig, ax = xy_plt.subplots(1, 2, figsize=(11, 4))\n", - " ax[0].hist(np.random.randn(1000))\n", + " xy_fig, xy_ax = xy_plt.subplots(1, 2, figsize=(11, 4))\n", + " xy_ax[0].hist(np.random.randn(1000))\n", " for i in range(3):\n", - " ax[1].plot(np.random.rand(10))\n", - " ax[1].legend([\"a\", \"b\", \"c\"], loc=\"lower left\")" + " xy_ax[1].plot(np.random.rand(10))\n", + " xy_ax[1].legend([\"a\", \"b\", \"c\"], loc=\"lower left\")" ] }, { diff --git a/examples/pdsh/pdsh_04_12_three_dimensional_plotting.ipynb b/examples/pdsh/pdsh_04_12_three_dimensional_plotting.ipynb index d5170a80..0e1e0330 100644 --- a/examples/pdsh/pdsh_04_12_three_dimensional_plotting.ipynb +++ b/examples/pdsh/pdsh_04_12_three_dimensional_plotting.ipynb @@ -108,8 +108,8 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "fig = mpl_plt.figure()\n", - "ax = mpl_plt.axes(projection=\"3d\")" + "mpl_fig = mpl_plt.figure()\n", + "mpl_ax = mpl_plt.axes(projection=\"3d\")" ] }, { @@ -123,8 +123,8 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "fig = xy_plt.figure()\n", - "ax = xy_plt.axes(projection=\"3d\")" + "xy_fig = xy_plt.figure()\n", + "xy_ax = xy_plt.axes(projection=\"3d\")" ] }, { @@ -150,19 +150,19 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "ax = mpl_plt.axes(projection=\"3d\")\n", + "mpl_ax = mpl_plt.axes(projection=\"3d\")\n", "\n", "# Data for a three-dimensional line\n", "zline = np.linspace(0, 15, 1000)\n", "xline = np.sin(zline)\n", "yline = np.cos(zline)\n", - "ax.plot3D(xline, yline, zline, \"gray\")\n", + "mpl_ax.plot3D(xline, yline, zline, \"gray\")\n", "\n", "# Data for three-dimensional scattered points\n", "zdata = 15 * np.random.random(100)\n", "xdata = np.sin(zdata) + 0.1 * np.random.randn(100)\n", "ydata = np.cos(zdata) + 0.1 * np.random.randn(100)\n", - "ax.scatter3D(xdata, ydata, zdata, c=zdata, cmap=\"Greens\");" + "mpl_ax.scatter3D(xdata, ydata, zdata, c=zdata, cmap=\"Greens\");" ] }, { @@ -176,19 +176,19 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "ax = xy_plt.axes(projection=\"3d\")\n", + "xy_ax = xy_plt.axes(projection=\"3d\")\n", "\n", "# Data for a three-dimensional line\n", "zline = np.linspace(0, 15, 1000)\n", "xline = np.sin(zline)\n", "yline = np.cos(zline)\n", - "ax.plot3D(xline, yline, zline, \"gray\")\n", + "xy_ax.plot3D(xline, yline, zline, \"gray\")\n", "\n", "# Data for three-dimensional scattered points\n", "zdata = 15 * np.random.random(100)\n", "xdata = np.sin(zdata) + 0.1 * np.random.randn(100)\n", "ydata = np.cos(zdata) + 0.1 * np.random.randn(100)\n", - "ax.scatter3D(xdata, ydata, zdata, c=zdata, cmap=\"Greens\");" + "xy_ax.scatter3D(xdata, ydata, zdata, c=zdata, cmap=\"Greens\");" ] }, { @@ -258,12 +258,12 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "fig = mpl_plt.figure()\n", - "ax = mpl_plt.axes(projection=\"3d\")\n", - "ax.contour3D(X, Y, Z, 40, cmap=\"binary\")\n", - "ax.set_xlabel(\"x\")\n", - "ax.set_ylabel(\"y\")\n", - "ax.set_zlabel(\"z\");" + "mpl_fig = mpl_plt.figure()\n", + "mpl_ax = mpl_plt.axes(projection=\"3d\")\n", + "mpl_ax.contour3D(X, Y, Z, 40, cmap=\"binary\")\n", + "mpl_ax.set_xlabel(\"x\")\n", + "mpl_ax.set_ylabel(\"y\")\n", + "mpl_ax.set_zlabel(\"z\");" ] }, { @@ -277,12 +277,12 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "fig = xy_plt.figure()\n", - "ax = xy_plt.axes(projection=\"3d\")\n", - "ax.contour3D(X, Y, Z, 40, cmap=\"binary\")\n", - "ax.set_xlabel(\"x\")\n", - "ax.set_ylabel(\"y\")\n", - "ax.set_zlabel(\"z\");" + "xy_fig = xy_plt.figure()\n", + "xy_ax = xy_plt.axes(projection=\"3d\")\n", + "xy_ax.contour3D(X, Y, Z, 40, cmap=\"binary\")\n", + "xy_ax.set_xlabel(\"x\")\n", + "xy_ax.set_ylabel(\"y\")\n", + "xy_ax.set_zlabel(\"z\");" ] }, { @@ -296,8 +296,8 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "ax.view_init(60, 35)\n", - "fig" + "mpl_ax.view_init(60, 35)\n", + "mpl_fig" ] }, { @@ -311,8 +311,8 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "ax.view_init(60, 35)\n", - "fig" + "xy_ax.view_init(60, 35)\n", + "xy_fig" ] }, { @@ -338,10 +338,10 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "fig = mpl_plt.figure()\n", - "ax = mpl_plt.axes(projection=\"3d\")\n", - "ax.plot_wireframe(X, Y, Z)\n", - "ax.set_title(\"wireframe\");" + "mpl_fig = mpl_plt.figure()\n", + "mpl_ax = mpl_plt.axes(projection=\"3d\")\n", + "mpl_ax.plot_wireframe(X, Y, Z)\n", + "mpl_ax.set_title(\"wireframe\");" ] }, { @@ -355,10 +355,10 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "fig = xy_plt.figure()\n", - "ax = xy_plt.axes(projection=\"3d\")\n", - "ax.plot_wireframe(X, Y, Z)\n", - "ax.set_title(\"wireframe\");" + "xy_fig = xy_plt.figure()\n", + "xy_ax = xy_plt.axes(projection=\"3d\")\n", + "xy_ax.plot_wireframe(X, Y, Z)\n", + "xy_ax.set_title(\"wireframe\");" ] }, { @@ -372,9 +372,9 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "ax = mpl_plt.axes(projection=\"3d\")\n", - "ax.plot_surface(X, Y, Z, rstride=1, cstride=1, cmap=\"viridis\", edgecolor=\"none\")\n", - "ax.set_title(\"surface\");" + "mpl_ax = mpl_plt.axes(projection=\"3d\")\n", + "mpl_ax.plot_surface(X, Y, Z, rstride=1, cstride=1, cmap=\"viridis\", edgecolor=\"none\")\n", + "mpl_ax.set_title(\"surface\");" ] }, { @@ -388,9 +388,9 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "ax = xy_plt.axes(projection=\"3d\")\n", - "ax.plot_surface(X, Y, Z, rstride=1, cstride=1, cmap=\"viridis\", edgecolor=\"none\")\n", - "ax.set_title(\"surface\");" + "xy_ax = xy_plt.axes(projection=\"3d\")\n", + "xy_ax.plot_surface(X, Y, Z, rstride=1, cstride=1, cmap=\"viridis\", edgecolor=\"none\")\n", + "xy_ax.set_title(\"surface\");" ] }, { @@ -412,8 +412,8 @@ "Y = r * np.cos(theta)\n", "Z = f(X, Y)\n", "\n", - "ax = mpl_plt.axes(projection=\"3d\")\n", - "ax.plot_surface(X, Y, Z, rstride=1, cstride=1, cmap=\"viridis\", edgecolor=\"none\");" + "mpl_ax = mpl_plt.axes(projection=\"3d\")\n", + "mpl_ax.plot_surface(X, Y, Z, rstride=1, cstride=1, cmap=\"viridis\", edgecolor=\"none\");" ] }, { @@ -435,8 +435,8 @@ "Y = r * np.cos(theta)\n", "Z = f(X, Y)\n", "\n", - "ax = xy_plt.axes(projection=\"3d\")\n", - "ax.plot_surface(X, Y, Z, rstride=1, cstride=1, cmap=\"viridis\", edgecolor=\"none\");" + "xy_ax = xy_plt.axes(projection=\"3d\")\n", + "xy_ax.plot_surface(X, Y, Z, rstride=1, cstride=1, cmap=\"viridis\", edgecolor=\"none\");" ] }, { @@ -498,8 +498,8 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "ax = mpl_plt.axes(projection=\"3d\")\n", - "ax.scatter(x, y, z, c=z, cmap=\"viridis\", linewidth=0.5);" + "mpl_ax = mpl_plt.axes(projection=\"3d\")\n", + "mpl_ax.scatter(x, y, z, c=z, cmap=\"viridis\", linewidth=0.5);" ] }, { @@ -513,8 +513,8 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "ax = xy_plt.axes(projection=\"3d\")\n", - "ax.scatter(x, y, z, c=z, cmap=\"viridis\", linewidth=0.5);" + "xy_ax = xy_plt.axes(projection=\"3d\")\n", + "xy_ax.scatter(x, y, z, c=z, cmap=\"viridis\", linewidth=0.5);" ] }, { @@ -528,8 +528,8 @@ "outputs": [], "source": [ "# Matplotlib reference\n", - "ax = mpl_plt.axes(projection=\"3d\")\n", - "ax.plot_trisurf(x, y, z, cmap=\"viridis\", edgecolor=\"none\");" + "mpl_ax = mpl_plt.axes(projection=\"3d\")\n", + "mpl_ax.plot_trisurf(x, y, z, cmap=\"viridis\", edgecolor=\"none\");" ] }, { @@ -543,8 +543,8 @@ "outputs": [], "source": [ "# xy.pyplot\n", - "ax = xy_plt.axes(projection=\"3d\")\n", - "ax.plot_trisurf(x, y, z, cmap=\"viridis\", edgecolor=\"none\");" + "xy_ax = xy_plt.axes(projection=\"3d\")\n", + "xy_ax.plot_trisurf(x, y, z, cmap=\"viridis\", edgecolor=\"none\");" ] }, { @@ -673,12 +673,12 @@ "\n", "tri = mpl_Triangulation(np.ravel(w), np.ravel(theta))\n", "\n", - "ax = mpl_plt.axes(projection=\"3d\")\n", - "ax.plot_trisurf(x, y, z, triangles=tri.triangles, cmap=\"Greys\", linewidths=0.2)\n", - "ax.set_xlim(-1, 1)\n", - "ax.set_ylim(-1, 1)\n", - "ax.set_zlim(-1, 1)\n", - "ax.axis(\"off\");" + "mpl_ax = mpl_plt.axes(projection=\"3d\")\n", + "mpl_ax.plot_trisurf(x, y, z, triangles=tri.triangles, cmap=\"Greys\", linewidths=0.2)\n", + "mpl_ax.set_xlim(-1, 1)\n", + "mpl_ax.set_ylim(-1, 1)\n", + "mpl_ax.set_zlim(-1, 1)\n", + "mpl_ax.axis(\"off\");" ] }, { @@ -697,12 +697,12 @@ "\n", "tri = xy_Triangulation(np.ravel(w), np.ravel(theta))\n", "\n", - "ax = xy_plt.axes(projection=\"3d\")\n", - "ax.plot_trisurf(x, y, z, triangles=tri.triangles, cmap=\"Greys\", linewidths=0.2)\n", - "ax.set_xlim(-1, 1)\n", - "ax.set_ylim(-1, 1)\n", - "ax.set_zlim(-1, 1)\n", - "ax.axis(\"off\");" + "xy_ax = xy_plt.axes(projection=\"3d\")\n", + "xy_ax.plot_trisurf(x, y, z, triangles=tri.triangles, cmap=\"Greys\", linewidths=0.2)\n", + "xy_ax.set_xlim(-1, 1)\n", + "xy_ax.set_ylim(-1, 1)\n", + "xy_ax.set_zlim(-1, 1)\n", + "xy_ax.axis(\"off\");" ] } ], diff --git a/examples/pdsh/pdsh_04_14_visualization_with_seaborn.ipynb b/examples/pdsh/pdsh_04_14_visualization_with_seaborn.ipynb index 047c5b82..1eba86a0 100644 --- a/examples/pdsh/pdsh_04_14_visualization_with_seaborn.ipynb +++ b/examples/pdsh/pdsh_04_14_visualization_with_seaborn.ipynb @@ -318,8 +318,8 @@ "# Matplotlib reference\n", "tips[\"tip_pct\"] = 100 * tips[\"tip\"] / tips[\"total_bill\"]\n", "\n", - "grid = sns.FacetGrid(tips, row=\"sex\", col=\"time\", margin_titles=True)\n", - "grid.map(mpl_plt.hist, \"tip_pct\", bins=np.linspace(0, 40, 15));" + "mpl_grid = sns.FacetGrid(tips, row=\"sex\", col=\"time\", margin_titles=True)\n", + "mpl_grid.map(mpl_plt.hist, \"tip_pct\", bins=np.linspace(0, 40, 15));" ] }, { @@ -335,8 +335,8 @@ "# xy.pyplot\n", "tips[\"tip_pct\"] = 100 * tips[\"tip\"] / tips[\"total_bill\"]\n", "\n", - "grid = sns.FacetGrid(tips, row=\"sex\", col=\"time\", margin_titles=True)\n", - "grid.map(xy_plt.hist, \"tip_pct\", bins=np.linspace(0, 40, 15));" + "xy_grid = sns.FacetGrid(tips, row=\"sex\", col=\"time\", margin_titles=True)\n", + "xy_grid.map(xy_plt.hist, \"tip_pct\", bins=np.linspace(0, 40, 15));" ] }, { diff --git a/examples/pdsh/sync_dual_engine_notebooks.py b/examples/pdsh/sync_dual_engine_notebooks.py index 3cbc0db9..17d75a20 100644 --- a/examples/pdsh/sync_dual_engine_notebooks.py +++ b/examples/pdsh/sync_dual_engine_notebooks.py @@ -12,6 +12,10 @@ ROOT = Path(__file__).resolve().parent SHARED_ID = "xy-pdsh-dual-engine-intro" IMPORT_REPLACEMENTS = { + # xy.pyplot serves as both `plt` and `mpl` (it carries the `dates` module + # etc.); the reference engine needs the real top-level package for `mpl`. + # Ordered before the generic rule so it wins for the `mpl` alias. + "import xy.pyplot as mpl\n": "import matplotlib as mpl\n", "import xy.pyplot as ": "import matplotlib.pyplot as ", "from xy.pyplot import LinearSegmentedColormap": ( "from matplotlib.colors import LinearSegmentedColormap" @@ -28,6 +32,24 @@ "Triangulation", "cycler", ) +# Cross-cell engine state: these variables are written in one cell and read in +# a later cell of the same engine, so the paired cells must not share them — +# an unprefixed `grid = xy_plt.GridSpec(...)` overwrote the matplotlib cell's +# `grid` and fed an xy object into real matplotlib. Prefixed like the aliases, +# but never in kwarg (`ax=ax`), attribute (`ax.grid(...)`), or string +# (`plt.rc("grid", ...)`) positions. +STATE_NAMES = ("fig", "ax", "grid") + + +def _prefix_state(line: str, prefix: str) -> str: + for name in STATE_NAMES: + line = re.sub( + rf"""(? list[Path]: @@ -52,7 +74,7 @@ def _xy_cells(notebook: dict) -> list[dict]: if metadata.get("xy_pdsh_auto_show") is True: cell["source"] = cell["source"][:-3] for index, line in enumerate(cell.get("source", [])): - for name in ENGINE_NAMES: + for name in (*ENGINE_NAMES, *STATE_NAMES): line = re.sub(rf"\bxy_{re.escape(name)}\b", name, line) for name in ENGINE_NAMES[2:]: line = line.replace(f" import {name} as {name}", f" import {name}") @@ -81,6 +103,7 @@ def _engine_cell(cell: dict, engine: str) -> dict: line = line.replace(old, new) for name in ENGINE_NAMES: line = re.sub(rf"\b{re.escape(name)}\b", f"{prefix}_{name}", line) + line = _prefix_state(line, prefix) for name in ENGINE_NAMES[2:]: line = line.replace(f" import {prefix}_{name}", f" import {name} as {prefix}_{name}") replaced.append(line) diff --git a/js/src/50_chartview.js b/js/src/50_chartview.js index b45f52ef..a5efb936 100644 --- a/js/src/50_chartview.js +++ b/js/src/50_chartview.js @@ -80,6 +80,19 @@ const FC_CONTEXT_GOVERNOR = { if (over <= 0) break; if (view._releaseContext()) over -= 1; } + if (over <= 0) return; + // Every remaining live view is on screen (a dense subplot grid). Release + // least-recently-visible ones anyway: the snapshot stand-in keeps them + // looking rendered, and pointer entry revives them. Letting the browser + // LRU-evict instead blanks visible charts with no recovery until the + // page scrolls (§28: data-fc-ctx stays legible either way). + const visible = live + .filter((view) => view._ctxVisible) + .sort((a, b) => (a._ctxSeenSeq || 0) - (b._ctxSeenSeq || 0)); + for (const view of visible) { + if (over <= 0) break; + if (view._releaseContext()) over -= 1; + } }, acquired(requester) { requester._ctxPendingReservation = false; @@ -145,7 +158,14 @@ class ChartView { const rect = this.fluid || this.fluidH ? el.getBoundingClientRect() : null; const cw = this.fluid ? Math.round(rect.width) || 640 : spec.width; // 0 = hidden; RO corrects const ch = this.fluidH ? Math.round(rect.height) || 420 : spec.height; - this.size = { w: Math.max(120, cw), h: Math.max(120, ch) }; + // Fluid floors stay high (a collapsed/hidden container must not produce a + // degenerate chart), but explicit sizes are honored down to a tiny floor: + // dense pyplot subplot grids legitimately build sub-120px panels whose + // plot boxes must land exactly on their matplotlib rects. + this.size = { + w: Math.max(this.fluid ? 120 : 48, cw), + h: Math.max(this.fluidH ? 120 : 48, ch), + }; this._layout(); this._buildDom(el); @@ -588,6 +608,7 @@ class ChartView { this.root.dataset.fcContextState = "ready"; this._scheduleViewRequest(this.view, { delay: 0 }); this.draw(); + this._dropContextSnapshot(); // live frame is back; retire the stand-in this._dispatchChartEvent("context_restored", { loss_count: this._contextLossCount, restore_count: this._contextRestoreCount, @@ -604,6 +625,7 @@ class ChartView { if (this._destroyed || !this.gl || this._glLost || this.gl.isContextLost()) return false; const ext = this.gl.getExtension("WEBGL_lose_context"); if (!ext) return false; + this._snapshotBeforeRelease(); this._ctxReleasedExt = ext; this._ctxReleases += 1; this._glLost = true; // synchronous: the lost *event* arrives as a task @@ -614,6 +636,44 @@ class ChartView { return true; } + // Freeze the current frame into a 2D stand-in before the GL context goes + // away. This is what lets the governor release *visible* views: an + // over-budget panel keeps showing its last frame as a static image + // (matplotlib-style) instead of blanking, and pointer entry swaps the live + // context back in. The draw must be synchronous and in the same task as the + // copy — the default drawing buffer does not persist between frames. + _snapshotBeforeRelease() { + try { + if (this._raf) cancelAnimationFrame(this._raf); + this._raf = null; + this._rafKeepPick = true; // pick FBO stays valid; only the color buffer is read + this._drawNow(); + let snap = this._ctxSnapshot; + if (!snap) { + snap = this._ctxSnapshot = document.createElement("canvas"); + snap.dataset.fcCtxSnapshot = ""; + } + snap.width = this.canvas.width; + snap.height = this.canvas.height; + snap.style.cssText = this.canvas.style.cssText; + snap.style.pointerEvents = "none"; + snap.getContext("2d").drawImage(this.canvas, 0, 0); + this.canvas.before(snap); + // Chrome composites a lost-context canvas as an opaque broken-image + // tile, which would sit on top of the stand-in. Events still reach the + // root, so pointer-entry revival keeps working. + this.canvas.style.visibility = "hidden"; + } catch (_err) { + this._dropContextSnapshot(); // released view degrades to blank, as before + } + } + + _dropContextSnapshot() { + this.canvas.style.visibility = ""; + if (this._ctxSnapshot) this._ctxSnapshot.remove(); + this._ctxSnapshot = null; + } + // Re-acquire on scroll-into-view. Governed releases undo via // restoreContext() -> the existing restored handler rebuilds; a real // browser eviction cannot be force-restored, so the canvas is swapped for a @@ -667,6 +727,7 @@ class ChartView { } this._scheduleViewRequest(this.view, { delay: 0 }); this.draw(); + this._dropContextSnapshot(); } // Visibility feed for the governor: tracks least-recently-visible order and @@ -674,6 +735,12 @@ class ChartView { // view (25% rootMargin = pre-warm hysteresis; release is demand-driven only, // so fast scrolling never thrashes contexts). _armContextVisibilityWatch() { + // A released-while-visible view (snapshot stand-in) revives on pointer + // entry — visibility alone can't distinguish it from its neighbors, and + // touching a chart is the interaction signal that it needs to be live. + this._listen(this.root, "pointerenter", () => { + if (this._glLost && !this._destroyed) this._recoverContext(); + }); if (typeof IntersectionObserver === "undefined") { this._ctxVisible = true; // no observer: never treat as releasable return; @@ -725,6 +792,7 @@ class ChartView { } this._positionReductionBadges(); this._positionColorbar(); + this._fitModebar(); this._pickDirty = true; this.draw(); this._scheduleViewRequest(); @@ -1983,9 +2051,11 @@ class ChartView { const { x0, x1, y0, y1 } = this.view; gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, this.canvas.width, this.canvas.height); - const bg = this.theme.bg; - if (bg) gl.clearColor(bg[0] * bg[3], bg[1] * bg[3], bg[2] * bg[3], bg[3]); - else gl.clearColor(0, 0, 0, 0); + // Always transparent: this canvas sits ABOVE the chrome canvas over the + // plot rect, so an opaque --chart-bg clear here would occlude everything + // chrome draws inside the plot (grid, bands, rules, arrows). The plot + // background paints at the bottom of the stack in _drawChrome instead. + gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); for (const g of this.gpuTraces) { @@ -2243,7 +2313,19 @@ class ChartView { gl.uniform4f(u("u_view"), vx0 ?? x0, vx1 ?? x1, vy0 ?? y0, vy1 ?? y1); gl.uniform1i(u("u_xmode"), this._axisMode(g.xAxis)); gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis)); - gl.uniform4f(u("u_gridRange"), h.xRange[0], h.xRange[1], h.yRange[0], h.yRange[1]); + // Grid row/column 0 anchors to the bottom/left edge of the grid rect in + // *display* orientation — the raster/SVG exporters' convention (the shim's + // imshow pre-flips rows for origin='upper' assuming it). A reversed axis + // flips the data direction, not the buffer, so swap the sampled range to + // keep the anchoring; without this an inverted-y imshow rendered upside + // down on canvas while every export of the same spec was upright. + const xrev = (vx0 ?? x0) > (vx1 ?? x1); + const yrev = (vy0 ?? y0) > (vy1 ?? y1); + gl.uniform4f( + u("u_gridRange"), + h.xRange[xrev ? 1 : 0], h.xRange[xrev ? 0 : 1], + h.yRange[yrev ? 1 : 0], h.yRange[yrev ? 0 : 1], + ); gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style)); gl.uniform1i(u("u_truecolor"), h.truecolor ? 1 : 0); gl.activeTexture(gl.TEXTURE0); @@ -2826,6 +2908,13 @@ class ChartView { } const p = this.plot; + // Plot background (--chart-bg) paints here, at the bottom of the chrome + // canvas, so the grid and annotation shapes drawn next stay visible and + // the transparent marks canvas above shows all of it (§36 theming). + if (this.theme.bg) { + ctx.fillStyle = cssColor(this.theme.bg); + ctx.fillRect(p.x, p.y, p.w, p.h); + } const xAxis = this._axis("x"); const yAxis = this._axis("y"); const hideX = this._axisTickLabelStrategy(xAxis) === "none"; @@ -3268,8 +3357,14 @@ class ChartView { const [x0, x1] = h.xRange; const [y0, y1] = h.yRange; if (dataX < x0 || dataX > x1 || dataY < y0 || dataY > y1) return null; - const col = Math.min(h.w - 1, Math.max(0, Math.floor(((dataX - x0) / (x1 - x0)) * h.w))); - const row = Math.min(h.h - 1, Math.max(0, Math.floor(((dataY - y0) / (y1 - y0)) * h.h))); + // Mirror _drawHeatmap's display-orientation anchoring: on a reversed axis + // buffer row/column 0 sits at the opposite end of the data range. + const [ax0, ax1] = this._axisRange(g.xAxis) ?? [this.view.x0, this.view.x1]; + const [ay0, ay1] = this._axisRange(g.yAxis) ?? [this.view.y0, this.view.y1]; + const fx = ((ax0 ?? this.view.x0) > (ax1 ?? this.view.x1)) ? (x1 - dataX) : (dataX - x0); + const fy = ((ay0 ?? this.view.y0) > (ay1 ?? this.view.y1)) ? (y1 - dataY) : (dataY - y0); + const col = Math.min(h.w - 1, Math.max(0, Math.floor((fx / (x1 - x0)) * h.w))); + const row = Math.min(h.h - 1, Math.max(0, Math.floor((fy / (y1 - y0)) * h.h))); return { trace: g.trace.id, index: row * h.w + col, g, heatmap: { row, col }, synthetic: true }; } diff --git a/js/src/51_annotations.js b/js/src/51_annotations.js index 64a309b3..66c30f24 100644 --- a/js/src/51_annotations.js +++ b/js/src/51_annotations.js @@ -3,6 +3,136 @@ // shape fills, and collision-nudged labels. Split out of 50_chartview.js; // augments the prototype so `this.*` is unchanged. +// Annotation style keys consumed by the canvas shape (shaft/head/marker +// geometry and paint) — never forwarded to the DOM label as CSS. +const FC_ANNOTATION_SHAPE_STYLE_KEYS = new Set([ + "color", + "label_color", + "width", + "head_size", + "head_style", + "tail_style", + "shaft_width_start", + "shaft_width_end", + "curve", + "angle_a", + "angle_b", + "gap_start", + "gap_end", + "dash", + "span_start", + "span_end", + "size", + "symbol", + "stroke_color", + "stroke_width", + "coordinate_space", +]); + +// Arrow path geometry shared by every arrow/callout draw (mirrored by the +// static exporters in python/xy/_arrowgeom.py — keep the two in sync): +// an optional quadratic control point from `curve` (matplotlib arc3 rad, +// bulge as a fraction of chord length) or `angle_a`/`angle_b` (matplotlib +// angle3/angle departure/arrival angles in degrees, y-up screen space — +// control point at the ray intersection), then `gap_start`/`gap_end` px +// trims along the path tangents (label/point clearance). +function fcArrowGeometry(x0, y0, x1, y1, style) { + const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : null); + const angleA = num(style.angle_a); + const angleB = num(style.angle_b); + const curve = num(style.curve); + let cx = null; + let cy = null; + if (angleA !== null && angleB !== null) { + const a = (-angleA * Math.PI) / 180; // spec angles are y-up; canvas is y-down + const b = (-angleB * Math.PI) / 180; + const denom = Math.cos(a) * Math.sin(b) - Math.sin(a) * Math.cos(b); + if (Math.abs(denom) > 1e-6) { + const t = ((x1 - x0) * Math.sin(b) - (y1 - y0) * Math.cos(b)) / denom; + cx = x0 + t * Math.cos(a); + cy = y0 + t * Math.sin(a); + } + } else if (curve) { + const dx = x1 - x0; + const dy = y1 - y0; + // arc3 rad > 0 bulges to the chord's left in matplotlib's y-up plane. + cx = (x0 + x1) / 2 + curve * dy; + cy = (y0 + y1) / 2 - curve * dx; + } + const toward = (px, py, qx, qy) => { + const d = Math.hypot(qx - px, qy - py) || 1; + return [(qx - px) / d, (qy - py) / d]; + }; + const t0 = cx === null ? toward(x0, y0, x1, y1) : toward(x0, y0, cx, cy); + const t1 = cx === null ? toward(x1, y1, x0, y0) : toward(x1, y1, cx, cy); + const gapStart = Math.max(0, num(style.gap_start) || 0); + const gapEnd = Math.max(0, num(style.gap_end) || 0); + const span = Math.hypot(x1 - x0, y1 - y0); + const trim = gapStart + gapEnd < span * 0.9; + const p0 = trim ? [x0 + gapStart * t0[0], y0 + gapStart * t0[1]] : [x0, y0]; + const p1 = trim ? [x1 + gapEnd * t1[0], y1 + gapEnd * t1[1]] : [x1, y1]; + // Tangent INTO each endpoint (head/tail orientation). + const dir1 = cx === null ? toward(p0[0], p0[1], p1[0], p1[1]) : toward(cx, cy, p1[0], p1[1]); + const dir0 = cx === null ? toward(p1[0], p1[1], p0[0], p0[1]) : toward(cx, cy, p0[0], p0[1]); + return { p0, p1, control: cx === null ? null : [cx, cy], dir0, dir1 }; +} + +// The shaft as a point list (quadratic Bézier sampled when curved). +function fcArrowShaftPoints(geom, samples = 24) { + const [x0, y0] = geom.p0; + const [x1, y1] = geom.p1; + if (!geom.control) return [[x0, y0], [x1, y1]]; + const [cx, cy] = geom.control; + const points = []; + for (let i = 0; i <= samples; i++) { + const t = i / samples; + const u = 1 - t; + points.push([u * u * x0 + 2 * u * t * cx + t * t * x1, u * u * y0 + 2 * u * t * cy + t * t * y1]); + } + return points; +} + +// The polyline with `trim` px of arclength removed from its end (a tapered +// shaft ends at the head BASE — a full-length shaft would swallow the head). +function fcTrimPolylineEnd(points, trim) { + if (!(trim > 0) || points.length < 2) return points; + const out = points.slice(); + let remaining = trim; + while (out.length >= 2) { + const [ax, ay] = out[out.length - 2]; + const [bx, by] = out[out.length - 1]; + const seg = Math.hypot(bx - ax, by - ay); + if (seg > remaining) { + const t = 1 - remaining / seg; + out[out.length - 1] = [ax + t * (bx - ax), ay + t * (by - ay)]; + return out; + } + remaining -= seg; + out.pop(); + } + return out; +} + +// The shaft as a filled polygon whose width interpolates from w0 to w1 +// (matplotlib's fancy/simple/wedge arrowstyles are filled tapered shafts). +function fcTaperPolygon(points, w0, w1) { + const left = []; + const right = []; + const count = points.length; + for (let i = 0; i < count; i++) { + const [px, py] = points[i]; + const [ax, ay] = points[Math.max(0, i - 1)]; + const [bx, by] = points[Math.min(count - 1, i + 1)]; + const d = Math.hypot(bx - ax, by - ay) || 1; + const nx = -(by - ay) / d; + const ny = (bx - ax) / d; + const half = (w0 + (w1 - w0) * (i / Math.max(1, count - 1))) / 2; + left.push([px + half * nx, py + half * ny]); + right.push([px - half * nx, py - half * ny]); + } + return left.concat(right.reverse()); +} + Object.assign(ChartView.prototype, { _annotationPaint(style, fallback) { return safeCssPaint(this.root, style && style.color, fallback); @@ -54,8 +184,7 @@ Object.assign(ChartView.prototype, { _drawArrowLine(ctx, x0, y0, x1, y1, style) { if (![x0, y0, x1, y1].every(Number.isFinite)) return; - const angle = Math.atan2(y1 - y0, x1 - x0); - const head = Math.max(7, this._styleNumber(style, "head_size", 8)); + const geom = fcArrowGeometry(x0, y0, x1, y1, style); ctx.save(); ctx.globalAlpha = this._styleNumber(style, "opacity", 1); ctx.strokeStyle = this._annotationPaint(style, [0.4, 0.44, 0.52, 1]); @@ -63,23 +192,69 @@ Object.assign(ChartView.prototype, { ctx.lineWidth = Math.max(0.5, this._styleNumber(style, "width", 1.5)); ctx.setLineDash(Array.isArray(style.dash) ? style.dash : (typeof style.dash === "string" ? style.dash.split(",").map(Number) : [])); + const w0 = Number(style.shaft_width_start); + const w1 = Number(style.shaft_width_end); + const headStyle = style.head_style || "triangle"; + const head = Math.max(4, this._styleNumber(style, "head_size", 8)); + if (Number.isFinite(w0) || Number.isFinite(w1)) { + let points = fcArrowShaftPoints(geom); + if (headStyle === "triangle") { + points = fcTrimPolylineEnd(points, head * Math.cos(Math.PI / 6)); + } + const polygon = fcTaperPolygon( + points, + Number.isFinite(w0) ? w0 : 1, + Number.isFinite(w1) ? w1 : 1 + ); + ctx.beginPath(); + ctx.moveTo(polygon[0][0], polygon[0][1]); + for (let i = 1; i < polygon.length; i++) ctx.lineTo(polygon[i][0], polygon[i][1]); + ctx.closePath(); + ctx.fill(); + } else { + ctx.beginPath(); + ctx.moveTo(geom.p0[0], geom.p0[1]); + if (geom.control) ctx.quadraticCurveTo(geom.control[0], geom.control[1], geom.p1[0], geom.p1[1]); + else ctx.lineTo(geom.p1[0], geom.p1[1]); + ctx.stroke(); + } + this._drawArrowEnd(ctx, geom.p1, geom.dir1, headStyle, head); + this._drawArrowEnd(ctx, geom.p0, geom.dir0, style.tail_style || "none", head); + ctx.restore(); + }, + + // One arrow endpoint decoration. dir is the unit tangent INTO the point; + // styles mirror matplotlib arrowstyles: "triangle" (filled, "-|>"/fancy), + // "v" (open stroke, "->"), "bar" ("|-|" caps), "none". + _drawArrowEnd(ctx, point, dir, endStyle, head) { + if (endStyle === "none") return; + const [px, py] = point; + const angle = Math.atan2(dir[1], dir[0]); ctx.beginPath(); - ctx.moveTo(x0, y0); - ctx.lineTo(x1, y1); - ctx.stroke(); - ctx.beginPath(); - ctx.moveTo(x1, y1); - ctx.lineTo( - x1 - head * Math.cos(angle - Math.PI / 6), - y1 - head * Math.sin(angle - Math.PI / 6) - ); - ctx.lineTo( - x1 - head * Math.cos(angle + Math.PI / 6), - y1 - head * Math.sin(angle + Math.PI / 6) - ); + if (endStyle === "bar") { + ctx.moveTo(px - (head / 2) * Math.sin(angle), py + (head / 2) * Math.cos(angle)); + ctx.lineTo(px + (head / 2) * Math.sin(angle), py - (head / 2) * Math.cos(angle)); + ctx.stroke(); + return; + } + const wing = (side) => [ + px - head * Math.cos(angle - side * Math.PI / 6), + py - head * Math.sin(angle - side * Math.PI / 6), + ]; + const [ax, ay] = wing(1); + const [bx, by] = wing(-1); + if (endStyle === "v") { + ctx.moveTo(ax, ay); + ctx.lineTo(px, py); + ctx.lineTo(bx, by); + ctx.stroke(); + return; + } + ctx.moveTo(px, py); + ctx.lineTo(ax, ay); + ctx.lineTo(bx, by); ctx.closePath(); ctx.fill(); - ctx.restore(); }, _drawAnnotationShapes(ctx) { @@ -226,15 +401,30 @@ Object.assign(ChartView.prototype, { const dx = Number.isFinite(Number(ann.dx)) ? Number(ann.dx) : 0; const dy = Number.isFinite(Number(ann.dy)) ? Number(ann.dy) : 0; const anchor = ann.anchor === "middle" ? "-50%" : ann.anchor === "end" ? "-100%" : "0"; + // matplotlib's va: the anchor y is the text center/bottom, not its top. + const va = style.vertical_align; + const vAnchor = + va === "center" || va === "middle" ? "-50%" : va === "bottom" ? "-100%" : "0"; // Structural inline only (position telegraphs the anchor); font + default // color live in the defeatable :where() stylesheet so utility classes win. + // width:max-content: shrink-to-fit for an absolutely positioned label is + // capped by the distance to the containing block's edge, so an end/middle + // anchored label near the right edge would wrap word-per-word BEFORE the + // translate(-100%) shift moves it back inside. d.style.cssText = `position:absolute;left:${px + dx}px;top:${py + dy}px;` + - `transform:translate(${anchor},0);pointer-events:none;` + - `white-space:pre-line;text-align:center;`; + `transform:translate(${anchor},${vAnchor});pointer-events:none;` + + `white-space:pre-line;text-align:center;width:max-content;`; this._applySlot(d, "annotation_label"); this._applyClass(d, ann.class_name); - this._applyStyle(d, style); + // Shape-geometry/paint keys style the canvas shape, not the label DOM — + // e.g. an arrow's shaft `width` must not become CSS width on the label. + const labelStyle = {}; + for (const [key, value] of Object.entries(style)) { + if (FC_ANNOTATION_SHAPE_STYLE_KEYS.has(key)) continue; + labelStyle[key] = value; + } + this._applyStyle(d, labelStyle); // Only pin color inline when the annotation asked for one — otherwise the // stylesheet's --chart-annotation-text default stays overridable by CSS. if (style && (style.label_color || style.color)) { diff --git a/js/src/53_interaction.js b/js/src/53_interaction.js index 989bbe78..b0b566a3 100644 --- a/js/src/53_interaction.js +++ b/js/src/53_interaction.js @@ -308,9 +308,25 @@ Object.assign(ChartView.prototype, { this._setView(this.view0, { animate: true }); }); root.appendChild(bar); + this._fitModebar(); this._setDragMode(this.dragMode); }, + // The modebar is unusable chrome once the plot box can't contain it — in a + // dense subplot grid the overflowing buttons alone grew scrollbars on every + // panel. Hidden is a fit state, not removal: a fluid resize re-checks and + // can bring the bar back. Wheel/drag zoom and pan keep working without it. + _fitModebar() { + const bar = this._modebar; + if (!bar) return; + bar.style.top = `${this.plot.y + 4}px`; + bar.style.left = `${this.plot.x + 4}px`; + bar.style.display = "flex"; // measurable before the verdict + const fits = + bar.offsetWidth + 8 <= this.plot.w && bar.offsetHeight + 8 <= this.plot.h; + if (!fits) bar.style.display = "none"; + }, + _setDragMode(mode) { this.dragMode = mode; // Cursor telegraphs the gesture (grab for pan, crosshair for box-zoom) but diff --git a/python/xy/_arrowgeom.py b/python/xy/_arrowgeom.py new file mode 100644 index 00000000..499a1900 --- /dev/null +++ b/python/xy/_arrowgeom.py @@ -0,0 +1,181 @@ +"""Arrow-annotation path geometry shared by the SVG and raster exporters. + +Mirrors ``fcArrowGeometry`` in ``js/src/51_annotations.js`` — keep the two in +sync. Style keys: ``curve`` (matplotlib arc3 rad — quadratic bulge as a +fraction of chord length), ``angle_a``/``angle_b`` (matplotlib angle3/angle +departure/arrival angles, degrees, y-up screen space — the control point is +the ray intersection), ``gap_start``/``gap_end`` (px trims along the path +tangents for label/point clearance), ``head_style``/``tail_style`` +(``triangle``/``v``/``bar``/``none``) and ``head_size``. +""" + +from __future__ import annotations + +import math +from typing import Any, Optional + + +def _number(value: Any) -> Optional[float]: + try: + number = float(value) + except (TypeError, ValueError): + return None + return number if math.isfinite(number) else None + + +def arrow_geometry( + x0: float, y0: float, x1: float, y1: float, style: dict[str, Any] +) -> dict[str, Any]: + angle_a = _number(style.get("angle_a")) + angle_b = _number(style.get("angle_b")) + curve = _number(style.get("curve")) + control: Optional[tuple[float, float]] = None + if angle_a is not None and angle_b is not None: + a = -angle_a * math.pi / 180 # spec angles are y-up; pixels are y-down + b = -angle_b * math.pi / 180 + denom = math.cos(a) * math.sin(b) - math.sin(a) * math.cos(b) + if abs(denom) > 1e-6: + t = ((x1 - x0) * math.sin(b) - (y1 - y0) * math.cos(b)) / denom + control = (x0 + t * math.cos(a), y0 + t * math.sin(a)) + elif curve: + dx, dy = x1 - x0, y1 - y0 + # arc3 rad > 0 bulges to the chord's left in matplotlib's y-up plane. + control = ((x0 + x1) / 2 + curve * dy, (y0 + y1) / 2 - curve * dx) + + def toward(px: float, py: float, qx: float, qy: float) -> tuple[float, float]: + d = math.hypot(qx - px, qy - py) or 1.0 + return ((qx - px) / d, (qy - py) / d) + + t0 = toward(x0, y0, *control) if control else toward(x0, y0, x1, y1) + t1 = toward(x1, y1, *control) if control else toward(x1, y1, x0, y0) + gap_start = max(0.0, _number(style.get("gap_start")) or 0.0) + gap_end = max(0.0, _number(style.get("gap_end")) or 0.0) + trim = gap_start + gap_end < math.hypot(x1 - x0, y1 - y0) * 0.9 + p0 = (x0 + gap_start * t0[0], y0 + gap_start * t0[1]) if trim else (x0, y0) + p1 = (x1 + gap_end * t1[0], y1 + gap_end * t1[1]) if trim else (x1, y1) + # Tangent INTO each endpoint (head/tail orientation). + dir1 = toward(*control, *p1) if control else toward(*p0, *p1) + dir0 = toward(*control, *p0) if control else toward(*p1, *p0) + return {"p0": p0, "p1": p1, "control": control, "dir0": dir0, "dir1": dir1} + + +def shaft_points(geom: dict[str, Any], samples: int = 24) -> list[tuple[float, float]]: + """The shaft as a polyline (quadratic Bézier sampled when curved).""" + (x0, y0), (x1, y1) = geom["p0"], geom["p1"] + control = geom["control"] + if control is None: + return [(x0, y0), (x1, y1)] + cx, cy = control + points = [] + for index in range(samples + 1): + t = index / samples + u = 1.0 - t + points.append( + ( + u * u * x0 + 2 * u * t * cx + t * t * x1, + u * u * y0 + 2 * u * t * cy + t * t * y1, + ) + ) + return points + + +def end_decoration( + point: tuple[float, float], + direction: tuple[float, float], + end_style: str, + head: float, +) -> Optional[dict[str, Any]]: + """One endpoint decoration: {'kind': 'fill'|'stroke', 'points': [...]}. + + ``direction`` is the unit tangent INTO the point; styles mirror matplotlib + arrowstyles: triangle (filled, "-|>"/fancy), v (open stroke, "->"), + bar ("|-|" caps), none. + """ + if end_style == "none": + return None + px, py = point + angle = math.atan2(direction[1], direction[0]) + if end_style == "bar": + return { + "kind": "stroke", + "points": [ + (px - (head / 2) * math.sin(angle), py + (head / 2) * math.cos(angle)), + (px + (head / 2) * math.sin(angle), py - (head / 2) * math.cos(angle)), + ], + } + wings = [ + ( + px - head * math.cos(angle - side * math.pi / 6), + py - head * math.sin(angle - side * math.pi / 6), + ) + for side in (1, -1) + ] + if end_style == "v": + return {"kind": "stroke", "points": [wings[0], (px, py), wings[1]]} + return {"kind": "fill", "points": [(px, py), wings[0], wings[1]]} + + +def taper_polygon( + points: list[tuple[float, float]], width_start: float, width_end: float +) -> list[tuple[float, float]]: + """The shaft polyline as a filled polygon whose width interpolates from + ``width_start`` to ``width_end`` (matplotlib's fancy/simple/wedge + arrowstyles are filled tapered shafts, not stroked lines).""" + count = len(points) + left: list[tuple[float, float]] = [] + right: list[tuple[float, float]] = [] + for index, (px, py) in enumerate(points): + ax, ay = points[max(0, index - 1)] + bx, by = points[min(count - 1, index + 1)] + d = math.hypot(bx - ax, by - ay) or 1.0 + nx, ny = -(by - ay) / d, (bx - ax) / d + half = (width_start + (width_end - width_start) * (index / max(1, count - 1))) / 2 + left.append((px + half * nx, py + half * ny)) + right.append((px - half * nx, py - half * ny)) + return left + right[::-1] + + +def trim_polyline_end(points: list[tuple[float, float]], trim: float) -> list[tuple[float, float]]: + """The polyline with ``trim`` px of arclength removed from its end.""" + if trim <= 0 or len(points) < 2: + return points + remaining = trim + out = list(points) + while len(out) >= 2: + (ax, ay), (bx, by) = out[-2], out[-1] + seg = math.hypot(bx - ax, by - ay) + if seg > remaining: + t = 1.0 - remaining / seg + out[-1] = (ax + t * (bx - ax), ay + t * (by - ay)) + return out + remaining -= seg + out.pop() + return out[:2] if len(out) >= 2 else points[:1] * 2 + + +def arrow_shapes( + x0: float, y0: float, x1: float, y1: float, style: dict[str, Any] +) -> dict[str, Any]: + """Shaft polyline (or taper polygon) + endpoint decorations for one + arrow/callout spec.""" + geom = arrow_geometry(x0, y0, x1, y1, style) + head = max(4.0, _number(style.get("head_size")) or 8.0) + head_style = str(style.get("head_style") or "triangle") + shaft = shaft_points(geom) + width_start = _number(style.get("shaft_width_start")) + width_end = _number(style.get("shaft_width_end")) + taper = None + if width_start is not None or width_end is not None: + if head_style == "triangle": + # matplotlib construction: the shaft ends at the head BASE and the + # head spans base→tip — a full-length shaft would swallow the head. + shaft = trim_polyline_end(shaft, head * math.cos(math.pi / 6)) + taper = taper_polygon(shaft, width_start or 1.0, width_end or 1.0) + return { + "shaft": None if taper else shaft, + "taper": taper, + "head": end_decoration(geom["p1"], geom["dir1"], head_style, head), + "tail": end_decoration( + geom["p0"], geom["dir0"], str(style.get("tail_style") or "none"), head + ), + } diff --git a/python/xy/_raster.py b/python/xy/_raster.py index f9be9b98..b31dd8ea 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -18,6 +18,7 @@ import numpy as np from . import _png, _scene +from ._arrowgeom import arrow_shapes as _arrow_shapes from ._svg import ( _AXIS, _AXIS_GRID_DASHES, @@ -809,50 +810,57 @@ def _emit_annotations(cmd, annotations, sx, sy, plot, width, height): _rect_pts(x0, y0, x1, y1), _rgba(style.get("color"), "#64748b", float(style.get("opacity", 0.14))), ) - elif ann.get("kind") == "arrow": - x0, y0 = float(sx(float(ann["x0"]))), float(sy(float(ann["y0"]))) - x1, y1 = float(sx(float(ann["x1"]))), float(sy(float(ann["y1"]))) + elif ann.get("kind") in ("arrow", "callout"): + if ann.get("kind") == "arrow": + x0, y0 = float(sx(float(ann["x0"]))), float(sy(float(ann["y0"]))) + x1, y1 = float(sx(float(ann["x1"]))), float(sy(float(ann["y1"]))) + else: # pointer from the offset label back to the data point + x1, y1 = float(sx(float(ann["x"]))), float(sy(float(ann["y"]))) + x0, y0 = x1 + float(ann.get("dx", 0.0)), y1 + float(ann.get("dy", 0.0)) if all(np.isfinite(v) for v in (x0, y0, x1, y1)): - angle = np.arctan2(y1 - y0, x1 - x0) - head = max(7.0, float(style.get("head_size", 8.0))) - cmd.stroke( - [(x0, y0), (x1, y1)], - max(0.5, float(style.get("width", 1.5))), - color, - dash=( - [float(value) for value in style["dash"].split(",")] - if isinstance(style.get("dash"), str) - else style.get("dash") - ), - ) - cmd.fill( - [ - (x1, y1), - ( - x1 - head * float(np.cos(angle - np.pi / 6)), - y1 - head * float(np.sin(angle - np.pi / 6)), - ), - ( - x1 - head * float(np.cos(angle + np.pi / 6)), - y1 - head * float(np.sin(angle + np.pi / 6)), + shapes = _arrow_shapes(x0, y0, x1, y1, style) + stroke_width = max(0.5, float(style.get("width", 1.5))) + if shapes["taper"] is not None: + cmd.fill(shapes["taper"], color) + else: + cmd.stroke( + shapes["shaft"], + stroke_width, + color, + dash=( + [float(value) for value in style["dash"].split(",")] + if isinstance(style.get("dash"), str) + else style.get("dash") ), - ], - color, - ) - if ann.get("kind") == "text" and ann.get("text"): + ) + for decoration in (shapes["head"], shapes["tail"]): + if decoration is None: + continue + if decoration["kind"] == "fill": + cmd.fill(decoration["points"], color) + else: + cmd.stroke(decoration["points"], stroke_width, color) + if ann.get("kind") in ("text", "callout") and ann.get("text"): x, y = _annotation_point(ann, style, sx, sy, plot, width, height) anchor = {"start": 0, "middle": 1, "end": 2}.get(ann.get("anchor"), 0) font_size = float(style.get("font_size", 11)) lines = str(ann["text"]).splitlines() or [""] line_height = font_size * 1.2 first_y = y - (len(lines) - 1) * line_height / 2 + vertical_align = style.get("vertical_align") + if vertical_align in ("center", "middle"): + first_y += font_size * 0.35 + elif vertical_align == "top": + first_y += font_size * 0.8 + # A callout's `color` paints its arrow; the label prefers its own. + label_color = style.get("label_color") or style.get("color") for index, line in enumerate(lines): cmd.text( x + float(ann.get("dx", 0.0)), first_y + index * line_height + float(ann.get("dy", 0.0)), anchor, font_size, - _rgba(style.get("color"), _TEXT, float(style.get("opacity", 1.0))), + _rgba(label_color, _TEXT, float(style.get("opacity", 1.0))), line, ) diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 4565d56c..012e6b52 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -27,6 +27,7 @@ import numpy as np from . import _png +from ._arrowgeom import arrow_shapes as _arrow_shapes from .config import DEFAULT_PALETTE @@ -1309,34 +1310,43 @@ def _annotation_svg(annotations, sx, sy, plot, width, height): f'' ) - elif kind == "arrow": - x0, y0 = float(sx(float(ann["x0"]))), float(sy(float(ann["y0"]))) - x1, y1 = float(sx(float(ann["x1"]))), float(sy(float(ann["y1"]))) + elif kind in ("arrow", "callout"): + if kind == "arrow": + x0, y0 = float(sx(float(ann["x0"]))), float(sy(float(ann["y0"]))) + x1, y1 = float(sx(float(ann["x1"]))), float(sy(float(ann["y1"]))) + else: # pointer from the offset label back to the data point + x1, y1 = float(sx(float(ann["x"]))), float(sy(float(ann["y"]))) + x0, y0 = x1 + float(ann.get("dx", 0.0)), y1 + float(ann.get("dy", 0.0)) if all(np.isfinite(v) for v in (x0, y0, x1, y1)): - angle = np.arctan2(y1 - y0, x1 - x0) - head = max(7.0, float(style.get("head_size", 8.0))) - wings = ( - ( - x1 - head * float(np.cos(angle - np.pi / 6)), - y1 - head * float(np.sin(angle - np.pi / 6)), - ), - ( - x1 - head * float(np.cos(angle + np.pi / 6)), - y1 - head * float(np.sin(angle + np.pi / 6)), - ), - ) - marks.append( - f'' - ) - marks.append( - f'' - ) - if kind == "text" and ann.get("text"): + shapes = _arrow_shapes(x0, y0, x1, y1, style) + stroke_width = _num(max(0.5, float(style.get("width", 1.5)))) + if shapes["taper"] is not None: + taper = " ".join(f"{_num(px)},{_num(py)}" for px, py in shapes["taper"]) + marks.append( + f'' + ) + else: + shaft = " ".join(f"{_num(px)},{_num(py)}" for px, py in shapes["shaft"]) + marks.append( + f'' + ) + for decoration in (shapes["head"], shapes["tail"]): + if decoration is None: + continue + points = " ".join(f"{_num(px)},{_num(py)}" for px, py in decoration["points"]) + if decoration["kind"] == "fill": + marks.append( + f'' + ) + else: + marks.append( + f'' + ) + if kind in ("text", "callout") and ann.get("text"): x, y = float(ann.get("x", 0.0)), float(ann.get("y", 0.0)) space = style.get("coordinate_space") if space == "axes_fraction": @@ -1357,16 +1367,23 @@ def _annotation_svg(annotations, sx, sy, plot, width, height): line_height = font_size * 1.2 x_text = tx + float(ann.get("dx", 0)) y_text = ty + float(ann.get("dy", 0)) - (len(lines) - 1) * line_height / 2 + vertical_align = style.get("vertical_align") + if vertical_align in ("center", "middle"): + y_text += font_size * 0.35 + elif vertical_align == "top": + y_text += font_size * 0.8 tspans = "".join( f'' f"{escape(line)}" for index, line in enumerate(lines) ) text_opacity = float(style.get("opacity", 1.0)) + # A callout's `color` paints its arrow; the label prefers its own. + label_color = escape(_css(style.get("label_color"), "")) or color labels.append( f'{tspans}' + + f'fill="{label_color}">{tspans}' ) return marks, labels diff --git a/python/xy/pyplot/__init__.py b/python/xy/pyplot/__init__.py index 5730c632..6983c03d 100644 --- a/python/xy/pyplot/__init__.py +++ b/python/xy/pyplot/__init__.py @@ -20,6 +20,7 @@ import numpy as np +from . import dates from ._artists import Legend from ._axes import Axes from ._colors import LinearSegmentedColormap, ListedColormap @@ -97,6 +98,7 @@ "contourf", "csd", "cycler", + "dates", "delaxes", "ecdf", "errorbar", @@ -220,9 +222,12 @@ def subplots( height_ratios = kwargs.pop("height_ratios", None) gridspec_kw = kwargs.pop("gridspec_kw", None) or {} subplot_kw = kwargs.pop("subplot_kw", None) or {} - fig = figure(figsize=figsize, dpi=dpi) + toolbar = kwargs.pop("toolbar", None) + # Remaining kwargs are matplotlib's **fig_kw, forwarded to figure(). + fig = figure(figsize=figsize, dpi=dpi, toolbar=toolbar, **kwargs) if fig._axes and any(ax._entries for ax in fig._axes): - fig = figure(None, figsize=figsize, dpi=dpi) # fresh figure, mpl semantics + # fresh figure, mpl semantics + fig = figure(None, figsize=figsize, dpi=dpi, toolbar=toolbar, **kwargs) axes = fig.subplots( nrows, ncols, diff --git a/python/xy/pyplot/_artists.py b/python/xy/pyplot/_artists.py index ba518439..8e1f6787 100644 --- a/python/xy/pyplot/_artists.py +++ b/python/xy/pyplot/_artists.py @@ -19,6 +19,18 @@ from ._transforms import Bbox, IdentityTransform +def unit_converted_values(values: Any) -> Any: + """Datetime-like values in the engine's converted unit — f64 ms since + epoch (columns.py); every other dtype is already its own converted form.""" + from xy.columns import _datetime_to_float_ms, _is_datetime_object_array + + array = np.asanyarray(values) + if np.issubdtype(array.dtype, np.datetime64) or _is_datetime_object_array(array.reshape(-1)): + converted, _ = _datetime_to_float_ms(array, 0) + return converted + return values + + def _set_entry_clim(artist: "Artist", vmin: Any = None, vmax: Any = None) -> None: """Set a mappable entry's color domain, autoscaling any side left as None.""" if vmax is None and isinstance(vmin, (tuple, list)): @@ -328,12 +340,13 @@ def set_ydata(self, y: Any) -> None: self._touch() def get_xdata(self, orig: bool = True) -> Any: - del orig # compat-noop: one canonical data array, no unit-converted copy - return self._entry["x"] + # orig=False asks for matplotlib's unit-converted floats. + data = self._entry["x"] + return data if orig else unit_converted_values(data) def get_ydata(self, orig: bool = True) -> Any: - del orig # compat-noop: one canonical data array, no unit-converted copy - return self._entry["y"] + data = self._entry["y"] + return data if orig else unit_converted_values(data) def set_linewidth(self, w: float) -> None: self._entry["kwargs"]["width"] = float(w) diff --git a/python/xy/pyplot/_axes.py b/python/xy/pyplot/_axes.py index e887a3ec..fdad13a5 100644 --- a/python/xy/pyplot/_axes.py +++ b/python/xy/pyplot/_axes.py @@ -12,7 +12,7 @@ import copy from contextlib import suppress -from datetime import timedelta +from datetime import datetime, timedelta from itertools import pairwise from typing import Any, Optional @@ -29,6 +29,7 @@ PathCollection, PolyCollection, Text, + unit_converted_values, ) from ._colors import PROP_CYCLE, resolve_cmap, resolve_color from ._fmt import parse_fmt @@ -234,7 +235,17 @@ def set(self, **kwargs: Any) -> None: if "major_formatter" in kwargs: self.set_major_formatter(kwargs.pop("major_formatter")) + @staticmethod + def _is_units_registry_ticker(ticker: Any) -> bool: + # pandas' date locators/formatters (TimeSeries_DateLocator & co.) speak + # matplotlib's unit-registry coordinates (period/date ordinals), which + # never exist here: the engine's time axis is native ms-since-epoch and + # ticks itself. Treat them as compat-noops so the native ticks render. + return (type(ticker).__module__ or "").startswith("pandas.plotting") + def set_major_locator(self, locator: Any) -> None: + if self._is_units_registry_ticker(locator): + return if not hasattr(locator, "tick_values"): raise TypeError("set_major_locator() requires a Locator with tick_values()") host, key = self._ticker_slot() @@ -251,6 +262,8 @@ def get_major_locator(self) -> Any: return host._tickers.get((key, "major_locator")) or AutoLocator() def set_major_formatter(self, formatter: Any) -> None: + if self._is_units_registry_ticker(formatter): + return host, key = self._ticker_slot() host._tickers[(key, "major_formatter")] = as_formatter(formatter, "set_major_formatter()") self.axes._invalidate() @@ -280,6 +293,12 @@ def tick_bottom(self) -> None: def tick_left(self) -> None: pass # exact no-op: the engine only draws left y ticks + def get_majorticklabels(self) -> list["_TickLabel"]: + return self.axes._tick_label_handles(self.axis) + + def get_minorticklabels(self) -> list["_TickLabel"]: + return [] # minor ticks are outside the native axis contract + def get_minor_formatter(self) -> Any: from ._ticker import NullFormatter @@ -473,6 +492,14 @@ def _cached_theme(grid: bool, tokens: dict[str, Any], style: dict[str, Any]) -> return made +def _cached_modebar(show: bool) -> Any: + key = ("modebar", show) + made = _component_cache.get(key) + if made is None: + made = _component_cache[key] = fc.modebar(show=show) + return made + + def _cached_axis(which: str, props: dict) -> Any: if props: factory = fc.x_axis if which == "x" else fc.y_axis @@ -2056,7 +2083,7 @@ def text( def annotate(self, text: str, xy: tuple, xytext: Optional[tuple] = None, **kwargs: Any) -> Text: arrowprops = kwargs.pop("arrowprops", None) - fontsize = kwargs.pop("fontsize", None) + fontsize = kwargs.pop("fontsize", kwargs.pop("size", None)) color = kwargs.pop("color", None) xycoords = kwargs.pop("xycoords", "data") textcoords = kwargs.pop("textcoords", None) @@ -2083,11 +2110,14 @@ def annotate(self, text: str, xy: tuple, xytext: Optional[tuple] = None, **kwarg if textcoords in {"offset points", "offset pixels"}: scale = self._point_scale() if textcoords == "offset points" else 1.0 akw["dx"], akw["dy"] = float(xytext[0]) * scale, -float(xytext[1]) * scale - elif all(_is_number(v) for v in (*xytext, *xy)): - # matplotlib places the text AT xytext (data coordinates). - text_xy = (float(xytext[0]), float(xytext[1])) else: - akw["dx"], akw["dy"] = 8.0, -8.0 + converted = self._data_coordinates(xytext) + if converted is not None: + # matplotlib places the text AT xytext (data coordinates); + # date strings convert like matplotlib's unit registry. + text_xy = converted + else: + akw["dx"], akw["dy"] = 8.0, -8.0 style: dict[str, Any] = {} if xycoords is self.transAxes or xycoords == "axes fraction": style["coordinate_space"] = "axes_fraction" @@ -2115,7 +2145,9 @@ def annotate(self, text: str, xy: tuple, xytext: Optional[tuple] = None, **kwarg "annotate(arrowprops=) outside data coordinates", "data-coordinate annotations", ) - if not all(_is_number(v) for v in (*text_xy, *xy)): + start = self._data_coordinates(text_xy) + end = self._data_coordinates(xy) + if start is None or end is None: raise not_implemented( "annotate(arrowprops=) with non-numeric coordinates", "numeric data coordinates", @@ -2123,18 +2155,18 @@ def annotate(self, text: str, xy: tuple, xytext: Optional[tuple] = None, **kwarg # Straight arrow from the text toward the point; arrowstyle and # connectionstyle curves are approximated by this straight shaft. shrink = float(arrowprops.get("shrink", 0.0)) - sx0, sy0 = float(text_xy[0]), float(text_xy[1]) - ex0, ey0 = float(xy[0]), float(xy[1]) + (sx0, sy0), (ex0, ey0) = start, end if shrink: dx_a, dy_a = ex0 - sx0, ey0 - sy0 sx0, sy0 = sx0 + shrink * dx_a, sy0 + shrink * dy_a ex0, ey0 = ex0 - shrink * dx_a, ey0 - shrink * dy_a - fancy = "arrowstyle" not in arrowprops - arrow_color = resolve_color( - arrowprops.get("color") - or arrowprops.get("facecolor") - or arrowprops.get("edgecolor") - or "black" + arrow_color, arrow_width, arrow_style = _arrow_visuals( + arrowprops, + mutation_scale=_font_size_points( + fontsize if fontsize is not None else rcParams["font.size"], + rcParams["font.size"], + ) + * self._point_scale(), ) self._add( "@arrow", @@ -2142,17 +2174,8 @@ def annotate(self, text: str, xy: tuple, xytext: Optional[tuple] = None, **kwarg "args": (sx0, sy0, ex0, ey0), "kwargs": { "color": arrow_color, - "width": float( - arrowprops.get( - "width", - arrowprops.get( - "lw", arrowprops.get("linewidth", 3.0 if fancy else 1.5) - ), - ) - ), - "style": { - "head_size": float(arrowprops.get("headwidth", 12.0 if fancy else 8.0)) - }, + "width": arrow_width, + "style": arrow_style, }, }, ) @@ -2289,13 +2312,45 @@ def set_position(self, position: Any) -> None: self._figure_rect = _parse_bounds(position, "set_position()") self._invalidate() - def _entry_extent(self, axis: str) -> tuple[float, float]: + def _axis_holds_datetimes(self, axis: str) -> bool: + from xy.components import _is_datetime_like + + key = "x" if axis == "x" else "y" + return any(key in entry and _is_datetime_like(entry[key]) for entry in self._entries) + + def _data_coordinate(self, value: Any, axis: str) -> Optional[float]: + """A data-space coordinate as the engine's float — numbers directly, + datetime-likes (and, on a datetime axis, matplotlib's registry-parsed + date strings) as ms since epoch. None means not coordinate-like.""" + if _is_number(value): + return float(value) + if isinstance(value, str): + value = _parse_date_text(value) if self._axis_holds_datetimes(axis) else None + if value is None: + return None + try: + converted = np.asarray( + unit_converted_values(np.asarray([value])), dtype=np.float64 + ).reshape(-1) + except (TypeError, ValueError): + return None + return float(converted[0]) if converted.size and np.isfinite(converted[0]) else None + + def _data_coordinates(self, xy: tuple) -> Optional[tuple[float, float]]: + x = self._data_coordinate(xy[0], "x") + y = self._data_coordinate(xy[1], "y") + return None if x is None or y is None else (x, y) + + def _entry_values(self, axis: str) -> np.ndarray: + """Every finite data coordinate the entries contribute to *axis* autoscale.""" values: list[np.ndarray] = [] for entry in self._entries: key = "x" if axis == "x" else "y" if key in entry: try: - array = np.asarray(entry[key], dtype=np.float64).reshape(-1) + array = np.asarray(unit_converted_values(entry[key]), dtype=np.float64).reshape( + -1 + ) except (TypeError, ValueError): continue values.append(array[np.isfinite(array)]) @@ -2319,7 +2374,10 @@ def _entry_extent(self, axis: str) -> tuple[float, float]: elif entry.get("kind") == "heatmap" and entry.get("extent") is not None: bounds = entry["extent"] values.append(np.asarray(bounds[:2] if axis == "x" else bounds[2:], dtype=float)) - finite = np.concatenate(values) if values else np.array([], dtype=np.float64) + return np.concatenate(values) if values else np.array([], dtype=np.float64) + + def _entry_extent(self, axis: str) -> tuple[float, float]: + finite = self._entry_values(axis) if len(finite) == 0: return (0.0, 1.0) lo, hi = float(np.min(finite)), float(np.max(finite)) @@ -3557,10 +3615,28 @@ def _ticker_view(self, key: str, props: dict[str, Any]) -> tuple[float, float]: lo, hi = sorted(map(float, _scale_values(np.asarray([lo, hi]), spec, inverse=True))) return lo, hi - def _apply_tickers(self, key: str, props: dict[str, Any]) -> None: + def _apply_tickers( + self, key: str, props: dict[str, Any], nbins_hint: Optional[int] = None + ) -> None: """Resolve a user locator/formatter into concrete tick props (in place).""" + from ._ticker import NullFormatter + locator = self._tickers.get((key, "major_locator")) formatter = self._tickers.get((key, "major_formatter")) + minor_locator = self._tickers.get((key, "minor_locator")) + minor_formatter = self._tickers.get((key, "minor_formatter")) + # The engine draws a single tick set. When a script blanks the major + # labels and puts the text on located minors (matplotlib's centered + # date-label idiom: major NullFormatter + labeled minor locator), the + # minor pair is the one carrying information — promote it. + if ( + isinstance(formatter, NullFormatter) + and minor_locator is not None + and hasattr(minor_locator, "tick_values") + and minor_formatter is not None + and not isinstance(minor_formatter, NullFormatter) + ): + locator, formatter = minor_locator, minor_formatter is_log = ( props.get("type_") == "log" or (self._scale_specs.get(key) or {}).get("name") == "log" ) @@ -3570,6 +3646,7 @@ def _apply_tickers(self, key: str, props: dict[str, Any]) -> None: lo, hi = self._ticker_view(key, props) auto_log = False if locator is not None: + locator._nbins_hint = nbins_hint ticks = np.asarray(locator.tick_values(lo, hi), dtype=float).reshape(-1) pad = (hi - lo) * 1e-9 ticks = ticks[(ticks >= lo - pad) & (ticks <= hi + pad)] @@ -3581,7 +3658,11 @@ def _apply_tickers(self, key: str, props: dict[str, Any]) -> None: from ._ticker import LogLocator auto = LogLocator() if is_log else AutoLocator() + auto._nbins_hint = nbins_hint ticks = np.asarray(auto.tick_values(lo, hi), dtype=float).reshape(-1) + if not is_log: + pad = (hi - lo) * 1e-9 + ticks = ticks[(ticks >= lo - pad) & (ticks <= hi + pad)] auto_log = is_log props["tick_values"] = list(map(float, _scale_values(ticks, spec))) if formatter is not None: @@ -3689,7 +3770,70 @@ def _chart_children(self) -> list[Any]: ) if opacity is not None and float(opacity) < 1.0: text_kw["style"] = {**(text_kw.get("style") or {}), "opacity": float(opacity)} - children.append(fc.text(*e["args"], **text_kw)) + if "font_size" not in (text_kw.get("style") or {}): + # matplotlib text defaults to font.size (10 pt → 13.9 px at + # dpi 100); without this the client's 11 px slot default wins. + text_kw["style"] = { + **(text_kw.get("style") or {}), + "font_size": _font_size_points(rcParams["font.size"], rcParams["font.size"]) + * self._point_scale(), + } + if kw.get("bbox"): + # matplotlib's text bbox patch, as label box styles. + text_kw["style"] = { + **_bbox_label_style( + kw["bbox"], + font_size=float((text_kw.get("style") or {}).get("font_size", 11.0)), + ), + **(text_kw.get("style") or {}), + } + # matplotlib's pandas-registered converter parses date strings + # placed on a date axis; categorical axes keep their strings. + x, y = e["args"][0], e["args"][1] + if isinstance(x, str) and self._axis_holds_datetimes("x"): + x = _parse_date_text(x) or x + if isinstance(y, str) and self._axis_holds_datetimes("y"): + y = _parse_date_text(y) or y + arrowprops = kw.get("arrowprops") + value = str(e["args"][2]) if len(e["args"]) > 2 else "" + if arrowprops and value and text_kw.get("dx") is not None: + # Offset-placed annotate(arrowprops=): matplotlib pins the + # arrow from the text to the data point across zoom — the + # engine's callout annotation is exactly that object. + font_size = float((text_kw.get("style") or {}).get("font_size", 11.0)) + arrow_color, arrow_width, arrow_style = _arrow_visuals( + arrowprops, mutation_scale=font_size + ) + style: dict[str, Any] = {**(text_kw.get("style") or {}), **arrow_style} + # matplotlib starts the arrow at the text patch edge + # (shrinkA/B default 2 pt); approximate the patch with a + # radial clearance around the label anchor. + style.setdefault("gap_start", font_size * 0.5 + 2.0) + style.setdefault("gap_end", 3.0) + # The callout color prop paints the arrow; pin the label's + # own color so it doesn't inherit the arrow's. + style.setdefault( + "label_color", + text_kw.get("color") + or resolve_color(rcParams.get("text.color", "black")) + or "black", + ) + children.append( + fc.callout( + x, + y, + value, + dx=float(text_kw["dx"]), + dy=float(text_kw.get("dy", 0.0)), + color=arrow_color, + width=arrow_width, + anchor=text_kw.get("anchor", "start"), + class_name=text_kw.get("class_name"), + style=style, + ) + ) + else: + children.append(fc.text(x, y, *e["args"][2:], **text_kw)) return children def _best_legend_loc( @@ -3871,20 +4015,32 @@ def _build_chart(self, width: int, height: int) -> Any: self._axis["x"]["domain"] = self._auto_domain("x") if not adjusted_aspect and self._ymargin != 0.0 and "y" not in self._explicit_domains: self._axis["y"]["domain"] = self._auto_domain("y") + # A dataless matplotlib axis views exactly (0, 1) — margins never apply. + # Pin it so the engine's autorange padding cannot widen the empty view + # (padding turns the 0.5 midpoint tick into a bare 0/1 pair). + for axis in ("x", "y"): + if ( + not adjusted_aspect + and axis not in self._explicit_domains + and self._axis[axis].get("domain") is None + and len(self._entry_values(axis)) == 0 + ): + self._axis[axis]["domain"] = (0.0, 1.0) x_props = {k: v for k, v in self._axis["x"].items() if v is not None} y_props = {k: v for k, v in self._axis["y"].items() if v is not None} if aspect_domains is not None: x_props["domain"], y_props["domain"] = aspect_domains - self._apply_tickers("x", x_props) - self._apply_tickers("y", y_props) - self._apply_auto_tick_density(x_props, y_props, width, height) + auto_tick_counts = self._auto_tick_counts(x_props, width, height) + self._apply_tickers("x", x_props, auto_tick_counts["x"]) + self._apply_tickers("y", y_props, auto_tick_counts["y"]) + self._apply_auto_tick_density(x_props, y_props, auto_tick_counts) children.append(_cached_axis("x", x_props)) children.append(_cached_axis("y", y_props)) for index, secondary in enumerate(self._secondary_axes, 1): children.append(secondary._component(index)) if self._twin is not None: y2_props = {k: v for k, v in self._axis["y2"].items() if v is not None} - self._apply_tickers("y2", y2_props) + self._apply_tickers("y2", y2_props, auto_tick_counts["y"]) children.append(fc.y_axis(id="y2", side="right", **y2_props)) if self._legend: legend_options = dict(self._legend_options) @@ -3897,6 +4053,8 @@ def _build_chart(self, width: int, height: int) -> Any: # Core XY can auto-create a continuous-color "value" legend. # An unlabeled Matplotlib collection must not acquire one. children.append(fc.legend(show=False)) + if not self.figure._show_toolbar(): + children.append(_cached_modebar(False)) theme_tokens = self._theme_tokens if _MPL_THEME_TOKENS: if self._grid_axis != "both": @@ -3941,14 +4099,14 @@ def _build_chart(self, width: int, height: int) -> Any: core_figure.extra_legends = extras return self._chart - def _apply_auto_tick_density( + def _auto_tick_counts( self, x_props: dict[str, Any], - y_props: dict[str, Any], width: int, height: int, - ) -> None: - """Match Matplotlib AutoLocator's axes-size tick-space heuristic.""" + ) -> dict[str, int]: + """Matplotlib's ``Axis.get_tick_space()`` per axis: how many tick + intervals fit the estimated plot rect at the tick-label font size.""" compact = width < 520 if self._padding is None: left, right = (46.0, 8.0) if compact else (62.0, 14.0) @@ -3965,10 +4123,18 @@ def _apply_auto_tick_density( base = float(rcParams["font.size"]) x_font = _font_size_points(rcParams["xtick.labelsize"], base) y_font = _font_size_points(rcParams["ytick.labelsize"], base) - counts = { + return { "x": max(1, min(9, int(np.floor(plot_width * 72.0 / dpi / (x_font * 3.0))))), "y": max(1, min(9, int(np.floor(plot_height * 72.0 / dpi / (y_font * 2.0))))), } + + def _apply_auto_tick_density( + self, + x_props: dict[str, Any], + y_props: dict[str, Any], + counts: dict[str, int], + ) -> None: + """Match Matplotlib AutoLocator's axes-size tick-space heuristic.""" for axis, props in (("x", x_props), ("y", y_props)): if ( "tick_count" not in props @@ -4028,6 +4194,163 @@ def _is_number(v: Any) -> bool: return isinstance(v, (int, float, np.integer, np.floating)) +# matplotlib arrowstyle name → (tail, head) endpoint shapes, in the shim's +# text→point drawing direction (A = tail at the text, B = head at the point). +_ARROWSTYLE_ENDS = { + "-": ("none", "none"), + "->": ("none", "v"), + "<-": ("v", "none"), + "<->": ("v", "v"), + "-|>": ("none", "triangle"), + "<|-": ("triangle", "none"), + "<|-|>": ("triangle", "triangle"), + "|-|": ("bar", "bar"), + "]-[": ("bar", "bar"), + "]-": ("bar", "none"), + "-[": ("none", "bar"), + "simple": ("none", "triangle"), + "fancy": ("none", "triangle"), + "wedge": ("none", "triangle"), +} + + +def _parse_style_options(spec: str) -> dict[str, float]: + options: dict[str, float] = {} + for part in spec.split(",")[1:]: + key, _, value = part.partition("=") + try: + options[key.strip()] = float(value) + except ValueError: + continue + return options + + +def _connection_curve(connectionstyle: Any) -> dict[str, float]: + """matplotlib ``connectionstyle`` → quadratic-curve style keys (see + ``_arrowgeom.py``): arc3's rad becomes ``curve``; angle3/angle become the + ``angle_a``/``angle_b`` departure/arrival angles (corner rounding is + approximated by the quadratic).""" + if not isinstance(connectionstyle, str): + return {} + name = connectionstyle.split(",")[0].strip() + options = _parse_style_options(connectionstyle) + if name == "arc3": + rad = options.get("rad", 0.0) + return {"curve": rad} if rad else {} + if name in ("angle3", "angle"): + return {"angle_a": options.get("angleA", 90.0), "angle_b": options.get("angleB", 0.0)} + return {} + + +def _arrow_visuals( + arrowprops: dict[str, Any], mutation_scale: float = 14.0 +) -> tuple[Optional[str], float, dict[str, Any]]: + """Color, shaft width, and shape style keys for matplotlib ``arrowprops``. + + Head/tail shapes, filled tapered shafts (fancy/simple/wedge), and + connectionstyle curves map onto the engine's arrow style vocabulary; + ``mutation_scale`` mirrors matplotlib's (the annotation text size, px). + What has no equivalent (corner rounding) is approximated, never dropped.""" + arrowstyle = arrowprops.get("arrowstyle") + fancy = arrowstyle is None # matplotlib's YAArrow-style thick default + color = resolve_color( + arrowprops.get("color") + or arrowprops.get("facecolor") + or arrowprops.get("fc") + or arrowprops.get("edgecolor") + or arrowprops.get("ec") + or "black" + ) + alpha = arrowprops.get("alpha") + if color is not None and alpha is not None: + from ._colors import _rgba_floats + + try: # alpha dims only the arrow, so bake it into the color itself + r, g, b, a = _rgba_floats(color) + except ValueError: + pass # exotic CSS name: keep the color, lose alpha + else: + color = ( + f"rgba({round(r * 255)},{round(g * 255)},{round(b * 255)},{float(alpha) * a:.3g})" + ) + width = float( + arrowprops.get( + "width", arrowprops.get("lw", arrowprops.get("linewidth", 3.0 if fancy else 1.5)) + ) + ) + style: dict[str, Any] = {} + if fancy: + style["head_size"] = float(arrowprops.get("headwidth", 12.0)) + else: + name = str(arrowstyle).split(",")[0].strip() + tail, head = _ARROWSTYLE_ENDS.get(name, ("none", "triangle")) + options = _parse_style_options(str(arrowstyle)) + scale = float(mutation_scale) + if name in ("fancy", "simple", "wedge"): + # Filled tapered shafts, matplotlib's mutation-scale-sized fills. + if name == "wedge": + style["shaft_width_start"] = options.get("tail_width", 0.3) * scale + style["shaft_width_end"] = 1.0 + style["gap_end"] = 0.0 # the wedge tip IS the pointer + head = "none" + else: + style["shaft_width_start"] = 2.0 if name == "fancy" else 1.5 + style["shaft_width_end"] = options.get("tail_width", 0.4) * scale + style["head_size"] = options.get("head_width", 0.4) * scale * 2.2 + if head != "triangle": + style["head_style"] = head + if tail != "none": + style["tail_style"] = tail + if "bar" in (head, tail): + # widthA/widthB are fractions of the mutation scale (~text size). + bar = options.get("widthA", options.get("widthB", 0.4)) + style["head_size"] = max(4.0, bar * 20.0) + elif "head_size" not in style: + style["head_size"] = float(arrowprops.get("headwidth", 8.0)) + style.update(_connection_curve(arrowprops.get("connectionstyle"))) + return color, width, style + + +def _bbox_label_style(bbox: dict[str, Any], font_size: float = 11.0) -> dict[str, Any]: + """matplotlib text ``bbox`` patch → annotation-label box styles. + + A CSS approximation drawn by the render client's DOM label; the static + exporters keep the plain label (recorded in docs/matplotlib-compat.md). + """ + style: dict[str, Any] = {} + face = bbox.get("fc", bbox.get("facecolor", "C0")) + alpha = bbox.get("alpha") + if face is not None and face != "none": + resolved = resolve_color(face) + if resolved is not None: + if alpha is not None: + from ._colors import _rgba_floats + + try: + r, g, b, a = _rgba_floats(resolved) + except ValueError: # exotic CSS name: keep the fill, lose alpha + style["background"] = resolved + else: + style["background"] = ( + f"rgba({round(r * 255)},{round(g * 255)},{round(b * 255)}," + f"{float(alpha) * a:.3g})" + ) + else: + style["background"] = resolved + edge = bbox.get("ec", bbox.get("edgecolor", "black")) + if edge is not None and edge != "none": + line_width = float(bbox.get("lw", bbox.get("linewidth", 1.0))) + style["border"] = f"{line_width:g}px solid {resolve_color(edge)}" + boxstyle = str(bbox.get("boxstyle", "square")) + name = boxstyle.split(",")[0].strip() + if "round" in name: + style["border_radius"] = 8.0 if name == "round4" else 5.0 + # matplotlib pads the patch pad×fontsize around the text. + pad = max(0.0, _parse_style_options(boxstyle).get("pad", 0.3)) * float(font_size) + style["padding"] = f"{pad:.3g}px {pad * 1.3:.3g}px" + return style + + def _font_size_points(value: Any, base: Any) -> float: relative = { "xx-small": 0.6, @@ -4067,14 +4390,15 @@ def _rc_axis_style(axis: str, dpi: float = 96.0) -> dict[str, Any]: result["tick_label_color"] = resolve_color( tick_color if label_color == "inherit" else label_color ) - if rcParams[f"{prefix}.labelsize"] != "medium": - result["tick_label_size"] = _font_size( - rcParams[f"{prefix}.labelsize"], rcParams["font.size"], dpi - ) + # Always explicit: the render client and static exporters otherwise fall + # back to their own 11 px default, not matplotlib's font.size-derived + # medium (10 pt → 13.9 px at dpi 100). + result["tick_label_size"] = _font_size( + rcParams[f"{prefix}.labelsize"], rcParams["font.size"], dpi + ) if rcParams["axes.labelcolor"] != "black": result["label_color"] = resolve_color(rcParams["axes.labelcolor"]) - if rcParams["axes.labelsize"] != "medium": - result["label_size"] = _font_size(rcParams["axes.labelsize"], rcParams["font.size"], dpi) + result["label_size"] = _font_size(rcParams["axes.labelsize"], rcParams["font.size"], dpi) return result @@ -4089,6 +4413,26 @@ def _parse_bounds(value: Any, context: str) -> tuple[float, float, float, float] return left, bottom, width, height +_DATE_TEXT_FORMATS = ( + "%Y-%m-%d", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d %H:%M", +) + + +def _parse_date_text(value: str) -> Optional[np.datetime64]: + """A date string in the dateutil shapes gallery scripts use, or None. + + strptime's %m/%d accept unpadded fields, so '2012-1-1' parses too.""" + for fmt in _DATE_TEXT_FORMATS: + try: + return np.datetime64(datetime.strptime(value, fmt)) + except ValueError: + continue + return None + + def _convert_timedelta_axis(values: np.ndarray) -> np.ndarray: """Map timedelta coordinates to seconds; dates and categories stay native.""" array = np.asanyarray(values) diff --git a/python/xy/pyplot/_grid.py b/python/xy/pyplot/_grid.py index a8f4cdae..1d25fe99 100644 --- a/python/xy/pyplot/_grid.py +++ b/python/xy/pyplot/_grid.py @@ -1,11 +1,13 @@ """Multi-panel composition — the shim-owned replacement for an engine grid. -HTML: one self-contained document, panels in a CSS grid, each panel embedded -as a sandboxed ``srcdoc`` iframe of its own standalone chart document (same -zero-dependency offline story as `Chart.to_html`). A page-level visibility -governor unloads off-screen panel documents. This matters in notebooks where -many executed multi-panel cells would otherwise keep enough independent -WebGL contexts alive for the browser to evict arbitrary visible canvases. +HTML: one self-contained document (same zero-dependency offline story as +`Chart.to_html`): the render client ships once and every panel hydrates into +a host div, so all panels share the page-level WebGL context governor. A +panel-per-iframe composition put each panel in its own document with its own +governor, and a dense subplot grid exceeded the browser's per-page context +cap — only the first ~dozen panels ever rendered. The shared governor +snapshots and releases over-budget panels instead; pointer entry revives +them. PNG: each panel renders through the engine's native rasterizer to an RGBA array; NumPy pastes them onto one canvas and the engine's PNG encoder writes @@ -28,18 +30,45 @@ def compose_html( ncols: int, suptitle: Optional[str], suptitle_style: Optional[dict[str, Any]] = None, + *, + positions: Optional[list[tuple[float, float, float, float]]] = None, + canvas_size: Optional[tuple[int, int]] = None, ) -> str: + """Compose panel documents into one page. + + Default: a CSS grid of panels. With ``positions`` (whole-panel + [left, bottom, width, height] figure fractions, bottom-origin like + matplotlib) and ``canvas_size`` px, panels are absolutely placed on a + fixed-size canvas instead — the add_axes/subplots_adjust layout path. + Document order stacks later axes above earlier ones, as matplotlib draws. + """ + from xy import export + + absolute = positions is not None and canvas_size is not None panels = [] - for chart in charts: - doc = chart.to_html() + payloads = [] + for index, chart in enumerate(charts): figure = chart.figure() - width = max(120, int(figure.width)) - height = max(120, int(figure.height)) + spec, blob = figure.build_payload() + # Exact chart size: absolute placement relies on the panel's plot box + # landing on its matplotlib rect, so a dense grid's sub-120px panels + # must not be inflated here (the client honors small explicit sizes). + width = int(figure.width) + height = int(figure.height) + placement = "" + if absolute: + left, bottom, _width, panel_height = positions[index] + x = round(left * canvas_size[0]) + y = round((1.0 - bottom - panel_height) * canvas_size[1]) + placement = f"position:absolute;left:{x}px;top:{y}px;" panels.append( - '' + '
' + ) + payloads.append( + "{" + f'"spec":{export._json_for_inline_script(spec)},' + f'"chunks":{export._json_for_inline_script(export._base64_chunks(blob))},' + f'"n":{len(blob)}' + "}" ) style = suptitle_style or {} title_css = ( @@ -47,21 +76,46 @@ def compose_html( f"font-family:{_html.escape(str(style.get('family', 'system-ui, sans-serif')))};" f"color:{_html.escape(str(style.get('color', '#262626')))}" ) - title_html = ( - f"

{_html.escape(suptitle)}

" - if suptitle - else "" - ) - grid = "\n".join(panels) + if not suptitle: + title_html = "" + elif absolute: + # The suptitle anchors at figure-fraction (x, y) on the canvas itself. + shift = {"left": "0%", "center": "-50%", "right": "-100%"}.get( + str(style.get("ha", "center")), "-50%" + ) + title_html = ( + "
" + f"{_html.escape(suptitle)}
" + ) + else: + title_html = f"

{_html.escape(suptitle)}

" + if absolute: + grid_css = ( + f".fc-grid {{ position: relative; width: {canvas_size[0]}px; " + f"height: {canvas_size[1]}px; overflow: hidden; }}" + ) + grid = "\n".join(panels) + ("\n" + title_html if title_html else "") + title_html = "" + else: + grid_css = ( + f".fc-grid {{ display: grid; grid-template-columns: repeat({ncols}, max-content); " + "gap: 4px; padding: 4px; overflow-x: auto; }}" + ) + grid = "\n".join(panels) + client_js = export._javascript_for_inline_script(export._bundled_js("standalone")) return f""" + @@ -69,62 +123,14 @@ def compose_html(
{grid}
+ """ @@ -136,32 +142,55 @@ def compose_svg( ncols: int, suptitle: Optional[str], suptitle_style: Optional[dict[str, Any]] = None, + *, + positions: Optional[list[tuple[float, float, float, float]]] = None, + canvas_size: Optional[tuple[int, int]] = None, ) -> str: - """Compose subplot SVGs with isolated ids into one portable SVG document.""" + """Compose subplot SVGs with isolated ids into one portable SVG document. + + Panels tile a uniform grid by default; with ``positions`` (whole-panel + figure fractions, bottom-origin) and ``canvas_size`` px they are placed + absolutely on a fixed canvas — the add_axes/subplots_adjust layout path. + """ from xy import _svg figures = [chart.figure() for chart in charts] if not figures: raise ValueError("figure has no axes to save") - col_widths = [ - max(int(figures[index].width) for index in range(col, len(figures), ncols)) - for col in range(ncols) - ] - row_heights = [ - max( - int(figures[index].height) - for index in range(row * ncols, min((row + 1) * ncols, len(figures))) - ) - for row in range(nrows) - ] - title_h = 28 if suptitle else 0 + if positions is not None and canvas_size is not None: + title_h = 0 + offsets = [ + ( + round(position[0] * canvas_size[0]), + round((1.0 - position[1] - position[3]) * canvas_size[1]), + ) + for position in positions + ] + total_size: tuple[int, int] = (int(canvas_size[0]), int(canvas_size[1])) + else: + col_widths = [ + max(int(figures[index].width) for index in range(col, len(figures), ncols)) + for col in range(ncols) + ] + row_heights = [ + max( + int(figures[index].height) + for index in range(row * ncols, min((row + 1) * ncols, len(figures))) + ) + for row in range(nrows) + ] + title_h = 28 if suptitle else 0 + offsets = [] + for index in range(len(figures)): + row, col = divmod(index, ncols) + offsets.append((sum(col_widths[:col]), title_h + sum(row_heights[:row]))) + total_size = (sum(col_widths), title_h + sum(row_heights)) body: list[str] = [] for index, figure in enumerate(figures): svg = _svg.to_svg(figure, id_prefix=f"xy-panel-{index}-") inner = svg[svg.find(">") + 1 : svg.rfind("")] - row, col = divmod(index, ncols) body.append( - f'{inner}' ) @@ -169,7 +198,7 @@ def compose_svg( anchor = {"left": "start", "center": "middle", "right": "end"}.get( str(style.get("ha", "center")), "middle" ) - width, height = sum(col_widths), title_h + sum(row_heights) + width, height = total_size size = float(style.get("size", 16)) # y is a figure fraction measured from the bottom, like matplotlib. baseline = min(height - 2.0, (1.0 - float(style.get("y", 0.98))) * height + 0.75 * size) diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 3c211d63..9c48d921 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -16,7 +16,7 @@ from ._artists import Text from ._axes import Axes, _plain_text -from ._rc import rc_figsize_px +from ._rc import rc_figsize_px, rcParams from ._transforms import CoordinateTransform from ._translate import check_unsupported, not_implemented @@ -56,10 +56,12 @@ def __init__( figsize: Optional[tuple[float, float]] = None, dpi: Optional[float] = None, facecolor: Optional[str] = None, + toolbar: Optional[bool] = None, ) -> None: self.number = num self._figsize = figsize self._dpi = dpi + self._toolbar = toolbar # None -> rcParams["toolbar"] decides self._facecolor = facecolor or "white" self._edgecolor = "white" self._suptitle: Optional[str] = None @@ -83,6 +85,17 @@ def __init__( self._label = "" self._gci: Any = None # last color-mapped artist, for plt.colorbar()/clim() + def _show_toolbar(self) -> bool: + """Whether panels render the interactive modebar controls. + + The figure kwarg wins; otherwise rcParams["toolbar"] decides, and the + shim default is "none" — Matplotlib's inline backend shows no toolbar, + so notebook output stays control-free unless explicitly enabled. + """ + if self._toolbar is not None: + return bool(self._toolbar) + return str(rcParams.get("toolbar", "none")).lower() != "none" + # -- layout -------------------------------------------------------------- def _invalidate(self) -> None: @@ -226,6 +239,9 @@ def _axes_at(self, index: int) -> Axes: def axes(self) -> list[Axes]: return list(self._axes) + def get_axes(self) -> list[Axes]: + return self.axes + def gca(self) -> Axes: if self._current_ax is not None and self._current_ax in self._axes: return self._current_ax @@ -351,19 +367,40 @@ def tight_layout(self, **kwargs: Any) -> None: } self._invalidate() - def subplots_adjust(self, **kwargs: Any) -> None: - allowed = {"left", "right", "top", "bottom", "wspace", "hspace"} - unsupported = set(kwargs) - allowed - if unsupported: - raise TypeError( - f"subplots_adjust() got unsupported keyword argument {sorted(unsupported)[0]!r}" - ) - material = {key: value for key, value in kwargs.items() if value is not None} - if material: - raise NotImplementedError( - "xy.pyplot does not implement Figure.subplots_adjust(); subplot frame and " - "spacing values cannot be represented by the current multi-panel renderer" - ) + def subplots_adjust( + self, + left: Any = None, + bottom: Any = None, + right: Any = None, + top: Any = None, + wspace: Any = None, + hspace: Any = None, + ) -> None: + """Move the SubplotParams frame; panels re-render at their new rects.""" + updates = { + "left": left, + "bottom": bottom, + "right": right, + "top": top, + "wspace": wspace, + "hspace": hspace, + } + material = {key: float(value) for key, value in updates.items() if value is not None} + merged = { + **_SUBPLOT_PARAMS, + "wspace": _SUBPLOT_SPACING, + "hspace": _SUBPLOT_SPACING, + **self._subplot_adjust, + **material, + } + if merged["left"] >= merged["right"]: + raise ValueError("left cannot be >= right") + if merged["bottom"] >= merged["top"]: + raise ValueError("bottom cannot be >= top") + self._subplot_adjust.update(material) + for ax in self._axes: + ax._invalidate() + self._invalidate() def autofmt_xdate(self, **kwargs: Any) -> None: rotation = float(kwargs.pop("rotation", 30)) @@ -573,17 +610,63 @@ def _panel_px(self) -> tuple[int, int]: return max(120, w // self._ncols), max(120, h // self._nrows) def _effective_rects(self) -> Optional[list[tuple[float, float, float, float]]]: - """Per-axes figure rects when any axes is free-form, else None. - + """Per-axes figure rects when the figure needs free-form placement, else None. + + A figure is free-form when any axes carries an explicit rect (the + add_axes path), when subplots_adjust() moved the SubplotParams frame, + or when it holds more than one panel: multi-panel grids place every + plot box at its matplotlib gridspec rectangle so the whole figure + occupies exactly figsize — the CSS-grid composition floored panels at + 120 px, blowing a 6-inch 8x8 grid up to ~1000 px of scrollbars. Matplotlib places a rect-less axes at the SubplotParams default, so a default axes mixed with an inset keeps its full-size position instead of dragging every axes back onto the uniform grid. """ + if not self._axes: + return None rects = [ax._figure_rect for ax in self._axes] - if not self._axes or not any(rect is not None for rect in rects): + if any(rect is not None for rect in rects): + default = ( + _GridSpec(self, 1, 1, **self._subplot_adjust).cell_rect((0, 1), (0, 1)) + if self._subplot_adjust + else (0.125, 0.11, 0.775, 0.77) + ) + return [rect if rect is not None else default for rect in rects] + if not self._subplot_adjust and len(self._axes) <= 1: return None - default = (0.125, 0.11, 0.775, 0.77) - return [rect if rect is not None else default for rect in rects] + # A uniform grid (adjusted or default SubplotParams): every panel + # resolves to its gridspec cell rectangle under the frame and spacing. + grid = _GridSpec( + self, + self._nrows, + self._ncols, + width_ratios=self._width_ratios, + height_ratios=self._height_ratios, + **self._subplot_adjust, + ) + return [ + grid.cell_rect( + (index // self._ncols, index // self._ncols + 1), + (index % self._ncols, index % self._ncols + 1), + ) + for index in range(len(self._axes)) + ] + + def _grid_cell_sizes(self) -> tuple[list[int], list[int]]: + """Per-column widths and per-row heights of the CSS-grid panel layout. + + Cells floor at 120 px (the render client's minimum chart size), so a + dense grid can legitimately exceed the nominal figure size — callers + sizing the outer document must use these, not `figsize`. + """ + total_w, total_h = rc_figsize_px(self._figsize, self._dpi) + width_ratios = self._width_ratios or (1.0,) * self._ncols + height_ratios = self._height_ratios or (1.0,) * self._nrows + if len(width_ratios) != self._ncols or len(height_ratios) != self._nrows: + raise ValueError("subplot width/height ratios must match the grid dimensions") + widths = [max(120, round(total_w * value / sum(width_ratios))) for value in width_ratios] + heights = [max(120, round(total_h * value / sum(height_ratios))) for value in height_ratios] + return widths, heights def _charts(self) -> list[Any]: total_w, total_h = rc_figsize_px(self._figsize, self._dpi) @@ -600,17 +683,12 @@ def _charts(self) -> list[Any]: margin_w, margin_h = (54, 42) if compact else (76, 52) ax._absolute_plot_ratio = plot_w / plot_h charts.append(ax._build_chart(plot_w + margin_w, plot_h + margin_h)) - return charts - width_ratios = self._width_ratios or (1.0,) * self._ncols - height_ratios = self._height_ratios or (1.0,) * self._nrows - if len(width_ratios) != self._ncols or len(height_ratios) != self._nrows: - raise ValueError("subplot width/height ratios must match the grid dimensions") - widths = [max(120, round(total_w * value / sum(width_ratios))) for value in width_ratios] - heights = [max(120, round(total_h * value / sum(height_ratios))) for value in height_ratios] - charts = [ - ax._build_chart(widths[index % self._ncols], heights[index // self._ncols]) - for index, ax in enumerate(self._axes) - ] + else: + widths, heights = self._grid_cell_sizes() + charts = [ + ax._build_chart(widths[index % self._ncols], heights[index // self._ncols]) + for index, ax in enumerate(self._axes) + ] if charts and (self._sharex or self._sharey): figures = [chart.figure() for chart in charts] linked: list[str] = [] @@ -620,8 +698,12 @@ def _charts(self) -> list[Any]: linked.append(dim) for group in self._share_groups(shared, len(figures)): members = [figures[i] for i in group] + # matplotlib shared limits autoscale over the group's data; + # dataless panels follow the group instead of contributing + # their (0, 1) default view to the union. + sources = [figure for figure in members if figure.traces] or members ranges = [ - figure.x_range() if dim == "x" else figure.y_range() for figure in members + figure.x_range() if dim == "x" else figure.y_range() for figure in sources ] domain = ( min(min(pair) for pair in ranges), @@ -653,10 +735,37 @@ def _single(self) -> Optional[Any]: self._nrows == self._ncols == 1 and len(charts) == 1 and self._axes[0]._figure_rect is None + and not self._subplot_adjust ): return charts[0] return None + def _panel_positions( + self, + rects: list[tuple[float, float, float, float]], + canvas_size: tuple[int, int], + ) -> list[tuple[float, float, float, float]]: + """Expand plot-box rects into whole-panel rects including chart chrome. + + Free-form panels are built at plot size plus fixed chrome margins + (`_charts`); exporters place the enlarged panel so its plot box lands + exactly on the requested figure rectangle. + """ + positions = [] + for rect in rects: + compact = round(canvas_size[0] * rect[2]) + 54 < 520 + left, bottom = (46, 36) if compact else (62, 42) + width, height = (54, 42) if compact else (76, 52) + positions.append( + ( + rect[0] - left / canvas_size[0], + rect[1] - bottom / canvas_size[1], + rect[2] + width / canvas_size[0], + rect[3] + height / canvas_size[1], + ) + ) + return positions + # -- output ----------------------------------------------------------------- def savefig( @@ -717,12 +826,18 @@ def savefig( if single is None or self._suptitle is not None: from ._grid import compose_svg + canvas_size = rc_figsize_px(self._figsize, self._dpi) + rects = self._effective_rects() data = compose_svg( self._charts(), self._nrows, self._ncols, self._suptitle, self._suptitle_style, + positions=( + None if rects is None else self._panel_positions(rects, canvas_size) + ), + canvas_size=None if rects is None else canvas_size, ).encode() else: data = single.to_svg().encode() @@ -744,12 +859,7 @@ def savefig( elif suffix == "html": if metadata: raise not_implemented("savefig(format='html', metadata=...)", "PNG or SVG") - data = self._to_html().encode() - if self._facecolor not in ("none", "white"): - import html - - fill = html.escape(self._facecolor, quote=True) - data = f'
'.encode() + data + b"
" + data = self._facecolor_wrapped(self._to_html()).encode() else: raise not_implemented(f"savefig(format={suffix!r})", "png, svg, or html") finally: @@ -770,23 +880,7 @@ def _to_png(self, *, bbox_tight: bool = False, pad_inches: float = 0.1) -> bytes canvas_size = rc_figsize_px(self._figsize, self._dpi) rects = self._effective_rects() - positions = ( - [ - ( - rect[0] - - (46 if round(canvas_size[0] * rect[2]) + 54 < 520 else 62) / canvas_size[0], - rect[1] - - (36 if round(canvas_size[0] * rect[2]) + 54 < 520 else 42) / canvas_size[1], - rect[2] - + (54 if round(canvas_size[0] * rect[2]) + 54 < 520 else 76) / canvas_size[0], - rect[3] - + (42 if round(canvas_size[0] * rect[2]) + 54 < 520 else 52) / canvas_size[1], - ) - for rect in rects - ] - if rects is not None - else None - ) + positions = None if rects is None else self._panel_positions(rects, canvas_size) return stitch_png( self._charts(), @@ -810,12 +904,18 @@ def _to_html(self) -> str: else: from ._grid import compose_html + canvas_size = rc_figsize_px(self._figsize, self._dpi) + rects = self._effective_rects() self._html_cache = compose_html( self._charts(), self._nrows, self._ncols, self._suptitle, self._suptitle_style, + positions=( + None if rects is None else self._panel_positions(rects, canvas_size) + ), + canvas_size=None if rects is None else canvas_size, ) return self._html_cache @@ -827,6 +927,7 @@ def _to_notebook_html(self) -> tuple[str, int, int]: self._nrows == self._ncols == 1 and len(self._axes) == 1 and self._axes[0]._figure_rect is None + and not self._subplot_adjust and self._suptitle is None ): # Matplotlib's inline backend displays figures with @@ -870,20 +971,54 @@ def _to_notebook_html(self) -> tuple[str, int, int]: ax._chart = old_chart ax._padding = old_padding return doc, tight_width, tight_height - return self._to_html(), width, height + doc = self._to_html() + single = self._single() + if single is not None and self._suptitle is None: + figure = single.figure() + return doc, int(figure.width), int(figure.height) + if self._effective_rects() is None and self._axes: + # CSS-grid panel layout: cells floor at 120 px, so the composed + # document can be larger than figsize. Size the notebook iframe to + # the real grid content (cells + 4 px gaps + 4 px padding) so a + # dense subplot grid displays whole instead of behind scrollbars. + widths, heights = self._grid_cell_sizes() + cols_used = min(len(self._axes), self._ncols) + rows_used = -(-len(self._axes) // self._ncols) + content_w = sum(widths[:cols_used]) + 4 * (self._ncols - 1) + 8 + content_h = sum(heights[:rows_used]) + 4 * (rows_used - 1) + 8 + if self._suptitle: + size = float((self._suptitle_style or {}).get("size", 16)) + content_h += round(size * 1.4) + 8 # h2 line box + top margin + return doc, content_w, content_h + return doc, width, height + + def _facecolor_wrapped(self, doc: str) -> str: + """The figure facecolor behind an HTML document — matplotlib's figure + patch around the (separately painted, `--chart-bg`) axes plot box.""" + if self._facecolor in ("none", "white"): + return doc + import html + + fill = html.escape(self._facecolor, quote=True) + head_end = doc.find("") + if head_end != -1: + # Same element specificity as the document's body{background:#fff} + # rule; later in the head, so it wins. + return doc[:head_end] + f"" + doc[head_end:] + return f'
{doc}
' def _repr_html_(self) -> str: from xy import export doc, width, height = self._to_notebook_html() - return export.notebook_iframe(doc, width=width, height=height) + return export.notebook_iframe(self._facecolor_wrapped(doc), width=width, height=height) def show(self, *args: Any, **kwargs: Any) -> None: import tempfile import webbrowser with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False) as f: - f.write(self._to_html()) + f.write(self._facecolor_wrapped(self._to_html())) webbrowser.open(f"file://{f.name}") @@ -990,7 +1125,9 @@ def __getitem__(self, key: Any) -> _SubplotSpec: r1, c1 = divmod(last, self.ncols) return _SubplotSpec(self, (min(r0, r1), max(r0, r1) + 1), (min(c0, c1), max(c0, c1) + 1)) - def cell_rect(self, rows: tuple[int, int], cols: tuple[int, int]) -> tuple[float, ...]: + def cell_rect( + self, rows: tuple[int, int], cols: tuple[int, int] + ) -> tuple[float, float, float, float]: """[left, bottom, width, height] figure fractions for a cell span.""" frame = { key: (self._geometry[key] if self._geometry[key] is not None else default) diff --git a/python/xy/pyplot/_rc.py b/python/xy/pyplot/_rc.py index 9403fe8c..28d0a460 100644 --- a/python/xy/pyplot/_rc.py +++ b/python/xy/pyplot/_rc.py @@ -21,6 +21,10 @@ def by_key(self) -> dict[str, list[str]]: _DEFAULTS: dict[str, Any] = { + # Matplotlib's inline backend shows no toolbar, so the shim defaults the + # interactive modebar off; "toolbar2"/"toolmanager" (or figure(toolbar=True)) + # opt back in to the on-chart controls. + "toolbar": "none", "figure.figsize": (6.4, 4.8), # inches, matplotlib default "figure.dpi": 100.0, "figure.facecolor": "white", diff --git a/python/xy/pyplot/_state.py b/python/xy/pyplot/_state.py index 5a238229..ef0dc2f0 100644 --- a/python/xy/pyplot/_state.py +++ b/python/xy/pyplot/_state.py @@ -22,6 +22,7 @@ def figure( **kwargs: Any, ) -> Figure: global _current + toolbar = kwargs.pop("toolbar", None) if num is None: num = max(_figures) + 1 if _figures else 1 key = num if isinstance(num, int) else hash(num) @@ -31,12 +32,14 @@ def figure( figsize=figsize, dpi=dpi, facecolor=kwargs.get("facecolor", rcParams["figure.facecolor"]), + toolbar=toolbar, ) _figures[key]._label = "" if isinstance(num, int) else str(num) - elif figsize is not None or dpi is not None: + elif figsize is not None or dpi is not None or toolbar is not None: fig = _figures[key] fig._figsize = figsize or fig._figsize fig._dpi = dpi or fig._dpi + fig._toolbar = toolbar if toolbar is not None else fig._toolbar fig._invalidate() _current = key return _figures[key] diff --git a/python/xy/pyplot/_ticker.py b/python/xy/pyplot/_ticker.py index 3428fac8..e4d03319 100644 --- a/python/xy/pyplot/_ticker.py +++ b/python/xy/pyplot/_ticker.py @@ -3,13 +3,15 @@ Locators own tick *positions* over the axis view interval; formatters own label text. The Axes applies them at chart-build time, when data limits are known, so locator-driven axes keep refreshing as data lands — the same -contract as the native tick generator they displace. The math is xy-owned -and approximates matplotlib's locators (documented in the compat table); -positions are exact for Null/Fixed/Multiple, heuristic for MaxN. +contract as the native tick generator they displace. The math is xy-owned; +positions are exact for Null/Fixed/Multiple/Linear and MaxN/Auto port +matplotlib's ``MaxNLocator._raw_ticks`` (Log stays approximate, documented +in the compat table). """ from __future__ import annotations +import math from collections.abc import Callable from typing import Any, Optional @@ -18,23 +20,54 @@ from ._translate import check_unsupported -class Locator: - def tick_values(self, vmin: float, vmax: float) -> np.ndarray: - raise NotImplementedError +def _scale_range(vmin: float, vmax: float, n: int) -> tuple[float, float]: + """matplotlib's ``ticker.scale_range``: decade scale and offset for a span.""" + dv = abs(vmax - vmin) + maxabsv = max(abs(vmin), abs(vmax)) + if maxabsv == 0 or dv / maxabsv < 1e-12: + return 1.0, 0.0 + meanv = (vmax + vmin) / 2 + offset = 0.0 + if abs(meanv) / dv >= 100: # threshold: far-from-zero spans get an offset + offset = math.copysign(10 ** (math.log10(abs(meanv)) // 1), meanv) + scale = 10 ** (math.log10(dv / n) // 1) + return scale, offset - def __repr__(self) -> str: - return f"" +class _EdgeInteger: + """matplotlib's ``ticker._Edge_integer``: offset-tolerant edge rounding.""" + + def __init__(self, step: float, offset: float) -> None: + self.step = step + self._offset = abs(offset) + + def _close_to(self, ms: float, edge: float) -> bool: + if self._offset > 0: + digits = np.log10(self._offset / self.step) + tol = min(0.4999, max(1e-10, 10 ** (digits - 12))) + else: + tol = 1e-10 + return abs(ms - edge) < tol + + def le(self, x: float) -> float: + d, m = divmod(x, self.step) + return d + 1 if self._close_to(m / self.step, 1) else d -class AutoLocator(Locator): - """The default: the engine's nice-linear tick generator.""" + def ge(self, x: float) -> float: + d, m = divmod(x, self.step) + return d if self._close_to(m / self.step, 0) else d + 1 + + +class Locator: + # Axes-size tick budget, set by the Axes before tick_values() when the + # axis pixel length is known (matplotlib reads it off self.axis instead). + _nbins_hint: Optional[int] = None def tick_values(self, vmin: float, vmax: float) -> np.ndarray: - from xy._svg import _linear_ticks + raise NotImplementedError - if not (np.isfinite(vmin) and np.isfinite(vmax)) or vmin == vmax: - return np.asarray([], dtype=float) - return np.asarray(_linear_ticks(float(vmin), float(vmax))[0], dtype=float) + def __repr__(self) -> str: + return f"" class NullLocator(Locator): @@ -71,41 +104,81 @@ def tick_values(self, vmin: float, vmax: float) -> np.ndarray: class MaxNLocator(Locator): - """At most *nbins* intervals on nice step sizes (1, 2, 2.5, 5) × 10^k.""" + """matplotlib's MaxNLocator (``_raw_ticks`` port): at most *nbins* intervals + on nice step values; edge ticks may overrun the view — the axis clips them, + exactly as matplotlib trims at draw time.""" - _default_steps = (1.0, 2.0, 2.5, 5.0, 10.0) + _default_steps = (1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 8.0, 10.0) def __init__(self, nbins: Any = 10, **kwargs: Any) -> None: self._integer = bool(kwargs.pop("integer", False)) steps = kwargs.pop("steps", None) kwargs.pop("prune", None) # compat-noop: ticks outside the view never draw + self._min_n_ticks = max(1, int(kwargs.pop("min_n_ticks", 2))) check_unsupported(kwargs, "MaxNLocator()") - if nbins == "auto": - nbins = 9 # matplotlib's density heuristic collapsed to its default - self._nbins = max(1, int(nbins)) - self._steps = ( - tuple(sorted(float(step) for step in steps)) - if steps is not None - else MaxNLocator._default_steps + self._nbins: Any = nbins if nbins == "auto" else max(1, int(nbins)) + if steps is None: + validated = list(MaxNLocator._default_steps) + else: + validated = sorted(float(step) for step in steps) + if any(step < 1 or step > 10 for step in validated): + raise ValueError("steps must be numbers between 1 and 10 inclusive") + if validated[0] != 1.0: + validated.insert(0, 1.0) + if validated[-1] != 10.0: + validated.append(10.0) + self._steps = tuple(validated) + self._extended_steps = np.concatenate( + [ + 0.1 * np.asarray(self._steps[:-1]), + np.asarray(self._steps), + [10.0 * self._steps[1]], + ] ) def tick_values(self, vmin: float, vmax: float) -> np.ndarray: vmin, vmax = sorted((float(vmin), float(vmax))) if not (np.isfinite(vmin) and np.isfinite(vmax)) or vmin == vmax: return np.asarray([vmin], dtype=float) - raw = (vmax - vmin) / self._nbins - magnitude = 10.0 ** np.floor(np.log10(raw)) - for scale in (magnitude, magnitude * 10.0, magnitude * 100.0): - for step in self._steps: - candidate = step * scale - if self._integer: - candidate = max(1.0, np.round(candidate)) - first = np.ceil(vmin / candidate - 1e-9) - last = np.floor(vmax / candidate + 1e-9) - if last < first or last - first > self._nbins: - continue - return np.arange(first, last + 1) * candidate - return np.asarray([vmin, vmax], dtype=float) + if self._nbins == "auto": + hint = 9 if self._nbins_hint is None else int(self._nbins_hint) + nbins = int(np.clip(hint, max(1, self._min_n_ticks - 1), 9)) + else: + nbins = self._nbins + scale, offset = _scale_range(vmin, vmax, nbins) + _vmin = vmin - offset + _vmax = vmax - offset + steps = self._extended_steps * scale + if self._integer: + # For steps > 1, keep only integer values. + steps = steps[(steps < 1) | (np.abs(steps - np.round(steps)) < 0.001)] + raw_step = (_vmax - _vmin) / nbins + large = np.nonzero(steps >= raw_step)[0] + istep = int(large[0]) if len(large) else len(steps) - 1 + # Start at the smallest step >= the raw step; walk down only if it + # leaves fewer than min_n_ticks ticks inside the view. + ticks = np.asarray([_vmin, _vmax]) + for step in steps[: istep + 1][::-1]: + step = float(step) + if self._integer and np.floor(_vmax) - np.ceil(_vmin) >= self._min_n_ticks - 1: + step = max(1.0, step) + best_vmin = (_vmin // step) * step + edge = _EdgeInteger(step, offset) + low = edge.le(_vmin - best_vmin) + high = edge.ge(_vmax - best_vmin) + ticks = np.arange(low, high + 1) * step + best_vmin + if ((ticks >= _vmin) & (ticks <= _vmax)).sum() >= self._min_n_ticks: + break + return ticks + offset + + +class AutoLocator(MaxNLocator): + """The default: matplotlib's AutoLocator — MaxNLocator with axes-size + density and the restricted (1, 2, 2.5, 5, 10) step table, which is also + the engine's native nice-step rule.""" + + def __init__(self) -> None: + super().__init__(nbins="auto", steps=(1.0, 2.0, 2.5, 5.0, 10.0)) class LinearLocator(Locator): diff --git a/python/xy/pyplot/dates.py b/python/xy/pyplot/dates.py new file mode 100644 index 00000000..aa6d1762 --- /dev/null +++ b/python/xy/pyplot/dates.py @@ -0,0 +1,144 @@ +"""The matplotlib.dates subset gallery scripts use, in the engine's time unit. + +matplotlib's date machinery works in *days* resolved through its unit +registry; the shim has no registry — datetime data is canonicalized once to +f64 ms since epoch (columns.py) and every axis quantity stays in that space. +These locators and formatters therefore speak ms directly, so they compose +with the same ``set_major_locator``/``set_major_formatter`` contract as +``xy.pyplot``'s numeric tickers. +""" + +from __future__ import annotations + +import datetime as dt +from typing import Any, Optional + +import numpy as np + +from ._ticker import Formatter, Locator + +_EPOCH = dt.datetime(1970, 1, 1) +_MAXTICKS = 1000 # matplotlib.dates.RRuleLocator's runaway-locator guard + + +def _from_ms(value: float) -> dt.datetime: + return _EPOCH + dt.timedelta(milliseconds=float(value)) + + +def _to_ms(value: dt.datetime) -> float: + return (value - _EPOCH).total_seconds() * 1000.0 + + +class DateFormatter(Formatter): + """``strftime`` of a tick position taken as ms since epoch (naive UTC).""" + + def __init__(self, fmt: str, tz: Any = None, *, usetex: Any = None) -> None: + del tz, usetex # compat-noop: naive timestamps, plain-text labels + self._fmt = str(fmt) + + def __call__(self, value: float, pos: Optional[int] = None) -> str: + return _from_ms(value).strftime(self._fmt) + + +def _month_set(bymonth: Any) -> tuple[int, ...]: + if bymonth is None: + return tuple(range(1, 13)) + if isinstance(bymonth, (int, np.integer)): + bymonth = (bymonth,) + months = tuple(sorted({int(month) for month in bymonth})) + if any(month < 1 or month > 12 for month in months): + raise ValueError("bymonth values must be in 1..12") + return months + + +class _CalendarLocator(Locator): + """Shared clip loop over calendar-rule candidates (rrule approximation: + occurrence counting for ``interval`` is anchored at the 1970 epoch, the + same dtstart matplotlib's rules default to).""" + + _interval: int + + def _candidates(self, lo: dt.datetime, hi: dt.datetime) -> list[tuple[int, dt.datetime]]: + raise NotImplementedError + + def tick_values(self, vmin: float, vmax: float) -> np.ndarray: + lo, hi = sorted((float(vmin), float(vmax))) + if not (np.isfinite(lo) and np.isfinite(hi)): + return np.asarray([], dtype=float) + ticks: list[float] = [] + for occurrence, when in self._candidates(_from_ms(lo), _from_ms(hi)): + if occurrence % self._interval: + continue + value = _to_ms(when) + if lo <= value <= hi: + ticks.append(value) + if len(ticks) > _MAXTICKS: + raise RuntimeError(f"Locator attempting to generate more than {_MAXTICKS} ticks") + return np.asarray(ticks, dtype=float) + + +class MonthLocator(_CalendarLocator): + """Ticks on the given day of the given months, every *interval* matches.""" + + def __init__( + self, bymonth: Any = None, bymonthday: int = 1, interval: int = 1, tz: Any = None + ) -> None: + del tz # compat-noop: naive timestamps + self._months = _month_set(bymonth) + self._day = int(bymonthday) + if not 1 <= self._day <= 31: + raise ValueError("bymonthday must be in 1..31") + self._interval = max(1, int(interval)) + + def _candidates(self, lo: dt.datetime, hi: dt.datetime) -> list[tuple[int, dt.datetime]]: + out = [] + for year in range(lo.year, hi.year + 1): + for index, month in enumerate(self._months): + try: + when = dt.datetime(year, month, self._day) + except ValueError: # bymonthday past the month's end: rrule skips it + continue + out.append(((year - 1970) * len(self._months) + index, when)) + return out + + +class YearLocator(_CalendarLocator): + """Ticks on month/day of years that are multiples of *base*.""" + + def __init__(self, base: int = 1, month: int = 1, day: int = 1, tz: Any = None) -> None: + del tz # compat-noop: naive timestamps + self._interval = max(1, int(base)) + self._month, self._day = int(month), int(day) + + def _candidates(self, lo: dt.datetime, hi: dt.datetime) -> list[tuple[int, dt.datetime]]: + out = [] + for year in range(lo.year, hi.year + 1): + try: + out.append((year, dt.datetime(year, self._month, self._day))) + except ValueError: + continue + return out + + +class DayLocator(_CalendarLocator): + """Ticks on the given days of the month (all days when omitted).""" + + def __init__(self, bymonthday: Any = None, interval: int = 1, tz: Any = None) -> None: + del tz # compat-noop: naive timestamps + if isinstance(bymonthday, (int, np.integer)): + bymonthday = (bymonthday,) + self._days = None if bymonthday is None else tuple(sorted({int(d) for d in bymonthday})) + if self._days is not None and any(day < 1 or day > 31 for day in self._days): + raise ValueError("bymonthday values must be in 1..31") + self._interval = max(1, int(interval)) + + def _candidates(self, lo: dt.datetime, hi: dt.datetime) -> list[tuple[int, dt.datetime]]: + out = [] + day = dt.datetime(lo.year, lo.month, lo.day) + while day <= hi: + if self._days is None or day.day in self._days: + out.append((day.toordinal() - _EPOCH.toordinal(), day)) + if len(out) > _MAXTICKS * max(1, self._interval): + break # tick_values raises past _MAXTICKS; stop generating + day += dt.timedelta(days=1) + return out diff --git a/python/xy/static/index.js b/python/xy/static/index.js index 4df88c17..28b437b9 100644 --- a/python/xy/static/index.js +++ b/python/xy/static/index.js @@ -1723,6 +1723,14 @@ for (const view of candidates) { if (over <= 0) break; if (view._releaseContext()) over -= 1; } +if (over <= 0) return; +const visible = live +.filter((view) => view._ctxVisible) +.sort((a, b) => (a._ctxSeenSeq || 0) - (b._ctxSeenSeq || 0)); +for (const view of visible) { +if (over <= 0) break; +if (view._releaseContext()) over -= 1; +} }, acquired(requester) { requester._ctxPendingReservation = false; @@ -1778,7 +1786,10 @@ this.fluidH = spec.height === "100%"; const rect = this.fluid || this.fluidH ? el.getBoundingClientRect() : null; const cw = this.fluid ? Math.round(rect.width) || 640 : spec.width; const ch = this.fluidH ? Math.round(rect.height) || 420 : spec.height; -this.size = { w: Math.max(120, cw), h: Math.max(120, ch) }; +this.size = { +w: Math.max(this.fluid ? 120 : 48, cw), +h: Math.max(this.fluidH ? 120 : 48, ch), +}; this._layout(); this._buildDom(el); this.theme = readTheme(this.root); @@ -2144,6 +2155,7 @@ this._contextRecoveryError = null; this.root.dataset.fcContextState = "ready"; this._scheduleViewRequest(this.view, { delay: 0 }); this.draw(); +this._dropContextSnapshot(); this._dispatchChartEvent("context_restored", { loss_count: this._contextLossCount, restore_count: this._contextRestoreCount, @@ -2154,6 +2166,7 @@ _releaseContext() { if (this._destroyed || !this.gl || this._glLost || this.gl.isContextLost()) return false; const ext = this.gl.getExtension("WEBGL_lose_context"); if (!ext) return false; +this._snapshotBeforeRelease(); this._ctxReleasedExt = ext; this._ctxReleases += 1; this._glLost = true; @@ -2163,6 +2176,33 @@ this._raf = null; ext.loseContext(); return true; } +_snapshotBeforeRelease() { +try { +if (this._raf) cancelAnimationFrame(this._raf); +this._raf = null; +this._rafKeepPick = true; +this._drawNow(); +let snap = this._ctxSnapshot; +if (!snap) { +snap = this._ctxSnapshot = document.createElement("canvas"); +snap.dataset.fcCtxSnapshot = ""; +} +snap.width = this.canvas.width; +snap.height = this.canvas.height; +snap.style.cssText = this.canvas.style.cssText; +snap.style.pointerEvents = "none"; +snap.getContext("2d").drawImage(this.canvas, 0, 0); +this.canvas.before(snap); +this.canvas.style.visibility = "hidden"; +} catch (_err) { +this._dropContextSnapshot(); +} +} +_dropContextSnapshot() { +this.canvas.style.visibility = ""; +if (this._ctxSnapshot) this._ctxSnapshot.remove(); +this._ctxSnapshot = null; +} _recoverContext() { if (this._destroyed || !this._glLost) return; this._ctxRecoveries += 1; @@ -2203,8 +2243,12 @@ return; } this._scheduleViewRequest(this.view, { delay: 0 }); this.draw(); +this._dropContextSnapshot(); } _armContextVisibilityWatch() { +this._listen(this.root, "pointerenter", () => { +if (this._glLost && !this._destroyed) this._recoverContext(); +}); if (typeof IntersectionObserver === "undefined") { this._ctxVisible = true; return; @@ -2245,6 +2289,7 @@ for (const lg of this._legends) lg.style.maxHeight = p.h - 12 + "px"; } this._positionReductionBadges(); this._positionColorbar(); +this._fitModebar(); this._pickDirty = true; this.draw(); this._scheduleViewRequest(); @@ -3336,9 +3381,7 @@ const gl = this.gl; const { x0, x1, y0, y1 } = this.view; gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, this.canvas.width, this.canvas.height); -const bg = this.theme.bg; -if (bg) gl.clearColor(bg[0] * bg[3], bg[1] * bg[3], bg[2] * bg[3], bg[3]); -else gl.clearColor(0, 0, 0, 0); +gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); for (const g of this.gpuTraces) { if (g.tier === "density") { @@ -3570,7 +3613,13 @@ const [vy0, vy1] = this._axisRange(g.yAxis); gl.uniform4f(u("u_view"), vx0 ?? x0, vx1 ?? x1, vy0 ?? y0, vy1 ?? y1); gl.uniform1i(u("u_xmode"), this._axisMode(g.xAxis)); gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis)); -gl.uniform4f(u("u_gridRange"), h.xRange[0], h.xRange[1], h.yRange[0], h.yRange[1]); +const xrev = (vx0 ?? x0) > (vx1 ?? x1); +const yrev = (vy0 ?? y0) > (vy1 ?? y1); +gl.uniform4f( +u("u_gridRange"), +h.xRange[xrev ? 1 : 0], h.xRange[xrev ? 0 : 1], +h.yRange[yrev ? 1 : 0], h.yRange[yrev ? 0 : 1], +); gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style)); gl.uniform1i(u("u_truecolor"), h.truecolor ? 1 : 0); gl.activeTexture(gl.TEXTURE0); @@ -4111,6 +4160,10 @@ this.labels.textContent = ""; this._lastLabelDraw = now; } const p = this.plot; +if (this.theme.bg) { +ctx.fillStyle = cssColor(this.theme.bg); +ctx.fillRect(p.x, p.y, p.w, p.h); +} const xAxis = this._axis("x"); const yAxis = this._axis("y"); const hideX = this._axisTickLabelStrategy(xAxis) === "none"; @@ -4520,8 +4573,12 @@ if (!h || !g._cpuHeatmap) return null; const [x0, x1] = h.xRange; const [y0, y1] = h.yRange; if (dataX < x0 || dataX > x1 || dataY < y0 || dataY > y1) return null; -const col = Math.min(h.w - 1, Math.max(0, Math.floor(((dataX - x0) / (x1 - x0)) * h.w))); -const row = Math.min(h.h - 1, Math.max(0, Math.floor(((dataY - y0) / (y1 - y0)) * h.h))); +const [ax0, ax1] = this._axisRange(g.xAxis) ?? [this.view.x0, this.view.x1]; +const [ay0, ay1] = this._axisRange(g.yAxis) ?? [this.view.y0, this.view.y1]; +const fx = ((ax0 ?? this.view.x0) > (ax1 ?? this.view.x1)) ? (x1 - dataX) : (dataX - x0); +const fy = ((ay0 ?? this.view.y0) > (ay1 ?? this.view.y1)) ? (y1 - dataY) : (dataY - y0); +const col = Math.min(h.w - 1, Math.max(0, Math.floor((fx / (x1 - x0)) * h.w))); +const row = Math.min(h.h - 1, Math.max(0, Math.floor((fy / (y1 - y0)) * h.h))); return { trace: g.trace.id, index: row * h.w + col, g, heatmap: { row, col }, synthetic: true }; } _drawKeepPick() { @@ -4694,6 +4751,115 @@ this._glPrograms = this._progCache; this.gpuTraces = []; } } +const FC_ANNOTATION_SHAPE_STYLE_KEYS = new Set([ +"color", +"label_color", +"width", +"head_size", +"head_style", +"tail_style", +"shaft_width_start", +"shaft_width_end", +"curve", +"angle_a", +"angle_b", +"gap_start", +"gap_end", +"dash", +"span_start", +"span_end", +"size", +"symbol", +"stroke_color", +"stroke_width", +"coordinate_space", +]); +function fcArrowGeometry(x0, y0, x1, y1, style) { +const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : null); +const angleA = num(style.angle_a); +const angleB = num(style.angle_b); +const curve = num(style.curve); +let cx = null; +let cy = null; +if (angleA !== null && angleB !== null) { +const a = (-angleA * Math.PI) / 180; +const b = (-angleB * Math.PI) / 180; +const denom = Math.cos(a) * Math.sin(b) - Math.sin(a) * Math.cos(b); +if (Math.abs(denom) > 1e-6) { +const t = ((x1 - x0) * Math.sin(b) - (y1 - y0) * Math.cos(b)) / denom; +cx = x0 + t * Math.cos(a); +cy = y0 + t * Math.sin(a); +} +} else if (curve) { +const dx = x1 - x0; +const dy = y1 - y0; +cx = (x0 + x1) / 2 + curve * dy; +cy = (y0 + y1) / 2 - curve * dx; +} +const toward = (px, py, qx, qy) => { +const d = Math.hypot(qx - px, qy - py) || 1; +return [(qx - px) / d, (qy - py) / d]; +}; +const t0 = cx === null ? toward(x0, y0, x1, y1) : toward(x0, y0, cx, cy); +const t1 = cx === null ? toward(x1, y1, x0, y0) : toward(x1, y1, cx, cy); +const gapStart = Math.max(0, num(style.gap_start) || 0); +const gapEnd = Math.max(0, num(style.gap_end) || 0); +const span = Math.hypot(x1 - x0, y1 - y0); +const trim = gapStart + gapEnd < span * 0.9; +const p0 = trim ? [x0 + gapStart * t0[0], y0 + gapStart * t0[1]] : [x0, y0]; +const p1 = trim ? [x1 + gapEnd * t1[0], y1 + gapEnd * t1[1]] : [x1, y1]; +const dir1 = cx === null ? toward(p0[0], p0[1], p1[0], p1[1]) : toward(cx, cy, p1[0], p1[1]); +const dir0 = cx === null ? toward(p1[0], p1[1], p0[0], p0[1]) : toward(cx, cy, p0[0], p0[1]); +return { p0, p1, control: cx === null ? null : [cx, cy], dir0, dir1 }; +} +function fcArrowShaftPoints(geom, samples = 24) { +const [x0, y0] = geom.p0; +const [x1, y1] = geom.p1; +if (!geom.control) return [[x0, y0], [x1, y1]]; +const [cx, cy] = geom.control; +const points = []; +for (let i = 0; i <= samples; i++) { +const t = i / samples; +const u = 1 - t; +points.push([u * u * x0 + 2 * u * t * cx + t * t * x1, u * u * y0 + 2 * u * t * cy + t * t * y1]); +} +return points; +} +function fcTrimPolylineEnd(points, trim) { +if (!(trim > 0) || points.length < 2) return points; +const out = points.slice(); +let remaining = trim; +while (out.length >= 2) { +const [ax, ay] = out[out.length - 2]; +const [bx, by] = out[out.length - 1]; +const seg = Math.hypot(bx - ax, by - ay); +if (seg > remaining) { +const t = 1 - remaining / seg; +out[out.length - 1] = [ax + t * (bx - ax), ay + t * (by - ay)]; +return out; +} +remaining -= seg; +out.pop(); +} +return out; +} +function fcTaperPolygon(points, w0, w1) { +const left = []; +const right = []; +const count = points.length; +for (let i = 0; i < count; i++) { +const [px, py] = points[i]; +const [ax, ay] = points[Math.max(0, i - 1)]; +const [bx, by] = points[Math.min(count - 1, i + 1)]; +const d = Math.hypot(bx - ax, by - ay) || 1; +const nx = -(by - ay) / d; +const ny = (bx - ax) / d; +const half = (w0 + (w1 - w0) * (i / Math.max(1, count - 1))) / 2; +left.push([px + half * nx, py + half * ny]); +right.push([px - half * nx, py - half * ny]); +} +return left.concat(right.reverse()); +} Object.assign(ChartView.prototype, { _annotationPaint(style, fallback) { return safeCssPaint(this.root, style && style.color, fallback); @@ -4741,8 +4907,7 @@ ctx.restore(); }, _drawArrowLine(ctx, x0, y0, x1, y1, style) { if (![x0, y0, x1, y1].every(Number.isFinite)) return; -const angle = Math.atan2(y1 - y0, x1 - x0); -const head = Math.max(7, this._styleNumber(style, "head_size", 8)); +const geom = fcArrowGeometry(x0, y0, x1, y1, style); ctx.save(); ctx.globalAlpha = this._styleNumber(style, "opacity", 1); ctx.strokeStyle = this._annotationPaint(style, [0.4, 0.44, 0.52, 1]); @@ -4750,23 +4915,65 @@ ctx.fillStyle = ctx.strokeStyle; ctx.lineWidth = Math.max(0.5, this._styleNumber(style, "width", 1.5)); ctx.setLineDash(Array.isArray(style.dash) ? style.dash : (typeof style.dash === "string" ? style.dash.split(",").map(Number) : [])); +const w0 = Number(style.shaft_width_start); +const w1 = Number(style.shaft_width_end); +const headStyle = style.head_style || "triangle"; +const head = Math.max(4, this._styleNumber(style, "head_size", 8)); +if (Number.isFinite(w0) || Number.isFinite(w1)) { +let points = fcArrowShaftPoints(geom); +if (headStyle === "triangle") { +points = fcTrimPolylineEnd(points, head * Math.cos(Math.PI / 6)); +} +const polygon = fcTaperPolygon( +points, +Number.isFinite(w0) ? w0 : 1, +Number.isFinite(w1) ? w1 : 1 +); +ctx.beginPath(); +ctx.moveTo(polygon[0][0], polygon[0][1]); +for (let i = 1; i < polygon.length; i++) ctx.lineTo(polygon[i][0], polygon[i][1]); +ctx.closePath(); +ctx.fill(); +} else { ctx.beginPath(); -ctx.moveTo(x0, y0); -ctx.lineTo(x1, y1); +ctx.moveTo(geom.p0[0], geom.p0[1]); +if (geom.control) ctx.quadraticCurveTo(geom.control[0], geom.control[1], geom.p1[0], geom.p1[1]); +else ctx.lineTo(geom.p1[0], geom.p1[1]); ctx.stroke(); +} +this._drawArrowEnd(ctx, geom.p1, geom.dir1, headStyle, head); +this._drawArrowEnd(ctx, geom.p0, geom.dir0, style.tail_style || "none", head); +ctx.restore(); +}, +_drawArrowEnd(ctx, point, dir, endStyle, head) { +if (endStyle === "none") return; +const [px, py] = point; +const angle = Math.atan2(dir[1], dir[0]); ctx.beginPath(); -ctx.moveTo(x1, y1); -ctx.lineTo( -x1 - head * Math.cos(angle - Math.PI / 6), -y1 - head * Math.sin(angle - Math.PI / 6) -); -ctx.lineTo( -x1 - head * Math.cos(angle + Math.PI / 6), -y1 - head * Math.sin(angle + Math.PI / 6) -); +if (endStyle === "bar") { +ctx.moveTo(px - (head / 2) * Math.sin(angle), py + (head / 2) * Math.cos(angle)); +ctx.lineTo(px + (head / 2) * Math.sin(angle), py - (head / 2) * Math.cos(angle)); +ctx.stroke(); +return; +} +const wing = (side) => [ +px - head * Math.cos(angle - side * Math.PI / 6), +py - head * Math.sin(angle - side * Math.PI / 6), +]; +const [ax, ay] = wing(1); +const [bx, by] = wing(-1); +if (endStyle === "v") { +ctx.moveTo(ax, ay); +ctx.lineTo(px, py); +ctx.lineTo(bx, by); +ctx.stroke(); +return; +} +ctx.moveTo(px, py); +ctx.lineTo(ax, ay); +ctx.lineTo(bx, by); ctx.closePath(); ctx.fill(); -ctx.restore(); }, _drawAnnotationShapes(ctx) { const annotations = Array.isArray(this.spec.annotations) ? this.spec.annotations : []; @@ -4911,13 +5118,21 @@ d.textContent = text; const dx = Number.isFinite(Number(ann.dx)) ? Number(ann.dx) : 0; const dy = Number.isFinite(Number(ann.dy)) ? Number(ann.dy) : 0; const anchor = ann.anchor === "middle" ? "-50%" : ann.anchor === "end" ? "-100%" : "0"; +const va = style.vertical_align; +const vAnchor = +va === "center" || va === "middle" ? "-50%" : va === "bottom" ? "-100%" : "0"; d.style.cssText = `position:absolute;left:${px + dx}px;top:${py + dy}px;` + -`transform:translate(${anchor},0);pointer-events:none;` + -`white-space:pre-line;text-align:center;`; +`transform:translate(${anchor},${vAnchor});pointer-events:none;` + +`white-space:pre-line;text-align:center;width:max-content;`; this._applySlot(d, "annotation_label"); this._applyClass(d, ann.class_name); -this._applyStyle(d, style); +const labelStyle = {}; +for (const [key, value] of Object.entries(style)) { +if (FC_ANNOTATION_SHAPE_STYLE_KEYS.has(key)) continue; +labelStyle[key] = value; +} +this._applyStyle(d, labelStyle); if (style && (style.label_color || style.color)) { d.style.color = this._annotationLabelPaint(style, this.theme.label); } @@ -5404,8 +5619,19 @@ this._clearSelection(); this._setView(this.view0, { animate: true }); }); root.appendChild(bar); +this._fitModebar(); this._setDragMode(this.dragMode); }, +_fitModebar() { +const bar = this._modebar; +if (!bar) return; +bar.style.top = `${this.plot.y + 4}px`; +bar.style.left = `${this.plot.x + 4}px`; +bar.style.display = "flex"; +const fits = +bar.offsetWidth + 8 <= this.plot.w && bar.offsetHeight + 8 <= this.plot.h; +if (!fits) bar.style.display = "none"; +}, _setDragMode(mode) { this.dragMode = mode; if (this.canvas) this.canvas.dataset.fcDragmode = mode; diff --git a/python/xy/static/standalone.js b/python/xy/static/standalone.js index 49802969..60c81f5c 100644 --- a/python/xy/static/standalone.js +++ b/python/xy/static/standalone.js @@ -1724,6 +1724,14 @@ for (const view of candidates) { if (over <= 0) break; if (view._releaseContext()) over -= 1; } +if (over <= 0) return; +const visible = live +.filter((view) => view._ctxVisible) +.sort((a, b) => (a._ctxSeenSeq || 0) - (b._ctxSeenSeq || 0)); +for (const view of visible) { +if (over <= 0) break; +if (view._releaseContext()) over -= 1; +} }, acquired(requester) { requester._ctxPendingReservation = false; @@ -1779,7 +1787,10 @@ this.fluidH = spec.height === "100%"; const rect = this.fluid || this.fluidH ? el.getBoundingClientRect() : null; const cw = this.fluid ? Math.round(rect.width) || 640 : spec.width; const ch = this.fluidH ? Math.round(rect.height) || 420 : spec.height; -this.size = { w: Math.max(120, cw), h: Math.max(120, ch) }; +this.size = { +w: Math.max(this.fluid ? 120 : 48, cw), +h: Math.max(this.fluidH ? 120 : 48, ch), +}; this._layout(); this._buildDom(el); this.theme = readTheme(this.root); @@ -2145,6 +2156,7 @@ this._contextRecoveryError = null; this.root.dataset.fcContextState = "ready"; this._scheduleViewRequest(this.view, { delay: 0 }); this.draw(); +this._dropContextSnapshot(); this._dispatchChartEvent("context_restored", { loss_count: this._contextLossCount, restore_count: this._contextRestoreCount, @@ -2155,6 +2167,7 @@ _releaseContext() { if (this._destroyed || !this.gl || this._glLost || this.gl.isContextLost()) return false; const ext = this.gl.getExtension("WEBGL_lose_context"); if (!ext) return false; +this._snapshotBeforeRelease(); this._ctxReleasedExt = ext; this._ctxReleases += 1; this._glLost = true; @@ -2164,6 +2177,33 @@ this._raf = null; ext.loseContext(); return true; } +_snapshotBeforeRelease() { +try { +if (this._raf) cancelAnimationFrame(this._raf); +this._raf = null; +this._rafKeepPick = true; +this._drawNow(); +let snap = this._ctxSnapshot; +if (!snap) { +snap = this._ctxSnapshot = document.createElement("canvas"); +snap.dataset.fcCtxSnapshot = ""; +} +snap.width = this.canvas.width; +snap.height = this.canvas.height; +snap.style.cssText = this.canvas.style.cssText; +snap.style.pointerEvents = "none"; +snap.getContext("2d").drawImage(this.canvas, 0, 0); +this.canvas.before(snap); +this.canvas.style.visibility = "hidden"; +} catch (_err) { +this._dropContextSnapshot(); +} +} +_dropContextSnapshot() { +this.canvas.style.visibility = ""; +if (this._ctxSnapshot) this._ctxSnapshot.remove(); +this._ctxSnapshot = null; +} _recoverContext() { if (this._destroyed || !this._glLost) return; this._ctxRecoveries += 1; @@ -2204,8 +2244,12 @@ return; } this._scheduleViewRequest(this.view, { delay: 0 }); this.draw(); +this._dropContextSnapshot(); } _armContextVisibilityWatch() { +this._listen(this.root, "pointerenter", () => { +if (this._glLost && !this._destroyed) this._recoverContext(); +}); if (typeof IntersectionObserver === "undefined") { this._ctxVisible = true; return; @@ -2246,6 +2290,7 @@ for (const lg of this._legends) lg.style.maxHeight = p.h - 12 + "px"; } this._positionReductionBadges(); this._positionColorbar(); +this._fitModebar(); this._pickDirty = true; this.draw(); this._scheduleViewRequest(); @@ -3337,9 +3382,7 @@ const gl = this.gl; const { x0, x1, y0, y1 } = this.view; gl.bindFramebuffer(gl.FRAMEBUFFER, null); gl.viewport(0, 0, this.canvas.width, this.canvas.height); -const bg = this.theme.bg; -if (bg) gl.clearColor(bg[0] * bg[3], bg[1] * bg[3], bg[2] * bg[3], bg[3]); -else gl.clearColor(0, 0, 0, 0); +gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT); for (const g of this.gpuTraces) { if (g.tier === "density") { @@ -3571,7 +3614,13 @@ const [vy0, vy1] = this._axisRange(g.yAxis); gl.uniform4f(u("u_view"), vx0 ?? x0, vx1 ?? x1, vy0 ?? y0, vy1 ?? y1); gl.uniform1i(u("u_xmode"), this._axisMode(g.xAxis)); gl.uniform1i(u("u_ymode"), this._axisMode(g.yAxis)); -gl.uniform4f(u("u_gridRange"), h.xRange[0], h.xRange[1], h.yRange[0], h.yRange[1]); +const xrev = (vx0 ?? x0) > (vx1 ?? x1); +const yrev = (vy0 ?? y0) > (vy1 ?? y1); +gl.uniform4f( +u("u_gridRange"), +h.xRange[xrev ? 1 : 0], h.xRange[xrev ? 0 : 1], +h.yRange[yrev ? 1 : 0], h.yRange[yrev ? 0 : 1], +); gl.uniform1f(u("u_opacity"), this._fillOpacity(g.trace.style)); gl.uniform1i(u("u_truecolor"), h.truecolor ? 1 : 0); gl.activeTexture(gl.TEXTURE0); @@ -4112,6 +4161,10 @@ this.labels.textContent = ""; this._lastLabelDraw = now; } const p = this.plot; +if (this.theme.bg) { +ctx.fillStyle = cssColor(this.theme.bg); +ctx.fillRect(p.x, p.y, p.w, p.h); +} const xAxis = this._axis("x"); const yAxis = this._axis("y"); const hideX = this._axisTickLabelStrategy(xAxis) === "none"; @@ -4521,8 +4574,12 @@ if (!h || !g._cpuHeatmap) return null; const [x0, x1] = h.xRange; const [y0, y1] = h.yRange; if (dataX < x0 || dataX > x1 || dataY < y0 || dataY > y1) return null; -const col = Math.min(h.w - 1, Math.max(0, Math.floor(((dataX - x0) / (x1 - x0)) * h.w))); -const row = Math.min(h.h - 1, Math.max(0, Math.floor(((dataY - y0) / (y1 - y0)) * h.h))); +const [ax0, ax1] = this._axisRange(g.xAxis) ?? [this.view.x0, this.view.x1]; +const [ay0, ay1] = this._axisRange(g.yAxis) ?? [this.view.y0, this.view.y1]; +const fx = ((ax0 ?? this.view.x0) > (ax1 ?? this.view.x1)) ? (x1 - dataX) : (dataX - x0); +const fy = ((ay0 ?? this.view.y0) > (ay1 ?? this.view.y1)) ? (y1 - dataY) : (dataY - y0); +const col = Math.min(h.w - 1, Math.max(0, Math.floor((fx / (x1 - x0)) * h.w))); +const row = Math.min(h.h - 1, Math.max(0, Math.floor((fy / (y1 - y0)) * h.h))); return { trace: g.trace.id, index: row * h.w + col, g, heatmap: { row, col }, synthetic: true }; } _drawKeepPick() { @@ -4695,6 +4752,115 @@ this._glPrograms = this._progCache; this.gpuTraces = []; } } +const FC_ANNOTATION_SHAPE_STYLE_KEYS = new Set([ +"color", +"label_color", +"width", +"head_size", +"head_style", +"tail_style", +"shaft_width_start", +"shaft_width_end", +"curve", +"angle_a", +"angle_b", +"gap_start", +"gap_end", +"dash", +"span_start", +"span_end", +"size", +"symbol", +"stroke_color", +"stroke_width", +"coordinate_space", +]); +function fcArrowGeometry(x0, y0, x1, y1, style) { +const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : null); +const angleA = num(style.angle_a); +const angleB = num(style.angle_b); +const curve = num(style.curve); +let cx = null; +let cy = null; +if (angleA !== null && angleB !== null) { +const a = (-angleA * Math.PI) / 180; +const b = (-angleB * Math.PI) / 180; +const denom = Math.cos(a) * Math.sin(b) - Math.sin(a) * Math.cos(b); +if (Math.abs(denom) > 1e-6) { +const t = ((x1 - x0) * Math.sin(b) - (y1 - y0) * Math.cos(b)) / denom; +cx = x0 + t * Math.cos(a); +cy = y0 + t * Math.sin(a); +} +} else if (curve) { +const dx = x1 - x0; +const dy = y1 - y0; +cx = (x0 + x1) / 2 + curve * dy; +cy = (y0 + y1) / 2 - curve * dx; +} +const toward = (px, py, qx, qy) => { +const d = Math.hypot(qx - px, qy - py) || 1; +return [(qx - px) / d, (qy - py) / d]; +}; +const t0 = cx === null ? toward(x0, y0, x1, y1) : toward(x0, y0, cx, cy); +const t1 = cx === null ? toward(x1, y1, x0, y0) : toward(x1, y1, cx, cy); +const gapStart = Math.max(0, num(style.gap_start) || 0); +const gapEnd = Math.max(0, num(style.gap_end) || 0); +const span = Math.hypot(x1 - x0, y1 - y0); +const trim = gapStart + gapEnd < span * 0.9; +const p0 = trim ? [x0 + gapStart * t0[0], y0 + gapStart * t0[1]] : [x0, y0]; +const p1 = trim ? [x1 + gapEnd * t1[0], y1 + gapEnd * t1[1]] : [x1, y1]; +const dir1 = cx === null ? toward(p0[0], p0[1], p1[0], p1[1]) : toward(cx, cy, p1[0], p1[1]); +const dir0 = cx === null ? toward(p1[0], p1[1], p0[0], p0[1]) : toward(cx, cy, p0[0], p0[1]); +return { p0, p1, control: cx === null ? null : [cx, cy], dir0, dir1 }; +} +function fcArrowShaftPoints(geom, samples = 24) { +const [x0, y0] = geom.p0; +const [x1, y1] = geom.p1; +if (!geom.control) return [[x0, y0], [x1, y1]]; +const [cx, cy] = geom.control; +const points = []; +for (let i = 0; i <= samples; i++) { +const t = i / samples; +const u = 1 - t; +points.push([u * u * x0 + 2 * u * t * cx + t * t * x1, u * u * y0 + 2 * u * t * cy + t * t * y1]); +} +return points; +} +function fcTrimPolylineEnd(points, trim) { +if (!(trim > 0) || points.length < 2) return points; +const out = points.slice(); +let remaining = trim; +while (out.length >= 2) { +const [ax, ay] = out[out.length - 2]; +const [bx, by] = out[out.length - 1]; +const seg = Math.hypot(bx - ax, by - ay); +if (seg > remaining) { +const t = 1 - remaining / seg; +out[out.length - 1] = [ax + t * (bx - ax), ay + t * (by - ay)]; +return out; +} +remaining -= seg; +out.pop(); +} +return out; +} +function fcTaperPolygon(points, w0, w1) { +const left = []; +const right = []; +const count = points.length; +for (let i = 0; i < count; i++) { +const [px, py] = points[i]; +const [ax, ay] = points[Math.max(0, i - 1)]; +const [bx, by] = points[Math.min(count - 1, i + 1)]; +const d = Math.hypot(bx - ax, by - ay) || 1; +const nx = -(by - ay) / d; +const ny = (bx - ax) / d; +const half = (w0 + (w1 - w0) * (i / Math.max(1, count - 1))) / 2; +left.push([px + half * nx, py + half * ny]); +right.push([px - half * nx, py - half * ny]); +} +return left.concat(right.reverse()); +} Object.assign(ChartView.prototype, { _annotationPaint(style, fallback) { return safeCssPaint(this.root, style && style.color, fallback); @@ -4742,8 +4908,7 @@ ctx.restore(); }, _drawArrowLine(ctx, x0, y0, x1, y1, style) { if (![x0, y0, x1, y1].every(Number.isFinite)) return; -const angle = Math.atan2(y1 - y0, x1 - x0); -const head = Math.max(7, this._styleNumber(style, "head_size", 8)); +const geom = fcArrowGeometry(x0, y0, x1, y1, style); ctx.save(); ctx.globalAlpha = this._styleNumber(style, "opacity", 1); ctx.strokeStyle = this._annotationPaint(style, [0.4, 0.44, 0.52, 1]); @@ -4751,23 +4916,65 @@ ctx.fillStyle = ctx.strokeStyle; ctx.lineWidth = Math.max(0.5, this._styleNumber(style, "width", 1.5)); ctx.setLineDash(Array.isArray(style.dash) ? style.dash : (typeof style.dash === "string" ? style.dash.split(",").map(Number) : [])); +const w0 = Number(style.shaft_width_start); +const w1 = Number(style.shaft_width_end); +const headStyle = style.head_style || "triangle"; +const head = Math.max(4, this._styleNumber(style, "head_size", 8)); +if (Number.isFinite(w0) || Number.isFinite(w1)) { +let points = fcArrowShaftPoints(geom); +if (headStyle === "triangle") { +points = fcTrimPolylineEnd(points, head * Math.cos(Math.PI / 6)); +} +const polygon = fcTaperPolygon( +points, +Number.isFinite(w0) ? w0 : 1, +Number.isFinite(w1) ? w1 : 1 +); +ctx.beginPath(); +ctx.moveTo(polygon[0][0], polygon[0][1]); +for (let i = 1; i < polygon.length; i++) ctx.lineTo(polygon[i][0], polygon[i][1]); +ctx.closePath(); +ctx.fill(); +} else { ctx.beginPath(); -ctx.moveTo(x0, y0); -ctx.lineTo(x1, y1); +ctx.moveTo(geom.p0[0], geom.p0[1]); +if (geom.control) ctx.quadraticCurveTo(geom.control[0], geom.control[1], geom.p1[0], geom.p1[1]); +else ctx.lineTo(geom.p1[0], geom.p1[1]); ctx.stroke(); +} +this._drawArrowEnd(ctx, geom.p1, geom.dir1, headStyle, head); +this._drawArrowEnd(ctx, geom.p0, geom.dir0, style.tail_style || "none", head); +ctx.restore(); +}, +_drawArrowEnd(ctx, point, dir, endStyle, head) { +if (endStyle === "none") return; +const [px, py] = point; +const angle = Math.atan2(dir[1], dir[0]); ctx.beginPath(); -ctx.moveTo(x1, y1); -ctx.lineTo( -x1 - head * Math.cos(angle - Math.PI / 6), -y1 - head * Math.sin(angle - Math.PI / 6) -); -ctx.lineTo( -x1 - head * Math.cos(angle + Math.PI / 6), -y1 - head * Math.sin(angle + Math.PI / 6) -); +if (endStyle === "bar") { +ctx.moveTo(px - (head / 2) * Math.sin(angle), py + (head / 2) * Math.cos(angle)); +ctx.lineTo(px + (head / 2) * Math.sin(angle), py - (head / 2) * Math.cos(angle)); +ctx.stroke(); +return; +} +const wing = (side) => [ +px - head * Math.cos(angle - side * Math.PI / 6), +py - head * Math.sin(angle - side * Math.PI / 6), +]; +const [ax, ay] = wing(1); +const [bx, by] = wing(-1); +if (endStyle === "v") { +ctx.moveTo(ax, ay); +ctx.lineTo(px, py); +ctx.lineTo(bx, by); +ctx.stroke(); +return; +} +ctx.moveTo(px, py); +ctx.lineTo(ax, ay); +ctx.lineTo(bx, by); ctx.closePath(); ctx.fill(); -ctx.restore(); }, _drawAnnotationShapes(ctx) { const annotations = Array.isArray(this.spec.annotations) ? this.spec.annotations : []; @@ -4912,13 +5119,21 @@ d.textContent = text; const dx = Number.isFinite(Number(ann.dx)) ? Number(ann.dx) : 0; const dy = Number.isFinite(Number(ann.dy)) ? Number(ann.dy) : 0; const anchor = ann.anchor === "middle" ? "-50%" : ann.anchor === "end" ? "-100%" : "0"; +const va = style.vertical_align; +const vAnchor = +va === "center" || va === "middle" ? "-50%" : va === "bottom" ? "-100%" : "0"; d.style.cssText = `position:absolute;left:${px + dx}px;top:${py + dy}px;` + -`transform:translate(${anchor},0);pointer-events:none;` + -`white-space:pre-line;text-align:center;`; +`transform:translate(${anchor},${vAnchor});pointer-events:none;` + +`white-space:pre-line;text-align:center;width:max-content;`; this._applySlot(d, "annotation_label"); this._applyClass(d, ann.class_name); -this._applyStyle(d, style); +const labelStyle = {}; +for (const [key, value] of Object.entries(style)) { +if (FC_ANNOTATION_SHAPE_STYLE_KEYS.has(key)) continue; +labelStyle[key] = value; +} +this._applyStyle(d, labelStyle); if (style && (style.label_color || style.color)) { d.style.color = this._annotationLabelPaint(style, this.theme.label); } @@ -5405,8 +5620,19 @@ this._clearSelection(); this._setView(this.view0, { animate: true }); }); root.appendChild(bar); +this._fitModebar(); this._setDragMode(this.dragMode); }, +_fitModebar() { +const bar = this._modebar; +if (!bar) return; +bar.style.top = `${this.plot.y + 4}px`; +bar.style.left = `${this.plot.x + 4}px`; +bar.style.display = "flex"; +const fits = +bar.offsetWidth + 8 <= this.plot.w && bar.offsetHeight + 8 <= this.plot.h; +if (!fits) bar.style.display = "none"; +}, _setDragMode(mode) { this.dragMode = mode; if (this.canvas) this.canvas.dataset.fcDragmode = mode; diff --git a/tests/pyplot/test_axes_layout.py b/tests/pyplot/test_axes_layout.py index 325add97..3e19c127 100644 --- a/tests/pyplot/test_axes_layout.py +++ b/tests/pyplot/test_axes_layout.py @@ -179,6 +179,10 @@ def test_tick_params_records_supported_style_and_rejects_unknown() -> None: "tick_length": pytest.approx(7.0 * 100.0 / 72.0), "tick_width": pytest.approx(2.0 * 100.0 / 72.0), "tick_direction": "in", + # Always explicit (10 pt font.size at dpi 100): the render client and + # static exporters otherwise fall back to their own 11 px default. + "tick_label_size": pytest.approx(10.0 * 100.0 / 72.0), + "label_size": pytest.approx(10.0 * 100.0 / 72.0), } with pytest.raises(TypeError, match="unsupported keyword"): diff --git a/tests/pyplot/test_layout_noops.py b/tests/pyplot/test_layout_noops.py index 4d82799f..8bcd2d80 100644 --- a/tests/pyplot/test_layout_noops.py +++ b/tests/pyplot/test_layout_noops.py @@ -22,12 +22,29 @@ def test_tight_layout_records_validated_noop_contract(): fig.tight_layout(foo=1) -def test_subplots_adjust_rejects_spacing_it_cannot_render(): +def test_subplots_adjust_records_supported_spacing_values(): fig, _ = plt.subplots(2, 2) - with pytest.raises(NotImplementedError, match="subplots_adjust"): - fig.subplots_adjust(left=0.1, wspace=0.3) + fig.subplots_adjust(left=0.1, wspace=0.3, hspace=0.4) + assert fig._subplot_adjust == {"left": 0.1, "wspace": 0.3, "hspace": 0.4} + + rects = fig._effective_rects() + assert rects is not None and len(rects) == 4 + top_left, top_right, bottom_left, _ = rects + assert top_left[0] == pytest.approx(0.1) # adjusted frame left + assert bottom_left[1] == pytest.approx(0.11) # frame bottom keeps its default + # wspace opens a horizontal gap between the columns... + assert top_right[0] > top_left[0] + top_left[2] + # ...and hspace a vertical gap between the rows. + assert top_left[1] > bottom_left[1] + bottom_left[3] + with pytest.raises(TypeError): fig.subplots_adjust(foo=1) + with pytest.raises(ValueError, match="left"): + fig.subplots_adjust(left=0.95, right=0.2) + with pytest.raises(ValueError, match="bottom"): + fig.subplots_adjust(bottom=0.9, top=0.3) + # rejected updates must not leak into the recorded frame + assert fig._subplot_adjust == {"left": 0.1, "wspace": 0.3, "hspace": 0.4} def test_autofmt_xdate_rotates_x_tick_labels_on_all_axes(): diff --git a/tests/pyplot/test_layout_text_parity_fixes.py b/tests/pyplot/test_layout_text_parity_fixes.py index 30f810d6..6233583d 100644 --- a/tests/pyplot/test_layout_text_parity_fixes.py +++ b/tests/pyplot/test_layout_text_parity_fixes.py @@ -55,6 +55,47 @@ def test_inset_rect_survives_next_to_a_default_axes() -> None: assert out.getvalue()[:4] == b"\x89PNG" +def test_free_form_axes_render_at_their_rects_in_html() -> None: + ax1 = plt.axes() + ax2 = plt.axes([0.65, 0.65, 0.2, 0.2]) + ax1.plot([0, 1], [0, 1]) + ax2.plot([0, 1], [1, 0]) + html = plt.gcf()._to_html() + placements = re.findall(r'style="position:absolute;left:(-?\d+)px;top:(-?\d+)px', html) + assert [(int(x), int(y)) for x, y in placements] == [(18, 48), (370, 66)] + # a fixed-size canvas replaces the side-by-side CSS grid + assert "position: relative; width: 640px; height: 480px" in html + assert "display: grid" not in html + + +def test_add_axes_rects_stack_vertically_in_html() -> None: + fig = plt.figure() + ax1 = fig.add_axes([0.1, 0.5, 0.8, 0.4], xticklabels=[], ylim=(-1.2, 1.2)) + ax2 = fig.add_axes([0.1, 0.1, 0.8, 0.4], ylim=(-1.2, 1.2)) + x = np.linspace(0, 10) + ax1.plot(np.sin(x)) + ax2.plot(np.cos(x)) + placements = re.findall( + r'style="position:absolute;left:(-?\d+)px;top:(-?\d+)px', fig._to_html() + ) + assert len(placements) == 2 + (x1, y1), (x2, y2) = [(int(x), int(y)) for x, y in placements] + assert x1 == x2 # shared left edge + assert y1 < y2 # the rect with the larger bottom renders as the upper panel + + +def test_subplots_adjust_positions_grid_panels_in_every_exporter() -> None: + fig = plt.figure() + fig.subplots_adjust(hspace=0.4, wspace=0.4) + for i in range(1, 7): + ax = fig.add_subplot(2, 3, i) + ax.text(0.5, 0.5, str((2, 3, i)), fontsize=18, ha="center") + html = fig._to_html() + assert len(re.findall(r'style="position:absolute;left:', html)) == 6 + assert _png_pixels(fig).shape[:2] == (960, 1280) # the full 640x480 canvas at 2x + assert _svg(fig).count("]*>([^<]+)", _svg())) == {"0.0", "0.4", "0.8"} + + +def test_auto_locator_density_adapts_to_panel_size(): + # matplotlib's AutoLocator budgets ticks by axes size: tiny panels get + # two intervals, so an explicit AutoLocator must match the default axes. + fig, axs = plt.subplots(4, 4, sharex=True, sharey=True) + for axi in axs.flat: + axi.xaxis.set_major_locator(plt.AutoLocator()) + axi.yaxis.set_major_locator(plt.AutoLocator()) + assert set(re.findall(r"]*>([^<]+)", _svg())) == {"0.0", "0.5", "1.0"} + + def test_func_formatter_labels_reach_the_export(): fig, ax = plt.subplots() ax.plot([0, 4], [0, 4]) @@ -417,3 +455,250 @@ def test_period_values_plot_as_timestamps(): (line,) = ax.plot(pd.period_range("2012-01", periods=5, freq="M"), np.arange(5)) assert np.issubdtype(np.asarray(line.get_xdata()).dtype, np.datetime64) _png() + + +# -- pandas dynamic timeseries + xy.pyplot.dates (PDSH 04.09) ---------------- + + +def _ms(stamp: str) -> float: + return float(np.datetime64(stamp, "ms").astype(np.int64)) + + +def test_pandas_datetime_series_plot_completes(): + pd = pytest.importorskip("pandas") + index = pd.date_range("2012-01-01", periods=120, freq="D") + series = pd.Series(np.linspace(4000.0, 5000.0, 120), index=index) + fig, ax = plt.subplots() + series.plot(ax=ax) # pandas' ts path: get_xdata(orig=False) + set_xlim + lo, hi = ax.get_xlim() + assert lo == _ms("2012-01-01") and hi == _ms("2012-04-29") + _png() + + +def test_get_xdata_orig_false_is_ms_since_epoch(): + fig, ax = plt.subplots() + x = np.asarray(["2012-01-01", "NaT", "2012-01-03"], dtype="datetime64[ns]") + (line,) = ax.plot(x, [1.0, 2.0, 3.0]) + assert np.issubdtype(np.asarray(line.get_xdata()).dtype, np.datetime64) + converted = np.asarray(line.get_xdata(orig=False)) + assert converted.dtype == np.float64 + assert converted[0] == _ms("2012-01-01") and np.isnan(converted[1]) + assert np.asarray(line.get_ydata(orig=False)).dtype == np.float64 + + +def test_month_locator_ticks_month_starts_in_ms(): + locator = plt.dates.MonthLocator() + ticks = locator.tick_values(_ms("2012-01-15"), _ms("2012-04-20")) + assert list(ticks) == [_ms("2012-02-01"), _ms("2012-03-01"), _ms("2012-04-01")] + mid = plt.dates.MonthLocator(bymonthday=15).tick_values(_ms("2012-01-01"), _ms("2012-03-01")) + assert list(mid) == [_ms("2012-01-15"), _ms("2012-02-15")] + + +def test_date_formatter_formats_ms_values(): + assert plt.dates.DateFormatter("%b %d")(_ms("2012-11-25")) == "Nov 25" + + +def test_labeled_minor_ticker_pair_is_promoted_when_majors_are_blank(): + fig, ax = plt.subplots() + x = np.arange("2012-01-01", "2012-07-01", dtype="datetime64[D]") + ax.plot(x, np.linspace(0.0, 1.0, len(x))) + ax.xaxis.set_major_locator(plt.dates.MonthLocator()) + ax.xaxis.set_minor_locator(plt.dates.MonthLocator(bymonthday=15)) + ax.xaxis.set_major_formatter(plt.NullFormatter()) + ax.xaxis.set_minor_formatter(plt.dates.DateFormatter("%b")) + svg = _svg() + assert "Feb" in svg and "May" in svg + + +def test_pandas_period_ordinal_tickers_are_ignored(): + pd = pytest.importorskip("pandas") + converter = pytest.importorskip("pandas.plotting._matplotlib.converter") + index = pd.date_range("2012-01-01", periods=30, freq="D") + fig, ax = plt.subplots() + ax.plot(index.values, np.arange(30.0)) + ax.xaxis.set_major_locator( + converter.TimeSeries_DateLocator(index.to_period("D").freq, plot_obj=None) + ) + ax.xaxis.set_major_formatter( + converter.TimeSeries_DateFormatter(index.to_period("D").freq, plot_obj=None) + ) + assert isinstance(ax.xaxis.get_major_locator(), plt.AutoLocator) + assert isinstance(ax.xaxis.get_major_formatter(), plt.ScalarFormatter) + _png() + + +def test_axis_proxy_majorticklabel_handles(): + fig, ax = plt.subplots() + ax.plot([0.0, 4.0], [0.0, 4.0]) + labels = ax.xaxis.get_majorticklabels() + assert labels and all(hasattr(label, "set_rotation") for label in labels) + assert ax.xaxis.get_minorticklabels() == [] + + +def test_figure_get_axes_matches_axes_property(): + fig, axes = plt.subplots(2, 2) + assert fig.get_axes() == list(axes.ravel()) + + +def test_annotate_accepts_size_alias(): + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + ax.annotate("peak", xy=(0.5, 0.5), size=13) + assert "peak" in _svg() + + +def test_text_date_string_coordinates_on_a_date_axis(): + fig, ax = plt.subplots() + x = np.arange("2012-01-01", "2012-12-31", dtype="datetime64[D]") + ax.plot(x, np.linspace(3600.0, 5400.0, len(x))) + ax.text("2012-1-1", 3950, "New Year's Day", color="gray") # unpadded, like PDSH + ax.text("2012-11-25", 4450, "Thanksgiving", ha="center") + svg = _svg() + assert "New Year" in svg and "Thanksgiving" in svg + + +def test_text_string_coordinates_stay_categorical_on_category_axes(): + fig, ax = plt.subplots() + ax.bar(["a", "b", "c"], [1.0, 3.0, 2.0]) + ax.text("b", 3.1, "peak") + assert "peak" in _svg() + + +def test_subplots_facecolor_reaches_figure_and_notebook_display(): + fig, ax = plt.subplots(facecolor="lightgray") + ax.plot([0.0, 1.0], [0.0, 1.0]) + assert fig.get_facecolor() == "lightgray" + # The display document paints its own opaque body; the figure patch is a + # later same-specificity body background override in the head. + assert "body{background:lightgray}" in fig._repr_html_() + _png(fig) + + +# -- annotate arrows and boxes (PDSH 04.09 births cell) ---------------------- + + +def _annotation_specs(ax): + return ax._build_chart(640, 480).figure()._annotation_specs() + + +def test_offset_point_annotate_with_arrowprops_becomes_a_callout(): + fig, ax = plt.subplots() + ax.plot([0.0, 10.0], [0.0, 10.0]) + ax.annotate( + "peak", + xy=(5, 5), + xytext=(30, 30), + textcoords="offset points", + arrowprops=dict(arrowstyle="->", connectionstyle="arc3,rad=-0.2"), + ) + (callout,) = [a for a in _annotation_specs(ax) if a["kind"] == "callout"] + assert callout["text"] == "peak" + assert callout["style"]["head_style"] == "v" # "->" is an open stroke head + assert callout["style"]["curve"] == pytest.approx(-0.2) # arc3 rad + assert callout["style"]["gap_start"] > 0 # clears the text patch + # Offset points go up; pixel offsets go down. + assert callout["dx"] > 0 and callout["dy"] < 0 + + +def test_arrowstyle_shapes_map_to_engine_arrow_styles(): + fig, ax = plt.subplots() + ax.plot([0.0, 10.0], [0.0, 10.0]) + ax.annotate( + "", xy=(1, 1), xytext=(2, 2), arrowprops={"arrowstyle": "|-|,widthA=0.2,widthB=0.2"} + ) + ax.annotate("w", xy=(3, 3), xytext=(4, 4), arrowprops=dict(arrowstyle="wedge,tail_width=0.5")) + ax.annotate( + "f", + xy=(5, 5), + xytext=(6, 6), + arrowprops=dict( + arrowstyle="fancy", fc="0.6", ec="none", connectionstyle="angle3,angleA=0,angleB=-90" + ), + ) + bar, wedge, fancy = [a for a in _annotation_specs(ax) if a["kind"] == "arrow"] + assert bar["style"]["head_style"] == "bar" and bar["style"]["tail_style"] == "bar" + assert wedge["style"]["head_style"] == "none" # the wedge tip IS the pointer + assert wedge["style"]["shaft_width_start"] > wedge["style"]["shaft_width_end"] + assert fancy["style"]["shaft_width_end"] > fancy["style"]["shaft_width_start"] + assert fancy["style"]["angle_a"] == 0.0 and fancy["style"]["angle_b"] == -90.0 + assert fancy["style"]["color"] == "rgb(153,153,153)" # fc="0.6" shorthand + + +def test_annotate_date_string_endpoints_draw_a_data_space_arrow(): + fig, ax = plt.subplots() + x = np.arange("2012-08-01", "2012-10-01", dtype="datetime64[D]") + ax.plot(x, np.linspace(4000.0, 5000.0, len(x))) + ax.annotate( + "", + xy=("2012-9-1", 4850), + xytext=("2012-9-7", 4850), + xycoords="data", + textcoords="data", + arrowprops={"arrowstyle": "|-|,widthA=0.2,widthB=0.2"}, + ) + (arrow,) = [a for a in _annotation_specs(ax) if a["kind"] == "arrow"] + assert arrow["x0"] == pytest.approx(_ms("2012-09-07")) + assert arrow["x1"] == pytest.approx(_ms("2012-09-01")) + assert arrow["y0"] == arrow["y1"] == 4850.0 + + +def test_annotate_bbox_becomes_label_box_styles(): + fig, ax = plt.subplots() + ax.plot([0.0, 1.0], [0.0, 1.0]) + ax.annotate( + "Thanksgiving", + xy=(0.5, 0.5), + xytext=(-40, -30), + textcoords="offset points", + bbox=dict(boxstyle="round4,pad=.5", fc="0.9"), + arrowprops=dict(arrowstyle="->"), + ) + (callout,) = [a for a in _annotation_specs(ax) if a["kind"] == "callout"] + style = callout["style"] + assert style["background"] == "rgb(230,230,230)" # fc="0.9" gray shorthand + assert style["border"].endswith("black") and style["border_radius"] == 8.0 + assert style["padding"].startswith("6.94") # pad=.5 of the 13.9px font + + +def test_annotate_arrowprops_alpha_dims_only_the_arrow(): + fig, ax = plt.subplots() + ax.plot([0.0, 1.0], [0.0, 1.0]) + ax.annotate( + "Christmas", + xy=(0.9, 0.1), + xytext=(-30, 0), + textcoords="offset points", + arrowprops=dict(arrowstyle="wedge,tail_width=0.5", alpha=0.1), + ) + (callout,) = [a for a in _annotation_specs(ax) if a["kind"] == "callout"] + assert callout["style"]["color"] == "rgba(0,0,0,0.1)" + assert callout["style"]["label_color"] == "black" # the text stays opaque + + +def test_callout_arrows_reach_static_exports(): + fig, ax = plt.subplots() + ax.plot([0.0, 10.0], [0.0, 10.0]) + ax.annotate( + "peak", + xy=(5, 5), + xytext=(30, 30), + textcoords="offset points", + arrowprops=dict(arrowstyle="->"), + ) + svg = _svg() + assert "peak" in svg and "' in svg.getvalue() html_out = io.BytesIO() fig.savefig(html_out, format="html", facecolor="red") - assert b"background-color:red" in html_out.getvalue() + # A head-level body override: the document body paints its own opaque + # background, so a wrapper div around the document would never show. + assert b"" in html_out.getvalue() with pytest.raises(NotImplementedError): fig.savefig(io.BytesIO(), format="html", metadata={"Title": "x"}) with pytest.raises(ValueError, match="Latin-1"): diff --git a/tests/pyplot/test_state_and_figs.py b/tests/pyplot/test_state_and_figs.py index 3048a08c..62bbeab1 100644 --- a/tests/pyplot/test_state_and_figs.py +++ b/tests/pyplot/test_state_and_figs.py @@ -113,12 +113,15 @@ def test_grid_html_has_panels() -> None: ax.plot([0, 1], [i, i + 1]) html = plt.gcf()._to_html() assert html.count('class="fc-panel"') == 4 - assert html.count('style="width:400px;height:300px"') == 4 - assert html.count('loading="lazy"') == 4 - assert "__xyPyplotPanelGovernorV1" in html - assert "frame.srcdoc = blank" in html - assert "frame.srcdoc = state.source" in html - assert "const root = panelGrid || document" in html + # Multi-panel grids are absolutely placed on a figsize canvas at their + # matplotlib gridspec rects. + assert html.count("position:absolute") >= 4 + # One document, one client: every panel hydrates through the same WebGL + # context governor (panel-per-iframe rendered only the first ~12 panels). + assert html.count("xy.renderStandalone(") == 1 # single hydrate loop + assert '"spec":' in html and '"chunks":' in html + assert " None: diff --git a/tests/pyplot/test_toolbar_controls.py b/tests/pyplot/test_toolbar_controls.py new file mode 100644 index 00000000..69872e80 --- /dev/null +++ b/tests/pyplot/test_toolbar_controls.py @@ -0,0 +1,68 @@ +"""Interactive modebar controls: off by default (Matplotlib's inline backend +shows no toolbar), opt-in via the figure/subplots ``toolbar`` kwarg or +``rcParams["toolbar"]``; dense grids size the notebook iframe to real content.""" + +from __future__ import annotations + +import numpy as np + +import xy.pyplot as plt + + +def _spec(ax): + spec, _blob = ax._build_chart(640, 480).figure().build_payload() + return spec + + +def test_controls_hidden_by_default_for_plot_subplots_and_imshow() -> None: + _fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + assert _spec(ax).get("show_modebar", True) is False + + plt.close("all") + _fig, axes = plt.subplots(2, 2) + axes[0, 0].imshow(np.zeros((4, 4))) + assert all(_spec(axi).get("show_modebar", True) is False for axi in axes.ravel()) + + +def test_toolbar_kwarg_enables_controls() -> None: + _fig, ax = plt.subplots(toolbar=True) + ax.plot([0, 1], [0, 1]) + assert "show_modebar" not in _spec(ax) # spec omits the True default + + plt.close("all") + fig = plt.figure(toolbar=True) + ax = fig.add_subplot(111) + ax.plot([0, 1], [0, 1]) + assert "show_modebar" not in _spec(ax) + + +def test_rc_toolbar_enables_controls_and_kwarg_wins_over_rc() -> None: + plt.rcParams["toolbar"] = "toolbar2" + _fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1]) + assert "show_modebar" not in _spec(ax) + + plt.close("all") + _fig, ax = plt.subplots(toolbar=False) # kwarg overrides the rc opt-in + ax.plot([0, 1], [0, 1]) + assert _spec(ax).get("show_modebar", True) is False + + +def test_notebook_iframe_matches_figsize_for_dense_grids() -> None: + fig, axes = plt.subplots(8, 8, figsize=(6, 6)) + for axi in axes.ravel(): + axi.imshow(np.zeros((8, 8)), cmap="binary") + axi.set(xticks=[], yticks=[]) + _doc, width, height = fig._to_notebook_html() + # Multi-panel grids place plot boxes at their matplotlib gridspec rects on + # a figsize canvas — the old CSS-grid path floored panels at 120 px and + # blew this figure up to ~1000 px of scrollbars. + assert (width, height) == (600, 600) + + +def test_notebook_iframe_matches_figsize_with_suptitle() -> None: + fig, _axes = plt.subplots(2, 3, figsize=(6, 4)) + fig.suptitle("grid") + _doc, width, height = fig._to_notebook_html() + assert (width, height) == (600, 400)