Skip to content

Commit

Permalink
feat: add support for linear and constant point scaling via a new pro…
Browse files Browse the repository at this point in the history
…perty 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: basta <[email protected]>
  • Loading branch information
3 people authored Sep 28, 2024
1 parent cc1d07c commit 11e3538
Show file tree
Hide file tree
Showing 6 changed files with 134 additions and 4 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
1 change: 1 addition & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
49 changes: 45 additions & 4 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1615,7 +1628,7 @@ const createScatterplot = (
resolution: getResolution,
modelViewProjection: getModelViewProjection,
devicePixelRatio: getDevicePixelRatio,
pointScale: getPointScale,
pointScale: () => getPointScale(),
encodingTex: getEncodingTex,
encodingTexRes: getEncodingTexRes,
encodingTexEps: getEncodingTexEps,
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -3457,6 +3490,10 @@ const createScatterplot = (
return pointConnectionTolerance;
}

if (property === 'pointScaleMode') {
return pointScaleMode;
}

if (property === 'reticleColor') {
return reticleColor;
}
Expand Down Expand Up @@ -3661,6 +3698,10 @@ const createScatterplot = (
setPointConnectionTolerance(properties.pointConnectionTolerance);
}

if (properties.pointScaleMode !== undefined) {
setPointScaleMode(properties.pointScaleMode);
}

if (properties.opacityBy !== undefined) {
setOpacityBy(properties.opacityBy);
}
Expand Down
3 changes: 3 additions & 0 deletions src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions tests/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 ---------------------------------- */
Expand Down

0 comments on commit 11e3538

Please sign in to comment.