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(