From 05b3880718bade0022b4bd9611a74d7b5ab17e5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20H=C3=B8xbro=20Hansen?= Date: Fri, 10 Nov 2023 16:31:27 +0100 Subject: [PATCH] Styling (#51) --- examples/Basics.ipynb | 154 ------- examples/Basics_2D.ipynb | 154 ------- examples/styling.ipynb | 266 +++++++++++ holonote/__init__.py | 6 +- holonote/annotate/__init__.py | 24 +- holonote/annotate/annotator.py | 453 +----------------- holonote/annotate/display.py | 534 ++++++++++++++++++++++ holonote/app/__init__.py | 4 +- holonote/editor/__init__.py | 4 +- holonote/tests/conftest.py | 22 + holonote/tests/test_annotators_element.py | 118 ++--- holonote/tests/test_annotators_style.py | 129 ++++++ holonote/tests/util.py | 24 + pyproject.toml | 2 +- 14 files changed, 1079 insertions(+), 815 deletions(-) create mode 100644 examples/styling.ipynb create mode 100644 holonote/annotate/display.py create mode 100644 holonote/tests/test_annotators_style.py create mode 100644 holonote/tests/util.py diff --git a/examples/Basics.ipynb b/examples/Basics.ipynb index bca5be8..05a135c 100644 --- a/examples/Basics.ipynb +++ b/examples/Basics.ipynb @@ -525,160 +525,6 @@ "source": [ "annotator.commit()" ] - }, - { - "cell_type": "markdown", - "id": "57962a14-32be-4239-9ad3-f9eee5d18158", - "metadata": {}, - "source": [ - "## 🚧 Selecting and highlighting annotations\n", - "\n", - "Earlier we styled the indicators with `color='red', alpha=0.2`. To highlight a select a specific indicator, we can create a dimension expression to assign selected and non-selected indicators different values. Here we have a highlighter that uses a value of `0.6` for selected indicators and `0.1` for non-selected indicators. We can then apply these values to the `alpha` option:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9f937116-bf9c-439d-8288-bcb20ef7d044", - "metadata": {}, - "outputs": [], - "source": [ - "highlighter = annotator.selected_dim_expr(0.6, 0.1)\n", - "annotator.element * annotator.indicators().opts(color='red', alpha=highlighter) * annotator.region_editor()" - ] - }, - { - "cell_type": "markdown", - "id": "22ee298a-160d-4aeb-8988-658d6e09d04d", - "metadata": {}, - "source": [ - "Now you can use the `Tap` tool to directly select indicators.\n", - "\n", - "You can now delesect by collecting `select_by_index()` without any arguments:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c9f01e83-5eea-4b75-8164-9b851053b0c7", - "metadata": {}, - "outputs": [], - "source": [ - "annotator.select_by_index()" - ] - }, - { - "cell_type": "markdown", - "id": "526a45ef-31c6-475c-95e8-82ec73318bc4", - "metadata": {}, - "source": [ - "And we can select one or more indicators by number:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "346679b5-88d6-42fd-a0b6-a16875a3aa71", - "metadata": {}, - "outputs": [], - "source": [ - "annotator.df" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "842a8345-e3eb-43e5-bc0c-c8da5a1d61a8", - "metadata": {}, - "outputs": [], - "source": [ - "annotator.select_by_index(annotator.df.index[0])" - ] - }, - { - "cell_type": "markdown", - "id": "e96a9082-ffdf-4eb6-a259-54d04c62dd06", - "metadata": {}, - "source": [ - "You can access the selected indicators using the `selected_indices` parameter (watchable):" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a96009e8-197a-48fd-bca5-e455627de0f1", - "metadata": {}, - "outputs": [], - "source": [ - "annotator.selected_indices # parameter" - ] - }, - { - "cell_type": "markdown", - "id": "7987aa0d-d804-44bd-9d96-1cc96b741006", - "metadata": {}, - "source": [ - "This demonstrates the basics without storing additional metadata, using the undo system or persisting any data.\n", - "\n", - "Note that everything shown so far can be achieved with simple Python API and a single line of display code:\n", - "\n", - "```\n", - "annotator.element * annotator.indicators().opts(color='red', alpha=annotator.selected_dim_expr(0.6, 0.1)) * annotator.editable()\n", - "```\n" - ] - }, - { - "cell_type": "markdown", - "id": "16134a64-1347-4ebe-909a-716f55c520ac", - "metadata": {}, - "source": [ - "## 🚧 Using `.overlay`\n", - "\n", - "The `.overlay` method is a shortcut to building the following overlay:\n", - "\n", - "```\n", - "annotator.element * annotator.indicators() * annotator.region_editor()\n", - "```\n", - "\n", - "Where each layer can be enabled/disabled and styled individually: the above is equivalent to `annotator.overlay(element=True, indicators=True, editor=True)`.\n", - "\n", - "\n", - "Note, if either building the overlay yourself or using `.overlay` with `element=False` you can use `annotator.element` *or* you can use the original element after adding the necessary tools with ```speed_curve.opts(tools=annotator.edit_tools)```.\n", - "\n", - "### Styling the annotator\n", - "\n", - "You can set the style either through the `_style` keywords in `.overlay`:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a30ffca4-92ed-4bde-87fe-82c2485d7d2b", - "metadata": {}, - "outputs": [], - "source": [ - "annotator.overlay(range_style={'color': 'green', 'alpha': 0.4}, \n", - " edit_range_style={'alpha': 0.4, 'line_alpha': 1, 'color':'yellow'})" - ] - }, - { - "cell_type": "markdown", - "id": "0cde2049-de01-4615-99e3-3175806615cc", - "metadata": {}, - "source": [ - "Or once at the class-level as follows:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "53cddcd9-897a-4ef7-bb1a-1e9dea2cdaee", - "metadata": {}, - "outputs": [], - "source": [ - "annotator.indicator.range_style['color']= 'green'\n", - "annotator.indicator.edit_range_style['color'] = 'yellow'" - ] } ], "metadata": { diff --git a/examples/Basics_2D.ipynb b/examples/Basics_2D.ipynb index 2baed2e..c5192a3 100644 --- a/examples/Basics_2D.ipynb +++ b/examples/Basics_2D.ipynb @@ -521,160 +521,6 @@ "source": [ "annotator.commit()" ] - }, - { - "cell_type": "markdown", - "id": "57962a14-32be-4239-9ad3-f9eee5d18158", - "metadata": {}, - "source": [ - "## 🚧 Selecting and highlighting annotations\n", - "\n", - "Earlier we styled the indicators with `color='red', alpha=0.2`. To highlight a select a specific indicator, we can create a dimension expression to assign selected and non-selected indicators different values. Here we have a highlighter that uses a value of `0.6` for selected indicators and `0.1` for non-selected indicators. We can then apply these values to the `alpha` option:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9f937116-bf9c-439d-8288-bcb20ef7d044", - "metadata": {}, - "outputs": [], - "source": [ - "highlighter = annotator.selected_dim_expr(0.6, 0.1)\n", - "annotator.element * annotator.indicators().opts(color='red', alpha=highlighter) * annotator.region_editor()" - ] - }, - { - "cell_type": "markdown", - "id": "22ee298a-160d-4aeb-8988-658d6e09d04d", - "metadata": {}, - "source": [ - "Now you can use the `Tap` tool to directly select indicators.\n", - "\n", - "You can now delesect by collecting `select_by_index()` without any arguments:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c9f01e83-5eea-4b75-8164-9b851053b0c7", - "metadata": {}, - "outputs": [], - "source": [ - "annotator.select_by_index()" - ] - }, - { - "cell_type": "markdown", - "id": "526a45ef-31c6-475c-95e8-82ec73318bc4", - "metadata": {}, - "source": [ - "And we can select one or more indicators by index value:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "346679b5-88d6-42fd-a0b6-a16875a3aa71", - "metadata": {}, - "outputs": [], - "source": [ - "annotator.df" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "842a8345-e3eb-43e5-bc0c-c8da5a1d61a8", - "metadata": {}, - "outputs": [], - "source": [ - "annotator.select_by_index(annotator.df.index[0])" - ] - }, - { - "cell_type": "markdown", - "id": "e96a9082-ffdf-4eb6-a259-54d04c62dd06", - "metadata": {}, - "source": [ - "You can access the selected indicators using the `selected_indices` parameter (watchable):" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a96009e8-197a-48fd-bca5-e455627de0f1", - "metadata": {}, - "outputs": [], - "source": [ - "annotator.selected_indices # parameter" - ] - }, - { - "cell_type": "markdown", - "id": "7987aa0d-d804-44bd-9d96-1cc96b741006", - "metadata": {}, - "source": [ - "This demonstrates the basics without storing additional metadata, using the undo system or persisting any data.\n", - "\n", - "Note that everything shown so far can be achieved with simple Python API and a single line of display code:\n", - "\n", - "```\n", - "annotator.element * annotator.indicators().opts(color='red', alpha=annotator.selected_dim_expr(0.6, 0.1)) * annotator.editable()\n", - "```\n" - ] - }, - { - "cell_type": "markdown", - "id": "16134a64-1347-4ebe-909a-716f55c520ac", - "metadata": {}, - "source": [ - "## 🚧 Using `.overlay`\n", - "\n", - "The `.overlay` method is a shortcut to building the following overlay:\n", - "\n", - "```\n", - "annotator.element * annotator.indicators() * annotator.region_editor()\n", - "```\n", - "\n", - "Where each layer can be enabled/disabled and styled individually: the above is equivalent to `annotator.overlay(element=True, indicators=True, editor=True)`.\n", - "\n", - "\n", - "Note, if either building the overlay yourself or using `.overlay` with `element=False` you can use `annotator.element` *or* you can use the original element after adding the necessary tools with ```speed_curve.opts(tools=annotator.edit_tools)```.\n", - "\n", - "### Styling the annotator\n", - "\n", - "You can set the style either through the `_style` keywords in `.overlay`:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a30ffca4-92ed-4bde-87fe-82c2485d7d2b", - "metadata": {}, - "outputs": [], - "source": [ - "annotator.overlay(range_style={'color': 'yellow', 'alpha': 0.4}, \n", - " edit_range_style={'alpha': 0.4, 'line_alpha': 1, 'color':'blue'})" - ] - }, - { - "cell_type": "markdown", - "id": "0cde2049-de01-4615-99e3-3175806615cc", - "metadata": {}, - "source": [ - "Or once at the class-level as follows:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "53cddcd9-897a-4ef7-bb1a-1e9dea2cdaee", - "metadata": {}, - "outputs": [], - "source": [ - "annotator.indicator.range_style['color']= 'green'\n", - "annotator.indicator.edit_range_style['color'] = 'yellow'" - ] } ], "metadata": { diff --git a/examples/styling.ipynb b/examples/styling.ipynb new file mode 100644 index 0000000..cee6dfa --- /dev/null +++ b/examples/styling.ipynb @@ -0,0 +1,266 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ed1bb358-ea08-4125-97d2-7aa49ef7dcbd", + "metadata": {}, + "source": [ + "# Styling of annotations\n", + "\n", + "
\n", + "What you see in this notebook will depend on whether you've run this notebook before and written annotations to the annotations.db database! For reproducibility, the rest of the notebook will assume the annotations.db has been deleted (if it exists).\n", + "
\n", + "\n", + "## Setup\n", + "The first thing we need to do is get and plot the data we want to annotate. This is done by using [pandas](https://pandas.pydata.org/) and [hvplot](https://hvplot.holoviz.org/). We'll use the same data as in the [basics](Basics.ipynb) notebook.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8cc9c2a3", + "metadata": {}, + "outputs": [], + "source": [ + "import hvplot.pandas\n", + "import pandas as pd\n", + "\n", + "speed_data = pd.read_parquet(\"./assets/example.parquet\")\n", + "curve = speed_data.hvplot(\"TIME\", \"SPEED\")" + ] + }, + { + "cell_type": "markdown", + "id": "d5c3264e-526d-4687-bec1-619cfea8793d", + "metadata": {}, + "source": [ + "The next step is to create an annotator and add some annotations to it with `define_annotations`. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9d011ecd-2b05-4735-abad-909c8df95c29", + "metadata": {}, + "outputs": [], + "source": [ + "from holonote.annotate import Annotator, SQLiteDB\n", + "\n", + "annotator = Annotator(\n", + " curve,\n", + " fields=[\"category\"],\n", + " connector=SQLiteDB(table_name=\"styling\"),\n", + ")\n", + "\n", + "start_time = pd.date_range(\"2022-06-04\", \"2022-06-22\", periods=5)\n", + "end_time = start_time + pd.Timedelta(days=2)\n", + "data = {\n", + " \"start_time\": start_time,\n", + " \"end_time\": end_time,\n", + " \"category\": [\"A\", \"B\", \"A\", \"C\", \"B\"],\n", + "}\n", + "annotator.define_annotations(pd.DataFrame(data), TIME=(\"start_time\", \"end_time\"))\n", + "annotator.df" + ] + }, + { + "cell_type": "markdown", + "id": "0fa81a9c-c1b1-4cbf-a29d-d4546dd335d9", + "metadata": {}, + "source": [ + "To apply the annotation over the curve, you simply multiply the curve by the annotation. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ffdea745-31ef-4c16-9dfe-4303e78067a2", + "metadata": {}, + "outputs": [], + "source": [ + "annotator * curve" + ] + }, + { + "cell_type": "markdown", + "id": "0c30c039-9c47-4cef-b18d-68c14b6a98cd", + "metadata": {}, + "source": [ + "## Style accessor\n", + "\n", + "Styling in this context is achieved through the `style` accessor of annotators, accessed using `annotator.style`. This accessor provides various methods for customizing the appearance of annotations, with two key methods being `color` and `alpha`. We will discuss these in more detail shortly.\n", + "\n", + "There are three distinct style categories: `indicator`, `selected`, and `editor.` The `indicator` style is the default appearance, and in the example given above, it's represented by the color red. The `selected` style is applied when an annotation is actively selected, as indicated by the highlighting effect when you click on an annotation. Lastly, the `editor` style is used when an annotation is being edited, and this is evident when you click hold and drag the mouse anywhere on the plot, resulting in a blue region.\n", + "\n", + "To modify the `color` and `alpha` properties of these three style categories, you can easily do so using the `color` and `alpha` methods provided by the style accessor. For the `selected` style, you can use the `selection_color` and `selection_alpha` settings, and for the `editor` style, the corresponding properties are `edit_color` and `edit_alpha`.\n", + "\n", + "When a style changes, the plot is automatically updated, so you can easily experiment with different styles and see the results immediately. \n", + "\n", + "Let's see how this works in practice. Let's try updating the `color` and `alpha` properties of the `indicator` style." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1a2c7721-7af9-4193-ac1e-c5d9f1aa9bc7", + "metadata": {}, + "outputs": [], + "source": [ + "annotator.style.alpha = 0.5\n", + "annotator.style.color = \"green\"" + ] + }, + { + "cell_type": "markdown", + "id": "b213477e-0e5a-4aaf-8e5c-eac7c0ddd815", + "metadata": {}, + "source": [ + "If we want to update the `selected` style, we can use the `selection_color` and `selection_alpha` methods. With these changes, any selected annotation will change its color to blue and remove its transparency." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6a83a9a5-b467-4f5d-8de2-5f23f472b5ee", + "metadata": {}, + "outputs": [], + "source": [ + "annotator.style.selection_alpha = 1\n", + "annotator.style.selection_color = \"blue\"" + ] + }, + { + "cell_type": "markdown", + "id": "29748a01-98c4-40f9-8a15-70e7b2cc1163", + "metadata": {}, + "source": [ + "Lastly, we can change the `editor` style. Note this will first show up on the following editable annotation you create." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b983e43a-a6dc-4826-9842-fcb00baa174b", + "metadata": {}, + "outputs": [], + "source": [ + "annotator.style.edit_alpha = 0.2\n", + "annotator.style.edit_color = \"yellow\"" + ] + }, + { + "cell_type": "markdown", + "id": "9a0d6387-42cd-42bf-bb2c-60de5f30f1d8", + "metadata": {}, + "source": [ + "If you want to reset the style to the default, you can use the `reset` method." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "060bc058-9780-4c15-8abc-b3646172db3c", + "metadata": {}, + "outputs": [], + "source": [ + "annotator.style.reset()" + ] + }, + { + "cell_type": "markdown", + "id": "1ee84913-4308-45ea-ab9c-795c1686b319", + "metadata": {}, + "source": [ + "## More granular styling\n", + "\n", + "If more granular style customization is needed than `color` and `alpha`, you can change the `annotator.style.opts` for the indicator and selected styles and `edit_opts` for the editor style. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6f232b1c-52da-4b35-974a-32e74f3c115f", + "metadata": {}, + "outputs": [], + "source": [ + "annotator.style.edit_opts = {\"line_width\": 10}" + ] + }, + { + "cell_type": "markdown", + "id": "3672a818-7c24-4f0b-838e-10620c2d3b37", + "metadata": {}, + "source": [ + "Lastly, you can use [dim transforms](https://holoviews.org/user_guide/Style_Mapping.html#what-are-dim-transforms) to make more advanced customization. An example of a dim transforms could be coloring based on the category. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e0838752-cdf9-469b-af8b-39271d0ab521", + "metadata": {}, + "outputs": [], + "source": [ + "import holoviews as hv\n", + "\n", + "color_dim = hv.dim(\"category\").categorize(\n", + " categories={\"A\": \"blue\", \"B\": \"red\", \"C\": \"green\"}, default=\"grey\"\n", + ")\n", + "\n", + "annotator.style.color = color_dim" + ] + }, + { + "cell_type": "markdown", + "id": "37ecf1a8-9a99-4c7a-86fe-fe688f3b6e8d", + "metadata": {}, + "source": [ + "## Groupby a field\n", + "Suppose one of the fields in the annotator contains categorial information and has a limited set of options. In that case, a way to group the annotations by this field is to use the `groupby` Parameter to group by that field and the `visible` Parameter to show one of the categories. Let's group by the `category` field and show the `A` category. For convenience, we show the annotated plot again below." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b6902e9d-1ac1-48ab-87af-0af1c5cee7f6", + "metadata": {}, + "outputs": [], + "source": [ + "annotator.groupby = \"category\"\n", + "annotator.visible = [\"B\"]\n", + "annotator * curve" + ] + }, + { + "cell_type": "markdown", + "id": "03a1635d", + "metadata": {}, + "source": [ + "To get more interactivity a [Panel](https://panel.holoviz.org/) widget can be used to change the `visible` Parameter." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b0311e2a-6d09-40fa-9b16-fbf795cee00e", + "metadata": {}, + "outputs": [], + "source": [ + "import panel as pn\n", + "\n", + "widget = pn.widgets.MultiSelect(value=[\"A\"], options=[\"A\", \"B\", \"C\"])\n", + "annotator.visible = widget\n", + "\n", + "widget" + ] + } + ], + "metadata": { + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/holonote/__init__.py b/holonote/__init__.py index 63d3914..b9c5cb3 100644 --- a/holonote/__init__.py +++ b/holonote/__init__.py @@ -1,8 +1,6 @@ -from __future__ import annotations - import os -from . import annotate, editor # noqa: F401 +from . import annotate, editor # Define '__version__' try: @@ -24,3 +22,5 @@ # The user is probably trying to run this without having installed # the package. __version__ = "0.0.0+unknown" + +__all__ = ("__version__", "annotate", "editor") diff --git a/holonote/annotate/__init__.py b/holonote/annotate/__init__.py index cc70fbf..8848e23 100644 --- a/holonote/annotate/__init__.py +++ b/holonote/annotate/__init__.py @@ -1,17 +1,21 @@ -from __future__ import annotations - -import warnings - -from .annotator import Annotator # noqa: F401 -from .connector import ( # noqa: F401 +from .annotator import Annotator +from .connector import ( AutoIncrementKey, Connector, SQLiteDB, UUIDBinaryKey, UUIDHexStringKey, ) -from .table import * +from .display import Style +from .table import AnnotationTable -# Ignore Bokeh UserWarning about multiple competiting tools introduced in 3.2.2 -warnings.filterwarnings("ignore", category=UserWarning, module="holoviews.plotting.bokeh.plot") -del warnings +__all__ = ( + "AnnotationTable", + "Annotator", + "AutoIncrementKey", + "Connector", + "SQLiteDB", + "Style", + "UUIDBinaryKey", + "UUIDHexStringKey", +) diff --git a/holonote/annotate/annotator.py b/holonote/annotate/annotator.py index 9e0d092..02bc230 100644 --- a/holonote/annotate/annotator.py +++ b/holonote/annotate/annotator.py @@ -1,109 +1,20 @@ from __future__ import annotations -import weakref from typing import TYPE_CHECKING, Any import holoviews as hv import pandas as pd import param -from bokeh.models.tools import BoxSelectTool, HoverTool, Tool from .._warnings import warn from .connector import Connector, SQLiteDB +from .display import AnnotationDisplay, Indicator, Style # noqa: F401 from .table import AnnotationTable if TYPE_CHECKING: from .typing import SpecDict -class Indicator: - """ - Collection of class methods that express annotation data as final - displayed (vectorized) HoloViews object. - """ - - range_style = {"color": "red", "alpha": 0.4, "apply_ranges": False} - point_style = {"color": "red", "alpha": 0.4, "apply_ranges": False} - indicator_highlight = {"alpha": (0.7, 0.2)} - - edit_range_style = {"alpha": 0.4, "line_alpha": 1, "line_width": 1, "line_color": "black"} - edit_point_style = {"alpha": 0.4, "line_alpha": 1, "line_color": "black"} - - @classmethod - def indicator_style(cls, range_style, point_style, highlighters): - return ( - hv.opts.Rectangles(**dict(range_style, **highlighters)), - hv.opts.VSpans(**dict(range_style, **highlighters)), - hv.opts.HSpans(**dict(range_style, **highlighters)), - hv.opts.VLines(**dict(range_style, **highlighters)), - hv.opts.HLines(**dict(range_style, **highlighters)), - ) - - @classmethod - def region_style(cls, edit_range_style, edit_point_style): - return ( - hv.opts.Rectangles(**edit_range_style), - hv.opts.VSpans(**edit_range_style), - hv.opts.HSpans(**edit_range_style), - hv.opts.VLines(**edit_range_style), - hv.opts.HLines(**edit_range_style), - ) - - @classmethod - def points_1d(cls, data, region_labels, fields_labels, invert_axes=False): - "Vectorizes point regions to VLines. Note does not support hover info" - vdims = [*fields_labels, data.index.name] - element = hv.VLines(data.reset_index(), kdims=region_labels, vdims=vdims) - hover = cls._build_hover_tool(data) - return element.opts(tools=[hover]) - - @classmethod - def points_2d(cls, data, region_labels, fields_labels, invert_axes=False): - "Vectorizes point regions to VLines * HLines. Note does not support hover info" - msg = "2D point regions not supported yet" - raise NotImplementedError(msg) - vdims = [*fields_labels, data.index.name] - element = hv.Points(data.reset_index(), kdims=region_labels, vdims=vdims) - hover = cls._build_hover_tool(data) - return element.opts(tools=[hover]) - - @classmethod - def ranges_2d(cls, data, region_labels, fields_labels, invert_axes=False): - "Vectorizes an nd-overlay of range_2d rectangles." - kdims = [region_labels[i] for i in (0, 2, 1, 3)] # LBRT format - vdims = [*fields_labels, data.index.name] - element = hv.Rectangles(data.reset_index(), kdims=kdims, vdims=vdims) - cds_map = dict(zip(region_labels, ("left", "right", "bottom", "top"))) - hover = cls._build_hover_tool(data, cds_map) - return element.opts(tools=[hover]) - - @classmethod - def ranges_1d(cls, data, region_labels, fields_labels, invert_axes=False): - """ - Vectorizes an nd-overlay of range_1d rectangles. - - NOTE: Should use VSpans once available! - """ - vdims = [*fields_labels, data.index.name] - element = hv.VSpans(data.reset_index(), kdims=region_labels, vdims=vdims) - hover = cls._build_hover_tool(data) - return element.opts(tools=[hover]) - - @classmethod - def _build_hover_tool(self, data, cds_map=None) -> HoverTool: - if cds_map is None: - cds_map = {} - tooltips, formatters = [], {} - for dim in data.columns: - cds_name = cds_map.get(dim, dim) - if data[dim].dtype.kind == "M": - tooltips.append((dim, f"@{{{cds_name}}}{{%F}}")) - formatters[f"@{{{cds_name}}}"] = "datetime" - else: - tooltips.append((dim, f"@{{{cds_name}}}")) - return HoverTool(tooltips=tooltips, formatters=formatters) - - class AnnotatorInterface(param.Parameterized): """ Baseclass that expresses the Python interface of an Annotator @@ -336,354 +247,6 @@ def commit(self, return_commits=False): return commits -class AnnotationDisplay(param.Parameterized): - kdims = param.List( - default=["x"], bounds=(1, 3), constant=True, doc="Dimensions of the element" - ) - - indicator = Indicator - - _count = param.Integer(default=0, precedence=-1) - - def __init__(self, annotator: Annotator, **params) -> None: - super().__init__(**params) - - self._annotation_count_stream = hv.streams.Params( - parameterized=self, - parameters=["_count"], - transient=True, - ) - - self._selection_info = {} - - self._selection_enabled = True - self._editable_enabled = True - self._selected_values = [] - self._selected_options = [] - - transient = False - self._edit_streams = [ - hv.streams.BoundsXY(transient=transient), - hv.streams.SingleTap(transient=transient), - hv.streams.Lasso(transient=transient), - ] - - self.annotator = weakref.proxy(annotator) - self._set_region_types() - self._element = self._make_empty_element() - - def _set_region_types(self) -> None: - self.region_types = "-".join([self.annotator.spec[k]["region"] for k in self.kdims]) - - @property - def element(self): - return self.overlay() - - @property - def edit_tools(self) -> list[Tool]: - tools = [] - if self.region_types == "range": - tools.append(BoxSelectTool(dimensions="width")) - elif self.region_types == "range-range": - tools.append(BoxSelectTool()) - elif self.region_types == "point": - tools.append(BoxSelectTool(dimensions="width")) - elif self.region_types == "point-point": - tools.append("tap") - return tools - - @classmethod - def _infer_kdim_dtypes(cls, element): - if not isinstance(element, hv.Element): - msg = "Supplied object {element} is not a bare HoloViews Element" - raise ValueError(msg) - kdim_dtypes = {} - for kdim in element.dimensions(selection="key"): - kdim_dtypes[str(kdim)] = type(element.dimension_values(kdim)[0]) - return kdim_dtypes - - def clear_indicated_region(self): - "Clear any region currently indicated on the plot by the editor" - self._edit_streams[0].event(bounds=None) - self._edit_streams[1].event(x=None, y=None) - self._edit_streams[2].event(geometry=None) - self.annotator.clear_regions() - - def _make_empty_element(self) -> hv.Curve | hv.Image: - El = hv.Curve if len(self.kdims) == 1 else hv.Image - return El([], kdims=self.kdims).opts(apply_ranges=False) - - @property - def selection_element(self) -> hv.Element: - if not hasattr(self, "_selection_element"): - self._selection_element = self._make_empty_element() - return self._selection_element - - @property - def selection_enabled(self) -> bool: - return self._selection_enabled - - @selection_enabled.setter - def selection_enabled(self, enabled: bool) -> None: - self._selection_enabled = enabled - - @property - def editable_enabled(self) -> bool: - return self._editable_enabled - - @editable_enabled.setter - def editable_enabled(self, enabled: bool) -> None: - self._editable_enabled = enabled - if not enabled: - self.clear_indicated_region() - - def _filter_stream_values(self, bounds, x, y, geometry): - if not self._editable_enabled: - return (None, None, None, None) - if self.region_types == "point" and bounds: - x = (bounds[0] + bounds[2]) / 2 - y = None - bounds = (x, 0, x, 0) - elif "range" not in self.region_types: - bounds = None - - # If selection enabled, tap stream used for selection not for creating point regions - # if ('point' in self.region_types and self.selection_enabled) or 'point' not in self.region_types: - if "point" not in self.region_types: - x, y = None, None - - return bounds, x, y, geometry - - def _make_selection_editor(self) -> hv.DynamicMap: - def inner(bounds, x, y, geometry): - bounds, x, y, geometry = self._filter_stream_values(bounds, x, y, geometry) - - info = self.selection_element._get_selection_expr_for_stream_value( - bounds=bounds, x=x, y=y, geometry=geometry - ) - (dim_expr, bbox, region_element) = info - - self._selection_info = { - "dim_expr": dim_expr, - "bbox": bbox, - "x": x, - "y": y, - "geometry": geometry, - "region_element": region_element, - } - - if bbox is not None: - # self.annotator.set_regions will give recursion error - self.annotator._set_regions(**bbox) - - kdims = list(self.kdims) - if self.region_types == "point" and x is not None: - self.annotator._set_regions(**{kdims[0]: x}) - if None not in [x, y]: - if len(kdims) == 1: - self.annotator._set_regions(**{kdims[0]: x}) - elif len(kdims) == 2: - self.annotator._set_regions(**{kdims[0]: x, kdims[1]: y}) - else: - msg = "Only 1d and 2d supported for Points" - raise ValueError(msg) - - return region_element - - return hv.DynamicMap(inner, streams=self._edit_streams) - - def region_editor(self) -> hv.DynamicMap: - if not hasattr(self, "_region_editor"): - self._region_editor = self._make_selection_editor() - return self._region_editor - - def _get_range_indices_by_position(self, **inputs) -> list[Any]: - df = self.static_indicators.data - if df.empty: - return [] - - # Because we reset_index in Indicators - id_col = df.columns[0] - - for i, (k, v) in enumerate(inputs.items()): - mask = (df[f"start[{k}]"] <= v) & (v < df[f"end[{k}]"]) - if i == 0: - ids = set(df[mask][id_col]) - else: - ids &= set(df[mask][id_col]) - return list(ids) - - def _get_point_indices_by_position(self, **inputs) -> list[Any]: - """ - Simple algorithm for finding the closest point - annotation to the given position. - """ - - df = self.static_indicators.data - if df.empty: - return [] - - # Because we reset_index in Indicators - id_col = df.columns[0] - - for i, (k, v) in enumerate(inputs.items()): - nearest = (df[f"point[{k}]"] - v).abs().argmin() - if i == 0: - ids = {df.iloc[nearest][id_col]} - else: - ids &= {df.iloc[nearest][id_col]} - return list(ids) - - def get_indices_by_position(self, **inputs) -> list[Any]: - "Return primary key values matching given position in data space" - if "range" in self.region_types: - return self._get_range_indices_by_position(**inputs) - elif "point" in self.region_types: - return self._get_point_indices_by_position(**inputs) - else: - msg = f"{self.region_types} not implemented" - raise NotImplementedError(msg) - - def register_tap_selector(self, element: hv.Element) -> hv.Element: - def tap_selector(x, y) -> None: # Tap tool must be enabled on the element - # Only select the first - inputs = {str(k): v for k, v in zip(self.kdims, (x, y))} - indices = self.get_indices_by_position(**inputs) - if indices: - self.annotator.select_by_index(indices[0]) - else: - self.annotator.select_by_index() - - tap_stream = hv.streams.Tap(source=element, transient=True) - tap_stream.add_subscriber(tap_selector) - return element - - def register_double_tap_clear(self, element: hv.Element) -> hv.Element: - def double_tap_clear(x, y): - self.clear_indicated_region() - - double_tap_stream = hv.streams.DoubleTap(source=element, transient=True) - double_tap_stream.add_subscriber(double_tap_clear) - return element - - def indicators(self) -> hv.DynamicMap: - self.register_tap_selector(self._element) - self.register_double_tap_clear(self._element) - - def inner(_count): - return self.static_indicators - - return hv.DynamicMap(inner, streams=[self._annotation_count_stream]) - - def overlay( - self, - indicators=True, - editor=True, - range_style=None, - point_style=None, - edit_range_style=None, - edit_point_style=None, - highlight=None, - ) -> hv.Overlay: - if range_style is None: - range_style = Indicator.range_style - if point_style is None: - point_style = Indicator.point_style - if edit_range_style is None: - edit_range_style = Indicator.edit_range_style - if edit_point_style is None: - edit_point_style = Indicator.edit_point_style - if highlight is None: - highlight = Indicator.indicator_highlight - - highlighters = {opt: self.selected_dim_expr(v[0], v[1]) for opt, v in highlight.items()} - indicator_style = Indicator.indicator_style(range_style, point_style, highlighters) - region_style = Indicator.region_style(edit_range_style, edit_point_style) - - layers = [] - active_tools = [] - if "range" in self.region_types or self.region_types == "point": - active_tools += ["box_select"] - elif self.region_types == "point-point": - active_tools += ["tap"] - layers.append(self._element.opts(tools=self.edit_tools, active_tools=active_tools)) - - if indicators: - layers.append(self.indicators().opts(*indicator_style)) - if editor: - layers.append(self.region_editor().opts(*region_style)) - return hv.Overlay(layers).collate() - - @property - def static_indicators(self): - data = self.annotator.get_dataframe(dims=self.kdims) - fields_labels = self.annotator.all_fields - region_labels = [k for k in data.columns if k not in fields_labels] - - indicator_kwargs = { - "data": data, - "region_labels": region_labels, - "fields_labels": fields_labels, - "invert_axes": False, # Not yet handled - } - - if self.region_types == "range": - indicator = Indicator.ranges_1d(**indicator_kwargs) - elif self.region_types == "range-range": - indicator = Indicator.ranges_2d(**indicator_kwargs) - elif self.region_types == "point": - indicator = Indicator.points_1d(**indicator_kwargs) - elif self.region_types == "point-point": - indicator = Indicator.points_2d(**indicator_kwargs) - else: - msg = f"{self.region_types} not implemented" - raise NotImplementedError(msg) - - return indicator - - def selected_dim_expr(self, selected_value, non_selected_value): - self._selected_values.append(selected_value) - self._selected_options.append({i: selected_value for i in self.annotator.selected_indices}) - index_name = ( - "id" - if self.annotator.annotation_table._field_df.index.name is None - else self.annotator.annotation_table._field_df.index.name - ) - return hv.dim(index_name).categorize( - self._selected_options[-1], default=non_selected_value - ) - - @property - def dim_expr(self): - return self._selection_info["dim_expr"] - - def show_region(self): - kdims = list(self.kdims) - region = {k: v for k, v in self.annotator._region.items() if k in self.kdims} - - if not region: - return - - if self.region_types == "range": - value = region[kdims[0]] - bounds = (value[0], 0, value[1], 1) - elif self.region_types == "range-range": - bounds = ( - region[kdims[0]][0], - region[kdims[1]][0], - region[kdims[0]][1], - region[kdims[1]][1], - ) - elif self.region_types == "point": - value = region[kdims[0]] - bounds = (value, 0, value, 1) - else: - bounds = False - - if bounds: - self._edit_streams[0].event(bounds=bounds) - - class Annotator(AnnotatorInterface): """ An annotator displays the contents of an AnnotationTable and @@ -691,6 +254,12 @@ class Annotator(AnnotatorInterface): add new annotations and update existing annotations. """ + groupby = param.Selector(default=None, doc="Groupby dimension", allow_refs=True) + visible = param.ListSelector( + default=[], doc="Visible dimensions, needs groupby enabled", allow_refs=True + ) + style = param.ClassSelector(default=Style(), class_=Style, doc="Style parameters") + def __init__(self, spec: dict, **params): """ The spec argument must be an element or a dictionary of kdim dtypes @@ -822,3 +391,11 @@ def editable_enabled(self) -> bool: def editable_enabled(self, enabled: bool) -> None: for v in self._displays.values(): v.editable_enabled = enabled + + @param.depends("style.param", "groupby", "visible", watch=True) + def _refresh_style(self) -> None: + self.refresh() + + @param.depends("fields", watch=True, on_init=True) + def _set_groupby_objects(self) -> None: + self.param.groupby.objects = [*self.fields, None] diff --git a/holonote/annotate/display.py b/holonote/annotate/display.py new file mode 100644 index 0000000..ef14f52 --- /dev/null +++ b/holonote/annotate/display.py @@ -0,0 +1,534 @@ +from __future__ import annotations + +import weakref +from typing import TYPE_CHECKING, Any + +import holoviews as hv +import pandas as pd +import param +from bokeh.models.tools import BoxSelectTool, HoverTool, Tool + +from .._warnings import warn + +if TYPE_CHECKING: + from .annotator import Annotator + + +class _StyleOpts(param.Dict): + def _validate(self, val) -> None: + super()._validate(val) + for k in val: + if k in ("color", "alpha"): + warn("Color and alpha opts should be set directly on the style object.") + + +_default_opts = {"apply_ranges": False, "show_legend": False} + + +class Style(param.Parameterized): + """ + Style class for controlling the appearance of the annotations + indicator and editor. + + This can be accessed as an accessor on an annotator object, + the following will set the annotation color to red: + + >>> from holonote.annotate import Annotator + >>> annotator = Annotator(...) + >>> annotator.style.color = "red" + + This will update existing annotation displays and any new + displays with the new style. + + The style object can also be used to control the appearance + of the editor and selected indicator: + + >>> annotator.style.edit_color = "blue" + >>> annotator.style.edit_alpha = 0.5 + >>> annotator.style.selection_color = "green" + >>> annotator.style.selection_alpha = 0.5 + + See the [styling notebook](../../examples/styling.ipynb) for more examples + of how to use the style object. + """ + + alpha = param.Number( + default=0.2, bounds=(0, 1), allow_refs=True, doc="Alpha value for non-selected regions" + ) + + selection_alpha = param.Number( + default=0.7, bounds=(0, 1), allow_refs=True, doc="Alpha value for selected regions" + ) + + edit_alpha = param.Number( + default=0.4, bounds=(0, 1), allow_refs=True, doc="Alpha value for editing regions" + ) + + color = param.Parameter(default="red", doc="Color of the indicator", allow_refs=True) + edit_color = param.Parameter(default="blue", doc="Color of the editor", allow_refs=True) + selection_color = param.Parameter( + default=None, doc="Color of selection, by the default the same as color", allow_refs=True + ) + + # Indicator opts (default and selection) + opts = _StyleOpts(default={}) + line_opts = _StyleOpts(default={}) + span_opts = _StyleOpts(default={}) + rectangle_opts = _StyleOpts(default={}) + + # Editor opts + edit_opts = _StyleOpts(default={"line_color": "black"}) + edit_line_opts = _StyleOpts(default={}) + edit_span_opts = _StyleOpts(default={}) + edit_rectangle_opts = _StyleOpts(default={}) + + @property + def _indicator_selection(self) -> dict[str, tuple]: + select = {"alpha": (self.selection_alpha, self.alpha)} + if self.selection_color is not None: + if isinstance(self.color, hv.dim): + msg = "'Style.color' cannot be a `hv.dim` when 'Style.selection_color' is not None" + raise ValueError(msg) + else: + select["color"] = (self.selection_color, self.color) + return select + + def indicator(self, **select_opts) -> tuple[hv.Options, ...]: + opts = {**_default_opts, "color": self.color, **select_opts, **self.opts} + return ( + hv.opts.Rectangles(**opts, **self.rectangle_opts), + hv.opts.VSpans(**opts, **self.span_opts), + hv.opts.HSpans(**opts, **self.span_opts), + hv.opts.VLines(**opts, **self.line_opts), + hv.opts.HLines(**opts, **self.line_opts), + ) + + def editor(self) -> tuple[hv.Options, ...]: + opts = { + **_default_opts, + "alpha": self.edit_alpha, + "color": self.edit_color, + **self.edit_opts, + } + return ( + hv.opts.Rectangles(**opts, **self.edit_rectangle_opts), + hv.opts.VSpan(**opts, **self.edit_span_opts), + hv.opts.HSpan(**opts, **self.edit_span_opts), + hv.opts.VLine(**opts, **self.edit_line_opts), + hv.opts.HLine(**opts, **self.edit_line_opts), + ) + + def reset(self) -> None: + params = self.param.objects().items() + self.param.update(**{k: v.default for k, v in params if k != "name"}) + + +class Indicator: + """ + Collection of class methods that express annotation data as final + displayed (vectorized) HoloViews object. + """ + + @classmethod + def points_1d( + cls, data, region_labels, fields_labels, invert_axes=False, groupby: str | None = None + ): + "Vectorizes point regions to VLines. Note does not support hover info" + vdims = [*fields_labels, data.index.name] + element = hv.VLines(data.reset_index(), kdims=region_labels, vdims=vdims) + hover = cls._build_hover_tool(data) + return element.opts(tools=[hover]) + + @classmethod + def points_2d( + cls, data, region_labels, fields_labels, invert_axes=False, groupby: str | None = None + ): + "Vectorizes point regions to VLines * HLines. Note does not support hover info" + msg = "2D point regions not supported yet" + raise NotImplementedError(msg) + vdims = [*fields_labels, data.index.name] + element = hv.Points(data.reset_index(), kdims=region_labels, vdims=vdims) + hover = cls._build_hover_tool(data) + return element.opts(tools=[hover]) + + @classmethod + def ranges_2d( + cls, data, region_labels, fields_labels, invert_axes=False, groupby: str | None = None + ): + "Vectorizes an nd-overlay of range_2d rectangles." + kdims = [region_labels[i] for i in (0, 2, 1, 3)] # LBRT format + vdims = [*fields_labels, data.index.name] + ds = hv.Dataset(data.reset_index(), kdims=kdims, vdims=vdims) + element = ds.to(hv.Rectangles, groupby=groupby) + cds_map = dict(zip(region_labels, ("left", "right", "bottom", "top"))) + hover = cls._build_hover_tool(data, cds_map) + return element.opts(tools=[hover]) + + @classmethod + def ranges_1d( + cls, data, region_labels, fields_labels, invert_axes=False, groupby: str | None = None + ): + """ + Vectorizes an nd-overlay of range_1d rectangles. + + NOTE: Should use VSpans once available! + """ + vdims = [*fields_labels, data.index.name] + ds = hv.Dataset(data.reset_index(), kdims=region_labels, vdims=vdims) + element = ds.to(hv.VSpans, groupby=groupby) + hover = cls._build_hover_tool(data) + return element.opts(tools=[hover]) + + @classmethod + def _build_hover_tool(self, data, cds_map=None) -> HoverTool: + if cds_map is None: + cds_map = {} + tooltips, formatters = [], {} + for dim in data.columns: + cds_name = cds_map.get(dim, dim) + if data[dim].dtype.kind == "M": + tooltips.append((dim, f"@{{{cds_name}}}{{%F}}")) + formatters[f"@{{{cds_name}}}"] = "datetime" + else: + tooltips.append((dim, f"@{{{cds_name}}}")) + return HoverTool(tooltips=tooltips, formatters=formatters) + + +class AnnotationDisplay(param.Parameterized): + kdims = param.List( + default=["x"], bounds=(1, 3), constant=True, doc="Dimensions of the element" + ) + + _count = param.Integer(default=0, precedence=-1) + + def __init__(self, annotator: Annotator, **params) -> None: + super().__init__(**params) + + self._annotation_count_stream = hv.streams.Params( + parameterized=self, + parameters=["_count"], + transient=True, + ) + + self._selection_info = {} + + self._selection_enabled = True + self._editable_enabled = True + self._selected_values = [] + self._selected_options = [] + + transient = False + self._edit_streams = [ + hv.streams.BoundsXY(transient=transient), + hv.streams.SingleTap(transient=transient), + hv.streams.Lasso(transient=transient), + ] + + self.annotator = weakref.proxy(annotator) + self.style = weakref.proxy(annotator.style) + self._set_region_format() + self._element = self._make_empty_element() + + def _set_region_format(self) -> None: + self.region_format = "-".join([self.annotator.spec[k]["region"] for k in self.kdims]) + + @property + def element(self): + return self.overlay() + + @property + def edit_tools(self) -> list[Tool]: + tools = [] + if self.region_format == "range": + tools.append(BoxSelectTool(dimensions="width")) + elif self.region_format == "range-range": + tools.append(BoxSelectTool()) + elif self.region_format == "point": + tools.append(BoxSelectTool(dimensions="width")) + elif self.region_format == "point-point": + tools.append("tap") + return tools + + @classmethod + def _infer_kdim_dtypes(cls, element): + if not isinstance(element, hv.Element): + msg = "Supplied object {element} is not a bare HoloViews Element" + raise ValueError(msg) + kdim_dtypes = {} + for kdim in element.dimensions(selection="key"): + kdim_dtypes[str(kdim)] = type(element.dimension_values(kdim)[0]) + return kdim_dtypes + + def clear_indicated_region(self): + "Clear any region currently indicated on the plot by the editor" + self._edit_streams[0].event(bounds=None) + self._edit_streams[1].event(x=None, y=None) + self._edit_streams[2].event(geometry=None) + self.annotator.clear_regions() + + def _make_empty_element(self) -> hv.Curve | hv.Image: + El = hv.Curve if len(self.kdims) == 1 else hv.Image + return El([], kdims=self.kdims).opts(apply_ranges=False) + + @property + def selection_element(self) -> hv.Element: + if not hasattr(self, "_selection_element"): + self._selection_element = self._make_empty_element() + return self._selection_element + + @property + def selection_enabled(self) -> bool: + return self._selection_enabled + + @selection_enabled.setter + def selection_enabled(self, enabled: bool) -> None: + self._selection_enabled = enabled + + @property + def editable_enabled(self) -> bool: + return self._editable_enabled + + @editable_enabled.setter + def editable_enabled(self, enabled: bool) -> None: + self._editable_enabled = enabled + if not enabled: + self.clear_indicated_region() + + def _filter_stream_values(self, bounds, x, y, geometry): + if not self._editable_enabled: + return (None, None, None, None) + if self.region_format == "point" and bounds: + x = (bounds[0] + bounds[2]) / 2 + y = None + bounds = (x, 0, x, 0) + elif "range" not in self.region_format: + bounds = None + + # If selection enabled, tap stream used for selection not for creating point regions + # if ('point' in self.region_format and self.selection_enabled) or 'point' not in self.region_format: + if "point" not in self.region_format: + x, y = None, None + + return bounds, x, y, geometry + + def _make_selection_editor(self) -> hv.DynamicMap: + def inner(bounds, x, y, geometry): + bounds, x, y, geometry = self._filter_stream_values(bounds, x, y, geometry) + + info = self.selection_element._get_selection_expr_for_stream_value( + bounds=bounds, x=x, y=y, geometry=geometry + ) + (dim_expr, bbox, region_element) = info + + self._selection_info = { + "dim_expr": dim_expr, + "bbox": bbox, + "x": x, + "y": y, + "geometry": geometry, + "region_element": region_element, + } + + if bbox is not None: + # self.annotator.set_regions will give recursion error + self.annotator._set_regions(**bbox) + + kdims = list(self.kdims) + if self.region_format == "point" and x is not None: + self.annotator._set_regions(**{kdims[0]: x}) + if None not in [x, y]: + if len(kdims) == 1: + self.annotator._set_regions(**{kdims[0]: x}) + elif len(kdims) == 2: + self.annotator._set_regions(**{kdims[0]: x, kdims[1]: y}) + else: + msg = "Only 1d and 2d supported for Points" + raise ValueError(msg) + + return region_element.opts(*self.style.editor()) + + return hv.DynamicMap(inner, streams=self._edit_streams) + + def region_editor(self) -> hv.DynamicMap: + if not hasattr(self, "_region_editor"): + self._region_editor = self._make_selection_editor() + return self._region_editor + + def _get_range_indices_by_position(self, **inputs) -> list[Any]: + if isinstance(self.static_indicators, hv.NdOverlay): + df = pd.concat([el.data for el in self.static_indicators.values()]) + else: + df = self.static_indicators.data + + if df.empty: + return [] + + # Because we reset_index in Indicators + id_col = df.columns[0] + + for i, (k, v) in enumerate(inputs.items()): + mask = (df[f"start[{k}]"] <= v) & (v < df[f"end[{k}]"]) + if i == 0: + ids = set(df[mask][id_col]) + else: + ids &= set(df[mask][id_col]) + return list(ids) + + def _get_point_indices_by_position(self, **inputs) -> list[Any]: + """ + Simple algorithm for finding the closest point + annotation to the given position. + """ + + df = self.static_indicators.data + if df.empty: + return [] + + # Because we reset_index in Indicators + id_col = df.columns[0] + + for i, (k, v) in enumerate(inputs.items()): + nearest = (df[f"point[{k}]"] - v).abs().argmin() + if i == 0: + ids = {df.iloc[nearest][id_col]} + else: + ids &= {df.iloc[nearest][id_col]} + return list(ids) + + def get_indices_by_position(self, **inputs) -> list[Any]: + "Return primary key values matching given position in data space" + if "range" in self.region_format: + return self._get_range_indices_by_position(**inputs) + elif "point" in self.region_format: + return self._get_point_indices_by_position(**inputs) + else: + msg = f"{self.region_format} not implemented" + raise NotImplementedError(msg) + + def register_tap_selector(self, element: hv.Element) -> hv.Element: + def tap_selector(x, y) -> None: # Tap tool must be enabled on the element + # Only select the first + inputs = {str(k): v for k, v in zip(self.kdims, (x, y))} + indices = self.get_indices_by_position(**inputs) + if indices: + self.annotator.select_by_index(indices[0]) + else: + self.annotator.select_by_index() + + tap_stream = hv.streams.Tap(source=element, transient=True) + tap_stream.add_subscriber(tap_selector) + return element + + def register_double_tap_clear(self, element: hv.Element) -> hv.Element: + def double_tap_clear(x, y): + self.clear_indicated_region() + + double_tap_stream = hv.streams.DoubleTap(source=element, transient=True) + double_tap_stream.add_subscriber(double_tap_clear) + return element + + def indicators(self) -> hv.DynamicMap: + self.register_tap_selector(self._element) + self.register_double_tap_clear(self._element) + + def inner(_count): + return self.static_indicators + + return hv.DynamicMap(inner, streams=[self._annotation_count_stream]) + + def overlay(self, indicators=True, editor=True) -> hv.Overlay: + layers = [] + active_tools = [] + if "range" in self.region_format or self.region_format == "point": + active_tools += ["box_select"] + elif self.region_format == "point-point": + active_tools += ["tap"] + layers.append(self._element.opts(tools=self.edit_tools, active_tools=active_tools)) + + if indicators: + layers.append(self.indicators()) + if editor: + layers.append(self.region_editor()) + return hv.Overlay(layers).collate() + + @property + def static_indicators(self): + data = self.annotator.get_dataframe(dims=self.kdims) + fields_labels = self.annotator.all_fields + region_labels = [k for k in data.columns if k not in fields_labels] + + indicator_kwargs = { + "data": data, + "region_labels": region_labels, + "fields_labels": fields_labels, + "invert_axes": False, # Not yet handled + "groupby": self.annotator.groupby, + } + + if self.region_format == "range": + indicator = Indicator.ranges_1d(**indicator_kwargs) + elif self.region_format == "range-range": + indicator = Indicator.ranges_2d(**indicator_kwargs) + elif self.region_format == "point": + indicator = Indicator.points_1d(**indicator_kwargs) + elif self.region_format == "point-point": + indicator = Indicator.points_2d(**indicator_kwargs) + else: + msg = f"{self.region_format} not implemented" + raise NotImplementedError(msg) + + if self.annotator.groupby and self.annotator.visible: + indicator = indicator.get(self.annotator.visible) + if indicator is None: + vis = "', '".join(self.annotator.visible) + msg = f"Visible dimensions {vis!r} not in spec" + raise ValueError(msg) + + # Set styling on annotations indicator + highlight = self.style._indicator_selection + highlighters = {opt: self.selected_dim_expr(v[0], v[1]) for opt, v in highlight.items()} + indicator = indicator.opts(*self.style.indicator(**highlighters)) + + return indicator.overlay() if self.annotator.groupby else hv.NdOverlay({0: indicator}) + + def selected_dim_expr(self, selected_value, non_selected_value): + self._selected_values.append(selected_value) + self._selected_options.append({i: selected_value for i in self.annotator.selected_indices}) + index_name = ( + "id" + if self.annotator.annotation_table._field_df.index.name is None + else self.annotator.annotation_table._field_df.index.name + ) + return hv.dim(index_name).categorize( + self._selected_options[-1], default=non_selected_value + ) + + @property + def dim_expr(self): + return self._selection_info["dim_expr"] + + def show_region(self): + kdims = list(self.kdims) + region = {k: v for k, v in self.annotator._region.items() if k in self.kdims} + + if not region: + return + + if self.region_format == "range": + value = region[kdims[0]] + bounds = (value[0], 0, value[1], 1) + elif self.region_format == "range-range": + bounds = ( + region[kdims[0]][0], + region[kdims[1]][0], + region[kdims[0]][1], + region[kdims[1]][1], + ) + elif self.region_format == "point": + value = region[kdims[0]] + bounds = (value, 0, value, 1) + else: + bounds = False + + if bounds: + self._edit_streams[0].event(bounds=bounds) diff --git a/holonote/app/__init__.py b/holonote/app/__init__.py index 01937c2..d99b502 100644 --- a/holonote/app/__init__.py +++ b/holonote/app/__init__.py @@ -1,3 +1,3 @@ -from __future__ import annotations +from .panel import PanelWidgets -from .panel import PanelWidgets # noqa: F401 +__all__ = ("PanelWidgets",) diff --git a/holonote/editor/__init__.py b/holonote/editor/__init__.py index eb0ef08..0c9cacd 100644 --- a/holonote/editor/__init__.py +++ b/holonote/editor/__init__.py @@ -1,3 +1,3 @@ -from __future__ import annotations +from .editors import PathEditor -from .editors import PathEditor # noqa: F401 +__all__ = ("PathEditor",) diff --git a/holonote/tests/conftest.py b/holonote/tests/conftest.py index e1f470b..79f5669 100644 --- a/holonote/tests/conftest.py +++ b/holonote/tests/conftest.py @@ -102,3 +102,25 @@ def element_range2d() -> hv.Image: x = np.arange(10) xy = x[:, np.newaxis] * x return hv.Image(xy, kdims=["x", "y"]) + + +@pytest.fixture() +def cat_annotator(conn_sqlite_uuid) -> Annotator: + # Initialize annotator + annotator = Annotator( + {"x": float}, + fields=["description", "category"], + connector=conn_sqlite_uuid, + groupby="category", + ) + # Add data to annotator + 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")) + # Setup display + annotator.get_display("x") + return annotator diff --git a/holonote/tests/test_annotators_element.py b/holonote/tests/test_annotators_element.py index dcfa4c5..7927070 100644 --- a/holonote/tests/test_annotators_element.py +++ b/holonote/tests/test_annotators_element.py @@ -1,56 +1,43 @@ from __future__ import annotations import holoviews as hv +import pytest -bk_renderer = hv.renderer("bokeh") +from holonote.tests.util import get_editor, get_indicator -def _get_display(annotator, kdims=None) -> hv.Element: - if kdims is None: - kdims = next(iter(annotator._displays)) - kdims = (kdims,) if isinstance(kdims, str) else tuple(kdims) - return annotator.get_display(*kdims) +def get_editor_data(annotator, element_type, kdims=None): + el = get_editor(annotator, element_type, kdims) + return getattr(el, "data", None) -def get_region_editor_data( - annotator, - element_type, - kdims=None, -): - el = _get_display(annotator, kdims).region_editor() - for e in el.last.traverse(): - if isinstance(e, element_type): - return e.data +def get_indicator_data(annotator, element_type, kdims=None): + for el in get_indicator(annotator, element_type, kdims): + yield el.data -def get_indicators_data(annotator, element_type, kdims=None): - return _get_display(annotator, kdims).static_indicators.data - - -def test_set_regions_range1d(annotator_range1d, element_range1d) -> None: +def test_set_regions_range1d(annotator_range1d) -> None: annotator = annotator_range1d - element = element_range1d - annotator_element = annotator * element - bk_renderer.get_plot(annotator_element) + annotator.get_display("TIME") # No regions has been set - output = get_region_editor_data(annotator, hv.VSpan) + output = get_editor_data(annotator, hv.VSpan) expected = [None, None] assert output == expected # Setting regions annotator.set_regions(TIME=(-0.25, 0.25)) - output = get_region_editor_data(annotator, hv.VSpan) + output = get_editor_data(annotator, hv.VSpan) expected = [-0.25, 0.25] assert output == expected # Adding annotation and remove selection regions. annotator.add_annotation(description="Test") - output = get_region_editor_data(annotator, hv.VSpan) + output = get_editor_data(annotator, hv.VSpan) expected = [None, None] assert output == expected - output = get_indicators_data(annotator, hv.Rectangles) + output = next(get_indicator_data(annotator, hv.Rectangles)) output1 = output.loc[0, ["start[TIME]", "end[TIME]"]].tolist() expected1 = [-0.25, 0.25] assert output1 == expected1 @@ -59,28 +46,26 @@ def test_set_regions_range1d(annotator_range1d, element_range1d) -> None: assert output2 == expected2 -def test_set_regions_range2d(annotator_range2d, element_range2d) -> None: +def test_set_regions_range2d(annotator_range2d) -> None: annotator = annotator_range2d - element = element_range2d - annotator_element = annotator * element - bk_renderer.get_plot(annotator_element) + annotator.get_display("x", "y") # No regions has been set - output = get_region_editor_data(annotator, hv.Rectangles) + output = get_editor_data(annotator, hv.Rectangles) assert output.empty # Setting regions annotator.set_regions(x=(-0.25, 0.25), y=(-0.25, 0.25)) - output = get_region_editor_data(annotator, hv.Rectangles).iloc[0].to_list() + output = get_editor_data(annotator, hv.Rectangles).iloc[0].to_list() expected = [-0.25, -0.25, 0.25, 0.25] assert output == expected # Adding annotation and remove selection regions. annotator.add_annotation(description="Test") - output = get_region_editor_data(annotator, hv.Rectangles) + output = get_editor_data(annotator, hv.Rectangles) assert output.empty - output = get_indicators_data(annotator, hv.Rectangles) + output = next(get_indicator_data(annotator, hv.Rectangles)) output1 = output.loc[0, ["start[x]", "start[y]", "end[x]", "end[y]"]].tolist() expected1 = [-0.25, -0.25, 0.25, 0.25] assert output1 == expected1 @@ -89,41 +74,39 @@ def test_set_regions_range2d(annotator_range2d, element_range2d) -> None: assert output2 == expected2 -def test_set_regions_multiple(multiple_annotators, element_range1d, element_range2d): +def test_set_regions_multiple(multiple_annotators): annotator = multiple_annotators - time_annotation = annotator * element_range1d - xy_annotation = annotator * element_range2d - annotator_element = time_annotation + xy_annotation - bk_renderer.get_plot(annotator_element) + annotator.get_display("TIME") + annotator.get_display("x", "y") # No regions has been set # Time annotation - output = get_region_editor_data(annotator, hv.VSpan, "TIME") + output = get_editor_data(annotator, hv.VSpan, "TIME") expected = [None, None] assert output == expected # xy annotation - output = get_region_editor_data(annotator, hv.Rectangles, ("x", "y")) + output = get_editor_data(annotator, hv.Rectangles, ("x", "y")) assert output.empty # Setting regions annotator.set_regions(TIME=(-0.25, 0.25), x=(-0.25, 0.25), y=(-0.25, 0.25)) # Time annotation - output = get_region_editor_data(annotator, hv.VSpan, "TIME") + output = get_editor_data(annotator, hv.VSpan, "TIME") expected = [-0.25, 0.25] assert output == expected # xy annotation - output = get_region_editor_data(annotator, hv.Rectangles, ("x", "y")).iloc[0].to_list() + output = get_editor_data(annotator, hv.Rectangles, ("x", "y")).iloc[0].to_list() expected = [-0.25, -0.25, 0.25, 0.25] assert output == expected # Adding annotation and remove selection regions. annotator.add_annotation(description="Test") # Time annotation - output = get_region_editor_data(annotator, hv.VSpan, "TIME") + output = get_editor_data(annotator, hv.VSpan, "TIME") expected = [None, None] assert output == expected - output = get_indicators_data(annotator, hv.Rectangles, "TIME") + output = next(get_indicator_data(annotator, hv.Rectangles, "TIME")) output1 = output.loc[0, ["start[TIME]", "end[TIME]"]].tolist() expected1 = [-0.25, 0.25] assert output1 == expected1 @@ -132,10 +115,10 @@ def test_set_regions_multiple(multiple_annotators, element_range1d, element_rang assert output2 == expected2 # xy annotation - output = get_region_editor_data(annotator, hv.Rectangles, ("x", "y")) + output = get_editor_data(annotator, hv.Rectangles, ("x", "y")) assert output.empty - output = get_indicators_data(annotator, hv.Rectangles, ("x", "y")) + output = next(get_indicator_data(annotator, hv.Rectangles, ("x", "y"))) output1 = output.loc[0, ["start[x]", "start[y]", "end[x]", "end[y]"]].tolist() expected1 = [-0.25, -0.25, 0.25, 0.25] assert output1 == expected1 @@ -144,8 +127,9 @@ def test_set_regions_multiple(multiple_annotators, element_range1d, element_rang assert output2 == expected2 -def test_editable_enabled(annotator_range1d, element_range1d): - annotator_range1d * element_range1d +def test_editable_enabled(annotator_range1d): + annotator_range1d.get_display("TIME") + assert annotator_range1d._displays annotator_range1d.editable_enabled = False for display in annotator_range1d._displays.values(): @@ -157,7 +141,8 @@ def test_editable_enabled(annotator_range1d, element_range1d): def test_selection_enabled(annotator_range1d, element_range1d): - annotator_range1d * element_range1d + annotator_range1d.get_display("TIME") + assert annotator_range1d._displays annotator_range1d.selection_enabled = False for display in annotator_range1d._displays.values(): @@ -166,3 +151,34 @@ def test_selection_enabled(annotator_range1d, element_range1d): annotator_range1d.selection_enabled = True for display in annotator_range1d._displays.values(): assert display.selection_enabled + + +def test_groupby(cat_annotator): + cat_annotator.groupby = "category" + iter_indicator = get_indicator(cat_annotator, hv.VSpans) + indicator = next(iter_indicator) + assert indicator.data.shape == (2, 5) + assert (indicator.data["category"] == "A").all() + + indicator = next(iter_indicator) + assert indicator.data.shape == (2, 5) + assert (indicator.data["category"] == "B").all() + + indicator = next(iter_indicator) + assert indicator.data.shape == (1, 5) + assert (indicator.data["category"] == "C").all() + + with pytest.raises(StopIteration): + next(iter_indicator) + + +def test_groupby_visible(cat_annotator): + cat_annotator.groupby = "category" + cat_annotator.visible = ["A"] + iter_indicator = get_indicator(cat_annotator, hv.VSpans) + indicator = next(iter_indicator) + assert indicator.data.shape == (2, 5) + assert (indicator.data["category"] == "A").all() + + with pytest.raises(StopIteration): + next(iter_indicator) diff --git a/holonote/tests/test_annotators_style.py b/holonote/tests/test_annotators_style.py new file mode 100644 index 0000000..6c4372a --- /dev/null +++ b/holonote/tests/test_annotators_style.py @@ -0,0 +1,129 @@ +import holoviews as hv +import pytest + +from holonote.annotate import Style +from holonote.tests.util import get_editor, get_indicator + + +def compare_indicator_color(indicator, style, style_categories): + if isinstance(indicator.opts["color"], hv.dim): + if isinstance(style.color, hv.dim): + assert str(indicator.opts["color"]) == str(style.color) + else: + expected_dim = hv.dim("uuid").categorize( + categories=style_categories or {}, default=style.color + ) + assert str(indicator.opts["color"]) == str(expected_dim) + else: + assert indicator.opts["color"] == style.color + + +def compare_style(cat_annotator, categories, style_categories=None): + style = cat_annotator.style + indicator = next(get_indicator(cat_annotator, hv.VSpans)) + compare_indicator_color(indicator, style, style_categories) + expected_dim = hv.dim("uuid").categorize(categories=categories, default=style.alpha) + assert str(indicator.opts["alpha"]) == str(expected_dim) + + editor = get_editor(cat_annotator, hv.VSpan) + assert editor.opts["color"] == style.edit_color + assert editor.opts["alpha"] == style.edit_alpha + + +def test_style_accessor(cat_annotator) -> None: + assert isinstance(cat_annotator.style, Style) + + +def test_style_default(cat_annotator) -> None: + compare_style(cat_annotator, {}) + + +def test_style_default_select(cat_annotator) -> None: + style = cat_annotator.style + + # Select the first value + cat_annotator.select_by_index(0) + compare_style(cat_annotator, {0: style.selection_alpha}) + + # remove it again + cat_annotator.select_by_index() + compare_style(cat_annotator, {}) + + +def test_style_change_color_alpha(cat_annotator) -> None: + style = cat_annotator.style + style.color = "blue" + style.alpha = 0.1 + + compare_style(cat_annotator, {}) + + style.edit_color = "yellow" + style.edit_alpha = 1 + cat_annotator.set_regions(x=(0, 1)) # To update plot + compare_style(cat_annotator, {}) + + +def test_style_color_dim(cat_annotator): + style = cat_annotator.style + style.color = hv.dim("category").categorize( + categories={"B": "red", "A": "blue", "C": "green"}, default="grey" + ) + compare_style(cat_annotator, {}) + + +def test_style_selection_color(cat_annotator): + style = cat_annotator.style + style.selection_color = "blue" + compare_style(cat_annotator, {}) + + # Select the first value + cat_annotator.select_by_index(0) + compare_style(cat_annotator, {0: style.selection_alpha}, {0: style.selection_color}) + + # remove it again + cat_annotator.select_by_index() + compare_style(cat_annotator, {}) + + +def test_style_error_color_dim_and_selection(cat_annotator): + style = cat_annotator.style + style.color = hv.dim("category").categorize( + categories={"B": "red", "A": "blue", "C": "green"}, default="grey" + ) + style.selection_color = "blue" + msg = r"'Style\.color' cannot be a `hv.dim` when 'Style.selection_color' is not None" + with pytest.raises(ValueError, match=msg): + compare_style(cat_annotator, {}) + + +def test_style_opts(cat_annotator): + cat_annotator.style.opts = {"line_width": 2} + compare_style(cat_annotator, {}) + + indicator = next(get_indicator(cat_annotator, hv.VSpans)) + assert indicator.opts["line_width"] == 2 + + editor = get_editor(cat_annotator, hv.VSpan) + assert "line_width" not in editor.opts.get().kwargs + + cat_annotator.style.edit_opts = {"line_width": 3} + cat_annotator.set_regions(x=(0, 1)) # To update plot + editor = get_editor(cat_annotator, hv.VSpan) + assert editor.opts["line_width"] == 3 + + +@pytest.mark.parametrize("opts", [{"alpha": 0.5}, {"color": "red"}], ids=["alpha", "color"]) +def test_style_opts_warn(cat_annotator, opts): + msg = "Color and alpha opts should be set directly on the style object" + with pytest.raises(UserWarning, match=msg): + cat_annotator.style.opts = opts + + +def test_style_reset(cat_annotator) -> None: + style = cat_annotator.style + style.color = "blue" + compare_style(cat_annotator, {}) + + style.reset() + assert style.color == "red" + compare_style(cat_annotator, {}) diff --git a/holonote/tests/util.py b/holonote/tests/util.py new file mode 100644 index 0000000..9985783 --- /dev/null +++ b/holonote/tests/util.py @@ -0,0 +1,24 @@ +import holoviews as hv + +bk_renderer = hv.renderer("bokeh") + + +def _get_display(annotator, kdims=None) -> hv.Element: + if kdims is None: + kdims = next(iter(annotator._displays)) + kdims = (kdims,) if isinstance(kdims, str) else tuple(kdims) + disp = annotator.get_display(*kdims) + bk_renderer.get_plot(disp.element) # Trigger rendering + return disp + + +def get_editor(annotator, element_type, kdims=None): + el = _get_display(annotator, kdims).region_editor() + for e in el.last.traverse(): + if isinstance(e, element_type): + return e + + +def get_indicator(annotator, element_type, kdims=None): + si = _get_display(annotator, kdims).static_indicators + yield from si.data.values() diff --git a/pyproject.toml b/pyproject.toml index 592b5e0..e7c5e1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ scripts.build = "bash conda/build.sh" [tool.hatch.envs.test] dependencies = ["pytest", "pytest-github-actions-annotate-failures"] -scripts.run = "python -m pytest holonote/tests" +scripts.run = "python -m pytest holonote/tests {args:.}" matrix = [{ python = ["3.9", "3.10", "3.11", "3.12"] }] [tool.hatch.envs.fmt]