diff --git a/examples/tutorial/04_Make_an_App.ipynb b/examples/tutorial/04_Make_an_App.ipynb index 0a9461a..2f28ca1 100644 --- a/examples/tutorial/04_Make_an_App.ipynb +++ b/examples/tutorial/04_Make_an_App.ipynb @@ -119,7 +119,30 @@ "outputs": [], "source": [ "tools = PanelWidgets(annotator, field_values=fields_values, as_popup=True)\n", - "pn.Row(tools, annotator_element).servable()" + "pn.Row(tools, annotator_element)" + ] + }, + { + "cell_type": "markdown", + "id": "b7152cba-6058-427d-b8a8-3e0611ab3c7f", + "metadata": {}, + "source": [ + "## See annotations in a table\n", + "\n", + "As the name suggests, `AnnotatorTable` is a way to display your annotations in a table. You can edit or delete the annotations from the table. \n", + "\n", + "New or edited annotations will appear grey until you commit to the database." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ddf720f6-0411-47e6-aff4-91939ae91cb1", + "metadata": {}, + "outputs": [], + "source": [ + "from holonote.app import AnnotatorTable\n", + "AnnotatorTable(annotator)" ] } ], diff --git a/examples/tutorial/05_Watch_Events.ipynb b/examples/tutorial/05_Watch_Events.ipynb index 782d9d1..c77c15b 100644 --- a/examples/tutorial/05_Watch_Events.ipynb +++ b/examples/tutorial/05_Watch_Events.ipynb @@ -66,6 +66,30 @@ "\n", "annotator.on_event(notification)" ] + }, + { + "cell_type": "markdown", + "id": "aa195de2-eabd-4096-8aa6-6d7b3fcd787f", + "metadata": {}, + "source": [ + "# `on_commit` event\n", + "\n", + "Another event possible to listen to is when committing.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8fb68c16-7912-4374-b727-33272f5cd07a", + "metadata": {}, + "outputs": [], + "source": [ + "def notification(event):\n", + " pn.state.notifications.info(\"Committed to database 🎉\")\n", + "\n", + "annotator.on_commit(notification)" + ] } ], "metadata": { diff --git a/holonote/annotate/annotator.py b/holonote/annotate/annotator.py index 687fb4e..e37c0d5 100644 --- a/holonote/annotate/annotator.py +++ b/holonote/annotate/annotator.py @@ -59,6 +59,10 @@ class AnnotatorInterface(param.Parameterized): doc="Event that is triggered when an annotation is created, updated, or deleted" ) + commit_event = param.Event( + doc="Event that is triggered when an annotation is committed", + ) + def __init__(self, spec, **params): if "connector" not in params: params["connector"] = self.connector_class() @@ -277,6 +281,8 @@ def snapshot(self) -> None: def commit(self, return_commits=False): # self.annotation_table.initialize_table(self.connector) # Only if not in params commits = self.annotation_table.commits(self.connector) + if commits: + self.param.trigger("commit_event") if return_commits: return commits @@ -293,6 +299,18 @@ def on_event(self, callback) -> None: """ param.bind(callback, self.param.event, watch=True) + def on_commit(self, callback) -> None: + """Register a callback to be called when an annotation commit is triggered. + + This is a wrapper around param.bind with watch=True. + + Parameters + ---------- + callback : function + function to be called when an commit is triggered + """ + param.bind(callback, self.param.commit_event, watch=True) + class Annotator(AnnotatorInterface): """ diff --git a/holonote/app/__init__.py b/holonote/app/__init__.py index d99b502..b4a36e4 100644 --- a/holonote/app/__init__.py +++ b/holonote/app/__init__.py @@ -1,3 +1,4 @@ from .panel import PanelWidgets +from .tabulator import AnnotatorTable -__all__ = ("PanelWidgets",) +__all__ = ("PanelWidgets", "AnnotatorTable") diff --git a/holonote/app/tabulator.py b/holonote/app/tabulator.py new file mode 100644 index 0000000..668e1ac --- /dev/null +++ b/holonote/app/tabulator.py @@ -0,0 +1,108 @@ +from collections import defaultdict + +import numpy as np +import panel as pn +import param + +pn.extension( + "tabulator", + css_files=["https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css"], +) + + +class AnnotatorTable(pn.viewable.Viewer): + annotator = param.Parameter(allow_refs=False) + tabulator = param.Parameter(allow_refs=False) + dataframe = param.DataFrame() + + _updating = False + + def __init__(self, annotator, **params): + super().__init__(annotator=annotator, **params) + annotator.snapshot() + self._create_tabulator() + + def _create_tabulator(self): + def inner(event, annotator=self.annotator): + return annotator.df + + def on_edit(event): + row = self.tabulator.value.iloc[event.row] + + # Extracting specs and fields from row + spec_dct, field_dct = defaultdict(list), {} + for k, v in row.items(): + if "[" in k: + k = k.split("[")[1][:-1] # Getting the spec name + spec_dct[k].append(v) + else: + field_dct[k] = v + + self.annotator.annotation_table.update_annotation_region(spec_dct, row.name) + self.annotator.update_annotation_fields(row.name, **field_dct) + self.annotator.refresh(clear=True) + + # So it is still reactive, as editing overwrites the table + self.tabulator.value = pn.bind(inner, self.annotator) + + def on_click(event): + if event.column != "delete": + return + index = self.tabulator.value.iloc[event.row].name + self.annotator.delete_annotation(index) + + def new_style(row): + changed = [e["id"] for e in self.annotator.annotation_table._edits] + color = "darkgray" if row.name in changed else "inherit" + return [f"color: {color}"] * len(row) + + self.tabulator = pn.widgets.Tabulator( + value=pn.bind(inner, self.annotator), + buttons={"delete": ''}, + show_index=False, + selectable=True, + ) + self.tabulator.on_edit(on_edit) + self.tabulator.on_click(on_click) + self.tabulator.style.apply(new_style, axis=1) + + def on_commit(event): + self.tabulator.param.trigger("value") + # So it is still reactive, as triggering the value overwrites the table + self.tabulator.value = pn.bind(inner, self.annotator) + + self.annotator.on_commit(on_commit) + + @param.depends("tabulator.selection", watch=True) + def _select_table_to_plot(self): + if self._updating: + return + try: + self._updating = True + self.annotator.selected_indices = list( + self.tabulator.value.iloc[self.tabulator.selection].index + ) + except IndexError: + pass # when we delete we select and get an index error if it is the last + finally: + self._updating = False + + @param.depends("annotator.selected_indices", watch=True) + def _select_plot_to_table(self): + if self._updating: + return + try: + self._updating = True + # Likely better way to get this mapping + mask = self.tabulator.value.index.isin(self.annotator.selected_indices) + self.tabulator.selection = list(map(int, np.where(mask)[0])) + + finally: + self._updating = False + + def clear(self): + self.tabulator.selection = [] + self.tabulator.param.trigger("value") + + def __panel__(self): + return self.tabulator diff --git a/holonote/tests/test_annotators_advanced.py b/holonote/tests/test_annotators_advanced.py index 9f270e9..02733ea 100644 --- a/holonote/tests/test_annotators_advanced.py +++ b/holonote/tests/test_annotators_advanced.py @@ -288,17 +288,22 @@ def test_update_region(multiple_annotators, conn_sqlite_uuid) -> None: class TestEvent: @pytest.fixture(autouse=True) def _setup_count(self, multiple_annotators): - self.count = {"create": 0, "update": 0, "delete": 0} + self.count = {"create": 0, "update": 0, "delete": 0, "commit": 0} def count(event) -> None: self.count[event.type] += 1 + def commit_count(event) -> None: + self.count["commit"] += 1 + multiple_annotators.on_event(count) + multiple_annotators.on_commit(commit_count) - def check(self, create=0, update=0, delete=0): + def check(self, create=0, update=0, delete=0, commit=0): assert self.count["create"] == create assert self.count["update"] == update assert self.count["delete"] == delete + assert self.count["commit"] == commit def test_create(self, multiple_annotators): annotator = multiple_annotators @@ -307,6 +312,9 @@ def test_create(self, multiple_annotators): annotator.add_annotation(description="test") self.check(create=1, update=0, delete=0) annotator.commit() + self.check(create=1, update=0, delete=0, commit=1) + annotator.commit() # empty, no change + self.check(create=1, update=0, delete=0, commit=1) def test_update_fields(self, multiple_annotators): annotator = multiple_annotators diff --git a/holonote/tests/test_app.py b/holonote/tests/test_app.py index 7e93d12..297aa86 100644 --- a/holonote/tests/test_app.py +++ b/holonote/tests/test_app.py @@ -1,8 +1,10 @@ from __future__ import annotations +import numpy as np +import pandas as pd import panel as pn -from holonote.app import PanelWidgets +from holonote.app import AnnotatorTable, PanelWidgets def test_panel_app(annotator_range1d): @@ -18,3 +20,19 @@ def test_as_popup(annotator_range1d): assert display._edit_streams[0].popup assert display._tap_stream.popup assert w.__panel__().visible + + +def test_tabulator(annotator_range1d): + t = AnnotatorTable(annotator_range1d) + assert isinstance(t.tabulator, pn.widgets.Tabulator) + + annotator_range1d.set_regions(TIME=(np.datetime64("2022-06-06"), np.datetime64("2022-06-08"))) + annotator_range1d.add_annotation(description="A test annotation!") + assert len(t.tabulator.value) == 1 + assert t.tabulator.value.iloc[0, 0] == pd.Timestamp("2022-06-06") + assert t.tabulator.value.iloc[0, 1] == pd.Timestamp("2022-06-08") + assert t.tabulator.value.iloc[0, 2] == "A test annotation!" + assert "darkgray" in t.tabulator.style.to_html() + + annotator_range1d.commit(return_commits=True) + assert "darkgray" not in t.tabulator.style.to_html()