Source code for wolfhece.assets.curve.controller

"""Persistent controller for editable curve plot assets."""

from __future__ import annotations

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

from ...PyVertexvectors import Zones
from .zones_asset import CurveZonesAsset

if TYPE_CHECKING:
    from .editor import CurveZonesEditor


[docs] class CurveZonesController: """Persistent controller for curve plots in a canvas fraction.""" def __init__( self, curves: Sequence[Sequence[tuple[float, float]]], *, id: str = "curve_plot", labels: Sequence[str] | None = None, x_bounds: tuple[float, float] | None = None, y_bounds: tuple[float, float] | None = None, clamp_projected_points: bool = True, sort_by_x: bool = True, colors: Sequence[tuple[int, int, int] | tuple[int, int, int, int]] | None = None, curve_styles: Sequence[dict] | None = None, canvas_origin: tuple[float, float] = (0.0, 0.0), canvas_size: tuple[float, float] = (100.0, 100.0), area_fraction: tuple[float, float, float, float] = (0.1, 0.1, 0.8, 0.3), line_width: float = 1.5, line_alpha: int = 255, show_area_frame: bool = True, frame_color: tuple[int, int, int] = (40, 40, 40), show_axes: bool = False, axes_color: tuple[int, int, int] = (25, 25, 25), axes_line_width: float = 1.2, show_grid: bool = False, grid_color: tuple[int, int, int] = (120, 120, 120), grid_line_width: float = 0.8, grid_dx: float = 1.0, grid_dy: float = 1.0, show_x_ticks: bool = False, show_y_ticks: bool = False, show_x_tick_labels: bool = False, show_y_tick_labels: bool = False, x_tick_position: str = "inside", y_tick_position: str = "inside", x_tick_format: str = "", y_tick_format: str = "", legend_visible: bool = False, legend_text_color: tuple[int, int, int] = (0, 0, 0), mapviewer=None, editor: CurveZonesEditor | None = None, ) -> None:
[docs] self.id = str(id)
[docs] self.curves = [[(float(x), float(y)) for x, y in curve] for curve in curves]
if not self.curves: raise ValueError("At least one curve is required")
[docs] self.labels = [str(lbl) for lbl in labels] if labels is not None else [f"Curve {i + 1}" for i in range(len(self.curves))]
if len(self.labels) != len(self.curves): raise ValueError("labels length must match curves length")
[docs] self.x_bounds = None if x_bounds is None else (float(x_bounds[0]), float(x_bounds[1]))
[docs] self.y_bounds = None if y_bounds is None else (float(y_bounds[0]), float(y_bounds[1]))
[docs] self.clamp_projected_points = bool(clamp_projected_points)
[docs] self.sort_by_x = bool(sort_by_x)
[docs] self.colors = [tuple(c) for c in colors] if colors is not None else None
[docs] self.canvas_x = float(canvas_origin[0])
[docs] self.canvas_y = float(canvas_origin[1])
[docs] self.canvas_width = float(canvas_size[0])
[docs] self.canvas_height = float(canvas_size[1])
[docs] self.area_fraction = tuple(float(v) for v in area_fraction)
[docs] self.line_width = float(line_width)
[docs] self.line_alpha = int(max(0, min(255, line_alpha)))
[docs] self.curve_styles = self._normalize_curve_styles(curve_styles)
[docs] self.show_area_frame = bool(show_area_frame)
[docs] self.frame_color = tuple(frame_color)
[docs] self.show_axes = bool(show_axes)
[docs] self.axes_color = tuple(axes_color)
[docs] self.axes_line_width = max(0.1, float(axes_line_width))
[docs] self.show_grid = bool(show_grid)
[docs] self.grid_color = tuple(grid_color)
[docs] self.grid_line_width = max(0.1, float(grid_line_width))
[docs] self.grid_dx = float(grid_dx)
[docs] self.grid_dy = float(grid_dy)
if self.grid_dx <= 0 or self.grid_dy <= 0: raise ValueError("grid_dx and grid_dy must be > 0")
[docs] self.show_x_ticks = bool(show_x_ticks)
[docs] self.show_y_ticks = bool(show_y_ticks)
[docs] self.show_x_tick_labels = bool(show_x_tick_labels)
[docs] self.show_y_tick_labels = bool(show_y_tick_labels)
[docs] self.x_tick_position = str(x_tick_position).strip().lower()
[docs] self.y_tick_position = str(y_tick_position).strip().lower()
if self.x_tick_position not in {"inside", "outside", "centered"}: self.x_tick_position = "inside" if self.y_tick_position not in {"inside", "outside", "centered"}: self.y_tick_position = "inside"
[docs] self.x_tick_format = str(x_tick_format)
[docs] self.y_tick_format = str(y_tick_format)
[docs] self.legend_visible = bool(legend_visible)
[docs] self.legend_text_color = ( int(max(0, min(255, legend_text_color[0]))), int(max(0, min(255, legend_text_color[1]))), int(max(0, min(255, legend_text_color[2]))), )
[docs] self.mapviewer = mapviewer
[docs] self.editor = editor
[docs] self.zones: Zones | None = None
[docs] def _normalize_curve_styles(self, styles: Sequence[dict] | None) -> list[dict]: count = len(self.curves) normalized: list[dict] = [] allowed = {"solid", "dashed", "dotted", "dashdot"} for i in range(count): src = styles[i] if (styles is not None and i < len(styles)) else {} if src is None: src = {} lw = float(src.get("line_width", self.line_width)) if lw <= 0: lw = self.line_width ls = str(src.get("line_style", "solid")).strip().lower() if ls not in allowed: ls = "solid" normalized.append({"line_width": lw, "line_style": ls}) return normalized
[docs] def _build_asset(self) -> CurveZonesAsset: return CurveZonesAsset( curves=self.curves, labels=self.labels, x_bounds=self.x_bounds, y_bounds=self.y_bounds, clamp_projected_points=self.clamp_projected_points, sort_by_x=self.sort_by_x, colors=self.colors, curve_styles=self.curve_styles, canvas_origin=(self.canvas_x, self.canvas_y), canvas_size=(self.canvas_width, self.canvas_height), area_fraction=self.area_fraction, line_width=self.line_width, line_alpha=self.line_alpha, show_area_frame=self.show_area_frame, frame_color=self.frame_color, show_axes=self.show_axes, axes_color=self.axes_color, axes_line_width=self.axes_line_width, show_grid=self.show_grid, grid_color=self.grid_color, grid_line_width=self.grid_line_width, grid_dx=self.grid_dx, grid_dy=self.grid_dy, show_x_ticks=self.show_x_ticks, show_y_ticks=self.show_y_ticks, show_x_tick_labels=self.show_x_tick_labels, show_y_tick_labels=self.show_y_tick_labels, x_tick_position=self.x_tick_position, y_tick_position=self.y_tick_position, x_tick_format=self.x_tick_format, y_tick_format=self.y_tick_format, legend_visible=self.legend_visible, legend_text_color=self.legend_text_color, idx=self.id, parent=self.mapviewer, mapviewer=self.mapviewer, )
[docs] def update_curves( self, curves: Sequence[Sequence[tuple[float, float]]] | None = None, labels: Sequence[str] | None = None, colors: Sequence[tuple[int, int, int] | tuple[int, int, int, int]] | None = None, curve_styles: Sequence[dict] | None = None, rebuild: bool = True, ) -> None: if curves is not None: self.curves = [[(float(x), float(y)) for x, y in curve] for curve in curves] if not self.curves: raise ValueError("At least one curve is required") if labels is not None: self.labels = [str(lbl) for lbl in labels] if len(self.labels) != len(self.curves): raise ValueError("labels length must match curves length") if colors is not None: self.colors = [tuple(c) for c in colors] if curve_styles is not None: self.curve_styles = self._normalize_curve_styles(curve_styles) elif curves is not None: self.curve_styles = self._normalize_curve_styles(self.curve_styles) if rebuild: self.rebuild(ToCheck=True)
[docs] def update_geometry( self, *, x_bounds: tuple[float, float] | None = None, y_bounds: tuple[float, float] | None = None, canvas_origin: tuple[float, float] | None = None, canvas_size: tuple[float, float] | None = None, area_fraction: tuple[float, float, float, float] | None = None, rebuild: bool = True, ) -> None: if x_bounds is not None: self.x_bounds = (float(x_bounds[0]), float(x_bounds[1])) if y_bounds is not None: self.y_bounds = (float(y_bounds[0]), float(y_bounds[1])) if canvas_origin is not None: self.canvas_x = float(canvas_origin[0]) self.canvas_y = float(canvas_origin[1]) if canvas_size is not None: self.canvas_width = float(canvas_size[0]) self.canvas_height = float(canvas_size[1]) if area_fraction is not None: self.area_fraction = tuple(float(v) for v in area_fraction) 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)``. For curve assets, this corresponds to the canvas rectangle in world coordinates. """ xmin = float(self.canvas_x) ymin = float(self.canvas_y) xmax = xmin + float(self.canvas_width) ymax = ymin + float(self.canvas_height) 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_geometry( canvas_origin=(xmin, ymin), canvas_size=(max(eps, xmax - xmin), max(eps, ymax - ymin)), rebuild=rebuild, )
[docs] def update_style( self, *, clamp_projected_points: bool | None = None, sort_by_x: bool | None = None, line_width: float | None = None, line_alpha: int | None = None, show_area_frame: bool | None = None, frame_color: tuple[int, int, int] | None = None, show_axes: bool | None = None, axes_color: tuple[int, int, int] | None = None, axes_line_width: float | None = None, show_grid: bool | None = None, grid_color: tuple[int, int, int] | None = None, grid_line_width: float | None = None, grid_dx: float | None = None, grid_dy: float | None = None, show_x_ticks: bool | None = None, show_y_ticks: bool | None = None, show_x_tick_labels: bool | None = None, show_y_tick_labels: bool | None = None, x_tick_position: str | None = None, y_tick_position: str | None = None, x_tick_format: str | None = None, y_tick_format: str | None = None, legend_visible: bool | None = None, legend_text_color: tuple[int, int, int] | None = None, rebuild: bool = True, ) -> None: if clamp_projected_points is not None: self.clamp_projected_points = bool(clamp_projected_points) if sort_by_x is not None: self.sort_by_x = bool(sort_by_x) if line_width is not None: self.line_width = float(line_width) if line_alpha is not None: self.line_alpha = int(max(0, min(255, line_alpha))) if show_area_frame is not None: self.show_area_frame = bool(show_area_frame) if frame_color is not None: self.frame_color = tuple(frame_color) if show_axes is not None: self.show_axes = bool(show_axes) if axes_color is not None: self.axes_color = tuple(axes_color) if axes_line_width is not None: self.axes_line_width = max(0.1, float(axes_line_width)) if show_grid is not None: self.show_grid = bool(show_grid) if grid_color is not None: self.grid_color = tuple(grid_color) if grid_line_width is not None: self.grid_line_width = max(0.1, float(grid_line_width)) if grid_dx is not None: self.grid_dx = float(grid_dx) if self.grid_dx <= 0: raise ValueError("grid_dx must be > 0") if grid_dy is not None: self.grid_dy = float(grid_dy) if self.grid_dy <= 0: raise ValueError("grid_dy must be > 0") if show_x_ticks is not None: self.show_x_ticks = bool(show_x_ticks) if show_y_ticks is not None: self.show_y_ticks = bool(show_y_ticks) if show_x_tick_labels is not None: self.show_x_tick_labels = bool(show_x_tick_labels) if show_y_tick_labels is not None: self.show_y_tick_labels = bool(show_y_tick_labels) if x_tick_position is not None: val = str(x_tick_position).strip().lower() if val not in {"inside", "outside", "centered"}: raise ValueError("x_tick_position must be one of: inside, outside, centered") self.x_tick_position = val if y_tick_position is not None: val = str(y_tick_position).strip().lower() if val not in {"inside", "outside", "centered"}: raise ValueError("y_tick_position must be one of: inside, outside, centered") self.y_tick_position = val if x_tick_format is not None: self.x_tick_format = str(x_tick_format) if y_tick_format is not None: self.y_tick_format = str(y_tick_format) if legend_visible is not None: self.legend_visible = bool(legend_visible) if legend_text_color is not None: self.legend_text_color = ( int(max(0, min(255, legend_text_color[0]))), int(max(0, min(255, legend_text_color[1]))), int(max(0, min(255, legend_text_color[2]))), ) if rebuild: self.rebuild(ToCheck=True)
[docs] def rebuild(self, ToCheck: bool = True) -> Zones: 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._curve_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) if old is not None and getattr(old, "_curve_controller", None) not in (None, self): self.id = self._resolve_unique_id(self.id) new_zones.idx = self.id old = None 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._curve_controller = self self.zones = new_zones self.mapviewer.Refresh() return new_zones except Exception: self.zones = old raise
[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 "curve_plot" if self.mapviewer is None: return base used_ids = { str(getattr(curobj, "idx", "")).lower() for curobj in self._get_mapviewer_objects() if curobj is not exclude_obj and str(getattr(curobj, "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 CurveZonesEditor self.editor = CurveZonesEditor(parent=parent, controller=self) self.editor.Show() self.editor.Raise() return self.editor
[docs] def to_dict(self) -> dict: return { "id": self.id, "curves": [[list(pt) for pt in curve] for curve in self.curves], "labels": self.labels, "x_bounds": None if self.x_bounds is None else list(self.x_bounds), "y_bounds": None if self.y_bounds is None else list(self.y_bounds), "clamp_projected_points": self.clamp_projected_points, "sort_by_x": self.sort_by_x, "colors": [list(c) for c in self.colors] if self.colors is not None else None, "curve_styles": self.curve_styles, "canvas_origin": [self.canvas_x, self.canvas_y], "canvas_size": [self.canvas_width, self.canvas_height], "area_fraction": list(self.area_fraction), "line_width": self.line_width, "line_alpha": self.line_alpha, "show_area_frame": self.show_area_frame, "frame_color": list(self.frame_color), "show_axes": self.show_axes, "axes_color": list(self.axes_color), "axes_line_width": self.axes_line_width, "show_grid": self.show_grid, "grid_color": list(self.grid_color), "grid_line_width": self.grid_line_width, "grid_dx": self.grid_dx, "grid_dy": self.grid_dy, "show_x_ticks": self.show_x_ticks, "show_y_ticks": self.show_y_ticks, "show_x_tick_labels": self.show_x_tick_labels, "show_y_tick_labels": self.show_y_tick_labels, "x_tick_position": self.x_tick_position, "y_tick_position": self.y_tick_position, "x_tick_format": self.x_tick_format, "y_tick_format": self.y_tick_format, "legend_visible": self.legend_visible, "legend_text_color": list(self.legend_text_color), }
@staticmethod
[docs] def from_dict(data: dict) -> "CurveZonesController": return CurveZonesController( id=str(data.get("id", "curve_plot")), curves=[ [(float(pt[0]), float(pt[1])) for pt in curve] for curve in data.get("curves", [[(0.0, 0.2), (1.0, 0.8)]]) ], labels=data.get("labels"), x_bounds=(tuple(data.get("x_bounds")) if data.get("x_bounds") is not None else None), y_bounds=(tuple(data.get("y_bounds")) if data.get("y_bounds") is not None else None), clamp_projected_points=bool(data.get("clamp_projected_points", True)), sort_by_x=bool(data.get("sort_by_x", True)), colors=data.get("colors"), curve_styles=data.get("curve_styles"), canvas_origin=tuple(data.get("canvas_origin", [0.0, 0.0])), canvas_size=tuple(data.get("canvas_size", [100.0, 100.0])), area_fraction=tuple(data.get("area_fraction", [0.1, 0.1, 0.8, 0.3])), line_width=float(data.get("line_width", 1.5)), line_alpha=int(data.get("line_alpha", 255)), show_area_frame=bool(data.get("show_area_frame", True)), frame_color=tuple(data.get("frame_color", [40, 40, 40])), show_axes=bool(data.get("show_axes", False)), axes_color=tuple(data.get("axes_color", [25, 25, 25])), axes_line_width=float(data.get("axes_line_width", 1.2)), show_grid=bool(data.get("show_grid", False)), grid_color=tuple(data.get("grid_color", [120, 120, 120])), grid_line_width=float(data.get("grid_line_width", 0.8)), grid_dx=float(data.get("grid_dx", 1.0)), grid_dy=float(data.get("grid_dy", 1.0)), show_x_ticks=bool(data.get("show_x_ticks", False)), show_y_ticks=bool(data.get("show_y_ticks", False)), show_x_tick_labels=bool(data.get("show_x_tick_labels", False)), show_y_tick_labels=bool(data.get("show_y_tick_labels", False)), x_tick_position=str(data.get("x_tick_position", "inside")), y_tick_position=str(data.get("y_tick_position", "inside")), x_tick_format=str(data.get("x_tick_format", "")), y_tick_format=str(data.get("y_tick_format", "")), legend_visible=bool(data.get("legend_visible", False)), legend_text_color=tuple(data.get("legend_text_color", [0, 0, 0])), )
[docs] def save_json(self, path: str | Path) -> None: with open(path, "w", encoding="utf-8") as f: json.dump(self.to_dict(), f, indent=2)
@staticmethod
[docs] def load_json(path: str | Path) -> "CurveZonesController": with open(path, encoding="utf-8") as f: data = json.load(f) return CurveZonesController.from_dict(data)