"""Shared enums and helpers for the FieldView Python package."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from enum import Enum
from typing import (
Protocol,
SupportsFloat,
SupportsIndex,
SupportsInt,
TypeVar,
cast,
)
from collections.abc import Callable, Mapping, Set
from . import constant
from .exceptions import InvalidArgumentError
__all__ = [
"TripletLike",
"Range",
"RangedValue",
"CuttingPlane",
"Plot2DGridLinesOptions",
"Plot2DTickMarksOptions",
"Plot2DAxisOptions",
"Plot2DSamplingOptions",
"Plot2DAnnotationOptions",
"Plot2DOptions",
"LegendSpectrum",
"LegendContour",
"LegendLabels",
"LegendAnnotationText",
"LegendAnnotation",
"Legend",
"VectorOptions",
"RuledGridAxisOptions",
"RuledGridOptions",
"ScalarAnnotation",
"ScalarMinMax",
"Colormap",
]
Number = int | float
NumberOrNone = Number | None
PayloadDict = dict[str, float]
StateDict = dict[str, object]
_EnumT = TypeVar("_EnumT", bound=Enum)
_IntLike = SupportsInt | SupportsIndex | str | bytes
_FloatLike = SupportsFloat | SupportsIndex | str | bytes
class TripletLike(Protocol):
def __len__(self) -> int: ...
def __getitem__(self, index: int, /) -> object: ...
class _PairLike(Protocol):
def __len__(self) -> int: ...
def __getitem__(self, index: int, /) -> object: ...
def _enum_payload(value: Enum) -> object:
return cast(object, value.value)
def _parse_enum_or_str(enum_cls: type[_EnumT], value: object) -> _EnumT | str | None:
if value is None:
return None
try:
return enum_cls(str(value))
except ValueError:
return str(value)
def _payload_bool(value: object) -> bool | None:
if value is None:
return None
return bool(value)
def _coerce_int(value: object) -> int:
return int(cast(_IntLike, value))
def _coerce_float(value: object) -> float:
return float(cast(_FloatLike, value))
def _coerce_pathlike_str(value: object, label: str) -> str:
if isinstance(value, bool):
raise InvalidArgumentError(f"{label} must be a string or path-like object")
if not isinstance(value, (str, os.PathLike)):
raise InvalidArgumentError(f"{label} must be a string or path-like object")
path = os.fspath(cast(str | os.PathLike[str], value))
if not isinstance(path, str):
raise InvalidArgumentError(f"{label} must be a string or path-like object")
return path
def _coerce_xyz_triplet(value: object, label: str) -> tuple[float, float, float]:
if isinstance(value, (str, bytes, bytearray, memoryview, Mapping, Set)):
raise InvalidArgumentError(f"{label} must be a 3-number sequence")
if not hasattr(value, "__len__") or not hasattr(value, "__getitem__"):
raise InvalidArgumentError(f"{label} must be a 3-number sequence")
indexable = cast(TripletLike, value)
if len(indexable) != 3:
raise InvalidArgumentError(f"{label} must be a 3-number sequence")
result: list[float] = []
for index in range(3):
component = indexable[index]
if isinstance(component, bool) or isinstance(component, (str, bytes)):
raise InvalidArgumentError(f"{label}[{index + 1}] must be a number")
try:
result.append(_coerce_float(component))
except (OverflowError, TypeError, ValueError) as exc:
raise InvalidArgumentError(
f"{label}[{index + 1}] must be a number"
) from exc
return result[0], result[1], result[2]
def _coerce_xy_pair(value: object, label: str) -> tuple[float, float]:
if isinstance(value, (str, bytes, bytearray, memoryview, Mapping, Set)):
raise InvalidArgumentError(f"{label} must be a 2-value sequence")
if not hasattr(value, "__len__") or not hasattr(value, "__getitem__"):
raise InvalidArgumentError(f"{label} must be a 2-value sequence")
indexable = cast(_PairLike, value)
if len(indexable) != 2:
raise InvalidArgumentError(f"{label} must be a 2-value sequence")
result: list[float] = []
for index in range(2):
component = indexable[index]
if isinstance(component, bool) or isinstance(component, (str, bytes)):
raise InvalidArgumentError(f"{label}[{index + 1}] must be a number")
try:
result.append(_coerce_float(component))
except (OverflowError, TypeError, ValueError) as exc:
raise InvalidArgumentError(
f"{label}[{index + 1}] must be a number"
) from exc
return result[0], result[1]
def _payload_int(value: object) -> int | None:
if value is None:
return None
return _coerce_int(value)
def _payload_float(value: object) -> float | None:
if value is None:
return None
return _coerce_float(value)
def _payload_str(value: object) -> str | None:
if value is None:
return None
return str(value)
def _payload_geometric_color(
value: object,
) -> constant.GeometricColor | int | None:
if value is None:
return None
color_value = _coerce_int(value)
try:
return constant.GeometricColor(color_value)
except ValueError:
return color_value
def _required_float(payload: StateDict, key: str) -> float:
return _coerce_float(payload[key])
[docs]
class Range:
"""Generic numeric range container."""
_min: NumberOrNone
_max: NumberOrNone
_abs_min: NumberOrNone
_abs_max: NumberOrNone
def __init__(
self,
min: NumberOrNone = None,
max: NumberOrNone = None,
abs_min: NumberOrNone = None,
abs_max: NumberOrNone = None,
) -> None:
self._min = min
self._max = max
self._abs_min = abs_min
self._abs_max = abs_max
self._validate()
def __setattr__(self, name: str, value: object) -> None:
_ensure_known_attribute(self, name)
object.__setattr__(self, name, value)
@property
def min(self) -> NumberOrNone:
return self._min
@min.setter
def min(self, value: NumberOrNone) -> None:
previous = self._min
self._min = value
try:
self._validate()
except Exception:
self._min = previous
raise
@property
def max(self) -> NumberOrNone:
return self._max
@max.setter
def max(self, value: NumberOrNone) -> None:
previous = self._max
self._max = value
try:
self._validate()
except Exception:
self._max = previous
raise
@property
def abs_min(self) -> NumberOrNone:
return self._abs_min
@abs_min.setter
def abs_min(self, value: NumberOrNone) -> None:
previous = self._abs_min
self._abs_min = value
try:
self._validate()
except Exception:
self._abs_min = previous
raise
@property
def abs_max(self) -> NumberOrNone:
return self._abs_max
@abs_max.setter
def abs_max(self, value: NumberOrNone) -> None:
previous = self._abs_max
self._abs_max = value
try:
self._validate()
except Exception:
self._abs_max = previous
raise
def to_payload(self) -> PayloadDict:
"""Return a dict payload for range updates."""
payload: PayloadDict = {}
if self.min is not None:
payload["min"] = float(self.min)
if self.max is not None:
payload["max"] = float(self.max)
if not payload:
raise InvalidArgumentError("Range must define at least one of min/max")
return payload
def _validate(self) -> None:
for name in ("min", "max", "abs_min", "abs_max"):
value = cast(object, getattr(self, name))
if value is None:
continue
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise InvalidArgumentError(f"Range.{name} must be a number")
if self.min is not None and self.max is not None and self.min > self.max:
raise InvalidArgumentError("Range.min must be <= Range.max")
if (
self.abs_min is not None
and self.abs_max is not None
and self.abs_min > self.abs_max
):
raise InvalidArgumentError("Range.abs_min must be <= Range.abs_max")
if self.abs_min is not None:
if self.min is not None and self.min < self.abs_min:
raise InvalidArgumentError("Range.min must be >= Range.abs_min")
if self.max is not None and self.max < self.abs_min:
raise InvalidArgumentError("Range.max must be >= Range.abs_min")
if self.abs_max is not None:
if self.min is not None and self.min > self.abs_max:
raise InvalidArgumentError("Range.min must be <= Range.abs_max")
if self.max is not None and self.max > self.abs_max:
raise InvalidArgumentError("Range.max must be <= Range.abs_max")
def __repr__(self) -> str:
return (
f"Range(min={self.min!r}, max={self.max!r}, "
f"abs_min={self.abs_min!r}, abs_max={self.abs_max!r})"
)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Range):
return NotImplemented
return (
self.min == other.min
and self.max == other.max
and self.abs_min == other.abs_min
and self.abs_max == other.abs_max
)
[docs]
class RangedValue:
"""Range plus selected value."""
_range: Range
_value: NumberOrNone
def __init__(self, range: Range | None = None, value: NumberOrNone = None) -> None:
self._range = Range() if range is None else range
self._value = value
self._validate()
def __setattr__(self, name: str, value: object) -> None:
_ensure_known_attribute(self, name)
object.__setattr__(self, name, value)
@property
def range(self) -> Range:
return self._range
@range.setter
def range(self, value: Range) -> None:
previous = self._range
self._range = value
try:
self._validate()
except Exception:
self._range = previous
raise
@property
def value(self) -> NumberOrNone:
return self._value
@value.setter
def value(self, value: NumberOrNone) -> None:
previous = self._value
self._value = value
try:
self._validate()
except Exception:
self._value = previous
raise
def _validate(self) -> None:
if not isinstance(self.range, Range):
raise InvalidArgumentError("RangedValue.range must be a Range")
if self.value is None:
return
if isinstance(self.value, bool) or not isinstance(self.value, (int, float)):
raise InvalidArgumentError("RangedValue.value must be a number")
if self.range.min is not None and self.value < self.range.min:
raise InvalidArgumentError(
"RangedValue.value must be >= RangedValue.range.min"
)
if self.range.max is not None and self.value > self.range.max:
raise InvalidArgumentError(
"RangedValue.value must be <= RangedValue.range.max"
)
if self.range.abs_min is not None and self.value < self.range.abs_min:
raise InvalidArgumentError(
"RangedValue.value must be >= RangedValue.range.abs_min"
)
if self.range.abs_max is not None and self.value > self.range.abs_max:
raise InvalidArgumentError(
"RangedValue.value must be <= RangedValue.range.abs_max"
)
def to_payload(self) -> PayloadDict:
"""Return range and current value payload."""
payload: PayloadDict = {}
if self.range.min is not None or self.range.max is not None:
payload.update(self.range.to_payload())
if self.value is not None:
payload["current"] = float(self.value)
if not payload:
raise InvalidArgumentError(
"RangedValue must define at least one of range.min/range.max/value"
)
return payload
def __repr__(self) -> str:
return f"RangedValue(range={self.range!r}, value={self.value!r})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, RangedValue):
return NotImplemented
return self.range == other.range and self.value == other.value
def _normalize_range_from_values(
*,
min_value: Number,
max_value: Number,
abs_min: NumberOrNone = None,
abs_max: NumberOrNone = None,
) -> Range:
"""Build a validated Range from potentially inconsistent raw values.
This is intended for values read from core state where min/max or
abs_min/abs_max may be swapped or partially missing.
"""
min_f = float(min_value)
max_f = float(max_value)
if min_f > max_f:
min_f, max_f = max_f, min_f
if abs_min is None and abs_max is None:
abs_min_f = min_f
abs_max_f = max_f
elif abs_min is None:
abs_max_f = _coerce_float(abs_max)
abs_min_f = min(min_f, max_f, abs_max_f)
elif abs_max is None:
abs_min_f = _coerce_float(abs_min)
abs_max_f = max(min_f, max_f, abs_min_f)
else:
abs_min_f = _coerce_float(abs_min)
abs_max_f = _coerce_float(abs_max)
if abs_min_f > abs_max_f:
abs_min_f, abs_max_f = abs_max_f, abs_min_f
min_f = max(abs_min_f, min(min_f, abs_max_f))
max_f = max(abs_min_f, min(max_f, abs_max_f))
if min_f > max_f:
min_f, max_f = max_f, min_f
return Range(min=min_f, max=max_f, abs_min=abs_min_f, abs_max=abs_max_f)
def _normalize_ranged_value_from_values(
*,
min_value: Number,
max_value: Number,
abs_min: NumberOrNone = None,
abs_max: NumberOrNone = None,
current: NumberOrNone = None,
) -> RangedValue:
"""Build a validated RangedValue from potentially inconsistent raw values."""
normalized_range = _normalize_range_from_values(
min_value=min_value,
max_value=max_value,
abs_min=abs_min,
abs_max=abs_max,
)
value_f: float | None = None
if current is not None:
value_f = float(current)
if normalized_range.min is not None and value_f < normalized_range.min:
value_f = float(normalized_range.min)
if normalized_range.max is not None and value_f > normalized_range.max:
value_f = float(normalized_range.max)
return RangedValue(range=normalized_range, value=value_f)
class _BoundRange(Range):
"""Range controller bound to core get/set callbacks."""
__slots__ = ("_get_state", "_set_state", "_cast")
def __init__( # pyright: ignore[reportMissingSuperCall]
self,
get_state: Callable[[], StateDict],
set_state: Callable[..., None],
cast: Callable[[object], Number],
) -> None:
self._get_state = get_state
self._set_state = set_state
self._cast = cast
def _state(self) -> StateDict:
return self._get_state()
@property
def abs_min(self) -> NumberOrNone:
return self._cast(self._state()["abs_min"])
@abs_min.setter
def abs_min(self, value: NumberOrNone) -> None:
raise InvalidArgumentError("Range.abs_min is read-only for bound ranges")
@property
def abs_max(self) -> NumberOrNone:
return self._cast(self._state()["abs_max"])
@abs_max.setter
def abs_max(self, value: NumberOrNone) -> None:
raise InvalidArgumentError("Range.abs_max is read-only for bound ranges")
@property
def min(self) -> NumberOrNone:
return self._cast(self._state()["min"])
@min.setter
def min(self, value: NumberOrNone) -> None:
if value is None:
raise InvalidArgumentError("Range.min cannot be None")
self._set_state(min=self._cast(value))
@property
def max(self) -> NumberOrNone:
return self._cast(self._state()["max"])
@max.setter
def max(self, value: NumberOrNone) -> None:
if value is None:
raise InvalidArgumentError("Range.max cannot be None")
self._set_state(max=self._cast(value))
def to_payload(self) -> PayloadDict:
state = self._state()
return {
"min": float(self._cast(state["min"])),
"max": float(self._cast(state["max"])),
}
class _BoundRangedValue(RangedValue):
"""RangedValue controller bound to core get/set callbacks."""
__slots__ = ("_range", "_get_state", "_set_state", "_cast")
def __init__( # pyright: ignore[reportMissingSuperCall]
self,
get_state: Callable[[], StateDict],
set_state: Callable[..., None],
cast: Callable[[object], Number],
) -> None:
self._get_state = get_state
self._set_state = set_state
self._cast = cast
self._range = _BoundRange(get_state=get_state, set_state=set_state, cast=cast)
@property
def range(self) -> Range:
return self._range
@range.setter
def range(self, value: Range) -> None:
if not isinstance(value, Range):
raise InvalidArgumentError("RangedValue.range must be a Range")
self._set_state(**value.to_payload())
@property
def value(self) -> NumberOrNone:
return self._cast(self._get_state()["current"])
@value.setter
def value(self, value: NumberOrNone) -> None:
if value is None:
raise InvalidArgumentError("RangedValue.value cannot be None")
self._set_state(current=self._cast(value))
def to_payload(self) -> PayloadDict:
payload = self.range.to_payload()
if self.value is None:
raise InvalidArgumentError("RangedValue.value cannot be None")
payload["current"] = float(self.value)
return payload
def _bind_range(
*,
get_state: Callable[[], StateDict],
set_state: Callable[..., None],
cast: Callable[[object], Number],
) -> Range:
"""Return a Range bound to core callbacks."""
return _BoundRange(get_state=get_state, set_state=set_state, cast=cast)
def _bind_ranged_value(
*,
get_state: Callable[[], StateDict],
set_state: Callable[..., None],
cast: Callable[[object], Number],
) -> RangedValue:
"""Return a RangedValue bound to core callbacks."""
return _BoundRangedValue(get_state=get_state, set_state=set_state, cast=cast)
def _to_point3(value: object, label: str) -> tuple[float, float, float]:
return _coerce_xyz_triplet(value, label)
def _to_point2(value: object, label: str) -> tuple[float, float]:
return _coerce_xy_pair(value, label)
def _ensure_known_attribute(instance: object, name: str) -> None:
if name.startswith("_"):
return
annotations = getattr(type(instance), "__annotations__", {})
if name in annotations:
return
class_attr = getattr(type(instance), name, None)
if isinstance(class_attr, property):
return
raise AttributeError(f"{type(instance).__name__} has no attribute '{name}'")
[docs]
@dataclass(frozen=True)
class CuttingPlane:
"""Cutting-plane definition used by iso surfaces."""
mode: str
pt1: tuple[float, float, float]
pt2: tuple[float, float, float]
pt3: tuple[float, float, float] = (0.0, 0.0, 0.0)
[docs]
@staticmethod
def point_and_normal(
point: TripletLike,
normal: TripletLike,
) -> "CuttingPlane":
"""Create a cutting plane from one point and a normal vector.
Args:
point: XYZ point lying on the plane.
normal: XYZ normal vector defining the plane orientation.
"""
return CuttingPlane(
mode="point_and_normal",
pt1=_to_point3(point, "point"),
pt2=_to_point3(normal, "normal"),
pt3=(0.0, 0.0, 0.0),
)
[docs]
@staticmethod
def points_in_plane(
pt1: TripletLike,
pt2: TripletLike,
pt3: TripletLike,
) -> "CuttingPlane":
"""Create a cutting plane from three points lying in the plane.
Args:
pt1: First XYZ point in the plane.
pt2: Second XYZ point in the plane.
pt3: Third XYZ point in the plane.
"""
return CuttingPlane(
mode="points_in_plane",
pt1=_to_point3(pt1, "pt1"),
pt2=_to_point3(pt2, "pt2"),
pt3=_to_point3(pt3, "pt3"),
)
def to_payload(self) -> StateDict:
payload: StateDict = {
"mode": self.mode,
"pt1": [self.pt1[0], self.pt1[1], self.pt1[2]],
"pt2": [self.pt2[0], self.pt2[1], self.pt2[2]],
}
if self.mode == "points_in_plane":
payload["pt3"] = [self.pt3[0], self.pt3[1], self.pt3[2]]
return payload
@staticmethod
def from_payload(payload: StateDict) -> "CuttingPlane":
"""Create a cutting plane from a host payload dictionary.
Args:
payload: Serialized cutting-plane payload from the FieldView host.
"""
mode = str(payload.get("mode", "point_and_normal"))
pt1 = _to_point3(payload.get("pt1", (0.0, 0.0, 0.0)), "pt1")
pt2 = _to_point3(payload.get("pt2", (0.0, 0.0, 0.0)), "pt2")
pt3 = _to_point3(payload.get("pt3", (0.0, 0.0, 0.0)), "pt3")
return CuttingPlane(mode=mode, pt1=pt1, pt2=pt2, pt3=pt3)
[docs]
@dataclass
class VectorOptions:
"""Vector display options."""
#: Straight or curved shafts.
shaft_type: constant.VectorShaftType | str | None = None
#: 2D or 3D heads.
head_type: constant.VectorHeadType | str | None = None
#: Toggle head scaling.
head_scaling: bool | None = None
#: Head scaling factor.
head_scaling_value: float | None = None
#: Vector projection type (total/yz/xz/xy/projected).
type: constant.VectorProjectionType | str | None = None
#: Toggle vector skipping.
skip: bool | None = None
#: Skip fraction (0.0-1.0).
skip_value: float | None = None
#: Toggle uniform sampling grid.
uniform_sampling: bool | None = None
#: Uniform sampling count for X.
x_samples: int | None = None
#: Uniform sampling count for Y.
y_samples: int | None = None
#: Uniform sampling count for Z.
z_samples: int | None = None
#: Uniform sampling count for R (cylindrical).
r_samples: int | None = None
#: Uniform sampling count for T (cylindrical).
t_samples: int | None = None
#: Shaft length scale.
vector_scale: float | None = None
#: Curved vector integration time limit.
time_limit: float | None = None
#: Curved vector display type (complete/filament/growing).
display_type: constant.CurvedVectorDisplayType | str | None = None
#: Toggle curved vector animation.
animate: bool | None = None
_on_change: Callable[[str, object], None] | None = field(
default=None,
init=False,
repr=False,
compare=False,
)
def __setattr__(self, name: str, value: object) -> None:
_ensure_known_attribute(self, name)
object.__setattr__(self, name, value)
if name.startswith("_"):
return
key = name
if name == "r_samples":
object.__setattr__(self, "x_samples", value)
key = "x_samples"
elif name == "t_samples":
object.__setattr__(self, "y_samples", value)
key = "y_samples"
elif name == "x_samples":
object.__setattr__(self, "r_samples", value)
elif name == "y_samples":
object.__setattr__(self, "t_samples", value)
callback = getattr(self, "_on_change", None)
if callback is None or value is None:
return
if key in {"shaft_type", "head_type", "type", "display_type"}:
if isinstance(value, Enum):
callback(key, _enum_payload(value))
else:
callback(key, str(value))
elif key in {"head_scaling", "skip", "uniform_sampling", "animate"}:
callback(key, bool(value))
elif key in {"head_scaling_value", "skip_value", "vector_scale", "time_limit"}:
callback(key, _coerce_float(value))
elif key in {"x_samples", "y_samples", "z_samples"}:
callback(key, _coerce_int(value))
def to_payload(self) -> StateDict:
"""Convert options into a payload dict."""
ignore: set[str] = set()
if self.r_samples is not None:
ignore.add("x_samples")
if self.t_samples is not None:
ignore.add("y_samples")
payload: StateDict = {}
for key, value in cast(dict[str, object], self.__dict__).items():
if key.startswith("_") or key in ignore or value is None:
continue
if isinstance(value, Enum):
payload[key] = _enum_payload(value)
else:
payload[key] = value
return payload
@classmethod
def from_payload(cls, payload: StateDict) -> "VectorOptions":
"""Build VectorOptions from a payload dict."""
x_samples = payload.get("x_samples")
y_samples = payload.get("y_samples")
z_samples = payload.get("z_samples")
return cls(
shaft_type=_parse_enum_or_str(
constant.VectorShaftType, payload.get("shaft_type")
),
head_type=_parse_enum_or_str(
constant.VectorHeadType, payload.get("head_type")
),
head_scaling=_payload_bool(payload.get("head_scaling")),
head_scaling_value=_payload_float(payload.get("head_scaling_value")),
type=_parse_enum_or_str(constant.VectorProjectionType, payload.get("type")),
skip=_payload_bool(payload.get("skip")),
skip_value=_payload_float(payload.get("skip_value")),
uniform_sampling=_payload_bool(payload.get("uniform_sampling")),
x_samples=_payload_int(x_samples),
y_samples=_payload_int(y_samples),
z_samples=_payload_int(z_samples),
r_samples=_payload_int(x_samples),
t_samples=_payload_int(y_samples),
vector_scale=_payload_float(payload.get("vector_scale")),
time_limit=_payload_float(payload.get("time_limit")),
display_type=_parse_enum_or_str(
constant.CurvedVectorDisplayType, payload.get("display_type")
),
animate=_payload_bool(payload.get("animate")),
)
[docs]
def copy(self) -> "VectorOptions":
"""Return a detached copy of the options."""
return VectorOptions(
shaft_type=self.shaft_type,
head_type=self.head_type,
head_scaling=self.head_scaling,
head_scaling_value=self.head_scaling_value,
type=self.type,
skip=self.skip,
skip_value=self.skip_value,
uniform_sampling=self.uniform_sampling,
x_samples=self.x_samples,
y_samples=self.y_samples,
z_samples=self.z_samples,
r_samples=self.r_samples,
t_samples=self.t_samples,
vector_scale=self.vector_scale,
time_limit=self.time_limit,
display_type=self.display_type,
animate=self.animate,
)
[docs]
@dataclass
class Colormap:
"""Scalar colormap options."""
#: constant.ColormapName or user map filename.
name: constant.ColormapName | str | None = None
#: Toggle inverted colormap.
invert: bool | None = None
#: Toggle filled contours.
filled_contour: bool | None = None
#: Colormap scale (linear/log).
scale: constant.Scale | str = constant.Scale.LINEAR
#: Toggle local scalar min/max.
use_local: bool | None = None
#: Scalar minimum.
min: float | None = None
#: Scalar maximum.
max: float | None = None
#: Contour count (2-500).
num_contours: int | None = None
_on_change: Callable[[str, object], None] | None = field(
default=None,
init=False,
repr=False,
compare=False,
)
_on_unify: Callable[[], None] | None = field(
default=None,
init=False,
repr=False,
compare=False,
)
def __setattr__(self, name: str, value: object) -> None:
_ensure_known_attribute(self, name)
object.__setattr__(self, name, value)
if name.startswith("_"):
return
callback = getattr(self, "_on_change", None)
if callback is None or value is None:
return
if name == "name":
if isinstance(value, Enum):
callback(name, _enum_payload(value))
else:
callback(name, str(value))
elif name in {"invert", "filled_contour", "use_local"}:
callback(name, bool(value))
elif name == "scale":
if isinstance(value, Enum):
scale_value = str(_enum_payload(value))
else:
scale_value = str(value)
scale_value = scale_value.lower()
if scale_value == constant.Scale.LOG.value:
callback("log_scale", True)
elif scale_value == constant.Scale.LINEAR.value:
callback("log_scale", False)
else:
raise InvalidArgumentError("scale must be Scale.LINEAR or Scale.LOG")
elif name in {"min", "max"}:
callback(name, _coerce_float(value))
elif name == "num_contours":
callback("number_of_contours", _coerce_int(value))
@property
def range(self) -> Range | None:
"""Get or set the scalar min/max as a Range, if both are set."""
if self.min is None or self.max is None:
return None
return Range(float(self.min), float(self.max))
@range.setter
def range(self, value: "Range") -> None:
"""Set scalar min/max from a Range."""
if not isinstance(value, Range):
raise InvalidArgumentError("range must be a Range")
if value.min is None or value.max is None:
raise InvalidArgumentError("range must define both min and max")
self.min = float(value.min)
self.max = float(value.max)
[docs]
def unify_map(self) -> None:
"""Unify the colormap across coordinate surfaces."""
callback = getattr(self, "_on_unify", None)
if callback is not None:
callback()
[docs]
def copy(self) -> "Colormap":
"""Return a detached copy of the options."""
return Colormap(
name=self.name,
invert=self.invert,
filled_contour=self.filled_contour,
scale=self.scale,
use_local=self.use_local,
min=self.min,
max=self.max,
num_contours=self.num_contours,
)
def to_payload(self) -> StateDict:
"""Convert options into a payload dict."""
payload: StateDict = {}
if self.name is not None:
payload["name"] = (
_enum_payload(self.name)
if isinstance(self.name, Enum)
else str(self.name)
)
if self.invert is not None:
payload["invert"] = bool(self.invert)
if self.filled_contour is not None:
payload["filled_contour"] = bool(self.filled_contour)
scale_value = (
str(_enum_payload(self.scale))
if isinstance(self.scale, Enum)
else str(self.scale)
)
scale_value = scale_value.lower()
if scale_value == constant.Scale.LOG.value:
payload["log_scale"] = True
elif scale_value == constant.Scale.LINEAR.value:
payload["log_scale"] = False
else:
raise InvalidArgumentError("scale must be Scale.LINEAR or Scale.LOG")
if self.use_local is not None:
payload["use_local"] = bool(self.use_local)
if self.min is not None:
payload["min"] = float(self.min)
if self.max is not None:
payload["max"] = float(self.max)
if self.num_contours is not None:
payload["number_of_contours"] = int(self.num_contours)
return payload
@classmethod
def from_payload(cls, payload: StateDict) -> "Colormap":
"""Create options from a payload dict."""
name_value = payload.get("name")
name: constant.ColormapName | str | None = None
if name_value:
try:
name = constant.ColormapName(str(name_value))
except ValueError:
name = str(name_value)
scale_value = (
constant.Scale.LOG if payload.get("log_scale") else constant.Scale.LINEAR
)
return cls(
name=name,
invert=_payload_bool(payload.get("invert")),
filled_contour=_payload_bool(payload.get("filled_contour")),
scale=scale_value,
use_local=_payload_bool(payload.get("use_local")),
min=_payload_float(payload.get("min")),
max=_payload_float(payload.get("max")),
num_contours=_payload_int(payload.get("number_of_contours")),
)
@dataclass
class LegendSpectrum:
"""Legend spectrum options."""
#: Toggle colorbar.
colorbar: bool | None = None
#: Toggle colorbar border.
border: bool | None = None
#: Toggle horizontal orientation.
horizontal: bool | None = None
#: Number of labels (2-52).
num_labels: int | None = None
_on_change: Callable[[str, object], None] | None = field(
default=None,
init=False,
repr=False,
compare=False,
)
def __setattr__(self, name: str, value: object) -> None:
_ensure_known_attribute(self, name)
object.__setattr__(self, name, value)
if name.startswith("_"):
return
callback = getattr(self, "_on_change", None)
if callback is None or value is None:
return
key = f"spectrum_{name}"
if name in {"colorbar", "border", "horizontal"}:
callback(key, bool(value))
elif name == "num_labels":
callback(key, _coerce_int(value))
def to_payload(self) -> StateDict:
payload: StateDict = {}
if self.colorbar is not None:
payload["spectrum_colorbar"] = bool(self.colorbar)
if self.border is not None:
payload["spectrum_border"] = bool(self.border)
if self.horizontal is not None:
payload["spectrum_horizontal"] = bool(self.horizontal)
if self.num_labels is not None:
payload["spectrum_num_labels"] = int(self.num_labels)
return payload
@classmethod
def from_payload(cls, payload: StateDict) -> "LegendSpectrum":
"""Create spectrum options from a payload dictionary.
Args:
payload: Serialized legend-spectrum payload from the FieldView host.
"""
return cls(
colorbar=_payload_bool(payload.get("spectrum_colorbar")),
border=_payload_bool(payload.get("spectrum_border")),
horizontal=_payload_bool(payload.get("spectrum_horizontal")),
num_labels=_payload_int(payload.get("spectrum_num_labels")),
)
def copy(self) -> "LegendSpectrum":
"""Return a detached copy of the options."""
return LegendSpectrum(
colorbar=self.colorbar,
border=self.border,
horizontal=self.horizontal,
num_labels=self.num_labels,
)
@dataclass
class LegendContour:
"""Legend contour options."""
#: constant.LegendLabelsPerLine (single/multi).
labels_per_line: constant.LegendLabelsPerLine | str | None = None
_on_change: Callable[[str, object], None] | None = field(
default=None,
init=False,
repr=False,
compare=False,
)
def __setattr__(self, name: str, value: object) -> None:
_ensure_known_attribute(self, name)
object.__setattr__(self, name, value)
if name.startswith("_"):
return
if name != "labels_per_line":
return
callback = getattr(self, "_on_change", None)
if callback is None or value is None:
return
if isinstance(value, Enum):
callback("contour_labels_per_line", _enum_payload(value))
else:
callback("contour_labels_per_line", str(value))
def to_payload(self) -> StateDict:
payload: StateDict = {}
if self.labels_per_line is not None:
if isinstance(self.labels_per_line, Enum):
payload["contour_labels_per_line"] = _enum_payload(self.labels_per_line)
else:
payload["contour_labels_per_line"] = str(self.labels_per_line)
return payload
@classmethod
def from_payload(cls, payload: StateDict) -> "LegendContour":
"""Create contour options from a payload dictionary.
Args:
payload: Serialized legend-contour payload from the FieldView host.
"""
value = payload.get("contour_labels_per_line")
parsed: constant.LegendLabelsPerLine | str | None = None
if value is not None:
try:
parsed = constant.LegendLabelsPerLine(str(value))
except ValueError:
parsed = str(value)
return cls(labels_per_line=parsed)
def copy(self) -> "LegendContour":
"""Return a detached copy of the options."""
return LegendContour(labels_per_line=self.labels_per_line)
@dataclass
class LegendLabels:
"""Legend labels options."""
#: Toggle legend labels.
show: bool | None = None
#: constant.LegendLabelsColorMode (geometric/scalar).
color_mode: constant.LegendLabelsColorMode | str | None = None
#: Geometric color id (1-10).
geometric_color: constant.GeometricColor | int | None = None
#: constant.NumericalFormat.
numerical_format: constant.NumericalFormat | str | None = None
#: Decimal places for labels.
decimal_places: int | None = None
#: constant.Font.
font: constant.Font | str | None = None
#: Font size.
size: int | None = None
_on_change: Callable[[str, object], None] | None = field(
default=None,
init=False,
repr=False,
compare=False,
)
def __setattr__(self, name: str, value: object) -> None:
_ensure_known_attribute(self, name)
object.__setattr__(self, name, value)
if name.startswith("_"):
return
callback = getattr(self, "_on_change", None)
if callback is None or value is None:
return
if name == "show":
callback("labels", bool(value))
elif name == "color_mode":
if isinstance(value, Enum):
callback("labels_color_mode", _enum_payload(value))
else:
callback("labels_color_mode", str(value))
elif name == "geometric_color":
callback("labels_color", _coerce_int(value))
elif name == "numerical_format":
if isinstance(value, Enum):
callback("labels_numerical_format", _enum_payload(value))
else:
callback("labels_numerical_format", str(value))
elif name == "decimal_places":
callback("labels_decimal_places", _coerce_int(value))
elif name == "font":
if isinstance(value, Enum):
callback("labels_font", _enum_payload(value))
else:
callback("labels_font", str(value))
elif name == "size":
callback("labels_size", _coerce_int(value))
def to_payload(self) -> StateDict:
payload: StateDict = {}
if self.show is not None:
payload["labels"] = bool(self.show)
if self.color_mode is not None:
if isinstance(self.color_mode, Enum):
payload["labels_color_mode"] = _enum_payload(self.color_mode)
else:
payload["labels_color_mode"] = str(self.color_mode)
if self.geometric_color is not None:
payload["labels_color"] = int(self.geometric_color)
if self.numerical_format is not None:
if isinstance(self.numerical_format, Enum):
payload["labels_numerical_format"] = _enum_payload(
self.numerical_format
)
else:
payload["labels_numerical_format"] = str(self.numerical_format)
if self.decimal_places is not None:
payload["labels_decimal_places"] = int(self.decimal_places)
if self.font is not None:
if isinstance(self.font, Enum):
payload["labels_font"] = _enum_payload(self.font)
else:
payload["labels_font"] = str(self.font)
if self.size is not None:
payload["labels_size"] = int(self.size)
return payload
@classmethod
def from_payload(cls, payload: StateDict) -> "LegendLabels":
"""Create label options from a payload dictionary.
Args:
payload: Serialized legend-label payload from the FieldView host.
"""
return cls(
show=_payload_bool(payload.get("labels")),
color_mode=_parse_enum_or_str(
constant.LegendLabelsColorMode, payload.get("labels_color_mode")
),
geometric_color=_payload_geometric_color(payload.get("labels_color")),
numerical_format=_parse_enum_or_str(
constant.NumericalFormat, payload.get("labels_numerical_format")
),
decimal_places=_payload_int(payload.get("labels_decimal_places")),
font=_parse_enum_or_str(constant.Font, payload.get("labels_font")),
size=_payload_int(payload.get("labels_size")),
)
def copy(self) -> "LegendLabels":
"""Return a detached copy of the options."""
return LegendLabels(
show=self.show,
color_mode=self.color_mode,
geometric_color=self.geometric_color,
numerical_format=self.numerical_format,
decimal_places=self.decimal_places,
font=self.font,
size=self.size,
)
@dataclass
class LegendAnnotationText:
"""Legend annotation text options."""
#: Text value.
text: str | None = None
#: constant.Font.
font: constant.Font | str | None = None
#: Font size.
size: int | None = None
#: Geometric color id (1-10).
color: constant.GeometricColor | int | None = None
_on_change: Callable[[str, object], None] | None = field(
default=None,
init=False,
repr=False,
compare=False,
)
_prefix: str | None = field(
default=None,
init=False,
repr=False,
compare=False,
)
def __setattr__(self, name: str, value: object) -> None:
_ensure_known_attribute(self, name)
object.__setattr__(self, name, value)
if name.startswith("_"):
return
callback = getattr(self, "_on_change", None)
prefix = getattr(self, "_prefix", None)
if callback is None or prefix is None or value is None:
return
if name == "text":
callback(prefix, str(value))
elif name == "font":
if isinstance(value, Enum):
callback(f"{prefix}_font", _enum_payload(value))
else:
callback(f"{prefix}_font", str(value))
elif name == "size":
callback(f"{prefix}_size", _coerce_int(value))
elif name == "color":
callback(f"{prefix}_color", _coerce_int(value))
def to_payload(self, prefix: str) -> StateDict:
"""Convert annotation text options into a payload dictionary.
Args:
prefix: Payload key prefix such as ``title`` or ``subtitle``.
"""
payload: StateDict = {}
if self.text is not None:
payload[prefix] = str(self.text)
if self.font is not None:
if isinstance(self.font, Enum):
payload[f"{prefix}_font"] = _enum_payload(self.font)
else:
payload[f"{prefix}_font"] = str(self.font)
if self.size is not None:
payload[f"{prefix}_size"] = int(self.size)
if self.color is not None:
payload[f"{prefix}_color"] = int(self.color)
return payload
@classmethod
def from_payload(cls, payload: StateDict, prefix: str) -> "LegendAnnotationText":
"""Create annotation text options from a payload dictionary.
Args:
payload: Serialized annotation-text payload from the FieldView host.
prefix: Payload key prefix such as ``title`` or ``subtitle``.
"""
return cls(
text=_payload_str(payload.get(prefix)),
font=_parse_enum_or_str(constant.Font, payload.get(f"{prefix}_font")),
size=_payload_int(payload.get(f"{prefix}_size")),
color=_payload_geometric_color(payload.get(f"{prefix}_color")),
)
def copy(self) -> "LegendAnnotationText":
"""Return a detached copy of the options."""
return LegendAnnotationText(
text=self.text,
font=self.font,
size=self.size,
color=self.color,
)
@dataclass
class LegendAnnotation:
"""Legend annotation options."""
#: Toggle legend annotation.
show: bool | None = None
#: constant.LegendAnnotationPosition.
position: constant.LegendAnnotationPosition | str | None = None
#: LegendAnnotationText.
title: LegendAnnotationText | None = None
#: LegendAnnotationText.
subtitle: LegendAnnotationText | None = None
_on_change: Callable[[str, object], None] | None = field(
default=None,
init=False,
repr=False,
compare=False,
)
def __setattr__(self, name: str, value: object) -> None:
_ensure_known_attribute(self, name)
object.__setattr__(self, name, value)
if name.startswith("_"):
return
if name in {"show", "position"}:
callback = getattr(self, "_on_change", None)
if callback is None or value is None:
return
if name == "show":
callback("annotation", bool(value))
else:
if isinstance(value, Enum):
callback("annotation_position", _enum_payload(value))
else:
callback("annotation_position", str(value))
return
if name not in {"title", "subtitle"} or not isinstance(
value, LegendAnnotationText
):
return
callback = getattr(self, "_on_change", None)
if callback is None:
return
prefix = "title" if name == "title" else "subtitle"
object.__setattr__(value, "_on_change", callback)
object.__setattr__(value, "_prefix", prefix)
def to_payload(self) -> StateDict:
payload: StateDict = {}
if self.show is not None:
payload["annotation"] = bool(self.show)
if self.position is not None:
if isinstance(self.position, Enum):
payload["annotation_position"] = _enum_payload(self.position)
else:
payload["annotation_position"] = str(self.position)
if self.title is not None:
payload.update(self.title.to_payload("title"))
if self.subtitle is not None:
payload.update(self.subtitle.to_payload("subtitle"))
return payload
@classmethod
def from_payload(cls, payload: StateDict) -> "LegendAnnotation":
"""Create annotation options from a payload dictionary.
Args:
payload: Serialized legend-annotation payload from the FieldView
host.
"""
position = payload.get("annotation_position")
parsed_position: constant.LegendAnnotationPosition | str | None = None
if position is not None:
try:
parsed_position = constant.LegendAnnotationPosition(str(position))
except ValueError:
parsed_position = str(position)
return cls(
show=_payload_bool(payload.get("annotation")),
position=parsed_position,
title=LegendAnnotationText.from_payload(payload, "title"),
subtitle=LegendAnnotationText.from_payload(payload, "subtitle"),
)
def copy(self) -> "LegendAnnotation":
"""Return a detached copy of the options."""
return LegendAnnotation(
show=self.show,
position=self.position,
title=self.title.copy() if self.title is not None else None,
subtitle=self.subtitle.copy() if self.subtitle is not None else None,
)
[docs]
@dataclass
class Legend:
"""Legend options."""
#: Toggle legend visibility.
show: bool | None = None
#: Toggle legend frame.
frame: bool | None = None
#: Toggle legend background.
background: bool | None = None
#: constant.LegendType (spectrum/contour).
type: constant.LegendType | str | None = None
#: Relative legend position as (x, y) in NDC.
relative_position: tuple[float, float] | None = None
#: Width scale (1.0 is natural width).
scale_width: float | None = None
#: Height scale (1.0 is natural height).
scale_height: float | None = None
#: LegendSpectrum.
spectrum: LegendSpectrum | None = None
#: LegendContour.
contour: LegendContour | None = None
#: LegendLabels.
labels: LegendLabels | None = None
#: LegendAnnotation.
annotation: LegendAnnotation | None = None
_on_change: Callable[[str, object], None] | None = field(
default=None,
init=False,
repr=False,
compare=False,
)
def __setattr__(self, name: str, value: object) -> None:
_ensure_known_attribute(self, name)
object.__setattr__(self, name, value)
if name.startswith("_"):
return
callback = getattr(self, "_on_change", None)
if name in {"spectrum", "contour", "labels", "annotation"}:
if callback is None or value is None:
return
if name == "spectrum" and isinstance(value, LegendSpectrum):
object.__setattr__(value, "_on_change", callback)
elif name == "contour" and isinstance(value, LegendContour):
object.__setattr__(value, "_on_change", callback)
elif name == "labels" and isinstance(value, LegendLabels):
object.__setattr__(value, "_on_change", callback)
elif name == "annotation" and isinstance(value, LegendAnnotation):
object.__setattr__(value, "_on_change", callback)
return
if callback is None or value is None:
return
if name in {"show", "frame", "background"}:
callback(name, bool(value))
elif name in {"scale_width", "scale_height"}:
callback(name, _coerce_float(value))
elif name == "relative_position":
xpos, ypos = _to_point2(value, "relative_position")
callback("relative_position", [xpos, ypos])
elif name == "type":
if isinstance(value, Enum):
callback("type", _enum_payload(value))
else:
callback("type", str(value))
def to_payload(self) -> StateDict:
payload: StateDict = {}
if self.show is not None:
payload["show"] = bool(self.show)
if self.frame is not None:
payload["frame"] = bool(self.frame)
if self.background is not None:
payload["background"] = bool(self.background)
if self.type is not None:
if isinstance(self.type, Enum):
payload["type"] = _enum_payload(self.type)
else:
payload["type"] = str(self.type)
if self.relative_position is not None:
xpos, ypos = _to_point2(self.relative_position, "relative_position")
payload["relative_position"] = [xpos, ypos]
if self.scale_width is not None:
payload["scale_width"] = float(self.scale_width)
if self.scale_height is not None:
payload["scale_height"] = float(self.scale_height)
if self.spectrum is not None:
payload.update(self.spectrum.to_payload())
if self.contour is not None:
payload.update(self.contour.to_payload())
if self.labels is not None:
payload.update(self.labels.to_payload())
if self.annotation is not None:
payload.update(self.annotation.to_payload())
return payload
@classmethod
def from_payload(cls, payload: StateDict) -> "Legend":
"""Create legend options from a payload dictionary.
Args:
payload: Serialized legend payload from the FieldView host.
"""
type_value = payload.get("type")
parsed_type: constant.LegendType | str | None = None
if type_value is not None:
try:
parsed_type = constant.LegendType(str(type_value))
except ValueError:
parsed_type = str(type_value)
relative_position = payload.get("relative_position")
parsed_relative_position = None
if relative_position is not None:
parsed_relative_position = _to_point2(
relative_position, "relative_position"
)
else:
xpos = payload.get("xpos")
ypos = payload.get("ypos")
if xpos is not None and ypos is not None:
parsed_relative_position = (_coerce_float(xpos), _coerce_float(ypos))
return cls(
show=_payload_bool(payload.get("show")),
frame=_payload_bool(payload.get("frame")),
background=_payload_bool(payload.get("background")),
type=parsed_type,
relative_position=parsed_relative_position,
scale_width=_payload_float(payload.get("scale_width")),
scale_height=_payload_float(payload.get("scale_height")),
spectrum=LegendSpectrum.from_payload(payload),
contour=LegendContour.from_payload(payload),
labels=LegendLabels.from_payload(payload),
annotation=LegendAnnotation.from_payload(payload),
)
[docs]
def copy(self) -> "Legend":
"""Return a detached copy of the options."""
return Legend(
show=self.show,
frame=self.frame,
background=self.background,
type=self.type,
relative_position=self.relative_position,
scale_width=self.scale_width,
scale_height=self.scale_height,
spectrum=self.spectrum.copy() if self.spectrum is not None else None,
contour=self.contour.copy() if self.contour is not None else None,
labels=self.labels.copy() if self.labels is not None else None,
annotation=self.annotation.copy() if self.annotation is not None else None,
)
@dataclass
class Plot2DGridLinesOptions:
"""Grid-line options for a Plot2D axis."""
enabled: bool | None = None
color: constant.GeometricColor | int | None = None
style: constant.Plot2DLineStyle | str | None = None
def to_payload(self) -> StateDict:
payload: StateDict = {}
if self.enabled is not None:
payload["enabled"] = bool(self.enabled)
if self.color is not None:
payload["color"] = int(self.color)
if self.style is not None:
if isinstance(self.style, Enum):
payload["style"] = _enum_payload(self.style)
else:
payload["style"] = str(self.style)
return payload
@classmethod
def from_payload(cls, payload: StateDict) -> "Plot2DGridLinesOptions":
style = _parse_enum_or_str(constant.Plot2DLineStyle, payload.get("style"))
return cls(
enabled=_payload_bool(payload.get("enabled")),
color=_payload_geometric_color(payload.get("color")),
style=style,
)
def copy(self) -> "Plot2DGridLinesOptions":
return Plot2DGridLinesOptions(
enabled=self.enabled,
color=self.color,
style=self.style,
)
@dataclass
class Plot2DTickMarksOptions:
"""Tick-mark options for a Plot2D axis."""
enabled: bool | None = None
unit: float | None = None
def to_payload(self) -> StateDict:
payload: StateDict = {}
if self.enabled is not None:
payload["enabled"] = bool(self.enabled)
if self.unit is not None:
payload["unit"] = float(self.unit)
return payload
@classmethod
def from_payload(cls, payload: StateDict) -> "Plot2DTickMarksOptions":
return cls(
enabled=_payload_bool(payload.get("enabled")),
unit=_payload_float(payload.get("unit")),
)
def copy(self) -> "Plot2DTickMarksOptions":
return Plot2DTickMarksOptions(enabled=self.enabled, unit=self.unit)
@dataclass
class Plot2DAxisOptions:
"""Axis options for Plot2D horizontal/left/right axes."""
label: str | None = None
color: constant.GeometricColor | int | None = None
min: float | None = None
max: float | None = None
normalize: float | None = None
fix_axis: bool | None = None
major_grid_lines: Plot2DGridLinesOptions | None = None
minor_grid_lines: Plot2DGridLinesOptions | None = None
major_tick_marks: Plot2DTickMarksOptions | None = None
minor_tick_marks: Plot2DTickMarksOptions | None = None
def to_payload(self) -> StateDict:
payload: StateDict = {}
if self.label is not None:
payload["label"] = str(self.label)
if self.color is not None:
payload["color"] = int(self.color)
if self.min is not None:
payload["min"] = float(self.min)
if self.max is not None:
payload["max"] = float(self.max)
if self.normalize is not None:
payload["normalize"] = float(self.normalize)
if self.fix_axis is not None:
payload["fix_axis"] = bool(self.fix_axis)
if self.major_grid_lines is not None:
payload["major_grid_lines"] = self.major_grid_lines.to_payload()
if self.minor_grid_lines is not None:
payload["minor_grid_lines"] = self.minor_grid_lines.to_payload()
if self.major_tick_marks is not None:
payload["major_tick_marks"] = self.major_tick_marks.to_payload()
if self.minor_tick_marks is not None:
payload["minor_tick_marks"] = self.minor_tick_marks.to_payload()
return payload
@classmethod
def from_payload(cls, payload: StateDict) -> "Plot2DAxisOptions":
major_grid_payload = payload.get("major_grid_lines")
minor_grid_payload = payload.get("minor_grid_lines")
major_tick_payload = payload.get("major_tick_marks")
minor_tick_payload = payload.get("minor_tick_marks")
return cls(
label=_payload_str(payload.get("label")),
color=_payload_geometric_color(payload.get("color")),
min=_payload_float(payload.get("min")),
max=_payload_float(payload.get("max")),
normalize=_payload_float(payload.get("normalize")),
fix_axis=_payload_bool(payload.get("fix_axis")),
major_grid_lines=(
Plot2DGridLinesOptions.from_payload(cast(StateDict, major_grid_payload))
if isinstance(major_grid_payload, Mapping)
else None
),
minor_grid_lines=(
Plot2DGridLinesOptions.from_payload(cast(StateDict, minor_grid_payload))
if isinstance(minor_grid_payload, Mapping)
else None
),
major_tick_marks=(
Plot2DTickMarksOptions.from_payload(cast(StateDict, major_tick_payload))
if isinstance(major_tick_payload, Mapping)
else None
),
minor_tick_marks=(
Plot2DTickMarksOptions.from_payload(cast(StateDict, minor_tick_payload))
if isinstance(minor_tick_payload, Mapping)
else None
),
)
def copy(self) -> "Plot2DAxisOptions":
return Plot2DAxisOptions(
label=self.label,
color=self.color,
min=self.min,
max=self.max,
normalize=self.normalize,
fix_axis=self.fix_axis,
major_grid_lines=(
self.major_grid_lines.copy() if self.major_grid_lines else None
),
minor_grid_lines=(
self.minor_grid_lines.copy() if self.minor_grid_lines else None
),
major_tick_marks=(
self.major_tick_marks.copy() if self.major_tick_marks else None
),
minor_tick_marks=(
self.minor_tick_marks.copy() if self.minor_tick_marks else None
),
)
@dataclass
class Plot2DSamplingOptions:
"""Sampling options for Plot2D."""
mode: constant.Plot2DSamplingMode | str | None = None
number_samples: int | None = None
def to_payload(self) -> StateDict:
payload: StateDict = {}
if self.mode is not None:
if isinstance(self.mode, Enum):
payload["mode"] = _enum_payload(self.mode)
else:
payload["mode"] = str(self.mode)
if self.number_samples is not None:
payload["number_samples"] = int(self.number_samples)
return payload
@classmethod
def from_payload(cls, payload: StateDict) -> "Plot2DSamplingOptions":
return cls(
mode=_parse_enum_or_str(constant.Plot2DSamplingMode, payload.get("mode")),
number_samples=_payload_int(payload.get("number_samples")),
)
def copy(self) -> "Plot2DSamplingOptions":
return Plot2DSamplingOptions(
mode=self.mode,
number_samples=self.number_samples,
)
@dataclass
class Plot2DAnnotationOptions:
"""Annotation options for Plot2D."""
show_date: bool | None = None
show_legend: bool | None = None
title: str | None = None
title_color: constant.GeometricColor | int | None = None
subtitle: str | None = None
subtitle_color: constant.GeometricColor | int | None = None
def to_payload(self) -> StateDict:
payload: StateDict = {}
if self.show_date is not None:
payload["show_date"] = bool(self.show_date)
if self.show_legend is not None:
payload["show_legend"] = bool(self.show_legend)
if self.title is not None:
payload["title"] = str(self.title)
if self.title_color is not None:
payload["title_color"] = int(self.title_color)
if self.subtitle is not None:
payload["subtitle"] = str(self.subtitle)
if self.subtitle_color is not None:
payload["subtitle_color"] = int(self.subtitle_color)
return payload
@classmethod
def from_payload(cls, payload: StateDict) -> "Plot2DAnnotationOptions":
return cls(
show_date=_payload_bool(payload.get("show_date")),
show_legend=_payload_bool(payload.get("show_legend")),
title=_payload_str(payload.get("title")),
title_color=_payload_geometric_color(payload.get("title_color")),
subtitle=_payload_str(payload.get("subtitle")),
subtitle_color=_payload_geometric_color(payload.get("subtitle_color")),
)
def copy(self) -> "Plot2DAnnotationOptions":
return Plot2DAnnotationOptions(
show_date=self.show_date,
show_legend=self.show_legend,
title=self.title,
title_color=self.title_color,
subtitle=self.subtitle,
subtitle_color=self.subtitle_color,
)
@dataclass
class Plot2DOptions:
"""Border/frame options for Plot2D."""
border_color: constant.GeometricColor | int | None = None
border_layout: constant.Plot2DBorderLayout | str | None = None
border_style: constant.Plot2DLineStyle | str | None = None
def to_payload(self) -> StateDict:
payload: StateDict = {}
if self.border_color is not None:
payload["border_color"] = int(self.border_color)
if self.border_layout is not None:
if isinstance(self.border_layout, Enum):
payload["border_layout"] = _enum_payload(self.border_layout)
else:
payload["border_layout"] = str(self.border_layout)
if self.border_style is not None:
if isinstance(self.border_style, Enum):
payload["border_style"] = _enum_payload(self.border_style)
else:
payload["border_style"] = str(self.border_style)
return payload
@classmethod
def from_payload(cls, payload: StateDict) -> "Plot2DOptions":
return cls(
border_color=_payload_geometric_color(payload.get("border_color")),
border_layout=_parse_enum_or_str(
constant.Plot2DBorderLayout,
payload.get("border_layout"),
),
border_style=_parse_enum_or_str(
constant.Plot2DLineStyle,
payload.get("border_style"),
),
)
def copy(self) -> "Plot2DOptions":
return Plot2DOptions(
border_color=self.border_color,
border_layout=self.border_layout,
border_style=self.border_style,
)
[docs]
@dataclass
class RuledGridAxisOptions:
"""Ruled grid axis options."""
#: constant.Plane label (x/y/z/r/t).
label: constant.Plane | str | None = None
#: Toggle grid lines.
grid_lines: bool | None = None
#: Toggle tick marks.
tick_marks: bool | None = None
#: Numeric grid interval (0 disables).
interval: float | None = None
#: Toggle labels.
labels: bool | None = None
#: Numerical format for labels.
numerical_format: constant.NumericalFormat | str | None = None
#: Decimal places for labels.
decimal_places: int | None = None
_on_change: Callable[[str, object], None] | None = field(
default=None,
init=False,
repr=False,
compare=False,
)
_prefix: str | None = field(
default=None,
init=False,
repr=False,
compare=False,
)
def __setattr__(self, name: str, value: object) -> None:
_ensure_known_attribute(self, name)
object.__setattr__(self, name, value)
if name.startswith("_"):
return
if name not in {
"grid_lines",
"tick_marks",
"interval",
"labels",
"numerical_format",
"decimal_places",
}:
return
callback = getattr(self, "_on_change", None)
prefix = getattr(self, "_prefix", None)
if callback is None or prefix is None or value is None:
return
key = f"{prefix}_{name}"
if name in {"grid_lines", "tick_marks", "labels"}:
callback(key, bool(value))
elif name == "interval":
callback(key, _coerce_float(value))
elif name == "decimal_places":
callback(key, _coerce_int(value))
else:
if isinstance(value, Enum):
callback(key, _enum_payload(value))
else:
callback(key, str(value))
def to_payload(self, prefix: str) -> StateDict:
"""Convert ruled-grid axis options into a payload dictionary.
Args:
prefix: Payload key prefix such as ``horiz`` or ``vert``.
"""
payload: StateDict = {}
if self.grid_lines is not None:
payload[f"{prefix}_grid_lines"] = bool(self.grid_lines)
if self.tick_marks is not None:
payload[f"{prefix}_tick_marks"] = bool(self.tick_marks)
if self.interval is not None:
payload[f"{prefix}_interval"] = float(self.interval)
if self.labels is not None:
payload[f"{prefix}_labels"] = bool(self.labels)
if self.numerical_format is not None:
if isinstance(self.numerical_format, Enum):
payload[f"{prefix}_numerical_format"] = _enum_payload(
self.numerical_format
)
else:
payload[f"{prefix}_numerical_format"] = str(self.numerical_format)
if self.decimal_places is not None:
payload[f"{prefix}_decimal_places"] = int(self.decimal_places)
return payload
@classmethod
def from_payload(cls, payload: StateDict, prefix: str) -> "RuledGridAxisOptions":
"""Create ruled-grid axis options from a payload dictionary.
Args:
payload: Serialized ruled-grid-axis payload from the FieldView
host.
prefix: Payload key prefix such as ``horiz`` or ``vert``.
"""
label_key = f"{prefix}_label"
label_value = payload.get(label_key)
label: constant.Plane | str | None = None
if label_value is not None:
try:
label = constant.Plane(str(label_value))
except ValueError:
label = str(label_value)
fmt_key = f"{prefix}_numerical_format"
fmt_value = payload.get(fmt_key)
fmt: constant.NumericalFormat | str | None = None
if fmt_value is not None:
try:
fmt = constant.NumericalFormat(str(fmt_value))
except ValueError:
fmt = str(fmt_value)
return cls(
label=label,
grid_lines=_payload_bool(payload.get(f"{prefix}_grid_lines")),
tick_marks=_payload_bool(payload.get(f"{prefix}_tick_marks")),
interval=_payload_float(payload.get(f"{prefix}_interval")),
labels=_payload_bool(payload.get(f"{prefix}_labels")),
numerical_format=fmt,
decimal_places=_payload_int(payload.get(f"{prefix}_decimal_places")),
)
[docs]
def copy(self) -> "RuledGridAxisOptions":
"""Return a detached copy of the options."""
return RuledGridAxisOptions(
label=self.label,
grid_lines=self.grid_lines,
tick_marks=self.tick_marks,
interval=self.interval,
labels=self.labels,
numerical_format=self.numerical_format,
decimal_places=self.decimal_places,
)
[docs]
@dataclass
class RuledGridOptions:
"""Ruled grid options."""
show: bool = False
#: Geometric color (1-10).
color: constant.GeometricColor | int | None = None
#: Text font identifier.
font: constant.Font | str | None = None
#: Font size.
size: int | None = None
#: Horizontal axis options.
horizontal_axis: RuledGridAxisOptions | None = None
#: Vertical axis options.
vertical_axis: RuledGridAxisOptions | None = None
_on_change: Callable[[str, object], None] | None = field(
default=None,
init=False,
repr=False,
compare=False,
)
def __setattr__(self, name: str, value: object) -> None:
_ensure_known_attribute(self, name)
object.__setattr__(self, name, value)
if name.startswith("_"):
return
callback = getattr(self, "_on_change", None)
if name in {"horizontal_axis", "vertical_axis"} and isinstance(
value, RuledGridAxisOptions
):
prefix = "horiz" if name == "horizontal_axis" else "vert"
object.__setattr__(value, "_on_change", callback)
object.__setattr__(value, "_prefix", prefix)
return
if callback is None or value is None:
return
if name == "show":
callback(name, bool(value))
elif name == "color":
callback(name, _coerce_int(value))
elif name == "font":
if isinstance(value, Enum):
callback(name, _enum_payload(value))
else:
callback(name, str(value))
elif name == "size":
callback(name, _coerce_int(value))
def to_payload(self) -> StateDict:
payload: StateDict = {}
if self.color is not None:
payload["color"] = int(self.color)
if self.font is not None:
if isinstance(self.font, Enum):
payload["font"] = _enum_payload(self.font)
else:
payload["font"] = str(self.font)
if self.size is not None:
payload["size"] = int(self.size)
if self.horizontal_axis is not None:
payload.update(self.horizontal_axis.to_payload("horiz"))
if self.vertical_axis is not None:
payload.update(self.vertical_axis.to_payload("vert"))
return payload
@classmethod
def from_payload(
cls, payload: StateDict, *, show: bool = False
) -> "RuledGridOptions":
"""Create ruled-grid options from a payload dictionary.
Args:
payload: Serialized ruled-grid payload from the FieldView host.
show: Initial ruled-grid visibility state to attach to the object.
"""
font_value = payload.get("font")
font: constant.Font | str | None = None
if font_value is not None:
try:
font = constant.Font(str(font_value))
except ValueError:
font = str(font_value)
return cls(
show=bool(show),
color=_payload_geometric_color(payload.get("color")),
font=font,
size=_payload_int(payload.get("size")),
horizontal_axis=RuledGridAxisOptions.from_payload(payload, "horiz"),
vertical_axis=RuledGridAxisOptions.from_payload(payload, "vert"),
)
[docs]
def copy(self) -> "RuledGridOptions":
"""Return a detached copy of the options."""
horiz = (
self.horizontal_axis.copy() if self.horizontal_axis is not None else None
)
vert = self.vertical_axis.copy() if self.vertical_axis is not None else None
return RuledGridOptions(
show=self.show,
color=self.color,
font=self.font,
size=self.size,
horizontal_axis=horiz,
vertical_axis=vert,
)
@dataclass
class ScalarAnnotation:
"""Scalar annotation settings."""
show_location: bool | None = None
show_text: bool | None = None
color: constant.GeometricColor | int | None = None
text: str | None = None
_on_change: Callable[[str, object], None] | None = field(
default=None,
init=False,
repr=False,
compare=False,
)
_prefix: str | None = field(
default=None,
init=False,
repr=False,
compare=False,
)
def __setattr__(self, name: str, value: object) -> None:
_ensure_known_attribute(self, name)
object.__setattr__(self, name, value)
if name.startswith("_"):
return
if name not in {"show_location", "show_text", "color", "text"}:
return
callback = getattr(self, "_on_change", None)
prefix = getattr(self, "_prefix", None)
if callback is None or prefix is None or value is None:
return
key = f"{prefix}_{name}"
if name in {"show_location", "show_text"}:
callback(key, bool(value))
elif name == "color":
callback(key, _coerce_int(value))
else:
callback(key, str(value))
def to_payload(self, prefix: str) -> StateDict:
"""Convert scalar-annotation settings into a payload dictionary.
Args:
prefix: Payload key prefix such as ``min`` or ``max``.
"""
payload: StateDict = {}
if self.show_location is not None:
payload[f"{prefix}_show_location"] = bool(self.show_location)
if self.show_text is not None:
payload[f"{prefix}_show_text"] = bool(self.show_text)
if self.color is not None:
payload[f"{prefix}_color"] = int(self.color)
if self.text is not None:
payload[f"{prefix}_text"] = str(self.text)
return payload
@classmethod
def from_payload(cls, payload: StateDict, prefix: str) -> "ScalarAnnotation":
"""Create scalar-annotation settings from a payload dictionary.
Args:
payload: Serialized scalar-annotation payload from the FieldView
host.
prefix: Payload key prefix such as ``min`` or ``max``.
"""
return cls(
show_location=_payload_bool(payload.get(f"{prefix}_show_location")),
show_text=_payload_bool(payload.get(f"{prefix}_show_text")),
color=_payload_geometric_color(payload.get(f"{prefix}_color")),
text=_payload_str(payload.get(f"{prefix}_text")),
)
def copy(self) -> "ScalarAnnotation":
"""Return a detached copy of the annotation."""
return ScalarAnnotation(
show_location=self.show_location,
show_text=self.show_text,
color=self.color,
text=self.text,
)
[docs]
@dataclass
class ScalarMinMax:
"""Scalar min/max annotation options."""
show: bool = False
font: constant.Font | str | None = None
size: int | None = None
min: ScalarAnnotation | None = None
max: ScalarAnnotation | None = None
_on_change: Callable[[str, object], None] | None = field(
default=None,
init=False,
repr=False,
compare=False,
)
_get_data: Callable[[], StateDict] | None = field(
default=None,
init=False,
repr=False,
compare=False,
)
def __setattr__(self, name: str, value: object) -> None:
_ensure_known_attribute(self, name)
object.__setattr__(self, name, value)
if name.startswith("_"):
return
callback = getattr(self, "_on_change", None)
if callback is None or value is None:
return
if name == "show":
callback(name, bool(value))
elif name == "font":
if isinstance(value, Enum):
callback(name, _enum_payload(value))
else:
callback(name, str(value))
elif name == "size":
callback(name, _coerce_int(value))
elif name in {"min", "max"} and isinstance(value, ScalarAnnotation):
object.__setattr__(value, "_on_change", callback)
object.__setattr__(value, "_prefix", name)
def to_payload(self) -> StateDict:
payload: StateDict = {}
if self.font is not None:
if isinstance(self.font, Enum):
payload["font"] = _enum_payload(self.font)
else:
payload["font"] = str(self.font)
if self.size is not None:
payload["size"] = int(self.size)
if self.min is not None:
payload.update(self.min.to_payload("min"))
if self.max is not None:
payload.update(self.max.to_payload("max"))
return payload
@classmethod
def from_payload(cls, payload: StateDict, *, show: bool = False) -> "ScalarMinMax":
"""Create scalar min/max annotation options from a payload dictionary.
Args:
payload: Serialized scalar-min/max payload from the FieldView host.
show: Initial scalar-min/max visibility state to attach to the
object.
"""
font_value = payload.get("font")
font: constant.Font | str | None = None
if font_value is not None:
try:
font = constant.Font(str(font_value))
except ValueError:
font = str(font_value)
return cls(
show=bool(show),
font=font,
size=_payload_int(payload.get("size")),
min=ScalarAnnotation.from_payload(payload, "min"),
max=ScalarAnnotation.from_payload(payload, "max"),
)
[docs]
def copy(self) -> "ScalarMinMax":
"""Return a detached copy of the options."""
min_annotation = self.min.copy() if self.min is not None else None
max_annotation = self.max.copy() if self.max is not None else None
return ScalarMinMax(
show=self.show,
font=self.font,
size=self.size,
min=min_annotation,
max=max_annotation,
)
def _require_data(self) -> StateDict:
callback = self._get_data
if callback is None:
raise InvalidArgumentError(
"ScalarMinMax min/max values and positions are only available for bound surface objects"
)
payload = callback()
if not isinstance(payload, dict):
raise InvalidArgumentError("ScalarMinMax data payload is invalid")
return payload
[docs]
def get_minmax_values(self) -> tuple[float, float]:
"""Return the current scalar min/max values."""
payload = self._require_data()
return _required_float(payload, "min"), _required_float(payload, "max")
[docs]
def get_minmax_positions(
self,
) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
"""Return XYZ positions for scalar min/max values."""
payload = self._require_data()
min_pos = (
_required_float(payload, "min_x"),
_required_float(payload, "min_y"),
_required_float(payload, "min_z"),
)
max_pos = (
_required_float(payload, "max_x"),
_required_float(payload, "max_y"),
_required_float(payload, "max_z"),
)
return min_pos, max_pos
[docs]
def get_min_value(self) -> float:
"""Return the current scalar minimum value."""
min_value, _ = self.get_minmax_values()
return min_value
[docs]
def get_max_value(self) -> float:
"""Return the current scalar maximum value."""
_, max_value = self.get_minmax_values()
return max_value
[docs]
def get_min_position(self) -> tuple[float, float, float]:
"""Return XYZ position for the current scalar minimum."""
min_pos, _ = self.get_minmax_positions()
return min_pos
[docs]
def get_max_position(self) -> tuple[float, float, float]:
"""Return XYZ position for the current scalar maximum."""
_, max_pos = self.get_minmax_positions()
return max_pos