Source code for wolfhece.assets.pie.editor

"""Graphical editor for pie chart asset configuration.

Provides a wx.Frame-based UI for interactively editing pie sector data,
layout, geometry, and legend positioning. Changes are applied to a
PieZonesController and reflected in the mapviewer in real-time.
"""

from __future__ import annotations

import numpy as np
import wx
import wx.grid as wxgrid
import wx.propgrid as pg

from ...CpGrid import CpGrid
from ...PyParams import Type_Param, Wolf_Param
from .zones_asset import PieZonesAsset
from .controller import PieZonesController


[docs] GROUP_GEOM = "Pie - Geometry"
[docs] GROUP_LEGEND = "Pie - Legend"
[docs] GROUP_COLORS = "Pie - Colors"
[docs] class PieZonesEditor(wx.Frame): """wx editor window for PieZonesController using CpGrid. Provides a 6-column grid for sector data (label, value, color, fraction, legend x, legend y), plus controls for geometry (center, radius, angle), legend layout (placement mode, offset, text color), and I/O operations (JSON save/load, VEC/VECZ export). """ def __init__(self, parent, controller: PieZonesController): super().__init__(parent, title=f"Pie Editor - {controller.id}", size=(980, 620))
[docs] self.controller = controller
[docs] self._updating_ui = False
[docs] self._colors: list[tuple[int, int, int, int]] = []
panel = wx.Panel(self) main = wx.BoxSizer(wx.VERTICAL) splitter = wx.SplitterWindow(panel, style=wx.SP_LIVE_UPDATE | wx.SP_3D) left_panel = wx.Panel(splitter) right_panel = wx.Panel(splitter) left = wx.BoxSizer(wx.VERTICAL) right = wx.BoxSizer(wx.VERTICAL)
[docs] self._wp = Wolf_Param(right_panel, to_read=False, withbuttons=False, init_GUI=False, force_even_if_same_default=True)
self._wp.ensure_prop(wxparent=right_panel, show_in_active_if_default=True, height=260) self._init_wolf_param_schema() if self._wp.prop is not None: self._wp.prop.SetMinSize((380, -1))
[docs] self._live = wx.CheckBox(right_panel, label="Live update")
self._live.SetValue(True)
[docs] self._grid = CpGrid(left_panel, wx.ID_ANY, wx.WANTS_CHARS)
self._grid.CreateGrid(0, 6) self._grid.SetColLabelValue(0, "Label") self._grid.SetColLabelValue(1, "Value") self._grid.SetColLabelValue(2, "Color") self._grid.SetColLabelValue(3, "Fraction") self._grid.SetColLabelValue(4, "Legend X") self._grid.SetColLabelValue(5, "Legend Y") self._grid.EnableEditing(True) colorbar = wx.BoxSizer(wx.HORIZONTAL)
[docs] self._pick_center = wx.Button(right_panel, label="Pick center on map")
[docs] self._transform_map = wx.Button(right_panel, label="Transform on map")
[docs] self._colorpicker = wx.ColourPickerCtrl(right_panel, colour=wx.Colour(57, 106, 177))
[docs] self._alpha = wx.SpinCtrl(right_panel, min=0, max=255, initial=235, size=(80, -1))
colorbar.Add(self._pick_center, 0, wx.RIGHT, 12) colorbar.Add(self._transform_map, 0, wx.RIGHT, 12) colorbar.Add(wx.StaticText(right_panel, label="Selected row color"), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 6) colorbar.Add(self._colorpicker, 0, wx.RIGHT | wx.ALIGN_CENTER_VERTICAL, 8) colorbar.Add(wx.StaticText(right_panel, label="Alpha"), 0, wx.ALIGN_CENTER_VERTICAL | wx.RIGHT, 4) colorbar.Add(self._alpha, 0, wx.ALIGN_CENTER_VERTICAL) btns = wx.BoxSizer(wx.HORIZONTAL)
[docs] self._add = wx.Button(left_panel, label="Row+")
[docs] self._del = wx.Button(left_panel, label="Row-")
[docs] self._apply = wx.Button(left_panel, label="Apply")
[docs] self._save_json = wx.Button(left_panel, label="Save JSON")
[docs] self._load_json = wx.Button(left_panel, label="Load JSON")
[docs] self._save_vecz = wx.Button(left_panel, label="Save VEC/VECZ")
for b in (self._add, self._del, self._apply, self._save_json, self._load_json, self._save_vecz): btns.Add(b, 0, wx.RIGHT, 6) left.Add(self._grid, 1, wx.EXPAND | wx.ALL, 8) left.Add(btns, 0, wx.ALL, 8) if self._wp.prop is not None: right.Add(self._wp.prop, 1, wx.EXPAND | wx.ALL, 8) right.Add(self._live, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 8) right.Add(colorbar, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 8) left_panel.SetSizer(left) right_panel.SetSizer(right) right_panel.SetMinSize((380, -1)) splitter.SplitVertically(left_panel, right_panel, sashPosition=600) splitter.SetSashGravity(1.0) splitter.SetMinimumPaneSize(300) main.Add(splitter, 1, wx.EXPAND) panel.SetSizer(main) self._bind_events() self.refresh_from_controller()
[docs] def _bind_events(self): """Bind UI event handlers to controls.""" self._pick_center.Bind(wx.EVT_BUTTON, self.on_pick_center) self._transform_map.Bind(wx.EVT_BUTTON, self.on_transform_map) self._add.Bind(wx.EVT_BUTTON, self.on_add_row) self._del.Bind(wx.EVT_BUTTON, self.on_del_row) self._apply.Bind(wx.EVT_BUTTON, self.on_apply) self._save_json.Bind(wx.EVT_BUTTON, self.on_save_json) self._load_json.Bind(wx.EVT_BUTTON, self.on_load_json) self._save_vecz.Bind(wx.EVT_BUTTON, self.on_save_vecz) self._grid.Bind(wxgrid.EVT_GRID_CELL_CHANGED, self.on_grid_changed) self._grid.Bind(wxgrid.EVT_GRID_SELECT_CELL, self.on_grid_select) self._grid.Bind(wxgrid.EVT_GRID_EDITOR_HIDDEN, self.on_grid_editor_hidden) self._colorpicker.Bind(wx.EVT_COLOURPICKER_CHANGED, self.on_picker_changed) self._alpha.Bind(wx.EVT_SPINCTRL, self.on_picker_changed) if self._wp.prop is not None: self._wp.prop.Bind(pg.EVT_PG_CHANGED, self.on_prop_changed)
[docs] def _init_wolf_param_schema(self) -> None: wp = self._wp wp.add_param(GROUP_GEOM, "X", 0.0, Type_Param.Float, "Pie center X", whichdict="All") wp.add_param(GROUP_GEOM, "Y", 0.0, Type_Param.Float, "Pie center Y", whichdict="All") wp.add_param(GROUP_GEOM, "Radius", 50.0, Type_Param.Float, "Pie radius", whichdict="All") wp.add_param(GROUP_GEOM, "Start angle", 0.0, Type_Param.Float, "Start angle in degrees", whichdict="All") wp.add_param(GROUP_GEOM, "Clockwise", False, Type_Param.Logical, "Draw sectors clockwise", whichdict="All") wp.add_param(GROUP_LEGEND, "Mode", "auto", Type_Param.Enum, "Legend position mode", enum_choices=["auto", "manual"], whichdict="All") wp.add_param(GROUP_LEGEND, "Offset factor", 1.1, Type_Param.Float, "Legend offset multiplier", whichdict="All") wp.add_param(GROUP_LEGEND, "Text color", (0, 0, 0), Type_Param.Color, "Legend text color", whichdict="All")
[docs] def _purge_color_params(self) -> None: for dict_name in ("myparams", "myparams_default"): d = getattr(self._wp, dict_name, None) if not isinstance(d, dict): continue grp = d.get(GROUP_COLORS) if not isinstance(grp, dict): continue for name in list(grp.keys()): if str(name).startswith("Slice ") or str(name).startswith("Color ") or str(name).startswith("Alpha "): del grp[name]
@staticmethod
[docs] def _color_key(idx: int) -> str: return f"Slice {idx} Color"
@staticmethod
[docs] def _alpha_key(idx: int) -> str: return f"Slice {idx} Alpha"
[docs] def _sync_color_param_schema(self, count: int) -> None: self._purge_color_params() for i in range(count): idx = i + 1 self._wp.add_param( GROUP_COLORS, self._color_key(idx), (0, 0, 0), Type_Param.Color, f"Slice {idx} color", whichdict="All", ) self._wp.add_param( GROUP_COLORS, self._alpha_key(idx), 235, Type_Param.Integer, f"Slice {idx} alpha in [0,255]", whichdict="All", )
[docs] def _set_wp_colors(self, colors: list[tuple[int, int, int, int]]) -> None: for i, rgba in enumerate(colors): idx = i + 1 self._wp[(GROUP_COLORS, self._color_key(idx))] = (int(rgba[0]), int(rgba[1]), int(rgba[2])) self._wp[(GROUP_COLORS, self._alpha_key(idx))] = int(max(0, min(255, rgba[3])))
[docs] def _read_wp_colors(self, count: int) -> list[tuple[int, int, int, int]]: out: list[tuple[int, int, int, int]] = [] for i in range(count): idx = i + 1 rgb = tuple(int(v) for v in self._wp[(GROUP_COLORS, self._color_key(idx))]) a = int(self._wp[(GROUP_COLORS, self._alpha_key(idx))]) out.append(( max(0, min(255, rgb[0])), max(0, min(255, rgb[1])), max(0, min(255, rgb[2])), max(0, min(255, a)), )) return out
[docs] def _colors_from_controller(self, count: int) -> list[tuple[int, int, int, int]]: colors: list[tuple[int, int, int, int]] = [] for i in range(count): if self.controller.colors and i < len(self.controller.colors): c = self.controller.colors[i] if len(c) == 3: colors.append((int(c[0]), int(c[1]), int(c[2]), 235)) else: colors.append((int(c[0]), int(c[1]), int(c[2]), int(c[3]))) else: colors.append(tuple(PieZonesAsset.DEFAULT_COLORS[i % len(PieZonesAsset.DEFAULT_COLORS)])) return colors
[docs] def _sync_wp_colors_to_grid(self) -> None: rows = self._grid.GetNumberRows() if rows <= 0: return try: colors = self._read_wp_colors(rows) except Exception: return for i, rgba in enumerate(colors): self._set_row_color(i, rgba) cur = self._grid.GetGridCursorRow() if cur >= 0: self._sync_picker_from_row(min(cur, rows - 1))
[docs] def _read_wp(self) -> dict: self._wp.apply_changes_to_memory(verbosity=False) color = tuple(int(v) for v in self._wp[(GROUP_LEGEND, "Text color")]) return { "x": float(self._wp[(GROUP_GEOM, "X")]), "y": float(self._wp[(GROUP_GEOM, "Y")]), "radius": float(self._wp[(GROUP_GEOM, "Radius")]), "start_angle": float(self._wp[(GROUP_GEOM, "Start angle")]), "clockwise": bool(self._wp[(GROUP_GEOM, "Clockwise")]), "legend_mode": str(self._wp[(GROUP_LEGEND, "Mode")]), "legend_offset": float(self._wp[(GROUP_LEGEND, "Offset factor")]), "legend_text_color": color[:3], }
[docs] def _color_text(self, rgba: tuple[int, int, int, int]) -> str: """Format RGBA tuple to comma-separated string.""" return f"{rgba[0]},{rgba[1]},{rgba[2]},{rgba[3]}"
[docs] def _set_row_color(self, row: int, rgba: tuple[int, int, int, int]) -> None: """Set sector fill color for grid row and update color picker.""" if row < 0: return while len(self._colors) <= row: base = PieZonesAsset.DEFAULT_COLORS[len(self._colors) % len(PieZonesAsset.DEFAULT_COLORS)] self._colors.append(tuple(base)) rgba = tuple(max(0, min(255, int(v))) for v in rgba) self._colors[row] = rgba self._grid.SetCellValue(row, 2, self._color_text(rgba)) self._grid.SetReadOnly(row, 2, True)
[docs] def _sync_picker_from_row(self, row: int) -> None: """Sync color picker UI to the RGBA value of the current grid row.""" if row < 0 or row >= len(self._colors): return rgba = self._colors[row] self._updating_ui = True try: self._colorpicker.SetColour(wx.Colour(rgba[0], rgba[1], rgba[2])) self._alpha.SetValue(int(rgba[3])) finally: self._updating_ui = False
[docs] def _collect_grid_data(self): """Extract labels, values, colors, and legend positions from grid. Returns: Tuple of (labels, values, colors, legend_positions) where legend_positions is a list of (x,y) tuples or None for auto. """ n = self._grid.GetNumberRows() labels: list[str] = [] values: list[float] = [] colors: list[tuple[int, int, int, int]] = [] legend_positions: list[tuple[float, float] | None] = [] for i in range(n): label = self._grid.GetCellValue(i, 0).strip() if label == "": label = f"Slice {i + 1}" raw_val = self._grid.GetCellValue(i, 1).strip() if raw_val == "": val = 0.0 else: val = float(raw_val) raw_col = self._grid.GetCellValue(i, 2).strip() if i < len(self._colors): col = self._colors[i] elif raw_col == "": col = PieZonesAsset.DEFAULT_COLORS[i % len(PieZonesAsset.DEFAULT_COLORS)] else: vals = [int(v.strip()) for v in raw_col.split(",")] if len(vals) == 3: vals.append(255) col = tuple(max(0, min(255, v)) for v in vals[:4]) labels.append(label) values.append(val) colors.append(col) raw_x = self._grid.GetCellValue(i, 4).strip() raw_y = self._grid.GetCellValue(i, 5).strip() if raw_x == "" or raw_y == "": legend_positions.append(None) else: legend_positions.append((float(raw_x), float(raw_y))) return labels, values, colors, legend_positions
[docs] def _refresh_manual_columns_state(self) -> None: """Enable/disable Legend X/Y columns based on placement mode. In 'manual' mode, X/Y columns are editable. In 'auto' mode, they are read-only to reflect that placement is computed. """ try: manual = str(self._wp[(GROUP_LEGEND, "Mode")]) == "manual" except Exception: manual = False rows = self._grid.GetNumberRows() for row in range(rows): self._grid.SetReadOnly(row, 4, not manual) self._grid.SetReadOnly(row, 5, not manual)
[docs] def _apply_controller(self): """Commit all UI state to controller and rebuild geometry. Saves currently-edited cell, reads all grid/control data, applies to controller (legend layout, legend color, geometry, data), and triggers rebuild with mapviewer sync. """ # Ensure the currently edited cell is committed before reading grid values. try: if self._grid.IsCellEditControlShown(): self._grid.SaveEditControlValue() self._grid.HideCellEditControl() self._grid.DisableCellEditControl() except Exception: pass cfg = self._read_wp() labels, values, colors, legend_positions = self._collect_grid_data() try: colors = self._read_wp_colors(len(values)) except Exception: pass self.controller.update_data(values=values, labels=labels, colors=colors, rebuild=False) self.controller.update_geometry( x=cfg["x"], y=cfg["y"], radius=cfg["radius"], start_angle_deg=cfg["start_angle"], clockwise=cfg["clockwise"], rebuild=False, ) self.controller.update_legend_layout( position_mode=cfg["legend_mode"], positions=legend_positions, offset_factor=cfg["legend_offset"], legend_text_color=cfg["legend_text_color"], rebuild=False, ) self.controller.rebuild(ToCheck=True) self.refresh_from_controller()
[docs] def refresh_from_controller(self): """Reload all UI state from controller (after external change or apply). Syncs grid (add/remove rows as needed), geometry fields, legend controls (mode, offset, color), and color picker from current controller state. """ self._updating_ui = True try: c = self.controller self._wp[(GROUP_GEOM, "X")] = c.center_x self._wp[(GROUP_GEOM, "Y")] = c.center_y self._wp[(GROUP_GEOM, "Radius")] = c.radius self._wp[(GROUP_GEOM, "Start angle")] = c.start_angle_deg self._wp[(GROUP_GEOM, "Clockwise")] = c.clockwise self._wp[(GROUP_LEGEND, "Mode")] = c.legend_position_mode self._wp[(GROUP_LEGEND, "Offset factor")] = c.legend_offset_factor self._wp[(GROUP_LEGEND, "Text color")] = c.legend_text_color n = len(c.values) base_colors = self._colors_from_controller(n) self._sync_color_param_schema(n) self._set_wp_colors(base_colors) self._wp.Populate(sorted_groups=False) cur = self._grid.GetNumberRows() if cur < n: self._grid.AppendRows(n - cur) elif cur > n: self._grid.DeleteRows(0, cur - n) self._colors = [] total = float(np.sum(np.asarray(c.values, dtype=np.float64))) for i in range(n): self._grid.SetCellValue(i, 0, str(c.labels[i])) self._grid.SetCellValue(i, 1, str(c.values[i])) rgba = base_colors[i] self._set_row_color(i, rgba) frac = c.values[i] / total if total > 0 else 0.0 self._grid.SetCellValue(i, 3, f"{frac:.6f}") self._grid.SetReadOnly(i, 3, True) pos = c.legend_positions[i] if i < len(c.legend_positions) else None self._grid.SetCellValue(i, 4, "" if pos is None else str(pos[0])) self._grid.SetCellValue(i, 5, "" if pos is None else str(pos[1])) if n > 0: self._sync_picker_from_row(0) self._refresh_manual_columns_state() finally: self._updating_ui = False
[docs] def _maybe_live(self): """Apply controller changes if live-update mode is enabled.""" if self._updating_ui: return if self._live.GetValue(): try: self._apply_controller() except Exception: pass
[docs] def on_prop_changed(self, event): """Handle changes in the Wolf_Param property panel.""" self._sync_wp_colors_to_grid() self._refresh_manual_columns_state() self._maybe_live() event.Skip()
[docs] def on_grid_changed(self, event): """Handle changes to grid cells (sector data).""" self._maybe_live() event.Skip()
[docs] def on_grid_select(self, event): """Handle grid row selection; sync color picker to selected row.""" self._sync_picker_from_row(event.GetRow()) event.Skip()
[docs] def on_grid_editor_hidden(self, event): """Keep focus on grid after Enter validation to avoid focus jump.""" wx.CallAfter(self._grid.SetFocus) event.Skip()
[docs] def on_picker_changed(self, event): """Handle color picker changes; apply to selected grid row and live-update.""" if self._updating_ui: event.Skip() return row = self._grid.GetGridCursorRow() if row < 0 or row >= self._grid.GetNumberRows(): event.Skip() return c = self._colorpicker.GetColour() self._set_row_color(row, (c.Red(), c.Green(), c.Blue(), int(self._alpha.GetValue()))) self._maybe_live() event.Skip()
[docs] def on_pick_center(self, event): """Interactive picker for pie center; user clicks map point to log coordinates.""" mv = self.controller.mapviewer if mv is None: wx.MessageBox("Pie is not attached to a mapviewer", "Pie Editor", wx.OK | wx.ICON_WARNING) event.Skip() return mv._pie_pick_controller = self.controller mv._pie_pick_editor = self mv.start_action('pick pie center', 'Pick pie center on map') wx.MessageBox("Right-click on map to set pie center", "Pie Editor", wx.OK | wx.ICON_INFORMATION) event.Skip()
[docs] def on_transform_map(self, event): """Start interactive move/resize mode with map handles.""" mv = self.controller.mapviewer if mv is None: wx.MessageBox("Pie is not attached to a mapviewer", "Pie Editor", wx.OK | wx.ICON_WARNING) event.Skip() return mv._asset_transform_controller = self.controller mv._asset_transform_editor = self mv.start_action('transform asset bounds', 'Transform asset bounds on map') wx.MessageBox( "Left-click a handle (corners/sides/center), then drag to move/resize the asset.\n" "Shift: keep ratio (corners) | Ctrl: resize from center | Alt: enable snap.", "Pie Editor", wx.OK | wx.ICON_INFORMATION, ) event.Skip()
[docs] def on_add_row(self, event): """Add a new empty sector row to the grid.""" self._grid.AppendRows(1) r = self._grid.GetNumberRows() - 1 self._grid.SetCellValue(r, 0, f"Slice {r + 1}") self._grid.SetCellValue(r, 1, "0") base = PieZonesAsset.DEFAULT_COLORS[r % len(PieZonesAsset.DEFAULT_COLORS)] self._set_row_color(r, base) self._grid.SetCellValue(r, 3, "0.000000") self._grid.SetReadOnly(r, 3, True) self._grid.SetCellValue(r, 4, "") self._grid.SetCellValue(r, 5, "") colors = self._colors_from_controller(self._grid.GetNumberRows()) colors[r] = tuple(base) self._sync_color_param_schema(self._grid.GetNumberRows()) self._set_wp_colors(colors) self._wp.Populate(sorted_groups=False) self._refresh_manual_columns_state() self._sync_picker_from_row(r) self._maybe_live() event.Skip()
[docs] def on_del_row(self, event): """Delete the selected grid row (sector).""" if self._grid.GetNumberRows() > 1: r = self._grid.GetNumberRows() - 1 self._grid.DeleteRows(r, 1) if r < len(self._colors): self._colors.pop(r) colors = list(self._colors) self._sync_color_param_schema(self._grid.GetNumberRows()) self._set_wp_colors(colors) self._wp.Populate(sorted_groups=False) cur = self._grid.GetGridCursorRow() self._sync_picker_from_row(max(0, min(cur, self._grid.GetNumberRows() - 1))) self._maybe_live() event.Skip()
[docs] def on_apply(self, event): """Commit all changes to controller and rebuild.""" try: self._apply_controller() except Exception as exc: wx.MessageBox(str(exc), "Pie Editor", wx.OK | wx.ICON_ERROR) event.Skip()
[docs] def on_save_json(self, event): """Prompt for file and save controller state to JSON.""" dlg = wx.FileDialog(self, "Save pie JSON", wildcard="JSON (*.json)|*.json", style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT) if dlg.ShowModal() == wx.ID_OK: try: self._apply_controller() self.controller.save_json(dlg.GetPath()) except Exception as exc: wx.MessageBox(str(exc), "Pie Editor", wx.OK | wx.ICON_ERROR) dlg.Destroy() event.Skip()
[docs] def on_load_json(self, event): """Prompt for file and load controller state from JSON.""" dlg = wx.FileDialog(self, "Load pie JSON", wildcard="JSON (*.json)|*.json", style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST) if dlg.ShowModal() == wx.ID_OK: try: loaded = PieZonesController.load_json(dlg.GetPath()) loaded.mapviewer = self.controller.mapviewer loaded.editor = self self.controller = loaded self.controller.rebuild(ToCheck=True) self.refresh_from_controller() except Exception as exc: wx.MessageBox(str(exc), "Pie Editor", wx.OK | wx.ICON_ERROR) dlg.Destroy() event.Skip()
[docs] def on_save_vecz(self, event): """Export pie geometry to VEC or VECZ (zipped) format.""" dlg = wx.FileDialog( self, "Save pie geometry", wildcard="VECZ (*.vecz)|*.vecz|VEC (*.vec)|*.vec", style=wx.FD_SAVE | wx.FD_OVERWRITE_PROMPT, ) if dlg.ShowModal() == wx.ID_OK: try: self._apply_controller() self.controller.save_vec_or_vecz(dlg.GetPath()) except Exception as exc: wx.MessageBox(str(exc), "Pie Editor", wx.OK | wx.ICON_ERROR) dlg.Destroy() event.Skip()