Source code for fieldview.vis.annotation

"""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