"""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.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.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