Skip to content

Commit

Permalink
feat: add support for line-based annotations (#151)
Browse files Browse the repository at this point in the history
* feat: add support line-based annotations

* docs: describe `scatter.annotations()`
  • Loading branch information
flekschas authored Aug 28, 2024
1 parent 750d151 commit d9523d8
Show file tree
Hide file tree
Showing 18 changed files with 656 additions and 6 deletions.
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
## v0.18.0

- Feat: The exported and saved images now include a background color instead of a transparent background. You can still still export or save images with a transparent background by holding down the ALT key while clicking on the download or camera button.
- Feat: add support for line-based annotations via `scatter.annotations()`
- Feat: the exported and saved images now include a background color instead of a transparent background. You can still still export or save images with a transparent background by holding down the ALT key while clicking on the download or camera button.
- Refactor: When saving the current view as an image via the camera button on the left side bar, the image gets saved in `scatter.widget.view_data` as a 3D Numpy array (shape: `[height, width, 4]`) instead of a 1D Numpy array. Since the shape is now encoded by the 3D numpy array, `scatter.widget.view_shape` is no longer needed and is removed.
- Fix: hide button for activating rotate mouse mode as the rotation did not work (which is easily fixable) and should not be available when axes are shown as the axes are not rotateable. However rotating the plot without rotating the axis leads to incorrect tick marks.
- Fix: VSCode integration by updating regl-scatterplot to v1.10.4 ([#37](https://github.com/flekschas/jupyter-scatter/issues/37))
Expand All @@ -15,7 +16,7 @@

## v0.17.0

- Feat: Add `scatter.show_tooltip(point_idx)`
- Feat: add `scatter.show_tooltip(point_idx)`
- Fix: reset scale & norm ranges upon updating the data via `scatter.data()`
- Fix: ensure `scatter.axes(labels=['x_label', 'y_label'])` works properly ([#137](https://github.com/flekschas/jupyter-scatter/issues/137]))

Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export default defineConfig({
{ text: 'Selections', link: '/selections' },
{ text: 'Link Multiple Scatter Plots', link: '/link-multiple-plots' },
{ text: 'Axes & Legends', link: '/axes-legends' },
{ text: 'Annotations', link: '/annotations' },
{ text: 'Tooltip', link: '/tooltip' },
{ text: 'Scales', link: '/scales' },
{ text: 'Connected Scatterplots', link: '/connected-scatterplots' },
Expand Down
96 changes: 96 additions & 0 deletions docs/annotations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Annotations

To help navigating and relating points and clusters, Jupyter Scatter offers
annotations such a lines and rectangles.

::: info
Currently, Jupyter Scatter supports line-based annotations only. In the future,
we plan to add support for text annotations as well.
:::

## Line, HLine, VLine, and Rect

To draw annotations, create instances of `Line`, `HLine`, `VLine`, or `Rect`.
You can then either pass the annotations into the constructor, as shown below,
or call `scatter.annotations()`.

```py{9-17,22}
import jscatter
import numpy as np
x1, y1 = np.random.normal(-1, 0.2, 1000), np.random.normal(+1, 0.05, 1000)
x2, y2 = np.random.normal(+1, 0.2, 1000), np.random.normal(+1, 0.05, 1000)
x3, y3 = np.random.normal(+1, 0.2, 1000), np.random.normal(-1, 0.05, 1000)
x4, y4 = np.random.normal(-1, 0.2, 1000), np.random.normal(-1, 0.05, 1000)
y0 = jscatter.HLine(0)
x0 = jscatter.VLine(0)
c1 = jscatter.Rect(x_start=-1.5, x_end=-0.5, y_start=+0.75, y_end=+1.25)
c2 = jscatter.Rect(x_start=+0.5, x_end=+1.5, y_start=+0.75, y_end=+1.25)
c3 = jscatter.Rect(x_start=+0.5, x_end=+1.5, y_start=-1.25, y_end=-0.75)
c4 = jscatter.Rect(x_start=-1.5, x_end=-0.5, y_start=-1.25, y_end=-0.75)
l = jscatter.Line([
(-2, -2), (-1.75, -1), (-1.25, -0.5), (1.25, 0.5), (1.75, 1), (2, 2)
])
scatter = jscatter.Scatter(
x=np.concatenate((x1, x2, x3, x4)), x_scale=(-2, 2),
y=np.concatenate((y1, y2, y3, y4)), y_scale=(-2, 2),
annotations=[x0, y0, c1, c2, c3, c4, l],
width=400,
height=400,
)
scatter.show()
```

<div class="img simple"><div /></div>

## Line Color & Width

You can customize the line color and width of `Line`, `HLine`, `VLine`, or
`Rect` via the `line_color` and `line_width` attributes.

```py
y0 = jscatter.HLine(0, line_color=(0, 0, 0, 0.1))
x0 = jscatter.VLine(0, line_color=(0, 0, 0, 0.1))
c1 = jscatter.Rect(x_start=-1.5, x_end=-0.5, y_start=+0.75, y_end=+1.25, line_color="#56B4E9", line_width=2)
c2 = jscatter.Rect(x_start=+0.5, x_end=+1.5, y_start=+0.75, y_end=+1.25, line_color="#56B4E9", line_width=2)
c3 = jscatter.Rect(x_start=+0.5, x_end=+1.5, y_start=-1.25, y_end=-0.75, line_color="#56B4E9", line_width=2)
c4 = jscatter.Rect(x_start=-1.5, x_end=-0.5, y_start=-1.25, y_end=-0.75, line_color="#56B4E9", line_width=2)
l = jscatter.Line(
[(-2, -2), (-1.75, -1), (-1.25, -0.5), (1.25, 0.5), (1.75, 1), (2, 2)],
line_color="red",
line_width=3
)
```

<div class="img styles"><div /></div>

<style scoped>
.img {
max-width: 100%;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
}

.img.simple {
width: 316px;
background-image: url(/images/annotations-simple-light.png)
}
.img.simple div { padding-top: 83.2278481% }

:root.dark .img.simple {
background-image: url(/images/annotations-simple-dark.png)
}

.img.styles {
width: 314px;
background-image: url(/images/annotations-styles-light.png)
}
.img.styles div { padding-top: 83.75796178% }

:root.dark .img.styles {
background-image: url(/images/annotations-styles-dark.png)
}
</style>
17 changes: 16 additions & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
- [selection()](#scatter.selection) and [filter()](#scatter.filter)
- [color()](#scatter.color), [opacity()](#scatter.opacity), and [size()](#scatter.size)
- [connect()](#scatter.connect), [connection_color()](#scatter.connection_color), [connection_opacity()](#scatter.connection_opacity), and [connection_size()](#scatter.connection_size)
- [axes()](#scatter.axes) and [legend()](#scatter.legend)
- [axes()](#scatter.axes), [legend()](#scatter.legend), and [annotations()](#scatter.annotations)
- [tooltip()](#scatter.tooltip) and [show_tooltip()](#scatter.show_tooltip)
- [zoom()](#scatter.zoom) and [camera()](#scatter.camera)
- [lasso()](#scatter.lasso), [reticle()](#scatter.reticle), and [mouse()](#scatter.mouse),
Expand Down Expand Up @@ -357,6 +357,21 @@ Set or get the legend settings.
scatter.legend(True, 'top-right', 'small')
```

### scatter.annotations(_annotations=Undefined_) {#scatter.annotations}

Set or get annotations.

**Arguments:**
- `annotations` is a list of annotations (`Line`, `HLine`, `VLine`, or `Rect`)

**Returns:** either the annotation properties when all arguments are `Undefined` or `self`.

**Example:**

```python
from jscatter import HLine, VLine
scatter.annotations([HLine(42), VLine(42)])
```

### scatter.tooltip(_enable=Undefined_, _properties=Undefined_, _histograms=Undefined_, _histograms_bins=Undefined_, _histograms_ranges=Undefined_, _histograms_size=Undefined_, _preview=Undefined_, _preview_type=Undefined_, _preview_text_lines=Undefined_, _preview_image_background_color=Undefined_, _preview_image_position=Undefined_, _preview_image_size=Undefined_, _preview_audio_length=Undefined_, _preview_audio_loop=Undefined_, _preview_audio_controls=Undefined_, _size=Undefined_) {#scatter.tooltip}

Expand Down
3 changes: 3 additions & 0 deletions docs/public/images/annotations-simple-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions docs/public/images/annotations-simple-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions docs/public/images/annotations-styles-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions docs/public/images/annotations-styles-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 42 additions & 0 deletions js/src/codecs.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { camelToSnake, snakeToCamel } from './utils';

const DTYPES = {
uint8: Uint8Array,
int8: Int8Array,
Expand Down Expand Up @@ -120,3 +122,43 @@ export function Numpy1D(dtype) {
}
}
}

export function Annotations() {
const pyToJsKey = { x_start: 'x1', x_end: 'x2', y_start: 'y1', y_end: 'y2' };
const jsToPyKey = { x1: 'x_start', x2: 'x_end', y1: 'y_start', y2: 'y_end' };
return {
/**
* @param {string[] | null} annotationStrs
* @returns {object[]}
*/
deserialize: (annotationStrs) => {
if (annotationStrs === null) {
return null;
}
return annotationStrs.map((annotationStr) => {
return Object.entries(JSON.parse(annotationStr)).reduce((acc, [key, value]) => {
const jsKey = key in pyToJsKey ? pyToJsKey[key] : snakeToCamel(key);
acc[jsKey] = value;
return acc;
}, {})
});
},
/**
* @param {object[] | null} annotations
* @returns {string[]}
*/
serialize: (annotations) => {
if (annotations === null) {
return null;
}
return annotations.map((annotation) => {
const pyAnnotation = Object.entries(annotation).reduce((acc, [key, value]) => {
const pyKey = key in jsToPyKey ? jsToPyKey[key] : camelToSnake(key);
acc[pyKey] = value;
return acc;
}, {});
return JSON.stringify(pyAnnotation);
})
},
}
}
11 changes: 10 additions & 1 deletion js/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { format } from 'd3-format';
import { scaleLinear } from 'd3-scale';
import { select } from 'd3-selection';

import { Numpy1D, Numpy2D, NumpyImage } from "./codecs";
import { Annotations, Numpy1D, Numpy2D, NumpyImage } from "./codecs";
import { createHistogram } from "./histogram";
import { createLegend } from "./legend";
import {
Expand Down Expand Up @@ -59,6 +59,7 @@ const TOOLTIP_OFFSET_REM = 0.5;
* different. E.g., size (Python) vs pointSize (JavaScript)
*/
const properties = {
annotations: 'annotations',
backgroundColor: 'backgroundColor',
backgroundImage: 'backgroundImage',
cameraDistance: 'cameraDistance',
Expand Down Expand Up @@ -371,6 +372,9 @@ class JupyterScatterView {
this.scatterplot
.draw(this.points, options)
.then(() => {
if (this.annotations) {
this.scatterplot.drawAnnotations(this.annotations);
}
if (this.filter?.length && this.model.get('zoom_on_filter')) {
this.zoomToHandler(this.filter);
}
Expand Down Expand Up @@ -1766,6 +1770,10 @@ class JupyterScatterView {
}
}

annotationsHandler(annotations) {
this.scatterplot.drawAnnotations(annotations || []);
}

// Event handlers for JS-triggered events
pointoverHandler(pointIndex) {
this.hoveringChangedByJs = true;
Expand Down Expand Up @@ -2426,6 +2434,7 @@ async function render({ model, el }) {
filter: Numpy1D('uint32'),
view_data: NumpyImage(),
zoom_to: Numpy1D('uint32'),
annotations: Annotations(),
}),
});
view.render();
Expand Down
4 changes: 4 additions & 0 deletions js/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ export function camelToSnake(string) {
return string.replace(/[\w]([A-Z])/g, (m) => m[0] + "_" + m[1]).toLowerCase();
}

export function snakeToCamel(string) {
return string.toLowerCase().replace(/[-_][a-z]/g, (group) => group.slice(-1).toUpperCase());
}

export function toCapitalCase(string) {
if (string.length === 0) return string;
return string.at(0).toUpperCase() + string.slice(1);
Expand Down
1 change: 1 addition & 0 deletions jscatter/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@
__version__ = "uninstalled"

from .jscatter import Scatter, plot
from .annotations import Line, HLine, VLine, Rect
from .compose import compose, link
from .color_maps import okabe_ito, glasbey_light, glasbey_dark
55 changes: 55 additions & 0 deletions jscatter/annotations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from __future__ import annotations

from dataclasses import dataclass
from matplotlib.colors import to_rgba
from typing import List, Tuple, Optional

from .types import Color

DEFAULT_1D_LINE_START = None
DEFAULT_1D_LINE_END = None
DEFAULT_LINE_COLOR = '#000000'
DEFAULT_LINE_WIDTH = 1

@dataclass
class HLine():
y: float
x_start: Optional[float] = DEFAULT_1D_LINE_START
x_end: Optional[float] = DEFAULT_1D_LINE_END
line_color: Color = DEFAULT_LINE_COLOR
line_width: int = DEFAULT_LINE_WIDTH

def __post_init__(self):
self.line_color = to_rgba(self.line_color)

@dataclass
class VLine():
x: float
y_start: Optional[float] = DEFAULT_1D_LINE_START
y_end: Optional[float] = DEFAULT_1D_LINE_END
line_color: Color = DEFAULT_LINE_COLOR
line_width: int = DEFAULT_LINE_WIDTH

def __post_init__(self):
self.line_color = to_rgba(self.line_color)

@dataclass
class Rect():
x_start: float
x_end: float
y_start: float
y_end: float
line_color: Color = DEFAULT_LINE_COLOR
line_width: int = DEFAULT_LINE_WIDTH

def __post_init__(self):
self.line_color = to_rgba(self.line_color)

@dataclass
class Line():
vertices: List[Tuple[float]]
line_color: Color = DEFAULT_LINE_COLOR
line_width: int = DEFAULT_LINE_WIDTH

def __post_init__(self):
self.line_color = to_rgba(self.line_color)
Loading

0 comments on commit d9523d8

Please sign in to comment.