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)