Skip to content

Commit b711eb5

Browse files
authored
Merge pull request #588 from Renumics/feature/lasso-selection
Feature/lasso selection
2 parents 07468b4 + fbc5ce8 commit b711eb5

19 files changed

Lines changed: 554 additions & 15 deletions

File tree

docs/configure_visualizations/ui_components/scatter_plot.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,9 @@ Simply select `x-Axis` and `y-Axis` from the dropdowns in settings. The points w
1919
</video>
2020

2121
_fsd50k_ - place datapoints on the **similarity map** based on a column
22+
23+
## Selection
24+
25+
Selection works exactly as it does for the
26+
[similarity map](similarity_map.md#selection), including the rectangular and lasso
27+
tools offered by the `Selection Tool` menu in the top bar.

docs/configure_visualizations/ui_components/similarity_map.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,17 @@ In order to select a single point simply click on it. This will reset any select
5656
To add a point to a selection click on it while pressing `shift` and to remove it press `ctrl` while clicking on it.
5757

5858
These steps also apply for multiple points. By pressing the left mouse button and moving the mouse,
59-
a selection rectangle will be shown. On releasing the left mouse button, the selection will be applied in the same fashion as it is done for a single point.
59+
a selection shape will be shown. On releasing the left mouse button, the selection will be applied in the same fashion as it is done for a single point.
60+
61+
Two selection tools are available and can be switched at any time through the
62+
`Selection Tool` menu in the top bar:
63+
64+
- **Rectangular selection** (default): drag with the left mouse button to select every point inside the resulting rectangle.
65+
- **Lasso selection**: drag with the left mouse button to draw a freehand outline. Every point enclosed by the outline is selected once the mouse button is released. This is useful for irregularly shaped clusters that a rectangle cannot isolate.
66+
67+
The `shift` and `ctrl` modifiers behave identically for both tools. The selected tool
68+
is a global setting: it applies to the similarity map and the
69+
[scatter plot](scatter_plot.md) alike and is remembered across sessions.
6070

6171
<video controls muted loop playsinline preload="metadata" poster="../../../assets/data/docs/ui-components/similarity-map/selection-720.jpg">
6272
<source src="../../../assets/data/docs/ui-components/similarity-map/selection-720.webm" type="video/webm" />

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@
5454
"d3-brush": "^3.0.0",
5555
"d3-cloud": "^1.2.5",
5656
"d3-delaunay": "^6.0.2",
57+
"d3-drag": "^3.0.0",
5758
"d3-format": "^3.1.0",
59+
"d3-polygon": "^3.0.1",
5860
"d3-scale": "^4.0.2",
5961
"d3-selection": "^3.0.0",
6062
"d3-shape": "^3.2.0",

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,14 @@ analyzers = [
9797
torch = [
9898
"torch>=2.0.0",
9999
"torchcodec",
100+
"torchvision",
100101
]
101102
all = [
102103
"cleanlab",
103104
"cleanvision",
104105
"torch>=2.0.0",
105106
"torchcodec",
107+
"torchvision",
106108
]
107109

108110
[project.scripts]
@@ -196,6 +198,7 @@ explicit = true
196198
[tool.uv.sources]
197199
torch = { index = "pytorch-cpu" }
198200
torchcodec = { index = "pytorch-cpu" }
201+
torchvision = { index = "pytorch-cpu" }
199202

200203
[tool.hatch.build.targets.sdist]
201204
include = [

src/components/AppBar.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import HelpIcon from '../icons/Help';
55
import OpenFolderIcon from '../icons/OpenFolder';
66
import ColorPaletteIcon from '../icons/ColorPalette';
77
import NumberIcon from '../icons/Number';
8+
import SelectionIcon from '../icons/Selection';
9+
import LassoIcon from '../icons/Lasso';
10+
import RectangularSelectionIcon from '../icons/RectangularSelection';
811
import Button from './ui/Button';
12+
import ToggleButton from './ui/ToggleButton';
913
import Dialog from './ui/Dialog';
1014
import Dropdown, { DropdownContext } from './ui/Dropdown';
1115
import Tooltip from './ui/Tooltip';
@@ -24,6 +28,7 @@ import ColorPaletteSelect from './ui/ColorPaletteSelect';
2428
import { categoricalPalettes, continuousPalettes } from '../palettes';
2529
import Select from './ui/Select';
2630
import { Notation, notations, useAppSettings } from '../stores/appSettings';
31+
import { SelectionMode } from './shared/Plot';
2732

2833
const NavBar = tw.nav`py-0.5 px-1 bg-gray-200 flex items-center w-full top-0 z-10 border-b border-gray-400`;
2934

@@ -193,6 +198,65 @@ const ColorMenu = () => {
193198
);
194199
};
195200

201+
const SelectionMenuContent = (): JSX.Element => {
202+
const selectionMode = useAppSettings((s) => s.selectionMode);
203+
const setSelectionMode = useAppSettings((s) => s.setSelectionMode);
204+
const dropdown = useContext(DropdownContext);
205+
206+
// close the menu after picking a tool, otherwise it keeps covering the plot
207+
const selectMode = useCallback(
208+
(mode: SelectionMode) => {
209+
setSelectionMode(mode);
210+
dropdown.hide();
211+
},
212+
[setSelectionMode, dropdown]
213+
);
214+
215+
return (
216+
<Menu tw="w-44">
217+
<Menu.Title>Selection Tool</Menu.Title>
218+
<Menu.Item>
219+
<ToggleButton
220+
tw="w-full"
221+
data-test-tag="selection-mode-rectangular"
222+
checked={selectionMode === 'rectangular'}
223+
onChange={() => selectMode('rectangular')}
224+
>
225+
<div tw="flex flex-row font-normal w-full items-center content-center">
226+
<RectangularSelectionIcon />
227+
<span tw="ml-1 text-sm">Rectangular</span>
228+
</div>
229+
</ToggleButton>
230+
</Menu.Item>
231+
<Menu.Item>
232+
<ToggleButton
233+
tw="w-full"
234+
data-test-tag="selection-mode-lasso"
235+
checked={selectionMode === 'lasso'}
236+
onChange={() => selectMode('lasso')}
237+
>
238+
<div tw="flex flex-row font-normal w-full items-center content-center">
239+
<LassoIcon />
240+
<span tw="ml-1 text-sm">Lasso</span>
241+
</div>
242+
</ToggleButton>
243+
</Menu.Item>
244+
</Menu>
245+
);
246+
};
247+
248+
const SelectionMenu = () => {
249+
const content = <SelectionMenuContent />;
250+
251+
return (
252+
<div data-test-tag="selection-mode-dropdown">
253+
<Dropdown content={content} tooltip="Selection Tool">
254+
<SelectionIcon />
255+
</Dropdown>
256+
</div>
257+
);
258+
};
259+
196260
const NumberMenu = () => {
197261
const notation = useAppSettings((s) => s.numberNotation);
198262
const onChangeNotation = (value?: Notation) => {
@@ -250,6 +314,7 @@ const AppBar = (): JSX.Element => {
250314
</Tooltip>
251315
<FileBar tw="flex-grow" />
252316
<div tw="flex items-center">
317+
<SelectionMenu />
253318
<ColorMenu />
254319
<NumberMenu />
255320
<HelpMenu />

src/components/shared/Plot/Brush.tsx

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as d3 from 'd3';
2-
import { useContext, useEffect } from 'react';
2+
import { useContext, useEffect, useState } from 'react';
33
import PlotContext from './PlotContext';
44
import { MergeStrategy, Point2d } from './types';
55

@@ -20,15 +20,39 @@ function findOrCreateGroup(svgElement: SVGSVGElement) {
2020
const Brush = ({ hidden, onSelect }: Props): JSX.Element => {
2121
const { svgRef, transformRef, xScale, yScale, points } = useContext(PlotContext);
2222

23+
// The behaviour is created once so that the group it is attached to survives
24+
// changes of the data or the scales. Recreating the group would move it to
25+
// the end of the svg and thereby change the hit testing order against the
26+
// axes, which are appended after it.
27+
const [brush] = useState(() =>
28+
d3
29+
.brush()
30+
.filter(({ button, altKey }) => button === 0 && !altKey)
31+
.keyModifiers(false)
32+
);
33+
2334
useEffect(() => {
2435
const svgElement = svgRef.current;
2536
if (!svgElement) return;
26-
if (!transformRef.current) return;
2737

28-
const brush = d3
29-
.brush()
30-
.filter(({ button, altKey }) => button === 0 && !altKey)
31-
.keyModifiers(false);
38+
const brushGroup = findOrCreateGroup(svgElement);
39+
brushGroup.call(brush);
40+
41+
return () => {
42+
// Detach the handlers before dropping the group. The listeners of a
43+
// gesture that is still in flight live on the window and cannot be
44+
// removed from here, so clearing the dispatch is what stops a stale
45+
// onSelect from firing after the component has unmounted.
46+
brush.on('start brush end', null);
47+
brushGroup.on('.brush', null);
48+
brushGroup.remove();
49+
};
50+
}, [svgRef, brush]);
51+
52+
useEffect(() => {
53+
const svgElement = svgRef.current;
54+
if (!svgElement) return;
55+
if (!transformRef.current) return;
3256

3357
const brushGroup = findOrCreateGroup(svgElement);
3458

@@ -86,8 +110,7 @@ const Brush = ({ hidden, onSelect }: Props): JSX.Element => {
86110
};
87111

88112
brush.on('end', handleBrush);
89-
brushGroup.call(brush);
90-
}, [svgRef, transformRef, xScale, yScale, points, hidden, onSelect]);
113+
}, [brush, svgRef, transformRef, xScale, yScale, points, hidden, onSelect]);
91114

92115
return <></>;
93116
};

0 commit comments

Comments
 (0)