diff --git a/CHANGELOG.md b/CHANGELOG.md
index f9d8f79..799864e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 1.15.0
+
+- Feat: add programmatic lasso selection API via new `lassoSelect()` method that accept a polygon in either data or GL space. This enables automated point selection without manual interaction. Supports `merge` and `remove` options. Note, vertices in data space requires `xScale` and `yScale` to be defined.
+
## 1.14.1
- Fix: ensure view aspect ratio is updated before the scales are updated on resize
diff --git a/README.md b/README.md
index 6031b28..2b467f1 100644
--- a/README.md
+++ b/README.md
@@ -516,6 +516,85 @@ scatterplot.draw([
scatterplot.select([0, 1]);
```
+# scatterplot.lassoSelect(vertices, options = {})
+
+Programmatically select points within a polygon region. This enables automated point selection without manual lasso interaction. This will trigger a `select` event (and `lassoEnd` event) unless `options.preventEvent === true`.
+
+**Arguments:**
+
+- `vertices` is an array of `[x, y]` coordinate pairs defining the polygon (minimum 3 vertices required). Coordinates are in **data space** by default (requires `xScale` and `yScale`), or **GL space** if `options.isGl === true`.
+- `options` [optional] is an object with the following properties:
+ - `merge`: if `true`, add the selected points to the current selection instead of replacing it.
+ - `remove`: if `true`, remove the selected points from the current selection.
+ - `isGl`: if `true`, interpret vertices as GL space coordinates (NDC). If `false` (default), interpret vertices as data space coordinates.
+ - `preventEvent`: if `true` the `select` and `lassoEnd` events will not be published.
+
+**Notes:**
+
+- Polygons are automatically closed if the first and last vertices differ.
+- Data space coordinates require `xScale` and `yScale` to be defined during scatterplot initialization.
+- GL space coordinates work without scales and are useful for direct NDC coordinate selection.
+
+**Examples:**
+
+```javascript
+import { scaleLinear } from 'd3-scale';
+
+// Create scatterplot with scales for data space coordinates
+const scatterplot = createScatterplot({
+ canvas,
+ xScale: scaleLinear().domain([0, 100]),
+ yScale: scaleLinear().domain([0, 100]),
+});
+
+// Draw points (internally stored in NDC, but we think in data space)
+scatterplot.draw([
+ [-0.8, -0.8], // corresponds to data coords ~[10, 10]
+ [0.8, 0.8], // corresponds to data coords ~[90, 90]
+ [0, 0], // corresponds to data coords [50, 50]
+]);
+
+// Select points within a triangular region (data space coordinates)
+scatterplot.lassoSelect([
+ [10, 20],
+ [50, 80],
+ [90, 30]
+]);
+
+// Select points within a rectangle, merging with current selection
+scatterplot.lassoSelect(
+ [
+ [0, 0],
+ [100, 0],
+ [100, 100],
+ [0, 100]
+ ],
+ { merge: true }
+);
+
+// Remove points in a polygon from the current selection
+scatterplot.lassoSelect(
+ [
+ [40, 40],
+ [60, 40],
+ [60, 60],
+ [40, 60]
+ ],
+ { remove: true }
+);
+
+// Use GL space coordinates (useful without scales)
+scatterplot.lassoSelect(
+ [
+ [-0.5, -0.5],
+ [0.5, -0.5],
+ [0.5, 0.5],
+ [-0.5, 0.5]
+ ],
+ { isGl: true }
+);
+```
+
# scatterplot.deselect(options = {})
Deselect all selected points. This will trigger a `deselect` event unless `options.preventEvent === true`.
diff --git a/example/menu.js b/example/menu.js
index 5b48c15..8224f4f 100644
--- a/example/menu.js
+++ b/example/menu.js
@@ -230,6 +230,13 @@ export function createMenu({
active: pathname === 'annotations.html',
});
+ examples.addBlade({
+ view: 'link',
+ label: 'Programmatic Lasso',
+ link: 'programmatic-lasso.html',
+ active: pathname === 'programmatic-lasso.html',
+ });
+
examples.addBlade({
view: 'link',
label: 'Multiple Instances',
diff --git a/example/programmatic-lasso.js b/example/programmatic-lasso.js
new file mode 100644
index 0000000..a4a24e8
--- /dev/null
+++ b/example/programmatic-lasso.js
@@ -0,0 +1,293 @@
+import { axisBottom, axisRight } from 'd3-axis';
+import { scaleLinear } from 'd3-scale';
+import { select } from 'd3-selection';
+
+import createScatterplot from '../src';
+import createMenu from './menu';
+import { checkSupport } from './utils';
+
+const parentWrapper = document.querySelector('#parent-wrapper');
+const canvasWrapper = document.querySelector('#canvas-wrapper');
+const canvas = document.querySelector('#canvas');
+
+// Create button container with grid layout
+const buttonContainer = document.createElement('div');
+buttonContainer.style.cssText = `
+ position: absolute;
+ top: 10px;
+ left: 10px;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 6px;
+ max-width: 400px;
+ max-height: calc(100vh - 20px);
+ overflow-y: auto;
+ z-index: 1000;
+`;
+parentWrapper.appendChild(buttonContainer);
+
+const xDomain = [0, 100];
+const yDomain = [0, 100];
+const xScale = scaleLinear().domain(xDomain);
+const yScale = scaleLinear().domain(yDomain);
+const xAxis = axisBottom(xScale);
+const yAxis = axisRight(yScale);
+const axisContainer = select(parentWrapper).append('svg');
+const xAxisContainer = axisContainer.append('g');
+const yAxisContainer = axisContainer.append('g');
+const xAxisPadding = 20;
+const yAxisPadding = 40;
+
+axisContainer.node().style.position = 'absolute';
+axisContainer.node().style.top = 0;
+axisContainer.node().style.left = 0;
+axisContainer.node().style.width = '100%';
+axisContainer.node().style.height = '100%';
+axisContainer.node().style.pointerEvents = 'none';
+
+canvasWrapper.style.right = `${yAxisPadding}px`;
+canvasWrapper.style.bottom = `${xAxisPadding}px`;
+
+let { width, height } = canvasWrapper.getBoundingClientRect();
+
+xAxisContainer.attr('transform', `translate(0, ${height})`).call(xAxis);
+yAxisContainer.attr('transform', `translate(${width}, 0)`).call(yAxis);
+
+// Render grid
+xAxis.tickSizeInner(-height);
+yAxis.tickSizeInner(-width);
+
+let points = [];
+let numPoints = 5000;
+let pointSize = 4;
+let opacity = 0.66;
+let selection = [];
+
+const selectHandler = ({ points: selectedPoints }) => {
+ console.log('Selected:', selectedPoints.length, 'points');
+ selection = selectedPoints;
+};
+
+const deselectHandler = () => {
+ console.log('Deselected');
+ selection = [];
+};
+
+const scatterplot = createScatterplot({
+ canvas,
+ pointSize,
+ opacity,
+ xScale,
+ yScale,
+ showReticle: true,
+ lassoInitiator: true,
+ pointColor: [0.33, 0.5, 1, 1],
+ pointColorActive: [1, 0.5, 0, 1],
+});
+
+checkSupport(scatterplot);
+
+console.log(`Scatterplot v${scatterplot.get('version')}`);
+
+scatterplot.subscribe('select', selectHandler);
+scatterplot.subscribe('deselect', deselectHandler);
+scatterplot.subscribe('view', (event) => {
+ xAxisContainer.call(xAxis.scale(event.xScale));
+ yAxisContainer.call(yAxis.scale(event.yScale));
+});
+
+scatterplot.subscribe(
+ 'init',
+ () => {
+ xAxisContainer.call(xAxis.scale(scatterplot.get('xScale')));
+ yAxisContainer.call(yAxis.scale(scatterplot.get('yScale')));
+ },
+ 1
+);
+
+const resizeHandler = () => {
+ ({ width, height } = canvasWrapper.getBoundingClientRect());
+
+ xAxisContainer.attr('transform', `translate(0, ${height})`).call(xAxis);
+ yAxisContainer.attr('transform', `translate(${width}, 0)`).call(yAxis);
+
+ // Render grid
+ xAxis.tickSizeInner(-height);
+ yAxis.tickSizeInner(-width);
+};
+
+window.addEventListener('resize', resizeHandler);
+window.addEventListener('orientationchange', resizeHandler);
+
+// Generate points in DATA SPACE (not NDC)
+const generatePoints = (num) => {
+ const pts = [];
+ for (let i = 0; i < num; i++) {
+ const x = Math.random() * 100; // 0 to 100 (data space)
+ const y = Math.random() * 100; // 0 to 100 (data space)
+
+ // Convert to NDC for scatterplot
+ const xNdc = (x / 100) * 2 - 1;
+ const yNdc = (y / 100) * 2 - 1;
+
+ pts.push([
+ xNdc,
+ yNdc,
+ Math.round(Math.random() * 4), // category
+ Math.random(), // value
+ x, // store original x for reference
+ y, // store original y for reference
+ ]);
+ }
+ return pts;
+};
+
+const setNumPoints = (newNumPoints) => {
+ points = generatePoints(newNumPoints);
+ scatterplot.draw(points);
+};
+
+createMenu({ scatterplot, setNumPoints });
+
+scatterplot.set({
+ colorBy: 'category',
+ pointColor: [
+ '#3a84cc',
+ '#56bf92',
+ '#eecb62',
+ '#c76526',
+ '#d192b7',
+ ],
+});
+
+// Helper function to create a button
+const createButton = (label, onClick, wide = false) => {
+ const btn = document.createElement('button');
+ btn.textContent = label;
+ btn.style.cssText = `
+ padding: 6px 10px;
+ background: #3a84cc;
+ color: white;
+ border: none;
+ border-radius: 3px;
+ cursor: pointer;
+ font-size: 11px;
+ white-space: nowrap;
+ text-align: center;
+ ${wide ? 'grid-column: 1 / -1;' : ''}
+ `;
+ btn.addEventListener('mouseenter', () => {
+ btn.style.background = '#2a6cb0';
+ });
+ btn.addEventListener('mouseleave', () => {
+ btn.style.background = '#3a84cc';
+ });
+ btn.addEventListener('click', onClick);
+ return btn;
+};
+
+// Button 1: Select bottom-left triangle
+buttonContainer.appendChild(
+ createButton('△ Bottom-Left', () => {
+ scatterplot.lassoSelect([
+ [10, 10],
+ [40, 10],
+ [10, 40],
+ ]);
+ })
+);
+
+// Button 2: Select top-right circle (approximated by polygon)
+buttonContainer.appendChild(
+ createButton('○ Top-Right', () => {
+ const cx = 75;
+ const cy = 75;
+ const radius = 20;
+ const sides = 16;
+ const polygon = [];
+
+ for (let i = 0; i < sides; i++) {
+ const angle = (i / sides) * Math.PI * 2;
+ polygon.push([
+ cx + Math.cos(angle) * radius,
+ cy + Math.sin(angle) * radius,
+ ]);
+ }
+
+ scatterplot.lassoSelect(polygon);
+ })
+);
+
+// Button 3: Select center rectangle
+buttonContainer.appendChild(
+ createButton('▭ Center', () => {
+ scatterplot.lassoSelect([
+ [30, 30],
+ [70, 30],
+ [70, 70],
+ [30, 70],
+ ]);
+ })
+);
+
+// Button 4: Add diagonal stripe (merge)
+buttonContainer.appendChild(
+ createButton('+ Diagonal (Merge)', () => {
+ scatterplot.lassoSelect(
+ [
+ [0, 40],
+ [60, 100],
+ [70, 100],
+ [10, 40],
+ ],
+ { merge: true }
+ );
+ })
+);
+
+// Button 5: Remove center square
+buttonContainer.appendChild(
+ createButton('− Center (Remove)', () => {
+ scatterplot.lassoSelect(
+ [
+ [40, 40],
+ [60, 40],
+ [60, 60],
+ [40, 60],
+ ],
+ { remove: true }
+ );
+ })
+);
+
+// Button 6: Star shape
+buttonContainer.appendChild(
+ createButton('★ Star', () => {
+ const cx = 50;
+ const cy = 50;
+ const outerRadius = 30;
+ const innerRadius = 15;
+ const points = 5;
+ const polygon = [];
+
+ for (let i = 0; i < points * 2; i++) {
+ const angle = (i / (points * 2)) * Math.PI * 2 - Math.PI / 2;
+ const radius = i % 2 === 0 ? outerRadius : innerRadius;
+ polygon.push([
+ cx + Math.cos(angle) * radius,
+ cy + Math.sin(angle) * radius,
+ ]);
+ }
+
+ scatterplot.lassoSelect(polygon);
+ })
+);
+
+// Button 7: Deselect all (wide button)
+buttonContainer.appendChild(
+ createButton('✕ Deselect All', () => {
+ scatterplot.deselect();
+ }, true)
+);
+
+setNumPoints(numPoints);
diff --git a/src/index.js b/src/index.js
index 1638a72..5f5bd26 100644
--- a/src/index.js
+++ b/src/index.js
@@ -148,7 +148,7 @@ import {
isHorizontalLine,
isMultipleColors,
isPointInPolygon,
- isPolygon,
+ isPolygonAnnotation,
isPositiveNumber,
isRect,
isSameRgbas,
@@ -156,12 +156,14 @@ import {
isString,
isValidBBox,
isVerticalLine,
+ isVertices,
limit,
max,
min,
rgbBrightness,
toArrayOrientedPoints,
toRgba,
+ verticesToPolygon,
} from './utils.js';
import { version } from '../package.json';
@@ -785,6 +787,40 @@ const createScatterplot = (
}
};
+ /**
+ * Convert vertices from data space to GL space
+ * @param {Array<[number, number]>} vertices - Vertices in data space
+ * @returns {number[] | null} Flat array of GL coordinates or null if scales not defined
+ */
+ const verticesFromDataToGl = (vertices) => {
+ // Check if xScale/yScale are defined
+ if (!(xScale && yScale)) {
+ // biome-ignore lint/suspicious/noConsole: User warning for missing configuration
+ console.warn(
+ 'xScale and yScale must be defined for programmatic lasso selection',
+ );
+ return null;
+ }
+
+ const verticesGl = [];
+ for (const [x, y] of vertices) {
+ // Step 1: Data space → normalized [0, 1]
+ const xNorm = (x - xDomainStart) / xDomainSize;
+ const yNorm = (y - yDomainStart) / yDomainSize;
+
+ // Step 2: Normalized [0, 1] → NDC [-1, 1]
+ const xNdc = xNorm * 2 - 1;
+ const yNdc = yNorm * 2 - 1;
+
+ // Step 3: NDC → GL space (camera transform)
+ const [xGl, yGl] = getScatterGlPos(xNdc, yNdc);
+
+ verticesGl.push(xGl, yGl);
+ }
+
+ return verticesGl;
+ };
+
/**
* Select and highlight a set of points
* @param {number | number[]} pointIdxs
@@ -871,6 +907,47 @@ const createScatterplot = (
draw = true;
};
+ /**
+ * Lasso a certain area and select contained points
+ * @param {[number, number][]} vertices - Lasso vertices in either data space (default) or GL space
+ * @param {import('./types').ScatterplotMethodOptions['lasso']}
+ */
+ const lassoSelect = (
+ vertices,
+ { merge = false, remove = false, isGl = false } = {},
+ ) => {
+ if (!isVertices(vertices)) {
+ throw new Error(
+ 'Lasso selection requires at least 3 vertices as [x, y] coordinate pairs',
+ );
+ }
+
+ const closedPolygon = verticesToPolygon(vertices);
+
+ let polygonGl;
+ let polygonGlFlat;
+
+ if (isGl) {
+ polygonGl = closedPolygon;
+ polygonGlFlat = closedPolygon.flat();
+ } else {
+ polygonGlFlat = verticesFromDataToGl(closedPolygon);
+
+ if (!polygonGlFlat) {
+ throw new Error(
+ 'xScale and yScale must be defined to convert lasso vertices from data space to GL space',
+ );
+ }
+
+ polygonGl = [];
+ for (let i = 0; i < polygonGlFlat.length; i += 2) {
+ polygonGl.push([polygonGlFlat[i], polygonGlFlat[i + 1]]);
+ }
+ }
+
+ lassoEnd(polygonGl, polygonGlFlat, { merge, remove });
+ };
+
/**
* @param {number} point
* @param {import('./types').ScatterplotMethodOptions['hover']} options
@@ -2730,7 +2807,7 @@ const createScatterplot = (
continue;
}
- if (isPolygon(annotation)) {
+ if (isPolygonAnnotation(annotation)) {
newPoints.push(annotation.vertices.flatMap(identity));
addColorAndWidth(annotation);
}
@@ -4572,6 +4649,7 @@ const createScatterplot = (
get,
getScreenPosition,
hover,
+ lassoSelect,
redraw,
refresh: renderer.refresh,
reset: withDraw(reset),
diff --git a/src/utils.js b/src/utils.js
index 68dfaf9..0a963f1 100644
--- a/src/utils.js
+++ b/src/utils.js
@@ -578,9 +578,48 @@ export const isRect = (annotation) =>
Number.isFinite(annotation.x2) &&
Number.isFinite(annotation.x2);
-export const isPolygon = (annotation) =>
+export const isPolygonAnnotation = (annotation) =>
'vertices' in annotation && annotation.vertices.length > 1;
+/**
+ * Check if an array is a valid list of 2D vertices
+ * @param {any} arg - The argument to check
+ * @returns {boolean} True if argument is an array of [x, y] coordinate pairs
+ */
+export const isVertices = (arg) => {
+ if (!Array.isArray(arg) || arg.length < 3) {
+ return false;
+ }
+
+ for (const vertex of arg) {
+ if (
+ !Array.isArray(vertex) ||
+ vertex.length !== 2 ||
+ typeof vertex[0] !== 'number' ||
+ typeof vertex[1] !== 'number'
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+};
+
+/**
+ * Ensure a list of vertices forms a closed polygon
+ * @param {Array<[number, number]>} vertices - Array of [x, y] coordinates
+ * @returns {Array<[number, number]>} Closed polygon (first vertex repeated at end if needed)
+ */
+export const verticesToPolygon = (vertices) => {
+ const polygon = [...vertices];
+ const firstVertex = vertices.at(0);
+ const lastVertex = vertices.at(-1);
+ if (firstVertex[0] !== lastVertex[0] || firstVertex[1] !== lastVertex[1]) {
+ polygon.push(firstVertex);
+ }
+ return polygon;
+};
+
export const insertionSort = (array) => {
const end = array.length;
for (let i = 1; i < end; i++) {
diff --git a/tests/methods.test.js b/tests/methods.test.js
index 681da45..dbb006c 100644
--- a/tests/methods.test.js
+++ b/tests/methods.test.js
@@ -232,6 +232,306 @@ test('select()', async () => {
scatterplot.destroy();
});
+test('lassoSelect() with data space coordinates', async () => {
+ const { scaleLinear } = await import('d3-scale');
+
+ const scatterplot = createScatterplot({
+ canvas: createCanvas(),
+ xScale: scaleLinear().domain([0, 100]),
+ yScale: scaleLinear().domain([0, 100]),
+ });
+
+ // Draw 5 points in data space coordinates
+ // Points at: [10,10], [90,90], [90,10], [10,90], [50,50]
+ const points = [
+ [-0.8, -0.8], // 10,10
+ [0.8, 0.8], // 90,90
+ [0.8, -0.8], // 90,10
+ [-0.8, 0.8], // 10,90
+ [0, 0], // 50,50
+ ];
+
+ await scatterplot.draw(points);
+
+ let selectedPoints = [];
+ const selectHandler = ({ points: newSelectedPoints }) => {
+ selectedPoints = [...newSelectedPoints];
+ };
+ scatterplot.subscribe('select', selectHandler);
+
+ // Select bottom-left quadrant in data space coordinates
+ // Should select point 0 (10,10) and point 4 (50,50)
+ scatterplot.lassoSelect([
+ [0, 0],
+ [60, 0],
+ [60, 60],
+ [0, 60],
+ ]);
+
+ await wait(0);
+
+ expect(selectedPoints.sort()).toEqual([0, 4]);
+
+ scatterplot.destroy();
+});
+
+test('lassoSelect() with GL space coordinates', async () => {
+ const { scaleLinear } = await import('d3-scale');
+
+ const scatterplot = createScatterplot({
+ canvas: createCanvas(),
+ xScale: scaleLinear().domain([0, 100]),
+ yScale: scaleLinear().domain([0, 100]),
+ });
+
+ const points = [
+ [-0.8, -0.8], // bottom-left
+ [0.8, 0.8], // top-right
+ [0.8, -0.8], // bottom-right
+ [-0.8, 0.8], // top-left
+ [0, 0], // center
+ ];
+
+ await scatterplot.draw(points);
+
+ let selectedPoints = [];
+ const selectHandler = ({ points: newSelectedPoints }) => {
+ selectedPoints = [...newSelectedPoints];
+ };
+ scatterplot.subscribe('select', selectHandler);
+
+ // Select using GL space coordinates (NDC space since no camera transform)
+ // Select left side: points 0, 3, 4
+ scatterplot.lassoSelect(
+ [
+ [-1, -1],
+ [0.1, -1],
+ [0.1, 1],
+ [-1, 1],
+ ],
+ { isGl: true }
+ );
+
+ await wait(0);
+
+ expect(selectedPoints.sort()).toEqual([0, 3, 4]);
+
+ scatterplot.destroy();
+});
+
+test('lassoSelect() with open polygon (auto-closes)', async () => {
+ const { scaleLinear } = await import('d3-scale');
+
+ const scatterplot = createScatterplot({
+ canvas: createCanvas(),
+ xScale: scaleLinear().domain([0, 100]),
+ yScale: scaleLinear().domain([0, 100]),
+ });
+
+ const points = [
+ [-0.5, -0.5],
+ [0.5, 0.5],
+ [0.5, -0.5],
+ ];
+
+ await scatterplot.draw(points);
+
+ let selectedPoints = [];
+ const selectHandler = ({ points: newSelectedPoints }) => {
+ selectedPoints = [...newSelectedPoints];
+ };
+ scatterplot.subscribe('select', selectHandler);
+
+ // Pass open polygon (first and last vertices different)
+ // Should auto-close and still work
+ scatterplot.lassoSelect([
+ [0, 0],
+ [100, 0],
+ [100, 100],
+ [0, 100],
+ // Note: NOT closing back to [0, 0]
+ ]);
+
+ await wait(0);
+
+ // All points should be selected
+ expect(selectedPoints.sort()).toEqual([0, 1, 2]);
+
+ scatterplot.destroy();
+});
+
+test('lassoSelect() with merge and remove options', async () => {
+ const { scaleLinear } = await import('d3-scale');
+
+ const scatterplot = createScatterplot({
+ canvas: createCanvas(),
+ xScale: scaleLinear().domain([0, 100]),
+ yScale: scaleLinear().domain([0, 100]),
+ });
+
+ const points = [
+ [-0.8, -0.8], // 10,10
+ [0.8, 0.8], // 90,90
+ [0.8, -0.8], // 90,10
+ [-0.8, 0.8], // 10,90
+ [0, 0], // 50,50
+ ];
+
+ await scatterplot.draw(points);
+
+ let selectedPoints = [];
+ const selectHandler = ({ points: newSelectedPoints }) => {
+ selectedPoints = [...newSelectedPoints];
+ };
+ scatterplot.subscribe('select', selectHandler);
+
+ // Select bottom-left quadrant (points 0, 4)
+ scatterplot.lassoSelect([
+ [0, 0],
+ [60, 0],
+ [60, 60],
+ [0, 60],
+ ]);
+
+ await wait(0);
+ expect(selectedPoints.sort()).toEqual([0, 4]);
+
+ // Merge with top-left quadrant (should add point 3)
+ scatterplot.lassoSelect(
+ [
+ [0, 40],
+ [60, 40],
+ [60, 100],
+ [0, 100],
+ ],
+ { merge: true }
+ );
+
+ await wait(0);
+ expect(selectedPoints.sort()).toEqual([0, 3, 4]);
+
+ // Remove center region (should remove point 4)
+ scatterplot.lassoSelect(
+ [
+ [25, 25],
+ [75, 25],
+ [75, 75],
+ [25, 75],
+ ],
+ { remove: true }
+ );
+
+ await wait(0);
+ expect(selectedPoints.sort()).toEqual([0, 3]);
+
+ scatterplot.destroy();
+});
+
+test('lassoSelect() error: less than 3 vertices', async () => {
+ const { scaleLinear } = await import('d3-scale');
+
+ const scatterplot = createScatterplot({
+ canvas: createCanvas(),
+ xScale: scaleLinear().domain([0, 100]),
+ yScale: scaleLinear().domain([0, 100]),
+ });
+
+ await scatterplot.draw([[0, 0]]);
+
+ // Should throw error with less than 3 vertices
+ expect(() => {
+ scatterplot.lassoSelect([
+ [10, 10],
+ [50, 50],
+ // Only 2 points - invalid
+ ]);
+ }).toThrow('Lasso selection requires at least 3 vertices');
+
+ scatterplot.destroy();
+});
+
+test('lassoSelect() error: invalid vertices format', async () => {
+ const { scaleLinear } = await import('d3-scale');
+
+ const scatterplot = createScatterplot({
+ canvas: createCanvas(),
+ xScale: scaleLinear().domain([0, 100]),
+ yScale: scaleLinear().domain([0, 100]),
+ });
+
+ await scatterplot.draw([[0, 0]]);
+
+ // Should throw error with invalid format
+ expect(() => {
+ scatterplot.lassoSelect([
+ [10, 10],
+ [50], // Invalid: missing y coordinate
+ [90, 90],
+ ]);
+ }).toThrow('Lasso selection requires at least 3 vertices');
+
+ scatterplot.destroy();
+});
+
+test('lassoSelect() error: no scales defined', async () => {
+ const scatterplot = createScatterplot({
+ canvas: createCanvas(),
+ // No xScale/yScale defined
+ });
+
+ await scatterplot.draw([[0, 0]]);
+
+ // Should throw error when trying to use data space without scales
+ expect(() => {
+ scatterplot.lassoSelect([
+ [10, 10],
+ [50, 50],
+ [90, 90],
+ ]);
+ }).toThrow('xScale and yScale must be defined');
+
+ scatterplot.destroy();
+});
+
+test('lassoSelect() works without scales when using isGl: true', async () => {
+ const scatterplot = createScatterplot({
+ canvas: createCanvas(),
+ // No xScale/yScale defined
+ });
+
+ const points = [
+ [-0.5, -0.5],
+ [0.5, 0.5],
+ [0.5, -0.5],
+ ];
+
+ await scatterplot.draw(points);
+
+ let selectedPoints = [];
+ const selectHandler = ({ points: newSelectedPoints }) => {
+ selectedPoints = [...newSelectedPoints];
+ };
+ scatterplot.subscribe('select', selectHandler);
+
+ // Should work with GL coordinates even without scales
+ scatterplot.lassoSelect(
+ [
+ [-1, -1],
+ [1, -1],
+ [1, 1],
+ [-1, 1],
+ ],
+ { isGl: true }
+ );
+
+ await wait(0);
+
+ // All points should be selected
+ expect(selectedPoints.sort()).toEqual([0, 1, 2]);
+
+ scatterplot.destroy();
+});
+
test('hover() with columnar data', async () => {
const scatterplot = createScatterplot({ canvas: createCanvas() });
diff --git a/vite.config.mjs b/vite.config.mjs
index 3097cf0..1650857 100644
--- a/vite.config.mjs
+++ b/vite.config.mjs
@@ -15,6 +15,7 @@ const chunks = [
'transition',
'multiple-instances',
'annotations',
+ 'programmatic-lasso',
];
const pages = Object.fromEntries(