Skip to content

Commit 752d26e

Browse files
committed
feat: implement programmatic lasso selection
- Add polygon selection API to select() method - Accepts array of [x,y] coordinate pairs in data space - Reuses existing findPointsInLasso() with KD-tree - Supports merge and remove modes - Add programmatic-lasso.js example with interactive demo - Update README with API documentation and examples - Add to Examples menu in Tweakpane
1 parent 7666ec6 commit 752d26e

File tree

5 files changed

+409
-5
lines changed

5 files changed

+409
-5
lines changed

README.md

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -498,24 +498,60 @@ Select some points, such that they get visually highlighted. This will trigger a
498498

499499
**Arguments:**
500500

501-
- `points` is an array of point indices referencing the points that you want to select.
501+
- `points` is either:
502+
- An array of point indices referencing the points that you want to select, OR
503+
- An array of `[x, y]` coordinate pairs defining a polygon in **data space** (requires `xScale` and `yScale` to be defined). All points within the polygon will be selected using the same lasso selection algorithm as the interactive lasso tool.
502504
- `options` [optional] is an object with the following properties:
503-
- `preventEvent`: if `true` the `select` will not be published.
505+
- `preventEvent`: if `true` the `select` event will not be published.
506+
- `merge`: if `true` the selected points will be added to the current selection.
507+
- `remove`: if `true` the selected points will be removed from the current selection.
504508

505509
**Examples:**
506510

507511
```javascript
512+
// Selection by point indices
508513
// Let's say we have three points
509514
scatterplot.draw([
510515
[0.1, 0.1],
511516
[0.2, 0.2],
512517
[0.3, 0.3],
513518
]);
514519

515-
// To select the first and second point we have to do
520+
// To select the first and second point
516521
scatterplot.select([0, 1]);
522+
523+
// Programmatic lasso selection (polygon in data space)
524+
// Requires xScale and yScale to be defined
525+
const xScale = scaleLinear().domain([0, 100]);
526+
const yScale = scaleLinear().domain([0, 100]);
527+
const scatterplot = createScatterplot({ xScale, yScale, ... });
528+
529+
// Select all points within a triangular region
530+
scatterplot.select([
531+
[10, 20],
532+
[50, 80],
533+
[90, 30]
534+
]);
535+
536+
// Select points within a rectangle and merge with existing selection
537+
scatterplot.select([
538+
[0, 0],
539+
[100, 0],
540+
[100, 100],
541+
[0, 100]
542+
], { merge: true });
543+
544+
// Remove points within a circle from the selection
545+
const cx = 50, cy = 50, radius = 20;
546+
const circlePolygon = Array.from({ length: 16 }, (_, i) => {
547+
const angle = (i / 16) * Math.PI * 2;
548+
return [cx + Math.cos(angle) * radius, cy + Math.sin(angle) * radius];
549+
});
550+
scatterplot.select(circlePolygon, { remove: true });
517551
```
518552

553+
[Code Example](example/programmatic-lasso.js) | [Demo](https://flekschas.github.io/regl-scatterplot/programmatic-lasso.html)
554+
519555
<a name="scatterplot.deselect" href="#scatterplot.deselect">#</a> scatterplot.<b>deselect</b>(<i>options = {}</i>)
520556

521557
Deselect all selected points. This will trigger a `deselect` event unless `options.preventEvent === true`.

example/menu.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,13 @@ export function createMenu({
230230
active: pathname === 'annotations.html',
231231
});
232232

233+
examples.addBlade({
234+
view: 'link',
235+
label: 'Programmatic Lasso',
236+
link: 'programmatic-lasso.html',
237+
active: pathname === 'programmatic-lasso.html',
238+
});
239+
233240
examples.addBlade({
234241
view: 'link',
235242
label: 'Multiple Instances',

example/programmatic-lasso.js

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
import { axisBottom, axisRight } from 'd3-axis';
2+
import { scaleLinear } from 'd3-scale';
3+
import { select } from 'd3-selection';
4+
5+
import createScatterplot from '../src';
6+
import createMenu from './menu';
7+
import { checkSupport } from './utils';
8+
9+
const parentWrapper = document.querySelector('#parent-wrapper');
10+
const canvasWrapper = document.querySelector('#canvas-wrapper');
11+
const canvas = document.querySelector('#canvas');
12+
13+
// Create button container with grid layout
14+
const buttonContainer = document.createElement('div');
15+
buttonContainer.style.cssText = `
16+
position: absolute;
17+
top: 10px;
18+
left: 10px;
19+
display: grid;
20+
grid-template-columns: 1fr 1fr;
21+
gap: 6px;
22+
max-width: 400px;
23+
max-height: calc(100vh - 20px);
24+
overflow-y: auto;
25+
z-index: 1000;
26+
`;
27+
parentWrapper.appendChild(buttonContainer);
28+
29+
const xDomain = [0, 100];
30+
const yDomain = [0, 100];
31+
const xScale = scaleLinear().domain(xDomain);
32+
const yScale = scaleLinear().domain(yDomain);
33+
const xAxis = axisBottom(xScale);
34+
const yAxis = axisRight(yScale);
35+
const axisContainer = select(parentWrapper).append('svg');
36+
const xAxisContainer = axisContainer.append('g');
37+
const yAxisContainer = axisContainer.append('g');
38+
const xAxisPadding = 20;
39+
const yAxisPadding = 40;
40+
41+
axisContainer.node().style.position = 'absolute';
42+
axisContainer.node().style.top = 0;
43+
axisContainer.node().style.left = 0;
44+
axisContainer.node().style.width = '100%';
45+
axisContainer.node().style.height = '100%';
46+
axisContainer.node().style.pointerEvents = 'none';
47+
48+
canvasWrapper.style.right = `${yAxisPadding}px`;
49+
canvasWrapper.style.bottom = `${xAxisPadding}px`;
50+
51+
let { width, height } = canvasWrapper.getBoundingClientRect();
52+
53+
xAxisContainer.attr('transform', `translate(0, ${height})`).call(xAxis);
54+
yAxisContainer.attr('transform', `translate(${width}, 0)`).call(yAxis);
55+
56+
// Render grid
57+
xAxis.tickSizeInner(-height);
58+
yAxis.tickSizeInner(-width);
59+
60+
let points = [];
61+
let numPoints = 5000;
62+
let pointSize = 4;
63+
let opacity = 0.66;
64+
let selection = [];
65+
66+
const selectHandler = ({ points: selectedPoints }) => {
67+
console.log('Selected:', selectedPoints.length, 'points');
68+
selection = selectedPoints;
69+
};
70+
71+
const deselectHandler = () => {
72+
console.log('Deselected');
73+
selection = [];
74+
};
75+
76+
const scatterplot = createScatterplot({
77+
canvas,
78+
pointSize,
79+
opacity,
80+
xScale,
81+
yScale,
82+
showReticle: true,
83+
lassoInitiator: true,
84+
pointColor: [0.33, 0.5, 1, 1],
85+
pointColorActive: [1, 0.5, 0, 1],
86+
});
87+
88+
checkSupport(scatterplot);
89+
90+
console.log(`Scatterplot v${scatterplot.get('version')}`);
91+
92+
scatterplot.subscribe('select', selectHandler);
93+
scatterplot.subscribe('deselect', deselectHandler);
94+
scatterplot.subscribe('view', (event) => {
95+
xAxisContainer.call(xAxis.scale(event.xScale));
96+
yAxisContainer.call(yAxis.scale(event.yScale));
97+
});
98+
99+
scatterplot.subscribe(
100+
'init',
101+
() => {
102+
xAxisContainer.call(xAxis.scale(scatterplot.get('xScale')));
103+
yAxisContainer.call(yAxis.scale(scatterplot.get('yScale')));
104+
},
105+
1
106+
);
107+
108+
const resizeHandler = () => {
109+
({ width, height } = canvasWrapper.getBoundingClientRect());
110+
111+
xAxisContainer.attr('transform', `translate(0, ${height})`).call(xAxis);
112+
yAxisContainer.attr('transform', `translate(${width}, 0)`).call(yAxis);
113+
114+
// Render grid
115+
xAxis.tickSizeInner(-height);
116+
yAxis.tickSizeInner(-width);
117+
};
118+
119+
window.addEventListener('resize', resizeHandler);
120+
window.addEventListener('orientationchange', resizeHandler);
121+
122+
// Generate points in DATA SPACE (not NDC)
123+
const generatePoints = (num) => {
124+
const pts = [];
125+
for (let i = 0; i < num; i++) {
126+
const x = Math.random() * 100; // 0 to 100 (data space)
127+
const y = Math.random() * 100; // 0 to 100 (data space)
128+
129+
// Convert to NDC for scatterplot
130+
const xNdc = (x / 100) * 2 - 1;
131+
const yNdc = (y / 100) * 2 - 1;
132+
133+
pts.push([
134+
xNdc,
135+
yNdc,
136+
Math.round(Math.random() * 4), // category
137+
Math.random(), // value
138+
x, // store original x for reference
139+
y, // store original y for reference
140+
]);
141+
}
142+
return pts;
143+
};
144+
145+
const setNumPoints = (newNumPoints) => {
146+
points = generatePoints(newNumPoints);
147+
scatterplot.draw(points);
148+
};
149+
150+
createMenu({ scatterplot, setNumPoints });
151+
152+
scatterplot.set({
153+
colorBy: 'category',
154+
pointColor: [
155+
'#3a84cc',
156+
'#56bf92',
157+
'#eecb62',
158+
'#c76526',
159+
'#d192b7',
160+
],
161+
});
162+
163+
// Helper function to create a button
164+
const createButton = (label, onClick, wide = false) => {
165+
const btn = document.createElement('button');
166+
btn.textContent = label;
167+
btn.style.cssText = `
168+
padding: 6px 10px;
169+
background: #3a84cc;
170+
color: white;
171+
border: none;
172+
border-radius: 3px;
173+
cursor: pointer;
174+
font-size: 11px;
175+
white-space: nowrap;
176+
text-align: center;
177+
${wide ? 'grid-column: 1 / -1;' : ''}
178+
`;
179+
btn.addEventListener('mouseenter', () => {
180+
btn.style.background = '#2a6cb0';
181+
});
182+
btn.addEventListener('mouseleave', () => {
183+
btn.style.background = '#3a84cc';
184+
});
185+
btn.addEventListener('click', onClick);
186+
return btn;
187+
};
188+
189+
// Button 1: Select bottom-left triangle
190+
buttonContainer.appendChild(
191+
createButton('△ Bottom-Left', () => {
192+
scatterplot.select([
193+
[10, 10],
194+
[40, 10],
195+
[10, 40],
196+
]);
197+
})
198+
);
199+
200+
// Button 2: Select top-right circle (approximated by polygon)
201+
buttonContainer.appendChild(
202+
createButton('○ Top-Right', () => {
203+
const cx = 75;
204+
const cy = 75;
205+
const radius = 20;
206+
const sides = 16;
207+
const polygon = [];
208+
209+
for (let i = 0; i < sides; i++) {
210+
const angle = (i / sides) * Math.PI * 2;
211+
polygon.push([
212+
cx + Math.cos(angle) * radius,
213+
cy + Math.sin(angle) * radius,
214+
]);
215+
}
216+
217+
scatterplot.select(polygon);
218+
})
219+
);
220+
221+
// Button 3: Select center rectangle
222+
buttonContainer.appendChild(
223+
createButton('▭ Center', () => {
224+
scatterplot.select([
225+
[30, 30],
226+
[70, 30],
227+
[70, 70],
228+
[30, 70],
229+
]);
230+
})
231+
);
232+
233+
// Button 4: Add diagonal stripe (merge)
234+
buttonContainer.appendChild(
235+
createButton('+ Diagonal (Merge)', () => {
236+
scatterplot.select(
237+
[
238+
[0, 40],
239+
[60, 100],
240+
[70, 100],
241+
[10, 40],
242+
],
243+
{ merge: true }
244+
);
245+
})
246+
);
247+
248+
// Button 5: Remove center square
249+
buttonContainer.appendChild(
250+
createButton('− Center (Remove)', () => {
251+
scatterplot.select(
252+
[
253+
[40, 40],
254+
[60, 40],
255+
[60, 60],
256+
[40, 60],
257+
],
258+
{ remove: true }
259+
);
260+
})
261+
);
262+
263+
// Button 6: Star shape
264+
buttonContainer.appendChild(
265+
createButton('★ Star', () => {
266+
const cx = 50;
267+
const cy = 50;
268+
const outerRadius = 30;
269+
const innerRadius = 15;
270+
const points = 5;
271+
const polygon = [];
272+
273+
for (let i = 0; i < points * 2; i++) {
274+
const angle = (i / (points * 2)) * Math.PI * 2 - Math.PI / 2;
275+
const radius = i % 2 === 0 ? outerRadius : innerRadius;
276+
polygon.push([
277+
cx + Math.cos(angle) * radius,
278+
cy + Math.sin(angle) * radius,
279+
]);
280+
}
281+
282+
scatterplot.select(polygon);
283+
})
284+
);
285+
286+
// Button 7: Deselect all (wide button)
287+
buttonContainer.appendChild(
288+
createButton('✕ Deselect All', () => {
289+
scatterplot.deselect();
290+
}, true)
291+
);
292+
293+
setNumPoints(numPoints);

0 commit comments

Comments
 (0)