Source code for wolfhece.assets.pie.controller

from __future__ import annotations

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

from ...PyVertexvectors import Zones
from .zones_asset import PieZonesAsset

if TYPE_CHECKING:
    from .editor import PieZonesEditor


[docs] class PieZonesController: """Persistent controller for editable pie assets. The controller stores editable parameters, keeps a pointer to the current Zones object in the mapviewer, and can rebuild geometry dynamically. """ def __init__( self, values: Sequence[float], center: tuple[float, float], radius: float, *, id: str = "pie_chart", labels: Sequence[str] | None = None, colors: Sequence[tuple[int, int, int] | tuple[int, int, int, int]] | None = None, start_angle_deg: float = 90.0, clockwise: bool = False, sectors_as_independent_zones: bool = False, legend_visible: bool = True, legend_show_percent: bool = True, legend_show_value: bool = False, legend_offset_factor: float = 0.65, legend_position_mode: str = "auto", legend_positions: Sequence[tuple[float, float] | None] | None = None, legend_text_color: tuple[int, int, int] = (0, 0, 0), fill_clock_animation: bool = False, fill_anim_speed: float = 1.0, smoothing: bool = True, border_enabled: bool = True, border_width: float = 1.0, border_color: tuple[int, int, int] = (25, 25, 25), store_fraction_in_z: bool = True, ) -> None:
[docs] self.id = str(id)
[docs] self.values = [float(v) for v in values]
if labels is None: self.labels = [f"Slice {i + 1}" for i in range(len(self.values))] else: self.labels = [str(lbl) for lbl in labels] if len(self.labels) != len(self.values): raise ValueError("labels length must match values length")
[docs] self.colors = [] if colors is None else [tuple(c) for c in colors]
[docs] self.center_x = float(center[0])
[docs] self.center_y = float(center[1])
[docs] self.radius = float(radius)
[docs] self.start_angle_deg = float(start_angle_deg)
[docs] self.clockwise = bool(clockwise)
[docs] self.sectors_as_independent_zones = bool(sectors_as_independent_zones)
[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.fill_clock_animation = bool(fill_clock_animation)
[docs] self.fill_anim_speed = float(fill_anim_speed)
[docs] self.smoothing = bool(smoothing)
[docs] self.border_enabled = bool(border_enabled)
[docs] self.border_width = float(border_width)
[docs] self.border_color = tuple(border_color)
[docs] self.store_fraction_in_z = bool(store_fraction_in_z)
[docs] self.mapviewer = None
[docs] self.zones: Zones | None = None
[docs] self.editor: PieZonesEditor | None = None
[docs] def _normalize_legend_positions( self, positions: Sequence[tuple[float, float] | None] | None, ) -> list[tuple[float, float] | None]: if positions is None: return [None] * len(self.values) out: list[tuple[float, float] | None] = [] for pos in positions: if pos is None: out.append(None) else: out.append((float(pos[0]), float(pos[1]))) if len(out) < len(self.values): out.extend([None] * (len(self.values) - len(out))) elif len(out) > len(self.values): out = out[:len(self.values)] return out
[docs] def _ensure_legend_positions_length(self) -> None: self.legend_positions = self._normalize_legend_positions(self.legend_positions)
[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 "pie_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 _build_asset(self) -> PieZonesAsset: return PieZonesAsset( values=self.values, center=(self.center_x, self.center_y), radius=self.radius, labels=self.labels, colors=self.colors if len(self.colors) > 0 else None, start_angle_deg=self.start_angle_deg, clockwise=self.clockwise, sectors_as_independent_zones=self.sectors_as_independent_zones, 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, fill_clock_animation=self.fill_clock_animation, fill_anim_speed=self.fill_anim_speed, smoothing=self.smoothing, border_enabled=self.border_enabled, border_width=self.border_width, border_color=self.border_color, store_fraction_in_z=self.store_fraction_in_z, idx=self.id, parent=self.mapviewer, mapviewer=self.mapviewer, )
[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 _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._pie_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 pie controller, do not replace it. if old is not None and getattr(old, '_pie_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._pie_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 pie disappear. if old is not None and self._get_vector_by_id(self.id) is None: self.mapviewer.add_object('vector', newobj=old, id=self.id, ToCheck=ToCheck) old._pie_controller = self self.zones = old self.mapviewer.Refresh() raise
[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: if values is not None: self.values = [float(v) for v in values] if labels is not None: self.labels = [str(lbl) for lbl in labels] if colors is not None: self.colors = [tuple(c) for c in colors] if len(self.labels) != len(self.values): raise ValueError("labels length must match values length") self._ensure_legend_positions_length() if rebuild: self.rebuild(ToCheck=True)
[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: 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 update_geometry( self, x: float | None = None, y: float | None = None, radius: float | None = None, start_angle_deg: float | None = None, clockwise: bool | None = None, rebuild: bool = True, ) -> None: if x is not None: self.center_x = float(x) if y is not None: self.center_y = float(y) if radius is not None: self.radius = float(radius) if start_angle_deg is not None: self.start_angle_deg = float(start_angle_deg) if clockwise is not None: self.clockwise = bool(clockwise) 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)``.""" r = float(self.radius) return ( float(self.center_x) - r, float(self.center_y) - r, float(self.center_x) + r, float(self.center_y) + r, )
[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. Pie is circular: center follows bounds midpoint and radius is derived from the smallest side so the circle remains fully inside the box. """ xmin, ymin, xmax, ymax = (float(v) for v in bounds) if xmax < xmin: xmin, xmax = xmax, xmin if ymax < ymin: ymin, ymax = ymax, ymin cx = 0.5 * (xmin + xmax) cy = 0.5 * (ymin + ymax) r = max(1e-9, 0.5 * min(xmax - xmin, ymax - ymin)) self.update_geometry(x=cx, y=cy, radius=r, rebuild=rebuild)
[docs] def to_dict(self) -> dict: return { "id": self.id, "values": self.values, "labels": self.labels, "colors": [list(c) for c in self.colors], "center": [self.center_x, self.center_y], "radius": self.radius, "start_angle_deg": self.start_angle_deg, "clockwise": self.clockwise, "sectors_as_independent_zones": self.sectors_as_independent_zones, "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), "fill_clock_animation": self.fill_clock_animation, "fill_anim_speed": self.fill_anim_speed, "smoothing": self.smoothing, "border_enabled": self.border_enabled, "border_width": self.border_width, "border_color": list(self.border_color), "store_fraction_in_z": self.store_fraction_in_z, }
@classmethod
[docs] def from_dict(cls, data: dict) -> "PieZonesController": center = data.get("center", [0.0, 0.0]) return cls( values=data.get("values", []), labels=data.get("labels", None), colors=[tuple(c) for c in data.get("colors", [])], center=(float(center[0]), float(center[1])), radius=float(data.get("radius", 10.0)), id=str(data.get("id", "pie_chart")), start_angle_deg=float(data.get("start_angle_deg", 90.0)), clockwise=bool(data.get("clockwise", False)), sectors_as_independent_zones=bool(data.get("sectors_as_independent_zones", False)), 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.65)), 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])), fill_clock_animation=bool(data.get("fill_clock_animation", False)), fill_anim_speed=float(data.get("fill_anim_speed", 1.0)), smoothing=bool(data.get("smoothing", True)), 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])), store_fraction_in_z=bool(data.get("store_fraction_in_z", True)), )
[docs] def save_json(self, filepath: str | Path) -> Path: target = Path(filepath) if target.suffix.lower() != ".json": target = target.with_suffix(".json") with open(target, "w", encoding="utf-8") as f: json.dump(self.to_dict(), f, ensure_ascii=False, indent=2) return target
@classmethod
[docs] def load_json(cls, filepath: str | Path) -> "PieZonesController": src = Path(filepath) with open(src, "r", encoding="utf-8") as f: data = json.load(f) return cls.from_dict(data)
[docs] def save_vec_or_vecz(self, filepath: str | Path) -> Path: if self.zones is None: self.rebuild(ToCheck=True) target = Path(filepath) if target.suffix.lower() not in (".vec", ".vecz"): target = target.with_suffix(".vecz" if self.store_fraction_in_z else ".vec") self.zones.saveas(str(target)) return target
[docs] def show_editor(self, parent=None): if self.editor is None: from .editor import PieZonesEditor self.editor = PieZonesEditor(parent=parent, controller=self) self.editor.Show() self.editor.Raise() return self.editor
[docs] def build_pie_controller( values: Sequence[float], center: tuple[float, float], radius: float, **kwargs, ) -> PieZonesController: """Convenience function returning a persistent pie controller.""" return PieZonesController(values=values, center=center, radius=radius, **kwargs)