diff --git a/package.json b/package.json index 24fcffe..4d8ea04 100644 --- a/package.json +++ b/package.json @@ -30,14 +30,14 @@ "prerelease": "rm -rf dist/*; npm run build; zip -r dist.zip dist", "pretest": "npm run lint", "start": "vite --port=3000", - "test": "rollup -c ./rollup.test.config.mjs | tape-run --render='tap-spec'", + "test": "rollup -c ./rollup.test.config.mjs | tape-run --browser='electron' --render='tap-spec'", "watch": "rollup -cw" }, "dependencies": { "@flekschas/utils": "^0.31.0", "dom-2d-camera": "~2.2.5", "gl-matrix": "~3.4.3", - "kdbush": "~3.0.0", + "kdbush": "~4.0.2", "lodash-es": "~4.17.21", "pub-sub-es": "~2.0.2", "regl": "~2.1.0", @@ -65,6 +65,7 @@ "d3-random": "^3.0.1", "d3-scale": "^4.0.2", "d3-selection": "^3.0.0", + "electron": "^24.1.3", "eslint": "^8.43.0", "eslint-config-airbnb": "^19.0.4", "eslint-config-prettier": "^8.8.0", @@ -77,7 +78,7 @@ "merge": "^2.1.1", "prettier": "^2.8.8", "pretty-quick": "^3.1.3", - "rollup": "^3.18.0", + "rollup": "^3.21.1", "rollup-plugin-filesize": "^10.0.0", "tap-spec": "^5.0.0", "tape-run": "^10.0.0", diff --git a/src/constants.js b/src/constants.js index 0357cfd..128b8a5 100644 --- a/src/constants.js +++ b/src/constants.js @@ -17,6 +17,7 @@ export const COLOR_BG_IDX = 3; export const COLOR_NUM_STATES = 4; export const FLOAT_BYTES = Float32Array.BYTES_PER_ELEMENT; export const GL_EXTENSIONS = [ + 'angle_instanced_arrays', 'OES_texture_float', 'OES_element_index_uint', 'WEBGL_color_buffer_float', @@ -100,7 +101,7 @@ export const DEFAULT_GAMMA = 1; // Default styles export const MIN_POINT_SIZE = 1; export const DEFAULT_POINT_SIZE = 6; -export const DEFAULT_POINT_SIZE_SELECTED = 2; +export const DEFAULT_POINT_SIZE_SELECTED = 0; export const DEFAULT_POINT_OUTLINE_WIDTH = 2; export const DEFAULT_SIZE_BY = null; export const DEFAULT_POINT_CONNECTION_SIZE = 2; diff --git a/src/index.js b/src/index.js index 4a3e1fa..f9dea38 100644 --- a/src/index.js +++ b/src/index.js @@ -132,10 +132,10 @@ import { limit, toRgba, max, - min, flipObj, rgbBrightness, clip, + createPropGetter, } from './utils'; import { version } from '../package.json'; @@ -172,6 +172,8 @@ const getEncodingType = ( if (type === 'density') return allowDensity ? 'density' : defaultValue; + if (type === null) return null; + return defaultValue; }; @@ -202,59 +204,146 @@ const createScatterplot = ( checkDeprecations(initialProperties); - let { - renderer, - backgroundColor = DEFAULT_COLOR_BG, - backgroundImage = DEFAULT_BACKGROUND_IMAGE, - canvas = document.createElement('canvas'), - colorBy = DEFAULT_COLOR_BY, - deselectOnDblClick = DEFAULT_DESELECT_ON_DBL_CLICK, - deselectOnEscape = DEFAULT_DESELECT_ON_ESCAPE, - lassoColor = DEFAULT_LASSO_COLOR, - lassoLineWidth = DEFAULT_LASSO_LINE_WIDTH, - lassoMinDelay = DEFAULT_LASSO_MIN_DELAY, - lassoMinDist = DEFAULT_LASSO_MIN_DIST, - lassoClearEvent = DEFAULT_LASSO_CLEAR_EVENT, - lassoInitiator = DEFAULT_LASSO_INITIATOR, - lassoInitiatorParentElement = document.body, - lassoOnLongPress = DEFAULT_LASSO_ON_LONG_PRESS, - lassoLongPressTime = DEFAULT_LASSO_LONG_PRESS_TIME, - 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, - mouseMode = DEFAULT_MOUSE_MODE, - showReticle = DEFAULT_SHOW_RETICLE, - reticleColor = DEFAULT_RETICLE_COLOR, - pointColor = DEFAULT_COLOR_NORMAL, - pointColorActive = DEFAULT_COLOR_ACTIVE, - pointColorHover = DEFAULT_COLOR_HOVER, - showPointConnections = DEFAULT_SHOW_POINT_CONNECTIONS, - pointConnectionColor = DEFAULT_POINT_CONNECTION_COLOR_NORMAL, - pointConnectionColorActive = DEFAULT_POINT_CONNECTION_COLOR_ACTIVE, - pointConnectionColorHover = DEFAULT_POINT_CONNECTION_COLOR_HOVER, - pointConnectionColorBy = DEFAULT_POINT_CONNECTION_COLOR_BY, - pointConnectionOpacity = DEFAULT_POINT_CONNECTION_OPACITY, - pointConnectionOpacityBy = DEFAULT_POINT_CONNECTION_OPACITY_BY, - pointConnectionOpacityActive = DEFAULT_POINT_CONNECTION_OPACITY_ACTIVE, - pointConnectionSize = DEFAULT_POINT_CONNECTION_SIZE, - pointConnectionSizeActive = DEFAULT_POINT_CONNECTION_SIZE_ACTIVE, - pointConnectionSizeBy = DEFAULT_POINT_CONNECTION_SIZE_BY, - pointConnectionMaxIntPointsPerSegment = DEFAULT_POINT_CONNECTION_MAX_INT_POINTS_PER_SEGMENT, - pointConnectionTolerance = DEFAULT_POINT_CONNECTION_INT_POINTS_TOLERANCE, - pointSize = DEFAULT_POINT_SIZE, - pointSizeSelected = DEFAULT_POINT_SIZE_SELECTED, - pointSizeMouseDetection = DEFAULT_POINT_SIZE_MOUSE_DETECTION, - pointOutlineWidth = DEFAULT_POINT_OUTLINE_WIDTH, - opacity = AUTO, - opacityBy = DEFAULT_OPACITY_BY, - opacityByDensityFill = DEFAULT_OPACITY_BY_DENSITY_FILL, - opacityInactiveMax = DEFAULT_OPACITY_INACTIVE_MAX, - opacityInactiveScale = DEFAULT_OPACITY_INACTIVE_SCALE, - sizeBy = DEFAULT_SIZE_BY, - height = DEFAULT_HEIGHT, - width = DEFAULT_WIDTH, - } = initialProperties; + const getInitProp = createPropGetter(initialProperties); + + let renderer = getInitProp('renderer'); + let backgroundColor = getInitProp('backgroundColor', DEFAULT_COLOR_BG); + let backgroundImage = getInitProp( + 'backgroundImage', + DEFAULT_BACKGROUND_IMAGE + ); + let canvas = getInitProp('canvas', document.createElement('canvas')); + let colorBy = getInitProp('colorBy', DEFAULT_COLOR_BY); + let deselectOnDblClick = getInitProp( + 'deselectOnDblClick', + DEFAULT_DESELECT_ON_DBL_CLICK + ); + let deselectOnEscape = getInitProp( + 'deselectOnEscape', + DEFAULT_DESELECT_ON_ESCAPE + ); + let lassoColor = getInitProp('lassoColor', DEFAULT_LASSO_COLOR); + let lassoLineWidth = getInitProp('lassoLineWidth', DEFAULT_LASSO_LINE_WIDTH); + let lassoMinDelay = getInitProp('lassoMinDelay', DEFAULT_LASSO_MIN_DELAY); + let lassoMinDist = getInitProp('lassoMinDist', DEFAULT_LASSO_MIN_DIST); + let lassoClearEvent = getInitProp( + 'lassoClearEvent', + DEFAULT_LASSO_CLEAR_EVENT + ); + let lassoInitiator = getInitProp('lassoInitiator', DEFAULT_LASSO_INITIATOR); + let lassoInitiatorParentElement = getInitProp( + 'lassoInitiatorParentElement', + document.body + ); + let lassoOnLongPress = getInitProp( + 'lassoOnLongPress', + DEFAULT_LASSO_ON_LONG_PRESS + ); + let lassoLongPressTime = getInitProp( + 'lassoLongPressTime', + DEFAULT_LASSO_LONG_PRESS_TIME + ); + let lassoLongPressAfterEffectTime = getInitProp( + 'lassoLongPressAfterEffectTime', + DEFAULT_LASSO_LONG_PRESS_AFTER_EFFECT_TIME + ); + let lassoLongPressEffectDelay = getInitProp( + 'lassoLongPressEffectDelay', + DEFAULT_LASSO_LONG_PRESS_EFFECT_DELAY + ); + let lassoLongPressRevertEffectTime = getInitProp( + 'lassoLongPressRevertEffectTime', + DEFAULT_LASSO_LONG_PRESS_REVERT_EFFECT_TIME + ); + let keyMap = getInitProp('keyMap', DEFAULT_KEY_MAP); + let mouseMode = getInitProp('mouseMode', DEFAULT_MOUSE_MODE); + let showReticle = getInitProp('showReticle', DEFAULT_SHOW_RETICLE); + let reticleColor = getInitProp('reticleColor', DEFAULT_RETICLE_COLOR); + let pointColor = getInitProp('pointColor', DEFAULT_COLOR_NORMAL); + let pointColorActive = getInitProp('pointColorActive', DEFAULT_COLOR_ACTIVE); + let pointColorHover = getInitProp('pointColorHover', DEFAULT_COLOR_HOVER); + let showPointConnections = getInitProp( + 'showPointConnections', + DEFAULT_SHOW_POINT_CONNECTIONS + ); + let pointConnectionColor = getInitProp( + 'pointConnectionColor', + DEFAULT_POINT_CONNECTION_COLOR_NORMAL + ); + let pointConnectionColorActive = getInitProp( + 'pointConnectionColorActive', + DEFAULT_POINT_CONNECTION_COLOR_ACTIVE + ); + let pointConnectionColorHover = getInitProp( + 'pointConnectionColorHover', + DEFAULT_POINT_CONNECTION_COLOR_HOVER + ); + let pointConnectionColorBy = getInitProp( + 'pointConnectionColorBy', + DEFAULT_POINT_CONNECTION_COLOR_BY + ); + let pointConnectionOpacity = getInitProp( + 'pointConnectionOpacity', + DEFAULT_POINT_CONNECTION_OPACITY + ); + let pointConnectionOpacityBy = getInitProp( + 'pointConnectionOpacityBy', + DEFAULT_POINT_CONNECTION_OPACITY_BY + ); + let pointConnectionOpacityActive = getInitProp( + 'pointConnectionOpacityActive', + DEFAULT_POINT_CONNECTION_OPACITY_ACTIVE + ); + let pointConnectionSize = getInitProp( + 'pointConnectionSize', + DEFAULT_POINT_CONNECTION_SIZE + ); + let pointConnectionSizeActive = getInitProp( + 'pointConnectionSizeActive', + DEFAULT_POINT_CONNECTION_SIZE_ACTIVE + ); + let pointConnectionSizeBy = getInitProp( + 'pointConnectionSizeBy', + DEFAULT_POINT_CONNECTION_SIZE_BY + ); + let pointConnectionMaxIntPointsPerSegment = getInitProp( + 'pointConnectionMaxIntPointsPerSegment', + DEFAULT_POINT_CONNECTION_MAX_INT_POINTS_PER_SEGMENT + ); + let pointConnectionTolerance = getInitProp( + 'pointConnectionTolerance', + DEFAULT_POINT_CONNECTION_INT_POINTS_TOLERANCE + ); + let pointSize = getInitProp('pointSize', DEFAULT_POINT_SIZE); + let pointSizeSelected = getInitProp( + 'pointSizeSelected', + DEFAULT_POINT_SIZE_SELECTED + ); + let pointSizeMouseDetection = getInitProp( + 'pointSizeMouseDetection', + DEFAULT_POINT_SIZE_MOUSE_DETECTION + ); + let pointOutlineWidth = getInitProp( + 'pointOutlineWidth', + DEFAULT_POINT_OUTLINE_WIDTH + ); + let opacity = getInitProp('opacity', AUTO); + let opacityBy = getInitProp('opacityBy', DEFAULT_OPACITY_BY); + let opacityByDensityFill = getInitProp( + 'opacityByDensityFill', + DEFAULT_OPACITY_BY_DENSITY_FILL + ); + let opacityInactiveMax = getInitProp( + 'opacityInactiveMax', + DEFAULT_OPACITY_INACTIVE_MAX + ); + let opacityInactiveScale = getInitProp( + 'opacityInactiveScale', + DEFAULT_OPACITY_INACTIVE_SCALE + ); + let sizeBy = getInitProp('sizeBy', DEFAULT_SIZE_BY); + let height = getInitProp('height', DEFAULT_HEIGHT); + let width = getInitProp('width', DEFAULT_WIDTH); let currentWidth = width === AUTO ? 1 : width; let currentHeight = height === AUTO ? 1 : height; @@ -295,6 +384,7 @@ const createScatterplot = ( let isPointsFiltered = false; /** @type{Set} */ const filteredPointsSet = new Set(); + let points = []; let numPoints = 0; let numPointsInView = 0; let lassoActive = false; @@ -319,6 +409,13 @@ const createScatterplot = ( let draw = true; let drawReticleOnce = false; let canvasObserver; + let densityBasedOpacity = 1; + let densityBasedPointSize = 1; + let hoveredPointExtraSize = 0; + + const positionBuffer = renderer.regl.buffer([ + -1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1, + ]); pointColor = isMultipleColors(pointColor) ? [...pointColor] : [pointColor]; pointColorActive = isMultipleColors(pointColorActive) @@ -415,7 +512,9 @@ const createScatterplot = ( opacityBy = getEncodingType(opacityBy, DEFAULT_OPACITY_BY, { allowDensity: true, }); - sizeBy = getEncodingType(sizeBy, DEFAULT_SIZE_BY); + sizeBy = getEncodingType(sizeBy, DEFAULT_SIZE_BY, { + allowDensity: true, + }); pointConnectionColorBy = getEncodingType( pointConnectionColorBy, @@ -531,8 +630,8 @@ const createScatterplot = ( const getPoints = () => { if (isPointsFiltered) - return searchIndex.points.filter((_, i) => filteredPointsSet.has(i)); - return searchIndex.points; + return points.filter((_, i) => filteredPointsSet.has(i)); + return points; }; const getPointsInBBox = (x0, y0, x1, y1) => { @@ -560,7 +659,7 @@ const createScatterplot = ( let minDist = pointSizeNdc; let clostestPoint; pointsInBBox.forEach((idx) => { - const [ptX, ptY] = searchIndex.points[idx]; + const [ptX, ptY] = points[idx]; const d = dist(ptX, ptY, xNdc, yNdc); if (d < minDist) { minDist = d; @@ -590,7 +689,7 @@ const createScatterplot = ( // next we test each point in the bounding box if it is in the polygon too const pointsInPolygon = []; pointsInBBox.forEach((pointIdx) => { - if (isPointInPolygon(lassoPolygon, searchIndex.points[pointIdx])) + if (isPointInPolygon(lassoPolygon, points[pointIdx])) pointsInPolygon.push(pointIdx); }); @@ -608,7 +707,7 @@ const createScatterplot = ( if ( computingPointConnectionCurves || !showPointConnections || - !hasPointConnections(searchIndex.points[pointIdxs[0]]) + !hasPointConnections(points[pointIdxs[0]]) ) return; @@ -621,7 +720,7 @@ const createScatterplot = ( // Get line IDs const lineIds = Object.keys( pointIdxs.reduce((ids, pointIdx) => { - const point = searchIndex.points[pointIdx]; + const point = points[pointIdx]; const isStruct = Array.isArray(point[4]); const lineId = isStruct ? point[4][0] : point[4]; @@ -1144,7 +1243,6 @@ const createScatterplot = ( setter(tmpColors); colorTex = createColorTexture(); } catch (e) { - console.error('Invalid colors. Switching back to default colors.'); // eslint-disable-next-line no-param-reassign setter(prevColors); colorTex = createColorTexture(); @@ -1243,6 +1341,7 @@ const createScatterplot = ( if (isStrictlyPositiveNumber(+newPointSize)) pointSize = [+newPointSize]; minPointScale = MIN_POINT_SIZE / pointSize[0]; + if (encodingTex) encodingTex.destroy(); encodingTex = createEncodingTexture(); computePointSizeMouseDetection(); }; @@ -1289,6 +1388,7 @@ const createScatterplot = ( if (isStrictlyPositiveNumber(+newOpacity)) opacity = [+newOpacity]; + if (encodingTex) encodingTex.destroy(); encodingTex = createEncodingTexture(); }; @@ -1325,7 +1425,9 @@ const createScatterplot = ( }); }; const setSizeBy = (type) => { - sizeBy = getEncodingType(type, DEFAULT_SIZE_BY); + sizeBy = getEncodingType(type, DEFAULT_SIZE_BY, { + allowDensity: true, + }); }; const setPointConnectionColorBy = (type) => { pointConnectionColorBy = getEncodingType( @@ -1350,6 +1452,9 @@ const createScatterplot = ( }; const getResolution = () => [canvas.width, canvas.height]; + const getMinHalfResolution = () => Math.min(canvas.width, canvas.height) / 2; + const getRelativePointOffset = () => + 0.5 / camera.scaling[0] / Math.min(canvas.width, canvas.height); const getBackgroundImage = () => backgroundImage; const getColorTex = () => colorTex; const getColorTexRes = () => colorTexRes; @@ -1372,7 +1477,7 @@ const createScatterplot = ( const getPointScale = () => { if (camera.scaling[0] > 1) return ( - (Math.asinh(max(1.0, camera.scaling[0])) / Math.asinh(1)) * + (Math.asinh(camera.scaling[0]) / Math.asinh(1)) * window.devicePixelRatio ); @@ -1392,6 +1497,7 @@ const createScatterplot = ( const getIsOpacityByDensity = () => +(opacityBy === 'density'); const getIsSizedByZ = () => +(sizeBy === 'valueZ'); const getIsSizedByW = () => +(sizeBy === 'valueW'); + const getIsSizeByDensity = () => +(sizeBy === 'density'); const getColorMultiplicator = () => { if (colorBy === 'valueZ') return valueZDataType === CONTINUOUS ? pointColor.length - 1 : 1; @@ -1407,45 +1513,60 @@ const createScatterplot = ( return valueZDataType === CONTINUOUS ? pointSize.length - 1 : 1; return valueWDataType === CONTINUOUS ? pointSize.length - 1 : 1; }; - const getOpacityDensity = (context) => { - if (opacityBy !== 'density') return 1; + const getOpacityDensity = () => densityBasedOpacity; + const getPointSizeDensity = () => densityBasedPointSize; - // Adopted from the fabulous Ricky Reusser: + const updateOpacityAndSizeByDensity = () => { + if (opacityBy !== 'density' && sizeBy !== 'density') return; + + // Inspired by 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 p = pointSize[0] * pointScale; // Compute the plot's x and y range from the view matrix, though these could come from any source - const s = (2 / (2 / camera.view[0])) * (2 / (2 / camera.view[5])); + // const s = (2 / (2 / camera.view[0])) * (2 / (2 / camera.view[5])); // Viewport size, in device pixels - const H = context.viewportHeight; - const W = context.viewportWidth; + const H = canvas.height; + const W = canvas.width; + + const fillTarget = opacityByDensityFill * W * H; // Adaptation: Instead of using the global number of points, I am using a // density-based approach that takes the points in the view into context // when zooming in. This ensure that in sparse areas, points are opaque and // in dense areas points are more translucent. - let alpha = - ((opacityByDensityFill * W * H) / (numPointsInView * p * p)) * min(1, s); - - // In performanceMode we use squares, otherwise we use circles, which only - // take up (pi r^2) of the unit square - alpha *= performanceMode ? 1 : 1 / (0.25 * Math.PI); + let alpha = performanceMode + ? // In performanceMode we use squares + fillTarget / (numPointsInView * (p * 2) ** 2) + : // otherwise we use circles, which only take up (pi r^2) of the unit square + fillTarget / (numPointsInView * p ** 2 * Math.PI); // If the pixels shrink below the minimum permitted size, then we adjust the opacity instead - // and apply clamping of the point size in the vertex shader. Note that we add 0.5 since we - // slightly inrease the size of points during rendering to accommodate SDF-style antialiasing. - const clampedPointDeviceSize = max(MIN_POINT_SIZE, p) + 0.5; + // and apply clamping of the point size in the vertex shader. + const clampedPointDeviceSize = max(MIN_POINT_SIZE, p); // We square this since we're concerned with the ratio of *areas*. // eslint-disable-next-line no-restricted-properties alpha *= (p / clampedPointDeviceSize) ** 2; - // And finally, we clamp to the range [0, 1]. We should really clamp this to 1 / precision - // on the low end, depending on the data type of the destination so that we never render *nothing*. - return min(1, max(0, alpha)); + if (alpha > 1) { + // If the alpha value is above one, we have to increase the point size to + // achieve the ideal fill target. In this case the alpha value is set to + // one as alpha values above one do not make any sense. + const newPointSize = performanceMode + ? Math.sqrt(fillTarget / (numPointsInView * Math.PI)) + : Math.sqrt(fillTarget / (numPointsInView * Math.PI)); + densityBasedOpacity = 1; + densityBasedPointSize = newPointSize / pointScale; + } else { + // In the case that the alpha value is below one we keep the current point + // size and return the dynamic alpha value. + densityBasedOpacity = Math.max(0, alpha); + densityBasedPointSize = pointSize[0]; + } }; const updatePoints = renderer.regl({ @@ -1494,12 +1615,18 @@ const createScatterplot = ( attributes: { stateIndex: { buffer: getStateIndexBuffer, - size: 2, + divisor: 1, + }, + position: { + buffer: positionBuffer, + divisor: 0, }, }, uniforms: { resolution: getResolution, + minHalfResolution: getMinHalfResolution, + relativePointOffset: getRelativePointOffset, modelViewProjection: getModelViewProjection, devicePixelRatio: getDevicePixelRatio, pointScale: getPointScale, @@ -1523,16 +1650,18 @@ const createScatterplot = ( isOpacityByDensity: getIsOpacityByDensity, isSizedByZ: getIsSizedByZ, isSizedByW: getIsSizedByW, + isSizeByDensity: getIsSizeByDensity, colorMultiplicator: getColorMultiplicator, opacityMultiplicator: getOpacityMultiplicator, opacityDensity: getOpacityDensity, + pointSizeDensity: getPointSizeDensity, sizeMultiplicator: getSizeMultiplicator, numColorStates: COLOR_NUM_STATES, }, - count: getNumPoints, + count: 6, - primitive: 'points', + instances: () => getNumPoints(), }); const drawPointBodies = drawPoints( @@ -1541,7 +1670,7 @@ const createScatterplot = ( getNormalPointsIndexBuffer ); - const drawHoveredPoint = drawPoints( + const drawHoveredPointBody = drawPoints( getNormalPointSizeExtra, () => 1, () => hoveredPointIndexBuffer, @@ -1550,8 +1679,44 @@ const createScatterplot = ( () => 1 ); + // Draw outer outline + const drawHoveredPointOutline = drawPoints( + () => hoveredPointExtraSize * window.devicePixelRatio, + () => 1, + () => hoveredPointIndexBuffer, + COLOR_ACTIVE_IDX, + () => 1, + () => 1 + ); + + // Draw inner outline + const drawHoveredPointInnerBorder = drawPoints( + () => hoveredPointExtraSize * window.devicePixelRatio, + () => 1, + () => hoveredPointIndexBuffer, + COLOR_BG_IDX, + () => 1, + () => 1 + ); + + const drawHoveredPoint = () => { + const pointExtraSize = selectedPointsSet.has(hoveredPoint) + ? pointSizeSelected + : 0; + + const pointOutlineWidthPx = pointOutlineWidth / camera.scaling[0]; + + hoveredPointExtraSize = pointExtraSize + pointOutlineWidthPx * 2; + drawHoveredPointOutline(); + hoveredPointExtraSize = pointExtraSize + pointOutlineWidthPx; + drawHoveredPointInnerBorder(); + drawHoveredPointBody(); + }; + const drawSelectedPointOutlines = drawPoints( - () => (pointSizeSelected + pointOutlineWidth * 2) * window.devicePixelRatio, + () => + (pointSizeSelected + (pointOutlineWidth / camera.scaling[0]) * 2) * + window.devicePixelRatio, getSelectedNumPoints, getSelectedPointsIndexBuffer, COLOR_ACTIVE_IDX, @@ -1560,7 +1725,9 @@ const createScatterplot = ( ); const drawSelectedPointInnerBorder = drawPoints( - () => (pointSizeSelected + pointOutlineWidth) * window.devicePixelRatio, + () => + (pointSizeSelected + pointOutlineWidth / camera.scaling[0]) * + window.devicePixelRatio, getSelectedNumPoints, getSelectedPointsIndexBuffer, COLOR_BG_IDX, @@ -1647,7 +1814,7 @@ const createScatterplot = ( const drawReticle = () => { if (!(hoveredPoint >= 0)) return; - const [x, y] = searchIndex.points[hoveredPoint].slice(0, 2); + const [x, y] = points[hoveredPoint].slice(0, 2); // Homogeneous coordinates of the point const v = [x, y, 0, 1]; @@ -1669,23 +1836,6 @@ const createScatterplot = ( reticleHLine.draw(); reticleVLine.draw(); - - // Draw outer outline - drawPoints( - () => - (pointSizeSelected + pointOutlineWidth * 2) * window.devicePixelRatio, - () => 1, - hoveredPointIndexBuffer, - COLOR_ACTIVE_IDX - )(); - - // Draw inner outline - drawPoints( - () => (pointSizeSelected + pointOutlineWidth) * window.devicePixelRatio, - () => 1, - hoveredPointIndexBuffer, - COLOR_BG_IDX - )(); }; const createPointIndex = (numNewPoints) => { @@ -1799,12 +1949,11 @@ const createScatterplot = ( data: createPointIndex(numPoints), }); - searchIndex = new KDBush( - newPoints, - (p) => p[0], - (p) => p[1], - 16 - ); + searchIndex = new KDBush(newPoints.length, 16); + newPoints.forEach(([x, y]) => searchIndex.add(x, y)); + searchIndex.finish(); + + points = newPoints; isPointsDrawn = true; }; @@ -2044,7 +2193,7 @@ const createScatterplot = ( }; // Update point connections - if (showPointConnections || hasPointConnections(searchIndex.points[0])) { + if (showPointConnections || hasPointConnections(points[0])) { setPointConnections(getPoints()).then(() => { if (!preventEvent) pubSub.publish('pointConnectionsDraw'); finish(); @@ -2112,7 +2261,7 @@ const createScatterplot = ( }; // Update point connections - if (showPointConnections || hasPointConnections(searchIndex.points[0])) { + if (showPointConnections || hasPointConnections(points[0])) { setPointConnections(getPoints()).then(() => { if (!preventEvent) pubSub.publish('pointConnectionsDraw'); // We have to re-apply the selection because the connections might @@ -2205,51 +2354,52 @@ const createScatterplot = ( pubSub.publish('transitionStart'); }; - const toArrayOrientedPoints = (points) => + const toArrayOrientedPoints = (newPoints) => new Promise((resolve, reject) => { - if (!points || Array.isArray(points)) { - resolve(points); + if (!newPoints || Array.isArray(newPoints)) { + resolve(newPoints); } else { const length = - Array.isArray(points.x) || ArrayBuffer.isView(points.x) - ? points.x.length + Array.isArray(newPoints.x) || ArrayBuffer.isView(newPoints.x) + ? newPoints.x.length : 0; const getX = - (Array.isArray(points.x) || ArrayBuffer.isView(points.x)) && - ((i) => points.x[i]); + (Array.isArray(newPoints.x) || ArrayBuffer.isView(newPoints.x)) && + ((i) => newPoints.x[i]); const getY = - (Array.isArray(points.y) || ArrayBuffer.isView(points.y)) && - ((i) => points.y[i]); + (Array.isArray(newPoints.y) || ArrayBuffer.isView(newPoints.y)) && + ((i) => newPoints.y[i]); const getL = - (Array.isArray(points.line) || ArrayBuffer.isView(points.line)) && - ((i) => points.line[i]); + (Array.isArray(newPoints.line) || + ArrayBuffer.isView(newPoints.line)) && + ((i) => newPoints.line[i]); const getLO = - (Array.isArray(points.lineOrder) || - ArrayBuffer.isView(points.lineOrder)) && - ((i) => points.lineOrder[i]); + (Array.isArray(newPoints.lineOrder) || + ArrayBuffer.isView(newPoints.lineOrder)) && + ((i) => newPoints.lineOrder[i]); - const components = Object.keys(points); + const components = Object.keys(newPoints); const getZ = (() => { const z = components.find((c) => Z_NAMES.has(c)); return ( z && - (Array.isArray(points[z]) || ArrayBuffer.isView(points[z])) && - ((i) => points[z][i]) + (Array.isArray(newPoints[z]) || ArrayBuffer.isView(newPoints[z])) && + ((i) => newPoints[z][i]) ); })(); const getW = (() => { const w = components.find((c) => W_NAMES.has(c)); return ( w && - (Array.isArray(points[w]) || ArrayBuffer.isView(points[w])) && - ((i) => points[w][i]) + (Array.isArray(newPoints[w]) || ArrayBuffer.isView(newPoints[w])) && + ((i) => newPoints[w][i]) ); })(); if (getX && getY && getZ && getW && getL && getLO) { resolve( - points.x.map((x, i) => [ + newPoints.x.map((x, i) => [ x, getY(i), getZ(i), @@ -2299,7 +2449,7 @@ const createScatterplot = ( return Promise.reject(new Error('The instance was already destroyed')); } return toArrayOrientedPoints(newPoints).then( - (points) => + (newArrayOrientedPoints) => new Promise((resolve) => { if (isDestroyed) { // In the special case where the instance was destroyed after @@ -2441,7 +2591,7 @@ const createScatterplot = ( let yMax = -Infinity; for (let i = 0; i < pointIdxs.length; i++) { - const [x, y] = searchIndex.points[pointIdxs[i]]; + const [x, y] = points[pointIdxs[i]]; xMin = Math.min(xMin, x); xMax = Math.max(xMax, x); yMin = Math.min(yMin, y); @@ -2504,6 +2654,7 @@ const createScatterplot = ( const zoomToPoints = (pointIdxs, options = {}) => { if (!isPointsDrawn) return Promise.reject(new Error(ERROR_POINTS_NOT_DRAWN)); + const rect = getBBoxOfPoints(pointIdxs); const cX = rect.x + rect.width / 2; const cY = rect.y + rect.height / 2; @@ -2792,7 +2943,7 @@ const createScatterplot = ( const setShowPointConnections = (newShowPointConnections) => { showPointConnections = !!newShowPointConnections; if (showPointConnections) { - if (hasPointConnections(searchIndex.points[0])) { + if (hasPointConnections(points[0])) { setPointConnections(getPoints()).then(() => { pubSub.publish('pointConnectionsDraw'); draw = true; @@ -2950,13 +3101,13 @@ const createScatterplot = ( return opacityByDensityDebounceTime; if (property === 'opacityInactiveMax') return opacityInactiveMax; if (property === 'opacityInactiveScale') return opacityInactiveScale; - if (property === 'points') return searchIndex.points; + if (property === 'points') return points; if (property === 'hoveredPoint') return hoveredPoint; if (property === 'selectedPoints') return [...selectedPoints]; if (property === 'filteredPoints') return isPointsFiltered ? Array.from(filteredPointsSet) - : Array.from({ length: searchIndex.points.length }, (_, i) => i); + : Array.from({ length: points.length }, (_, i) => i); if (property === 'pointsInView') return getPointsInView(); if (property === 'pointColor') return pointColor.length === 1 ? pointColor[0] : pointColor; @@ -3484,6 +3635,7 @@ const createScatterplot = ( const widthRatio = canvas.width / renderer.canvas.width; const heightRatio = canvas.height / renderer.canvas.height; + updateOpacityAndSizeByDensity(); updateProjectionMatrix(widthRatio, heightRatio); // eslint-disable-next-line no-underscore-dangle @@ -3560,6 +3712,7 @@ const createScatterplot = ( window.removeEventListener('resize', resizeHandler); window.removeEventListener('orientationchange', resizeHandler); } + points = undefined; canvas = undefined; camera.dispose(); camera = undefined; @@ -3568,6 +3721,16 @@ const createScatterplot = ( pointConnections.destroy(); reticleHLine.destroy(); reticleVLine.destroy(); + positionBuffer.destroy(); + normalPointsIndexBuffer.destroy(); + selectedPointsIndexBuffer.destroy(); + hoveredPointIndexBuffer.destroy(); + if (stateTex) stateTex.destroy(); + if (prevStateTex) prevStateTex.destroy(); + if (tmpStateBuffer) tmpStateBuffer.destroy(); + colorTex.destroy(); + encodingTex.destroy(); + clearCachedPoints(); if (!initialProperties.renderer) { // Since the user did not pass in an externally created renderer we can // assume that the renderer is only used by this scatter plot instance. diff --git a/src/point.fs b/src/point.fs index 7870988..1e37bbd 100644 --- a/src/point.fs +++ b/src/point.fs @@ -1,19 +1,38 @@ const FRAGMENT_SHADER = ` precision highp float; -varying vec4 color; -varying float finalPointSize; +uniform float relativePointOffset; +uniform float minHalfResolution; -float linearstep(float edge0, float edge1, float x) { - return clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0); +varying vec4 vColor; +varying vec2 vPosition; +varying float vRadiusSquared; + +vec4 sample(float d2, float r2) { + if (d2 > r2) { + return vec4(vColor.rgb, 0.0); + } + return vColor; } void main() { - vec2 c = gl_PointCoord * 2.0 - 1.0; - float sdf = length(c) * finalPointSize; - float alpha = linearstep(finalPointSize + 0.5, finalPointSize - 0.5, sdf); - - gl_FragColor = vec4(color.rgb, alpha * color.a); + vec2 p1 = vPosition + vec2(-relativePointOffset, +relativePointOffset); + vec2 p2 = vPosition + vec2(+relativePointOffset, +relativePointOffset); + vec2 p3 = vPosition + vec2(+relativePointOffset, -relativePointOffset); + vec2 p4 = vPosition + vec2(-relativePointOffset, -relativePointOffset); + float d1 = dot(p1, p1); + float d2 = dot(p2, p2); + float d3 = dot(p3, p3); + float d4 = dot(p4, p4); + vec4 pc = vec4(0.0); + pc += sample(d1, vRadiusSquared); + pc += sample(d2, vRadiusSquared); + pc += sample(d3, vRadiusSquared); + pc += sample(d4, vRadiusSquared); + if (pc.a == 0.0) { + discard; + } + gl_FragColor = 0.25 * pc; } `; diff --git a/src/point.vs b/src/point.vs index 3f07999..2e79d7d 100644 --- a/src/point.vs +++ b/src/point.vs @@ -23,24 +23,27 @@ uniform float isOpacityByW; uniform float isOpacityByDensity; uniform float isSizedByZ; uniform float isSizedByW; +uniform float isSizeByDensity; uniform float colorMultiplicator; uniform float opacityMultiplicator; uniform float opacityDensity; uniform float sizeMultiplicator; +uniform float pointSizeDensity; uniform float numColorStates; uniform float pointScale; uniform mat4 modelViewProjection; +uniform float minHalfResolution; attribute vec2 stateIndex; +attribute vec2 position; -varying vec4 color; -varying float finalPointSize; +varying vec4 vColor; +varying vec2 vPosition; +varying float vRadiusSquared; void main() { vec4 state = texture2D(stateTex, stateIndex); - gl_Position = modelViewProjection * vec4(state.x, state.y, 0.0, 1.0); - // Determine color index float colorIndexZ = isColoredByZ * floor(state.z * colorMultiplicator); float colorIndexW = isColoredByW * floor(state.w * colorMultiplicator); @@ -62,19 +65,26 @@ void main() { colorRowIndex / colorTexRes + colorTexEps ); - color = texture2D(colorTex, colorTexIndex); - - // Retrieve point size - float pointSizeIndexZ = isSizedByZ * floor(state.z * sizeMultiplicator); - float pointSizeIndexW = isSizedByW * floor(state.w * sizeMultiplicator); - float pointSizeIndex = pointSizeIndexZ + pointSizeIndexW; - - float pointSizeRowIndex = floor((pointSizeIndex + encodingTexEps) / encodingTexRes); - vec2 pointSizeTexIndex = vec2( - (pointSizeIndex / encodingTexRes) - pointSizeRowIndex + encodingTexEps, - pointSizeRowIndex / encodingTexRes + encodingTexEps - ); - float pointSize = texture2D(encodingTex, pointSizeTexIndex).x; + vColor = texture2D(colorTex, colorTexIndex); + + float radius = 1.0; + + if (isSizeByDensity < 0.5) { + // Retrieve point size from texture + float pointSizeIndexZ = isSizedByZ * floor(state.z * sizeMultiplicator); + float pointSizeIndexW = isSizedByW * floor(state.w * sizeMultiplicator); + float pointSizeIndex = pointSizeIndexZ + pointSizeIndexW; + + float pointSizeRowIndex = floor((pointSizeIndex + encodingTexEps) / encodingTexRes); + vec2 pointSizeTexIndex = vec2( + (pointSizeIndex / encodingTexRes) - pointSizeRowIndex + encodingTexEps, + pointSizeRowIndex / encodingTexRes + encodingTexEps + ); + radius = texture2D(encodingTex, pointSizeTexIndex).x + pointSizeExtra; + } else { + // Determine density based-point size + radius = pointSizeDensity + pointSizeExtra; + } // Retrieve opacity ${ @@ -94,17 +104,24 @@ void main() { (opacityIndex / encodingTexRes) - opacityRowIndex + encodingTexEps, opacityRowIndex / encodingTexRes + encodingTexEps ); - color.a = texture2D(encodingTex, opacityTexIndex)[${1 + globalState}]; + vColor.a = texture2D(encodingTex, opacityTexIndex)[${1 + globalState}]; } else { - color.a = min(1.0, opacityDensity + globalState); + vColor.a = min(1.0, opacityDensity + globalState); } `; })() } - color.a = min(pointOpacityMax, color.a) * pointOpacityScale; - finalPointSize = (pointSize * pointScale) + pointSizeExtra; - gl_PointSize = finalPointSize; + vColor.a = min(pointOpacityMax, vColor.a) * pointOpacityScale; + + // To clip coordinats + radius = radius / minHalfResolution; + vPosition = position * radius; + + vRadiusSquared = radius * radius; + + // The point center + gl_Position = modelViewProjection * vec4(state.x + vPosition.x, state.y + vPosition.y, 0.0, 1.0); } `; diff --git a/src/types.d.ts b/src/types.d.ts index 7732e00..19eea75 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -104,8 +104,8 @@ interface BaseOptions { // Nullifiable backgroundImage: null | import('regl').Texture2D | string; colorBy: null | DataEncoding; - sizeBy: null | DataEncoding; - opacityBy: null | DataEncoding; + sizeBy: null | DataEncoding | 'density'; + opacityBy: null | DataEncoding | 'density'; xScale: null | Scale; yScale: null | Scale; } diff --git a/src/utils.js b/src/utils.js index 8627c8b..274b8cd 100644 --- a/src/utils.js +++ b/src/utils.js @@ -59,7 +59,15 @@ export const createRegl = (canvas) => { } }); - return createOriginalRegl({ gl, extensions }); + return createOriginalRegl({ + gl, + extensions, + attributes: { + depth: false, + alpha: false, + premultipliedAlpha: true, + }, + }); }; /** @@ -396,3 +404,11 @@ export const rgbBrightness = (rgb) => */ export const clip = (value, minValue, maxValue) => Math.min(maxValue, Math.max(minValue, value)); + +export const getProp = (object, property, defaultValue) => + Object.prototype.hasOwnProperty.call(object, property) + ? object[property] + : defaultValue; + +export const createPropGetter = (object) => (property, defaultValue) => + getProp(object, property, defaultValue); diff --git a/tests/index.js b/tests/index.js index f36b3eb..62abec7 100644 --- a/tests/index.js +++ b/tests/index.js @@ -2395,10 +2395,10 @@ test( const points = [ [0, 0], - [1, 1], - [1, -1], - [-1, -1], - [-1, 1], + [0.5, 0.5], + [0.5, -0.5], + [-0.5, -0.5], + [-0.5, 0.5], ]; await scatterplot.draw(points);