"""Persistent controller for editable bar chart assets.
The controller stores editable parameters, keeps a pointer to the current
Zones object in the mapviewer, and can rebuild geometry dynamically.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING, Sequence
from ...PyVertexvectors import Zones
from .zones_asset import BarZonesAsset
if TYPE_CHECKING:
from .editor import BarZonesEditor
[docs]
class BarZonesController:
"""Persistent controller for editable bar assets."""
def __init__(
self,
values: Sequence[float],
position: tuple[float, float] = (0.0, 0.0),
width: float = 10.0,
height: float = 2.0,
*,
id: str = "bar_chart",
orientation: str = "horizontal",
labels: Sequence[str] | None = None,
colors: Sequence[tuple[int, int, int] | tuple[int, int, int, int]] | None = None,
legend_visible: bool = True,
legend_show_percent: bool = True,
legend_show_value: bool = False,
legend_offset_factor: float = 0.3,
legend_position_mode: str = "auto",
legend_positions: Sequence[tuple[float, float] | None] | None = None,
legend_text_color: tuple[int, int, int] = (0, 0, 0),
border_enabled: bool = True,
border_width: float = 1.0,
border_color: tuple[int, int, int] = (25, 25, 25),
mapviewer=None,
editor: BarZonesEditor | None = None,
) -> None:
[docs]
self.values = list(values)
[docs]
self.position_x = float(position[0])
[docs]
self.position_y = float(position[1])
[docs]
self.width = float(width)
[docs]
self.height = float(height)
[docs]
self.orientation = str(orientation).strip().lower()
[docs]
self.labels = list(labels) if labels is not None else None
[docs]
self.colors = [tuple(c) for c in colors] if colors is not None else None
[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.border_enabled = bool(border_enabled)
[docs]
self.border_width = float(border_width)
[docs]
self.border_color = tuple(border_color)
[docs]
self.mapviewer = mapviewer
[docs]
self.zones: Zones | None = None
[docs]
def _normalize_legend_positions(
self,
positions: Sequence[tuple[float, float] | None] | None,
) -> list[tuple[float, float] | None]:
count = len(self.values)
if positions is None:
return [None] * count
pos_list = list(positions)
if len(pos_list) < count:
pos_list.extend([None] * (count - len(pos_list)))
return pos_list[:count]
[docs]
def _ensure_legend_positions_length(self) -> None:
count = len(self.values)
if len(self.legend_positions) < count:
self.legend_positions.extend([None] * (count - len(self.legend_positions)))
elif len(self.legend_positions) > count:
self.legend_positions = self.legend_positions[:count]
[docs]
def _build_asset(self) -> BarZonesAsset:
"""Build BarZonesAsset from current controller state."""
return BarZonesAsset(
values=self.values,
position=(self.position_x, self.position_y),
width=self.width,
height=self.height,
orientation=self.orientation,
labels=self.labels,
colors=self.colors,
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,
border_enabled=self.border_enabled,
border_width=self.border_width,
border_color=self.border_color,
idx=self.id,
mapviewer=self.mapviewer,
)
[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:
"""Update segment data."""
if values is not None:
self.values = list(values)
self._ensure_legend_positions_length()
if labels is not None:
self.labels = list(labels)
if colors is not None:
self.colors = [tuple(c) for c in colors]
if rebuild:
self.rebuild(ToCheck=True)
[docs]
def update_geometry(
self,
x: float | None = None,
y: float | None = None,
width: float | None = None,
height: float | None = None,
orientation: str | None = None,
rebuild: bool = True,
) -> None:
"""Update bar geometry."""
if x is not None:
self.position_x = float(x)
if y is not None:
self.position_y = float(y)
if width is not None:
self.width = float(width)
if height is not None:
self.height = float(height)
if orientation is not None:
o = str(orientation).strip().lower()
if o not in {"horizontal", "vertical"}:
raise ValueError("orientation must be 'horizontal' or 'vertical'")
self.orientation = o
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:
"""Update legend layout."""
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 _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._bar_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 bar controller, do not replace it.
if old is not None and getattr(old, '_bar_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._bar_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 bar disappear.
self.zones = old
raise
[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 "bar_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 attach_to_mapviewer(self, mapviewer, id: str | None = None, ToCheck: bool = True) -> Zones:
"""Attach the controller to a mapviewer and rebuild geometry."""
self.mapviewer = mapviewer
if id is not None and id != "":
self.id = str(id)
return self.rebuild(ToCheck=ToCheck)
[docs]
def show_editor(self, parent=None):
"""Open the editor UI window for this controller."""
if self.editor is None:
from .editor import BarZonesEditor
self.editor = BarZonesEditor(parent=parent, controller=self)
self.editor.Show()
self.editor.Raise()
return self.editor
[docs]
def to_dict(self) -> dict:
"""Serialize controller state to dictionary."""
return {
"values": self.values,
"position": [self.position_x, self.position_y],
"width": self.width,
"height": self.height,
"orientation": self.orientation,
"id": self.id,
"labels": self.labels,
"colors": [list(c) for c in self.colors] if self.colors else None,
"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),
"border_enabled": self.border_enabled,
"border_width": self.border_width,
"border_color": list(self.border_color),
}
@staticmethod
[docs]
def from_dict(data: dict) -> BarZonesController:
"""Deserialize controller state from dictionary."""
return BarZonesController(
values=data.get("values", [1]),
position=tuple(data.get("position", [0, 0])),
width=float(data.get("width", 10)),
height=float(data.get("height", 2)),
id=str(data.get("id", "bar_chart")),
orientation=str(data.get("orientation", "horizontal")),
labels=data.get("labels"),
colors=data.get("colors"),
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.3)),
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])),
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])),
)
[docs]
def save_json(self, path: str | Path) -> None:
"""Save controller state to JSON file."""
with open(path, "w") as f:
json.dump(self.to_dict(), f, indent=2)
@staticmethod
[docs]
def load_json(path: str | Path) -> BarZonesController:
"""Load controller state from JSON file."""
with open(path) as f:
data = json.load(f)
return BarZonesController.from_dict(data)