"""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]),
)
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.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
[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)
# ------------------------------------------------------------------
# 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)