"""Curve asset factory producing native Zones for mapviewer rendering."""
from __future__ import annotations
import math
from datetime import datetime, timezone
from typing import Sequence
from ...PyVertexvectors import Zones, zone, vector, getIfromRGB
from .distribution import CurveDistributionModel
[docs]
class CurveZonesAsset:
"""Build polyline curves in a rectangular sub-area of a canvas."""
[docs]
DEFAULT_COLORS: tuple[tuple[int, int, int, int], ...] = (
(41, 98, 255, 255),
(0, 166, 118, 255),
(255, 122, 69, 255),
(138, 79, 255, 255),
(255, 178, 0, 255),
)
def __init__(
self,
curves: Sequence[Sequence[tuple[float, float]]],
*,
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),
idx: str = "curve_plot",
parent=None,
mapviewer=None,
) -> None:
# Cache immutable source coordinates exactly as provided by the caller.
[docs]
self._source_curves_cache = self._freeze_source_curves(curves)
auto_x_bounds, auto_y_bounds = self._infer_bounds_from_source(self._source_curves_cache)
[docs]
self.x_bounds = self._normalize_bounds(x_bounds, auto_x_bounds, axis_name="x")
[docs]
self.y_bounds = self._normalize_bounds(y_bounds, auto_y_bounds, axis_name="y")
[docs]
self.clamp_projected_points = bool(clamp_projected_points)
[docs]
self.sort_by_x = bool(sort_by_x)
# World->normalized projection matrix (homogeneous coordinates).
[docs]
self.projection_matrix = self._compute_projection_matrix(self.x_bounds, self.y_bounds)
normalized_curves = self._project_source_curves(self._source_curves_cache)
[docs]
self.model = CurveDistributionModel(
normalized_curves,
labels=labels,
clamp_points=self.clamp_projected_points,
sort_by_x=self.sort_by_x,
)
[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])
if self.canvas_width <= 0 or self.canvas_height <= 0:
raise ValueError("canvas_size must be strictly positive")
fx, fy, fw, fh = area_fraction
[docs]
self.area_fraction = (float(fx), float(fy), float(fw), float(fh))
self._validate_area_fraction(self.area_fraction)
[docs]
self.line_width = float(line_width)
if self.line_width <= 0:
raise ValueError("line_width must be > 0")
[docs]
self.line_alpha = int(max(0, min(255, line_alpha)))
[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)
allowed_tick_positions = {"inside", "outside", "centered"}
[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 allowed_tick_positions:
self.x_tick_position = "inside"
if self.y_tick_position not in allowed_tick_positions:
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._colors = self._normalize_colors(colors)
[docs]
self._curve_styles = self._normalize_curve_styles(curve_styles)
[docs]
def _normalize_curve_styles(self, styles: Sequence[dict] | None) -> list[dict]:
count = len(self.model.curves)
out: 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"
out.append({"line_width": lw, "line_style": ls})
return out
@staticmethod
[docs]
def _freeze_source_curves(
curves: Sequence[Sequence[tuple[float, float]]],
) -> tuple[tuple[tuple[float, float], ...], ...]:
if not curves:
raise ValueError("At least one curve is required")
frozen: list[tuple[tuple[float, float], ...]] = []
for curve in curves:
if len(curve) < 2:
raise ValueError("Each curve must contain at least 2 points")
pts: list[tuple[float, float]] = []
for x, y in curve:
px = float(x)
py = float(y)
if not (math.isfinite(px) and math.isfinite(py)):
raise ValueError("All curve coordinates must be finite")
pts.append((px, py))
frozen.append(tuple(pts))
return tuple(frozen)
@staticmethod
[docs]
def _infer_bounds_from_source(
source_curves: tuple[tuple[tuple[float, float], ...], ...],
) -> tuple[tuple[float, float], tuple[float, float]]:
xs = [p[0] for curve in source_curves for p in curve]
ys = [p[1] for curve in source_curves for p in curve]
xmin = min(xs)
xmax = max(xs)
ymin = min(ys)
ymax = max(ys)
if xmin == xmax:
xmin -= 0.5
xmax += 0.5
if ymin == ymax:
ymin -= 0.5
ymax += 0.5
return (xmin, xmax), (ymin, ymax)
@staticmethod
[docs]
def _normalize_bounds(
provided: tuple[float, float] | None,
auto_bounds: tuple[float, float],
*,
axis_name: str,
) -> tuple[float, float]:
if provided is None:
return auto_bounds
lo = float(provided[0])
hi = float(provided[1])
if not (math.isfinite(lo) and math.isfinite(hi)):
raise ValueError(f"{axis_name}_bounds must be finite")
if hi <= lo:
raise ValueError(f"{axis_name}_bounds must verify max > min")
return lo, hi
@staticmethod
[docs]
def _compute_projection_matrix(
x_bounds: tuple[float, float],
y_bounds: tuple[float, float],
) -> tuple[tuple[float, float, float], tuple[float, float, float], tuple[float, float, float]]:
xmin, xmax = x_bounds
ymin, ymax = y_bounds
sx = 1.0 / (xmax - xmin)
sy = 1.0 / (ymax - ymin)
tx = -xmin * sx
ty = -ymin * sy
return (
(sx, 0.0, tx),
(0.0, sy, ty),
(0.0, 0.0, 1.0),
)
[docs]
def _project_point(self, x: float, y: float) -> tuple[float, float]:
m = self.projection_matrix
nx = m[0][0] * x + m[0][1] * y + m[0][2]
ny = m[1][0] * x + m[1][1] * y + m[1][2]
if self.clamp_projected_points:
nx = max(0.0, min(1.0, nx))
ny = max(0.0, min(1.0, ny))
return nx, ny
[docs]
def _project_source_curves(
self,
source_curves: tuple[tuple[tuple[float, float], ...], ...],
) -> list[list[tuple[float, float]]]:
projected: list[list[tuple[float, float]]] = []
for curve in source_curves:
projected.append([self._project_point(x, y) for x, y in curve])
return projected
[docs]
def _project_point_unclamped(self, x: float, y: float) -> tuple[float, float]:
m = self.projection_matrix
nx = m[0][0] * x + m[0][1] * y + m[0][2]
ny = m[1][0] * x + m[1][1] * y + m[1][2]
return nx, ny
[docs]
def _add_line(
self,
target_zone: zone,
name: str,
p0: tuple[float, float],
p1: tuple[float, float],
*,
color: tuple[int, int, int],
width: float,
z_value: float,
) -> None:
line_vec = vector.make_from_list(
[(p0[0], p0[1], z_value), (p1[0], p1[1], z_value)],
name=name,
parentzone=target_zone,
)
line_vec._simplified_geometry = True
line_vec.myprop.filled = False
line_vec.myprop.contour_enabled = True
line_vec.myprop.contour_color = getIfromRGB(color)
line_vec.myprop.contour_width = width
line_vec.myprop.transparent = False
line_vec.myprop.alpha = 255
target_zone.add_vector(line_vec, forceparent=True)
@staticmethod
[docs]
def _validate_area_fraction(area_fraction: tuple[float, float, float, float]) -> None:
fx, fy, fw, fh = area_fraction
if fw <= 0 or fh <= 0:
raise ValueError("area_fraction width and height must be > 0")
if fx < 0 or fy < 0 or fx + fw > 1 or fy + fh > 1:
raise ValueError("area_fraction must stay inside [0,1]x[0,1]")
[docs]
def _normalize_colors(
self,
colors: Sequence[tuple[int, int, int] | tuple[int, int, int, int]] | None,
) -> list[tuple[int, int, int, int]]:
count = len(self.model.curves)
result: list[tuple[int, int, int, int]] = []
if colors is None:
for i in range(count):
result.append(self.DEFAULT_COLORS[i % len(self.DEFAULT_COLORS)])
return result
for i in range(count):
if i < len(colors):
c = colors[i]
if len(c) == 3:
result.append((int(c[0]), int(c[1]), int(c[2]), self.line_alpha))
else:
result.append((int(c[0]), int(c[1]), int(c[2]), int(c[3])))
else:
result.append(self.DEFAULT_COLORS[i % len(self.DEFAULT_COLORS)])
return result
[docs]
def _area_rect(self) -> tuple[float, float, float, float]:
fx, fy, fw, fh = self.area_fraction
x = self.canvas_x + fx * self.canvas_width
y = self.canvas_y + fy * self.canvas_height
w = fw * self.canvas_width
h = fh * self.canvas_height
return x, y, w, h
@staticmethod
[docs]
def _grid_values(lo: float, hi: float, step: float) -> list[float]:
eps = abs(hi - lo) * 1e-12 + 1e-12
k_start = math.floor(lo / step)
k_end = math.ceil(hi / step)
out: list[float] = []
for k in range(k_start, k_end + 1):
v = k * step
if lo + eps < v < hi - eps:
out.append(v)
if len(out) >= 2000:
break
return out
@staticmethod
[docs]
def datetime_to_timestamp(dt: datetime) -> float:
"""Convert a datetime to a UTC POSIX timestamp (float seconds).
Naive datetimes are assumed to be in UTC.
"""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc).timestamp()
return dt.timestamp()
@staticmethod
[docs]
def to_zones(self) -> Zones:
"""Build and return a Zones object containing curve geometry."""
result = Zones(idx=self.idx, parent=self.parent, mapviewer=self.mapviewer)
axes_zone: zone | None = None
if self.show_axes or self.show_grid or self.show_x_ticks or self.show_y_ticks:
axes_zone = zone(name=f"{self.idx}_axes", parent=result)
result.add_zone(axes_zone, forceparent=True)
root_zone = zone(name=f"{self.idx}_curves", parent=result)
result.add_zone(root_zone, forceparent=True)
area_x, area_y, area_w, area_h = self._area_rect()
x_grid_values = self._grid_values(self.x_bounds[0], self.x_bounds[1], self.grid_dx)
y_grid_values = self._grid_values(self.y_bounds[0], self.y_bounds[1], self.grid_dy)
if axes_zone is not None and self.show_grid:
# Grid lines are generated from source-space step values.
for i, xv in enumerate(x_grid_values, start=1):
xn, _ = self._project_point_unclamped(xv, self.y_bounds[0])
xw = area_x + xn * area_w
self._add_line(
axes_zone,
f"grid_x_{i:03d}",
(xw, area_y),
(xw, area_y + area_h),
color=self.grid_color,
width=self.grid_line_width,
z_value=-100.0,
)
for j, yv in enumerate(y_grid_values, start=1):
_, yn = self._project_point_unclamped(self.x_bounds[0], yv)
yw = area_y + yn * area_h
self._add_line(
axes_zone,
f"grid_y_{j:03d}",
(area_x, yw),
(area_x + area_w, yw),
color=self.grid_color,
width=self.grid_line_width,
z_value=-100.0,
)
if axes_zone is not None and (self.show_x_ticks or self.show_y_ticks):
tick_len = max(4.0, min(area_w, area_h) * 0.015)
tick_label_gap = tick_len * 1.3
if self.show_x_ticks:
for i, xv in enumerate(x_grid_values, start=1):
xn, _ = self._project_point_unclamped(xv, self.y_bounds[0])
xw = area_x + xn * area_w
if self.x_tick_position == "outside":
p0 = (xw, area_y - tick_len)
p1 = (xw, area_y)
label_pos = (xw, area_y - tick_label_gap)
elif self.x_tick_position == "centered":
p0 = (xw, area_y - 0.5 * tick_len)
p1 = (xw, area_y + 0.5 * tick_len)
label_pos = (xw, area_y - tick_label_gap)
else: # inside
p0 = (xw, area_y)
p1 = (xw, area_y + tick_len)
label_pos = (xw, area_y + tick_label_gap)
tick_vec = vector.make_from_list(
[(p0[0], p0[1], -90.0), (p1[0], p1[1], -90.0)],
name=f"x_tick_{i:03d}",
parentzone=axes_zone,
)
tick_vec._simplified_geometry = True
tick_vec.myprop.filled = False
tick_vec.myprop.contour_enabled = True
tick_vec.myprop.contour_color = getIfromRGB(self.axes_color)
tick_vec.myprop.contour_width = self.axes_line_width
if self.show_x_tick_labels:
tick_vec.myprop.legendvisible = True
tick_vec.set_legend_text(self._format_tick_value(xv, self.x_tick_format))
tick_vec.set_legend_position(label_pos[0], label_pos[1])
tick_vec.myprop.legend_alignment = "center"
tick_vec.myprop.legendpriority = 1
tick_vec.myprop.legendheight = max(area_h * 0.03, 6.0)
tick_vec.myprop.legendlength = max(area_w * 0.06, 18.0)
tick_vec.myprop.legendcolor = getIfromRGB(self.axes_color)
axes_zone.add_vector(tick_vec, forceparent=True)
if self.show_y_ticks:
for j, yv in enumerate(y_grid_values, start=1):
_, yn = self._project_point_unclamped(self.x_bounds[0], yv)
yw = area_y + yn * area_h
if self.y_tick_position == "outside":
p0 = (area_x - tick_len, yw)
p1 = (area_x, yw)
label_pos = (area_x - tick_label_gap, yw)
elif self.y_tick_position == "centered":
p0 = (area_x - 0.5 * tick_len, yw)
p1 = (area_x + 0.5 * tick_len, yw)
label_pos = (area_x - tick_label_gap, yw)
else: # inside
p0 = (area_x, yw)
p1 = (area_x + tick_len, yw)
label_pos = (area_x + tick_label_gap, yw)
tick_vec = vector.make_from_list(
[(p0[0], p0[1], -90.0), (p1[0], p1[1], -90.0)],
name=f"y_tick_{j:03d}",
parentzone=axes_zone,
)
tick_vec._simplified_geometry = True
tick_vec.myprop.filled = False
tick_vec.myprop.contour_enabled = True
tick_vec.myprop.contour_color = getIfromRGB(self.axes_color)
tick_vec.myprop.contour_width = self.axes_line_width
if self.show_y_tick_labels:
tick_vec.myprop.legendvisible = True
tick_vec.set_legend_text(self._format_tick_value(yv, self.y_tick_format))
tick_vec.set_legend_position(label_pos[0], label_pos[1])
tick_vec.myprop.legend_alignment = "center"
tick_vec.myprop.legendpriority = 1
tick_vec.myprop.legendheight = max(area_h * 0.03, 6.0)
tick_vec.myprop.legendlength = max(area_w * 0.06, 18.0)
tick_vec.myprop.legendcolor = getIfromRGB(self.axes_color)
axes_zone.add_vector(tick_vec, forceparent=True)
if axes_zone is not None and self.show_axes:
# Axes correspond to x=0 and y=0 in source coordinate system.
x0n, _ = self._project_point_unclamped(0.0, self.y_bounds[0])
_, y0n = self._project_point_unclamped(self.x_bounds[0], 0.0)
if 0.0 <= x0n <= 1.0:
x0w = area_x + x0n * area_w
self._add_line(
axes_zone,
"y_axis",
(x0w, area_y),
(x0w, area_y + area_h),
color=self.axes_color,
width=self.axes_line_width,
z_value=-90.0,
)
if 0.0 <= y0n <= 1.0:
y0w = area_y + y0n * area_h
self._add_line(
axes_zone,
"x_axis",
(area_x, y0w),
(area_x + area_w, y0w),
color=self.axes_color,
width=self.axes_line_width,
z_value=-90.0,
)
if self.show_area_frame:
frame_coords = [
(area_x, area_y, 0.0),
(area_x + area_w, area_y, 0.0),
(area_x + area_w, area_y + area_h, 0.0),
(area_x, area_y + area_h, 0.0),
]
frame = vector.make_from_list(frame_coords, name="plot_area", parentzone=root_zone)
frame.close_force()
frame._simplified_geometry = True
frame.myprop.filled = False
frame.myprop.contour_enabled = True
frame.myprop.contour_color = getIfromRGB(self.frame_color)
frame.myprop.contour_width = max(1.0, self.line_width * 0.6)
root_zone.add_vector(frame, forceparent=True)
for i, series in enumerate(self.model.curves):
color_rgba = self._colors[i]
style = self._curve_styles[i]
coords = []
for p in series.points:
px = area_x + p.x * area_w
py = area_y + p.y * area_h
coords.append((px, py, float(i + 1)))
curve_vec = vector.make_from_list(
coords,
name=f"curve_{i + 1:03d}",
parentzone=root_zone,
)
curve_vec._simplified_geometry = True
curve_vec.myprop.filled = False
curve_vec.myprop.contour_enabled = True
# Set both line color and contour color for renderer compatibility.
color_idx = getIfromRGB((color_rgba[0], color_rgba[1], color_rgba[2]))
curve_vec.myprop.color = color_idx
curve_vec.myprop.contour_color = color_idx
curve_vec.myprop.contour_width = float(style["line_width"])
curve_vec.myprop.transparent = color_rgba[3] < 255
curve_vec.myprop.alpha = color_rgba[3]
line_style = style["line_style"]
if line_style == "solid":
curve_vec.myprop.dash_enabled = False
elif line_style == "dashed":
curve_vec.myprop.dash_enabled = True
curve_vec.myprop.dash_length = 10.0
curve_vec.myprop.gap_length = 6.0
elif line_style == "dotted":
curve_vec.myprop.dash_enabled = True
curve_vec.myprop.dash_length = 2.0
curve_vec.myprop.gap_length = 4.0
else: # dashdot
curve_vec.myprop.dash_enabled = True
curve_vec.myprop.dash_length = 10.0
curve_vec.myprop.gap_length = 3.0
if self.legend_visible:
last_pt = coords[-1]
curve_vec.myprop.legendvisible = True
curve_vec.set_legend_text(series.label)
curve_vec.set_legend_position(last_pt[0], last_pt[1])
curve_vec.myprop.legend_alignment = "left"
curve_vec.myprop.legendpriority = 1
curve_vec.myprop.legendheight = max(area_h * 0.035, area_w * 0.02)
curve_vec.myprop.legendlength = max(len(series.label) * area_w * 0.015, area_w * 0.08)
curve_vec.myprop.legendcolor = getIfromRGB(self.legend_text_color)
curve_vec.add_value("curve_label", series.label)
root_zone.add_vector(curve_vec, forceparent=True)
result.find_minmax(update=True)
return result