from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING, Sequence
from ...PyVertexvectors import Zones
from .zones_asset import PieZonesAsset
if TYPE_CHECKING:
from .editor import PieZonesEditor
[docs]
class PieZonesController:
"""Persistent controller for editable pie assets.
The controller stores editable parameters, keeps a pointer to the current
Zones object in the mapviewer, and can rebuild geometry dynamically.
"""
def __init__(
self,
values: Sequence[float],
center: tuple[float, float],
radius: float,
*,
id: str = "pie_chart",
labels: Sequence[str] | None = None,
colors: Sequence[tuple[int, int, int] | tuple[int, int, int, int]] | None = None,
start_angle_deg: float = 90.0,
clockwise: bool = False,
sectors_as_independent_zones: bool = False,
legend_visible: bool = True,
legend_show_percent: bool = True,
legend_show_value: bool = False,
legend_offset_factor: float = 0.65,
legend_position_mode: str = "auto",
legend_positions: Sequence[tuple[float, float] | None] | None = None,
legend_text_color: tuple[int, int, int] = (0, 0, 0),
fill_clock_animation: bool = False,
fill_anim_speed: float = 1.0,
smoothing: bool = True,
border_enabled: bool = True,
border_width: float = 1.0,
border_color: tuple[int, int, int] = (25, 25, 25),
store_fraction_in_z: bool = True,
) -> None:
[docs]
self.values = [float(v) for v in values]
if labels is None:
self.labels = [f"Slice {i + 1}" for i in range(len(self.values))]
else:
self.labels = [str(lbl) for lbl in labels]
if len(self.labels) != len(self.values):
raise ValueError("labels length must match values length")
[docs]
self.colors = [] if colors is None else [tuple(c) for c in colors]
[docs]
self.center_x = float(center[0])
[docs]
self.center_y = float(center[1])
[docs]
self.radius = float(radius)
[docs]
self.start_angle_deg = float(start_angle_deg)
[docs]
self.clockwise = bool(clockwise)
[docs]
self.sectors_as_independent_zones = bool(sectors_as_independent_zones)
[docs]
self.legend_visible = bool(legend_visible)
[docs]
self.legend_show_percent = bool(legend_show_percent)
[docs]
self.legend_show_value = bool(legend_show_value)
[docs]
self.legend_offset_factor = float(legend_offset_factor)
[docs]
self.legend_position_mode = str(legend_position_mode).strip().lower() or "auto"
if self.legend_position_mode not in {"auto", "manual"}:
raise ValueError("legend_position_mode must be 'auto' or 'manual'")
[docs]
self.legend_positions = self._normalize_legend_positions(legend_positions)
[docs]
self.legend_text_color = (
int(max(0, min(255, legend_text_color[0]))),
int(max(0, min(255, legend_text_color[1]))),
int(max(0, min(255, legend_text_color[2]))),
)
[docs]
self.fill_clock_animation = bool(fill_clock_animation)
[docs]
self.fill_anim_speed = float(fill_anim_speed)
[docs]
self.smoothing = bool(smoothing)
[docs]
self.border_enabled = bool(border_enabled)
[docs]
self.border_width = float(border_width)
[docs]
self.border_color = tuple(border_color)
[docs]
self.store_fraction_in_z = bool(store_fraction_in_z)
[docs]
self.zones: Zones | None = None
[docs]
self.editor: PieZonesEditor | None = None
[docs]
def _normalize_legend_positions(
self,
positions: Sequence[tuple[float, float] | None] | None,
) -> list[tuple[float, float] | None]:
if positions is None:
return [None] * len(self.values)
out: list[tuple[float, float] | None] = []
for pos in positions:
if pos is None:
out.append(None)
else:
out.append((float(pos[0]), float(pos[1])))
if len(out) < len(self.values):
out.extend([None] * (len(self.values) - len(out)))
elif len(out) > len(self.values):
out = out[:len(self.values)]
return out
[docs]
def _ensure_legend_positions_length(self) -> None:
self.legend_positions = self._normalize_legend_positions(self.legend_positions)
[docs]
def _get_mapviewer_objects(self) -> list:
"""Return all mapviewer objects, or an empty list if unavailable."""
if self.mapviewer is None or not hasattr(self.mapviewer, "get_list_objects"):
return []
try:
return list(self.mapviewer.get_list_objects(drawing_type=None, checked_state=None))
except TypeError:
# Backward-compatible signature fallback.
return list(self.mapviewer.get_list_objects(None, checked_state=None))
except Exception:
return []
[docs]
def _get_vector_draw_type(self):
"""Resolve the viewer enum value for vector objects lazily."""
from ...PyDraw import draw_type
return draw_type.VECTORS
[docs]
def _get_vector_by_id(self, obj_id: str):
"""Return a vector object by id without scanning unrelated draw types."""
if self.mapviewer is None or not hasattr(self.mapviewer, "get_obj_from_id"):
return None
return self.mapviewer.get_obj_from_id(obj_id, drawing_type=self._get_vector_draw_type())
[docs]
def _find_bound_zones_in_mapviewer(self) -> Zones | None:
"""Return the currently bound Zones object if it is still present in the viewer."""
if self.zones is None or self.mapviewer is None:
return None
for curobj in self._get_mapviewer_objects():
if curobj is self.zones:
return self.zones
return None
[docs]
def _resolve_unique_id(self, preferred: str, exclude_obj=None) -> str:
"""Return an id not used by other objects in the viewer."""
base = str(preferred).strip() or "pie_chart"
if self.mapviewer is None:
return base
used_ids = {
str(getattr(curobj, "idx", "")).lower()
for curobj in self._get_mapviewer_objects()
if curobj is not exclude_obj and str(getattr(curobj, "idx", "")).strip() != ""
}
if base.lower() not in used_ids:
return base
k = 1
while f"{base}_{k:03d}".lower() in used_ids:
k += 1
return f"{base}_{k:03d}"
[docs]
def _build_asset(self) -> PieZonesAsset:
return PieZonesAsset(
values=self.values,
center=(self.center_x, self.center_y),
radius=self.radius,
labels=self.labels,
colors=self.colors if len(self.colors) > 0 else None,
start_angle_deg=self.start_angle_deg,
clockwise=self.clockwise,
sectors_as_independent_zones=self.sectors_as_independent_zones,
legend_visible=self.legend_visible,
legend_show_percent=self.legend_show_percent,
legend_show_value=self.legend_show_value,
legend_offset_factor=self.legend_offset_factor,
legend_position_mode=self.legend_position_mode,
legend_positions=self.legend_positions,
legend_text_color=self.legend_text_color,
fill_clock_animation=self.fill_clock_animation,
fill_anim_speed=self.fill_anim_speed,
smoothing=self.smoothing,
border_enabled=self.border_enabled,
border_width=self.border_width,
border_color=self.border_color,
store_fraction_in_z=self.store_fraction_in_z,
idx=self.id,
parent=self.mapviewer,
mapviewer=self.mapviewer,
)
[docs]
def attach_to_mapviewer(self, mapviewer, id: str | None = None, ToCheck: bool = True) -> Zones:
self.mapviewer = mapviewer
if id is not None and id != "":
self.id = str(id)
return self.rebuild(ToCheck=ToCheck)
[docs]
def _sync_effective_legend_positions(self) -> None:
"""Read back legend positions from the built Zones vectors.
In 'auto' mode the positions are computed inside ``to_zones()`` but
never stored in the controller. This helper copies them back so that
the editor grid can display them.
"""
if self.zones is None:
return
try:
vecs = self.zones.myzones[0].myvectors
except (IndexError, AttributeError):
return
positions: list[tuple[float, float] | None] = []
for v in vecs:
try:
positions.append((float(v.myprop.legendx), float(v.myprop.legendy)))
except Exception:
positions.append(None)
self.legend_positions = positions
[docs]
def rebuild(self, ToCheck: bool = True) -> Zones:
"""Rebuild geometry from current parameters and update mapviewer."""
bound = self._find_bound_zones_in_mapviewer()
if bound is not None:
# Keep controller id aligned with the actually bound object.
self.id = str(bound.idx)
else:
# Direct attach calls may bypass mapviewer-level id checks.
self.id = self._resolve_unique_id(self.id)
asset = self._build_asset()
new_zones = asset.to_zones()
new_zones.idx = self.id
new_zones._pie_controller = self
if self.mapviewer is None:
self.zones = new_zones
self._sync_effective_legend_positions()
return new_zones
old = bound if bound is not None else self._get_vector_by_id(self.id)
# Safety: if id lookup points to another pie controller, do not replace it.
if old is not None and getattr(old, '_pie_controller', None) not in (None, self):
self.id = self._resolve_unique_id(self.id)
new_zones.idx = self.id
old = None
# Preferred path: replace object in-place to keep tree/gui state stable.
if old is not None and hasattr(self.mapviewer, 'replace_object'):
try:
try:
new_zones.change_gui(self.mapviewer)
except Exception:
pass
new_zones.plotted = getattr(old, 'plotted', ToCheck)
self.mapviewer.replace_object(self.id, new_zones, drawing_type=self._get_vector_draw_type())
self.zones = new_zones
self._sync_effective_legend_positions()
self.mapviewer.Refresh()
return new_zones
except Exception:
# Fall back to remove/add path below.
pass
try:
if old is not None:
self.mapviewer.removeobj_from_id(self.id)
self.mapviewer.add_object('vector', newobj=new_zones, id=self.id, ToCheck=ToCheck)
new_zones._pie_controller = self
self.zones = new_zones
self._sync_effective_legend_positions()
self.mapviewer.Refresh()
return new_zones
except Exception:
# Roll back to the previous object so UI edits cannot make the pie disappear.
if old is not None and self._get_vector_by_id(self.id) is None:
self.mapviewer.add_object('vector', newobj=old, id=self.id, ToCheck=ToCheck)
old._pie_controller = self
self.zones = old
self.mapviewer.Refresh()
raise
[docs]
def update_data(
self,
values: Sequence[float] | None = None,
labels: Sequence[str] | None = None,
colors: Sequence[tuple[int, int, int] | tuple[int, int, int, int]] | None = None,
rebuild: bool = True,
) -> None:
if values is not None:
self.values = [float(v) for v in values]
if labels is not None:
self.labels = [str(lbl) for lbl in labels]
if colors is not None:
self.colors = [tuple(c) for c in colors]
if len(self.labels) != len(self.values):
raise ValueError("labels length must match values length")
self._ensure_legend_positions_length()
if rebuild:
self.rebuild(ToCheck=True)
[docs]
def update_legend_layout(
self,
*,
position_mode: str | None = None,
positions: Sequence[tuple[float, float] | None] | None = None,
offset_factor: float | None = None,
legend_text_color: tuple[int, int, int] | None = None,
rebuild: bool = True,
) -> None:
if position_mode is not None:
mode = str(position_mode).strip().lower() or "auto"
if mode not in {"auto", "manual"}:
raise ValueError("legend_position_mode must be 'auto' or 'manual'")
self.legend_position_mode = mode
if positions is not None:
self.legend_positions = self._normalize_legend_positions(positions)
else:
self._ensure_legend_positions_length()
if offset_factor is not None:
self.legend_offset_factor = float(offset_factor)
if legend_text_color is not None:
self.legend_text_color = (
int(max(0, min(255, legend_text_color[0]))),
int(max(0, min(255, legend_text_color[1]))),
int(max(0, min(255, legend_text_color[2]))),
)
if rebuild:
self.rebuild(ToCheck=True)
[docs]
def update_geometry(
self,
x: float | None = None,
y: float | None = None,
radius: float | None = None,
start_angle_deg: float | None = None,
clockwise: bool | None = None,
rebuild: bool = True,
) -> None:
if x is not None:
self.center_x = float(x)
if y is not None:
self.center_y = float(y)
if radius is not None:
self.radius = float(radius)
if start_angle_deg is not None:
self.start_angle_deg = float(start_angle_deg)
if clockwise is not None:
self.clockwise = bool(clockwise)
if rebuild:
self.rebuild(ToCheck=True)
[docs]
def to_dict(self) -> dict:
return {
"id": self.id,
"values": self.values,
"labels": self.labels,
"colors": [list(c) for c in self.colors],
"center": [self.center_x, self.center_y],
"radius": self.radius,
"start_angle_deg": self.start_angle_deg,
"clockwise": self.clockwise,
"sectors_as_independent_zones": self.sectors_as_independent_zones,
"legend_visible": self.legend_visible,
"legend_show_percent": self.legend_show_percent,
"legend_show_value": self.legend_show_value,
"legend_offset_factor": self.legend_offset_factor,
"legend_position_mode": self.legend_position_mode,
"legend_positions": [list(pos) if pos is not None else None for pos in self.legend_positions],
"legend_text_color": list(self.legend_text_color),
"fill_clock_animation": self.fill_clock_animation,
"fill_anim_speed": self.fill_anim_speed,
"smoothing": self.smoothing,
"border_enabled": self.border_enabled,
"border_width": self.border_width,
"border_color": list(self.border_color),
"store_fraction_in_z": self.store_fraction_in_z,
}
@classmethod
[docs]
def from_dict(cls, data: dict) -> "PieZonesController":
center = data.get("center", [0.0, 0.0])
return cls(
values=data.get("values", []),
labels=data.get("labels", None),
colors=[tuple(c) for c in data.get("colors", [])],
center=(float(center[0]), float(center[1])),
radius=float(data.get("radius", 10.0)),
id=str(data.get("id", "pie_chart")),
start_angle_deg=float(data.get("start_angle_deg", 90.0)),
clockwise=bool(data.get("clockwise", False)),
sectors_as_independent_zones=bool(data.get("sectors_as_independent_zones", False)),
legend_visible=bool(data.get("legend_visible", True)),
legend_show_percent=bool(data.get("legend_show_percent", True)),
legend_show_value=bool(data.get("legend_show_value", False)),
legend_offset_factor=float(data.get("legend_offset_factor", 0.65)),
legend_position_mode=str(data.get("legend_position_mode", "auto")),
legend_positions=[tuple(pos) if pos is not None else None for pos in data.get("legend_positions", [])],
legend_text_color=tuple(data.get("legend_text_color", [0, 0, 0])),
fill_clock_animation=bool(data.get("fill_clock_animation", False)),
fill_anim_speed=float(data.get("fill_anim_speed", 1.0)),
smoothing=bool(data.get("smoothing", True)),
border_enabled=bool(data.get("border_enabled", True)),
border_width=float(data.get("border_width", 1.0)),
border_color=tuple(data.get("border_color", [25, 25, 25])),
store_fraction_in_z=bool(data.get("store_fraction_in_z", True)),
)
[docs]
def save_json(self, filepath: str | Path) -> Path:
target = Path(filepath)
if target.suffix.lower() != ".json":
target = target.with_suffix(".json")
with open(target, "w", encoding="utf-8") as f:
json.dump(self.to_dict(), f, ensure_ascii=False, indent=2)
return target
@classmethod
[docs]
def load_json(cls, filepath: str | Path) -> "PieZonesController":
src = Path(filepath)
with open(src, "r", encoding="utf-8") as f:
data = json.load(f)
return cls.from_dict(data)
[docs]
def save_vec_or_vecz(self, filepath: str | Path) -> Path:
if self.zones is None:
self.rebuild(ToCheck=True)
target = Path(filepath)
if target.suffix.lower() not in (".vec", ".vecz"):
target = target.with_suffix(".vecz" if self.store_fraction_in_z else ".vec")
self.zones.saveas(str(target))
return target
[docs]
def show_editor(self, parent=None):
if self.editor is None:
from .editor import PieZonesEditor
self.editor = PieZonesEditor(parent=parent, controller=self)
self.editor.Show()
self.editor.Raise()
return self.editor
[docs]
def build_pie_controller(
values: Sequence[float],
center: tuple[float, float],
radius: float,
**kwargs,
) -> PieZonesController:
"""Convenience function returning a persistent pie controller."""
return PieZonesController(values=values, center=center, radius=radius, **kwargs)