Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 32 additions & 5 deletions js/src/50_chartview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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"]'
Expand Down
40 changes: 34 additions & 6 deletions python/xy/_raster.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
54 changes: 49 additions & 5 deletions python/xy/_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = (
Expand All @@ -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(
Expand All @@ -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'<line data-xy-colorbar-minor="true" x1="{_num(x + width)}" '
f'x2="{_num(x + width + 3)}" '
f'y1="{_num(y + height * (1 - (value - lo) / span))}" '
f'y2="{_num(y + height * (1 - (value - lo) / span))}" '
f'stroke="{escape(text_color)}"/>'
for value in minor_positions
)
else:
minor_nodes = "".join(
f'<line data-xy-colorbar-minor="true" '
f'x1="{_num(x + width * (value - lo) / span)}" '
f'x2="{_num(x + width * (value - lo) / span)}" '
f'y1="{_num(y + height)}" y2="{_num(y + height + 3)}" '
f'stroke="{escape(text_color)}"/>'
for value in minor_positions
)
extend = options.get("extend")
extend_nodes = ""
if extend in ("max", "both"):
Expand All @@ -2922,10 +2961,15 @@ def _colorbar(
f'<defs><linearGradient id="{gradient_id}" {gradient_attrs}>'
f"{stop_nodes}</linearGradient></defs>"
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,
Expand Down
42 changes: 39 additions & 3 deletions python/xy/pyplot/_mplfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": (
Expand All @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
64 changes: 64 additions & 0 deletions spec/api/styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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="<slot>"`, so
Expand Down
Loading
Loading