Skip to content

Commit 8b9f9db

Browse files
committed
Implement density-based opacity
Via `opacityBy: 'density'`
1 parent 1d1f0db commit 8b9f9db

3 files changed

Lines changed: 126 additions & 18 deletions

File tree

src/constants.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ export const DEFAULT_POINT_CONNECTION_OPACITY_BY = null;
9292
export const DEFAULT_POINT_CONNECTION_OPACITY_ACTIVE = 0.66;
9393
export const DEFAULT_OPACITY = 1;
9494
export const DEFAULT_OPACITY_BY = null;
95+
export const DEFAULT_OPACITY_BY_DENSITY_FILL = 0.15;
96+
export const DEFAULT_OPACITY_BY_DENSITY_DEBOUNCE_TIME = 25;
9597

9698
// Default colors
9799
export const DEFAULT_COLORMAP = [];

src/index.js

Lines changed: 108 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import createPubSub from 'pub-sub-es';
44
import withRaf from 'with-raf';
55
import { mat4, vec4 } from 'gl-matrix';
66
import createLine from 'regl-line';
7-
import { identity, rangeMap, unionIntegers } from '@flekschas/utils';
7+
import {
8+
identity,
9+
rangeMap,
10+
unionIntegers,
11+
throttleAndDebounce,
12+
} from '@flekschas/utils';
813

914
import createLassoManager from './lasso-manager';
1015

@@ -91,6 +96,8 @@ import {
9196
LONG_CLICK_TIME,
9297
DEFAULT_OPACITY,
9398
DEFAULT_OPACITY_BY,
99+
DEFAULT_OPACITY_BY_DENSITY_FILL,
100+
DEFAULT_OPACITY_BY_DENSITY_DEBOUNCE_TIME,
94101
DEFAULT_GAMMA,
95102
} from './constants';
96103

@@ -131,7 +138,7 @@ const checkDeprecations = (properties) => {
131138
const getEncodingType = (
132139
type,
133140
defaultValue,
134-
{ allowSegment = false } = false
141+
{ allowSegment = false, allowDensity = false } = {}
135142
) => {
136143
switch (type) {
137144
case 'category':
@@ -151,6 +158,9 @@ const getEncodingType = (
151158
case 'segment':
152159
return allowSegment ? 'segment' : defaultValue;
153160

161+
case 'density':
162+
return allowDensity ? 'density' : defaultValue;
163+
154164
default:
155165
return defaultValue;
156166
}
@@ -220,6 +230,7 @@ const createScatterplot = (initialProperties = {}) => {
220230
pointOutlineWidth = DEFAULT_POINT_OUTLINE_WIDTH,
221231
opacity = DEFAULT_OPACITY,
222232
opacityBy = DEFAULT_OPACITY_BY,
233+
opacityByDensityFill = DEFAULT_OPACITY_BY_DENSITY_FILL,
223234
sizeBy = DEFAULT_SIZE_BY,
224235
height = DEFAULT_HEIGHT,
225236
width = DEFAULT_WIDTH,
@@ -230,7 +241,10 @@ const createScatterplot = (initialProperties = {}) => {
230241
let currentHeight = height === 'auto' ? 1 : height;
231242

232243
// The following properties cannod be changed after the initialization
233-
const { performanceMode = DEFAULT_PERFORMANCE_MODE } = initialProperties;
244+
const {
245+
performanceMode = DEFAULT_PERFORMANCE_MODE,
246+
opacityByDensityDebounceTime = DEFAULT_OPACITY_BY_DENSITY_DEBOUNCE_TIME,
247+
} = initialProperties;
234248

235249
checkReglExtensions(regl);
236250

@@ -260,6 +274,7 @@ const createScatterplot = (initialProperties = {}) => {
260274
let mouseDownTime = null;
261275
let mouseDownPosition = [0, 0];
262276
let numPoints = 0;
277+
let numPointsInView = 0;
263278
let lassoActive = false;
264279
let lassoPointsCurr = [];
265280
let searchIndex;
@@ -275,6 +290,8 @@ const createScatterplot = (initialProperties = {}) => {
275290
let computedPointSizeMouseDetection;
276291
let keyActionMap = flipObj(keyMap);
277292
let lassoInitiatorTimeout;
293+
let topRightNdc;
294+
let bottomLeftNdc;
278295

279296
pointColor = isMultipleColors(pointColor) ? [...pointColor] : [pointColor];
280297
pointColorActive = isMultipleColors(pointColorActive)
@@ -366,7 +383,9 @@ const createScatterplot = (initialProperties = {}) => {
366383
}
367384

368385
colorBy = getEncodingType(colorBy, DEFAULT_COLOR_BY);
369-
opacityBy = getEncodingType(opacityBy, DEFAULT_OPACITY_BY);
386+
opacityBy = getEncodingType(opacityBy, DEFAULT_OPACITY_BY, {
387+
allowDensity: true,
388+
});
370389
sizeBy = getEncodingType(sizeBy, DEFAULT_SIZE_BY);
371390

372391
pointConnectionColorBy = getEncodingType(
@@ -469,7 +488,7 @@ const createScatterplot = (initialProperties = {}) => {
469488
const pointScale = getPointScale();
470489

471490
// The height of the view in normalized device coordinates
472-
const heightNdc = getScatterGlPos(1, 1)[1] - getScatterGlPos(-1, -1)[1];
491+
const heightNdc = topRightNdc[1] - bottomLeftNdc[1];
473492
// The size of a pixel in the current view in normalized device coordinates
474493
const pxNdc = heightNdc / currentHeight;
475494
// The scaled point size in normalized device coordinates
@@ -1080,7 +1099,9 @@ const createScatterplot = (initialProperties = {}) => {
10801099
colorBy = getEncodingType(type, DEFAULT_COLOR_BY);
10811100
};
10821101
const setOpacityBy = (type) => {
1083-
opacityBy = getEncodingType(type, DEFAULT_OPACITY_BY);
1102+
opacityBy = getEncodingType(type, DEFAULT_OPACITY_BY, {
1103+
allowDensity: true,
1104+
});
10841105
};
10851106
const setSizeBy = (type) => {
10861107
sizeBy = getEncodingType(type, DEFAULT_SIZE_BY);
@@ -1127,13 +1148,13 @@ const createScatterplot = (initialProperties = {}) => {
11271148
const getProjectionViewModel = () =>
11281149
mat4.multiply(pvm, projection, mat4.multiply(pvm, camera.view, model));
11291150
const getPointScale = () =>
1130-
min(1.0, camera.scaling) +
1131-
Math.log2(max(1.0, camera.scaling)) * window.devicePixelRatio;
1151+
1 + Math.log2(max(1.0, camera.scaling)) * window.devicePixelRatio;
11321152
const getNormalNumPoints = () => numPoints;
11331153
const getIsColoredByZ = () => +(colorBy === 'valueZ');
11341154
const getIsColoredByW = () => +(colorBy === 'valueW');
11351155
const getIsOpacityByZ = () => +(opacityBy === 'valueZ');
11361156
const getIsOpacityByW = () => +(opacityBy === 'valueW');
1157+
const getIsOpacityByDensity = () => +(opacityBy === 'density');
11371158
const getIsSizedByZ = () => +(sizeBy === 'valueZ');
11381159
const getIsSizedByW = () => +(sizeBy === 'valueW');
11391160
const getColorMultiplicator = () => {
@@ -1148,6 +1169,46 @@ const createScatterplot = (initialProperties = {}) => {
11481169
if (sizeBy === 'valueZ') return maxValueZ <= 1 ? pointSize.length - 1 : 1;
11491170
return maxValueW <= 1 ? pointSize.length - 1 : 1;
11501171
};
1172+
const getOpacityDensity = (context) => {
1173+
if (opacityBy !== 'density') return 1;
1174+
1175+
// Adopted from the fabulous Ricky Reusser:
1176+
// https://observablehq.com/@rreusser/selecting-the-right-opacity-for-2d-point-clouds
1177+
// Extended with a point-density based approach
1178+
const pointScale = getPointScale();
1179+
const smallestPointSize = pointSize.length === 1 ? pointSize[0] : pointSize;
1180+
const p = smallestPointSize * pointScale;
1181+
1182+
// Compute the plot's x and y range from the view matrix, though these could come from any source
1183+
const s = (2 / (2 / camera.view[0])) * (2 / (2 / camera.view[5]));
1184+
1185+
// Viewport size, in device pixels
1186+
const H = context.viewportHeight;
1187+
1188+
// Adaptation: Instead of using the global number of points, I am using a
1189+
// density-based approach that takes the points in the view into context
1190+
// when zooming in. This ensure that in sparse areas, points are opaque and
1191+
// in dense areas points are more translucent.
1192+
let alpha =
1193+
((opacityByDensityFill * H * H) / (numPointsInView * p * p)) * min(1, s);
1194+
1195+
// In performanceMode we use squares, otherwise we use circles, which only
1196+
// take up (pi r^2) of the unit square
1197+
alpha *= performanceMode ? 1 : 1 / (0.25 * Math.PI);
1198+
1199+
// If the pixels shrink below the minimum permitted size, then we adjust the opacity instead
1200+
// and apply clamping of the point size in the vertex shader. Note that we add 0.5 since we
1201+
// slightly inrease the size of points during rendering to accommodate SDF-style antialiasing.
1202+
const clampedPointDeviceSize = max(smallestPointSize, p) + 0.5;
1203+
1204+
// We square this since we're concerned with the ratio of *areas*.
1205+
// eslint-disable-next-line no-restricted-properties
1206+
alpha *= Math.pow(p / clampedPointDeviceSize, 2);
1207+
1208+
// And finally, we clamp to the range [0, 1]. We should really clamp this to 1 / precision
1209+
// on the low end, depending on the data type of the destination so that we never render *nothing*.
1210+
return min(1, max(0, alpha));
1211+
};
11511212

11521213
const updatePoints = regl({
11531214
framebuffer: () => tmpStateBuffer,
@@ -1259,10 +1320,12 @@ const createScatterplot = (initialProperties = {}) => {
12591320
isColoredByW: getIsColoredByW,
12601321
isOpacityByZ: getIsOpacityByZ,
12611322
isOpacityByW: getIsOpacityByW,
1323+
isOpacityByDensity: getIsOpacityByDensity,
12621324
isSizedByZ: getIsSizedByZ,
12631325
isSizedByW: getIsSizedByW,
12641326
colorMultiplicator: getColorMultiplicator,
12651327
opacityMultiplicator: getOpacityMultiplicator,
1328+
opacityDensity: getOpacityDensity,
12661329
sizeMultiplicator: getSizeMultiplicator,
12671330
numColorStates: COLOR_NUM_STATES,
12681331
},
@@ -1495,6 +1558,7 @@ const createScatterplot = (initialProperties = {}) => {
14951558
isInit = false;
14961559

14971560
numPoints = newPoints.length;
1561+
numPointsInView = numPoints;
14981562

14991563
if (stateTex) stateTex.destroy();
15001564
stateTex = createStateTexture(newPoints);
@@ -1700,12 +1764,34 @@ const createScatterplot = (initialProperties = {}) => {
17001764
}
17011765
});
17021766

1767+
const computeNumPointsInView = () => {
1768+
numPointsInView =
1769+
camera.scaling <= 1
1770+
? numPoints
1771+
: searchIndex.range(
1772+
bottomLeftNdc[0],
1773+
bottomLeftNdc[1],
1774+
topRightNdc[0],
1775+
topRightNdc[1]
1776+
).length;
1777+
};
1778+
const computeNumPointsInViewDb = throttleAndDebounce(
1779+
computeNumPointsInView,
1780+
opacityByDensityDebounceTime
1781+
);
1782+
17031783
const draw = (showRecticleOnce) => {
17041784
if (!isInit || !regl) return;
17051785

17061786
// Update camera
17071787
isViewChanged = camera.tick();
17081788

1789+
if (isViewChanged) {
1790+
topRightNdc = getScatterGlPos(1, 1);
1791+
bottomLeftNdc = getScatterGlPos(-1, -1);
1792+
computeNumPointsInViewDb();
1793+
}
1794+
17091795
regl.clear({
17101796
// background color (transparent)
17111797
color: [0, 0, 0, 0],
@@ -2163,6 +2249,10 @@ const createScatterplot = (initialProperties = {}) => {
21632249
computePointSizeMouseDetection();
21642250
};
21652251

2252+
const setOpacityByDensityFill = (newOpacityByDensityFill) => {
2253+
opacityByDensityFill = +newOpacityByDensityFill;
2254+
};
2255+
21662256
const setGamma = (newGamma) => {
21672257
gamma = +newGamma;
21682258
};
@@ -2208,6 +2298,9 @@ const createScatterplot = (initialProperties = {}) => {
22082298
if (property === 'opacity')
22092299
return opacity.length === 1 ? opacity[0] : opacity;
22102300
if (property === 'opacityBy') return opacityBy;
2301+
if (property === 'opacityByDensityFill') return opacityByDensityFill;
2302+
if (property === 'opacityByDensityDebounceTime')
2303+
return opacityByDensityDebounceTime;
22112304
if (property === 'pointColor')
22122305
return pointColor.length === 1 ? pointColor[0] : pointColor;
22132306
if (property === 'pointColorActive')
@@ -2465,6 +2558,10 @@ const createScatterplot = (initialProperties = {}) => {
24652558
setDeselectOnEscape(properties.deselectOnEscape);
24662559
}
24672560

2561+
if (properties.opacityByDensityFill !== undefined) {
2562+
setOpacityByDensityFill(properties.opacityByDensityFill);
2563+
}
2564+
24682565
if (properties.gamma !== undefined) {
24692566
setGamma(properties.gamma);
24702567
}
@@ -2529,6 +2626,9 @@ const createScatterplot = (initialProperties = {}) => {
25292626
} else {
25302627
camera.setView(mat4.clone(DEFAULT_VIEW));
25312628
}
2629+
2630+
topRightNdc = getScatterGlPos(1, 1);
2631+
bottomLeftNdc = getScatterGlPos(-1, -1);
25322632
};
25332633

25342634
const reset = () => {

src/point.vs

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ uniform float isColoredByZ;
1818
uniform float isColoredByW;
1919
uniform float isOpacityByZ;
2020
uniform float isOpacityByW;
21+
uniform float isOpacityByDensity;
2122
uniform float isSizedByZ;
2223
uniform float isSizedByW;
2324
uniform float colorMultiplicator;
2425
uniform float opacityMultiplicator;
26+
uniform float opacityDensity;
2527
uniform float sizeMultiplicator;
2628
uniform float numColorStates;
2729
uniform float pointScale;
@@ -74,16 +76,20 @@ void main() {
7476
float pointSize = texture2D(encodingTex, pointSizeTexIndex).x;
7577

7678
// Retrieve opacity
77-
float opacityIndexZ = isOpacityByZ * floor(state.z * opacityMultiplicator);
78-
float opacityIndexW = isOpacityByW * floor(state.w * opacityMultiplicator);
79-
float opacityIndex = opacityIndexZ + opacityIndexW;
80-
81-
float opacityRowIndex = floor((opacityIndex + encodingTexEps) / encodingTexRes);
82-
vec2 opacityTexIndex = vec2(
83-
(opacityIndex / encodingTexRes) - opacityRowIndex + encodingTexEps,
84-
opacityRowIndex / encodingTexRes + encodingTexEps
85-
);
86-
color.a = min(1.0, texture2D(encodingTex, opacityTexIndex).y + globalState);
79+
if (isOpacityByDensity < 0.5) {
80+
float opacityIndexZ = isOpacityByZ * floor(state.z * opacityMultiplicator);
81+
float opacityIndexW = isOpacityByW * floor(state.w * opacityMultiplicator);
82+
float opacityIndex = opacityIndexZ + opacityIndexW;
83+
84+
float opacityRowIndex = floor((opacityIndex + encodingTexEps) / encodingTexRes);
85+
vec2 opacityTexIndex = vec2(
86+
(opacityIndex / encodingTexRes) - opacityRowIndex + encodingTexEps,
87+
opacityRowIndex / encodingTexRes + encodingTexEps
88+
);
89+
color.a = min(1.0, texture2D(encodingTex, opacityTexIndex).y + globalState);
90+
} else {
91+
color.a = min(1.0, opacityDensity + globalState);
92+
}
8793

8894
finalPointSize = (pointSize * pointScale) + pointSizeExtra;
8995
gl_PointSize = finalPointSize;

0 commit comments

Comments
 (0)