"""
plot1d/_plotbar.py
==================
Bar chart panel (PlotBar).
"""
from __future__ import annotations
import numpy as np
from typing import Callable
from anyplotlib._base_plot import _BasePlot, _PanelMixin
from anyplotlib.callbacks import CallbackRegistry
from anyplotlib.widgets import (
Widget,
VLineWidget as _VLineWidget,
HLineWidget as _HLineWidget,
RangeWidget as _RangeWidget,
PointWidget as _PointWidget,
)
# ---------------------------------------------------------------------------
# _bar_x_axis helper
# ---------------------------------------------------------------------------
def _bar_x_axis(x_centers: np.ndarray) -> list:
"""Return a 2-element [x_left_edge, x_right_edge] list for a bar chart.
The edges are half a slot-width outside the first/last bar centre so that
a vline_widget at ``x_centers[i]`` renders at exactly the bar's centre
pixel when used with ``_xToFrac1d`` / ``_fracToPx1d`` in the JS renderer.
"""
n = len(x_centers)
if n == 0:
return [0.0, 1.0]
if n == 1:
return [float(x_centers[0]) - 0.5, float(x_centers[0]) + 0.5]
slot = (float(x_centers[-1]) - float(x_centers[0])) / (n - 1)
half = slot / 2.0
return [float(x_centers[0]) - half, float(x_centers[-1]) + half]
# ---------------------------------------------------------------------------
# PlotBar
# ---------------------------------------------------------------------------
_LOG_CLAMP = 1e-10 # smallest positive value used when log_scale=True
_DEFAULT_GROUP_PALETTE = [
"#4fc3f7", "#ff7043", "#66bb6a", "#ab47bc",
"#ffa726", "#26c6da", "#ec407a", "#8d6e63",
]
def _bar_range(flat: np.ndarray, bottom: float, log_scale: bool):
"""Return ``(dmin, dmax)`` with padding for the value axis."""
if log_scale:
pos = flat[flat > 0]
dmin = float(np.nanmin(pos)) if len(pos) else _LOG_CLAMP
dmax = max(float(np.nanmax(flat)) if len(flat) else 1.0,
bottom if bottom > 0 else _LOG_CLAMP)
if dmin <= 0:
dmin = _LOG_CLAMP
if dmax <= 0:
dmax = 1.0
dmax = 10 ** (np.log10(dmax) + 0.15)
dmin = 10 ** (np.log10(dmin) - 0.15)
else:
dmin = min(bottom, float(np.nanmin(flat)) if len(flat) else 0.0)
dmax = max(bottom, float(np.nanmax(flat)) if len(flat) else 1.0)
pad = (dmax - dmin) * 0.07 if dmax > dmin else 0.5
dmax += pad
if dmin < bottom:
dmin -= pad
return dmin, dmax
[docs]
class PlotBar(_BasePlot, _PanelMixin):
"""Bar-chart plot panel.
Not an anywidget. Holds state in ``_state`` dict; every mutation calls
``_push()`` which writes to the parent Figure's panel trait.
Supports grouped bars (pass a 2-D *height* array with shape ``(N, G)``),
log-scale value axis, draggable overlay widgets, and hover/click callbacks.
Created by :meth:`Axes.bar`.
"""
def __init__(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *,
align: str = "center",
color: str = "#4fc3f7",
colors=None,
orient: str = "v",
log_scale: bool = False,
group_labels=None,
group_colors=None,
show_values: bool = False,
units: str = "",
y_units: str = "",
# ── legacy backward-compat kwargs ──────────────────────
x_labels=None,
x_centers=None,
bar_width=None,
baseline=None,
values=None):
self._id: str = ""
self._fig: object = None
if align not in ("center", "edge"):
raise ValueError("align must be 'center' or 'edge'")
if orient not in ("v", "h"):
raise ValueError("orient must be 'v' or 'h'")
# ── legacy resolution ──────────────────────────────────────────
if height is None:
if values is not None:
height = values
else:
height = x
x = None
if baseline is not None:
bottom = baseline
if bar_width is not None:
width = bar_width
# ── height (values) — 1-D or 2-D for grouped bars ─────────────
height_arr = np.asarray(height, dtype=float)
if height_arr.ndim == 1:
groups = 1
values_2d = height_arr.reshape(-1, 1)
elif height_arr.ndim == 2:
groups = height_arr.shape[1]
values_2d = height_arr
else:
raise ValueError(
f"height must be 1-D or 2-D, got shape {height_arr.shape}"
)
n = values_2d.shape[0]
# ── x (positions or labels) ────────────────────────────────────
_x_labels: list = []
_x_centers: np.ndarray | None = None
if x is not None:
x_list = list(x)
if x_list and isinstance(x_list[0], str):
_x_labels = x_list
else:
_x_centers = np.asarray(x, dtype=float)
# Legacy keyword overrides
if x_labels is not None:
_x_labels = list(x_labels)
if x_centers is not None:
_x_centers = np.asarray(x_centers, dtype=float)
if _x_centers is None:
_x_centers = np.arange(n, dtype=float)
if len(_x_centers) != n:
raise ValueError("x length must match height length")
# ── data range ─────────────────────────────────────────────────
flat = values_2d.ravel()
dmin, dmax = _bar_range(flat, float(bottom), bool(log_scale))
# ── group colours ──────────────────────────────────────────────
if group_colors is None:
gc_list = (
[_DEFAULT_GROUP_PALETTE[i % len(_DEFAULT_GROUP_PALETTE)]
for i in range(groups)]
if groups > 1 else []
)
else:
gc_list = list(group_colors)
x_axis = _bar_x_axis(_x_centers)
self._state: dict = {
"kind": "bar",
"values": values_2d.tolist(), # always (N, G) 2-D list
"groups": groups,
"x_centers": _x_centers.tolist(),
"x_labels": _x_labels,
"bar_color": color,
"bar_colors": list(colors) if colors is not None else [],
"group_labels": list(group_labels) if group_labels is not None else [],
"group_colors": gc_list,
"bar_width": float(width),
"align": align,
"orient": orient,
"baseline": float(bottom),
"log_scale": bool(log_scale),
"show_values": bool(show_values),
"data_min": dmin,
"data_max": dmax,
"y_range": None,
"units": units,
"y_units": y_units,
"title": "",
"x_label": "",
"y_label": "",
"axis_visible": True,
"x_ticks_visible": True,
"y_ticks_visible": True,
# overlay-widget coordinate system (mirrors Plot1D)
"x_axis": x_axis,
"view_x0": 0.0,
"view_x1": 1.0,
"overlay_widgets": [],
"pointer_settled_ms": 0,
"pointer_settled_delta": 4,
"_view_from_python": False,
}
self.callbacks = CallbackRegistry()
self._widgets: dict[str, Widget] = {}
[docs]
def to_state_dict(self) -> dict:
d = dict(self._state)
d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()]
return d
# ------------------------------------------------------------------
# Data
# ------------------------------------------------------------------
[docs]
def set_data(self, height, x=None, x_labels=None, *, x_centers=None) -> None:
"""Replace bar heights; recalculates the value-axis range automatically.
Parameters
----------
height : array-like, shape ``(N,)`` or ``(N, G)``
New bar heights. For grouped charts the group count *G* must
match the original.
x : array-like of numeric, optional
New bar positions (replaces the stored ``x_centers``). Also
accepts the legacy keyword alias ``x_centers``.
x_labels : list of str, optional
New category labels.
"""
height_arr = np.asarray(height, dtype=float)
if height_arr.ndim == 1:
values_2d = height_arr.reshape(-1, 1)
elif height_arr.ndim == 2:
expected_g = self._state.get("groups", 1)
if height_arr.shape[1] != expected_g:
raise ValueError(
f"Group count mismatch: expected {expected_g}, "
f"got {height_arr.shape[1]}"
)
values_2d = height_arr
else:
raise ValueError(
f"height must be 1-D or 2-D, got shape {height_arr.shape}"
)
flat = values_2d.ravel()
baseline = self._state["baseline"]
log_scale = self._state.get("log_scale", False)
dmin, dmax = _bar_range(flat, float(baseline), bool(log_scale))
self._state["values"] = values_2d.tolist()
self._state["data_min"] = dmin
self._state["data_max"] = dmax
# Accept both `x` and legacy `x_centers` keyword
_x = x if x is not None else x_centers
if _x is not None:
xc = np.asarray(_x, dtype=float)
self._state["x_centers"] = xc.tolist()
self._state["x_axis"] = _bar_x_axis(xc)
if x_labels is not None:
self._state["x_labels"] = list(x_labels)
self._push()
# ------------------------------------------------------------------
# Display settings
# ------------------------------------------------------------------
[docs]
def set_color(self, color: str) -> None:
"""Set a single colour for all bars."""
self._state["bar_color"] = color
self._push()
[docs]
def set_colors(self, colors) -> None:
"""Set per-bar colours (list of CSS colour strings, length N)."""
self._state["bar_colors"] = list(colors)
self._push()
[docs]
def set_show_values(self, show: bool) -> None:
"""Show or hide in-bar value annotations."""
self._state["show_values"] = bool(show)
self._push()
[docs]
def set_log_scale(self, log_scale: bool) -> None:
"""Enable or disable a logarithmic value axis.
When *log_scale* is ``True`` any non-positive values are clamped to
``1e-10`` for display; the data-range bounds are recalculated in
log-space automatically.
"""
self._state["log_scale"] = bool(log_scale)
flat = np.asarray(self._state["values"]).ravel()
baseline = self._state["baseline"]
dmin, dmax = _bar_range(flat, float(baseline), bool(log_scale))
self._state["data_min"] = dmin
self._state["data_max"] = dmax
self._push()
# ------------------------------------------------------------------
# Display control
# ------------------------------------------------------------------
[docs]
def set_xlabel(self, label: str, fontsize: float | None = None) -> None:
"""Set the x-axis label (mini-TeX allowed; default size 10 px)."""
self._set_label("x_label", label, "x_label_size", fontsize)
[docs]
def set_ylabel(self, label: str, fontsize: float | None = None) -> None:
"""Set the y-axis / value-axis label (mini-TeX allowed; default size 10 px)."""
self._set_label("y_label", label, "y_label_size", fontsize)
[docs]
def set_bar_width(self, width: float) -> None:
"""Set the bar width."""
self._state["bar_width"] = float(width)
self._push()
[docs]
def set_align(self, align: str) -> None:
"""Set bar alignment: ``'center'`` or ``'edge'``."""
if align not in ("center", "edge"):
raise ValueError("align must be 'center' or 'edge'")
self._state["align"] = align
self._push()
[docs]
def set_orient(self, orient: str) -> None:
"""Set bar orientation: ``'v'`` (vertical) or ``'h'`` (horizontal)."""
if orient not in ("v", "h"):
raise ValueError("orient must be 'v' or 'h'")
self._state["orient"] = orient
self._push()
[docs]
def set_group_labels(self, labels) -> None:
"""Replace the category labels on the category axis."""
self._state["group_labels"] = list(labels)
self._push()
# ------------------------------------------------------------------
# View (xlim / ylim)
# ------------------------------------------------------------------
[docs]
def set_xlim(self, xmin: float, xmax: float) -> None:
"""Pan/zoom the x-axis to [xmin, xmax] in data coordinates."""
x_axis = self._state["x_axis"]
span = x_axis[1] - x_axis[0]
if span == 0:
return
with self._python_view_push():
self._state["view_x0"] = (xmin - x_axis[0]) / span
self._state["view_x1"] = (xmax - x_axis[0]) / span
[docs]
def set_ylim(self, y_min: float, y_max: float) -> None:
"""Fix the value-axis range to [y_min, y_max]."""
self._state["y_range"] = [float(y_min), float(y_max)]
self._push()
[docs]
def get_ylim(self) -> tuple:
"""Return the current value-axis range as ``(y_min, y_max)``."""
yr = self._state.get("y_range")
if yr is not None:
return (float(yr[0]), float(yr[1]))
return (float(self._state["data_min"]), float(self._state["data_max"]))
[docs]
def get_xlim(self) -> tuple:
"""Return the current x-axis view range in data coordinates."""
x_axis = self._state["x_axis"]
span = x_axis[1] - x_axis[0]
x0 = x_axis[0] + self._state["view_x0"] * span
x1 = x_axis[0] + self._state["view_x1"] * span
return (float(x0), float(x1))
[docs]
def reset_view(self) -> None:
"""Reset pan/zoom to show all bars."""
with self._python_view_push():
self._state["view_x0"] = 0.0
self._state["view_x1"] = 1.0
self._state["y_range"] = None
# ------------------------------------------------------------------
# Overlay Widgets
# ------------------------------------------------------------------
def __repr__(self) -> str:
n = len(self._state.get("values", []))
orient = self._state.get("orient", "v")
groups = self._state.get("groups", 1)
if groups > 1:
return f"PlotBar(n={n}, groups={groups}, orient={orient!r})"
return f"PlotBar(n={n}, orient={orient!r})"