"""Persistent controller for editable curve plot assets."""
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING, Sequence
from ...PyVertexvectors import Zones
from .zones_asset import CurveZonesAsset
if TYPE_CHECKING:
from .editor import CurveZonesEditor
[docs]
class CurveZonesController:
"""Persistent controller for curve plots in a canvas fraction."""
def __init__(
self,
curves: Sequence[Sequence[tuple[float, float]]],
*,
id: str = "curve_plot",
labels: Sequence[str] | None = None,
x_bounds: tuple[float, float] | None = None,
y_bounds: tuple[float, float] | None = None,
clamp_projected_points: bool = True,
sort_by_x: bool = True,
colors: Sequence[tuple[int, int, int] | tuple[int, int, int, int]] | None = None,
curve_styles: Sequence[dict] | None = 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),
line_width: float = 1.5,
line_alpha: int = 255,
show_area_frame: bool = True,
frame_color: tuple[int, int, int] = (40, 40, 40),
show_axes: bool = False,
axes_color: tuple[int, int, int] = (25, 25, 25),
axes_line_width: float = 1.2,
show_grid: bool = False,
grid_color: tuple[int, int, int] = (120, 120, 120),
grid_line_width: float = 0.8,
grid_dx: float = 1.0,
grid_dy: float = 1.0,
show_x_ticks: bool = False,
show_y_ticks: bool = False,
show_x_tick_labels: bool = False,
show_y_tick_labels: bool = False,
x_tick_position: str = "inside",
y_tick_position: str = "inside",
x_tick_format: str = "",
y_tick_format: str = "",
legend_visible: bool = False,
legend_text_color: tuple[int, int, int] = (0, 0, 0),
mapviewer=None,
editor: CurveZonesEditor | None = None,
) -> None:
[docs]
self.curves = [[(float(x), float(y)) for x, y in curve] for curve in curves]
if not self.curves:
raise ValueError("At least one curve is required")
[docs]
self.labels = [str(lbl) for lbl in labels] if labels is not None else [f"Curve {i + 1}" for i in range(len(self.curves))]
if len(self.labels) != len(self.curves):
raise ValueError("labels length must match curves length")
[docs]
self.x_bounds = None if x_bounds is None else (float(x_bounds[0]), float(x_bounds[1]))
[docs]
self.y_bounds = None if y_bounds is None else (float(y_bounds[0]), float(y_bounds[1]))
[docs]
self.clamp_projected_points = bool(clamp_projected_points)
[docs]
self.sort_by_x = bool(sort_by_x)
[docs]
self.colors = [tuple(c) for c in colors] if colors is not None else None
[docs]
self.canvas_x = float(canvas_origin[0])
[docs]
self.canvas_y = float(canvas_origin[1])
[docs]
self.canvas_width = float(canvas_size[0])
[docs]
self.canvas_height = float(canvas_size[1])
[docs]
self.area_fraction = tuple(float(v) for v in area_fraction)
[docs]
self.line_width = float(line_width)
[docs]
self.line_alpha = int(max(0, min(255, line_alpha)))
[docs]
self.curve_styles = self._normalize_curve_styles(curve_styles)
[docs]
self.show_area_frame = bool(show_area_frame)
[docs]
self.frame_color = tuple(frame_color)
[docs]
self.show_axes = bool(show_axes)
[docs]
self.axes_color = tuple(axes_color)
[docs]
self.axes_line_width = max(0.1, float(axes_line_width))
[docs]
self.show_grid = bool(show_grid)
[docs]
self.grid_color = tuple(grid_color)
[docs]
self.grid_line_width = max(0.1, float(grid_line_width))
[docs]
self.grid_dx = float(grid_dx)
[docs]
self.grid_dy = float(grid_dy)
if self.grid_dx <= 0 or self.grid_dy <= 0:
raise ValueError("grid_dx and grid_dy must be > 0")
[docs]
self.show_x_ticks = bool(show_x_ticks)
[docs]
self.show_y_ticks = bool(show_y_ticks)
[docs]
self.show_x_tick_labels = bool(show_x_tick_labels)
[docs]
self.show_y_tick_labels = bool(show_y_tick_labels)
[docs]
self.x_tick_position = str(x_tick_position).strip().lower()
[docs]
self.y_tick_position = str(y_tick_position).strip().lower()
if self.x_tick_position not in {"inside", "outside", "centered"}:
self.x_tick_position = "inside"
if self.y_tick_position not in {"inside", "outside", "centered"}:
self.y_tick_position = "inside"
[docs]
self.legend_visible = bool(legend_visible)
[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.mapviewer = mapviewer
[docs]
self.zones: Zones | None = None
[docs]
def _normalize_curve_styles(self, styles: Sequence[dict] | None) -> list[dict]:
count = len(self.curves)
normalized: list[dict] = []
allowed = {"solid", "dashed", "dotted", "dashdot"}
for i in range(count):
src = styles[i] if (styles is not None and i < len(styles)) else {}
if src is None:
src = {}
lw = float(src.get("line_width", self.line_width))
if lw <= 0:
lw = self.line_width
ls = str(src.get("line_style", "solid")).strip().lower()
if ls not in allowed:
ls = "solid"
normalized.append({"line_width": lw, "line_style": ls})
return normalized
[docs]
def _build_asset(self) -> CurveZonesAsset:
return CurveZonesAsset(
curves=self.curves,
labels=self.labels,
x_bounds=self.x_bounds,
y_bounds=self.y_bounds,
clamp_projected_points=self.clamp_projected_points,
sort_by_x=self.sort_by_x,
colors=self.colors,
curve_styles=self.curve_styles,
canvas_origin=(self.canvas_x, self.canvas_y),
canvas_size=(self.canvas_width, self.canvas_height),
area_fraction=self.area_fraction,
line_width=self.line_width,
line_alpha=self.line_alpha,
show_area_frame=self.show_area_frame,
frame_color=self.frame_color,
show_axes=self.show_axes,
axes_color=self.axes_color,
axes_line_width=self.axes_line_width,
show_grid=self.show_grid,
grid_color=self.grid_color,
grid_line_width=self.grid_line_width,
grid_dx=self.grid_dx,
grid_dy=self.grid_dy,
show_x_ticks=self.show_x_ticks,
show_y_ticks=self.show_y_ticks,
show_x_tick_labels=self.show_x_tick_labels,
show_y_tick_labels=self.show_y_tick_labels,
x_tick_position=self.x_tick_position,
y_tick_position=self.y_tick_position,
x_tick_format=self.x_tick_format,
y_tick_format=self.y_tick_format,
legend_visible=self.legend_visible,
legend_text_color=self.legend_text_color,
idx=self.id,
parent=self.mapviewer,
mapviewer=self.mapviewer,
)
[docs]
def update_curves(
self,
curves: Sequence[Sequence[tuple[float, float]]] | None = None,
labels: Sequence[str] | None = None,
colors: Sequence[tuple[int, int, int] | tuple[int, int, int, int]] | None = None,
curve_styles: Sequence[dict] | None = None,
rebuild: bool = True,
) -> None:
if curves is not None:
self.curves = [[(float(x), float(y)) for x, y in curve] for curve in curves]
if not self.curves:
raise ValueError("At least one curve is required")
if labels is not None:
self.labels = [str(lbl) for lbl in labels]
if len(self.labels) != len(self.curves):
raise ValueError("labels length must match curves length")
if colors is not None:
self.colors = [tuple(c) for c in colors]
if curve_styles is not None:
self.curve_styles = self._normalize_curve_styles(curve_styles)
elif curves is not None:
self.curve_styles = self._normalize_curve_styles(self.curve_styles)
if rebuild:
self.rebuild(ToCheck=True)
[docs]
def update_geometry(
self,
*,
x_bounds: tuple[float, float] | None = None,
y_bounds: tuple[float, float] | None = None,
canvas_origin: tuple[float, float] | None = None,
canvas_size: tuple[float, float] | None = None,
area_fraction: tuple[float, float, float, float] | None = None,
rebuild: bool = True,
) -> None:
if x_bounds is not None:
self.x_bounds = (float(x_bounds[0]), float(x_bounds[1]))
if y_bounds is not None:
self.y_bounds = (float(y_bounds[0]), float(y_bounds[1]))
if canvas_origin is not None:
self.canvas_x = float(canvas_origin[0])
self.canvas_y = float(canvas_origin[1])
if canvas_size is not None:
self.canvas_width = float(canvas_size[0])
self.canvas_height = float(canvas_size[1])
if area_fraction is not None:
self.area_fraction = tuple(float(v) for v in area_fraction)
if rebuild:
self.rebuild(ToCheck=True)
[docs]
def update_style(
self,
*,
clamp_projected_points: bool | None = None,
sort_by_x: bool | None = None,
line_width: float | None = None,
line_alpha: int | None = None,
show_area_frame: bool | None = None,
frame_color: tuple[int, int, int] | None = None,
show_axes: bool | None = None,
axes_color: tuple[int, int, int] | None = None,
axes_line_width: float | None = None,
show_grid: bool | None = None,
grid_color: tuple[int, int, int] | None = None,
grid_line_width: float | None = None,
grid_dx: float | None = None,
grid_dy: float | None = None,
show_x_ticks: bool | None = None,
show_y_ticks: bool | None = None,
show_x_tick_labels: bool | None = None,
show_y_tick_labels: bool | None = None,
x_tick_position: str | None = None,
y_tick_position: str | None = None,
x_tick_format: str | None = None,
y_tick_format: str | None = None,
legend_visible: bool | None = None,
legend_text_color: tuple[int, int, int] | None = None,
rebuild: bool = True,
) -> None:
if clamp_projected_points is not None:
self.clamp_projected_points = bool(clamp_projected_points)
if sort_by_x is not None:
self.sort_by_x = bool(sort_by_x)
if line_width is not None:
self.line_width = float(line_width)
if line_alpha is not None:
self.line_alpha = int(max(0, min(255, line_alpha)))
if show_area_frame is not None:
self.show_area_frame = bool(show_area_frame)
if frame_color is not None:
self.frame_color = tuple(frame_color)
if show_axes is not None:
self.show_axes = bool(show_axes)
if axes_color is not None:
self.axes_color = tuple(axes_color)
if axes_line_width is not None:
self.axes_line_width = max(0.1, float(axes_line_width))
if show_grid is not None:
self.show_grid = bool(show_grid)
if grid_color is not None:
self.grid_color = tuple(grid_color)
if grid_line_width is not None:
self.grid_line_width = max(0.1, float(grid_line_width))
if grid_dx is not None:
self.grid_dx = float(grid_dx)
if self.grid_dx <= 0:
raise ValueError("grid_dx must be > 0")
if grid_dy is not None:
self.grid_dy = float(grid_dy)
if self.grid_dy <= 0:
raise ValueError("grid_dy must be > 0")
if show_x_ticks is not None:
self.show_x_ticks = bool(show_x_ticks)
if show_y_ticks is not None:
self.show_y_ticks = bool(show_y_ticks)
if show_x_tick_labels is not None:
self.show_x_tick_labels = bool(show_x_tick_labels)
if show_y_tick_labels is not None:
self.show_y_tick_labels = bool(show_y_tick_labels)
if x_tick_position is not None:
val = str(x_tick_position).strip().lower()
if val not in {"inside", "outside", "centered"}:
raise ValueError("x_tick_position must be one of: inside, outside, centered")
self.x_tick_position = val
if y_tick_position is not None:
val = str(y_tick_position).strip().lower()
if val not in {"inside", "outside", "centered"}:
raise ValueError("y_tick_position must be one of: inside, outside, centered")
self.y_tick_position = val
if x_tick_format is not None:
self.x_tick_format = str(x_tick_format)
if y_tick_format is not None:
self.y_tick_format = str(y_tick_format)
if legend_visible is not None:
self.legend_visible = bool(legend_visible)
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 rebuild(self, ToCheck: bool = True) -> Zones:
bound = self._find_bound_zones_in_mapviewer()
if bound is not None:
self.id = str(bound.idx)
else:
self.id = self._resolve_unique_id(self.id)
asset = self._build_asset()
new_zones = asset.to_zones()
new_zones.idx = self.id
new_zones._curve_controller = self
if self.mapviewer is None:
self.zones = new_zones
return new_zones
old = bound if bound is not None else self._get_vector_by_id(self.id)
if old is not None and getattr(old, "_curve_controller", None) not in (None, self):
self.id = self._resolve_unique_id(self.id)
new_zones.idx = self.id
old = None
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.mapviewer.Refresh()
return new_zones
except Exception:
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._curve_controller = self
self.zones = new_zones
self.mapviewer.Refresh()
return new_zones
except Exception:
self.zones = old
raise
[docs]
def _get_mapviewer_objects(self) -> list:
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:
return list(self.mapviewer.get_list_objects(None, checked_state=None))
except Exception:
return []
[docs]
def _get_vector_draw_type(self):
from ...PyDraw import draw_type
return draw_type.VECTORS
[docs]
def _get_vector_by_id(self, obj_id: str):
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:
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:
base = str(preferred).strip() or "curve_plot"
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:
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):
if self.editor is None:
from .editor import CurveZonesEditor
self.editor = CurveZonesEditor(parent=parent, controller=self)
self.editor.Show()
self.editor.Raise()
return self.editor
[docs]
def to_dict(self) -> dict:
return {
"id": self.id,
"curves": [[list(pt) for pt in curve] for curve in self.curves],
"labels": self.labels,
"x_bounds": None if self.x_bounds is None else list(self.x_bounds),
"y_bounds": None if self.y_bounds is None else list(self.y_bounds),
"clamp_projected_points": self.clamp_projected_points,
"sort_by_x": self.sort_by_x,
"colors": [list(c) for c in self.colors] if self.colors is not None else None,
"curve_styles": self.curve_styles,
"canvas_origin": [self.canvas_x, self.canvas_y],
"canvas_size": [self.canvas_width, self.canvas_height],
"area_fraction": list(self.area_fraction),
"line_width": self.line_width,
"line_alpha": self.line_alpha,
"show_area_frame": self.show_area_frame,
"frame_color": list(self.frame_color),
"show_axes": self.show_axes,
"axes_color": list(self.axes_color),
"axes_line_width": self.axes_line_width,
"show_grid": self.show_grid,
"grid_color": list(self.grid_color),
"grid_line_width": self.grid_line_width,
"grid_dx": self.grid_dx,
"grid_dy": self.grid_dy,
"show_x_ticks": self.show_x_ticks,
"show_y_ticks": self.show_y_ticks,
"show_x_tick_labels": self.show_x_tick_labels,
"show_y_tick_labels": self.show_y_tick_labels,
"x_tick_position": self.x_tick_position,
"y_tick_position": self.y_tick_position,
"x_tick_format": self.x_tick_format,
"y_tick_format": self.y_tick_format,
"legend_visible": self.legend_visible,
"legend_text_color": list(self.legend_text_color),
}
@staticmethod
[docs]
def from_dict(data: dict) -> "CurveZonesController":
return CurveZonesController(
id=str(data.get("id", "curve_plot")),
curves=[
[(float(pt[0]), float(pt[1])) for pt in curve]
for curve in data.get("curves", [[(0.0, 0.2), (1.0, 0.8)]])
],
labels=data.get("labels"),
x_bounds=(tuple(data.get("x_bounds")) if data.get("x_bounds") is not None else None),
y_bounds=(tuple(data.get("y_bounds")) if data.get("y_bounds") is not None else None),
clamp_projected_points=bool(data.get("clamp_projected_points", True)),
sort_by_x=bool(data.get("sort_by_x", True)),
colors=data.get("colors"),
curve_styles=data.get("curve_styles"),
canvas_origin=tuple(data.get("canvas_origin", [0.0, 0.0])),
canvas_size=tuple(data.get("canvas_size", [100.0, 100.0])),
area_fraction=tuple(data.get("area_fraction", [0.1, 0.1, 0.8, 0.3])),
line_width=float(data.get("line_width", 1.5)),
line_alpha=int(data.get("line_alpha", 255)),
show_area_frame=bool(data.get("show_area_frame", True)),
frame_color=tuple(data.get("frame_color", [40, 40, 40])),
show_axes=bool(data.get("show_axes", False)),
axes_color=tuple(data.get("axes_color", [25, 25, 25])),
axes_line_width=float(data.get("axes_line_width", 1.2)),
show_grid=bool(data.get("show_grid", False)),
grid_color=tuple(data.get("grid_color", [120, 120, 120])),
grid_line_width=float(data.get("grid_line_width", 0.8)),
grid_dx=float(data.get("grid_dx", 1.0)),
grid_dy=float(data.get("grid_dy", 1.0)),
show_x_ticks=bool(data.get("show_x_ticks", False)),
show_y_ticks=bool(data.get("show_y_ticks", False)),
show_x_tick_labels=bool(data.get("show_x_tick_labels", False)),
show_y_tick_labels=bool(data.get("show_y_tick_labels", False)),
x_tick_position=str(data.get("x_tick_position", "inside")),
y_tick_position=str(data.get("y_tick_position", "inside")),
x_tick_format=str(data.get("x_tick_format", "")),
y_tick_format=str(data.get("y_tick_format", "")),
legend_visible=bool(data.get("legend_visible", False)),
legend_text_color=tuple(data.get("legend_text_color", [0, 0, 0])),
)
[docs]
def save_json(self, path: str | Path) -> None:
with open(path, "w", encoding="utf-8") as f:
json.dump(self.to_dict(), f, indent=2)
@staticmethod
[docs]
def load_json(path: str | Path) -> "CurveZonesController":
with open(path, encoding="utf-8") as f:
data = json.load(f)
return CurveZonesController.from_dict(data)