"""Screen-space annotation helpers.
Example usage:
.. code-block:: python
>>> import os
>>> import fieldview as fv
>>> data_dir = os.path.join(fv.home, "examples", "f18")
>>> ds = fv.data.load_plot3d(
... os.path.join(data_dir, "f18i9b_g_bin"),
... os.path.join(data_dir, "f18i9b_q_bin"),
... )
>>> anno = fv.vis.create_annotation_text("Probe label")
>>> arrow = fv.vis.create_annotation_arrow((320, 220), (180, 120))
"""
from __future__ import annotations
from abc import ABC
from collections.abc import Mapping, Sequence
from typing import cast
import warnings
from .. import _core
from .. import constant
from .._base import GraphicObjectBase
from .._core_utils import _core_call
from ..data import _resolve_dataset_or_current
from ..exceptions import InvalidArgumentError
from ..utils import _coerce_float, _coerce_int, _to_point2
__all__: list[str] = [
"AnnotationArrow",
"AnnotationText",
"create_annotation_arrow",
"create_annotation_text",
]
_MAX_ANNOTATION_TEXT_LEN = 79
def _normalize_text(text: str) -> str:
if not isinstance(text, str):
raise InvalidArgumentError("text must be a string")
if not text:
raise InvalidArgumentError("text cannot be empty")
clean = text.replace("\x00", "")
if not clean:
raise InvalidArgumentError("text cannot be empty")
if len(clean) > _MAX_ANNOTATION_TEXT_LEN:
warnings.warn(
(
"annotation text too long; truncating to "
f"{_MAX_ANNOTATION_TEXT_LEN} characters."
),
UserWarning,
stacklevel=3,
)
clean = clean[:_MAX_ANNOTATION_TEXT_LEN]
return clean
def _normalize_point(value: object, label: str) -> tuple[float, float]:
return _to_point2(value, label)
def _normalize_arrow_position(
position: object = None,
*,
tip: object = None,
tail: object = None,
) -> dict[str, tuple[float, float]]:
resolved_tip = tip
resolved_tail = tail
if position is not None:
if isinstance(position, Mapping):
position_map = cast(Mapping[str, object], position)
if resolved_tip is None:
resolved_tip = position_map.get(
"tip",
position_map.get(constant.AnnotationArrowPoint.TIP),
)
if resolved_tail is None:
resolved_tail = position_map.get(
"tail",
position_map.get(constant.AnnotationArrowPoint.TAIL),
)
elif (
isinstance(position, Sequence)
and not isinstance(position, (str, bytes))
and len(position) == 2
):
if resolved_tip is None:
resolved_tip = position[0]
if resolved_tail is None:
resolved_tail = position[1]
else:
raise InvalidArgumentError(
"position must be a mapping with tip/tail or a 2-point sequence"
)
if resolved_tip is None or resolved_tail is None:
raise InvalidArgumentError(
"annotation arrow position must define both tip and tail"
)
return {
"tip": _normalize_point(resolved_tip, "tip"),
"tail": _normalize_point(resolved_tail, "tail"),
}
class _AnnotationBase(GraphicObjectBase, ABC):
__slots__ = ()
def _state(self) -> dict[str, object]:
return _core_call(_core.annotation_get_state, self._phigs_obj)
def _modify(self, payload: dict[str, object]) -> None:
_core_call(_core.annotation_modify, self._phigs_obj, payload)
@property
def visibility(self) -> bool:
"""`bool`: Visibility state."""
return bool(self._state()["visibility"])
@visibility.setter
def visibility(self, value: bool) -> None:
self._modify({"visibility": bool(value)})
@property
def color(self) -> constant.GeometricColor:
"""`GeometricColor`: Annotation color."""
return constant.GeometricColor(_coerce_int(self._state()["color"]))
@color.setter
def color(self, value: constant.GeometricColor | int) -> None:
self._modify({"color": int(value)})
def delete(self) -> None:
"""Delete the annotation object."""
self._delete_surface(_core.annotation_delete)
[docs]
class AnnotationText(_AnnotationBase):
"""Text annotation wrapper."""
__slots__ = ()
@property
def text(self) -> str:
"""`str`: Displayed annotation string."""
return str(self._state()["text"])
@text.setter
def text(self, value: str) -> None:
self._modify({"text": _normalize_text(value)})
@property
def position(self) -> tuple[float, float]:
"""`tuple[float, float]`: Text anchor position in screen coordinates."""
xpos, ypos = cast(tuple[object, object], self._state()["position"])
return (_coerce_float(xpos), _coerce_float(ypos))
@position.setter
def position(self, value: object) -> None:
self._modify({"position": list(_normalize_point(value, "position"))})
@property
def font(self) -> constant.Font:
"""`Font`: Text font."""
value = str(self._state()["font"])
if value == constant.Font.NOTO_SANS_REGULAR.value:
return constant.Font.NOTO
return constant.Font(value)
@font.setter
def font(self, value: constant.Font | str) -> None:
self._modify(
{"font": value.value if isinstance(value, constant.Font) else str(value)}
)
@property
def font_size(self) -> int:
"""`int`: Font size."""
return _coerce_int(self._state()["font_size"])
@font_size.setter
def font_size(self, value: int) -> None:
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise InvalidArgumentError("font_size must be a positive integer")
self._modify({"font_size": value})
@property
def align(self) -> constant.AnnotationTextAlign:
"""`AnnotationTextAlign`: Text alignment."""
return constant.AnnotationTextAlign(str(self._state()["align"]))
@align.setter
def align(self, value: constant.AnnotationTextAlign | str) -> None:
self._modify(
{
"align": value.value
if isinstance(value, constant.AnnotationTextAlign)
else str(value)
}
)
@property
def direction(self) -> constant.AnnotationTextDirection:
"""`AnnotationTextDirection`: Text direction."""
return constant.AnnotationTextDirection(str(self._state()["direction"]))
@direction.setter
def direction(self, value: constant.AnnotationTextDirection | str) -> None:
self._modify(
{
"direction": value.value
if isinstance(value, constant.AnnotationTextDirection)
else str(value)
}
)
[docs]
def modify(
self,
*,
text: str | None = None,
visibility: bool | None = None,
position: object = None,
color: constant.GeometricColor | int | None = None,
font: constant.Font | str | None = None,
font_size: int | None = None,
align: constant.AnnotationTextAlign | str | None = None,
direction: constant.AnnotationTextDirection | str | None = None,
) -> None:
"""Update multiple text annotation properties in one call."""
payload: dict[str, object] = {}
if text is not None:
payload["text"] = _normalize_text(text)
if visibility is not None:
payload["visibility"] = bool(visibility)
if position is not None:
payload["position"] = list(_normalize_point(position, "position"))
if color is not None:
payload["color"] = int(color)
if font is not None:
payload["font"] = (
font.value if isinstance(font, constant.Font) else str(font)
)
if font_size is not None:
if (
isinstance(font_size, bool)
or not isinstance(font_size, int)
or font_size <= 0
):
raise InvalidArgumentError("font_size must be a positive integer")
payload["font_size"] = font_size
if align is not None:
payload["align"] = (
align.value
if isinstance(align, constant.AnnotationTextAlign)
else str(align)
)
if direction is not None:
payload["direction"] = (
direction.value
if isinstance(direction, constant.AnnotationTextDirection)
else str(direction)
)
if not payload:
raise InvalidArgumentError("modify requires at least one property")
self._modify(payload)
[docs]
class AnnotationArrow(_AnnotationBase):
"""Arrow annotation wrapper."""
__slots__ = ()
@property
def position(self) -> dict[str, tuple[float, float]]:
"""`dict[str, tuple[float, float]]`: Arrow `tip` and `tail` screen coordinates."""
state = self._state()
tip_x, tip_y = cast(tuple[object, object], state["tip"])
tail_x, tail_y = cast(tuple[object, object], state["tail"])
return {
"tip": (_coerce_float(tip_x), _coerce_float(tip_y)),
"tail": (_coerce_float(tail_x), _coerce_float(tail_y)),
}
@position.setter
def position(self, value: object) -> None:
position_payload = _normalize_arrow_position(value)
self._modify(
{
"tip": list(position_payload["tip"]),
"tail": list(position_payload["tail"]),
}
)
[docs]
def modify(
self,
*,
visibility: bool | None = None,
position: object = None,
color: constant.GeometricColor | int | None = None,
tip: object = None,
tail: object = None,
) -> None:
"""Update multiple arrow annotation properties in one call."""
payload: dict[str, object] = {}
if visibility is not None:
payload["visibility"] = bool(visibility)
if color is not None:
payload["color"] = int(color)
if position is not None or tip is not None or tail is not None:
resolved = _normalize_arrow_position(position, tip=tip, tail=tail)
payload["tip"] = list(resolved["tip"])
payload["tail"] = list(resolved["tail"])
if not payload:
raise InvalidArgumentError("modify requires at least one property")
self._modify(payload)
[docs]
def create_annotation_text(
text: str,
*,
position: object = None,
visibility: bool | None = None,
color: constant.GeometricColor | int | None = None,
font: constant.Font | str | None = None,
font_size: int | None = None,
align: constant.AnnotationTextAlign | str | None = None,
direction: constant.AnnotationTextDirection | str | None = None,
) -> AnnotationText:
"""Create a screen-space text annotation."""
_resolve_dataset_or_current(None)
phigs_obj = _core_call(_core.annotation_text_create, _normalize_text(text))
annotation = AnnotationText(phigs_obj)
if any(
value is not None
for value in (
position,
visibility,
color,
font,
font_size,
align,
direction,
)
):
try:
annotation.modify(
position=position,
visibility=visibility,
color=color,
font=font,
font_size=font_size,
align=align,
direction=direction,
)
except Exception:
annotation.delete()
raise
return annotation
[docs]
def create_annotation_arrow(
tip: object,
tail: object,
*,
visibility: bool | None = None,
color: constant.GeometricColor | int | None = None,
) -> AnnotationArrow:
"""Create a screen-space arrow annotation."""
_resolve_dataset_or_current(None)
position_payload = _normalize_arrow_position(tip=tip, tail=tail)
phigs_obj = _core_call(_core.annotation_arrow_create)
annotation = AnnotationArrow(phigs_obj)
try:
annotation.modify(position=position_payload, visibility=visibility, color=color)
except Exception:
annotation.delete()
raise
return annotation