Source code for whitecanvas.layers.group.boxplot

from __future__ import annotations

import numpy as np
from numpy.typing import ArrayLike, NDArray

from whitecanvas.backend import Backend
from whitecanvas.types import ColorType, FacePattern, LineStyle, Orientation
from whitecanvas.layers.primitive import Bars, MultiLine
from whitecanvas.layers.group._collections import ListLayerGroup
from whitecanvas.layers.group._cat_utils import check_array_input
from whitecanvas.utils.normalize import as_color_array


[docs]class BoxPlot(ListLayerGroup): """ A group for boxplot. Children layers are: - Bars (boxes) - MultiLine (whiskers) - MultiLine (median line) - Markers (outliers) ──┬── <-- max ┌──┴──┐ <-- 75% quantile │ o │ <-- mean ╞═════╡ <-- median └──┬──┘ <-- 25% quantile ──┴── <-- min """ def __init__( self, boxes: Bars, whiskers: MultiLine, medians: MultiLine, # outliers: Markers | None = None, *, name: str | None = None, orient: Orientation = Orientation.VERTICAL, ): super().__init__([boxes, whiskers, medians], name=name) self._orient = Orientation.parse(orient) @property def boxes(self) -> Bars: """The boxes layer (Bars).""" return self._children[0] @property def whiskers(self) -> MultiLine: """The whiskers layer (MultiLine).""" return self._children[1] @property def medians(self) -> MultiLine: """The median line layer (MultiLine).""" return self._children[2]
[docs] @classmethod def from_arrays( cls, x: list[float], data: list[ArrayLike], *, name: str | None = None, orient: str | Orientation = Orientation.VERTICAL, box_width: float = 0.3, capsize: float = 0.15, color: ColorType | list[ColorType] = "blue", alpha: float = 1.0, pattern: str | FacePattern = FacePattern.SOLID, backend: str | Backend | None = None, ): x, data = check_array_input(x, data) ori = Orientation.parse(orient) color = as_color_array(color, len(x)) agg_values: list[NDArray[np.number]] = [] for d in data: agg_values.append(np.quantile(d, [0, 0.25, 0.5, 0.75, 1])) agg_arr = np.stack(agg_values, axis=1) box = Bars( x, agg_arr[3] - agg_arr[1], agg_arr[1], name=name, orient=ori, bar_width=box_width, backend=backend, ).with_face_multi( pattern=pattern, color=color, alpha=alpha, ).with_edge(color="black") # fmt: skip if ori.is_vertical: segs = _xyy_to_segments( x, agg_arr[0], agg_arr[1], agg_arr[3], agg_arr[4], capsize ) medsegs = [ [(x0 - box_width / 2, y0), (x0 + box_width / 2, y0)] for x0, y0 in zip(x, agg_arr[2]) ] else: segs = _yxx_to_segments( x, agg_arr[0], agg_arr[1], agg_arr[3], agg_arr[4], capsize ) medsegs = [ [(x0, y0 - box_width / 2), (x0, y0 + box_width / 2)] for x0, y0 in zip(x, agg_arr[2]) ] whiskers = MultiLine( segs, name=name, style=LineStyle.SOLID, alpha=alpha, backend=backend, color="black", ) # fmt: skip medians = MultiLine( medsegs, name="medians", color="black", alpha=alpha, backend=backend, ) # fmt: skip return cls(box, whiskers, medians, name=name, orient=ori)
@property def orient(self) -> Orientation: """Orientation of the boxplot.""" return self._orient
[docs] def with_face( self, color: ColorType | list[ColorType], alpha: float | list[float] = 1.0, pattern: str | FacePattern | list[FacePattern] = FacePattern.SOLID, ) -> BoxPlot: """Add face to the strip plot.""" self.boxes.with_face(color=color, alpha=alpha, pattern=pattern) return self
[docs] def with_edge( self, *, color: ColorType = "black", width: float = 1.0, style: str | LineStyle = LineStyle.SOLID, alpha: float = 1.0, ) -> BoxPlot: """Add edges to the strip plot.""" self.boxes.with_edge(color=color, alpha=alpha, width=width, style=style) self.whiskers.update(color=color, alpha=alpha, width=width, style=style) self.medians.update(color=color, alpha=alpha, width=width, style=style) return self
def _xyy_to_segments( x: ArrayLike, y0: ArrayLike, y1: ArrayLike, y2: ArrayLike, y3: ArrayLike, capsize: float, ): """ ──┬── <-- y3 │ <-- y2 │ <-- y1 ──┴── <-- y0 x """ v0 = np.stack([x, y0], axis=1) v1 = np.stack([x, y1], axis=1) v2 = np.stack([x, y2], axis=1) v3 = np.stack([x, y3], axis=1) segments_0 = [[s0, s1] for s0, s1 in zip(v0, v1)] segments_1 = [[s2, s3] for s2, s3 in zip(v2, v3)] if capsize > 0: _c = np.array([capsize / 2, 0]) cap0 = [[s0 - _c, s0 + _c] for s0 in v0] cap1 = [[s3 - _c, s3 + _c] for s3 in v3] else: cap0 = [] cap1 = [] return segments_0 + segments_1 + cap0 + cap1 def _yxx_to_segments( y: ArrayLike, x0: ArrayLike, x1: ArrayLike, x2: ArrayLike, x3: ArrayLike, capsize: float, ): """ | | ─── ─── <-- y | | ↑ ↑ ↑ ↑ x0 x1 x2 x3 """ v0 = np.stack([x0, y], axis=1) v1 = np.stack([x1, y], axis=1) v2 = np.stack([x2, y], axis=1) v3 = np.stack([x3, y], axis=1) segments_0 = [[s0, s1] for s0, s1 in zip(v0, v1)] segments_1 = [[s2, s3] for s2, s3 in zip(v2, v3)] if capsize > 0: _c = np.array([0, capsize / 2]) cap0 = [[s0 - _c, s0 + _c] for s0 in v0] cap1 = [[s3 - _c, s3 + _c] for s3 in v3] else: cap0 = [] cap1 = [] return segments_0 + segments_1 + cap0 + cap1