Source code for wolfhece._asset_manager

"""Asset-chart and interactive-transform companion object for WolfMapViewer.

All pie / bar / curve chart CRUD logic and the asset-transform drag / snap
machinery live here.  ``WolfMapViewer`` holds a single instance as
``self._assets`` and exposes one-line delegators for every public method so
that external callers and the retrocompatibility test remain unaffected.

Design notes
------------
* ``AssetManager`` owns all mutable *transform* state (8 attrs) and the snap
  *grid cache* (2 attrs) — none of these were ever part of the public API.
* Viewer-level settings (``snap_grid_unit``, ``snap_grid_round_base``) remain
  properties on ``WolfMapViewer``; ``AssetManager`` reads them via
  ``self._viewer``.
* wx parent for dialogs is always ``self._viewer``.
* Three *event-hook* methods (``on_left_down``, ``on_left_up``,
  ``on_end_action``) let the viewer delegate its mouse / action events with
  a single call rather than scattering attribute access.
"""
from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Sequence

import numpy as np
import wx


from .PyTranslate import _
from .PyVertexvectors import Zones
from .assets.pie import PieZonesController
from .assets.bar import BarZonesController
from .assets.curve import CurveZonesController
from .assets.boxplot import BoxplotZonesController
from ._pydraw_utils import draw_type

if TYPE_CHECKING:
    from .PyDraw import WolfMapViewer

__all__ = ['AssetManager']


[docs] class AssetManager: """Companion object that owns chart CRUD and asset-transform state. Instantiated once as ``viewer._assets = AssetManager(viewer)`` inside ``WolfMapViewer.__init__``. """ def __init__(self, viewer: "WolfMapViewer") -> None:
[docs] self._viewer = viewer
# ── Transform drag state ─────────────────────────────────────────────
[docs] self.transform_controller = None # active controller being dragged
[docs] self.transform_editor = None # open editor window (if any)
[docs] self.drag_handle: str | None = None
[docs] self.drag_start_xy: tuple[float, float] | None = None
[docs] self.drag_start_bounds: tuple[float, float, float, float] | None = None
[docs] self.cursor_key: str = ''
[docs] self.direct_pan: bool = False
[docs] self.editor_refresh_pending: bool = False
# ── Snap-grid cache ──────────────────────────────────────────────────
[docs] self._snap_grid_origin: tuple[float, float] | None = None
[docs] self._snap_grid_origin_signature: tuple[float, float] | None = None
# ---------------------------------------------------------------- # Event-hook methods called directly from WolfMapViewer handlers # ----------------------------------------------------------------
[docs] def on_left_down(self, x: float, y: float) -> bool: """Handle left-mouse-down in *transform asset bounds* mode. Returns ``True`` if the event was consumed (no further processing needed by the caller). """ handle = self._asset_handle_hit_test(x, y) if handle is None: return False self.drag_handle = handle self.drag_start_xy = (x, y) self.drag_start_bounds = self._get_asset_transform_bounds() self.direct_pan = False self.editor_refresh_pending = False if handle == 'c': ctrl = self.transform_controller zones = getattr(ctrl, 'zones', None) if ctrl is not None else None if zones is not None and hasattr(zones, 'set_cache') and hasattr(zones, 'move'): try: zones.set_cache() self.direct_pan = True except Exception: self.direct_pan = False self._update_asset_transform_cursor(x, y) return True
[docs] def on_left_up(self, x: float, y: float) -> None: """Flush pending editor refresh, reset drag state, update cursor.""" ctrl = self.transform_controller if self.direct_pan: zones = getattr(ctrl, 'zones', None) if ctrl is not None else None if zones is not None and hasattr(zones, 'clear_cache'): try: zones.clear_cache() except Exception: pass if self.editor_refresh_pending: ed = self.transform_editor if ed is not None and hasattr(ed, 'refresh_from_controller'): try: ed.refresh_from_controller() except Exception: pass self.drag_handle = None self.drag_start_xy = None self.drag_start_bounds = None self.direct_pan = False self.editor_refresh_pending = False self._update_asset_transform_cursor(x, y)
[docs] def on_end_action(self) -> None: """Reset transform state when a viewer action ends.""" if self.direct_pan: ctrl = self.transform_controller zones = getattr(ctrl, 'zones', None) if ctrl is not None else None if zones is not None and hasattr(zones, 'clear_cache'): try: zones.clear_cache() except Exception: pass if self.editor_refresh_pending: ed = self.transform_editor if ed is not None and hasattr(ed, 'refresh_from_controller'): try: ed.refresh_from_controller() except Exception: pass self.drag_handle = None self.drag_start_xy = None self.drag_start_bounds = None self.direct_pan = False self.editor_refresh_pending = False self._set_asset_transform_cursor(None)
# ---------------------------------------------------------------- # Pie chart CRUD # ----------------------------------------------------------------
[docs] def add_pie_asset(self, values: Sequence[float], x: float, y: float, radius: float, id: str = 'pie_chart', labels: Sequence[str] = None, colors: Sequence[tuple] = None, ToCheck: bool = True, return_controller: bool = False, open_editor: bool = False, **kwargs) -> 'Zones | PieZonesController': """Create and add a world-space pie chart with persistent controller.""" v = self._viewer existing = self._get_pie_controller(str(id).lower()) if existing is not None: existing.update_data(values=values, labels=labels, colors=colors, rebuild=False) existing.update_geometry(x=x, y=y, radius=radius, rebuild=False) for key, val in kwargs.items(): if hasattr(existing, key): setattr(existing, key, val) zones = existing.rebuild(ToCheck=ToCheck) self._bind_pie_controller_to_zones(existing, zones) if open_editor: existing.show_editor(parent=v) if return_controller: return existing return zones pie_id = str(id).strip() if str(id).strip() != '' else 'pie_chart' used_ids = set(v.get_list_keys(None, checked_state=None)) if pie_id.lower() in used_ids: k = 1 base = pie_id while f'{base}_{k:03d}'.lower() in used_ids: k += 1 pie_id = f'{base}_{k:03d}' pie = PieZonesController(values=values, center=(x, y), radius=radius, id=pie_id, labels=labels, colors=colors, **kwargs) zones = pie.attach_to_mapviewer(v, id=pie_id, ToCheck=ToCheck) self._bind_pie_controller_to_zones(pie, zones) if open_editor: pie.show_editor(parent=v) if return_controller: return pie return zones
[docs] def _bind_pie_controller_to_zones(self, controller: PieZonesController, zones: 'Zones | None') -> None: """Attach pie controller metadata to the Zones object.""" if zones is None: return zones._pie_controller = controller controller.zones = zones controller.id = str(zones.idx)
[docs] def _iter_pie_controllers(self) -> list[PieZonesController]: """Collect pie controllers from the viewer's vector objects.""" out: list[PieZonesController] = [] seen: set[int] = set() for curvec in self._viewer.iterator_over_objects(draw_type.VECTORS, checked_state=None): ctrl = getattr(curvec, '_pie_controller', None) if isinstance(ctrl, PieZonesController): ctrl.id = str(curvec.idx) ctrl.zones = curvec if id(ctrl) not in seen: seen.add(id(ctrl)) out.append(ctrl) return out
[docs] def _get_pie_controller(self, pie_id: str) -> 'PieZonesController | None': pid = str(pie_id).lower() obj = self._viewer.get_obj_from_id(pid, drawing_type=draw_type.VECTORS) if obj is not None: ctrl = getattr(obj, '_pie_controller', None) if isinstance(ctrl, PieZonesController): ctrl.id = str(obj.idx) ctrl.zones = obj return ctrl for ctrl in self._iter_pie_controllers(): if str(ctrl.id).lower() == pid: return ctrl return None
[docs] def OnCreatePieChart(self, event: wx.Event) -> None: """Create a new editable pie chart and open the full wx editor.""" v = self._viewer dlg_id = wx.TextEntryDialog(v, _('Pie chart id ?'), _('Create pie chart'), 'pie_chart') if dlg_id.ShowModal() != wx.ID_OK: dlg_id.Destroy() return pie_id = dlg_id.GetValue().strip() dlg_id.Destroy() if pie_id == '': pie_id = 'pie_chart' if self._get_pie_controller(pie_id) is not None: dlg = wx.MessageDialog(v, _('A pie chart with this id already exists. Open editor instead?'), _('Pie chart exists'), style=wx.YES_NO | wx.YES_DEFAULT | wx.ICON_QUESTION) ret = dlg.ShowModal() dlg.Destroy() if ret == wx.ID_YES: self._get_pie_controller(pie_id).show_editor(parent=v) return try: self.add_pie_asset(values=[1.0, 1.0, 1.0], x=0.0, y=0.0, radius=10.0, id=pie_id, open_editor=True, return_controller=False) except Exception as exc: wx.MessageDialog(v, _('Cannot create pie chart: {}').format(exc), _('Error'), style=wx.OK | wx.ICON_ERROR).ShowModal()
[docs] def OnEditPieChart(self, event: wx.Event) -> None: """Open pie editor for an existing controller.""" v = self._viewer controllers = self._iter_pie_controllers() if not controllers: wx.MessageDialog(v, _('No editable pie chart found in this viewer.'), _('Pie charts'), style=wx.OK | wx.ICON_INFORMATION).ShowModal() return ids = sorted([str(ctrl.id).lower() for ctrl in controllers]) dlg = wx.SingleChoiceDialog(v, _('Choose pie chart to edit'), _('Edit pie chart'), ids) if dlg.ShowModal() != wx.ID_OK: dlg.Destroy() return chosen = dlg.GetStringSelection() dlg.Destroy() ctrl = self._get_pie_controller(chosen) if ctrl is not None: ctrl.show_editor(parent=v)
[docs] def OnLoadPieChartJSON(self, event: wx.Event) -> None: """Load an editable pie chart from JSON and attach it to the viewer.""" v = self._viewer dlg = wx.FileDialog(v, _('Load pie chart JSON'), wildcard='JSON (*.json)|*.json|All (*.*)|*.*', style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST) if dlg.ShowModal() != wx.ID_OK: dlg.Destroy() return filepath = dlg.GetPath() dlg.Destroy() try: ctrl = PieZonesController.load_json(filepath) base_id = str(ctrl.id).strip() or 'pie_chart' final_id = base_id k = 1 all_ids = set(v.get_list_keys(None, checked_state=None)) while final_id.lower() in all_ids: final_id = f'{base_id}_{k:03d}' k += 1 ctrl.id = final_id zones = ctrl.attach_to_mapviewer(v, id=final_id, ToCheck=True) self._bind_pie_controller_to_zones(ctrl, zones) ctrl.show_editor(parent=v) except Exception as exc: wx.MessageDialog(v, _('Cannot load pie chart JSON: {}').format(exc), _('Error'), style=wx.OK | wx.ICON_ERROR).ShowModal()
# ---------------------------------------------------------------- # Bar chart CRUD # ----------------------------------------------------------------
[docs] def add_bar_asset(self, values: Sequence[float], x: float, y: float, width: float, height: float, id: str = 'bar_chart', labels: Sequence[str] = None, colors: Sequence[tuple] = None, orientation: str = 'horizontal', ToCheck: bool = True, return_controller: bool = False, open_editor: bool = False, **kwargs) -> 'Zones | BarZonesController': """Create and add a world-space bar chart with persistent controller.""" v = self._viewer existing = self._get_bar_controller(str(id).lower()) if existing is not None: existing.update_data(values=values, labels=labels, colors=colors, rebuild=False) existing.update_geometry(x=x, y=y, width=width, height=height, orientation=orientation, rebuild=False) for key, val in kwargs.items(): if hasattr(existing, key): setattr(existing, key, val) zones = existing.rebuild(ToCheck=ToCheck) self._bind_bar_controller_to_zones(existing, zones) if open_editor: existing.show_editor(parent=v) if return_controller: return existing return zones bar_id = str(id).strip() if str(id).strip() != '' else 'bar_chart' used_ids = set(v.get_list_keys(None, checked_state=None)) if bar_id.lower() in used_ids: k = 1 base = bar_id while f'{base}_{k:03d}'.lower() in used_ids: k += 1 bar_id = f'{base}_{k:03d}' bar = BarZonesController(values=values, position=(x, y), width=width, height=height, orientation=orientation, id=bar_id, labels=labels, colors=colors, **kwargs) zones = bar.attach_to_mapviewer(v, id=bar_id, ToCheck=ToCheck) self._bind_bar_controller_to_zones(bar, zones) if open_editor: bar.show_editor(parent=v) if return_controller: return bar return zones
[docs] def _bind_bar_controller_to_zones(self, controller: BarZonesController, zones: 'Zones | None') -> None: if zones is None: return zones._bar_controller = controller controller.zones = zones controller.id = str(zones.idx)
[docs] def _iter_bar_controllers(self) -> list[BarZonesController]: out: list[BarZonesController] = [] seen: set[int] = set() for curvec in self._viewer.iterator_over_objects(draw_type.VECTORS, checked_state=None): ctrl = getattr(curvec, '_bar_controller', None) if isinstance(ctrl, BarZonesController): ctrl.id = str(curvec.idx) ctrl.zones = curvec if id(ctrl) not in seen: seen.add(id(ctrl)) out.append(ctrl) return out
[docs] def _get_bar_controller(self, bar_id: str) -> 'BarZonesController | None': bid = str(bar_id).lower() obj = self._viewer.get_obj_from_id(bid, drawing_type=draw_type.VECTORS) if obj is not None: ctrl = getattr(obj, '_bar_controller', None) if isinstance(ctrl, BarZonesController): ctrl.id = str(obj.idx) ctrl.zones = obj return ctrl for ctrl in self._iter_bar_controllers(): if str(ctrl.id).lower() == bid: return ctrl return None
[docs] def OnCreateBarChart(self, event: wx.Event) -> None: v = self._viewer dlg_id = wx.TextEntryDialog(v, _('Bar chart id ?'), _('Create bar chart'), 'bar_chart') if dlg_id.ShowModal() != wx.ID_OK: dlg_id.Destroy() return bar_id = dlg_id.GetValue().strip() dlg_id.Destroy() if bar_id == '': bar_id = 'bar_chart' if self._get_bar_controller(bar_id) is not None: dlg = wx.MessageDialog(v, _('A bar chart with this id already exists. Open editor instead?'), _('Bar chart exists'), style=wx.YES_NO | wx.YES_DEFAULT | wx.ICON_QUESTION) ret = dlg.ShowModal() dlg.Destroy() if ret == wx.ID_YES: self._get_bar_controller(bar_id).show_editor(parent=v) return try: self.add_bar_asset(values=[1.0, 1.0, 1.0], x=0.0, y=0.0, width=10.0, height=2.0, id=bar_id, orientation='horizontal', open_editor=True, return_controller=False) except Exception as exc: wx.MessageDialog(v, _('Cannot create bar chart: {}').format(exc), _('Error'), style=wx.OK | wx.ICON_ERROR).ShowModal()
[docs] def OnEditBarChart(self, event: wx.Event) -> None: v = self._viewer controllers = self._iter_bar_controllers() if not controllers: wx.MessageDialog(v, _('No editable bar chart found in this viewer.'), _('Bar charts'), style=wx.OK | wx.ICON_INFORMATION).ShowModal() return ids = sorted([str(ctrl.id).lower() for ctrl in controllers]) dlg = wx.SingleChoiceDialog(v, _('Choose bar chart to edit'), _('Edit bar chart'), ids) if dlg.ShowModal() != wx.ID_OK: dlg.Destroy() return chosen = dlg.GetStringSelection() dlg.Destroy() ctrl = self._get_bar_controller(chosen) if ctrl is not None: ctrl.show_editor(parent=v)
[docs] def OnLoadBarChartJSON(self, event: wx.Event) -> None: v = self._viewer dlg = wx.FileDialog(v, _('Load bar chart JSON'), wildcard='JSON (*.json)|*.json|All (*.*)|*.*', style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST) if dlg.ShowModal() != wx.ID_OK: dlg.Destroy() return filepath = dlg.GetPath() dlg.Destroy() try: ctrl = BarZonesController.load_json(filepath) base_id = str(ctrl.id).strip() or 'bar_chart' final_id = base_id k = 1 all_ids = set(v.get_list_keys(None, checked_state=None)) while final_id.lower() in all_ids: final_id = f'{base_id}_{k:03d}' k += 1 ctrl.id = final_id zones = ctrl.attach_to_mapviewer(v, id=final_id, ToCheck=True) self._bind_bar_controller_to_zones(ctrl, zones) ctrl.show_editor(parent=v) except Exception as exc: wx.MessageDialog(v, _('Cannot load bar chart JSON: {}').format(exc), _('Error'), style=wx.OK | wx.ICON_ERROR).ShowModal()
# ---------------------------------------------------------------- # Curve chart CRUD # ----------------------------------------------------------------
[docs] def add_curve_asset(self, curves: Sequence, id: str = 'curve_plot', labels: Sequence[str] = None, colors: Sequence[tuple] = 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), ToCheck: bool = True, return_controller: bool = False, open_editor: bool = False, **kwargs) -> 'Zones | CurveZonesController': """Create and add a world-space curve chart with persistent controller.""" v = self._viewer existing = self._get_curve_controller(str(id).lower()) if existing is not None: existing.update_curves(curves=curves, labels=labels, colors=colors, rebuild=False) existing.update_geometry(canvas_origin=canvas_origin, canvas_size=canvas_size, area_fraction=area_fraction, rebuild=False) for key, val in kwargs.items(): if hasattr(existing, key): setattr(existing, key, val) zones = existing.rebuild(ToCheck=ToCheck) self._bind_curve_controller_to_zones(existing, zones) if open_editor: existing.show_editor(parent=v) if return_controller: return existing return zones curve_id = str(id).strip() if str(id).strip() != '' else 'curve_plot' used_ids = set(v.get_list_keys(None, checked_state=None)) if curve_id.lower() in used_ids: k = 1 base = curve_id while f'{base}_{k:03d}'.lower() in used_ids: k += 1 curve_id = f'{base}_{k:03d}' curve = CurveZonesController(curves=curves, id=curve_id, labels=labels, colors=colors, canvas_origin=canvas_origin, canvas_size=canvas_size, area_fraction=area_fraction, **kwargs) zones = curve.attach_to_mapviewer(v, id=curve_id, ToCheck=ToCheck) self._bind_curve_controller_to_zones(curve, zones) if open_editor: curve.show_editor(parent=v) if return_controller: return curve return zones
[docs] def _bind_curve_controller_to_zones(self, controller: CurveZonesController, zones: 'Zones | None') -> None: if zones is None: return zones._curve_controller = controller controller.zones = zones controller.id = str(zones.idx)
[docs] def _iter_curve_controllers(self) -> list[CurveZonesController]: out: list[CurveZonesController] = [] seen: set[int] = set() for curvec in self._viewer.iterator_over_objects(draw_type.VECTORS, checked_state=None): ctrl = getattr(curvec, '_curve_controller', None) if isinstance(ctrl, CurveZonesController): ctrl.id = str(curvec.idx) ctrl.zones = curvec if id(ctrl) not in seen: seen.add(id(ctrl)) out.append(ctrl) return out
[docs] def _get_curve_controller(self, curve_id: str) -> 'CurveZonesController | None': cid = str(curve_id).lower() obj = self._viewer.get_obj_from_id(cid, drawing_type=draw_type.VECTORS) if obj is not None: ctrl = getattr(obj, '_curve_controller', None) if isinstance(ctrl, CurveZonesController): ctrl.id = str(obj.idx) ctrl.zones = obj return ctrl for ctrl in self._iter_curve_controllers(): if str(ctrl.id).lower() == cid: return ctrl return None
[docs] def OnCreateCurveChart(self, event: wx.Event) -> None: v = self._viewer dlg_id = wx.TextEntryDialog(v, _('Curve chart id ?'), _('Create curve chart'), 'curve_plot') if dlg_id.ShowModal() != wx.ID_OK: dlg_id.Destroy() return curve_id = dlg_id.GetValue().strip() dlg_id.Destroy() if curve_id == '': curve_id = 'curve_plot' if self._get_curve_controller(curve_id) is not None: dlg = wx.MessageDialog(v, _('A curve chart with this id already exists. Open editor instead?'), _('Curve chart exists'), style=wx.YES_NO | wx.YES_DEFAULT | wx.ICON_QUESTION) ret = dlg.ShowModal() dlg.Destroy() if ret == wx.ID_YES: self._get_curve_controller(curve_id).show_editor(parent=v) return try: self.add_curve_asset( curves=[[(0.0, 0.2), (0.3, 0.8), (0.7, 0.4), (1.0, 0.9)]], id=curve_id, canvas_origin=(0.0, 0.0), canvas_size=(100.0, 100.0), area_fraction=(0.1, 0.1, 0.8, 0.3), open_editor=True, return_controller=False) except Exception as exc: wx.MessageDialog(v, _('Cannot create curve chart: {}').format(exc), _('Error'), style=wx.OK | wx.ICON_ERROR).ShowModal()
[docs] def OnEditCurveChart(self, event: wx.Event) -> None: v = self._viewer controllers = self._iter_curve_controllers() if not controllers: wx.MessageDialog(v, _('No editable curve chart found in this viewer.'), _('Curve charts'), style=wx.OK | wx.ICON_INFORMATION).ShowModal() return ids = sorted([str(ctrl.id).lower() for ctrl in controllers]) dlg = wx.SingleChoiceDialog(v, _('Choose curve chart to edit'), _('Edit curve chart'), ids) if dlg.ShowModal() != wx.ID_OK: dlg.Destroy() return chosen = dlg.GetStringSelection() dlg.Destroy() ctrl = self._get_curve_controller(chosen) if ctrl is not None: ctrl.show_editor(parent=v)
[docs] def OnLoadCurveChartJSON(self, event: wx.Event) -> None: v = self._viewer dlg = wx.FileDialog(v, _('Load curve chart JSON'), wildcard='JSON (*.json)|*.json|All (*.*)|*.*', style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST) if dlg.ShowModal() != wx.ID_OK: dlg.Destroy() return filepath = dlg.GetPath() dlg.Destroy() try: ctrl = CurveZonesController.load_json(filepath) base_id = str(ctrl.id).strip() or 'curve_plot' final_id = base_id k = 1 all_ids = set(v.get_list_keys(None, checked_state=None)) while final_id.lower() in all_ids: final_id = f'{base_id}_{k:03d}' k += 1 ctrl.id = final_id zones = ctrl.attach_to_mapviewer(v, id=final_id, ToCheck=True) self._bind_curve_controller_to_zones(ctrl, zones) ctrl.show_editor(parent=v) except Exception as exc: wx.MessageDialog(v, _('Cannot load curve chart JSON: {}').format(exc), _('Error'), style=wx.OK | wx.ICON_ERROR).ShowModal()
# ---------------------------------------------------------------- # Boxplot chart CRUD # ----------------------------------------------------------------
[docs] def add_boxplot_asset( self, series, *, id: str = 'boxplot', canvas_origin: tuple = (0.0, 0.0), canvas_size: tuple = (100.0, 80.0), area_fraction: tuple = (0.10, 0.08, 0.92, 0.88), open_editor: bool = False, return_controller: bool = False, **kwargs, ): v = self._viewer ctrl = BoxplotZonesController( series=series, canvas_origin=canvas_origin, canvas_size=canvas_size, area_fraction=area_fraction, id=id, mapviewer=v, **kwargs, ) zones = ctrl.attach_to_mapviewer(v, id=id, ToCheck=True) self._bind_boxplot_controller_to_zones(ctrl, zones) if open_editor: ctrl.show_editor(parent=v) if return_controller: return ctrl return zones
[docs] def _bind_boxplot_controller_to_zones( self, controller: BoxplotZonesController, zones: 'Zones | None', ) -> None: if zones is None: return zones._boxplot_controller = controller controller.zones = zones controller.id = str(zones.idx)
[docs] def _iter_boxplot_controllers(self) -> list[BoxplotZonesController]: out: list[BoxplotZonesController] = [] seen: set[int] = set() for obj in self._viewer.iterator_over_objects(draw_type.VECTORS, checked_state=None): ctrl = getattr(obj, '_boxplot_controller', None) if isinstance(ctrl, BoxplotZonesController): ctrl.id = str(obj.idx) ctrl.zones = obj if id(ctrl) not in seen: seen.add(id(ctrl)) out.append(ctrl) return out
[docs] def _get_boxplot_controller(self, boxplot_id: str) -> 'BoxplotZonesController | None': bid = str(boxplot_id).lower() obj = self._viewer.get_obj_from_id(bid, drawing_type=draw_type.VECTORS) if obj is not None: ctrl = getattr(obj, '_boxplot_controller', None) if isinstance(ctrl, BoxplotZonesController): ctrl.id = str(obj.idx) ctrl.zones = obj return ctrl for ctrl in self._iter_boxplot_controllers(): if str(ctrl.id).lower() == bid: return ctrl return None
[docs] def OnCreateBoxplot(self, event: wx.Event) -> None: v = self._viewer dlg_id = wx.TextEntryDialog(v, _('Boxplot id ?'), _('Create boxplot'), 'boxplot') if dlg_id.ShowModal() != wx.ID_OK: dlg_id.Destroy() return boxplot_id = dlg_id.GetValue().strip() dlg_id.Destroy() if boxplot_id == '': boxplot_id = 'boxplot' if self._get_boxplot_controller(boxplot_id) is not None: dlg = wx.MessageDialog( v, _('A boxplot with this id already exists. Open editor instead?'), _('Boxplot exists'), style=wx.YES_NO | wx.YES_DEFAULT | wx.ICON_QUESTION, ) ret = dlg.ShowModal() dlg.Destroy() if ret == wx.ID_YES: self._get_boxplot_controller(boxplot_id).show_editor(parent=v) return try: self.add_boxplot_asset( series=[ [1.0, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0], [0.5, 1.0, 2.0, 3.0, 4.0, 4.5, 5.5], ], id=boxplot_id, canvas_origin=(0.0, 0.0), canvas_size=(100.0, 80.0), open_editor=True, return_controller=False, ) except Exception as exc: wx.MessageDialog( v, _('Cannot create boxplot: {}').format(exc), _('Error'), style=wx.OK | wx.ICON_ERROR, ).ShowModal()
[docs] def OnEditBoxplot(self, event: wx.Event) -> None: v = self._viewer controllers = self._iter_boxplot_controllers() if not controllers: wx.MessageDialog( v, _('No editable boxplot found in this viewer.'), _('Boxplots'), style=wx.OK | wx.ICON_INFORMATION, ).ShowModal() return ids = sorted([str(ctrl.id).lower() for ctrl in controllers]) dlg = wx.SingleChoiceDialog(v, _('Choose boxplot to edit'), _('Edit boxplot'), ids) if dlg.ShowModal() != wx.ID_OK: dlg.Destroy() return chosen = dlg.GetStringSelection() dlg.Destroy() ctrl = self._get_boxplot_controller(chosen) if ctrl is not None: ctrl.show_editor(parent=v)
[docs] def OnLoadBoxplotJSON(self, event: wx.Event) -> None: v = self._viewer dlg = wx.FileDialog( v, _('Load boxplot JSON'), wildcard='JSON (*.json)|*.json|All (*.*)|*.*', style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST, ) if dlg.ShowModal() != wx.ID_OK: dlg.Destroy() return filepath = dlg.GetPath() dlg.Destroy() try: ctrl = BoxplotZonesController.load_json(filepath) base_id = str(ctrl.id).strip() or 'boxplot' final_id = base_id k = 1 all_ids = set(v.get_list_keys(None, checked_state=None)) while final_id.lower() in all_ids: final_id = f'{base_id}_{k:03d}' k += 1 ctrl.id = final_id zones = ctrl.attach_to_mapviewer(v, id=final_id, ToCheck=True) self._bind_boxplot_controller_to_zones(ctrl, zones) ctrl.show_editor(parent=v) except Exception as exc: wx.MessageDialog( v, _('Cannot load boxplot JSON: {}').format(exc), _('Error'), style=wx.OK | wx.ICON_ERROR, ).ShowModal()
# ---------------------------------------------------------------- # Asset-transform geometry helpers # ----------------------------------------------------------------
[docs] def _get_asset_transform_bounds(self): ctrl = self.transform_controller if ctrl is None or not hasattr(ctrl, 'get_transform_bounds'): return None try: return tuple(float(v) for v in ctrl.get_transform_bounds()) except Exception: return None
@staticmethod
[docs] def _compute_asset_handles(bounds): xmin, ymin, xmax, ymax = bounds cx = 0.5 * (xmin + xmax) cy = 0.5 * (ymin + ymax) return { 'sw': (xmin, ymin), 's': (cx, ymin), 'se': (xmax, ymin), 'e': (xmax, cy), 'ne': (xmax, ymax), 'n': (cx, ymax), 'nw': (xmin, ymax), 'w': (xmin, cy), 'c': (cx, cy), }
[docs] def _asset_handle_hit_test(self, x: float, y: float): bounds = self._get_asset_transform_bounds() if bounds is None: return None handles = self._compute_asset_handles(bounds) sx = self._viewer.sx sy = self._viewer.sy tol_x = max(8.0 / max(sx, 1e-9), (bounds[2] - bounds[0]) * 0.02) tol_y = max(8.0 / max(sy, 1e-9), (bounds[3] - bounds[1]) * 0.02) best = None best_d2 = None for key, (hx, hy) in handles.items(): dx = (x - hx) / max(tol_x, 1e-12) dy = (y - hy) / max(tol_y, 1e-12) d2 = dx * dx + dy * dy if d2 <= 1.0 and (best_d2 is None or d2 < best_d2): best = key best_d2 = d2 if best is None: xmin, ymin, xmax, ymax = bounds if xmin <= x <= xmax and ymin <= y <= ymax: return 'c' return best
@staticmethod
[docs] def _cursor_for_asset_handle(handle: 'str | None') -> int: if handle in ('n', 's'): return wx.CURSOR_SIZENS if handle in ('e', 'w'): return wx.CURSOR_SIZEWE if handle in ('ne', 'sw'): return wx.CURSOR_SIZENESW if handle in ('nw', 'se'): return wx.CURSOR_SIZENWSE if handle == 'c': return wx.CURSOR_HAND return wx.CURSOR_DEFAULT
[docs] def _set_asset_transform_cursor(self, handle: 'str | None') -> None: v = self._viewer if not hasattr(v, 'canvas') or v.canvas is None: return key = handle or '' if self.cursor_key == key: return self.cursor_key = key v.canvas.SetCursor(wx.Cursor(self._cursor_for_asset_handle(handle)))
[docs] def _update_asset_transform_cursor(self, x: float, y: float) -> None: if self._viewer.action != 'transform asset bounds': self._set_asset_transform_cursor(None) return handle = self.drag_handle if handle is None: handle = self._asset_handle_hit_test(x, y) self._set_asset_transform_cursor(handle)
# ---------------------------------------------------------------- # Snap-grid helpers # ---------------------------------------------------------------- @staticmethod
[docs] def _nice_step(raw_step: float) -> float: """Round a raw spacing to a readable 1-2-5 × 10ⁿ step.""" raw_step = max(float(raw_step), 1e-12) exp10 = np.floor(np.log10(raw_step)) base = 10.0 ** exp10 frac = raw_step / base if frac <= 1.0: nice = 1.0 elif frac <= 2.0: nice = 2.0 elif frac <= 5.0: nice = 5.0 else: nice = 10.0 return nice * base
[docs] def _ensure_snap_grid_origin(self) -> tuple[float, float]: """Return a stable world origin for snapping, initialized once.""" v = self._viewer unit = v.snap_grid_unit round_base = v.snap_grid_round_base signature = (float(unit), float(round_base)) if (self._snap_grid_origin is None or self._snap_grid_origin_signature != signature): base = max(float(round_base), float(unit), 1e-12) ox = np.floor(float(v.xmin) / base) * base oy = np.floor(float(v.ymin) / base) * base self._snap_grid_origin = (float(ox), float(oy)) self._snap_grid_origin_signature = signature return self._snap_grid_origin
[docs] def _adaptive_snap_step_from_span(self, span: float, target_divisions: float = 20.0) -> float: """Compute zoom-adaptive step as power-of-two multiple of base unit.""" unit = max(float(self._viewer.snap_grid_unit), 1e-12) raw_step = max(abs(float(span)) / max(float(target_divisions), 1.0), unit) multiple = max(raw_step / unit, 1.0) exponent = int(np.ceil(np.log2(multiple))) return unit * float(2 ** max(exponent, 0))
[docs] def _asset_transform_snap_steps(self) -> tuple[float, float]: """Return adaptive grid steps as nested multiples of base snap unit.""" v = self._viewer step_x = self._adaptive_snap_step_from_span(v.width, target_divisions=20.0) step_y = self._adaptive_snap_step_from_span(v.height, target_divisions=20.0) return step_x, step_y
[docs] def _asset_transform_snap_origin(self) -> tuple[float, float]: return self._ensure_snap_grid_origin()
@staticmethod
[docs] def _snap_value(v: float, step: float, origin: float = 0.0) -> float: if step <= 0: return v return origin + round((v - origin) / step) * step
[docs] def _snap_xy_on_grid(self, x: float, y: float, do_snap: bool = True) -> tuple[float, float]: """Snap point coordinates using the current grid.""" if not do_snap: return x, y step_x, step_y = self._asset_transform_snap_steps() origin_x, origin_y = self._asset_transform_snap_origin() return ( self._snap_value(float(x), step_x, origin_x), self._snap_value(float(y), step_y, origin_y), )
# ---------------------------------------------------------------- # Asset-transform drag application # ----------------------------------------------------------------
[docs] def _apply_asset_transform_drag( self, x: float, y: float, *, keep_ratio: bool = False, resize_from_center: bool = False, do_snap: bool = True, ) -> bool: ctrl = self.transform_controller handle = self.drag_handle p0 = self.drag_start_xy b0 = self.drag_start_bounds if ctrl is None or handle is None or p0 is None or b0 is None: return False if not hasattr(ctrl, 'apply_transform_bounds'): return False v = self._viewer dx = x - p0[0] dy = y - p0[1] xmin0, ymin0, xmax0, ymax0 = b0 xmin, ymin, xmax, ymax = xmin0, ymin0, xmax0, ymax0 if handle == 'c': xmin += dx; xmax += dx ymin += dy; ymax += dy else: if 'w' in handle: xmin = xmin0 + dx if 'e' in handle: xmax = xmax0 + dx if 's' in handle: ymin = ymin0 + dy if 'n' in handle: ymax = ymax0 + dy if resize_from_center: cx0 = 0.5 * (xmin0 + xmax0) cy0 = 0.5 * (ymin0 + ymax0) if 'w' in handle or 'e' in handle: hx = xmin if 'w' in handle else xmax xmin = 2.0 * cx0 - hx xmax = hx if 'n' in handle or 's' in handle: hy = ymax if 'n' in handle else ymin ymin = 2.0 * cy0 - hy ymax = hy if (keep_ratio and ('n' in handle or 's' in handle) and ('e' in handle or 'w' in handle)): w0 = max(abs(xmax0 - xmin0), 1e-12) h0 = max(abs(ymax0 - ymin0), 1e-12) ratio = w0 / h0 w = xmax - xmin h = ymax - ymin if abs(w) / max(abs(h), 1e-12) > ratio: target_h = abs(w) / ratio if 'n' in handle: ymax = ymin + target_h else: ymin = ymax - target_h else: target_w = abs(h) * ratio if 'e' in handle: xmax = xmin + target_w else: xmin = xmax - target_w if do_snap: step_x, step_y = self._asset_transform_snap_steps() origin_x, origin_y = self._asset_transform_snap_origin() if handle == 'c': cx = self._snap_value(0.5 * (xmin + xmax), step_x, origin_x) cy = self._snap_value(0.5 * (ymin + ymax), step_y, origin_y) w = xmax - xmin h = ymax - ymin xmin = cx - 0.5 * w; xmax = cx + 0.5 * w ymin = cy - 0.5 * h; ymax = cy + 0.5 * h else: if 'w' in handle: xmin = self._snap_value(xmin, step_x, origin_x) if 'e' in handle: xmax = self._snap_value(xmax, step_x, origin_x) if 's' in handle: ymin = self._snap_value(ymin, step_y, origin_y) if 'n' in handle: ymax = self._snap_value(ymax, step_y, origin_y) eps_x = max(1e-9, 6.0 / max(v.sx, 1e-9)) eps_y = max(1e-9, 6.0 / max(v.sy, 1e-9)) if xmax - xmin < eps_x: if 'w' in handle and 'e' not in handle: xmin = xmax - eps_x else: xmax = xmin + eps_x if ymax - ymin < eps_y: if 's' in handle and 'n' not in handle: ymin = ymax - eps_y else: ymax = ymin + eps_y try: if handle == 'c' and self.direct_pan: zones = getattr(ctrl, 'zones', None) if zones is not None and hasattr(zones, 'move'): dx_eff = xmin - xmin0 dy_eff = ymin - ymin0 zones.move(dx_eff, dy_eff, use_cache=True, inplace=True) ctrl.apply_transform_bounds((xmin, ymin, xmax, ymax), rebuild=False) self.editor_refresh_pending = True return True # rebuild=True: update the zones geometry every frame so the shape # follows the cursor during drag. Only ed.refresh_from_controller() # (the slow PropertyGrid update) is deferred to on_left_up. ctrl.apply_transform_bounds((xmin, ymin, xmax, ymax), rebuild=True) self.editor_refresh_pending = True return True except Exception as exc: logging.warning('Asset transform drag failed: %s', exc) return False