From 23254068ed0fee00e8fde039ccef4c90f5a73648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Wed, 20 Sep 2023 11:59:09 +0200 Subject: [PATCH] Simple panel app (#27) --- examples/app_example.ipynb | 130 +++++++++++++++++++++++++++++++ holonote/app/__init__.py | 1 + holonote/app/panel.py | 151 +++++++++++++++++++++++++++++++++++++ holonote/tests/test_app.py | 9 +++ 4 files changed, 291 insertions(+) create mode 100644 examples/app_example.ipynb create mode 100644 holonote/app/__init__.py create mode 100644 holonote/app/panel.py create mode 100644 holonote/tests/test_app.py diff --git a/examples/app_example.ipynb b/examples/app_example.ipynb new file mode 100644 index 0000000..49c7fb8 --- /dev/null +++ b/examples/app_example.ipynb @@ -0,0 +1,130 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "e9254fc3-0447-440e-825e-68e02dcb9eb7", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "import numpy as np\n", + "import pandas as pd\n", + "import hvplot.pandas\n", + "import numpy as np\n", + "import holoviews as hv\n", + "\n", + "from holonote.annotate import SQLiteDB, Annotator\n", + "from holonote.app import PanelWidgets\n", + "\n", + "pn.extension()" + ] + }, + { + "cell_type": "markdown", + "id": "4867a649-3a32-4004-8d81-f47090eacf98", + "metadata": {}, + "source": [ + "# Single figure" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6273665b-b850-451b-a0c8-0635bcdfe765", + "metadata": {}, + "outputs": [], + "source": [ + "curve = pd.read_parquet(\"assets/example.parquet\").hvplot(x=\"TIME\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "74340a51-d1b0-4cd7-954f-923f45c50941", + "metadata": {}, + "outputs": [], + "source": [ + "connector = SQLiteDB(table_name=\"test_app\")\n", + "fields = [\"Stoppage\", \"Reason\", \"Category\"]\n", + "annotator = Annotator({\"TIME\": np.datetime64}, fields=fields, connector=connector)\n", + "annotator_element = annotator * curve" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b65b2e68-057c-490c-9061-4ee4ea69ca0e", + "metadata": {}, + "outputs": [], + "source": [ + "fields_values = {\n", + " \"Stoppage\": [\"Yes\", \"No\"],\n", + " \"Category\": [\"Mechanical\", \"Electrical\", \"Process\", \"Other\"],\n", + "}\n", + "\n", + "w = PanelWidgets(annotator, field_values=fields_values)\n", + "pn.Row(w, annotator_element).servable()" + ] + }, + { + "cell_type": "markdown", + "id": "cc26d77d-4c47-4037-a2a4-1b389988ff44", + "metadata": {}, + "source": [ + "# Multiple figures" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e5a8c340-0211-436b-9f51-fe2240bc2a73", + "metadata": {}, + "outputs": [], + "source": [ + "xvals = np.linspace(-4, 0, 202)\n", + "yvals = np.linspace(4, 0, 202)\n", + "xs, ys = np.meshgrid(xvals, yvals)\n", + "alpha, beta = 1, 0\n", + "ab_data = np.sin(((ys / alpha) ** alpha + beta) * xs)\n", + "\n", + "image = hv.Image(ab_data, kdims=[\"A\", \"B\"]).opts(cmap=\"greens\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "de2c967f-067b-47da-9fe7-201ee7cd2df4", + "metadata": {}, + "outputs": [], + "source": [ + "connector = SQLiteDB(table_name=\"test_multi_app\")\n", + "fields = [\"Stoppage\", \"Reason\", \"Category\"]\n", + "multi_annotator = Annotator(\n", + " {\"TIME\": np.datetime64, \"A\": float, \"B\": float}, fields=fields, connector=connector\n", + ")\n", + "\n", + "multi_annotator_element = multi_annotator * curve + multi_annotator * image" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2896d0a4-54bc-4d49-97ab-59cb77a031f7", + "metadata": {}, + "outputs": [], + "source": [ + "mutli_w = PanelWidgets(multi_annotator, field_values=fields_values)\n", + "pn.Row(mutli_w, multi_annotator_element).servable()" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/holonote/app/__init__.py b/holonote/app/__init__.py new file mode 100644 index 0000000..47102d3 --- /dev/null +++ b/holonote/app/__init__.py @@ -0,0 +1 @@ +from .panel import PanelWidgets # noqa: F401 diff --git a/holonote/app/panel.py b/holonote/app/panel.py new file mode 100644 index 0000000..296d62d --- /dev/null +++ b/holonote/app/panel.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import datetime as dt +from typing import TYPE_CHECKING, Any + +import panel as pn +import param + +if TYPE_CHECKING: + from holonote.annotate import Annotator + + +class PanelWidgets: + mapping = { + str: pn.widgets.TextInput, + bool: pn.widgets.Checkbox, + dt.datetime: pn.widgets.DatePicker, + dt.date: pn.widgets.DatePicker, + int: pn.widgets.IntSlider, + float: pn.widgets.FloatSlider, + } + + def __init__(self, annotator: Annotator, field_values: dict[str, Any] | None=None): + self.annotator = annotator + self.annotator.snapshot() + self._widget_mode_group = pn.widgets.RadioButtonGroup( + name="Mode", options=["+", "-", "✏"], width=90 + ) + self._widget_apply_button = pn.widgets.Button(name="✓", width=20) + self._widget_revert_button = pn.widgets.Button(name="↺", width=20) + self._widget_commit_button = pn.widgets.Button(name="▲", width=20) + + if field_values is None: + self._fields_values = {k: "" for k in self.annotator.fields} + else: + self._fields_values = { + k: field_values.get(k, "") for k in self.annotator.fields + } + self._fields_widgets = self._create_fields_widgets(self._fields_values) + + self._set_standard_callbacks() + + @property + def tool_widgets(self): + return pn.Row( + self._widget_apply_button, + pn.Spacer(width=10), + self._widget_mode_group, + pn.Spacer(width=10), + self._widget_revert_button, + self._widget_commit_button, + ) + + def _create_fields_widgets(self, fields_values): + fields_widgets = {} + for widget_name, default in fields_values.items(): + if isinstance(default, param.Parameter): + parameterized = type( + "widgets", (param.Parameterized,), {widget_name: default} + ) + pane = pn.Param(parameterized) + fields_widgets[widget_name] = pane.layout[1] + elif isinstance(default, list): + fields_widgets[widget_name] = pn.widgets.Select( + value=default[0], options=default, name=widget_name + ) + else: + widget_type = self.mapping[type(default)] + if issubclass(widget_type, pn.widgets.TextInput): + fields_widgets[widget_name] = widget_type( + value=default, placeholder=widget_name, name=widget_name + ) + else: + fields_widgets[widget_name] = widget_type( + value=default, name=widget_name + ) + return fields_widgets + + @property + def fields_widgets(self): + accordion = False # Experimental + widgets = pn.Column(*self._fields_widgets.values()) + if accordion: + return pn.Accordion(("fields", widgets)) + else: + return widgets + + def _reset_fields_widgets(self): + for widget_name, default in self._fields_values.items(): + if isinstance(default, param.Parameter): + default = default.default + try: + self._fields_widgets[widget_name].value = default + except Exception: + pass # TODO: Fix when lists (for categories, not the same as the default!) + + def _callback_apply(self, event): + selected_ind = ( + self.annotator.selected_indices[0] + if len(self.annotator.selected_indices) == 1 + else None + ) + self.annotator.select_by_index() + + if self._widget_mode_group.value in ["+", "✏"]: + fields_values = {k: v.value for k, v in self._fields_widgets.items()} + if self._widget_mode_group.value == "+": + self.annotator.add_annotation(**fields_values) + self._reset_fields_widgets() + elif (self._widget_mode_group.value == "✏") and (selected_ind is not None): + self.annotator.update_annotation_fields( + selected_ind, **fields_values + ) # TODO: Handle only changed + elif self._widget_mode_group.value == "-": + if selected_ind is not None: + self.annotator.delete_annotation(selected_ind) + + def _callback_commit(self, event): + self.annotator.commit() + + def _watcher_selected_indices(self, event): + if len(event.new) != 1: + return + selected_index = event.new[0] + # if self._widget_mode_group.value == '✏': + for name, widget in self._fields_widgets.items(): + value = self.annotator.annotation_table._field_df.loc[selected_index][name] + widget.value = value + + def _watcher_mode_group(self, event): + if event.new in ["-", "✏"]: + self.annotator.selection_enabled = True + self.annotator.select_by_index() + self.annotator.editable_enabled = False + elif event.new == "+": + self.annotator.editable_enabled = True + self.annotator.select_by_index() + self.annotator.selection_enabled = False + + for widget in self._fields_widgets.values(): + widget.disabled = event.new == "-" + + def _set_standard_callbacks(self): + self._widget_apply_button.on_click(self._callback_apply) + self._widget_revert_button.on_click(lambda event: self.annotator.revert_to_snapshot()) + self._widget_commit_button.on_click(self._callback_commit) + self.annotator.param.watch(self._watcher_selected_indices, "selected_indices") + self._widget_mode_group.param.watch(self._watcher_mode_group, "value") + + def __panel__(self): + return pn.Column(self.fields_widgets, self.tool_widgets) diff --git a/holonote/tests/test_app.py b/holonote/tests/test_app.py new file mode 100644 index 0000000..cbb39c1 --- /dev/null +++ b/holonote/tests/test_app.py @@ -0,0 +1,9 @@ +import panel as pn + +from holonote.app import PanelWidgets + + +def test_panel_app(annotator_range1d): + w = PanelWidgets(annotator_range1d) + assert isinstance(w.fields_widgets, pn.Column) + assert isinstance(w.tool_widgets, pn.Row)