Source code for phenotypic.sdk_.viz.notebook._adapter

"""ipywidgets notebook shell for :class:`~phenotypic.abc_.FigureProvider`.

Invoked only when a provider's figures declare ``Control``s (control-free
providers render as a composed ``go.Figure`` instead — see
:meth:`FigureProvider.dash`). The render-loop logic is factored into pure,
ipywidgets-free module-level helpers so it can be unit-tested without a kernel;
``ipywidgets`` is imported lazily inside :func:`build_notebook_dashboard`.

Controls are deduped **by identity** (``id(control)``): one widget per unique
``Control`` instance, and changing it re-renders exactly the figures that
reference that instance.
"""

from __future__ import annotations

import json
from typing import TYPE_CHECKING, Any

from phenotypic.abc_._figure_provider import Control, FigureSpec

if TYPE_CHECKING:  # pragma: no cover - typing only
    from phenotypic.abc_._figure_provider import FigureProvider

__all__ = [
    "unique_controls",
    "control_owners",
    "initial_control_state",
    "spec_control_kwargs",
    "build_notebook_dashboard",
]


# -- pure helpers (no ipywidgets) -------------------------------------------


def unique_controls(specs: list[FigureSpec]) -> list[Control]:
    """Return the controls across ``specs``, deduped by identity, first-seen order.

    Two distinct ``Control`` instances are kept separate even if their fields are
    equal; the same instance shared by several specs collapses to one entry.
    """
    seen: dict[int, Control] = {}
    for spec in specs:
        for control in spec.controls.values():
            seen.setdefault(id(control), control)
    return list(seen.values())


def control_owners(specs: list[FigureSpec]) -> dict[int, list[FigureSpec]]:
    """Map ``id(control)`` → the specs whose figures reference that control.

    Used to recompute exactly the affected figures when a control changes.
    """
    owners: dict[int, list[FigureSpec]] = {}
    for spec in specs:
        for control in spec.controls.values():
            owners.setdefault(id(control), [])
            if spec not in owners[id(control)]:
                owners[id(control)].append(spec)
    return owners


def initial_control_state(specs: list[FigureSpec]) -> dict[int, Any]:
    """Map ``id(control)`` → its default value, for every unique control."""
    return {id(c): c.default for c in unique_controls(specs)}


def spec_control_kwargs(spec: FigureSpec, state: dict[int, Any]) -> dict[str, Any]:
    """Resolve ``spec``'s control kwargs to current values from ``state``.

    Args:
        spec: The figure spec.
        state: ``{id(control): value}`` current control values.

    Returns:
        ``{method-kwarg: value}`` ready to splat into the figure method.
    """
    return {kwarg: state[id(control)] for kwarg, control in spec.controls.items()}


# -- ipywidgets shell -------------------------------------------------------


def _require_ipywidgets() -> Any:
    """Import and return the ``ipywidgets`` module, or raise a helpful error."""
    try:
        import ipywidgets as widgets
    except ImportError as exc:  # pragma: no cover - exercised only without the extra
        raise ImportError(
            "The interactive notebook dashboard requires ipywidgets. Install the "
            "GUI extra: `uv sync --extra gui`."
        ) from exc
    return widgets


def _make_widget(control: Control, widgets: Any) -> Any:
    """Build the ipywidget for a single :class:`Control`."""
    if control.kind == "float":
        low, high = control.bounds  # type: ignore[misc]
        kwargs: dict[str, Any] = dict(
            value=control.default, min=low, max=high, description=control.label
        )
        if control.step is not None:
            kwargs["step"] = control.step
        return widgets.FloatSlider(**kwargs)
    if control.kind == "select":
        return widgets.Dropdown(
            value=control.default,
            options=list(control.options or ()),
            description=control.label,
        )
    if control.kind == "bool":
        return widgets.Checkbox(value=control.default, description=control.label)
    return widgets.Text(value=control.default, description=control.label)


def _plotly_display_payload(fig: Any) -> dict[str, Any]:
    """Return an Output-compatible display-data payload for a Plotly figure."""
    from plotly.io import to_json

    data = {
        "application/vnd.plotly.v1+json": json.loads(
            to_json(fig, validate=False)
        )
    }
    return {
        "output_type": "display_data",
        "data": data,
        "metadata": {},
    }


[docs] def build_notebook_dashboard(provider: "FigureProvider", subject: Any = None) -> Any: """Build an ipywidgets dashboard for ``provider``'s figures. One widget per unique control (deduped by identity); changing a control re-renders exactly the figures that reference it. Figures are grouped into collapsible ``Accordion`` cards by their ``section`` tag. Args: provider: The :class:`FigureProvider` to render. subject: Subject to bind (operations); ``None`` uses the held subject. Returns: An ``ipywidgets.Widget`` ready to display in a Jupyter cell. """ widgets = _require_ipywidgets() try: __import__("IPython") except ImportError as exc: # pragma: no cover - exercised only without IPython raise ImportError( "The interactive notebook dashboard requires IPython. Install the " "GUI extra: `uv sync --extra gui`." ) from exc specs = provider.iter_figures() bound = provider.figures(subject) state = initial_control_state(specs) owners = control_owners(specs) # One Output per figure (keyed by spec name) and a control-widget per unique # control instance (keyed by id(control)). outputs: dict[str, Any] = {spec.name: widgets.Output() for spec in specs} control_widgets: dict[int, Any] = { id(control): _make_widget(control, widgets) for control in unique_controls(specs) } def render_spec(spec: FigureSpec) -> None: fig = bound.render(spec, **spec_control_kwargs(spec, state)) out = outputs[spec.name] out.outputs = (_plotly_display_payload(fig),) def on_change(control_id: int) -> Any: def handler(change: Any) -> None: state[control_id] = change["new"] for spec in owners.get(control_id, ()): render_spec(spec) return handler for control_id, widget in control_widgets.items(): widget.observe(on_change(control_id), names="value") # Initial render of every figure. for spec in specs: render_spec(spec) # Group figures into collapsible cards by section, in definition order. sections: dict[str, list[FigureSpec]] = {} for spec in specs: sections.setdefault(spec.section, []).append(spec) accordion = widgets.Accordion( children=[ widgets.VBox([outputs[spec.name] for spec in section_specs]) for section_specs in sections.values() ] ) for i, name in enumerate(sections): accordion.set_title(i, name) controls_box = widgets.VBox(list(control_widgets.values())) return widgets.VBox([controls_box, accordion])