"""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:
# ── Transform drag state ─────────────────────────────────────────────
[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
# ----------------------------------------------------------------
@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
# ----------------------------------------------------------------
# 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))
@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
# ----------------------------------------------------------------