"""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._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_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()