diff --git a/js/src/50_chartview.ts b/js/src/50_chartview.ts index c9fb3fc2..943ff7e4 100644 --- a/js/src/50_chartview.ts +++ b/js/src/50_chartview.ts @@ -1906,7 +1906,10 @@ export class ChartView { const domain = cb.domain || [0, 1]; const lo = Number(domain[0]), hi = Number(domain[1]); const span = hi - lo || 1; - const tickResult = linearTicks(lo, hi, 8); + const shrink = Math.max(0.01, Math.min(1, Number(cb.shrink) || 1)); + const barLength = (horizontal ? this.plot.w : this.plot.h) * shrink; + const tickTarget = Math.max(2, Math.min(8, Math.floor(Math.max(0, barLength) / 48) + 1)); + const tickResult = linearTicks(lo, hi, tickTarget); const hasExplicitTicks = Array.isArray(cb.ticks); const tickValues = hasExplicitTicks ? cb.ticks : tickResult.ticks; const tickStep = tickResult.step; @@ -1922,6 +1925,25 @@ export class ChartView { this._applySlot(tick, "colorbar_tick"); box.appendChild(tick); } + if (cb.minor_ticks) { + const orderedTicks = [...tickValues] + .map(Number) + .filter(Number.isFinite) + .sort((a, b) => a - b); + for (let index = 0; index + 1 < orderedTicks.length; index++) { + const left = orderedTicks[index], right = orderedTicks[index + 1]; + for (let step = 1; step < 5; step++) { + const value = left + (right - left) * step / 5; + const fraction = (value - lo) / span; + const tick = document.createElement("i"); + tick.dataset.xyColorbarMinor = "true"; + tick.style.cssText = horizontal + ? `position:absolute;left:${100 * fraction}%;top:${COLORBAR_THICKNESS}px;height:3px;border-left:1px solid currentColor;` + : `position:absolute;left:${COLORBAR_THICKNESS}px;top:${100 * (1 - fraction)}%;width:3px;border-top:1px solid currentColor;`; + box.appendChild(tick); + } + } + } if (cb.label) { const label = document.createElement("span"); label.textContent = String(cb.label); @@ -1940,19 +1962,24 @@ export class ChartView { _positionColorbar() { if (!this._colorbar) return; + const cb = this.spec.colorbar || {}; const horizontal = this._colorbarHorizontal; const compactVertical = !horizontal && this._compactVerticalColorbar; const gap = compactVertical ? COMPACT_COLORBAR_GAP : COLORBAR_GAP; + const shrink = Math.max(0.01, Math.min(1, Number(cb.shrink) || 1)); + const anchor = Array.isArray(cb.anchor) ? cb.anchor : [0.5, 0.5]; + const barWidth = this.plot.w * shrink; + const barHeight = this.plot.h * shrink; this._colorbar.style.left = (horizontal - ? this.plot.x + ? this.plot.x + (this.plot.w - barWidth) * Number(anchor[0] ?? 0.5) : this.plot.x + this.plot.w + this._rightAxisRoom + gap) + "px"; this._colorbar.style.top = (horizontal ? this.plot.y + this.plot.h + (this._bottomAxisRoom || 8) - : this.plot.y) + "px"; + : this.plot.y + (this.plot.h - barHeight) * (1 - Number(anchor[1] ?? 0.5))) + "px"; this._colorbar.style.width = (horizontal - ? this.plot.w + ? barWidth : compactVertical ? COLORBAR_THICKNESS : 66) + "px"; - this._colorbar.style.height = (horizontal ? 50 : Math.max(24, this.plot.h)) + "px"; + this._colorbar.style.height = (horizontal ? 50 : Math.max(24, barHeight)) + "px"; this._colorbar.dataset.xyCompact = compactVertical ? "true" : "false"; for (const node of this._colorbar.querySelectorAll( '[data-xy-slot="colorbar_tick"], [data-xy-slot="colorbar_title"]' diff --git a/python/xy/_raster.py b/python/xy/_raster.py index 2514840a..975a9eae 100644 --- a/python/xy/_raster.py +++ b/python/xy/_raster.py @@ -13,6 +13,7 @@ import struct from collections.abc import Callable, Sequence +from itertools import pairwise from os import PathLike from typing import Any, Optional @@ -2010,18 +2011,23 @@ def _emit_colorbar( right_axis_room: float = 0.0, text_color: str = _TEXT, ) -> None: - from ._svg import _linear_ticks, _lut + from ._svg import _colorbar_tick_target, _linear_ticks, _lut orientation = options.get("orientation", "vertical") + shrink = float(options.get("shrink", 1.0)) + anchor = options.get("anchor") or [0.5, 0.5] if orientation == "horizontal": - x = plot["x"] + width = plot["w"] * shrink + x = plot["x"] + (plot["w"] - width) * float(anchor[0]) y = plot["y"] + plot["h"] + (plot["bottom_axis_room"] or 10) - width, height = plot["w"], 18 + height = 18 else: # right_axis_room shifts the whole colorbar clear of right-side named # y-axis chrome (layout() reserves room for both additively). x = plot["x"] + plot["w"] + right_axis_room + 24 - y, width, height = plot["y"], 18, plot["h"] + height = plot["h"] * shrink + y = plot["y"] + (plot["h"] - height) * (1.0 - float(anchor[1])) + width = 18 # A discrete (resampled) colormap paints N solid bands; otherwise a smooth # 64-step gradient approximates the continuous ramp. levels = options.get("levels") @@ -2065,8 +2071,19 @@ def _emit_colorbar( h_positions = ( [float(value) for value in ticks if lo <= float(value) <= hi] if ticks is not None - else (_linear_ticks(lo, hi, 8)[0] or [lo, hi]) + else (_linear_ticks(lo, hi, _colorbar_tick_target(width))[0] or [lo, hi]) ) + if options.get("minor_ticks") and len(h_positions) >= 2: + ordered = sorted(set(h_positions)) + for left, right in pairwise(ordered): + for step in range(1, 5): + value = left + (right - left) * step / 5.0 + tx = x + width * (value - lo) / span + cmd.stroke( + [(tx, y + height), (tx, y + height + 3)], + 1, + _parse_color(text_color), + ) for value in h_positions: cmd.text( x + width * (value - lo) / span, @@ -2089,8 +2106,19 @@ def _emit_colorbar( tick_positions = ( [float(value) for value in ticks if lo <= float(value) <= hi] if ticks is not None - else (_linear_ticks(lo, hi, 8)[0] or [lo, hi]) + else (_linear_ticks(lo, hi, _colorbar_tick_target(height))[0] or [lo, hi]) ) + if options.get("minor_ticks") and len(tick_positions) >= 2: + ordered = sorted(set(tick_positions)) + for lower, upper in pairwise(ordered): + for step in range(1, 5): + value = lower + (upper - lower) * step / 5.0 + ty = y + height * (1 - (value - lo) / span) + cmd.stroke( + [(x + width, ty), (x + width + 3, ty)], + 1, + _parse_color(text_color), + ) for value in tick_positions: cmd.text( x + width + 4, diff --git a/python/xy/_svg.py b/python/xy/_svg.py index 3f235e58..ee2f0392 100644 --- a/python/xy/_svg.py +++ b/python/xy/_svg.py @@ -22,6 +22,7 @@ import re from collections.abc import Callable, Sequence from datetime import UTC, datetime +from itertools import pairwise from os import PathLike from typing import Any, Optional from xml.sax.saxutils import escape @@ -2849,17 +2850,22 @@ def _colorbar( for index, (r, g, b) in enumerate(stops) ) orientation = options.get("orientation", "vertical") + shrink = float(options.get("shrink", 1.0)) + anchor = options.get("anchor") or [0.5, 0.5] domain = options.get("domain", [0.0, 1.0]) if orientation == "horizontal": - x = plot["x"] + width = plot["w"] * shrink + x = plot["x"] + (plot["w"] - width) * float(anchor[0]) y = plot["y"] + plot["h"] + (plot["bottom_axis_room"] or 10) - width, height = plot["w"], 18 + height = 18 gradient_attrs = 'x1="0" y1="0" x2="100%" y2="0"' else: # right_axis_room shifts the whole colorbar clear of right-side named # y-axis chrome (layout() reserves room for both additively). x = plot["x"] + plot["w"] + right_axis_room + 24 - y, width, height = plot["y"], 18, plot["h"] + height = plot["h"] * shrink + y = plot["y"] + (plot["h"] - height) * (1.0 - float(anchor[1])) + width = 18 gradient_attrs = 'x1="0" y1="100%" x2="0" y2="0"' label = str(options.get("label") or "") label_node = ( @@ -2880,7 +2886,14 @@ def _colorbar( tick_positions = ( [float(value) for value in ticks if lo <= float(value) <= hi] if ticks is not None - else (_linear_ticks(lo, hi, 8)[0] or [lo, hi]) + else ( + _linear_ticks( + lo, + hi, + _colorbar_tick_target(width if orientation == "horizontal" else height), + )[0] + or [lo, hi] + ) ) tick_nodes = ( "".join( @@ -2897,6 +2910,32 @@ def _colorbar( for value in tick_positions ) ) + minor_nodes = "" + if options.get("minor_ticks") and len(tick_positions) >= 2: + ordered = sorted(set(tick_positions)) + minor_positions = [ + left + (right - left) * step / 5.0 + for left, right in pairwise(ordered) + for step in range(1, 5) + ] + if orientation != "horizontal": + minor_nodes = "".join( + f'' + for value in minor_positions + ) + else: + minor_nodes = "".join( + f'' + for value in minor_positions + ) extend = options.get("extend") extend_nodes = "" if extend in ("max", "both"): @@ -2922,10 +2961,15 @@ def _colorbar( f'' f"{stop_nodes}" f"{_colorbar_body(options, x, y, width, height, orientation, gradient_id)}" - f"{extend_nodes}{tick_nodes}{label_node}" + f"{extend_nodes}{minor_nodes}{tick_nodes}{label_node}" ) +def _colorbar_tick_target(length: float) -> int: + """Major-tick budget for the rendered colorbar length in CSS pixels.""" + return max(2, min(8, int(max(0.0, float(length)) // 48.0) + 1)) + + def _colorbar_body( options: dict, x: float, diff --git a/python/xy/pyplot/_mplfig.py b/python/xy/pyplot/_mplfig.py index 262bd6af..a9a5c1c9 100644 --- a/python/xy/pyplot/_mplfig.py +++ b/python/xy/pyplot/_mplfig.py @@ -523,11 +523,35 @@ def colorbar(self, mappable: Any = None, cax: Any = None, ax: Any = None, **kwar cmap_obj = mappable.get_cmap() colormap = getattr(cmap_obj, "name", cmap_obj) try: - numeric = np.asarray(mapped_values, dtype=np.float64) - finite = numeric[np.isfinite(numeric)] + numeric = np.ma.asarray(mapped_values, dtype=np.float64) + finite = np.asarray(numeric.compressed(), dtype=np.float64) + finite = finite[np.isfinite(finite)] except (TypeError, ValueError): finite = np.asarray([], dtype=np.float64) explicit_domain = entry.get("domain", props.get("domain")) + orientation_arg = kwargs.pop("orientation", None) + location = kwargs.pop("location", None) + if location is not None: + location = str(location).lower() + if location not in {"right", "bottom"}: + raise not_implemented( + f"colorbar(location={location!r})", + "right or bottom colorbar placement", + ) + located_orientation = "vertical" if location == "right" else "horizontal" + if orientation_arg is not None and str(orientation_arg) != located_orientation: + raise ValueError("location and orientation select incompatible colorbar sides") + orientation_arg = located_orientation + orientation = str(orientation_arg or "vertical") + if orientation not in {"vertical", "horizontal"}: + raise ValueError("colorbar() orientation must be 'vertical' or 'horizontal'") + shrink = float(kwargs.pop("shrink", 1.0)) + if not np.isfinite(shrink) or not 0.0 < shrink <= 1.0: + raise ValueError("colorbar() shrink must be finite and in (0, 1]") + anchor_arg = kwargs.pop("anchor", (0.5, 0.5)) + anchor_values = np.asarray(anchor_arg, dtype=np.float64).reshape(-1) + if len(anchor_values) != 2 or not np.all(np.isfinite(anchor_values)): + raise ValueError("colorbar() anchor must be a finite (x, y) pair") options = { "colormap": colormap or "viridis", "domain": ( @@ -536,8 +560,12 @@ def colorbar(self, mappable: Any = None, cax: Any = None, ax: Any = None, **kwar else ([float(finite.min()), float(finite.max())] if finite.size else [0.0, 1.0]) ), "label": _plain_text(kwargs.pop("label", "")), - "orientation": str(kwargs.pop("orientation", "vertical")), + "orientation": orientation, } + if shrink != 1.0: + options["shrink"] = shrink + if not np.array_equal(anchor_values, [0.5, 0.5]): + options["anchor"] = [float(anchor_values[0]), float(anchor_values[1])] # When the mappable's value domain is not knowable at colorbar() time # (e.g. hexbin counts are binned inside the mark), defer to the compiled # figure's color domain at render time instead of the 0..1 placeholder. @@ -604,6 +632,14 @@ def set_ticks(self, ticks: Any, labels: Any = None, **kwargs: Any) -> None: self._options["ticks"] = [float(value) for value in np.asarray(ticks).reshape(-1)] self.ax._invalidate() + def minorticks_on(self) -> None: + self._options["minor_ticks"] = True + self.ax._invalidate() + + def minorticks_off(self) -> None: + self._options["minor_ticks"] = False + self.ax._invalidate() + return _Colorbar(axes, options) def figimage( diff --git a/spec/api/styling.md b/spec/api/styling.md index e9785377..9713998b 100644 --- a/spec/api/styling.md +++ b/spec/api/styling.md @@ -192,6 +192,70 @@ but they fail differently, and only the numeric grammar falls back. `format` is absent or not a string. - **Category axes** ignore `format=` and render the category label. +### Colorbar placement and ticks + +The built-in colorbar's geometry rides the first-paint spec's `colorbar` object +(`spec["colorbar"]`, written by `python/xy/_payload.py` from +`Figure.colorbar_options`) and is honored identically by the browser client +(`js/src/50_chartview.ts`), SVG (`python/xy/_svg.py`), and native PNG +(`python/xy/_raster.py`). + +| Colorbar option | Value | Default | +| --- | --- | --- | +| `orientation` | `"vertical"` (right of the plot) or `"horizontal"` (below it) | `"vertical"` | +| `shrink` | Fraction of the plot's length the bar spans along its long axis, in `(0, 1]` | `1` — full plot length | +| `anchor` | `[x, y]` placement of a shrunken bar within the leftover room | `[0.5, 0.5]` — centered | +| `minor_ticks` | Draw unlabeled minor ticks between the major ticks | absent — off | + +- **`shrink`** scales only the long axis: a horizontal bar's width becomes + `plot.w * shrink`, a vertical bar's height `plot.h * shrink`. Bar thickness + and the chrome room the layout reserves are unchanged, so shrinking a colorbar + never reflows the plot. The browser client additionally clamps the value into + `[0.01, 1]` (absent, zero, or non-finite reads as `1`) and floors a vertical + bar at 24 px; the static renderers use the authored value as given, because + the authoring surface below validates it. +- **`anchor`** is a fraction of the *leftover* room, not of the plot, and only + the component along the bar's long axis is read: a vertical bar uses + `anchor[1]`, a horizontal bar `anchor[0]`. `anchor[0]` runs left → right + (`0` flush left, `1` flush right). `anchor[1]` runs **bottom → top** (`0` + flush with the plot's bottom edge, `1` with its top) — Matplotlib's bottom-up + axes-fraction convention, not the renderers' top-down pixel space. At + `shrink = 1` there is no leftover room, so `anchor` has no effect. The + cross-axis position stays layout-owned: a vertical bar always clears + right-side y-axis chrome, a horizontal one always sits below the bottom axis. +- **`minor_ticks`** splits each interval between consecutive *rendered* major + ticks into fifths and draws four unlabeled 3 px ticks per interval on the + bar's tick side (right of a vertical bar, below a horizontal one). The + subdivision follows whichever major positions the colorbar actually drew — + explicit `ticks` included — and needs at least two of them, so a colorbar + showing a single major tick draws no minor ticks. Minor ticks carry + `data-xy-colorbar-minor="true"` in both the DOM and the SVG and deliberately + carry **no slot**: they are not `class_names`/`styles` targets and inherit the + surrounding text color (`currentColor` in the browser). + +`plt.colorbar()` / `fig.colorbar()` is the only authoring surface for `shrink`, +`anchor`, and `minor_ticks` today; the declarative `xy.colorbar()` component +still exposes `title`, `orientation`, and `ticks` only. The shim also accepts +Matplotlib's `location=` as a synonym for the side — `"right"` selects +`orientation: "vertical"`, `"bottom"` selects `"horizontal"` — and `location` +never reaches the spec as a field of its own. Invalid values raise instead of +being silently reinterpreted: `location="left"`/`"top"` is a +`NotImplementedError` (unsupported placement), a `location`/`orientation` pair +naming different sides is a `ValueError`, and so are a `shrink` outside +`(0, 1]` and an `anchor` that is not a finite `(x, y)` pair. +`Colorbar.minorticks_on()` / `minorticks_off()` toggle `minor_ticks` on the live +handle. `shrink` and `anchor` are omitted from the spec entirely when they hold +their defaults, so the wire shape of a default colorbar is unchanged. + +An **inferred** colorbar domain — the one the shim derives when no explicit +`domain` was authored — is computed over **unmasked, finite** samples only: +`np.ma`-masked entries are compressed out before the min/max, so a masked +image's colorbar spans the values it actually paints rather than the fill values +hidden underneath the mask. When masking (or non-finiteness) leaves no sample at +all, the domain falls through to the existing autoscale path and resolves from +the compiled figure's color domain at render time instead of a `0..1` +placeholder. + ## Slot reference Every element below is rendered with `data-xy-slot=""`, so diff --git a/tests/pyplot/test_color_pipeline_fixes.py b/tests/pyplot/test_color_pipeline_fixes.py index 7dce20d3..b30ae12b 100644 --- a/tests/pyplot/test_color_pipeline_fixes.py +++ b/tests/pyplot/test_color_pipeline_fixes.py @@ -113,10 +113,16 @@ def test_default_colorbar_ticks_are_dense_for_small_decimal_domains(): plt.colorbar(image) svg = _svg() assert all(f">{value:.2f}<" in svg for value in (0.02, 0.04, 0.06, 0.08, 0.12, 0.14)) - # The client-side colorbar mirrors the 8-tick target; the embedded bundle - # is minified, so assert against the client source instead of the HTML. + # A normal-height colorbar retains the dense eight-tick ceiling. Shorter + # bars reduce their budget so labels do not collide. + from xy._svg import _colorbar_tick_target + + assert _colorbar_tick_target(360) == 8 + assert _colorbar_tick_target(140) == 3 + # The client-side colorbar uses the same 48 px spacing budget; the embedded + # bundle is minified, so assert against the client source instead of HTML. client = ROOT / "js" / "src" / "50_chartview.ts" - assert "linearTicks(lo, hi, 8)" in client.read_text(encoding="utf-8") + assert "barLength) / 48" in client.read_text(encoding="utf-8") def test_explicit_colorbar_ticks_still_honored(): diff --git a/tests/pyplot/test_gallery_colorbar_options.py b/tests/pyplot/test_gallery_colorbar_options.py new file mode 100644 index 00000000..e7e0df10 --- /dev/null +++ b/tests/pyplot/test_gallery_colorbar_options.py @@ -0,0 +1,104 @@ +"""Regressions reduced from Matplotlib's colorbar gallery.""" + +from __future__ import annotations + +import re +from io import BytesIO + +import numpy as np +import pytest + +import xy.pyplot as plt + + +@pytest.fixture(autouse=True) +def _clean() -> None: + plt.close("all") + yield + plt.close("all") + + +def test_colorbar_location_anchor_shrink_and_minor_ticks_reach_exports() -> None: + fig, ax = plt.subplots() + image = ax.imshow(np.arange(16).reshape(4, 4), cmap="Blues") + + colorbar = fig.colorbar( + image, + ax=ax, + location="right", + anchor=(0.0, 0.3), + shrink=0.7, + ) + colorbar.minorticks_on() + + assert ax._colorbar["orientation"] == "vertical" + assert ax._colorbar["anchor"] == [0.0, 0.3] + assert ax._colorbar["shrink"] == pytest.approx(0.7) + assert ax._colorbar["minor_ticks"] is True + + svg = BytesIO() + fig.savefig(svg, format="svg") + assert b'data-xy-colorbar-minor="true"' in svg.getvalue() + + png = BytesIO() + fig.savefig(png, format="png") + assert png.getvalue().startswith(b"\x89PNG\r\n\x1a\n") + + colorbar.minorticks_off() + assert ax._colorbar["minor_ticks"] is False + + +def test_bottom_location_selects_horizontal_orientation() -> None: + fig, ax = plt.subplots() + image = ax.imshow(np.eye(3)) + + fig.colorbar(image, ax=ax, location="bottom", shrink=0.5) + + assert ax._colorbar["orientation"] == "horizontal" + assert ax._colorbar["shrink"] == pytest.approx(0.5) + + +def test_colorbar_domain_excludes_masked_image_values() -> None: + fig, ax = plt.subplots() + values = np.ma.masked_greater(np.asarray([[-2.0, -1.0], [1.0, 2.0]]), 0.0) + image = ax.imshow(values, cmap="Blues") + + fig.colorbar(image, ax=ax) + + assert ax._colorbar["domain"] == [-2.0, -1.0] + + +def test_short_gallery_colorbar_renders_only_three_major_tick_labels() -> None: + """The shrunken negative panel stays readable like the gallery reference.""" + size = 37 + x, y = np.mgrid[:size, :size] + values = np.cos(x * 0.2) + np.sin(y * 0.3) + negative = np.ma.masked_greater(values, 0.0) + fig, ax = plt.subplots(figsize=(13 / 3, 3)) + image = ax.imshow(negative, cmap="Blues", interpolation="none") + fig.colorbar(image, ax=ax, location="right", anchor=(0.0, 0.3), shrink=0.7) + + svg = BytesIO() + fig.savefig(svg, format="svg") + negative_labels = re.findall(rb"]*>(-[^<]+)", svg.getvalue()) + + assert negative_labels == [b"-1.5", b"-1", b"-0.5"] + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"location": "left"}, "right or bottom"), + ({"shrink": 0.0}, "shrink"), + ({"anchor": (0.5,)}, "anchor"), + ({"location": "right", "orientation": "horizontal"}, "incompatible"), + ], +) +def test_colorbar_gallery_options_reject_invalid_values( + kwargs: dict[str, object], message: str +) -> None: + fig, ax = plt.subplots() + image = ax.imshow(np.eye(3)) + + with pytest.raises((ValueError, NotImplementedError), match=message): + fig.colorbar(image, ax=ax, **kwargs)