Source code for wolfhece.assets.bar.controller

"""Persistent controller for editable bar chart assets.

The controller 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 BarZonesAsset

if TYPE_CHECKING:
    from .editor import BarZonesEditor


[docs] class BarZonesController: """Persistent controller for editable bar assets.""" def __init__( self, values: Sequence[float], position: tuple[float, float] = (0.0, 0.0), width: float = 10.0, height: float = 2.0, *, id: str = "bar_chart", orientation: str = "horizontal", labels: Sequence[str] | None = None, colors: Sequence[tuple[int, int, int] | tuple[int, int, int, int]] | None = None, legend_visible: bool = True, legend_show_percent: bool = True, legend_show_value: bool = False, legend_offset_factor: float = 0.3, legend_position_mode: str = "auto", legend_positions: Sequence[tuple[float, float] | None] | None = None, legend_text_color: tuple[int, int, int] = (0, 0, 0), border_enabled: bool = True, border_width: float = 1.0, border_color: tuple[int, int, int] = (25, 25, 25), mapviewer=None, editor: BarZonesEditor | None = None, ) -> None:
[docs] self.values = list(values)
[docs] self.position_x = float(position[0])
[docs] self.position_y = float(position[1])
[docs] self.width = float(width)
[docs] self.height = float(height)
[docs] self.orientation = str(orientation).strip().lower()
[docs] self.id = str(id)
[docs] self.labels = list(labels) if labels is not None else None
[docs] self.colors = [tuple(c) for c in colors] if colors is not None else None
[docs] self.legend_visible = bool(legend_visible)
[docs] self.legend_show_percent = bool(legend_show_percent)
[docs] self.legend_show_value = bool(legend_show_value)
[docs] self.legend_offset_factor = float(legend_offset_factor)
[docs] self.legend_position_mode = str(legend_position_mode).strip().lower() or "auto"
if self.legend_position_mode not in {"auto", "manual"}: raise ValueError("legend_position_mode must be 'auto' or 'manual'")
[docs] self.legend_positions = self._normalize_legend_positions(legend_positions)
[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.border_enabled = bool(border_enabled)
[docs] self.border_width = float(border_width)
[docs] self.border_color = tuple(border_color)
[docs] self.mapviewer = mapviewer
[docs] self.editor = editor
[docs] self.zones: Zones | None = None
[docs] def _normalize_legend_positions( self, positions: Sequence[tuple[float, float] | None] | None, ) -> list[tuple[float, float] | None]: count = len(self.values) if positions is None: return [None] * count pos_list = list(positions) if len(pos_list) < count: pos_list.extend([None] * (count - len(pos_list))) return pos_list[:count]
[docs] def _ensure_legend_positions_length(self) -> None: count = len(self.values) if len(self.legend_positions) < count: self.legend_positions.extend([None] * (count - len(self.legend_positions))) elif len(self.legend_positions) > count: self.legend_positions = self.legend_positions[:count]
[docs] def _build_asset(self) -> BarZonesAsset: """Build BarZonesAsset from current controller state.""" return BarZonesAsset( values=self.values, position=(self.position_x, self.position_y), width=self.width, height=self.height, orientation=self.orientation, labels=self.labels, colors=self.colors, legend_visible=self.legend_visible, legend_show_percent=self.legend_show_percent, legend_show_value=self.legend_show_value, legend_offset_factor=self.legend_offset_factor, legend_position_mode=self.legend_position_mode, legend_positions=self.legend_positions, legend_text_color=self.legend_text_color, border_enabled=self.border_enabled, border_width=self.border_width, border_color=self.border_color, idx=self.id, mapviewer=self.mapviewer, )
[docs] def update_data( self, values: Sequence[float] | None = None, labels: Sequence[str] | None = None, colors: Sequence[tuple[int, int, int] | tuple[int, int, int, int]] | None = None, rebuild: bool = True, ) -> None: """Update segment data.""" if values is not None: self.values = list(values) self._ensure_legend_positions_length() 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)
[docs] def update_geometry( self, x: float | None = None, y: float | None = None, width: float | None = None, height: float | None = None, orientation: str | None = None, rebuild: bool = True, ) -> None: """Update bar geometry.""" if x is not None: self.position_x = float(x) if y is not None: self.position_y = float(y) if width is not None: self.width = float(width) if height is not None: self.height = float(height) if orientation is not None: o = str(orientation).strip().lower() if o not in {"horizontal", "vertical"}: raise ValueError("orientation must be 'horizontal' or 'vertical'") self.orientation = o 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 = float(self.position_x) ymin = float(self.position_y) xmax = xmin + float(self.width) ymax = ymin + float(self.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( x=xmin, y=ymin, width=max(eps, xmax - xmin), height=max(eps, ymax - ymin), rebuild=rebuild, )
[docs] def update_legend_layout( self, *, position_mode: str | None = None, positions: Sequence[tuple[float, float] | None] | None = None, offset_factor: float | None = None, legend_text_color: tuple[int, int, int] | None = None, rebuild: bool = True, ) -> None: """Update legend layout.""" if position_mode is not None: mode = str(position_mode).strip().lower() or "auto" if mode not in {"auto", "manual"}: raise ValueError("legend_position_mode must be 'auto' or 'manual'") self.legend_position_mode = mode if positions is not None: self.legend_positions = self._normalize_legend_positions(positions) else: self._ensure_legend_positions_length() if offset_factor is not None: self.legend_offset_factor = float(offset_factor) 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 _sync_effective_legend_positions(self) -> None: """Read back legend positions from the built Zones vectors. In 'auto' mode the positions are computed inside ``to_zones()`` but never stored in the controller. This helper copies them back so that the editor grid can display them. """ if self.zones is None: return try: vecs = self.zones.myzones[0].myvectors except (IndexError, AttributeError): return positions: list[tuple[float, float] | None] = [] for v in vecs: try: positions.append((float(v.myprop.legendx), float(v.myprop.legendy))) except Exception: positions.append(None) self.legend_positions = positions
[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: # Keep controller id aligned with the actually bound object. self.id = str(bound.idx) else: # Direct attach calls may bypass mapviewer-level id checks. self.id = self._resolve_unique_id(self.id) asset = self._build_asset() new_zones = asset.to_zones() new_zones.idx = self.id new_zones._bar_controller = self if self.mapviewer is None: self.zones = new_zones self._sync_effective_legend_positions() return new_zones old = bound if bound is not None else self._get_vector_by_id(self.id) # Safety: if id lookup points to another bar controller, do not replace it. if old is not None and getattr(old, '_bar_controller', None) not in (None, self): self.id = self._resolve_unique_id(self.id) new_zones.idx = self.id old = None # Preferred path: replace object 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._sync_effective_legend_positions() self.mapviewer.Refresh() return new_zones except Exception: # Fall back to remove/add path below. 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._bar_controller = self self.zones = new_zones self._sync_effective_legend_positions() self.mapviewer.Refresh() return new_zones except Exception: # Roll back to the previous object so UI edits cannot make the bar disappear. self.zones = old raise
[docs] def _get_mapviewer_objects(self) -> list: """Return all mapviewer objects, or an empty list if unavailable.""" 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: # Backward-compatible signature fallback. return list(self.mapviewer.get_list_objects(None, checked_state=None)) except Exception: return []
[docs] def _get_vector_draw_type(self): """Resolve the viewer enum value for vector objects lazily.""" from ...PyDraw import draw_type return draw_type.VECTORS
[docs] def _get_vector_by_id(self, obj_id: str): """Return a vector object by id without scanning unrelated draw types.""" 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: """Return the currently bound Zones object if it is still present in the viewer.""" 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: """Return an id not used by other objects in the viewer.""" base = str(preferred).strip() or "bar_chart" 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: """Attach the controller to a mapviewer and rebuild geometry.""" 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): """Open the editor UI window for this controller.""" if self.editor is None: from .editor import BarZonesEditor self.editor = BarZonesEditor(parent=parent, controller=self) self.editor.Show() self.editor.Raise() return self.editor
[docs] def to_dict(self) -> dict: """Serialize controller state to dictionary.""" return { "values": self.values, "position": [self.position_x, self.position_y], "width": self.width, "height": self.height, "orientation": self.orientation, "id": self.id, "labels": self.labels, "colors": [list(c) for c in self.colors] if self.colors else None, "legend_visible": self.legend_visible, "legend_show_percent": self.legend_show_percent, "legend_show_value": self.legend_show_value, "legend_offset_factor": self.legend_offset_factor, "legend_position_mode": self.legend_position_mode, "legend_positions": [list(pos) if pos is not None else None for pos in self.legend_positions], "legend_text_color": list(self.legend_text_color), "border_enabled": self.border_enabled, "border_width": self.border_width, "border_color": list(self.border_color), }
@staticmethod
[docs] def from_dict(data: dict) -> BarZonesController: """Deserialize controller state from dictionary.""" return BarZonesController( values=data.get("values", [1]), position=tuple(data.get("position", [0, 0])), width=float(data.get("width", 10)), height=float(data.get("height", 2)), id=str(data.get("id", "bar_chart")), orientation=str(data.get("orientation", "horizontal")), labels=data.get("labels"), colors=data.get("colors"), legend_visible=bool(data.get("legend_visible", True)), legend_show_percent=bool(data.get("legend_show_percent", True)), legend_show_value=bool(data.get("legend_show_value", False)), legend_offset_factor=float(data.get("legend_offset_factor", 0.3)), legend_position_mode=str(data.get("legend_position_mode", "auto")), legend_positions=[tuple(pos) if pos is not None else None for pos in data.get("legend_positions", [])], legend_text_color=tuple(data.get("legend_text_color", [0, 0, 0])), border_enabled=bool(data.get("border_enabled", True)), border_width=float(data.get("border_width", 1.0)), border_color=tuple(data.get("border_color", [25, 25, 25])), )
[docs] def save_json(self, path: str | Path) -> None: """Save controller state to JSON file.""" with open(path, "w") as f: json.dump(self.to_dict(), f, indent=2)
@staticmethod
[docs] def load_json(path: str | Path) -> BarZonesController: """Load controller state from JSON file.""" with open(path) as f: data = json.load(f) return BarZonesController.from_dict(data)