@@ -4,7 +4,12 @@ import createPubSub from 'pub-sub-es';
44import withRaf from 'with-raf' ;
55import { mat4 , vec4 } from 'gl-matrix' ;
66import 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
914import 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) => {
131138const 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 = ( ) => {
0 commit comments