Skip to content

Commit

Permalink
Add Tabulator app (#106)
Browse files Browse the repository at this point in the history
  • Loading branch information
hoxbro authored Jul 4, 2024
1 parent f779575 commit ceb60de
Show file tree
Hide file tree
Showing 7 changed files with 205 additions and 5 deletions.
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()

0 comments on commit ceb60de

Please sign in to comment.