From 2bd9ac870562537f3ffe85801d6716e855196be6 Mon Sep 17 00:00:00 2001 From: Fritz Lekschas Date: Sun, 19 Jan 2025 13:53:20 -0500 Subject: [PATCH] feat: rectangle and brush type lasso selection (#212) * feat: add `brush` and `rectangle` lasso types Fix #186 * feat: add tweakpane to examples for better customizations * feat: allow removing selected points Fix #105 * refactor: rename `keyMap` to `actionKeyMap` To allow triggering multiple actions with the same key. * test: rectangle and brush lasso selections * docs: update changelog * docs: link ticket --- CHANGELOG.md | 3 + README.md | 19 +- example/annotations.js | 83 +------ example/axes.js | 84 +------ example/connected-points-by-segments.js | 91 +------ example/connected-points.js | 87 +------ example/dynamic-opacity.js | 97 +------- example/embedded.js | 11 +- example/index.js | 84 +------ example/menu.js | 306 ++++++++++++++++++++++++ example/multiple-instances.js | 66 +---- example/performance-mode.js | 10 +- example/size-encoding.js | 87 +------ example/text-labels.js | 87 +------ example/texture-background.js | 87 +------ example/transition.js | 60 +---- example/tweakpane-link-plugin.js | 136 +++++++++++ example/utils.js | 2 +- package-lock.json | 26 ++ package.json | 3 + public/index.html | 269 +++------------------ src/constants.js | 17 +- src/index.js | 176 +++++++++++--- src/lasso-manager/constants.js | 2 + src/lasso-manager/index.js | 216 +++++++++++++++-- src/types.d.ts | 1 + tests/constructor.test.js | 16 +- tests/events.test.js | 134 ++++++++++- tests/get-set.test.js | 29 ++- tests/methods.test.js | 29 +-- tests/utils.js | 6 - 31 files changed, 1148 insertions(+), 1176 deletions(-) create mode 100644 example/menu.js create mode 100644 example/tweakpane-link-plugin.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 974ba6a..d0c54d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## 1.13.0 +- Feat: add support for two new lasso types: `'rectangle'` and `'brush'`. The lasso type can be changed via `lassoType`. Additionally, for the brush lasso, you can adjust the brush size via `lassoBrushSize`. The default lasso type is `'freeform'`. ([#186](/~https://github.com/flekschas/regl-scatterplot/issues/186)) +- Feat: replace `keyMap` with `actionKeyMap` to allow triggering multiple actions with the same modifier key. +- Feat: add `'remove'` key action to allow removing selecting points. By default, to rgitemove selected points hold down `ALT` and then lasso around selected points. ([#105](/~https://github.com/flekschas/regl-scatterplot/issues/105)) - Feat: expose `renderPointsAsSquares` and `disableAlphaBlending` to allow finer control over performance increasing settings ([#206](/~https://github.com/flekschas/regl-scatterplot/issues/206)) ## 1.12.1 diff --git a/README.md b/README.md index 080dab4..48d1de3 100644 --- a/README.md +++ b/README.md @@ -793,6 +793,7 @@ can be read and written via [`scatterplot.get()`](#scatterplot.get) and [`scatte | pointConnectionMaxIntPointsPerSegment | int | `100` | | `true` | `false` | | pointConnectionTolerance | float | `0.002` | | `true` | `false` | | pointScaleMode | string | `'asinh'` | `'asinh'`, `'linear'`, or `'constant'` | `true` | `false` | +| lassoType | string | `'freeform'` | `'freeform'`, `'rectangle'`, or `'brush'` | `true` | `false` | | lassoColor | quadruple | rgba(0, 0.667, 1, 1) | hex, rgb, rgba | `true` | `false` | | lassoLineWidth | float | 2 | >= 1 | `true` | `false` | | lassoMinDelay | int | 15 | >= 0 | `true` | `false` | @@ -807,11 +808,12 @@ can be read and written via [`scatterplot.get()`](#scatterplot.get) and [`scatte | lassoLongPressAfterEffectTime | int | `500` | | `true` | `false` | | lassoLongPressEffectDelay | int | `100` | | `true` | `false` | | lassoLongPressRevertEffectTime | int | `250` | | `true` | `false` | +| lassoBrushSize | int | `24` | | `true` | `false` | | showReticle | boolean | `false` | `true` or `false` | `true` | `false` | | reticleColor | quadruple | rgba(1, 1, 1, .5) | hex, rgb, rgba | `true` | `false` | | xScale | function | `null` | must follow the D3 scale API | `true` | `true` | | yScale | function | `null` | must follow the D3 scale API | `true` | `true` | -| keyMap | object | `{ alt: 'rotate', shift: 'lasso' }` | See the notes below | `true` | `false` | +| actionKeyMap | object | `{ remove: 'alt': rotate: 'alt', merge: 'cmd', lasso: 'shift' }` | See the notes below | `true` | `false` | | mouseMode | string | `'panZoom'` | `'panZoom'`, `'lasso'`, or `'rotate'` | `true` | `false` | | performanceMode | boolean | `false` | can only be set during initialization! | `true` | `false` | | gamma | float | `1` | to control the opacity blending | `true` | `false` | @@ -911,15 +913,16 @@ You don't like the look of the lasso initiator? No problem. Simple get the DOM element via `scatterplot.get('lassoInitiatorElement')` and adjust the style via JavaScript. E.g.: `scatterplot.get('lassoInitiatorElement').style.background = 'green'`. -# KeyMap: +# ActionKeyMap: -The `keyMap` property is an object defining which actions are enabled when -holding down which modifier key. E.g.: `{ shift: 'lasso' }`. Acceptable -modifier keys are `alt`, `cmd`, `ctrl`, `meta`, `shift`. Acceptable actions -are `lasso`, `rotate`, and `merge` (for selecting multiple items by merging a series of lasso or click selections). +The `actionKeyMap` property is an object defining which actions are enabled when +holding down which modifier key. E.g.: `{ lasso: 'shift' }`. Acceptable actions +are `lasso`, `rotate`, `merge` (for selecting multiple items by merging a series +of lasso or click selections), and `remove` (for removing selected points). +Acceptable modifier keys are `alt`, `cmd`, `ctrl`, `meta`, `shift`. -You can also use the `keyMap` option to disable the lasso selection and rotation -by setting `keyMap` to an empty object. +You can also use the `actionKeyMap` option to disable the lasso selection and +rotation by setting `actionKeyMap` to an empty object. # Examples: diff --git a/example/annotations.js b/example/annotations.js index 6f3c8d9..7bdaafb 100644 --- a/example/annotations.js +++ b/example/annotations.js @@ -1,22 +1,8 @@ -/* eslint no-console: 0 */ - import createScatterplot from '../src'; -import { saveAsPng, checkSupport } from './utils'; +import createMenu from './menu'; +import { checkSupport } from './utils'; const canvas = document.querySelector('#canvas'); -const numPointsEl = document.querySelector('#num-points'); -const numPointsValEl = document.querySelector('#num-points-value'); -const pointSizeEl = document.querySelector('#point-size'); -const pointSizeValEl = document.querySelector('#point-size-value'); -const opacityEl = document.querySelector('#opacity'); -const opacityValEl = document.querySelector('#opacity-value'); -const clickLassoInitiatorEl = document.querySelector('#click-lasso-initiator'); -const resetEl = document.querySelector('#reset'); -const exportEl = document.querySelector('#export'); -const exampleEl = document.querySelector('#example-annotations'); - -exampleEl.setAttribute('class', 'active'); -exampleEl.removeAttribute('href'); let points = { x: [], y: [], z: [], w: [] }; let numPoints = 100000; @@ -60,6 +46,7 @@ const scatterplot = createScatterplot({ lassoMinDelay, lassoMinDist, pointSize, + opacity, showReticle, reticleColor, lassoOnLongPress: true, @@ -67,8 +54,6 @@ const scatterplot = createScatterplot({ checkSupport(scatterplot); -exportEl.addEventListener('click', () => saveAsPng(scatterplot)); - console.log(`Scatterplot v${scatterplot.get('version')}`); scatterplot.subscribe('select', selectHandler); @@ -122,64 +107,12 @@ const generatePoints = (l) => { return { x, y, z, w }; }; -const setNumPoint = (newNumPoints) => { - numPoints = newNumPoints; - numPointsEl.value = numPoints; - numPointsValEl.innerHTML = numPoints; - points = generatePoints(numPoints); +const setNumPoints = (newNumPoints) => { + points = generatePoints(newNumPoints); scatterplot.draw(points); }; -const numPointsInputHandler = (event) => { - numPointsValEl.innerHTML = `${+event.target - .value} release to redraw`; -}; - -numPointsEl.addEventListener('input', numPointsInputHandler); - -const numPointsChangeHandler = (event) => setNumPoint(+event.target.value); - -numPointsEl.addEventListener('change', numPointsChangeHandler); - -const setPointSize = (newPointSize) => { - pointSize = newPointSize; - pointSizeEl.value = pointSize; - pointSizeValEl.innerHTML = pointSize; - scatterplot.set({ pointSize }); -}; - -const pointSizeInputHandler = (event) => setPointSize(+event.target.value); - -pointSizeEl.addEventListener('input', pointSizeInputHandler); - -const setOpacity = (newOpacity) => { - opacity = newOpacity; - opacityEl.value = opacity; - opacityValEl.innerHTML = opacity; - scatterplot.set({ opacity }); -}; - -const opacityInputHandler = (event) => setOpacity(+event.target.value); - -opacityEl.addEventListener('input', opacityInputHandler); - -const clickLassoInitiatorChangeHandler = (event) => { - scatterplot.set({ - lassoInitiator: event.target.checked, - }); -}; - -clickLassoInitiatorEl.addEventListener( - 'change', - clickLassoInitiatorChangeHandler -); -clickLassoInitiatorEl.checked = scatterplot.get('lassoInitiator'); - -const resetClickHandler = () => { - scatterplot.reset(); -}; - -resetEl.addEventListener('click', resetClickHandler); +createMenu({ scatterplot, setNumPoints }); const colorsCat = ['#3a78aa', '#aa3a99']; scatterplot.set({ colorBy: 'category', pointColor: colorsCat }); @@ -187,9 +120,7 @@ scatterplot.set({ colorBy: 'category', pointColor: colorsCat }); const colorsScale = ['#009E73', '#CC79A7', '#56B4E9', '#F0E442']; scatterplot.set({ colorBy: 'z', pointColor: colorsScale }); -setPointSize(pointSize); -setOpacity(opacity); -setNumPoint(numPoints); +setNumPoints(numPoints); scatterplot.drawAnnotations([ { x: 0, lineColor: [1, 1, 1, 0.1], lineWidth: 1 }, diff --git a/example/axes.js b/example/axes.js index cfa6b99..5971cdf 100644 --- a/example/axes.js +++ b/example/axes.js @@ -1,28 +1,14 @@ -/* eslint no-console: 0 */ - import { axisBottom, axisRight } from 'd3-axis'; import { scaleLinear } from 'd3-scale'; import { select } from 'd3-selection'; import createScatterplot from '../src'; -import { saveAsPng, checkSupport } from './utils'; +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'); -const numPointsEl = document.querySelector('#num-points'); -const numPointsValEl = document.querySelector('#num-points-value'); -const pointSizeEl = document.querySelector('#point-size'); -const pointSizeValEl = document.querySelector('#point-size-value'); -const opacityEl = document.querySelector('#opacity'); -const opacityValEl = document.querySelector('#opacity-value'); -const clickLassoInitiatorEl = document.querySelector('#click-lasso-initiator'); -const resetEl = document.querySelector('#reset'); -const exportEl = document.querySelector('#export'); -const exampleBg = document.querySelector('#example-axes'); - -exampleBg.setAttribute('class', 'active'); -exampleBg.removeAttribute('href'); const xDomain = [0, 42]; const yDomain = [0, 4.2]; @@ -80,6 +66,7 @@ const deselectHandler = () => { const scatterplot = createScatterplot({ canvas, pointSize, + opacity, xScale, yScale, showReticle: true, @@ -88,8 +75,6 @@ const scatterplot = createScatterplot({ checkSupport(scatterplot); -exportEl.addEventListener('click', () => saveAsPng(scatterplot)); - console.log(`Scatterplot v${scatterplot.get('version')}`); scatterplot.subscribe('select', selectHandler); @@ -130,65 +115,12 @@ const generatePoints = (num) => Math.random(), // value ]); -const setNumPoint = (newNumPoints) => { - numPoints = newNumPoints; - numPointsEl.value = numPoints; - numPointsValEl.innerHTML = numPoints; - points = generatePoints(numPoints); - +const setNumPoints = (newNumPoints) => { + points = generatePoints(newNumPoints); scatterplot.draw(points); }; -const numPointsInputHandler = (event) => { - numPointsValEl.innerHTML = `${+event.target - .value} release to redraw`; -}; - -numPointsEl.addEventListener('input', numPointsInputHandler); - -const numPointsChangeHandler = (event) => setNumPoint(+event.target.value); - -numPointsEl.addEventListener('change', numPointsChangeHandler); - -const setPointSize = (newPointSize) => { - pointSize = newPointSize; - pointSizeEl.value = pointSize; - pointSizeValEl.innerHTML = pointSize; - scatterplot.set({ pointSize }); -}; - -const pointSizeInputHandler = (event) => setPointSize(+event.target.value); - -pointSizeEl.addEventListener('input', pointSizeInputHandler); - -const setOpacity = (newOpacity) => { - opacity = newOpacity; - opacityEl.value = opacity; - opacityValEl.innerHTML = opacity; - scatterplot.set({ opacity }); -}; - -const opacityInputHandler = (event) => setOpacity(+event.target.value); - -opacityEl.addEventListener('input', opacityInputHandler); - -const clickLassoInitiatorChangeHandler = (event) => { - scatterplot.set({ - lassoInitiator: event.target.checked, - }); -}; - -clickLassoInitiatorEl.addEventListener( - 'change', - clickLassoInitiatorChangeHandler -); -clickLassoInitiatorEl.checked = scatterplot.get('lassoInitiator'); - -const resetClickHandler = () => { - scatterplot.reset(); -}; - -resetEl.addEventListener('click', resetClickHandler); +createMenu({ scatterplot, setNumPoints }); scatterplot.set({ colorBy: 'category', @@ -203,6 +135,4 @@ scatterplot.set({ ], }); -setPointSize(pointSize); -setOpacity(opacity); -setNumPoint(numPoints); +setNumPoints(numPoints); diff --git a/example/connected-points-by-segments.js b/example/connected-points-by-segments.js index 7295aea..5124b01 100644 --- a/example/connected-points-by-segments.js +++ b/example/connected-points-by-segments.js @@ -1,24 +1,8 @@ -/* eslint no-console: 0 */ - import createScatterplot from '../src'; -import { saveAsPng, checkSupport } from './utils'; +import createMenu from './menu'; +import { checkSupport } from './utils'; const canvas = document.querySelector('#canvas'); -const numPointsEl = document.querySelector('#num-points'); -const numPointsValEl = document.querySelector('#num-points-value'); -const pointSizeEl = document.querySelector('#point-size'); -const pointSizeValEl = document.querySelector('#point-size-value'); -const opacityEl = document.querySelector('#opacity'); -const opacityValEl = document.querySelector('#opacity-value'); -const clickLassoInitiatorEl = document.querySelector('#click-lasso-initiator'); -const resetEl = document.querySelector('#reset'); -const exportEl = document.querySelector('#export'); -const exampleEl = document.querySelector( - '#example-connected-points-by-segment' -); - -exampleEl.setAttribute('class', 'active'); -exampleEl.removeAttribute('href'); let points = []; let numPoints = 9000; @@ -56,6 +40,7 @@ const scatterplot = createScatterplot({ lassoMinDelay, lassoMinDist, pointSize, + opacity, showReticle, reticleColor, showPointConnections, @@ -66,8 +51,6 @@ const scatterplot = createScatterplot({ checkSupport(scatterplot); -exportEl.addEventListener('click', () => saveAsPng(scatterplot)); - console.log(`Scatterplot v${scatterplot.get('version')}`); scatterplot.subscribe('select', selectHandler); @@ -97,69 +80,22 @@ const generatePoints = (num) => { return outPoints; }; -const setNumPoint = (newNumPoints) => { - numPoints = newNumPoints; - numPointsEl.value = numPoints; - numPointsValEl.innerHTML = numPoints; - points = generatePoints(numPoints); +const setNumPoints = (newNumPoints) => { + points = generatePoints(newNumPoints); scatterplot.draw(points); }; -const numPointsInputHandler = (event) => { - numPointsValEl.innerHTML = `${+event.target - .value} release to redraw`; -}; - -numPointsEl.addEventListener('input', numPointsInputHandler); - -const numPointsChangeHandler = (event) => setNumPoint(+event.target.value); - -numPointsEl.addEventListener('change', numPointsChangeHandler); - const getPointSizeRange = (basePointSize) => - Array(100) - .fill() - .map((x, i) => 1 + (i / 99) * (basePointSize * pointSizeMax - 1)); + Array.from( + { length: 100 }, + (_, i) => 1 + (i / 99) * (basePointSize * pointSizeMax - 1) + ); const setPointSize = (newPointSize) => { - pointSize = newPointSize; - pointSizeEl.value = pointSize; - pointSizeValEl.innerHTML = pointSize; - scatterplot.set({ pointSize: getPointSizeRange(pointSize) }); -}; - -const pointSizeInputHandler = (event) => setPointSize(+event.target.value); - -pointSizeEl.addEventListener('input', pointSizeInputHandler); - -const setOpacity = (newOpacity) => { - opacity = newOpacity; - opacityEl.value = opacity; - opacityValEl.innerHTML = opacity; - scatterplot.set({ opacity }); -}; - -const opacityInputHandler = (event) => setOpacity(+event.target.value); - -opacityEl.addEventListener('input', opacityInputHandler); - -const clickLassoInitiatorChangeHandler = (event) => { - scatterplot.set({ - lassoInitiator: event.target.checked, - }); -}; - -clickLassoInitiatorEl.addEventListener( - 'change', - clickLassoInitiatorChangeHandler -); -clickLassoInitiatorEl.checked = scatterplot.get('lassoInitiator'); - -const resetClickHandler = () => { - scatterplot.reset(); + scatterplot.set({ pointSize: getPointSizeRange(newPointSize) }); }; -resetEl.addEventListener('click', resetClickHandler); +createMenu({ scatterplot, setNumPoints, setPointSize }); scatterplot.set({ colorBy: 'valueZ', @@ -183,6 +119,5 @@ scatterplot.set({ .map((v, i) => (i + 8) / 256), }); -setPointSize(pointSize); -setOpacity(opacity); -setNumPoint(numPoints); +setPointSize(pointSize) +setNumPoints(numPoints); diff --git a/example/connected-points.js b/example/connected-points.js index bf61904..2b12a3b 100644 --- a/example/connected-points.js +++ b/example/connected-points.js @@ -1,22 +1,8 @@ -/* eslint no-console: 0 */ - import createScatterplot from '../src'; -import { saveAsPng, checkSupport } from './utils'; +import createMenu from './menu'; +import { checkSupport } from './utils'; const canvas = document.querySelector('#canvas'); -const numPointsEl = document.querySelector('#num-points'); -const numPointsValEl = document.querySelector('#num-points-value'); -const pointSizeEl = document.querySelector('#point-size'); -const pointSizeValEl = document.querySelector('#point-size-value'); -const opacityEl = document.querySelector('#opacity'); -const opacityValEl = document.querySelector('#opacity-value'); -const clickLassoInitiatorEl = document.querySelector('#click-lasso-initiator'); -const resetEl = document.querySelector('#reset'); -const exportEl = document.querySelector('#export'); -const exampleEl = document.querySelector('#example-connected-points'); - -exampleEl.setAttribute('class', 'active'); -exampleEl.removeAttribute('href'); let points = []; let numPoints = 9000; @@ -54,6 +40,7 @@ const scatterplot = createScatterplot({ lassoMinDelay, lassoMinDist, pointSize, + opacity, showReticle, reticleColor, showPointConnections, @@ -64,8 +51,6 @@ const scatterplot = createScatterplot({ checkSupport(scatterplot); -exportEl.addEventListener('click', () => saveAsPng(scatterplot)); - console.log(`Scatterplot v${scatterplot.get('version')}`); scatterplot.subscribe('select', selectHandler); @@ -96,69 +81,22 @@ const generatePoints = (num) => { return outPoints; }; -const setNumPoint = (newNumPoints) => { - numPoints = newNumPoints; - numPointsEl.value = numPoints; - numPointsValEl.innerHTML = numPoints; - points = generatePoints(numPoints); +const setNumPoints = (newNumPoints) => { + points = generatePoints(newNumPoints); scatterplot.draw(points); }; -const numPointsInputHandler = (event) => { - numPointsValEl.innerHTML = `${+event.target - .value} release to redraw`; -}; - -numPointsEl.addEventListener('input', numPointsInputHandler); - -const numPointsChangeHandler = (event) => setNumPoint(+event.target.value); - -numPointsEl.addEventListener('change', numPointsChangeHandler); - const getPointSizeRange = (basePointSize) => - Array(100) - .fill() - .map((x, i) => 1 + (i / 99) * (basePointSize * pointSizeMax - 1)); + Array.from( + { length: 100 }, + (_, i) => 1 + (i / 99) * (basePointSize * pointSizeMax - 1) + ); const setPointSize = (newPointSize) => { - pointSize = newPointSize; - pointSizeEl.value = pointSize; - pointSizeValEl.innerHTML = pointSize; - scatterplot.set({ pointSize: getPointSizeRange(pointSize) }); -}; - -const pointSizeInputHandler = (event) => setPointSize(+event.target.value); - -pointSizeEl.addEventListener('input', pointSizeInputHandler); - -const setOpacity = (newOpacity) => { - opacity = newOpacity; - opacityEl.value = opacity; - opacityValEl.innerHTML = opacity; - scatterplot.set({ opacity }); -}; - -const opacityInputHandler = (event) => setOpacity(+event.target.value); - -opacityEl.addEventListener('input', opacityInputHandler); - -const clickLassoInitiatorChangeHandler = (event) => { - scatterplot.set({ - lassoInitiator: event.target.checked, - }); -}; - -clickLassoInitiatorEl.addEventListener( - 'change', - clickLassoInitiatorChangeHandler -); -clickLassoInitiatorEl.checked = scatterplot.get('lassoInitiator'); - -const resetClickHandler = () => { - scatterplot.reset(); + scatterplot.set({ pointSize: getPointSizeRange(newPointSize) }); }; -resetEl.addEventListener('click', resetClickHandler); +createMenu({ scatterplot, setNumPoints, setPointSize }); scatterplot.set({ colorBy: 'valueZ', @@ -195,5 +133,4 @@ scatterplot.set({ }); setPointSize(pointSize); -setOpacity(opacity); -setNumPoint(numPoints); +setNumPoints(numPoints); diff --git a/example/dynamic-opacity.js b/example/dynamic-opacity.js index d5a0761..2e5a212 100644 --- a/example/dynamic-opacity.js +++ b/example/dynamic-opacity.js @@ -1,22 +1,8 @@ -/* eslint no-console: 0 */ - import createScatterplot from '../src'; -import { saveAsPng, checkSupport } from './utils'; +import createMenu from './menu'; +import { checkSupport } from './utils'; const canvas = document.querySelector('#canvas'); -const numPointsEl = document.querySelector('#num-points'); -const numPointsValEl = document.querySelector('#num-points-value'); -const pointSizeEl = document.querySelector('#point-size'); -const pointSizeValEl = document.querySelector('#point-size-value'); -const opacityEl = document.querySelector('#opacity'); -const opacityValEl = document.querySelector('#opacity-value'); -const clickLassoInitiatorEl = document.querySelector('#click-lasso-initiator'); -const resetEl = document.querySelector('#reset'); -const exportEl = document.querySelector('#export'); -const exampleEl = document.querySelector('#example-dynamic-opacity'); - -exampleEl.setAttribute('class', 'active'); -exampleEl.removeAttribute('href'); let points = []; let numPoints = 1000000; @@ -59,8 +45,6 @@ const scatterplot = createScatterplot({ checkSupport(scatterplot); -exportEl.addEventListener('click', () => saveAsPng(scatterplot)); - console.log(`Scatterplot v${scatterplot.get('version')}`); scatterplot.subscribe('select', selectHandler); @@ -128,80 +112,11 @@ const generatePoints = (num) => { return newPoints; }; -const setNumPoint = (newNumPoints) => { - numPoints = newNumPoints; - numPointsEl.value = numPoints; - numPointsValEl.innerHTML = numPoints; - points = generatePoints(numPoints); +const setNumPoints = (newNumPoints) => { + points = generatePoints(newNumPoints); scatterplot.draw(points); }; -const numPointsInputHandler = (event) => { - numPointsValEl.innerHTML = `${+event.target - .value} release to redraw`; -}; - -numPointsEl.addEventListener('input', numPointsInputHandler); - -const numPointsChangeHandler = (event) => setNumPoint(+event.target.value); - -numPointsEl.addEventListener('change', numPointsChangeHandler); - -const setPointSize = (newPointSize) => { - pointSize = newPointSize; - pointSizeEl.value = pointSize; - pointSizeValEl.innerHTML = pointSize; - scatterplot.set({ pointSize }); -}; - -const pointSizeInputHandler = (event) => setPointSize(+event.target.value); -pointSizeEl.addEventListener('input', pointSizeInputHandler); - -const opacityByDensityFillGroup = opacityEl.parentNode.cloneNode(true); -opacityEl.parentNode.after(opacityByDensityFillGroup); - -opacityEl.style.display = 'none'; -opacityValEl.innerHTML = 'Auto'; - -const opacityByDensityFillEl = opacityByDensityFillGroup.querySelector('input'); -const opacityByDensityFillLabEl = - opacityByDensityFillGroup.querySelector('.label'); -const opacityByDensityFillValEl = - opacityByDensityFillGroup.querySelector('.value'); - -opacityByDensityFillLabEl.innerHTML = 'Opacity Density Fill'; - -const setOpacityByDensityFill = (newOpacityByDensityFill, silent) => { - opacityByDensityFill = newOpacityByDensityFill; - opacityByDensityFillEl.value = opacityByDensityFill; - opacityByDensityFillValEl.innerHTML = opacityByDensityFill; - if (!silent) scatterplot.set({ opacityByDensityFill }); -}; -const opacityByDensityFillInputHandler = (event) => - setOpacityByDensityFill(+event.target.value); -opacityByDensityFillEl.addEventListener( - 'input', - opacityByDensityFillInputHandler -); -setOpacityByDensityFill(opacityByDensityFill, true); - -const clickLassoInitiatorChangeHandler = (event) => { - scatterplot.set({ - lassoInitiator: event.target.checked, - }); -}; - -clickLassoInitiatorEl.addEventListener( - 'change', - clickLassoInitiatorChangeHandler -); -clickLassoInitiatorEl.checked = scatterplot.get('lassoInitiator'); - -const resetClickHandler = () => { - scatterplot.reset(); -}; - -resetEl.addEventListener('click', resetClickHandler); +createMenu({ scatterplot, setNumPoints }); -setPointSize(pointSize); -setNumPoint(numPoints); +setNumPoints(numPoints); diff --git a/example/embedded.js b/example/embedded.js index 69f6644..16864ee 100644 --- a/example/embedded.js +++ b/example/embedded.js @@ -1,6 +1,5 @@ -/* eslint no-console: 0 */ - import createScatterplot from '../src'; +import createMenu from './menu'; import { saveAsPng, checkSupport } from './utils'; const canvas = document.querySelector('#canvas'); @@ -71,7 +70,7 @@ const generatePoints = (num) => Math.random(), // value ]); -const setNumPoint = (newNumPoints) => { +const setNumPoints = (newNumPoints) => { numPoints = newNumPoints; numPointsEl.value = numPoints; numPointsValEl.innerHTML = numPoints; @@ -79,6 +78,8 @@ const setNumPoint = (newNumPoints) => { scatterplot.draw(points); }; +createMenu({ scatterplot, setNumPoints }); + const numPointsInputHandler = (event) => { numPointsValEl.innerHTML = `${+event.target .value} release to redraw`; @@ -86,7 +87,7 @@ const numPointsInputHandler = (event) => { numPointsEl.addEventListener('input', numPointsInputHandler); -const numPointsChangeHandler = (event) => setNumPoint(+event.target.value); +const numPointsChangeHandler = (event) => setNumPoints(+event.target.value); numPointsEl.addEventListener('change', numPointsChangeHandler); @@ -132,4 +133,4 @@ resetEl.addEventListener('click', resetClickHandler); setPointSize(pointSize); setOpacity(opacity); -setNumPoint(numPoints); +setNumPoints(numPoints); diff --git a/example/index.js b/example/index.js index 50dd35c..c012fa4 100644 --- a/example/index.js +++ b/example/index.js @@ -1,22 +1,8 @@ -/* eslint no-console: 0 */ - import createScatterplot from '../src'; -import { saveAsPng, checkSupport } from './utils'; +import createMenu from './menu'; +import { checkSupport } from './utils'; const canvas = document.querySelector('#canvas'); -const numPointsEl = document.querySelector('#num-points'); -const numPointsValEl = document.querySelector('#num-points-value'); -const pointSizeEl = document.querySelector('#point-size'); -const pointSizeValEl = document.querySelector('#point-size-value'); -const opacityEl = document.querySelector('#opacity'); -const opacityValEl = document.querySelector('#opacity-value'); -const clickLassoInitiatorEl = document.querySelector('#click-lasso-initiator'); -const resetEl = document.querySelector('#reset'); -const exportEl = document.querySelector('#export'); -const exampleEl = document.querySelector('#example-basic'); - -exampleEl.setAttribute('class', 'active'); -exampleEl.removeAttribute('href'); let points = { x: [], y: [], z: [], w: [] }; let numPoints = 100000; @@ -77,13 +63,13 @@ const scatterplot = createScatterplot({ pointSize, showReticle, reticleColor, - lassoInitiator: true, + opacity, + lassoOnLongPress: true, + lassoType: 'brush' }); checkSupport(scatterplot); -exportEl.addEventListener('click', () => saveAsPng(scatterplot)); - console.log(`Scatterplot v${scatterplot.get('version')}`); scatterplot.subscribe('pointover', pointoverHandler); @@ -98,64 +84,12 @@ const generatePoints = (length) => ({ w: Array.from({ length }, () => Math.random()), // value }); -const setNumPoint = (newNumPoints) => { - numPoints = newNumPoints; - numPointsEl.value = numPoints; - numPointsValEl.innerHTML = numPoints; +const setNumPoints = (newNumPoints) => { points = generatePoints(numPoints); scatterplot.draw(points); }; -const numPointsInputHandler = (event) => { - numPointsValEl.innerHTML = `${+event.target - .value} release to redraw`; -}; - -numPointsEl.addEventListener('input', numPointsInputHandler); - -const numPointsChangeHandler = (event) => setNumPoint(+event.target.value); - -numPointsEl.addEventListener('change', numPointsChangeHandler); - -const setPointSize = (newPointSize) => { - pointSize = newPointSize; - pointSizeEl.value = pointSize; - pointSizeValEl.innerHTML = pointSize; - scatterplot.set({ pointSize }); -}; - -const pointSizeInputHandler = (event) => setPointSize(+event.target.value); - -pointSizeEl.addEventListener('input', pointSizeInputHandler); - -const setOpacity = (newOpacity) => { - opacity = newOpacity; - opacityEl.value = opacity; - opacityValEl.innerHTML = opacity; - scatterplot.set({ opacity }); -}; - -const opacityInputHandler = (event) => setOpacity(+event.target.value); - -opacityEl.addEventListener('input', opacityInputHandler); - -const clickLassoInitiatorChangeHandler = (event) => { - scatterplot.set({ - lassoInitiator: event.target.checked, - }); -}; - -clickLassoInitiatorEl.addEventListener( - 'change', - clickLassoInitiatorChangeHandler -); -clickLassoInitiatorEl.checked = scatterplot.get('lassoInitiator'); - -const resetClickHandler = () => { - scatterplot.reset(); -}; - -resetEl.addEventListener('click', resetClickHandler); +createMenu({ scatterplot, setNumPoints }); const colorsCat = ['#3a78aa', '#aa3a99']; scatterplot.set({ colorBy: 'category', pointColor: colorsCat }); @@ -184,6 +118,4 @@ const colorsScale = [ ]; scatterplot.set({ colorBy: 'value', pointColor: colorsScale }); -setPointSize(pointSize); -setOpacity(opacity); -setNumPoint(numPoints); +setNumPoints(numPoints); diff --git a/example/menu.js b/example/menu.js new file mode 100644 index 0000000..5b48c15 --- /dev/null +++ b/example/menu.js @@ -0,0 +1,306 @@ +import { Pane } from 'tweakpane'; + +import { LinkPlugin } from './tweakpane-link-plugin'; +import { saveAsPng } from './utils'; + +const DEFAULT_PARAMS = { + numPoints: 100000, + pointSize: 2, + opacity: 0.33, + opacityByDensity: false, + lassoInit: 'longPress', + lassoType: 'freeform', + lassoBrushSize: 24, +}; + +const set = (scatterplot, keyValuePairs) => { + if (Array.isArray(scatterplot)) { + for (const s of scatterplot) { + s.set(keyValuePairs); + } + } else { + scatterplot.set(keyValuePairs); + } +} + +export function createMenu({ + scatterplot, + setNumPoints, + setPointSize, + setOpacity, + opacityChangesDisabled, +}) { + let init = false; + + const refScatterplot = Array.isArray(scatterplot) + ? scatterplot[0] + : scatterplot; + + const params = { + ...DEFAULT_PARAMS, + numPoints: 0, + pointSize: Array.isArray(refScatterplot.get('pointSize')) + ? refScatterplot.get('pointSize')[0] + : refScatterplot.get('pointSize'), + opacity: refScatterplot.get('opacity'), + opacityByDensity: refScatterplot.get('opacityBy') === 'density', + lassoType: refScatterplot.get('lassoType'), + lassoBrushSize: refScatterplot.get('lassoBrushSize'), + }; + const initialParams = { ...params }; + + const pane = new Pane({ + title: 'Details', + container: document.getElementById('controls'), + }); + pane.registerPlugin({ id: 'link', plugins: [LinkPlugin] }); + + const settings = pane.addFolder({ title: 'Settings' }); + + const numPoints = settings.addBinding( + params, + 'numPoints', + { label: 'Num Points', min: 1000, step: 1000, max: 2000000 } + ); + numPoints.disabled = true; + if (setNumPoints) { + numPoints.on('change', ({ last, value }) => { + if (init && last) setNumPoints(value); + }); + } + + const pointSize = settings.addBinding( + params, 'pointSize', { label: 'Point Size', min: 1, max: 32, step: 1 } + ); + pointSize.disabled = Array.isArray(refScatterplot.get('pointSize')); + pointSize.on('change', ({ value }) => { + if (setPointSize) { + setPointSize(value); + } else { + set(scatterplot, { pointSize: value }); + } + }); + + const opacity = settings.addBinding( + params, + 'opacity', + { label: 'Opacity', min: 0.01, max: 1, step: 0.01 } + ); + opacity.disabled = params.opacityByDensity || Boolean(opacityChangesDisabled); + opacity.on('change', ({ value }) => { + if (setOpacity) { + setOpacity(value); + } else { + set(scatterplot, { opacity: value }); + } + }); + + const opacityByDensity = settings.addBinding( + params, + 'opacityByDensity', + { label: 'Dynamic Opacity' } + ); + opacityByDensity.disabled = Boolean(opacityChangesDisabled); + opacityByDensity.on('change', ({ value }) => { + set(scatterplot, { opacityBy: value ? 'density' : null }); + opacity.disabled = value; + }); + + const lassoInit = settings.addBinding( + params, + 'lassoInit', + { + label: 'Lasso Init', + options: { + 'On Long Press': 'longPress', + 'Via Click Initiator': 'clickInitiator' + } + } + ); + lassoInit.on('change', ({ value }) => { + switch (value) { + case 'longPress': { + set(scatterplot, { + lassoInitiator: false, + lassoOnLongPress: true, + }); + break; + } + case 'clickInitiator': { + set(scatterplot, { + lassoInitiator: true, + lassoOnLongPress: false, + }); + break; + } + } + }); + + const lassoType = settings.addBinding( + params, + 'lassoType', + { + label: 'Lasso Type', + options: { + 'Freeform': 'freeform', + 'Brush': 'brush', + 'Rectangle': 'rectangle', + } + } + ); + lassoType.on('change', ({ value }) => { + switch (value) { + case 'freeform': { + set(scatterplot, { lassoType: 'freeform' }); + break; + } + case 'brush': { + set(scatterplot, { lassoType: 'brush' }); + break; + } + case 'rectangle': { + set(scatterplot, { lassoType: 'rectangle' }); + break; + } + } + lassoBrushSize.hidden = value !== 'brush'; + }); + + const lassoBrushSize = settings.addBinding( + params, + 'lassoBrushSize', + { label: 'Brush Size', min: 1, max: 256, step: 1 } + ); + lassoBrushSize.hidden = params.lassoType !== 'brush'; + lassoBrushSize.on('change', ({ value }) => { + set(scatterplot, { lassoBrushSize: value }); + }); + + const reset = settings.addButton({ title: 'Reset' }); + reset.on('click', () => { + for (const [key, value] of Object.entries(initialParams)) { + params[key] = value; + } + pane.refresh(); + }) + + const examples = pane.addFolder({ title: 'Examples' }); + + const pathname = window.location.pathname.slice(1); + + examples.addBlade({ + view: 'link', + label: 'Color Encoding', + link: 'index.html', + active: pathname === '' || pathname === 'index.html', + }); + + examples.addBlade({ + view: 'link', + label: 'Size & Opacity Encoding', + link: 'size-encoding.html', + active: pathname === 'size-encoding.html', + }); + + examples.addBlade({ + view: 'link', + label: 'Dynamic Opacity', + link: 'dynamic-opacity.html', + active: pathname === 'dynamic-opacity.html', + }); + + examples.addBlade({ + view: 'link', + label: 'Axes', + link: 'axes.html', + active: pathname === 'axes.html', + }); + + examples.addBlade({ + view: 'link', + label: 'Text Labels', + link: 'text-labels.html', + active: pathname === 'text-labels.html', + }); + + examples.addBlade({ + view: 'link', + label: 'Annotations', + link: 'annotations.html', + active: pathname === 'annotations.html', + }); + + examples.addBlade({ + view: 'link', + label: 'Multiple Instances', + link: 'multiple-instances.html', + active: pathname === 'multiple-instances.html', + }); + + examples.addBlade({ + view: 'link', + label: 'Transition', + link: 'transition.html', + active: pathname === 'transition.html', + }); + + examples.addBlade({ + view: 'link', + label: 'Point Connections', + link: 'connected-points.html', + active: pathname === 'connected-points.html', + }); + + examples.addBlade({ + view: 'link', + label: 'Point Connections by Line Segments', + link: 'connected-points-by-segments.html', + active: pathname === 'connected-points-by-segments.html', + }); + + examples.addBlade({ + view: 'link', + label: 'Background Image', + link: 'texture-background.html', + active: pathname === 'texture-background.html', + }); + + examples.addBlade({ + view: 'link', + label: 'Performance Mode (20M Points)', + link: 'performance-mode.html', + active: pathname === 'performance-mode.html', + }); + + const info = pane.addFolder({ title: 'Info', expanded: false }); + + info.addBlade({ + view: 'text', + label: 'version', + parse: (v) => String(v), + value: refScatterplot.get('version'), + disabled: true, + }); + + const download = pane.addButton({ title: 'Download as PNG' }); + download.on('click', () => { + saveAsPng(scatterplot); + }); + + const sourceCode = pane.addButton({ title: 'Source Code' }); + sourceCode.on('click', () => { + window.open( + '/~https://github.com/flekschas/regl-scatterplot', '_blank' + ).focus(); + }); + + refScatterplot.subscribe('draw', () => { + params.numPoints = refScatterplot.get('points').length; + initialParams.numPoints = params.numPoints; + if (setNumPoints) numPoints.disabled = false; + pane.refresh(); + init = true; + }, 1); +} + +export default createMenu; diff --git a/example/multiple-instances.js b/example/multiple-instances.js index 5b19b3e..0faeae4 100644 --- a/example/multiple-instances.js +++ b/example/multiple-instances.js @@ -1,6 +1,7 @@ /* eslint no-console: 0 */ import { tableFromIPC } from 'apache-arrow'; import createScatterplot, { createRenderer } from '../src'; +import createMenu from './menu'; import { showModal, closeModal } from './utils'; /** @@ -10,7 +11,6 @@ const NUM_COLUMNS = 2; const NUM_ROWS = 2; const POINT_SIZE = 3; const OPACITY = 0.66; -const LASSO_INITIATOR = true; const FASHION_MNIST_CLASS_LABELS = [ 'T-shirt/top', 'Trouser', @@ -135,22 +135,6 @@ for (let i = 0; i < NUM_COLUMNS * NUM_ROWS - 1; i++) { canvases.push(newCanvas); } -/** - * Getting references for the menu UI elements - */ -const numPointsEl = document.querySelector('#num-points'); -const numPointsValEl = document.querySelector('#num-points-value'); -const pointSizeEl = document.querySelector('#point-size'); -const pointSizeValEl = document.querySelector('#point-size-value'); -const opacityEl = document.querySelector('#opacity'); -const opacityValEl = document.querySelector('#opacity-value'); -const clickLassoInitiatorEl = document.querySelector('#click-lasso-initiator'); -const resetEl = document.querySelector('#reset'); -const exampleEl = document.querySelector('#example-multiple-instances'); - -exampleEl.setAttribute('class', 'active'); -exampleEl.removeAttribute('href'); - /** * Create a reusable renderer */ @@ -179,52 +163,7 @@ const scatterplots = canvases.map((canvas) => console.log(`Scatterplot v${scatterplots[0].get('version')}`); -/** - * Disable num points setter because we work with fixed data - */ -numPointsEl.disabled = true; - -/** - * Link menu UI elements to the scatter plot instances - */ -const setPointSize = (newPointSize) => { - pointSizeEl.value = newPointSize; - pointSizeValEl.innerHTML = newPointSize; - scatterplots.forEach((sp) => sp.set({ pointSize: newPointSize })); -}; - -const pointSizeInputHandler = (event) => setPointSize(+event.target.value); -pointSizeEl.addEventListener('input', pointSizeInputHandler); - -setPointSize(scatterplots[0].get('pointSize')); - -const setOpacity = (newOpacity) => { - opacityEl.value = newOpacity; - opacityValEl.innerHTML = newOpacity; - scatterplots.forEach((sp) => sp.set({ opacity: newOpacity })); -}; - -const opacityInputHandler = (event) => setOpacity(+event.target.value); -opacityEl.addEventListener('input', opacityInputHandler); - -setOpacity(scatterplots[0].get('opacity')); - -const clickLassoInitiatorChangeHandler = (event) => { - scatterplots.forEach((sp) => - sp.set({ lassoInitiator: event.target.checked }) - ); -}; - -clickLassoInitiatorEl.addEventListener( - 'change', - clickLassoInitiatorChangeHandler -); -clickLassoInitiatorEl.checked = scatterplots[0].get('lassoInitiator'); - -const resetClickHandler = () => { - scatterplots.forEach((sp) => sp.reset()); -}; -resetEl.addEventListener('click', resetClickHandler); +createMenu({ scatterplot: scatterplots }); /** * Link scatter plots @@ -277,7 +216,6 @@ whenData const columnValues = table.data[0].children.map((data) => data.values); const classIds = columnValues[columnValues.length - 1]; - numPointsValEl.innerHTML = table.numRows; scatterplots.forEach((sp, i) => { sp.draw({ x: columnValues[i * 2], diff --git a/example/performance-mode.js b/example/performance-mode.js index 0361908..44da226 100644 --- a/example/performance-mode.js +++ b/example/performance-mode.js @@ -1,8 +1,8 @@ -/* eslint no-console: 0 */ import { createWorker } from '@flekschas/utils'; import { saveAsPng, checkSupport } from './utils'; import createScatterplot from '../src'; +import createMenu from './menu'; import pointWorkerFn from './performance-mode-point-worker'; const modal = document.querySelector('#modal'); @@ -87,7 +87,7 @@ console.log(`Scatterplot v${scatterplot.get('version')}`); scatterplot.subscribe('select', selectHandler); scatterplot.subscribe('deselect', deselectHandler); -const setNumPoint = (newNumPoints) => { +const setNumPoints = (newNumPoints) => { showModal( `Hang tight. Generating ${(numPoints / 1000000).toFixed( 1 @@ -104,6 +104,8 @@ const setNumPoint = (newNumPoints) => { }); }; +createMenu({ scatterplot, setNumPoints }); + const numPointsInputHandler = (event) => { numPointsValEl.innerHTML = `${+event.target .value} release to redraw`; @@ -111,7 +113,7 @@ const numPointsInputHandler = (event) => { numPointsEl.addEventListener('input', numPointsInputHandler); -const numPointsChangeHandler = (event) => setNumPoint(+event.target.value); +const numPointsChangeHandler = (event) => setNumPoints(+event.target.value); numPointsEl.addEventListener('change', numPointsChangeHandler); @@ -181,4 +183,4 @@ scatterplot.set({ colorBy: 'value', pointColor: colorsScale }); setPointSize(pointSize); setOpacity(opacity); -setNumPoint(numPoints); +setNumPoints(numPoints); diff --git a/example/size-encoding.js b/example/size-encoding.js index 03b156f..480c298 100644 --- a/example/size-encoding.js +++ b/example/size-encoding.js @@ -1,25 +1,11 @@ -/* eslint no-console: 0 */ - import { scaleLog } from 'd3-scale'; import { randomExponential } from 'd3-random'; import createScatterplot from '../src'; -import { saveAsPng, checkSupport } from './utils'; +import createMenu from './menu'; +import { checkSupport } from './utils'; const canvas = document.querySelector('#canvas'); -const numPointsEl = document.querySelector('#num-points'); -const numPointsValEl = document.querySelector('#num-points-value'); -const pointSizeEl = document.querySelector('#point-size'); -const pointSizeValEl = document.querySelector('#point-size-value'); -const opacityEl = document.querySelector('#opacity'); -const opacityValEl = document.querySelector('#opacity-value'); -const clickLassoInitiatorEl = document.querySelector('#click-lasso-initiator'); -const resetEl = document.querySelector('#reset'); -const exportEl = document.querySelector('#export'); -const exampleEl = document.querySelector('#example-size-encoding'); - -exampleEl.setAttribute('class', 'active'); -exampleEl.removeAttribute('href'); let points = []; let numPoints = 100000; @@ -53,6 +39,7 @@ const scatterplot = createScatterplot({ lassoMinDelay, lassoMinDist, pointSize, + opacity, showReticle, reticleColor, lassoInitiator: true, @@ -61,8 +48,6 @@ const scatterplot = createScatterplot({ checkSupport(scatterplot); -exportEl.addEventListener('click', () => saveAsPng(scatterplot)); - console.log(`Scatterplot v${scatterplot.get('version')}`); scatterplot.subscribe('select', selectHandler); @@ -108,79 +93,33 @@ const generatePoints = (num) => { return newPoints; }; -const setNumPoint = (newNumPoints) => { - numPoints = newNumPoints; - numPointsEl.value = numPoints; - numPointsValEl.innerHTML = numPoints; - points = generatePoints(numPoints); +const setNumPoints = (newNumPoints) => { + points = generatePoints(newNumPoints); scatterplot.draw(points); }; -const numPointsInputHandler = (event) => { - numPointsValEl.innerHTML = `${+event.target - .value} release to redraw`; -}; - -numPointsEl.addEventListener('input', numPointsInputHandler); - -const numPointsChangeHandler = (event) => setNumPoint(+event.target.value); - -numPointsEl.addEventListener('change', numPointsChangeHandler); - const getPointSizeRange = (basePointSize) => { const pointSizeScale = scaleLog() .domain([1, 10]) .range([basePointSize, basePointSize * 10]); - return Array(100) - .fill() - .map((x, i) => pointSizeScale(1 + (i / 99) * 9)); + return Array.from( + { length: 100 }, (_, i) => pointSizeScale(1 + (i / 99) * 9) + ); }; const setPointSize = (newPointSize) => { - pointSize = newPointSize; - pointSizeEl.value = pointSize; - pointSizeValEl.innerHTML = pointSize; - scatterplot.set({ pointSize: getPointSizeRange(pointSize) }); + scatterplot.set({ pointSize: getPointSizeRange(newPointSize) }); }; -const pointSizeInputHandler = (event) => setPointSize(+event.target.value); - -pointSizeEl.addEventListener('input', pointSizeInputHandler); - const getOpacityRange = (baseOpacity) => - Array(10) - .fill() - .map((x, i) => ((i + 1) / 10) * baseOpacity); + Array.from({ length: 10 }, (_, i) => ((i + 1) / 10) * baseOpacity); const setOpacity = (newOpacity) => { - opacity = newOpacity; - opacityEl.value = opacity; - opacityValEl.innerHTML = opacity; - scatterplot.set({ opacity: getOpacityRange(opacity) }); -}; - -const opacityInputHandler = (event) => setOpacity(+event.target.value); - -opacityEl.addEventListener('input', opacityInputHandler); - -const clickLassoInitiatorChangeHandler = (event) => { - scatterplot.set({ - lassoInitiator: event.target.checked, - }); -}; - -clickLassoInitiatorEl.addEventListener( - 'change', - clickLassoInitiatorChangeHandler -); -clickLassoInitiatorEl.checked = scatterplot.get('lassoInitiator'); - -const resetClickHandler = () => { - scatterplot.reset(); + scatterplot.set({ opacity: getOpacityRange(newOpacity) }); }; -resetEl.addEventListener('click', resetClickHandler); +createMenu({ scatterplot, setNumPoints, setPointSize, setOpacity }); scatterplot.set({ colorBy: 'category', @@ -191,4 +130,4 @@ scatterplot.set({ setPointSize(pointSize); setOpacity(opacity); -setNumPoint(numPoints); +setNumPoints(numPoints); diff --git a/example/text-labels.js b/example/text-labels.js index 5d091da..7d133fb 100644 --- a/example/text-labels.js +++ b/example/text-labels.js @@ -1,25 +1,11 @@ -/* eslint no-console: 0 */ - import { scaleLinear, scaleLog } from 'd3-scale'; import createScatterplot from '../src'; -import { saveAsPng, checkSupport } from './utils'; +import createMenu from './menu'; +import { checkSupport } from './utils'; const canvas = document.querySelector('#canvas'); const canvasWrapper = document.querySelector('#canvas-wrapper'); -const numPointsEl = document.querySelector('#num-points'); -const numPointsValEl = document.querySelector('#num-points-value'); -const pointSizeEl = document.querySelector('#point-size'); -const pointSizeValEl = document.querySelector('#point-size-value'); -const opacityEl = document.querySelector('#opacity'); -const opacityValEl = document.querySelector('#opacity-value'); -const clickLassoInitiatorEl = document.querySelector('#click-lasso-initiator'); -const resetEl = document.querySelector('#reset'); -const exportEl = document.querySelector('#export'); -const exampleEl = document.querySelector('#example-text-overlay'); - -exampleEl.setAttribute('class', 'active'); -exampleEl.removeAttribute('href'); const noteEl = document.createElement('div'); noteEl.id = 'note'; @@ -72,6 +58,7 @@ const scatterplot = createScatterplot({ lassoMinDelay, lassoMinDist, pointSize, + opacity, showReticle, reticleColor, xScale: scaleLinear().domain([-1, 1]), @@ -83,8 +70,6 @@ const scatterplot = createScatterplot({ checkSupport(scatterplot); -exportEl.addEventListener('click', () => saveAsPng(scatterplot)); - console.log(`Scatterplot v${scatterplot.get('version')}`); scatterplot.subscribe('select', ({ points: selectedPoints }) => { @@ -137,80 +122,34 @@ const generatePoints = (num) => -1 + Math.random() * 2, ]); -const setNumPoint = (newNumPoints) => { - numPoints = newNumPoints; - numPointsEl.value = numPoints; - numPointsValEl.innerHTML = numPoints; - points = generatePoints(numPoints); +const setNumPoints = (newNumPoints) => { + points = generatePoints(newNumPoints); scatterplot.draw(points); }; -const numPointsInputHandler = (event) => { - numPointsValEl.innerHTML = `${+event.target - .value} release to redraw`; -}; - -numPointsEl.addEventListener('input', numPointsInputHandler); - -const numPointsChangeHandler = (event) => setNumPoint(+event.target.value); - -numPointsEl.addEventListener('change', numPointsChangeHandler); - const getPointSizeRange = (basePointSize) => { const pointSizeScale = scaleLog() .domain([1, 10]) .range([basePointSize, basePointSize * 10]); - return Array(100) - .fill() - .map((x, i) => pointSizeScale(1 + (i / 99) * 9)); + return Array.from( + { length: 100 }, (_, i) => pointSizeScale(1 + (i / 99) * 9) + ); }; const setPointSize = (newPointSize) => { - pointSize = newPointSize; - pointSizeEl.value = pointSize; - pointSizeValEl.innerHTML = pointSize; - scatterplot.set({ pointSize: getPointSizeRange(pointSize) }); + scatterplot.set({ pointSize: getPointSizeRange(newPointSize) }); }; -const pointSizeInputHandler = (event) => setPointSize(+event.target.value); - -pointSizeEl.addEventListener('input', pointSizeInputHandler); - const getOpacityRange = (baseOpacity) => - Array(10) - .fill() - .map((x, i) => ((i + 1) / 10) * baseOpacity); + Array.from({ length: 10 }, (_, i) => ((i + 1) / 10) * baseOpacity); const setOpacity = (newOpacity) => { - opacity = newOpacity; - opacityEl.value = opacity; - opacityValEl.innerHTML = opacity; - scatterplot.set({ opacity: getOpacityRange(opacity) }); -}; - -const opacityInputHandler = (event) => setOpacity(+event.target.value); - -opacityEl.addEventListener('input', opacityInputHandler); - -const clickLassoInitiatorChangeHandler = (event) => { - scatterplot.set({ - lassoInitiator: event.target.checked, - }); -}; - -clickLassoInitiatorEl.addEventListener( - 'change', - clickLassoInitiatorChangeHandler -); -clickLassoInitiatorEl.checked = scatterplot.get('lassoInitiator'); - -const resetClickHandler = () => { - scatterplot.reset(); + scatterplot.set({ opacity: getOpacityRange(newOpacity) }); }; -resetEl.addEventListener('click', resetClickHandler); +createMenu({ scatterplot, setNumPoints, setPointSize }); setPointSize(pointSize); setOpacity(opacity); -setNumPoint(numPoints); +setNumPoints(numPoints); diff --git a/example/texture-background.js b/example/texture-background.js index d18e9f7..c21675b 100644 --- a/example/texture-background.js +++ b/example/texture-background.js @@ -1,29 +1,15 @@ -/* eslint no-console: 0 */ - import createScatterplot from '../src'; -import { saveAsPng, checkSupport } from './utils'; +import createMenu from './menu'; +import { checkSupport } from './utils'; const canvas = document.querySelector('#canvas'); -const numPointsEl = document.querySelector('#num-points'); -const numPointsValEl = document.querySelector('#num-points-value'); -const pointSizeEl = document.querySelector('#point-size'); -const pointSizeValEl = document.querySelector('#point-size-value'); -const opacityEl = document.querySelector('#opacity'); -const opacityValEl = document.querySelector('#opacity-value'); -const clickLassoInitiatorEl = document.querySelector('#click-lasso-initiator'); -const resetEl = document.querySelector('#reset'); -const exportEl = document.querySelector('#export'); -const exampleEl = document.querySelector('#example-background'); - -exampleEl.setAttribute('class', 'active'); -exampleEl.removeAttribute('href'); const { width, height } = canvas.getBoundingClientRect(); let points = []; let numPoints = 100000; -let pointSize = 2; -let opacity = 0.33; +let pointSize = 3; +let opacity = 1; let selection = []; const selectHandler = ({ points: selectedPoints }) => { @@ -45,6 +31,7 @@ const deselectHandler = () => { const scatterplot = createScatterplot({ canvas, pointSize, + opacity, showReticle: true, backgroundImage: `https://picsum.photos/${Math.min(640, width)}/${Math.min( 640, @@ -55,8 +42,6 @@ const scatterplot = createScatterplot({ checkSupport(scatterplot); -exportEl.addEventListener('click', () => saveAsPng(scatterplot)); - console.log(`Scatterplot v${scatterplot.get('version')}`); scatterplot.subscribe('select', selectHandler); @@ -70,67 +55,13 @@ const generatePoints = (num) => Math.random(), // value ]); -const setNumPoint = (newNumPoints) => { - numPoints = newNumPoints; - numPointsEl.value = numPoints; - numPointsValEl.innerHTML = numPoints; - points = generatePoints(numPoints); +const setNumPoints = (newNumPoints) => { + points = generatePoints(newNumPoints); scatterplot.draw(points); }; -const numPointsInputHandler = (event) => { - numPointsValEl.innerHTML = `${+event.target - .value} release to redraw`; -}; - -numPointsEl.addEventListener('input', numPointsInputHandler); - -const numPointsChangeHandler = (event) => setNumPoint(+event.target.value); - -numPointsEl.addEventListener('change', numPointsChangeHandler); - -const setPointSize = (newPointSize) => { - pointSize = newPointSize; - pointSizeEl.value = pointSize; - pointSizeValEl.innerHTML = pointSize; - scatterplot.set({ pointSize }); -}; - -const pointSizeInputHandler = (event) => setPointSize(+event.target.value); - -pointSizeEl.addEventListener('input', pointSizeInputHandler); - -const setOpacity = (newOpacity) => { - opacity = newOpacity; - opacityEl.value = opacity; - opacityValEl.innerHTML = opacity; - scatterplot.set({ opacity }); -}; - -const opacityInputHandler = (event) => setOpacity(+event.target.value); - -opacityEl.addEventListener('input', opacityInputHandler); - -const clickLassoInitiatorChangeHandler = (event) => { - scatterplot.set({ - lassoInitiator: event.target.checked, - }); -}; - -clickLassoInitiatorEl.addEventListener( - 'change', - clickLassoInitiatorChangeHandler -); -clickLassoInitiatorEl.checked = scatterplot.get('lassoInitiator'); - -const resetClickHandler = () => { - scatterplot.reset(); -}; - -resetEl.addEventListener('click', resetClickHandler); +createMenu({ scatterplot, setNumPoints }); scatterplot.set({ colorBy: 'category', pointColor: ['#3a78aa', '#aa3a99'] }); -setPointSize(pointSize); -setOpacity(opacity); -setNumPoint(numPoints); +setNumPoints(numPoints); diff --git a/example/transition.js b/example/transition.js index 0cc7960..107aed7 100644 --- a/example/transition.js +++ b/example/transition.js @@ -1,9 +1,8 @@ -/* eslint no-console: 0 */ - import { tableFromIPC } from 'apache-arrow'; import createScatterplot from '../src'; -import { saveAsPng, checkSupport, showModal, closeModal } from './utils'; +import createMenu from './menu'; +import { checkSupport, showModal, closeModal } from './utils'; const CLASS_COLORS = [ '#FFFF00', // bright yellow @@ -41,19 +40,6 @@ infoContent.innerHTML = ` `; const canvas = document.querySelector('#canvas'); -const numPointsEl = document.querySelector('#num-points'); -const numPointsValEl = document.querySelector('#num-points-value'); -const pointSizeEl = document.querySelector('#point-size'); -const pointSizeValEl = document.querySelector('#point-size-value'); -const opacityEl = document.querySelector('#opacity'); -const opacityValEl = document.querySelector('#opacity-value'); -const clickLassoInitiatorEl = document.querySelector('#click-lasso-initiator'); -const resetEl = document.querySelector('#reset'); -const exportEl = document.querySelector('#export'); -const exampleEl = document.querySelector('#example-transition'); - -exampleEl.setAttribute('class', 'active'); -exampleEl.removeAttribute('href'); let pointSize = 1.5; @@ -65,46 +51,9 @@ const scatterplot = createScatterplot({ checkSupport(scatterplot); -exportEl.addEventListener('click', () => saveAsPng(scatterplot)); - console.log(`Scatterplot v${scatterplot.get('version')}`); -/** - * Disable num points and opacity setter because we work with fixed data - */ -numPointsEl.disabled = true; -opacityEl.disabled = true; - -const setPointSize = (newPointSize) => { - pointSize = newPointSize; - pointSizeEl.value = pointSize; - pointSizeValEl.innerHTML = pointSize; - scatterplot.set({ pointSize }); -}; - -const pointSizeInputHandler = (event) => setPointSize(+event.target.value); - -pointSizeEl.addEventListener('input', pointSizeInputHandler); - -const clickLassoInitiatorChangeHandler = (event) => { - scatterplot.set({ - lassoInitiator: event.target.checked, - }); -}; - -clickLassoInitiatorEl.addEventListener( - 'change', - clickLassoInitiatorChangeHandler -); -clickLassoInitiatorEl.checked = scatterplot.get('lassoInitiator'); - -const resetClickHandler = () => { - scatterplot.reset(); -}; - -resetEl.addEventListener('click', resetClickHandler); - -setPointSize(1.5); +createMenu({ scatterplot, opacityChangesDisabled: true }); whenData .then((data) => tableFromIPC(data)) @@ -120,9 +69,6 @@ whenData staticOpacity[i] = 1; } - numPointsValEl.innerHTML = table.numRows; - opacityValEl.innerHTML = 'Automatic'; - scatterplot.draw({ x: columnValues[0], y: columnValues[1], diff --git a/example/tweakpane-link-plugin.js b/example/tweakpane-link-plugin.js new file mode 100644 index 0000000..b82db44 --- /dev/null +++ b/example/tweakpane-link-plugin.js @@ -0,0 +1,136 @@ +import { + bindValue, + createPlugin, + parseRecord, + BladeApi, + BladeController, + ValueMap +} from '@tweakpane/core'; + +class LinkController extends BladeController { + constructor(doc, config) { + super({ + ...config, + view: new LinkView(doc, { + props: config.props, + viewProps: config.viewProps, + }), + }); + } +} + +class LinkView { + constructor(doc, { props, viewProps }) { + // Create view elements + this.element = doc.createElement('div'); + this.element.classList.add('tp-link'); + viewProps.bindClassModifiers(this.element); + + // Create an `a` element for user interaction + const linkElement = doc.createElement('a'); + viewProps.bindDisabled(linkElement); + + function setLink(link) { + linkElement.href = link ?? ''; + } + + function setLabel(label) { + linkElement.textContent = label ?? ''; + } + + function setActive(active) { + if (active) { + linkElement.classList.add('active'); + linkElement.removeAttribute('href'); + linkElement.removeAttribute('target'); + } else { + linkElement.classList.remove('active'); + setLink(props.get('link')); + setNewPage(props.get('newPage')); + } + } + + function setNewPage(newPage) { + if (newPage) { + linkElement.target = '_blank'; + } else { + linkElement.removeAttribute('target'); + } + } + + bindValue(props.value('link'), setLink); + bindValue(props.value('label'), setLabel); + bindValue(props.value('active'), setActive); + bindValue(props.value('newPage'), setNewPage); + + this.element.appendChild(linkElement); + this.linkElement = linkElement; + } +} + +class LinkApi extends BladeApi { + get label() { + return this.controller.props.get('label') ?? ''; + } + + set label(label) { + this.controller.props.set('label', label); + } + + get link() { + return this.controller.props.get('link'); + } + + set link(link) { + this.controller.props.set('link', link); + } + + get active() { + return this.controller.props.get('active'); + } + + set active(active) { + this.controller.props.set('active', Boolean(active)); + } + + get newPage() { + return this.controller.props.get('newPage'); + } + + set newPage(newPage) { + this.controller.props.set('newPage', Boolean(newPage)); + } +} + +export const LinkPlugin = createPlugin({ + id: 'link', + type: 'blade', + accept(params) { + const result = parseRecord(params, (p) => ({ + view: p.required.constant('link'), + link: p.required.string, + label: p.required.string, + active: p.optional.boolean, + newPage: p.optional.boolean, + })); + return result ? { params: result } : null; + }, + controller(args) { + return new LinkController(args.document, { + blade: args.blade, + props: ValueMap.fromObject({ + label: args.params.label, + link: args.params.link, + active: args.params.active, + newPage: args.params.newPage, + }), + viewProps: args.viewProps, + }); + }, + api(args) { + if (!(args.controller instanceof LinkController)) { + return null; + } + return new LinkApi(args.controller); + }, +}); diff --git a/example/utils.js b/example/utils.js index 2bc9713..14fdaef 100644 --- a/example/utils.js +++ b/example/utils.js @@ -20,7 +20,7 @@ export function saveAsPng(scatterplot) { const imageObject = new Image(); imageObject.onload = () => { scatterplot.get('canvas').toBlob((blob) => { - downloadBlob(blob, 'scatter.png'); + downloadBlob(blob, 'regl-scatterplot.png'); }); }; imageObject.src = scatterplot.get('canvas').toDataURL(); diff --git a/package-lock.json b/package-lock.json index f1aa602..f688f3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@flekschas/utils": "^0.32.2", "dom-2d-camera": "^2.2.6", + "earcut": "^3.0.1", "gl-matrix": "~3.4.3", "pub-sub-es": "~3.0.0", "regl": "~2.1.1", @@ -27,6 +28,7 @@ "@rollup/plugin-json": "^6.0.0", "@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-terser": "^0.4.3", + "@tweakpane/core": "^2.0.5", "@types/d3-scale": "^4.0.6", "@types/node": "^22.9.3", "@vitest/browser": "^2.1.5", @@ -43,6 +45,7 @@ "playwright": "^1.49.0", "rollup": "^4.27.4", "rollup-plugin-filesize": "^10.0.0", + "tweakpane": "^4.0.5", "typescript": "~5.7.2", "vite": "^5.4.11", "vite-plugin-virtual-html-template": "^1.1.0", @@ -3227,6 +3230,13 @@ "url": "/~https://github.com/sponsors/isaacs" } }, + "node_modules/@tweakpane/core": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@tweakpane/core/-/core-2.0.5.tgz", + "integrity": "sha512-punBgD5rKCF5vcNo6BsSOXiDR/NSs9VM7SG65QSLJIxfRaGgj54ree9zQW6bO3pNFf3AogiGgaNODUVQRk9YqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -4695,6 +4705,12 @@ "integrity": "sha512-sxNZ+ljy+RA1maXoUReeqBBpBC6RLKmg5ewzV+x+mSETmWNoKdZN6vcQjpFROemza23hGFskJtFNoUWUaQ+R4Q==", "dev": true }, + "node_modules/earcut": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.1.tgz", + "integrity": "sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw==", + "license": "ISC" + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -8809,6 +8825,16 @@ "node": "*" } }, + "node_modules/tweakpane": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/tweakpane/-/tweakpane-4.0.5.tgz", + "integrity": "sha512-rxEXdSI+ArlG1RyO6FghC4ZUX8JkEfz8F3v1JuteXSV0pEtHJzyo07fcDG+NsJfN5L39kSbCYbB9cBGHyuI/tQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "/~https://github.com/sponsors/cocopon" + } + }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", diff --git a/package.json b/package.json index 372ec4b..8f5150c 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "dependencies": { "@flekschas/utils": "^0.32.2", "dom-2d-camera": "^2.2.6", + "earcut": "^3.0.1", "gl-matrix": "~3.4.3", "pub-sub-es": "~3.0.0", "regl": "~2.1.1", @@ -58,6 +59,7 @@ "@rollup/plugin-json": "^6.0.0", "@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-terser": "^0.4.3", + "@tweakpane/core": "^2.0.5", "@types/d3-scale": "^4.0.6", "@types/node": "^22.9.3", "@vitest/browser": "^2.1.5", @@ -74,6 +76,7 @@ "playwright": "^1.49.0", "rollup": "^4.27.4", "rollup-plugin-filesize": "^10.0.0", + "tweakpane": "^4.0.5", "typescript": "~5.7.2", "vite": "^5.4.11", "vite-plugin-virtual-html-template": "^1.1.0", diff --git a/public/index.html b/public/index.html index 59e754b..dccce48 100644 --- a/public/index.html +++ b/public/index.html @@ -149,6 +149,10 @@ background: #fff; } + input[type='radio'] { + margin: 0; + } + button, a.button-like { display: inline-block; @@ -225,6 +229,10 @@ flex-direction: column; } + .gap-h { + gap: 0 0.25rem; + } + .no-select { -webkit-user-select: none; -moz-user-select: none; @@ -232,34 +240,26 @@ user-select: none; } - #topbar { - position: absolute; - z-index: 1; - top: 0.5rem; - left: 0.5rem; - width: 16rem; - padding: 0.5rem; - font-size: 0.9rem; - background: rgba(32, 32, 32, 0.9); - border-radius: 0.2rem; - transition: background 0.15s cubic-bezier(0.25, 0.1, 0.25, 1); - } - - #topbar:focus, - #topbar:focus-within { - pointer-events: none; - outline: none; - background: rgba(32, 32, 32, 1); + .tp-link { + padding-left: 4px; + line-height: 1.5em; } - #topbar hr { - border-color: rgba(255, 255, 255, 0.1); + .tp-link a.active { + color: #34bbff; + font-weight: bold; + text-decoration: none; } - #title { + h1 { + position: absolute; + z-index: 1; + top: 1rem; + left: 1rem; padding: 0; margin: 0; - font-size: 1rem; + font-family: 'Roboto Mono', 'Source Code Pro', Menlo, Courier, monospace; + font-size: 0.8rem; color: white; -webkit-touch-callout: none; -webkit-user-select: none; @@ -269,56 +269,12 @@ user-select: none; } - #topbar:focus #title-wrapper, - #topbar:focus-within #title-wrapper { - color: #fff; - padding-bottom: 0.5rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - } - - #title-wrapper span { - color: #666; - font-size: 0.7rem; - line-height: 1rem; - text-transform: uppercase; - } - - #topbar:focus #title-wrapper span, - #topbar:focus-within #title-wrapper span { - color: #fff; - } - #controls { - display: none; - } - - #topbar:focus #controls, - #topbar:focus-within #controls { - display: block; - pointer-events: auto; - } - - #controls label { - margin: 0.5rem 0; - } - - #controls label:last-child { - margin-bottom: 0; - } - - #controls .label:after { - content: ':'; - margin-right: 0.25rem; - } - - #controls .value:after { - text-align: center; - } - - #controls .value em { - color: #666; - font-size: 0.7rem; - line-height: 0.9rem; + position: absolute; + z-index: 1; + top: 1rem; + right: 1rem; + width: 18rem; } #footer.hidden { @@ -395,46 +351,6 @@ pointer-events: auto; } - #examples { - margin: 0; - padding: 0 0 0 1.25em; - } - - #examples li { - margin: 0.125em 0; - } - - #examples li:last-child { - margin-bottom: 0; - } - - #examples li a { - color: rgba(255, 255, 255, 0.66); - transition: color 0.15s cubic-bezier(0.25, 0.1, 0.25, 1); - } - - #examples li a:hover { - color: rgba(255, 255, 255, 1); - } - - #examples li a.active { - color: #34bbff; - font-weight: bold; - text-decoration: none; - } - - #num-points-value { - min-width: 5rem; - } - - #point-size-value { - min-width: 2rem; - } - - #opacity-value { - min-width: 2rem; - } - #parent-wrapper { position: absolute; top: 0; @@ -508,135 +424,8 @@ -
-
-

Regl Scatterplot

- Menu -
- -
+

Regl Scatterplot

+
diff --git a/src/constants.js b/src/constants.js index 351f2b3..bed43e5 100644 --- a/src/constants.js +++ b/src/constants.js @@ -57,6 +57,7 @@ export const VALUE_ZW_DATA_TYPES = [CONTINUOUS, CATEGORICAL]; export const LASSO_CLEAR_ON_DESELECT = 'deselect'; export const LASSO_CLEAR_ON_END = 'lassoEnd'; export const LASSO_CLEAR_EVENTS = [LASSO_CLEAR_ON_DESELECT, LASSO_CLEAR_ON_END]; +export const LASSO_BRUSH_MIN_MIN_DIST = 3; export const DEFAULT_LASSO_COLOR = [0, 0.666666667, 1, 1]; export const DEFAULT_LASSO_LINE_WIDTH = 2; export const DEFAULT_LASSO_INITIATOR = false; @@ -69,15 +70,18 @@ export const DEFAULT_LASSO_LONG_PRESS_TIME = 750; export const DEFAULT_LASSO_LONG_PRESS_AFTER_EFFECT_TIME = 500; export const DEFAULT_LASSO_LONG_PRESS_EFFECT_DELAY = 100; export const DEFAULT_LASSO_LONG_PRESS_REVERT_EFFECT_TIME = 250; +export const DEFAULT_LASSO_BRUSH_SIZE = 24; // Key mapping export const KEY_ACTION_LASSO = 'lasso'; export const KEY_ACTION_ROTATE = 'rotate'; export const KEY_ACTION_MERGE = 'merge'; +export const KEY_ACTION_REMOVE = 'remove'; export const KEY_ACTIONS = [ KEY_ACTION_LASSO, KEY_ACTION_ROTATE, KEY_ACTION_MERGE, + KEY_ACTION_REMOVE, ]; export const KEY_ALT = 'alt'; export const KEY_CMD = 'cmd'; @@ -85,10 +89,11 @@ export const KEY_CTRL = 'ctrl'; export const KEY_META = 'meta'; export const KEY_SHIFT = 'shift'; export const KEYS = [KEY_ALT, KEY_CMD, KEY_CTRL, KEY_META, KEY_SHIFT]; -export const DEFAULT_KEY_MAP = { - [KEY_ALT]: KEY_ACTION_ROTATE, - [KEY_SHIFT]: KEY_ACTION_LASSO, - [KEY_CMD]: KEY_ACTION_MERGE, +export const DEFAULT_ACTION_KEY_MAP = { + [KEY_ACTION_REMOVE]: KEY_ALT, + [KEY_ACTION_ROTATE]: KEY_ALT, + [KEY_ACTION_LASSO]: KEY_SHIFT, + [KEY_ACTION_MERGE]: KEY_CMD, }; // Default attribute @@ -169,6 +174,10 @@ export const DEFAULT_SPATIAL_INDEX_USE_WORKER = undefined; export const DEFAULT_CAMERA_IS_FIXED = false; export const DEFAULT_ANTI_ALIASING = 0.5; export const DEFAULT_PIXEL_ALIGNED = false; +export const DEFAULT_LASSO_TYPE = 'lasso'; +export const SKIP_DEPRECATION_VALUE_TRANSLATION = Symbol( + 'SKIP_DEPRECATION_VALUE_TRANSLATION', +); // Error messages export const ERROR_POINTS_NOT_DRAWN = 'Points have not been drawn'; diff --git a/src/index.js b/src/index.js index 2ec147b..0f095b7 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,7 @@ import { unionIntegers, } from '@flekschas/utils'; import createDom2dCamera from 'dom-2d-camera'; +import earcut from 'earcut'; import { mat4, vec4 } from 'gl-matrix'; import createPubSub from 'pub-sub-es'; import createLine from 'regl-line'; @@ -34,6 +35,7 @@ import { COLOR_NORMAL_IDX, COLOR_NUM_STATES, CONTINUOUS, + DEFAULT_ACTION_KEY_MAP, DEFAULT_ANNOTATION_HVLINE_LIMIT, DEFAULT_ANNOTATION_LINE_COLOR, DEFAULT_ANNOTATION_LINE_WIDTH, @@ -52,7 +54,7 @@ import { DEFAULT_EASING, DEFAULT_HEIGHT, DEFAULT_IMAGE_LOAD_TIMEOUT, - DEFAULT_KEY_MAP, + DEFAULT_LASSO_BRUSH_SIZE, DEFAULT_LASSO_CLEAR_EVENT, DEFAULT_LASSO_COLOR, DEFAULT_LASSO_INITIATOR, @@ -64,6 +66,7 @@ import { DEFAULT_LASSO_MIN_DELAY, DEFAULT_LASSO_MIN_DIST, DEFAULT_LASSO_ON_LONG_PRESS, + DEFAULT_LASSO_TYPE, DEFAULT_MOUSE_MODE, DEFAULT_OPACITY_BY, DEFAULT_OPACITY_BY_DENSITY_DEBOUNCE_TIME, @@ -107,12 +110,14 @@ import { KEY_ACTIONS, KEY_ACTION_LASSO, KEY_ACTION_MERGE, + KEY_ACTION_REMOVE, KEY_ACTION_ROTATE, KEY_ALT, KEY_CMD, KEY_CTRL, KEY_META, KEY_SHIFT, + LASSO_BRUSH_MIN_MIN_DIST, LASSO_CLEAR_EVENTS, LASSO_CLEAR_ON_DESELECT, LASSO_CLEAR_ON_END, @@ -123,6 +128,7 @@ import { MOUSE_MODE_PANZOOM, MOUSE_MODE_ROTATE, SINGLE_CLICK_DELAY, + SKIP_DEPRECATION_VALUE_TRANSLATION, VALUE_ZW_DATA_TYPES, W_NAMES, Z_NAMES, @@ -145,7 +151,6 @@ import { isPolygon, isPositiveNumber, isRect, - isSameElements, isSameRgbas, isStrictlyPositiveNumber, isString, @@ -162,8 +167,21 @@ import { import { version } from '../package.json'; const deprecations = { - showRecticle: 'showReticle', - recticleColor: 'reticleColor', + showRecticle: { + replacement: 'showReticle', + removalVersion: '2', + translation: identity, + }, + recticleColor: { + replacement: 'reticleColor', + removalVersion: '2', + translation: identity, + }, + keyMap: { + replacement: 'actionKeyMap', + removalVersion: '2', + translation: flipObj, + }, }; const checkDeprecations = (properties) => { @@ -172,13 +190,19 @@ const checkDeprecations = (properties) => { ); for (const prop of deprecatedProps) { + const { replacement, removalVersion, translation } = deprecations[prop]; // biome-ignore lint/suspicious/noConsole: This is a legitimately useful warning console.warn( - `regl-scatterplot: the "${prop}" property is deprecated. Please use "${deprecations[prop]}" instead.`, + `regl-scatterplot: the "${prop}" property is deprecated and will be removed in v${removalVersion}. Please use "${replacement}" instead.`, ); - properties[deprecations[prop]] = properties[prop]; + properties[deprecations[prop].replacement] = + properties[prop] !== SKIP_DEPRECATION_VALUE_TRANSLATION + ? translation(properties[prop]) + : properties[prop]; delete properties[prop]; } + + return properties; }; const getEncodingType = ( @@ -261,7 +285,9 @@ const createScatterplot = ( lassoLongPressAfterEffectTime = DEFAULT_LASSO_LONG_PRESS_AFTER_EFFECT_TIME, lassoLongPressEffectDelay = DEFAULT_LASSO_LONG_PRESS_EFFECT_DELAY, lassoLongPressRevertEffectTime = DEFAULT_LASSO_LONG_PRESS_REVERT_EFFECT_TIME, - keyMap = DEFAULT_KEY_MAP, + lassoType = DEFAULT_LASSO_TYPE, + lassoBrushSize = DEFAULT_LASSO_BRUSH_SIZE, + actionKeyMap = DEFAULT_ACTION_KEY_MAP, mouseMode = DEFAULT_MOUSE_MODE, showReticle = DEFAULT_SHOW_RETICLE, reticleColor = DEFAULT_RETICLE_COLOR, @@ -371,7 +397,6 @@ const createScatterplot = ( // biome-ignore lint/style/useNamingConvention: VLine stands for VerticalLine let reticleVLine; let computedPointSizeMouseDetection; - let keyActionMap = flipObj(keyMap); let lassoInitiatorTimeout; let topRightNdc; let bottomLeftNdc; @@ -765,7 +790,10 @@ const createScatterplot = ( * @param {number | number[]} pointIdxs * @param {import('./types').ScatterplotMethodOptions['select']} */ - const select = (pointIdxs, { merge = false, preventEvent = false } = {}) => { + const select = ( + pointIdxs, + { merge = false, remove = false, preventEvent = false } = {}, + ) => { const newSelectedPoints = Array.isArray(pointIdxs) ? pointIdxs : [pointIdxs]; @@ -777,6 +805,15 @@ const createScatterplot = ( draw = true; return; } + } else if (remove) { + const newSelectedPointsSet = new Set(newSelectedPoints); + selectedPoints = selectedPoints.filter( + (point) => !newSelectedPointsSet.has(point), + ); + if (currSelectedPoints.length === selectedPoints.length) { + draw = true; + return; + } } else { // Unset previously highlight point connections if (selectedPoints?.length > 0) { @@ -906,11 +943,15 @@ const createScatterplot = ( pubSub.publish('lassoStart'); }; - const lassoEnd = (lassoPoints, lassoPointsFlat, { merge = false } = {}) => { + const lassoEnd = ( + lassoPoints, + lassoPointsFlat, + { merge = false, remove = false } = {}, + ) => { camera.config({ isFixed: cameraIsFixed }); lassoPointsCurr = [...lassoPoints]; const pointsInLasso = findPointsInLasso(lassoPointsFlat); - select(pointsInLasso, { merge }); + select(pointsInLasso, { merge, remove }); pubSub.publish('lassoEnd', { coordinates: lassoPointsCurr, @@ -928,12 +969,18 @@ const createScatterplot = ( initiatorParentElement: lassoInitiatorParentElement, longPressIndicatorParentElement: lassoLongPressIndicatorParentElement, pointNorm: ([x, y]) => getScatterGlPos(getNdcX(x), getNdcY(y)), + minDelay: lassoMinDelay, + minDist: + lassoType === 'brush' + ? Math.max(LASSO_BRUSH_MIN_MIN_DIST, lassoMinDist) + : lassoMinDist, + type: lassoType, }); const checkLassoMode = () => mouseMode === MOUSE_MODE_LASSO; const checkModKey = (event, action) => { - switch (keyActionMap[action]) { + switch (actionKeyMap[action]) { case KEY_ALT: return event.altKey; @@ -999,6 +1046,7 @@ const createScatterplot = ( lassoActive = false; lassoManager.end({ merge: checkModKey(event, KEY_ACTION_MERGE), + remove: checkModKey(event, KEY_ACTION_REMOVE), }); } @@ -1039,6 +1087,7 @@ const createScatterplot = ( } select([clostestPoint], { merge: checkModKey(event, KEY_ACTION_MERGE), + remove: checkModKey(event, KEY_ACTION_REMOVE), }); } else if (!lassoInitiatorTimeout) { // We'll also wait to make sure the user didn't double click @@ -1368,7 +1417,10 @@ const createScatterplot = ( pointSize = [+newPointSize]; } - if (oldPointSize === pointSize || isSameElements(oldPointSize, pointSize)) { + if ( + oldPointSize === pointSize || + hasSameElements(oldPointSize, pointSize) + ) { // We don't need to update the encoding texture so we return early return; } @@ -1435,7 +1487,7 @@ const createScatterplot = ( opacity = [+newOpacity]; } - if (oldOpacity === opacity || isSameElements(oldOpacity, opacity)) { + if (oldOpacity === opacity || hasSameElements(oldOpacity, opacity)) { // We don't need to update the encoding texture so we return early return; } @@ -1777,7 +1829,7 @@ const createScatterplot = ( count: 6, }); - const drawPolygon2d = renderer.regl({ + const drawLassoPolygon = renderer.regl({ vert: ` precision mediump float; uniform mat4 modelViewProjection; @@ -1816,12 +1868,7 @@ const createScatterplot = ( color: () => lassoColor, }, - elements: () => - Array.from({ length: lassoPointsCurr.length - 2 }, (_, i) => [ - 0, - i + 1, - i + 2, - ]), + elements: () => earcut(lasso.getPoints()), }); const drawReticle = () => { @@ -3078,19 +3125,31 @@ const createScatterplot = ( lassoLongPressRevertEffectTime = Number(newTime); }; - const setKeyMap = (newKeyMap) => { - keyMap = Object.entries(newKeyMap).reduce((map, [key, value]) => { - if (KEYS.includes(key) && KEY_ACTIONS.includes(value)) { - map[key] = value; - } - return map; - }, {}); - keyActionMap = flipObj(keyMap); + const setLassoType = (newType) => { + if (newType === 'brush') { + lassoManager.set({ + type: newType, + minDist: Math.max(LASSO_BRUSH_MIN_MIN_DIST, lassoMinDist), + }); + } else { + lassoManager.set({ + type: newType, + minDist: lassoMinDist, + }); + } + lassoType = lassoManager.get('type'); + }; + + const setLassoBrushSize = (newBrushSize) => { + lassoBrushSize = Number(newBrushSize) || lassoBrushSize; + lassoManager.set({ brushSize: lassoBrushSize }); + }; - if (keyActionMap[KEY_ACTION_ROTATE]) { + const updateActionKeyMapChange = () => { + if (actionKeyMap[KEY_ACTION_ROTATE]) { camera.config({ isRotate: true, - mouseDownMoveModKey: keyActionMap[KEY_ACTION_ROTATE], + mouseDownMoveModKey: actionKeyMap[KEY_ACTION_ROTATE], }); } else { camera.config({ @@ -3099,6 +3158,20 @@ const createScatterplot = ( } }; + const setActionKeyMap = (newActionKeyMap) => { + actionKeyMap = Object.entries(newActionKeyMap).reduce( + (map, [action, key]) => { + if (KEYS.includes(key) && KEY_ACTIONS.includes(action)) { + map[action] = key; + } + return map; + }, + {}, + ); + + updateActionKeyMapChange(); + }; + const setMouseMode = (newMouseMode) => { mouseMode = limit(MOUSE_MODES, MOUSE_MODE_PANZOOM)(newMouseMode); @@ -3332,8 +3405,12 @@ const createScatterplot = ( }; /** @type {(property: Key) => import('./types').Properties[Key] } */ - const get = (property) => { - checkDeprecations({ property: true }); + const get = (prop) => { + const [property] = Object.keys( + checkDeprecations({ + [prop]: SKIP_DEPRECATION_VALUE_TRANSLATION, + }), + ); if (property === 'aspectRatio') { return dataAspectRatio; @@ -3435,8 +3512,16 @@ const createScatterplot = ( return lassoLongPressIndicatorParentElement; } - if (property === 'keyMap') { - return { ...keyMap }; + if (property === 'lassoOnLongPress') { + return lassoOnLongPress; + } + + if (property === 'lassoType') { + return lassoType; + } + + if (property === 'lassoBrushSize') { + return lassoBrushSize; } if (property === 'mouseMode') { @@ -3446,6 +3531,7 @@ const createScatterplot = ( if (property === 'opacity') { return opacity.length === 1 ? opacity[0] : opacity; } + if (property === 'opacityBy') { return opacityBy; } @@ -3686,6 +3772,10 @@ const createScatterplot = ( return pixelAligned; } + if (property === 'actionKeyMap') { + return { ...actionKeyMap }; + } + return undefined; }; @@ -3884,8 +3974,16 @@ const createScatterplot = ( ); } - if (properties.keyMap !== undefined) { - setKeyMap(properties.keyMap); + if (properties.lassoType !== undefined) { + setLassoType(properties.lassoType); + } + + if (properties.lassoBrushSize !== undefined) { + setLassoBrushSize(properties.lassoBrushSize); + } + + if (properties.actionKeyMap !== undefined) { + setActionKeyMap(properties.actionKeyMap); } if (properties.mouseMode !== undefined) { @@ -4265,7 +4363,7 @@ const createScatterplot = ( backgroundImage, width, height, - keyMap, + actionKeyMap, }); updateLassoInitiatorStyle(); updateLassoLongPressIndicatorStyle(); @@ -4325,7 +4423,7 @@ const createScatterplot = ( } if (lassoPointsCurr.length > 2) { - drawPolygon2d(); + drawLassoPolygon(); } // The draw order of the following calls is important! diff --git a/src/lasso-manager/constants.js b/src/lasso-manager/constants.js index d793f0c..80c2c25 100644 --- a/src/lasso-manager/constants.js +++ b/src/lasso-manager/constants.js @@ -1,5 +1,7 @@ export const DEFAULT_LASSO_START_INITIATOR_SHOW = true; export const DEFAULT_LASSO_MIN_DELAY = 8; export const DEFAULT_LASSO_MIN_DIST = 2; +export const DEFAULT_LASSO_TYPE = 'freeform'; +export const DEFAULT_BRUSH_SIZE = 24; export const LASSO_SHOW_START_INITIATOR_TIME = 2500; export const LASSO_HIDE_START_INITIATOR_TIME = 250; diff --git a/src/lasso-manager/index.js b/src/lasso-manager/index.js index f47e7e5..438afbe 100644 --- a/src/lasso-manager/index.js +++ b/src/lasso-manager/index.js @@ -1,6 +1,7 @@ import { assign, identity, + l2Norm, l2PointDist, nextAnimationFrame, pipe, @@ -10,9 +11,11 @@ import { } from '@flekschas/utils'; import { + DEFAULT_BRUSH_SIZE, DEFAULT_LASSO_MIN_DELAY, DEFAULT_LASSO_MIN_DIST, DEFAULT_LASSO_START_INITIATOR_SHOW, + DEFAULT_LASSO_TYPE, LASSO_HIDE_START_INITIATOR_TIME, LASSO_SHOW_START_INITIATOR_TIME, } from './constants.js'; @@ -104,6 +107,8 @@ export const createLasso = ( minDelay: initialMinDelay = DEFAULT_LASSO_MIN_DELAY, minDist: initialMinDist = DEFAULT_LASSO_MIN_DIST, pointNorm: initialPointNorm = identity, + type: initialType = DEFAULT_LASSO_TYPE, + brushSize: initialBrushSize = DEFAULT_BRUSH_SIZE, } = {}, ) => { let enableInitiator = initialenableInitiator; @@ -119,6 +124,9 @@ export const createLasso = ( let pointNorm = initialPointNorm; + let type = initialType; + let brushSize = initialBrushSize; + const initiator = document.createElement('div'); const initiatorId = Math.random().toString(36).substring(2, 5) + @@ -147,7 +155,9 @@ export const createLasso = ( let isLasso = false; let lassoPos = []; let lassoPosFlat = []; - let lassoPrevMousePos; + let lassoBrushCenterPos = []; + let lassoBrushNormals = []; + let prevMousePos; let longPressIsStarting = false; let longPressMainInAnimationRuleIndex = null; @@ -435,20 +445,123 @@ export const createLasso = ( onDraw(lassoPos, lassoPosFlat); }; + const extendFreeform = (point) => { + lassoPos.push(point); + lassoPosFlat.push(point[0], point[1]); + }; + + const extendRectangle = (point) => { + const [x, y] = point; + const [startX, startY] = lassoPos[0]; + + lassoPos[1] = [x, startY]; + lassoPos[2] = [x, y]; + lassoPos[3] = [startX, y]; + lassoPos[4] = [startX, startY]; + + lassoPosFlat[2] = x; + lassoPosFlat[3] = startY; + lassoPosFlat[4] = x; + lassoPosFlat[5] = y; + lassoPosFlat[6] = startX; + lassoPosFlat[7] = y; + lassoPosFlat[8] = startX; + lassoPosFlat[9] = startY; + }; + + const startBrush = (point) => { + lassoBrushCenterPos.push(point); + }; + + const getNormalizedBrushSize = () => + Math.abs(pointNorm([0, 0])[0] - pointNorm([brushSize / 2, 0])[0]); + + const getBrushNormal = (point1, point2, w) => { + const [x1, y1] = point1; + const [x2, y2] = point2; + + const dx = x1 - x2; + const dy = y1 - y2; + const dn = l2Norm([dx, dy]); + + return [(+dy / dn) * w, (-dx / dn) * w]; + }; + + const extendBrush = (point) => { + const prevPoint = lassoBrushCenterPos.at(-1); + + const width = getNormalizedBrushSize(); + let [nx, ny] = getBrushNormal(point, prevPoint, width); + + const N = lassoBrushCenterPos.length; + + if (N === 1) { + // In this special case, we have to add the initial two points upon and + // addition of the second point because when the first brush point was set + // the direction is undefined. + const pl = [prevPoint[0] + nx, prevPoint[1] + ny]; + const pr = [prevPoint[0] - nx, prevPoint[1] - ny]; + + lassoPos.push(pl, pr); + lassoPosFlat.push(pl[0], pl[1], pr[0], pr[1]); + lassoBrushNormals.push([nx, ny]); + } else { + // In this case, we have to adjust the previous normal to create a proper + // line join by taking the middle between the current and previous normal. + const prevPrevPoint = lassoBrushCenterPos.at(-2); + const [pnx, pny] = lassoBrushNormals.at(-1); + + // Smoothing the current normal + const d = l2PointDist(point[0], point[1], prevPoint[0], prevPoint[1]); + const pd = l2PointDist( + prevPoint[0], + prevPoint[1], + prevPrevPoint[0], + prevPrevPoint[1], + ); + const easing = Math.max(0, Math.min(1, 2 / 3 / (pd / d))); + nx = easing * nx + (1 - easing) * pnx; + ny = easing * ny + (1 - easing) * pny; + + const pnx2 = (nx + pnx) / 2; + const pny2 = (ny + pny) / 2; + + const pl = [prevPoint[0] + pnx2, prevPoint[1] + pny2]; + const pr = [prevPoint[0] - pnx2, prevPoint[1] - pny2]; + + // We're going to replace the previous left and right points + lassoPos.splice(N - 1, 2, pl, pr); + lassoPosFlat.splice(2 * (N - 1), 4, pl[0], pl[1], pr[0], pr[1]); + lassoBrushNormals.splice(N, 1, [pnx2, pny2]); + } + + const pl = [point[0] + nx, point[1] + ny]; + const pr = [point[0] - nx, point[1] - ny]; + + lassoPos.splice(N, 0, pl, pr); + lassoPosFlat.splice(2 * N, 0, pl[0], pl[1], pr[0], pr[1]); + + lassoBrushCenterPos.push(point); + lassoBrushNormals.push([nx, ny]); + }; + + let extendLasso = extendFreeform; + let startLasso = extendFreeform; + const extend = (currMousePos) => { - if (lassoPrevMousePos) { + if (prevMousePos) { const d = l2PointDist( currMousePos[0], currMousePos[1], - lassoPrevMousePos[0], - lassoPrevMousePos[1], + prevMousePos[0], + prevMousePos[1], ); - if (d > DEFAULT_LASSO_MIN_DIST) { - lassoPrevMousePos = currMousePos; - const point = pointNorm(currMousePos); - lassoPos.push(point); - lassoPosFlat.push(point[0], point[1]); + if (d > minDist) { + prevMousePos = currMousePos; + + extendLasso(pointNorm(currMousePos)); + if (lassoPos.length > 1) { draw(); } @@ -458,18 +571,13 @@ export const createLasso = ( isLasso = true; onStart(); } - lassoPrevMousePos = currMousePos; + prevMousePos = currMousePos; const point = pointNorm(currMousePos); - lassoPos = [point]; - lassoPosFlat = [point[0], point[1]]; + startLasso(point); } }; - const extendDb = throttleAndDebounce( - extend, - DEFAULT_LASSO_MIN_DELAY, - DEFAULT_LASSO_MIN_DELAY, - ); + const extendDb = throttleAndDebounce(extend, minDelay, minDelay); const extendPublic = (event, debounced) => { const mousePosition = getMousePosition(event); @@ -482,7 +590,9 @@ export const createLasso = ( const clear = () => { lassoPos = []; lassoPosFlat = []; - lassoPrevMousePos = undefined; + lassoBrushCenterPos = []; + lassoBrushNormals = []; + prevMousePos = undefined; draw(); }; @@ -501,7 +611,7 @@ export const createLasso = ( hideInitiator(); }; - const end = ({ merge = false } = {}) => { + const end = ({ merge = false, remove = false } = {}) => { isLasso = false; const currLassoPos = [...lassoPos]; @@ -513,12 +623,68 @@ export const createLasso = ( // When `currLassoPos` is empty the user didn't actually lasso if (currLassoPos.length > 0) { - onEnd(currLassoPos, currLassoPosFlat, { merge }); + onEnd(currLassoPos, currLassoPosFlat, { merge, remove }); } return currLassoPos; }; + const setExtendLasso = (newType) => { + switch (newType) { + case 'rectangle': { + type = newType; + extendLasso = extendRectangle; + // This is on purpose. The start of a rectangle & freeform are the same + startLasso = extendFreeform; + break; + } + + case 'brush': { + type = newType; + extendLasso = extendBrush; + startLasso = startBrush; + break; + } + + default: { + type = 'freeform'; + extendLasso = extendFreeform; + startLasso = extendFreeform; + break; + } + } + }; + + const get = (property) => { + if (property === 'onDraw') { + return onDraw; + } + if (property === 'onStart') { + return onStart; + } + if (property === 'onEnd') { + return onEnd; + } + if (property === 'enableInitiator') { + return enableInitiator; + } + if (property === 'minDelay') { + return minDelay; + } + if (property === 'minDist') { + return minDist; + } + if (property === 'pointNorm') { + return pointNorm; + } + if (property === 'type') { + return type; + } + if (property === 'brushSize') { + return brushSize; + } + }; + const set = ({ onDraw: newOnDraw = null, onStart: newOnStart = null, @@ -529,6 +695,8 @@ export const createLasso = ( minDelay: newMinDelay = null, minDist: newMinDist = null, pointNorm: newPointNorm = null, + type: newType = null, + brushSize: newBrushSize = null, } = {}) => { onDraw = ifNotNull(newOnDraw, onDraw); onStart = ifNotNull(newOnStart, onStart); @@ -537,6 +705,7 @@ export const createLasso = ( minDelay = ifNotNull(newMinDelay, minDelay); minDist = ifNotNull(newMinDist, minDist); pointNorm = ifNotNull(newPointNorm, pointNorm); + brushSize = ifNotNull(newBrushSize, brushSize); if ( newInitiatorParentElement !== null && @@ -564,6 +733,10 @@ export const createLasso = ( initiator.removeEventListener('mousedown', initiatorMouseDownHandler); initiator.removeEventListener('mouseleave', initiatorMouseLeaveHandler); } + + if (newType !== null) { + setExtendLasso(newType); + } }; const destroy = () => { @@ -581,6 +754,7 @@ export const createLasso = ( destroy, end, extend: extendPublic, + get, set, showInitiator, hideInitiator, @@ -597,6 +771,8 @@ export const createLasso = ( onEnd, enableInitiator, initiatorParentElement, + type, + brushSize, }); return pipe( diff --git a/src/types.d.ts b/src/types.d.ts index 78bf430..22c7ad6 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -245,6 +245,7 @@ export interface ScatterplotMethodOptions { }>; select: Partial<{ merge: boolean; + remove: boolean; preventEvent: boolean; }>; filter: Partial<{ diff --git a/tests/constructor.test.js b/tests/constructor.test.js index 6510362..eddfcc4 100644 --- a/tests/constructor.test.js +++ b/tests/constructor.test.js @@ -11,19 +11,21 @@ import createScatterplot, { import { DEFAULT_CAMERA_IS_FIXED, - DEFAULT_COLOR_NORMAL, DEFAULT_COLOR_ACTIVE, - DEFAULT_COLOR_HOVER, DEFAULT_COLOR_BG, + DEFAULT_COLOR_HOVER, + DEFAULT_COLOR_NORMAL, + DEFAULT_GAMMA, DEFAULT_HEIGHT, + DEFAULT_LASSO_BRUSH_SIZE, + DEFAULT_LASSO_TYPE, + DEFAULT_OPACITY, + DEFAULT_OPACITY_INACTIVE_MAX, + DEFAULT_OPACITY_INACTIVE_SCALE, DEFAULT_POINT_OUTLINE_WIDTH, DEFAULT_POINT_SIZE, DEFAULT_POINT_SIZE_SELECTED, - DEFAULT_OPACITY_INACTIVE_MAX, - DEFAULT_OPACITY_INACTIVE_SCALE, DEFAULT_WIDTH, - DEFAULT_GAMMA, - DEFAULT_OPACITY, IMAGE_LOAD_ERROR, } from '../src/constants'; @@ -75,6 +77,8 @@ test('createScatterplot()', () => { expect(scatterplot.get('width')).toBe(DEFAULT_WIDTH); expect(scatterplot.get('height')).toBe(DEFAULT_HEIGHT); expect(scatterplot.get('cameraIsFixed')).toBe(DEFAULT_CAMERA_IS_FIXED); + expect(scatterplot.get('lassoType')).toBe(DEFAULT_LASSO_TYPE); + expect(scatterplot.get('lassoBrushSize')).toBe(DEFAULT_LASSO_BRUSH_SIZE); scatterplot.destroy(); }); diff --git a/tests/events.test.js b/tests/events.test.js index d267c46..d77436c 100644 --- a/tests/events.test.js +++ b/tests/events.test.js @@ -351,8 +351,8 @@ test( lassoEndCoordinates = coordinates; }); - const [lassoKey] = Object.entries(scatterplot.get('keyMap')).find( - (mapping) => mapping[1] === KEY_ACTION_LASSO + const [_, lassoKey] = Object.entries(scatterplot.get('keyMap')).find( + ([action]) => action === KEY_ACTION_LASSO ); // Test multi selections via mousedown + mousemove @@ -419,7 +419,7 @@ test('disable lasso selection', async () => { let lassoStartCount = 0; scatterplot.subscribe('lassoStart', () => ++lassoStartCount); - expect(Object.entries(scatterplot.get('keyMap')).length).toBe(0); + expect(Object.entries(scatterplot.get('actionKeyMap')).length).toBe(0); // Test multi selections via mousedown + mousemove canvas.dispatchEvent( @@ -559,6 +559,129 @@ test('test lasso selection via the initiator', async () => { scatterplot.destroy(); }); +test('test brush lasso selection', async () => { + const dim = 200; + const hdim = dim / 2; + const qdim = dim / 4; + const canvas = createCanvas(dim, dim); + const scatterplot = createScatterplot({ + canvas, + width: dim, + height: dim, + lassoInitiator: true, + lassoType: 'brush', + }); + + await scatterplot.draw([ + [0, 0], + [1, 1], + [1, -1], + [-1, -1], + [-1, 1], + [0, 0.5], + [0.5, 0], + [0, -0.5], + [-0.5, 0], + ]); + + const lassoIniatorElement = scatterplot.get('lassoInitiatorElement'); + + let selectedPoints = []; + scatterplot.subscribe('select', ({ points: newSelectedPoints }) => { + selectedPoints = [...newSelectedPoints]; + }); + + canvas.dispatchEvent(createMouseEvent('click', qdim, qdim)); + + // We need to wait for the click delay and some extra milliseconds for + // the circle to appear + await wait(SINGLE_CLICK_DELAY + 50); + + lassoIniatorElement.dispatchEvent( + createMouseEvent('mousedown', qdim, qdim, { buttons: 1 }) + ); + await wait(0); + + const mousePositions = [ + [qdim, qdim], + [hdim + qdim, qdim], + [hdim + qdim, hdim + qdim], + [qdim, hdim + qdim], + [qdim, qdim], + ]; + + for (const mousePosition of mousePositions) { + window.dispatchEvent(createMouseEvent('mousemove', ...mousePosition)); + await wait(DEFAULT_LASSO_MIN_DELAY + 5); + } + + window.dispatchEvent(createMouseEvent('mouseup')); + + await wait(0); + + expect(selectedPoints).toEqual([5, 6, 7, 8]); + + scatterplot.destroy(); +}); + +test('test rectangle lasso selection', async () => { + const dim = 200; + const hdim = dim / 2; + const canvas = createCanvas(dim, dim); + const scatterplot = createScatterplot({ + canvas, + width: dim, + height: dim, + lassoInitiator: true, + lassoType: 'rectangle', + }); + + await scatterplot.draw([ + [0, 0], + [1, 1], + [1, -1], + [0, -1], + [-1, -1], + [-1, 1], + ]); + + const lassoIniatorElement = scatterplot.get('lassoInitiatorElement'); + + let selectedPoints = []; + scatterplot.subscribe('select', ({ points: newSelectedPoints }) => { + selectedPoints = [...newSelectedPoints]; + }); + + canvas.dispatchEvent(createMouseEvent('click', hdim - 10, hdim - 10)); + + // We need to wait for the click delay and some extra milliseconds for + // the circle to appear + await wait(SINGLE_CLICK_DELAY + 50); + + lassoIniatorElement.dispatchEvent( + createMouseEvent('mousedown', hdim - 10, hdim - 10, { buttons: 1 }) + ); + await wait(0); + + const mousePositions = [ + [hdim - 10, hdim - 10], + [dim + 10, dim + 10], + ]; + + for (const mousePosition of mousePositions) { + window.dispatchEvent(createMouseEvent('mousemove', ...mousePosition)); + await wait(DEFAULT_LASSO_MIN_DELAY + 5); + } + + window.dispatchEvent(createMouseEvent('mouseup')); + + await wait(0); + + expect(selectedPoints).toEqual([0, 2, 3]); + + scatterplot.destroy(); +}); + test('test rotation', async () => { const dim = 200; const hdim = dim / 2; @@ -567,6 +690,7 @@ test('test rotation', async () => { canvas, width: dim, height: dim, + // keyMap: { alt: 'rotate', shift: 'lasso', cmd: 'merge' } }); await scatterplot.draw([[0, 0]]); @@ -580,8 +704,8 @@ test('test rotation', async () => { }; scatterplot.subscribe('view', viewHandler); - let [rotateKey] = Object.entries(scatterplot.get('keyMap')).find( - (mapping) => mapping[1] === KEY_ACTION_ROTATE + let [_, rotateKey] = Object.entries(scatterplot.get('actionKeyMap')).find( + ([action]) => action === KEY_ACTION_ROTATE ); // Test rotation via keydown + mousedown + mousemove + keydown diff --git a/tests/get-set.test.js b/tests/get-set.test.js index e80b24c..90b8a98 100644 --- a/tests/get-set.test.js +++ b/tests/get-set.test.js @@ -738,7 +738,6 @@ test('set() after destroy', async () => { await expect(whenSet).rejects.toThrow(ERROR_INSTANCE_IS_DESTROYED); }); - test('get() and set() performance properties', async () => { const scatterplotA = createScatterplot({ canvas: createCanvas() }); @@ -791,3 +790,31 @@ test('get() and set() performance properties', async () => { scatterplotD.destroy(); }); + +test('get() and set() lasso types', async () => { + const scatterplot = createScatterplot({ + canvas: createCanvas(), + lassoType: 'rectangle', + lassoBrushSize: 32, + }); + + expect(scatterplot.get('lassoType')).toBe('rectangle'); + expect(scatterplot.get('lassoBrushSize')).toBe(32); + + scatterplot.set({ lassoType: 'brush', lassoBrushSize: 18 }); + + expect(scatterplot.get('lassoType')).toBe('brush'); + expect(scatterplot.get('lassoBrushSize')).toBe(18); + + scatterplot.set({ lassoType: 'freeform' }); + + expect(scatterplot.get('lassoType')).toBe('freeform'); + + scatterplot.set({ lassoType: 'rectangle' }); + + expect(scatterplot.get('lassoType')).toBe('rectangle'); + + scatterplot.set({ lassoType: 'invalid' }); + + expect(scatterplot.get('lassoType')).toBe('freeform'); +}); diff --git a/tests/methods.test.js b/tests/methods.test.js index a2ab51e..681da45 100644 --- a/tests/methods.test.js +++ b/tests/methods.test.js @@ -1,6 +1,6 @@ import '@babel/polyfill'; import { assert, expect, test } from 'vitest'; -import { nextAnimationFrame } from '@flekschas/utils'; +import { nextAnimationFrame, hasSameElements } from '@flekschas/utils'; import createScatterplot from '../src'; @@ -11,12 +11,7 @@ import { ERROR_POINTS_NOT_DRAWN, } from '../src/constants'; -import { - createCanvas, - wait, - isSameElements, - getPixelSum, -} from './utils'; +import { createCanvas, wait, getPixelSum } from './utils'; const EPS = 1e-7; @@ -69,7 +64,7 @@ test('draw() with preventFilterReset', async () => { await wait(0); expect( - isSameElements(scatterplot.get('filteredPoints'), filteredPoints) + hasSameElements(scatterplot.get('filteredPoints'), filteredPoints) ).toBe(true); let img = scatterplot.export(); @@ -91,7 +86,7 @@ test('draw() with preventFilterReset', async () => { // the filtered points should be the same as before expect( - isSameElements(scatterplot.get('filteredPoints'), filteredPoints) + hasSameElements(scatterplot.get('filteredPoints'), filteredPoints) ).toBe(true); img = scatterplot.export(); @@ -317,7 +312,7 @@ test('filter()', async () => { expect(scatterplot.get('isPointsFiltered')).toBe(false); expect( - isSameElements(scatterplot.get('filteredPoints'), [0, 1, 2, 3, 4]) + hasSameElements(scatterplot.get('filteredPoints'), [0, 1, 2, 3, 4]) ).toBe(true); let filteredPoints = []; @@ -344,17 +339,17 @@ test('filter()', async () => { await wait(0); expect( - isSameElements(filteredPoints, [1, 3]) + hasSameElements(filteredPoints, [1, 3]) ).toBe(true); expect(scatterplot.get('isPointsFiltered')).toBe(true); expect( - isSameElements(scatterplot.get('pointsInView'), [1, 3]) + hasSameElements(scatterplot.get('pointsInView'), [1, 3]) ).toBe(true); expect( - isSameElements(scatterplot.get('filteredPoints'), [1, 3]) + hasSameElements(scatterplot.get('filteredPoints'), [1, 3]) ).toBe(true); scatterplot.hover(1); @@ -404,11 +399,11 @@ test('filter()', async () => { // should have unfiltered the points expect( - isSameElements(filteredPoints, [0, 1, 2, 3, 4]) + hasSameElements(filteredPoints, [0, 1, 2, 3, 4]) ).toBe(true); expect( - isSameElements(scatterplot.get('filteredPoints'), [0, 1, 2, 3, 4]) + hasSameElements(scatterplot.get('filteredPoints'), [0, 1, 2, 3, 4]) ).toBe(true); expect( @@ -458,7 +453,7 @@ test('filter()', async () => { // should have filtered down to valid points (0, 2, and 4) only expect( - isSameElements(filteredPoints, [0, 2, 4]) + hasSameElements(filteredPoints, [0, 2, 4]) ).toBe(true); // We're testing this due to the following bug where we accidentically @@ -548,7 +543,7 @@ test('test hover, select, and filter options of `draw()`', async () => { expect(scatterplot.get('hoveredPoint')).toBe(0); expect(scatterplot.get('selectedPoints')).toEqual([2]); expect( - isSameElements(scatterplot.get('filteredPoints'), [0, 2, 3]) + hasSameElements(scatterplot.get('filteredPoints'), [0, 2, 3]) ).toBe(true); scatterplot.destroy(); diff --git a/tests/utils.js b/tests/utils.js index e547d70..ea9c8ed 100644 --- a/tests/utils.js +++ b/tests/utils.js @@ -57,12 +57,6 @@ export const wait = (milliSeconds) => export const capitalize = (s) => `${s[0].toUpperCase}${s.slice(1)}`; -export const isSameElements = (a, b) => { - if (a.length !== b.length) return false; - const aSet = new Set(a); - return b.every((value) => aSet.has(value)); -}; - export const getPixelSum = (img, xStart, xEnd, yStart, yEnd) => { let pixelSum = 0; for (let i = yStart; i < yEnd; i++) {