Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Tabulator app #106

Merged
merged 14 commits into from
Jul 4, 2024
25 changes: 24 additions & 1 deletion examples/tutorial/04_Make_an_App.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
]
}
],
Expand Down
24 changes: 24 additions & 0 deletions examples/tutorial/05_Watch_Events.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
18 changes: 18 additions & 0 deletions holonote/annotate/annotator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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

Expand All @@ -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):
"""
Expand Down
3 changes: 2 additions & 1 deletion holonote/app/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .panel import PanelWidgets
from .tabulator import AnnotatorTable

__all__ = ("PanelWidgets",)
__all__ = ("PanelWidgets", "AnnotatorTable")
108 changes: 108 additions & 0 deletions holonote/app/tabulator.py
Original file line number Diff line number Diff line change
@@ -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": '<i class="fa fa-trash"></i>'},
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
12 changes: 10 additions & 2 deletions holonote/tests/test_annotators_advanced.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
20 changes: 19 additions & 1 deletion holonote/tests/test_app.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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()