Skip to content

Commit

Permalink
Update visible UI from annotator events (#123)
Browse files Browse the repository at this point in the history
  • Loading branch information
droumis authored Aug 31, 2024
1 parent 57aa6b3 commit eb9dbf4
Show file tree
Hide file tree
Showing 5 changed files with 293 additions and 32 deletions.
6 changes: 3 additions & 3 deletions holonote/annotate/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,9 @@ def static_indicators(self, **events):
msg = f"{self.region_format} not implemented"
raise NotImplementedError(msg)

if len(indicator.data) == 0:
return hv.NdOverlay({0: self._make_empty_element()})

if self.annotator.groupby and self.annotator.visible:
indicator = indicator.get(self.annotator.visible)
if indicator is None:
Expand All @@ -549,9 +552,6 @@ def static_indicators(self, **events):
highlighters = {opt: self._selected_dim_expr(v[0], v[1]) for opt, v in highlight.items()}
indicator = indicator.opts(*self.style.indicator(**highlighters))

if len(indicator.data) == 0:
return hv.NdOverlay({0: self._make_empty_element()})

return indicator.overlay() if self.annotator.groupby else hv.NdOverlay({0: indicator})

def _selected_dim_expr(self, selected_value, non_selected_value) -> hv.dim:
Expand Down
130 changes: 103 additions & 27 deletions holonote/app/panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import datetime as dt
from typing import TYPE_CHECKING, Any

import holoviews as hv
import panel as pn
import param
from packaging.version import Version
Expand Down Expand Up @@ -55,6 +56,7 @@ def __init__(
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._create_visible_widget()
self.annotator.on_event(self._update_visible_widget)

self._set_standard_callbacks()

Expand Down Expand Up @@ -83,41 +85,115 @@ def _create_visible_widget(self):
if self.annotator.groupby is None:
self.visible_widget = None
return
style = self.annotator.style
if style.color is None and style._colormap is None:
data = sorted(self.annotator.df[self.annotator.groupby].unique())
colormap = dict(zip(data, _default_color))
else:
colormap = style._colormap
if isinstance(colormap, dict):
stylesheet = """
option:after {
content: "";
width: 10px;
height: 10px;
position: absolute;
border-radius: 50%;
left: calc(100% - var(--design-unit, 4) * 2px - 3px);
border: 1px solid black;
opacity: 0.5;
}"""
for i, color in enumerate(colormap.values()):
stylesheet += f"""
option:nth-child({i + 1}):after {{
background-color: {color};
}}"""
else:
stylesheet = ""

options = list(colormap)
style = self.annotator.style
self.colormap = {}
options = sorted(set(self.annotator.df[self.annotator.groupby].unique()))
# if all_options:
if style.color is None:
self.colormap = dict(zip(options, _default_color))
elif isinstance(style.color, str):
self.colormap = dict(zip(options, [style.color] * len(options)))
elif isinstance(style.color, hv.dim):
self.colormap = self.annotator.style.color.ops[0]["kwargs"]["categories"]
# assign default to any options whose color is unspecified by the user
for option in options:
if option not in self.colormap:
self.colormap[option] = self.annotator.style.color.ops[0]["kwargs"]["default"]

self._update_stylesheet()
self.visible_widget = pn.widgets.MultiSelect(
name="Visible",
options=options,
value=self.annotator.visible or options,
stylesheets=[stylesheet],
stylesheets=[self.stylesheet],
)
self.annotator.visible = self.visible_widget

def _update_stylesheet(self):
self.stylesheet = """
option {
position: relative;
padding-left: 20px;
}
option:after {
content: "";
width: 8px;
height: 8px;
position: absolute;
border-radius: 50%;
left: 5px;
top: 50%; /* Align vertically */
transform: translateY(-50%); /* Align vertically */
border: 1px solid black;
opacity: 0.60;
}
"""
for _, (option, color) in enumerate(sorted(self.colormap.items())):
self.stylesheet += f"""
option[value="{option}"]:after {{
background-color: {color};
}}"""

def _update_visible_widget(self, event):
style = self.annotator.style
old_options = list(self.visible_widget.options)
old_values = list(self.visible_widget.value)

if event.type == "create":
new_option = event.fields[self.annotator.groupby]
if new_option not in old_options:
self.visible_widget.param.update(
options=sorted([*old_options, new_option]), value=[*old_values, new_option]
)
if new_option not in self.colormap:
if style.color is None:
# For now, we need to update the colormap so that
# the new sorted order of the keys matches the order of the default_colors
new_options = sorted(self.annotator.df[self.annotator.groupby].unique())
self.colormap = dict(zip(new_options, _default_color))
elif isinstance(style.color, str):
self.colormap[new_option] = style.color
elif isinstance(style.color, hv.dim):
self.colormap[new_option] = style.color.ops[0]["kwargs"]["default"]
self._update_stylesheet()
self.visible_widget.stylesheets = [self.stylesheet]
return

if event.type == "delete":
new_options = sorted(self.annotator.df[self.annotator.groupby].unique())
# if color was not user-specified, remake colormap in case an anno type was dropped
if style.color is None:
self.colormap = dict(zip(new_options, _default_color))
self.visible_widget.options = list(new_options)
self._update_stylesheet()
self.visible_widget.stylesheets = [self.stylesheet]
return

if event.type == "update" and event.fields is not None:
new_option = event.fields[self.annotator.groupby]
new_options = sorted(self.annotator.df[self.annotator.groupby].unique())
self.visible_widget.options = new_options
# Make new vals visible, else inherit visible state
if new_option not in old_options:
self.visible_widget.value = [*old_values, new_option]
if new_option not in self.colormap:
if style.color is None:
# if the color was not user-specified, remake colormap for new anno type
self.colormap = dict(zip(new_options, _default_color))
elif isinstance(style.color, str):
self.colormap[new_option] = style.color
elif isinstance(style.color, hv.dim):
# if it's a new annot type but color dim had been specified by the user, it would already
# be in the colormap, so otherwise set the new anno type to the default color
self.colormap[new_option] = style.color.ops[0]["kwargs"]["default"]
self._update_stylesheet()
self.visible_widget.stylesheets = [self.stylesheet]

if event.type == "update" and event.region is not None:
return

def _add_button_description(self):
from bokeh.models import Tooltip
from bokeh.models.dom import HTML
Expand Down
8 changes: 6 additions & 2 deletions holonote/app/tabulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,12 @@ def on_edit(event):
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)
if "[" in event.column:
self.annotator.set_regions(**spec_dct)
self.annotator.update_annotation_region(row.name)
self.annotator.clear_regions()
else:
self.annotator.update_annotation_fields(row.name, **field_dct)
self.annotator.refresh(clear=True)

# So it is still reactive, as editing overwrites the table
Expand Down
14 changes: 14 additions & 0 deletions holonote/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,17 @@ def cat_annotator(conn_sqlite_uuid) -> Annotator:
# Setup display
annotator.get_display("x")
return annotator


@pytest.fixture
def cat_annotator_no_data(conn_sqlite_uuid) -> Annotator:
# Initialize annotator
annotator = Annotator(
{"x": float},
fields=["description", "category"],
connector=conn_sqlite_uuid,
groupby="category",
)
# Setup display
annotator.get_display("x")
return annotator
167 changes: 167 additions & 0 deletions holonote/tests/test_annotators_style.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from holonote.annotate import Style
from holonote.annotate.display import _default_color
from holonote.app.panel import PanelWidgets
from holonote.tests.util import get_editor, get_indicator


Expand Down Expand Up @@ -41,6 +42,172 @@ def get_selected_indicator_data(annotator) -> pd.Series:
return df["__selected__"]


def test_color_dim_defined_no_data(cat_annotator_no_data):
# testing colormap when color is defined by user's dim and there's no data at annotator instantiation
annotator = cat_annotator_no_data
color_dim = hv.dim("category").categorize(
categories={"A": "purple", "B": "orange", "C": "green"}, default="grey"
)
annotator.style.color = color_dim
panel_widgets = PanelWidgets(annotator)

visible_options = panel_widgets.visible_widget.options
colormap = panel_widgets.colormap

assert (
"A" not in visible_options
), "Color defined type 'A' should not yet be in visible options"
assert colormap["A"] == "purple", f"Expected color 'purple' for 'A', but got {colormap['A']}"


def test_color_undefined_resorting_no_data(cat_annotator_no_data):
# Testing the impact on the colormap that sorting imposes when a new annotation type is added or removed
annotator = cat_annotator_no_data
panel_widgets = PanelWidgets(annotator)

# Add a new annotation type 'A'
annotator.set_regions(x=(0, 1))
annotator.add_annotation(category="A")
annotator.commit()
compare_style(cat_annotator_no_data)
visible_options = panel_widgets.visible_widget.options
colormap = panel_widgets.colormap

assert "A" in visible_options, "New annotation type 'A' should be in visible options"
assert (
colormap["A"] == _default_color[0]
), f"Expected default color for 'A', but got {colormap['A']}"

# Add a new annotation type 'C'
annotator.set_regions(x=(0, 1))
annotator.add_annotation(category="C")
annotator.commit()
compare_style(annotator)
visible_options = panel_widgets.visible_widget.options
colormap = panel_widgets.colormap

assert "C" in visible_options, "New annotation type 'C' should be in visible options"
assert (
colormap["C"] == _default_color[1]
), f"Expected default color for 'C', but got {colormap['C']}"

# Add a new annotation type 'B' which resorts the order (A-B-C) of the options and therefore colormap

annotator.set_regions(x=(0, 1))
annotator.add_annotation(category="B")
annotator.commit()

compare_style(annotator)
visible_options = panel_widgets.visible_widget.options
colormap = panel_widgets.colormap

assert "B" in visible_options, "New annotation type 'B' should be in visible options"
assert (
colormap["B"] == _default_color[1]
), f"Expected default color for 'B', but got {colormap['B']}"
assert (
colormap["C"] == _default_color[2]
), f"Expected default color for 'C', but got {colormap['C']}"

# Remove the annotation type 'B', which again resorts the order of the options and assigned colormap
b_index = annotator.df[annotator.df["category"] == "B"].index[0]
annotator.delete_annotation(b_index)
annotator.commit()

compare_style(annotator)
visible_options = panel_widgets.visible_widget.options
colormap = panel_widgets.colormap

assert (
"B" not in visible_options
), "Removed annotation type 'B' should not be in visible options"
assert (
colormap["A"] == _default_color[0]
), f"Expected default color for 'A', but got {colormap['A']}"
assert (
colormap["C"] == _default_color[1]
), f"Expected default color for 'C', but got {colormap['C']}"


def test_colormap_persistence(cat_annotator_no_data):
# Testing persistence of colormap after removing and then remaking the last annotation of a type
annotator = cat_annotator_no_data
# defining data here in the test to impose single entry for 'C'
data = {
"category": ["A", "B", "A", "C", "B"],
"start_number": [1, 6, 11, 16, 21],
"end_number": [5, 10, 15, 20, 25],
"description": list("ABCDE"),
}
annotator.define_annotations(pd.DataFrame(data), x=("start_number", "end_number"))

color_dim = hv.dim("category").categorize(
categories={"A": "purple", "B": "orange", "C": "green"}, default="grey"
)
annotator.style.color = color_dim
panel_widgets = PanelWidgets(annotator)

compare_style(annotator)

# Delete annotation type 'C', which only has a single data entry
c_index = annotator.df[annotator.df["category"] == "C"].index[0]
annotator.delete_annotation(c_index)
annotator.commit()

compare_style(annotator)
visible_options = panel_widgets.visible_widget.options
colormap = panel_widgets.colormap

assert (
"C" not in visible_options
), "Removed annotation type 'C' should not be in visible options"

# Add annotation type 'C' again
annotator.set_regions(x=(0, 1))
annotator.add_annotation(category="C")
annotator.commit()

compare_style(annotator)
visible_options = panel_widgets.visible_widget.options
colormap = panel_widgets.colormap

assert colormap["C"] == "green", f"Expected color 'green' for 'C', but got {colormap['C']}"


def test_default_color_assignment(cat_annotator_no_data):
annotator = cat_annotator_no_data
# defining data here in the test to impose no entry for 'D'
data = {
"category": ["A", "B", "A", "C", "B"],
"start_number": [1, 6, 11, 16, 21],
"end_number": [5, 10, 15, 20, 25],
"description": list("ABCDE"),
}
annotator.define_annotations(pd.DataFrame(data), x=("start_number", "end_number"))

color_dim = hv.dim("category").categorize(
categories={"A": "purple", "B": "orange", "C": "green"}, default="grey"
)
annotator.style.color = color_dim
panel_widgets = PanelWidgets(annotator)

compare_style(annotator)

# Add a new annotation type 'D'
annotator.set_regions(x=(3, 4))
annotator.add_annotation(category="D")
annotator.commit()

compare_style(annotator)
visible_options = panel_widgets.visible_widget.options
colormap = panel_widgets.colormap

assert "D" in visible_options, "New annotation type 'D' should be in visible options"
assert (
colormap["D"] == "grey"
), f"Expected default color 'grey' for 'D', but got {colormap['D']}"


def test_style_accessor(cat_annotator) -> None:
assert isinstance(cat_annotator.style, Style)

Expand Down

0 comments on commit eb9dbf4

Please sign in to comment.