Source code for wolfhece.assets.curve.zones_asset

"""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.x_tick_format = str(x_tick_format)
[docs] self.y_tick_format = str(y_tick_format)
[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.idx = str(idx)
[docs] self.parent = parent
[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 _format_tick_value(value: float, fmt: str = "") -> str: """Format a tick value. If *fmt* is a strftime pattern (contains ``%``), *value* is interpreted as a UTC POSIX timestamp and formatted with that pattern. Otherwise compact numeric ``:.6g`` formatting is used. """ if fmt and "%" in fmt: try: return datetime.fromtimestamp(value, tz=timezone.utc).strftime(fmt) except (OSError, OverflowError, ValueError): pass if fmt: # Plain Python format spec, e.g. ".2f" or ".0f" try: return format(value, fmt) except (ValueError, TypeError): pass return f"{value:.6g}"
[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