From 11e353852312373312032ec5517c848f3de9ec01 Mon Sep 17 00:00:00 2001 From: Fritz Lekschas Date: Sat, 28 Sep 2024 12:02:26 -0400 Subject: [PATCH] feat: add support for linear and constant point scaling via a new property called `pointScaleMode` (#195) * feat: add support for linear and constant point scaling via a new property called `pointScaleMode` (#194) * add pointScaleMode: proportional scales points proportional to zoom, constant keeps point size constant, default keeps the current smart scaling strategy * keep default mode to default; formatting --------- Co-authored-by: basta * fix: remove nonsense * refactor: ensure `getPointScale()` runs as fast as possible * docs: update changelog * docs: document and type `pointScaleMode` * chore: revert accidentally removed comma * fix: allow changing scale mode interactively * test: add a test for point scale mode --------- Co-authored-by: abast Co-authored-by: basta --- CHANGELOG.md | 4 +++ README.md | 1 + src/constants.js | 1 + src/index.js | 49 ++++++++++++++++++++++++++--- src/types.d.ts | 3 ++ tests/index.js | 80 ++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 134 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5530616..4047a12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.11.0 + +- Feat: add support for linear and constant point scaling via a new property called `pointScaleMode`. + ## 1.10.4 - Fix: restrict the renderer's canvas to be at most as large as the screen. Previously we had the canvas size bound to `window.innerWidth` and `window.innerHeight`. However, in VSCode it was possible that `window.innerHeight` was muuuuch larger than the actual screen, which in turn caused a WebGL error (invalid renderbuffer size). This issues was first reported at https://github.com/flekschas/jupyter-scatter/issues/37. diff --git a/README.md b/README.md index 11fda47..1c58216 100644 --- a/README.md +++ b/README.md @@ -788,6 +788,7 @@ can be read and written via [`scatterplot.get()`](#scatterplot.get) and [`scatte | pointConnectionSizeBy | string | `null` | See [data encoding](#property-point-conntection-by) | `true` | `false` | | pointConnectionMaxIntPointsPerSegment | int | `100` | | `true` | `false` | | pointConnectionTolerance | float | `0.002` | | `true` | `false` | +| pointScale | string | `'asinh'` | `'asinh'`, `'linear'`, or `'constant'` | `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` | diff --git a/src/constants.js b/src/constants.js index 3a3a017..8c22444 100644 --- a/src/constants.js +++ b/src/constants.js @@ -99,6 +99,7 @@ export const DEFAULT_GAMMA = 1; // Default styles export const MIN_POINT_SIZE = 1; +export const DEFAULT_POINT_SCALE_MODE = 'asinh'; export const DEFAULT_POINT_SIZE = 6; export const DEFAULT_POINT_SIZE_SELECTED = 2; export const DEFAULT_POINT_OUTLINE_WIDTH = 2; diff --git a/src/index.js b/src/index.js index a5bc792..327ed4d 100644 --- a/src/index.js +++ b/src/index.js @@ -82,6 +82,7 @@ import { DEFAULT_POINT_CONNECTION_SIZE_ACTIVE, DEFAULT_POINT_CONNECTION_SIZE_BY, DEFAULT_POINT_OUTLINE_WIDTH, + DEFAULT_POINT_SCALE_MODE, DEFAULT_POINT_SIZE, DEFAULT_POINT_SIZE_MOUSE_DETECTION, DEFAULT_POINT_SIZE_SELECTED, @@ -278,6 +279,7 @@ const createScatterplot = ( opacityInactiveMax = DEFAULT_OPACITY_INACTIVE_MAX, opacityInactiveScale = DEFAULT_OPACITY_INACTIVE_SCALE, sizeBy = DEFAULT_SIZE_BY, + pointScaleMode = DEFAULT_POINT_SCALE_MODE, height = DEFAULT_HEIGHT, width = DEFAULT_WIDTH, annotationLineColor = DEFAULT_ANNOTATION_LINE_COLOR, @@ -1475,16 +1477,27 @@ const createScatterplot = ( const getModel = () => model; const getModelViewProjection = () => mat4.multiply(pvm, projection, mat4.multiply(pvm, camera.view, model)); - const getPointScale = () => { + const getConstantPointScale = () => { + return window.devicePixelRatio; + }; + const getLinearPointScale = () => { + return max(minPointScale, camera.scaling[0]) * window.devicePixelRatio; + }; + const getAsinhPointScale = () => { if (camera.scaling[0] > 1) { return ( (Math.asinh(max(1.0, camera.scaling[0])) / Math.asinh(1)) * window.devicePixelRatio ); } - return max(minPointScale, camera.scaling[0]) * window.devicePixelRatio; }; + let getPointScale = getAsinhPointScale; + if (pointScaleMode === 'linear') { + getPointScale = getLinearPointScale; + } else if (pointScaleMode === 'constant') { + getPointScale = getConstantPointScale; + } const getNormalNumPoints = () => isPointsFiltered ? filteredPointsSet.size : numPoints; const getSelectedNumPoints = () => selectedPoints.length; @@ -1525,7 +1538,7 @@ const createScatterplot = ( // Adopted from the fabulous Ricky Reusser: // https://observablehq.com/@rreusser/selecting-the-right-opacity-for-2d-point-clouds // Extended with a point-density based approach - const pointScale = getPointScale(true); + const pointScale = getPointScale(); const p = pointSize[0] * pointScale; // Compute the plot's x and y range from the view matrix, though these could come from any source @@ -1615,7 +1628,7 @@ const createScatterplot = ( resolution: getResolution, modelViewProjection: getModelViewProjection, devicePixelRatio: getDevicePixelRatio, - pointScale: getPointScale, + pointScale: () => getPointScale(), encodingTex: getEncodingTex, encodingTexRes: getEncodingTexRes, encodingTexEps: getEncodingTexEps, @@ -3186,6 +3199,26 @@ const createScatterplot = ( computePointSizeMouseDetection(); }; + const setPointScaleMode = (newPointScaleMode) => { + switch (newPointScaleMode) { + case 'linear': { + pointScaleMode = newPointScaleMode; + getPointScale = getLinearPointScale; + break; + } + case 'constant': { + pointScaleMode = newPointScaleMode; + getPointScale = getConstantPointScale; + break; + } + default: { + pointScaleMode = 'asinh'; + getPointScale = getAsinhPointScale; + break; + } + } + }; + const setOpacityByDensityFill = (newOpacityByDensityFill) => { opacityByDensityFill = +newOpacityByDensityFill; }; @@ -3457,6 +3490,10 @@ const createScatterplot = ( return pointConnectionTolerance; } + if (property === 'pointScaleMode') { + return pointScaleMode; + } + if (property === 'reticleColor') { return reticleColor; } @@ -3661,6 +3698,10 @@ const createScatterplot = ( setPointConnectionTolerance(properties.pointConnectionTolerance); } + if (properties.pointScaleMode !== undefined) { + setPointScaleMode(properties.pointScaleMode); + } + if (properties.opacityBy !== undefined) { setOpacityBy(properties.opacityBy); } diff --git a/src/types.d.ts b/src/types.d.ts index 66e226a..4cdd837 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -15,6 +15,8 @@ type KeyMap = Record<'alt' | 'cmd' | 'ctrl' | 'meta' | 'shift', KeyAction>; type MouseMode = 'panZoom' | 'lasso' | 'rotate'; +type PointScaleMode = 'constant' | 'asinh' | 'linear'; + // biome-ignore lint/style/useNamingConvention: ZWData are three words, z, w, and data type ZWDataType = 'continuous' | 'categorical'; @@ -159,6 +161,7 @@ interface BaseOptions { opacityBy: null | DataEncoding; xScale: null | Scale; yScale: null | Scale; + pointScaleMode: PointScaleMode; } // biome-ignore lint/style/useNamingConvention: KDBush is a library name diff --git a/tests/index.js b/tests/index.js index 1586df6..0059867 100644 --- a/tests/index.js +++ b/tests/index.js @@ -38,6 +38,7 @@ import { DEFAULT_POINT_CONNECTION_OPACITY_ACTIVE, DEFAULT_POINT_CONNECTION_SIZE, DEFAULT_POINT_CONNECTION_SIZE_ACTIVE, + DEFAULT_POINT_SCALE_MODE, DEFAULT_GAMMA, KEY_ACTION_LASSO, KEY_ACTION_ROTATE, @@ -3302,6 +3303,85 @@ test('regl-scatterplot', async (t2) => { scatterplot.destroy(); }) ); + + await t2.test( + 'pointScaleMode', + catchError(async (t) => { + const dim = 100; + const scatterplot = createScatterplot({ + canvas: createCanvas(dim, dim), + width: dim, + height: dim, + pointSize: 10, + }); + + t.equal( + scatterplot.get('pointScaleMode'), + DEFAULT_POINT_SCALE_MODE, + `The default point scale mode should be ${DEFAULT_POINT_SCALE_MODE}` + ); + + await scatterplot.draw([[0, 0]]); + + const initialImage = scatterplot.export(); + const initialPixelSum = getPixelSum(initialImage, 0, dim, 0, dim); + + t.ok(initialPixelSum > 0, 'The point should be drawn'); + + // Zoom in a bit + await scatterplot.zoomToLocation([0, 0], 0.5); + + const asinhImage = scatterplot.export(); + const asinhPixelSum = getPixelSum(asinhImage, 0, dim, 0, dim); + + t.ok(asinhPixelSum > initialPixelSum, 'The point should be larger'); + + // Zoom back to the origin + await scatterplot.zoomToLocation([0, 0], 1); + + scatterplot.set({ pointScaleMode: 'constant' }); + t.equal( + scatterplot.get('pointScaleMode'), + 'constant', + 'The new point scale mode should be constant' + ); + + // Zoom in a bit + await scatterplot.zoomToLocation([0, 0], 0.5); + + const constantImage = scatterplot.export(); + const constantPixelSum = getPixelSum(constantImage, 0, dim, 0, dim); + + t.ok(constantPixelSum === initialPixelSum, 'The point should be unchanged'); + + // Zoom back to the origin + await scatterplot.zoomToLocation([0, 0], 1); + + scatterplot.set({ pointScaleMode: 'linear' }); + t.equal( + scatterplot.get('pointScaleMode'), + 'linear', + 'The new point scale mode should be linear' + ); + + // Zoom in a bit + await scatterplot.zoomToLocation([0, 0], 0.5); + + const linearImage = scatterplot.export(); + const linearPixelSum = getPixelSum(linearImage, 0, dim, 0, dim); + + t.ok(linearPixelSum > asinhPixelSum, 'The point should be larger'); + + scatterplot.set({ pointScaleMode: 'nonsense' }); + t.equal( + scatterplot.get('pointScaleMode'), + 'asinh', + 'The point scale mode should default to "asinh" for invalid values' + ); + + scatterplot.destroy(); + }) + ); }); /* --------------------------------- Utils ---------------------------------- */