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

Update visible UI from annotator events #123

Merged
merged 14 commits into from
Aug 31, 2024
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
122 changes: 95 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,107 @@ 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"]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
self.colormap = self.annotator.style.color.ops[0]["kwargs"]["categories"]
self.colormap = dict(self.annotator.style.color.ops[0]["kwargs"]["categories"])

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively could you do something like:

colormap = dict(zip(options, style.color.apply(hv.Dataset(options, [self.annotator.groupby]))))

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think @hoxbro had an initial impression that this suggestion might be too heavy for this particular use case, but I'll let him clarify/expound in his review

# 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:after {
content: "";
width: 8px;
height: 8px;
position: absolute;
border-radius: 50%;
left: 1px;
border: 1px solid black;
opacity: 0.5;
}"""
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()
hoxbro marked this conversation as resolved.
Show resolved Hide resolved
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