Source code for wolfhece.assets.boxplot.controller

"""Persistent controller for editable boxplot chart assets.

Stores editable parameters, keeps a pointer to the current Zones object in
the mapviewer, and can rebuild geometry dynamically.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import TYPE_CHECKING, Sequence

from ...PyVertexvectors import Zones
from .zones_asset import BoxplotZonesAsset

if TYPE_CHECKING:
    from .editor import BoxplotZonesEditor


[docs] class BoxplotZonesController: """Persistent controller for editable boxplot assets.""" 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), id: str = "boxplot", 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, y_range_mode: str = "auto", 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, mapviewer=None, editor: BoxplotZonesEditor | None = None, ) -> None: # Validate and copy series data
[docs] self.series: list[list[float]] = [list(s) for s in series]
if not self.series: raise ValueError("At least one data series is required")
[docs] self.canvas_origin_x = float(canvas_origin[0])
[docs] self.canvas_origin_y = float(canvas_origin[1])
[docs] self.canvas_size_w = float(canvas_size[0])
[docs] self.canvas_size_h = float(canvas_size[1])
[docs] self.area_fraction = ( float(area_fraction[0]), float(area_fraction[1]), float(area_fraction[2]), float(area_fraction[3]), )
[docs] self.id = str(id)
n = len(self.series)
[docs] self.labels: list[str] | None = list(labels) if labels is not None else None
[docs] self.colors: list | None = ( [tuple(c) for c in colors] if colors is not None else None )
[docs] self.whis = float(whis)
[docs] self.y_range_mode = "auto" if str(y_range_mode).strip().lower() != "fixed" else "fixed"
[docs] self.y_min: float | None = float(y_min) if y_min is not None else None
[docs] self.y_max: float | None = float(y_max) if y_max is not None else None
[docs] self.box_width_fraction = 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.mapviewer = mapviewer
[docs] self.editor: BoxplotZonesEditor | None = editor
[docs] self.zones: Zones | None = None
# ------------------------------------------------------------------ # Build helpers # ------------------------------------------------------------------
[docs] def _build_asset(self) -> BoxplotZonesAsset: y_min = self.y_min if self.y_range_mode == "fixed" else None y_max = self.y_max if self.y_range_mode == "fixed" else None return BoxplotZonesAsset( series=self.series, canvas_origin=(self.canvas_origin_x, self.canvas_origin_y), canvas_size=(self.canvas_size_w, self.canvas_size_h), area_fraction=self.area_fraction, labels=self.labels, colors=self.colors, whis=self.whis, y_min=y_min, y_max=y_max, box_width_fraction=self.box_width_fraction, show_mean=self.show_mean, show_outliers=self.show_outliers, show_labels=self.show_labels, show_area_frame=self.show_area_frame, frame_color=self.frame_color, border_color=self.border_color, median_color=self.median_color, legend_text_color=self.legend_text_color, frame_line_width=self.frame_line_width, box_line_width=self.box_line_width, whisker_line_width=self.whisker_line_width, median_line_width=self.median_line_width, idx=self.id, mapviewer=self.mapviewer, )
# ------------------------------------------------------------------ # Geometry / bounds update # ------------------------------------------------------------------
[docs] def update_canvas( self, x: float | None = None, y: float | None = None, width: float | None = None, height: float | None = None, rebuild: bool = True, ) -> None: """Update canvas position / size.""" if x is not None: self.canvas_origin_x = float(x) if y is not None: self.canvas_origin_y = float(y) if width is not None: self.canvas_size_w = float(max(1e-9, width)) if height is not None: self.canvas_size_h = float(max(1e-9, height)) if rebuild: self.rebuild(ToCheck=True)
[docs] def get_transform_bounds(self) -> tuple[float, float, float, float]: """Return editable asset bounds as ``(xmin, ymin, xmax, ymax)``.""" xmin = self.canvas_origin_x ymin = self.canvas_origin_y xmax = xmin + self.canvas_size_w ymax = ymin + self.canvas_size_h return xmin, ymin, xmax, ymax
[docs] def apply_transform_bounds( self, bounds: tuple[float, float, float, float], *, rebuild: bool = True, ) -> None: """Apply rectangle bounds produced by map drag/resize handles.""" xmin, ymin, xmax, ymax = (float(v) for v in bounds) if xmax < xmin: xmin, xmax = xmax, xmin if ymax < ymin: ymin, ymax = ymax, ymin eps = 1e-9 self.update_canvas( x=xmin, y=ymin, width=max(eps, xmax - xmin), height=max(eps, ymax - ymin), rebuild=rebuild, )
# ------------------------------------------------------------------ # Data update # ------------------------------------------------------------------
[docs] def update_data( self, series: Sequence[Sequence[float]] | None = None, labels: Sequence[str] | None = None, colors: Sequence[tuple] | None = None, rebuild: bool = True, ) -> None: if series is not None: self.series = [list(s) for s in series] if labels is not None: self.labels = list(labels) if colors is not None: self.colors = [tuple(c) for c in colors] if rebuild: self.rebuild(ToCheck=True)
# ------------------------------------------------------------------ # Rebuild # ------------------------------------------------------------------
[docs] def rebuild(self, ToCheck: bool = True) -> Zones: """Rebuild geometry from current parameters and update mapviewer.""" bound = self._find_bound_zones_in_mapviewer() if bound is not None: self.id = str(bound.idx) else: self.id = self._resolve_unique_id(self.id) asset = self._build_asset() new_zones = asset.to_zones() new_zones.idx = self.id new_zones._boxplot_controller = self if self.mapviewer is None: self.zones = new_zones return new_zones old = bound if bound is not None else self._get_vector_by_id(self.id) # Safety: do not overwrite a different controller's object. if old is not None and getattr(old, '_boxplot_controller', None) not in (None, self): self.id = self._resolve_unique_id(self.id) new_zones.idx = self.id old = None # Preferred path: replace in-place to keep tree/GUI state stable. if old is not None and hasattr(self.mapviewer, 'replace_object'): try: try: new_zones.change_gui(self.mapviewer) except Exception: pass new_zones.plotted = getattr(old, 'plotted', ToCheck) self.mapviewer.replace_object( self.id, new_zones, drawing_type=self._get_vector_draw_type() ) self.zones = new_zones self.mapviewer.Refresh() return new_zones except Exception: pass try: if old is not None: self.mapviewer.removeobj_from_id(self.id) self.mapviewer.add_object('vector', newobj=new_zones, id=self.id, ToCheck=ToCheck) new_zones._boxplot_controller = self self.zones = new_zones self.mapviewer.Refresh() return new_zones except Exception: self.zones = old raise
# ------------------------------------------------------------------ # Mapviewer helpers (identical pattern to bar controller) # ------------------------------------------------------------------
[docs] def _get_mapviewer_objects(self) -> list: if self.mapviewer is None or not hasattr(self.mapviewer, "get_list_objects"): return [] try: return list(self.mapviewer.get_list_objects(drawing_type=None, checked_state=None)) except TypeError: return list(self.mapviewer.get_list_objects(None, checked_state=None)) except Exception: return []
[docs] def _get_vector_draw_type(self): from ...PyDraw import draw_type return draw_type.VECTORS
[docs] def _get_vector_by_id(self, obj_id: str): if self.mapviewer is None or not hasattr(self.mapviewer, "get_obj_from_id"): return None return self.mapviewer.get_obj_from_id(obj_id, drawing_type=self._get_vector_draw_type())
[docs] def _find_bound_zones_in_mapviewer(self) -> Zones | None: if self.zones is None or self.mapviewer is None: return None for curobj in self._get_mapviewer_objects(): if curobj is self.zones: return self.zones return None
[docs] def _resolve_unique_id(self, preferred: str, exclude_obj=None) -> str: base = str(preferred).strip() or "boxplot" if self.mapviewer is None: return base used_ids = { str(getattr(o, "idx", "")).lower() for o in self._get_mapviewer_objects() if o is not exclude_obj and str(getattr(o, "idx", "")).strip() != "" } if base.lower() not in used_ids: return base k = 1 while f"{base}_{k:03d}".lower() in used_ids: k += 1 return f"{base}_{k:03d}"
[docs] def attach_to_mapviewer(self, mapviewer, id: str | None = None, ToCheck: bool = True) -> Zones: self.mapviewer = mapviewer if id is not None and id != "": self.id = str(id) return self.rebuild(ToCheck=ToCheck)
[docs] def show_editor(self, parent=None): if self.editor is None: from .editor import BoxplotZonesEditor self.editor = BoxplotZonesEditor(parent=parent, controller=self) self.editor.Show() self.editor.Raise() return self.editor
# ------------------------------------------------------------------ # Serialization # ------------------------------------------------------------------
[docs] def to_dict(self) -> dict: return { "series": self.series, "canvas_origin": [self.canvas_origin_x, self.canvas_origin_y], "canvas_size": [self.canvas_size_w, self.canvas_size_h], "area_fraction": list(self.area_fraction), "id": self.id, "labels": self.labels, "colors": [list(c) for c in self.colors] if self.colors else None, "whis": self.whis, "y_range_mode": self.y_range_mode, "y_min": self.y_min, "y_max": self.y_max, "box_width_fraction": self.box_width_fraction, "show_mean": self.show_mean, "show_outliers": self.show_outliers, "show_labels": self.show_labels, "show_area_frame": self.show_area_frame, "frame_color": list(self.frame_color), "border_color": list(self.border_color), "median_color": list(self.median_color), "legend_text_color": list(self.legend_text_color), "frame_line_width": self.frame_line_width, "box_line_width": self.box_line_width, "whisker_line_width": self.whisker_line_width, "median_line_width": self.median_line_width, }
@staticmethod
[docs] def from_dict(data: dict) -> "BoxplotZonesController": return BoxplotZonesController( series=data.get("series", [[0, 1, 2, 3, 4]]), canvas_origin=tuple(data.get("canvas_origin", [0.0, 0.0])), canvas_size=tuple(data.get("canvas_size", [100.0, 80.0])), area_fraction=tuple(data.get("area_fraction", [0.10, 0.08, 0.92, 0.88])), id=str(data.get("id", "boxplot")), labels=data.get("labels"), colors=[tuple(c) for c in data.get("colors", [])] or None if data.get("colors") else None, whis=float(data.get("whis", 1.5)), y_range_mode=str(data.get("y_range_mode", "auto")), y_min=data.get("y_min"), y_max=data.get("y_max"), box_width_fraction=float(data.get("box_width_fraction", 0.55)), show_mean=bool(data.get("show_mean", False)), show_outliers=bool(data.get("show_outliers", True)), show_labels=bool(data.get("show_labels", True)), show_area_frame=bool(data.get("show_area_frame", True)), frame_color=tuple(data.get("frame_color", [40, 40, 40])), border_color=tuple(data.get("border_color", [25, 25, 25])), median_color=tuple(data.get("median_color", [20, 20, 20])), legend_text_color=tuple(data.get("legend_text_color", [0, 0, 0])), frame_line_width=float(data.get("frame_line_width", 1.0)), box_line_width=float(data.get("box_line_width", 1.0)), whisker_line_width=float(data.get("whisker_line_width", 1.0)), median_line_width=float(data.get("median_line_width", 2.0)), )
[docs] def save_json(self, path: str | Path) -> None: with open(path, "w") as f: json.dump(self.to_dict(), f, indent=2)
@staticmethod
[docs] def load_json(path: str | Path) -> "BoxplotZonesController": with open(path) as f: data = json.load(f) return BoxplotZonesController.from_dict(data)