Source code for wolfhece.assets.boxplot.zones_asset

"""Boxplot chart asset factory producing native Zones for mapviewer rendering.

Draws N side-by-side boxplots inside a configurable canvas rectangle.  Each
boxplot consists of:
  - a filled box  (Q1 … Q3)
  - a median line (Q2)
  - lower/upper whiskers with end caps
  - optional outlier diamonds
  - optional mean diamond marker
  - an optional series label below the plot area
"""

from __future__ import annotations

from typing import Sequence

import numpy as np

from ...PyVertexvectors import Zones, zone, vector, getIfromRGB
from .distribution import BoxplotDistributionModel


[docs] class BoxplotZonesAsset: """Build a side-by-side boxplot chart as a single Zones object."""
[docs] DEFAULT_COLORS: tuple[tuple[int, int, int, int], ...] = ( (41, 98, 255, 180), (0, 166, 118, 180), (255, 122, 69, 180), (138, 79, 255, 180), (255, 178, 0, 180), (0, 170, 204, 180), (255, 84, 112, 180), (90, 122, 146, 180), (121, 85, 72, 180), )
def __init__( self, series: Sequence[Sequence[float]], *, canvas_origin: tuple[float, float] = (0.0, 0.0), canvas_size: tuple[float, float] = (100.0, 80.0), area_fraction: tuple[float, float, float, float] = (0.10, 0.08, 0.92, 0.88), labels: Sequence[str] | None = None, colors: Sequence[tuple[int, int, int] | tuple[int, int, int, int]] | None = None, whis: float = 1.5, y_min: float | None = None, y_max: float | None = None, box_width_fraction: float = 0.55, show_mean: bool = False, show_outliers: bool = True, show_labels: bool = True, show_area_frame: bool = True, frame_color: tuple[int, int, int] = (40, 40, 40), border_color: tuple[int, int, int] = (25, 25, 25), median_color: tuple[int, int, int] = (20, 20, 20), legend_text_color: tuple[int, int, int] = (0, 0, 0), frame_line_width: float = 1.0, box_line_width: float = 1.0, whisker_line_width: float = 1.0, median_line_width: float = 2.0, idx: str = "boxplot", parent=None, mapviewer=None, ) -> None:
[docs] self.model = BoxplotDistributionModel(series, labels=labels, whis=whis)
cx0, cy0 = float(canvas_origin[0]), float(canvas_origin[1]) cw, ch = float(canvas_size[0]), float(canvas_size[1]) if cw <= 0 or ch <= 0: raise ValueError("canvas_size must be positive")
[docs] self.canvas_origin = (cx0, cy0)
[docs] self.canvas_size = (cw, ch)
[docs] self.area_fraction = ( float(area_fraction[0]), float(area_fraction[1]), float(area_fraction[2]), float(area_fraction[3]), )
[docs] self._colors = self._normalize_colors(colors)
[docs] self.y_min = float(y_min) if y_min is not None else None
[docs] self.y_max = float(y_max) if y_max is not None else None
[docs] self.box_width_fraction = max(0.05, min(1.0, float(box_width_fraction)))
[docs] self.show_mean = bool(show_mean)
[docs] self.show_outliers = bool(show_outliers)
[docs] self.show_labels = bool(show_labels)
[docs] self.show_area_frame = bool(show_area_frame)
[docs] self.frame_color = tuple(int(v) for v in frame_color[:3])
[docs] self.border_color = tuple(int(v) for v in border_color[:3])
[docs] self.median_color = tuple(int(v) for v in median_color[:3])
[docs] self.legend_text_color = tuple(int(v) for v in legend_text_color[:3])
[docs] self.frame_line_width = float(frame_line_width)
[docs] self.box_line_width = float(box_line_width)
[docs] self.whisker_line_width = float(whisker_line_width)
[docs] self.median_line_width = float(median_line_width)
[docs] self.idx = str(idx)
[docs] self.parent = parent
[docs] self.mapviewer = mapviewer
# ------------------------------------------------------------------ # Public factory method # ------------------------------------------------------------------
[docs] def to_zones(self) -> Zones: """Build and return the Zones object containing all boxplot geometry.""" stats = self.model.statistics() ax0, ay0, ax1, ay1, aw, ah = self._plot_area() y_lo, y_hi = self._effective_y_range(stats, ay0, ah) N = len(stats) slot_w = aw / max(N, 1) result = Zones(idx=self.idx, parent=self.parent, mapviewer=self.mapviewer) main_zone = zone(name=f"{self.idx}_all", parent=result) result.add_zone(main_zone, forceparent=True) # ── Canvas and plot-area frames ──────────────────────────────── if self.show_area_frame: cx0, cy0 = self.canvas_origin cw, ch = self.canvas_size self._add_rect_outline( main_zone, "canvas_frame", cx0, cy0, cx0 + cw, cy0 + ch, self.frame_color, self.frame_line_width, ) self._add_rect_outline( main_zone, "area_frame", ax0, ay0, ax1, ay1, self.frame_color, self.frame_line_width, ) # ── Per-series elements ──────────────────────────────────────── for i, (s, rgba) in enumerate(zip(stats, self._colors)): cx = ax0 + (i + 0.5) * slot_w half_box = self.box_width_fraction * slot_w / 2.0 cap_hw = half_box * 0.4 bx1 = cx - half_box bx2 = cx + half_box y_q1 = self._yw(s.q1, ay0, ah, y_lo, y_hi) y_q2 = self._yw(s.q2, ay0, ah, y_lo, y_hi) y_q3 = self._yw(s.q3, ay0, ah, y_lo, y_hi) y_wlo = self._yw(s.whisker_low, ay0, ah, y_lo, y_hi) y_whi = self._yw(s.whisker_high, ay0, ah, y_lo, y_hi) # Clamp to plot area y_q1 = max(ay0, min(ay1, y_q1)) y_q2 = max(ay0, min(ay1, y_q2)) y_q3 = max(ay0, min(ay1, y_q3)) y_wlo = max(ay0, min(ay1, y_wlo)) y_whi = max(ay0, min(ay1, y_whi)) # Box (Q1–Q3, filled rectangle) box_vec = self._add_filled_rect( main_zone, f"box_{i:03d}", bx1, y_q1, bx2, y_q3, rgba, border_color=self.border_color, border_width=self.box_line_width, ) # Series label below box (attached to the box vector) if self.show_labels: label = s.label lx = cx ly = ay0 - ah * 0.06 lh = min(ah, aw) * 0.025 ll = len(label) * lh * 0.65 box_vec.myprop.legendvisible = True box_vec.set_legend_text(label) box_vec.set_legend_position(lx, ly) box_vec.myprop.legend_alignment = 'center' box_vec.myprop.legendpriority = 1 box_vec.myprop.legendheight = max(lh, 1e-6) box_vec.myprop.legendlength = max(ll, 1e-6) lc_idx = getIfromRGB(self.legend_text_color) box_vec.myprop.legendcolor = lc_idx # Median line self._add_open_line( main_zone, f"median_{i:03d}", [(bx1, y_q2, 0.0), (bx2, y_q2, 0.0)], self.median_color, self.median_line_width, ) # Lower whisker: cap ─ centre ─ Q1 self._add_open_line( main_zone, f"whisker_lo_{i:03d}", [ (cx - cap_hw, y_wlo, 0.0), (cx + cap_hw, y_wlo, 0.0), (cx, y_wlo, 0.0), (cx, y_q1, 0.0), ], self.border_color, self.whisker_line_width, ) # Upper whisker: Q3 ─ centre ─ cap self._add_open_line( main_zone, f"whisker_hi_{i:03d}", [ (cx, y_q3, 0.0), (cx, y_whi, 0.0), (cx - cap_hw, y_whi, 0.0), (cx + cap_hw, y_whi, 0.0), ], self.border_color, self.whisker_line_width, ) # Mean marker (hollow diamond) if self.show_mean: y_mean = max(ay0, min(ay1, self._yw(s.mean, ay0, ah, y_lo, y_hi))) dm = half_box * 0.3 self._add_open_line( main_zone, f"mean_{i:03d}", [ (cx, y_mean - dm, 0.0), (cx + dm * 0.6, y_mean, 0.0), (cx, y_mean + dm, 0.0), (cx - dm * 0.6, y_mean, 0.0), (cx, y_mean - dm, 0.0), ], self.border_color, 1.0, ) # Outlier diamonds (filled) if self.show_outliers and s.outliers.size > 0: dm = half_box * 0.18 for j, ov in enumerate(s.outliers): yo = max(ay0, min(ay1, self._yw(float(ov), ay0, ah, y_lo, y_hi))) rgba_out = (rgba[0], rgba[1], rgba[2], min(255, rgba[3] + 40)) self._add_filled_rect( main_zone, f"outlier_{i:03d}_{j:04d}", cx - dm, yo - dm * 0.6, cx + dm, yo + dm * 0.6, rgba_out, border_color=self.border_color, border_width=0.7, ) result.find_minmax(update=True) return result
# ------------------------------------------------------------------ # Geometry helpers # ------------------------------------------------------------------
[docs] def _plot_area(self) -> tuple[float, float, float, float, float, float]: """Return (ax0, ay0, ax1, ay1, aw, ah) of the plot area in world coords.""" cx0, cy0 = self.canvas_origin cw, ch = self.canvas_size fx0, fy0, fx1, fy1 = self.area_fraction ax0 = cx0 + fx0 * cw ay0 = cy0 + fy0 * ch ax1 = cx0 + fx1 * cw ay1 = cy0 + fy1 * ch return ax0, ay0, ax1, ay1, ax1 - ax0, ay1 - ay0
[docs] def _effective_y_range( self, stats, ay0: float, ah: float, ) -> tuple[float, float]: y_lo, y_hi = self.model.global_y_bounds() margin = max(abs(y_hi - y_lo) * 0.05, 1e-9) y_lo -= margin y_hi += margin if self.y_min is not None: y_lo = self.y_min if self.y_max is not None: y_hi = self.y_max if y_hi <= y_lo: y_hi = y_lo + 1.0 return y_lo, y_hi
@staticmethod
[docs] def _yw(v: float, ay0: float, ah: float, y_lo: float, y_hi: float) -> float: """Map data value *v* to world Y coordinate.""" if y_hi == y_lo: return ay0 + ah * 0.5 return ay0 + (v - y_lo) / (y_hi - y_lo) * ah
[docs] def _add_rect_outline( self, parent_zone: zone, name: str, x1: float, y1: float, x2: float, y2: float, color: tuple, line_width: float, ) -> vector: pts = [(x1, y1, 0.0), (x2, y1, 0.0), (x2, y2, 0.0), (x1, y2, 0.0)] vec = vector.make_from_list(pts, name=name, parentzone=parent_zone) vec.close_force() vec.myprop.color = getIfromRGB(color) vec.myprop.filled = False vec.myprop.width = line_width parent_zone.add_vector(vec, forceparent=True) return vec
[docs] def _add_filled_rect( self, parent_zone: zone, name: str, x1: float, y1: float, x2: float, y2: float, rgba: tuple, *, border_color: tuple | None = None, border_width: float = 1.0, ) -> vector: pts = [(x1, y1, 0.0), (x2, y1, 0.0), (x2, y2, 0.0), (x1, y2, 0.0)] vec = vector.make_from_list(pts, name=name, parentzone=parent_zone) vec.close_force() vec.myprop.color = getIfromRGB((rgba[0], rgba[1], rgba[2])) vec.myprop.filled = True vec.myprop.transparent = rgba[3] < 255 vec.myprop.alpha = rgba[3] if border_color is not None: vec.myprop.contour_enabled = True vec.myprop.contour_color = getIfromRGB(border_color) vec.myprop.contour_width = border_width parent_zone.add_vector(vec, forceparent=True) return vec
[docs] def _add_open_line( self, parent_zone: zone, name: str, pts: list[tuple[float, float, float]], color: tuple, line_width: float, ) -> vector: vec = vector.make_from_list(pts, name=name, parentzone=parent_zone) vec.myprop.color = getIfromRGB(color) vec.myprop.filled = False vec.myprop.width = line_width parent_zone.add_vector(vec, forceparent=True) return vec
# ------------------------------------------------------------------ # Color helpers # ------------------------------------------------------------------
[docs] def _normalize_colors( self, colors: Sequence[tuple] | None, ) -> list[tuple[int, int, int, int]]: n = self.model.n_series result: list[tuple[int, int, int, int]] = [] if colors is None: for i in range(n): result.append(tuple(self.DEFAULT_COLORS[i % len(self.DEFAULT_COLORS)])) else: for i, c in enumerate(colors): if i >= n: break r, g, b = int(c[0]), int(c[1]), int(c[2]) a = int(c[3]) if len(c) >= 4 else 180 result.append((r, g, b, a)) while len(result) < n: result.append(tuple(self.DEFAULT_COLORS[len(result) % len(self.DEFAULT_COLORS)])) return result